diff --git a/src/ApiStack.ts b/src/ApiStack.ts index bc094234..d7c0be7f 100644 --- a/src/ApiStack.ts +++ b/src/ApiStack.ts @@ -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, @@ -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.', @@ -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); diff --git a/src/app/auth/auth.lambda.ts b/src/app/auth/auth.lambda.ts index 0cbb7d29..f70d97b9 100644 --- a/src/app/auth/auth.lambda.ts +++ b/src/app/auth/auth.lambda.ts @@ -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({}); @@ -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(); diff --git a/src/app/auth/handleRequest.ts b/src/app/auth/handleRequest.ts index 31706d97..30c39ebe 100644 --- a/src/app/auth/handleRequest.ts +++ b/src/app/auth/handleRequest.ts @@ -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(); @@ -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); diff --git a/src/app/code/OpenIDConnect.ts b/src/app/code/OpenIDConnect.ts index 5241534d..b8603c58 100644 --- a/src/app/code/OpenIDConnect.ts +++ b/src/app/code/OpenIDConnect.ts @@ -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; } /** @@ -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'], @@ -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; @@ -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'], @@ -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; diff --git a/src/app/login/login.lambda.ts b/src/app/login/login.lambda.ts index 5ba9dde4..f5f0b682 100644 --- a/src/app/login/login.lambda.ts +++ b/src/app/login/login.lambda.ts @@ -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({}); @@ -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); diff --git a/src/app/login/loginRequestHandler.ts b/src/app/login/loginRequestHandler.ts index f376f754..15ada2df 100644 --- a/src/app/login/loginRequestHandler.ts +++ b/src/app/login/loginRequestHandler.ts @@ -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 }, diff --git a/test/app/auth.test.ts b/test/app/auth.test.ts index 4217dd04..a622b836 100644 --- a/test/app/auth.test.ts +++ b/test/app/auth.test.ts @@ -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(); @@ -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', @@ -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', }; @@ -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('/'); }); @@ -104,7 +116,7 @@ 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=')); @@ -112,7 +124,7 @@ test('Successful auth creates new session', async () => { 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'); }); @@ -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(); diff --git a/test/app/login.test.ts b/test/app/login.test.ts index 29b94d1e..9f278997 100644 --- a/test/app/login.test.ts +++ b/test/app/login.test.ts @@ -3,10 +3,19 @@ import * as path from 'path'; import { DynamoDBClient, GetItemCommandOutput, GetItemCommand } from '@aws-sdk/client-dynamodb'; import { mockClient } from 'aws-sdk-client-mock'; import { handleLoginRequest } from '../../src/app/login/loginRequestHandler'; +import { OpenIDConnect } from '../../src/app/code/OpenIDConnect'; const ddbMock = mockClient(DynamoDBClient); +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 () => { -beforeAll(() => { if (process.env.VERBOSETESTS != 'True') { global.console.error = jest.fn(); global.console.time = jest.fn(); @@ -21,6 +30,8 @@ beforeAll(() => { process.env.OIDC_SECRET_ARN = '123'; process.env.OIDC_CLIENT_ID = '1234'; process.env.OIDC_SCOPE = 'openid'; + + await OIDC.init(); }); @@ -31,14 +42,14 @@ beforeEach(() => { test('index is ok', async () => { const dynamoDBClient = new DynamoDBClient({ region: 'eu-west-1' }); - const result = await handleLoginRequest({ cookies: '' }, dynamoDBClient); + const result = await handleLoginRequest({ cookies: '' }, dynamoDBClient, OIDC); expect(result.statusCode).toBe(200); }); test('Return login page with correct link', async () => { const dynamoDBClient = new DynamoDBClient({ region: 'eu-west-1' }); - const result = await handleLoginRequest({ cookies: '' }, dynamoDBClient); + const result = await handleLoginRequest({ cookies: '' }, dynamoDBClient, OIDC); if(!('body' in result)){ expect('body' in result).toBe(true); return; @@ -52,14 +63,14 @@ test('Return login page with correct link', async () => { test('No redirect if session cookie doesn\'t exist', async () => { const dynamoDBClient = new DynamoDBClient({ region: 'eu-west-1' }); - const result = await handleLoginRequest({ cookies: 'demo=12345' }, dynamoDBClient); + const result = await handleLoginRequest({ cookies: 'demo=12345' }, dynamoDBClient, OIDC); expect(result.statusCode).toBe(200); }); test('Create session if no session exists', async () => { const dynamoDBClient = new DynamoDBClient({ region: 'eu-west-1' }); - await handleLoginRequest({ cookies: 'demo=12345' }, dynamoDBClient); + await handleLoginRequest({ cookies: 'demo=12345' }, dynamoDBClient, OIDC); expect(ddbMock.calls().length).toBe(1); }); @@ -79,7 +90,7 @@ test('Redirect to home if already logged in', async () => { ddbMock.on(GetItemCommand).resolves(output); const dynamoDBClient = new DynamoDBClient({ region: 'eu-west-1' }); const sessionId = '12345'; - const result = await handleLoginRequest({ cookies: `session=${sessionId}` }, dynamoDBClient); + const result = await handleLoginRequest({ cookies: `session=${sessionId}` }, dynamoDBClient, OIDC); expect(result.statusCode).toBe(302); if(!('Location' in (result.headers ?? {}))){ expect('Location' in (result.headers ?? {})).toBe(true); @@ -93,7 +104,7 @@ test('Unknown session returns login page', async () => { const output: Partial = {}; //empty output ddbMock.on(GetItemCommand).resolves(output); const sessionId = '12345'; - const result = await handleLoginRequest({ cookies: `session=${sessionId}` }, dynamoDBClient); + const result = await handleLoginRequest({ cookies: `session=${sessionId}` }, dynamoDBClient, OIDC); expect(ddbMock.calls().length).toBe(2); expect(result.statusCode).toBe(200); }); @@ -109,14 +120,14 @@ test('Known session without login returns login page, without creating new sessi ddbMock.on(GetItemCommand).resolves(output); const dynamoDBClient = new DynamoDBClient({ region: 'eu-west-1' }); const sessionId = '12345'; - const result = await handleLoginRequest({cookies: `session=${sessionId}` }, dynamoDBClient); + const result = await handleLoginRequest({cookies: `session=${sessionId}` }, dynamoDBClient, OIDC); expect(ddbMock.calls().length).toBe(2); expect(result.statusCode).toBe(200); }); test('Request without session returns session cookie', async () => { const dynamoDBClient = new DynamoDBClient({ region: 'eu-west-1' }); - const result = await handleLoginRequest({cookies: ''}, dynamoDBClient); + const result = await handleLoginRequest({cookies: ''}, dynamoDBClient, OIDC); if(!('cookies' in result)){ expect('cookies' in result).toBe(true); return; @@ -131,7 +142,7 @@ test('DynamoDB error', async () => { const dynamoDBClient = new DynamoDBClient({ region: 'eu-west-1' }); let failed = false; try { - await handleLoginRequest({ cookies: `session=12345` }, dynamoDBClient); + await handleLoginRequest({ cookies: `session=12345` }, dynamoDBClient, OIDC); } catch (error) { failed = true; }