Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,14 @@ workflows:
test:
jobs:
- test:
context: boost-btt
filters:
branches:
ignore: master
deploy:
jobs:
- test:
context: boost-btt
filters:
branches:
only: master
Expand Down
7 changes: 6 additions & 1 deletion packages/keycloak-auth/package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
{
"name": "@ovotech/keycloak-auth",
"version": "2.0.5",
"version": "2.1.0",
"main": "dist/index.js",
"source": "src/index.ts",
"types": "dist/index.d.ts",
"description": "Keycloak Auth",
"author": "Boost Internal Tools <boost-bit-tech@ovoenergy.com>",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "git+https://github.com/ovotech/bit-node-tools.git",
"directory": "packages/keycloak-auth"
},
"devDependencies": {
"@types/axios": "^0.14.0",
"@types/jest": "^24.0.13",
Expand Down
22 changes: 19 additions & 3 deletions packages/keycloak-auth/src/KeycloakAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,31 @@ export interface KeycloakAuthOptions {
serverUrl: string;
clientId: string;
clientSecret: string;
/** API key is optionally used to identify our services for purposes like rate limiting */
apiKey?: string;
margin?: number;
}

export class KeycloakAuth {
private authPromise: Promise<AuthResponse> | undefined;
private previous: AuthResponse | undefined;
constructor(private options: KeycloakAuthOptions) {}

async authenticate() {
this.previous = await authenticate({ ...this.options, previous: this.previous });
return this.previous;
async authenticate(): Promise<AuthResponse> {
// Prevent thundering herd - I=if there's a request already in-flight, return the existing promise
if (this.authPromise) return this.authPromise;

this.authPromise = this.fetchAuth();

return this.authPromise;
}

private async fetchAuth(): Promise<AuthResponse> {
try {
this.previous = await authenticate({ ...this.options, previous: this.previous });
return this.previous;
} finally {
this.authPromise = undefined;
}
}
}
21 changes: 14 additions & 7 deletions packages/keycloak-auth/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface KeycloakRequest {
serverUrl: string;
clientId: string;
clientSecret: string;
apiKey?: string;
}

interface RefreshTokenKeycloakRequest extends KeycloakRequest {
Expand All @@ -45,17 +46,19 @@ interface AuthRequest extends KeycloakRequest {
margin?: number;
}

export const login = ({ serverUrl, clientId, clientSecret }: KeycloakRequest) =>
jsonFetch<KeycloakResponse>(serverUrl, {
export const login = ({ serverUrl, clientId, clientSecret, apiKey }: KeycloakRequest) => {
return jsonFetch<KeycloakResponse>(serverUrl, {
method: 'POST',
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
}),
...(apiKey && { headers: { 'X-API-Key': apiKey } }),
});
};

export const refresh = ({ serverUrl, clientId, clientSecret, refreshToken }: RefreshTokenKeycloakRequest) =>
export const refresh = ({ serverUrl, clientId, clientSecret, refreshToken, apiKey }: RefreshTokenKeycloakRequest) =>
jsonFetch<KeycloakResponse>(serverUrl, {
method: 'POST',
body: new URLSearchParams({
Expand All @@ -64,6 +67,7 @@ export const refresh = ({ serverUrl, clientId, clientSecret, refreshToken }: Ref
client_secret: clientSecret,
refresh_token: refreshToken,
}),
...(apiKey && { headers: { 'X-API-Key': apiKey } }),
});

const nowSeconds = () => new Date().getTime() / 1000;
Expand All @@ -88,25 +92,28 @@ export const authenticate = async ({
previous,
clockTimestamp,
margin = 10,
apiKey,
}: AuthRequest) => {
const timestamp = clockTimestamp || nowSeconds();
if (previous) {
const { accessTokenExpires, refreshTokenExpires, refreshToken } = previous;
if (!isExpired(accessTokenExpires, timestamp, margin)) {
return previous;
} else if (!isExpired(refreshTokenExpires, timestamp, margin)) {
}

if (refreshToken && !isExpired(refreshTokenExpires, timestamp, margin)) {
try {
const refreshResponse = await refresh({ serverUrl, clientId, clientSecret, refreshToken });
const refreshResponse = await refresh({ serverUrl, clientId, clientSecret, refreshToken, apiKey });
return toAuth(refreshResponse, timestamp);
} catch (error) {
if (error instanceof KeycloakAuthError) {
return toAuth(await login({ serverUrl, clientId, clientSecret }), timestamp);
return toAuth(await login({ serverUrl, clientId, clientSecret, apiKey }), timestamp);
} else {
throw error;
}
}
}
}
const loginResponse = await login({ serverUrl, clientId, clientSecret });
const loginResponse = await login({ serverUrl, clientId, clientSecret, apiKey });
return toAuth(loginResponse, timestamp);
};
1 change: 1 addition & 0 deletions packages/keycloak-auth/src/keycloakAxios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface KeycloakAxiosOptions {
serverUrl: string;
clientId: string;
clientSecret: string;
apiKey?: string;
margin?: number;
}

Expand Down
19 changes: 19 additions & 0 deletions packages/keycloak-auth/test/KeycloakAuth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,23 @@ describe('Integration test', () => {
await auth.authenticate();
await auth.authenticate();
});

it('Should perform authentication with apiKey', async () => {
nock('http://auth', { reqheaders: { 'X-API-Key': 'test-api-key' } })
.post(
'/auth/realms/my-realm/protocol/openid-connect/token',
'grant_type=client_credentials&client_id=test-portal&client_secret=11-22-33',
)
.reply(200, response);

const auth = new KeycloakAuth({
serverUrl: 'http://auth/auth/realms/my-realm/protocol/openid-connect/token',
clientId: 'test-portal',
clientSecret: '11-22-33',
apiKey: 'test-api-key',
margin: 0,
});

await auth.authenticate();
});
});
33 changes: 33 additions & 0 deletions packages/keycloak-auth/test/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,4 +120,37 @@ describe('Api test', () => {
const response3 = await authenticate({ ...params, previous: response2 });
await authenticate({ ...params, previous: response3 });
});

it('Should call auth endpoints with apiKey', async () => {
nock('http://auth', { reqheaders: { 'X-API-Key': 'test-api-key' } })
.post(
'/auth/realms/my-realm/protocol/openid-connect/token',
'grant_type=client_credentials&client_id=test-portal&client_secret=11-22-33',
)
.reply(200, response);

nock('http://auth', { reqheaders: { 'X-API-Key': 'test-api-key' } })
.post(
'/auth/realms/my-realm/protocol/openid-connect/token',
'grant_type=refresh_token&client_id=test-portal&client_secret=11-22-33&refresh_token=refresh-1',
)
.reply(200, { ...response, refresh_token: 'refresh-2', access_token: 'access-2' });

const params = {
serverUrl: 'http://auth/auth/realms/my-realm/protocol/openid-connect/token',
clientId: 'test-portal',
clientSecret: '11-22-33',
apiKey: 'test-api-key',
margin: 0,
};

const response1 = await authenticate(params);
const response2 = await authenticate({ ...params, previous: response1 });
await new Promise(resolve => setTimeout(resolve, 1000));
const response3 = await authenticate({ ...params, previous: response2 });

expect(response1).toEqual(expect.objectContaining({ accessToken: 'access-1', refreshToken: 'refresh-1' }));
expect(response2).toEqual(expect.objectContaining({ accessToken: 'access-1', refreshToken: 'refresh-1' }));
expect(response3).toEqual(expect.objectContaining({ accessToken: 'access-2', refreshToken: 'refresh-2' }));
});
});
69 changes: 57 additions & 12 deletions packages/keycloak-auth/test/keycloakAxios.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,29 @@ const clientSecret = 'client-secret';
const authBaseURL = 'http://auth.test/auth/realms/my-realm/protocol/openid-connect/token';
const serviceBaseURL = 'http://service.test';

