Skip to content

Commit

Permalink
feat: OIDC utility uses getParameter and fixed tests
Browse files Browse the repository at this point in the history
  • Loading branch information
marnixdessing committed Mar 14, 2023
1 parent 3cb3335 commit 5634216
Show file tree
Hide file tree
Showing 8 changed files with 116 additions and 62 deletions.
11 changes: 11 additions & 0 deletions src/ApiStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export class ApiStack extends Stack {
// See https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/Lambda-Insights-extension-versionsx86-64.html
const insightsArn = `arn:aws:lambda:${this.region}:580247275435:layer:LambdaInsightsExtension:16`;


const authBaseUrl = SSM.StringParameter.fromStringParameterName(this, 'ssm-auth-base-url', Statics.ssmAuthUrlBaseParameter);
const odicClientId = SSM.StringParameter.fromStringParameterName(this, 'ssm-odic-client-id', Statics.ssmOIDCClientID);
const oidcScope = SSM.StringParameter.fromStringParameterName(this, 'ssm-odic-scope', Statics.ssmOIDCScope);

const loginFunction = new ApiFunction(this, 'yivi-issue-login-function', {
description: 'Login-pagina voor de YIVI issue-applicatie.',
table: this.sessionsTable,
Expand All @@ -74,6 +79,9 @@ export class ApiStack extends Stack {
readOnlyRole,
lambdaInsightsExtensionArn: insightsArn,
}, LoginFunction);
authBaseUrl.grantRead(loginFunction.lambda);
odicClientId.grantRead(loginFunction.lambda);
oidcScope.grantRead(loginFunction.lambda);

const logoutFunction = new ApiFunction(this, 'yivi-issue-logout-function', {
description: 'Uitlog-pagina voor de YIVI issue-applicatie.',
Expand All @@ -97,6 +105,9 @@ export class ApiStack extends Stack {
lambdaInsightsExtensionArn: insightsArn,
}, AuthFunction);
oidcSecret.grantRead(authFunction.lambda);
authBaseUrl.grantRead(loginFunction.lambda);
odicClientId.grantRead(loginFunction.lambda);
oidcScope.grantRead(loginFunction.lambda);

const secretMTLSPrivateKey = aws_secretsmanager.Secret.fromSecretNameV2(this, 'tls-key-secret', Statics.secretMTLSPrivateKey);
const tlskeyParam = SSM.StringParameter.fromStringParameterName(this, 'tlskey', Statics.ssmMTLSClientCert);
Expand Down
7 changes: 6 additions & 1 deletion src/app/auth/auth.lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Logger } from '@aws-lambda-powertools/logger';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { Response } from '@gemeentenijmegen/apigateway-http';
import { handleRequest } from './handleRequest';
import { OpenIDConnect } from '../code/OpenIDConnect';

const logger = new Logger({ serviceName: 'YiviAuthLambda' });
const dynamoDBClient = new DynamoDBClient({});
Expand All @@ -14,10 +15,14 @@ function parseEvent(event: any) {
};
}

const OIDC = new OpenIDConnect();
const init = OIDC.init();

exports.handler = async (event: any) => {
await init;
try {
const params = parseEvent(event);
return await handleRequest(params.cookies, params.code, params.state, dynamoDBClient, logger);
return await handleRequest(params.cookies, params.code, params.state, dynamoDBClient, logger, OIDC);
} catch (err) {
console.error(err);
return Response.error();
Expand Down
2 changes: 1 addition & 1 deletion src/app/auth/handleRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export async function handleRequest(
queryStringParamState: string,
dynamoDBClient: DynamoDBClient,
logger: Logger,
OIDC: OpenIDConnect,
) {
let session = new Session(cookies, dynamoDBClient);
await session.init();
Expand All @@ -25,7 +26,6 @@ export async function handleRequest(
return Response.redirect('/login');
}
const state = session.getValue('state');
const OIDC = new OpenIDConnect();
try {
const claims = await OIDC.authorize(queryStringParamCode, state, queryStringParamState);
return await authenticate(session, claims, logger);
Expand Down
93 changes: 52 additions & 41 deletions src/app/code/OpenIDConnect.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,47 @@
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import { AWS } from '@gemeentenijmegen/utils';
import { Issuer, generators } from 'openid-client';

export class OpenIDConnect {

private issuer?: Issuer;
private clientSecret?: string;
private authBaseUrl?: string;
private applicationBaseUrl?: string;
private oidcClientId?: string;
private oidcClientSecret?: string;
private oidcScope?: string;

/**
* Helper class for our OIDC auth flow
*/
constructor() {
this.issuer = this.getIssuer();
* Helper class for our OIDC auth flow
*/
constructor() {}

async init() {
if (!process.env.AUTH_URL_BASE || !process.env.OIDC_CLIENT_ID || !process.env.APPLICATION_URL_BASE || !process.env.OIDC_SCOPE) {
let errorMsg = 'Initalization failed: one of the folowing env variables is missing:';
errorMsg += [
'AUTH_URL_BASE (ssm path)',
'OIDC_CLIENT_ID (ssm path)',
'APPLICATION_URL_BASE',
'OIDC_SCOPE',
].join(', ');
throw Error(errorMsg);
}
this.authBaseUrl = await AWS.getParameter(process.env.AUTH_URL_BASE);
this.oidcClientId = await AWS.getParameter(process.env.OIDC_CLIENT_ID);
this.oidcScope = await AWS.getParameter(process.env.OIDC_SCOPE);

this.issuer = this.getIssuer(this.authBaseUrl);
this.applicationBaseUrl = process.env.APPLICATION_URL_BASE;
}

/**
* Retrieve client secret from secrets manager
*
* @returns string the client secret
*/
async getOidcClientSecret() {
if (!this.clientSecret) {
const secretsManagerClient = new SecretsManagerClient({});
const command = new GetSecretValueCommand({ SecretId: process.env.CLIENT_SECRET_ARN });
const data = await secretsManagerClient.send(command);
// Depending on whether the secret is a string or binary, one of these fields will be populated.
if ('SecretString' in data) {
this.clientSecret = data.SecretString;
} else {
console.log('no secret value found');
if (!this.oidcClientSecret) {
if (!process.env.CLIENT_SECRET_ARN) {
throw Error('process.env.CLIENT_SECRET_ARN not configured');
}
this.oidcClientSecret = await AWS.getSecret(process.env.CLIENT_SECRET_ARN);
}
return this.clientSecret;
return this.oidcClientSecret;
}

/**
Expand All @@ -40,16 +51,16 @@ export class OpenIDConnect {
*
* @returns openid-client Issuer
*/
getIssuer() {
getIssuer(url: string) {
const issuer = new Issuer({
issuer: `${process.env.AUTH_URL_BASE}/broker/sp/oidc`,
authorization_endpoint: `${process.env.AUTH_URL_BASE}/broker/sp/oidc/authenticate`,
token_endpoint: `${process.env.AUTH_URL_BASE}/broker/sp/oidc/token`,
jwks_uri: `${process.env.AUTH_URL_BASE}/broker/sp/oidc/certs`,
userinfo_endpoint: `${process.env.AUTH_URL_BASE}/broker/sp/oidc/userinfo`,
revocation_endpoint: `${process.env.AUTH_URL_BASE}/broker/sp/oidc/token/revoke`,
introspection_endpoint: `${process.env.AUTH_URL_BASE}/broker/sp/oidc/token/introspect`,
end_session_endpoint: `${process.env.AUTH_URL_BASE}/broker/sp/oidc/logout`,
issuer: `${url}/broker/sp/oidc`,
authorization_endpoint: `${url}/broker/sp/oidc/authenticate`,
token_endpoint: `${url}/broker/sp/oidc/token`,
jwks_uri: `${url}/broker/sp/oidc/certs`,
userinfo_endpoint: `${url}/broker/sp/oidc/userinfo`,
revocation_endpoint: `${url}/broker/sp/oidc/token/revoke`,
introspection_endpoint: `${url}/broker/sp/oidc/token/introspect`,
end_session_endpoint: `${url}/broker/sp/oidc/logout`,
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
introspection_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
revocation_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
Expand All @@ -65,19 +76,19 @@ export class OpenIDConnect {
* @returns {string} the login url
*/
getLoginUrl(state: string) {
if (!this.issuer || !process.env.APPLICATION_URL_BASE || !process.env.OIDC_CLIENT_ID) {
throw Error('Issuer does not yet exist or APPLICATION_URL_BASE or OIDC_CLIENT_ID env parms are not set');
if (!this.issuer || !this.applicationBaseUrl || !this.oidcClientId) {
throw Error('Client not (correctly) initalized!');
}
const base_url = new URL(process.env.APPLICATION_URL_BASE);
const base_url = new URL(this.applicationBaseUrl);
const redirect_uri = new URL('/auth', base_url);
const client = new this.issuer.Client({
client_id: process.env.OIDC_CLIENT_ID,
client_id: this.oidcClientId,
redirect_uris: [redirect_uri.toString()],
response_types: ['code'],
});
const authUrl = client.authorizationUrl({
scope: process.env.OIDC_SCOPE,
resource: process.env.AUTH_URL_BASE,
scope: this.oidcScope,
resource: this.authBaseUrl,
state: state,
});
return authUrl;
Expand All @@ -92,14 +103,14 @@ export class OpenIDConnect {
* @returns {object | false} returns a claims object on succesful auth
*/
async authorize(code: string, state: string, returnedState: string | false) {
if (!this.issuer || !process.env.APPLICATION_URL_BASE || !process.env.OIDC_CLIENT_ID) {
throw Error('Issuer does not yet exist or APPLICATION_URL_BASE or OIDC_CLIENT_ID env parms are not set');
if (!this.issuer || !this.applicationBaseUrl || !this.oidcClientId) {
throw Error('Client not (correctly) initialized!');
}
const base_url = new URL(process.env.APPLICATION_URL_BASE);
const base_url = new URL(this.applicationBaseUrl);
const redirect_uri = new URL('/auth', base_url);
const client_secret = await this.getOidcClientSecret();
const client = new this.issuer.Client({
client_id: process.env.OIDC_CLIENT_ID,
client_id: this.oidcClientId,
redirect_uris: [redirect_uri.toString()],
client_secret: client_secret,
response_types: ['code'],
Expand All @@ -115,7 +126,7 @@ export class OpenIDConnect {
throw new Error(`${err.error} ${err.error_description}`);
}
const claims = tokenSet.claims();
if (claims.aud != process.env.OIDC_CLIENT_ID) {
if (claims.aud != this.oidcClientId) {
throw new Error('claims aud does not match client id');
}
return claims;
Expand Down
7 changes: 6 additions & 1 deletion src/app/login/login.lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { Response } from '@gemeentenijmegen/apigateway-http';
import { APIGatewayProxyEventV2 } from 'aws-lambda';
import { handleLoginRequest } from './loginRequestHandler';
import { OpenIDConnect } from '../code/OpenIDConnect';

const dynamoDBClient = new DynamoDBClient({});

Expand All @@ -12,10 +13,14 @@ function parseEvent(event: APIGatewayProxyEventV2) {
};
}

const OIDC = new OpenIDConnect();
const init = OIDC.init();

export async function handler (event: APIGatewayProxyEventV2) {
await init;
try {
const params = parseEvent(event);
const response = await handleLoginRequest(params, dynamoDBClient);
const response = await handleLoginRequest(params, dynamoDBClient, OIDC);
return response;
} catch (err) {
console.error(err);
Expand Down
3 changes: 1 addition & 2 deletions src/app/login/loginRequestHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,13 @@ import * as template from './login.mustache';
import { OpenIDConnect } from '../code/OpenIDConnect';
import render from '../code/Render';

export async function handleLoginRequest(params: any, dynamoDBClient: DynamoDBClient) {
export async function handleLoginRequest(params: any, dynamoDBClient: DynamoDBClient, OIDC: OpenIDConnect) {
let session = new Session(params.cookies, dynamoDBClient);
await session.init();
if (session.isLoggedIn() === true) {
console.debug('redirect to home');
return Response.redirect('/');
}
let OIDC = new OpenIDConnect();
const state = OIDC.generateState();
await session.createSession({
loggedin: { BOOL: false },
Expand Down
24 changes: 18 additions & 6 deletions test/app/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,19 @@ import { DynamoDBClient, GetItemCommand, GetItemCommandOutput } from '@aws-sdk/c
import { SecretsManagerClient, GetSecretValueCommandOutput, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import { mockClient } from 'aws-sdk-client-mock';
import { handleRequest } from '../../src/app/auth/handleRequest';
import { OpenIDConnect } from '../../src/app/code/OpenIDConnect';

beforeAll(() => {
jest.mock('@gemeentenijmegen/utils/lib/AWS', () => ({
AWS: {
getParameter: jest.fn().mockImplementation((name) => name),
getSecret: jest.fn().mockImplementation((arn) => arn),
}
}));

const OIDC = new OpenIDConnect();

beforeAll( async () => {

if (process.env.VERBOSETESTS!='True') {
global.console.error = jest.fn();
global.console.time = jest.fn();
Expand All @@ -20,6 +30,8 @@ beforeAll(() => {
process.env.OIDC_CLIENT_ID = '1234';
process.env.OIDC_SCOPE = 'openid';

await OIDC.init();

const output: GetSecretValueCommandOutput = {
$metadata: {},
SecretString: 'ditiseennepgeheim',
Expand All @@ -46,7 +58,7 @@ jest.mock('openid-client', () => {
return {
claims: jest.fn(() => {
return {
aud: process.env.OIDC_CLIENT_ID,
aud: '1234',
sub: '12345',
acr: 'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract',
};
Expand Down Expand Up @@ -82,7 +94,7 @@ test('Successful auth redirects to home', async () => {
};
ddbMock.on(GetItemCommand).resolves(getItemOutput);

const result = await handleRequest(`session=${sessionId}`, 'state', '12345', dynamoDBClient, logger);
const result = await handleRequest(`session=${sessionId}`, 'state', '12345', dynamoDBClient, logger, OIDC);
expect(result.statusCode).toBe(302);
expect(result.headers?.Location).toBe('/');
});
Expand All @@ -104,15 +116,15 @@ test('Successful auth creates new session', async () => {
ddbMock.on(GetItemCommand).resolves(getItemOutput);


const result = await handleRequest(`session=${sessionId}`, 'state', '12345', dynamoDBClient, logger);
const result = await handleRequest(`session=${sessionId}`, 'state', '12345', dynamoDBClient, logger, OIDC);
expect(result.statusCode).toBe(302);
expect(result.headers?.Location).toBe('/');
expect(result.cookies).toContainEqual(expect.stringContaining('session='));
});

test('No session redirects to login', async () => {
const dynamoDBClient = new DynamoDBClient({ region: 'eu-west-1' });
const result = await handleRequest('', 'state', 'state', dynamoDBClient, logger);
const result = await handleRequest('', 'state', 'state', dynamoDBClient, logger, OIDC);
expect(result.statusCode).toBe(302);
expect(result.headers?.Location).toBe('/login');
});
Expand All @@ -134,7 +146,7 @@ test('Incorrect state errors', async () => {
const logger = new Logger({serviceName: 'test'});
ddbMock.on(GetItemCommand).resolves(getItemOutput);
const logSpy = jest.spyOn(logger, 'error');
const result = await handleRequest(`session=${sessionId}`, '12345', 'returnedstate', dynamoDBClient, logger);
const result = await handleRequest(`session=${sessionId}`, '12345', 'returnedstate', dynamoDBClient, logger, OIDC);
expect(result.statusCode).toBe(302);
expect(result.headers?.Location).toBe('/login');
expect(logSpy).toHaveBeenCalled();
Expand Down
Loading

0 comments on commit 5634216

Please sign in to comment.