/**
* When axios sends a request with a custom header (like Authorization) an initial OPTIONS request
* is sent to verify CORS permissions. This mock intercepts that OPTIONS request and returns the
* necessary Allow headers.
*/
const handleOptionsPreflight = (times = 2) => {
nock(serviceBaseURL)
.options('/test')
.times(times)
.reply(
200,
{},
{
'Access-Control-Allow-Origin': 'http://localhost',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
},
);
};

describe('Integration test', () => {
beforeEach(() => {
nock(serviceBaseURL)
.options('/test')
.times(2)
.reply(
200,
{},
{
'Access-Control-Allow-Origin': 'http://localhost',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Authorization, Content-Type',
},
);
handleOptionsPreflight();

nock(serviceBaseURL)
.matchHeader('Authorization', 'Bearer access-1')
Expand Down Expand Up @@ -72,4 +81,40 @@ describe('Integration test', () => {
await api.get('/test');
await api.get('/test');
});

it('Should take a config object with apiKey turn it into a usable axios interceptor', async () => {
nock.cleanAll();
handleOptionsPreflight(1);

nock(serviceBaseURL)
.matchHeader('Authorization', 'Bearer access-1')
.get('/test')
.reply(200, { ok: true });

nock(authBaseURL, { reqheaders: { 'X-API-Key': 'test-api-key' } })
.post('', `grant_type=client_credentials&client_id=${clientId}&client_secret=${clientSecret}`)
.reply(200, {
access_token: 'access-1',
expires_in: 60,
refresh_expires_in: 1800,
refresh_token: 'refresh-1',
token_type: 'bearer',
'not-before-policy': 1508951547,
session_state: '72ea6748-9ffa-4a2d-8431-71b0c563aeae',
scope: 'email profile',
});

const api = axios.create({ baseURL: serviceBaseURL });
const authInterceptor = keycloakAxios({
serverUrl: authBaseURL,
clientId,
clientSecret,
apiKey: 'test-api-key',
margin: 12,
});

api.interceptors.request.use(authInterceptor);

await api.get('/test');
});
});