From 798a7c8a463c97d27cad815a1e4f8fa57674d76f Mon Sep 17 00:00:00 2001 From: Vikas Reddy Date: Mon, 12 Jun 2023 09:58:02 -0700 Subject: [PATCH 1/4] Add additional handlers and CSRF protection 1. Added additional handlers for signIn, parseAuth, refreshToken and signOut 2. Added the ability to enable CSRF protection (csrfProtectionEnabled, disabled by default) 3. Added the ability to enable and customize the uri for parseAuth handler 4. Added a signOut handler that revokes tokens and clears cookies 5. handle will now log user out if the path matches the logoutUri param configured --- __tests__/index.test.ts | 532 +++++++++++++++++++++++++++++++++- __tests__/util/cookie.test.ts | 16 +- src/index.ts | 449 +++++++++++++++++++++++++++- src/util/cookie.ts | 41 +++ src/util/csrf.ts | 107 +++++++ 5 files changed, 1130 insertions(+), 15 deletions(-) create mode 100644 src/util/csrf.ts diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 66b71fa..ca4b92b 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -1,9 +1,12 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ import axios from 'axios'; jest.mock('axios'); +import { CloudFrontRequest } from 'aws-lambda'; import { Authenticator } from '../src/'; import { Cookies } from '../src/util/cookie'; +import { NONCE_COOKIE_NAME_SUFFIX, NONCE_HMAC_COOKIE_NAME_SUFFIX, PKCE_COOKIE_NAME_SUFFIX } from '../src/util/csrf'; const DATE = new Date('2017'); // @ts-ignore @@ -178,7 +181,7 @@ describe('private functions', () => { }], }, }); - expect(response.headers['set-cookie']).toEqual(expect.arrayContaining([ + expect(response?.headers?.['set-cookie']).toEqual(expect.arrayContaining([ {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.accessToken=${tokenData.access_token}; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure; HttpOnly; SameSite=Strict`}, {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.refreshToken=${tokenData.refresh_token}; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure; HttpOnly; SameSite=Strict`}, {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.tokenScopesString=phone%20email%20profile%20openid%20aws.cognito.signin.user.admin; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure; HttpOnly; SameSite=Strict`}, @@ -228,6 +231,107 @@ describe('private functions', () => { expect(authenticatorWithPath._jwtVerifier.verify).toHaveBeenCalled(); }); + test('should set csrf tokens when the feature is enabled', async () => { + const cookiePath = '/test/path'; + const authenticatorWithPath = new Authenticator({ + region: 'us-east-1', + userPoolId: 'us-east-1_abcdef123', + userPoolAppId: '123456789qwertyuiop987abcd', + userPoolDomain: 'my-cognito-domain.auth.us-east-1.amazoncognito.com', + cookieExpirationDays: 365, + disableCookieDomain: false, + logLevel: 'error', + cookiePath, + csrfProtection: { + nonceSigningSecret: 'foo-bar', + }, + }); + authenticatorWithPath._jwtVerifier.cacheJwks(jwksData); + + const username = 'toto'; + const domain = 'example.com'; + const path = '/test'; + jest.spyOn(authenticatorWithPath._jwtVerifier, 'verify'); + authenticatorWithPath._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({ token_use: 'id', 'cognito:username': username })); + + const response = await authenticatorWithPath._getRedirectResponse({ accessToken: tokenData.access_token, idToken: tokenData.id_token, refreshToken: tokenData.refresh_token }, domain, path); + expect(response).toMatchObject({ + status: '302', + headers: { + location: [{ + key: 'Location', + value: path, + }], + }, + }); + expect(response?.headers?.['set-cookie']).toEqual(expect.arrayContaining([ + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.accessToken=${tokenData.access_token}; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.refreshToken=${tokenData.refresh_token}; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.tokenScopesString=phone%20email%20profile%20openid%20aws.cognito.signin.user.admin; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.idToken=${tokenData.id_token}; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.LastAuthUser=${username}; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${PKCE_COOKIE_NAME_SUFFIX}=; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${NONCE_COOKIE_NAME_SUFFIX}=; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${NONCE_HMAC_COOKIE_NAME_SUFFIX}=; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure`}, + ])); + expect(authenticatorWithPath._jwtVerifier.verify).toHaveBeenCalled(); + }); + + test('should use overriden cookie settings', async () => { + const cookiePath = '/test/path'; + const authenticatorWithPath = new Authenticator({ + region: 'us-east-1', + userPoolId: 'us-east-1_abcdef123', + userPoolAppId: '123456789qwertyuiop987abcd', + userPoolDomain: 'my-cognito-domain.auth.us-east-1.amazoncognito.com', + cookieExpirationDays: 365, + disableCookieDomain: false, + logLevel: 'error', + cookiePath, + httpOnly: true, + csrfProtection: { + nonceSigningSecret: 'foo-bar', + }, + cookieSettingsOverrides: { + accessToken: { + httpOnly: false, + sameSite: 'Lax', + path: '/foo', + expirationDays: 2, + }, + }, + }); + authenticatorWithPath._jwtVerifier.cacheJwks(jwksData); + + const username = 'toto'; + const domain = 'example.com'; + const path = '/test'; + jest.spyOn(authenticatorWithPath._jwtVerifier, 'verify'); + authenticatorWithPath._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({ token_use: 'id', 'cognito:username': username })); + + const response = await authenticatorWithPath._getRedirectResponse({ accessToken: tokenData.access_token, idToken: tokenData.id_token, refreshToken: tokenData.refresh_token }, domain, path); + expect(response).toMatchObject({ + status: '302', + headers: { + location: [{ + key: 'Location', + value: path, + }], + }, + }); + expect(response?.headers?.['set-cookie']).toEqual(expect.arrayContaining([ + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.accessToken=${tokenData.access_token}; Domain=${domain}; Path=${'/foo'}; Expires=${DATE.toUTCString()}; Secure; SameSite=Lax`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.refreshToken=${tokenData.refresh_token}; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure; HttpOnly`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.tokenScopesString=phone%20email%20profile%20openid%20aws.cognito.signin.user.admin; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure; HttpOnly`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.idToken=${tokenData.id_token}; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure; HttpOnly`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.LastAuthUser=${username}; Domain=${domain}; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure; HttpOnly`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${PKCE_COOKIE_NAME_SUFFIX}=; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure; HttpOnly`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${NONCE_COOKIE_NAME_SUFFIX}=; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure; HttpOnly`}, + {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${NONCE_HMAC_COOKIE_NAME_SUFFIX}=; Path=${cookiePath}; Expires=${DATE.toUTCString()}; Secure; HttpOnly`}, + ])); + expect(authenticatorWithPath._jwtVerifier.verify).toHaveBeenCalled(); + }); + test('should getIdTokenFromCookie', () => { const appClientName = 'toto,./;;..-_lol123'; expect( @@ -268,6 +372,113 @@ describe('private functions', () => { test('should getTokensFromCookie throw on cookies', () => { expect(() => authenticator._getTokensFromCookie([])).toThrow('idToken'); }); + + describe('_validateCSRFCookies', () => { + function buildRequest(tokensInState = {}, tokensInCookie = {}): CloudFrontRequest { + const state = Buffer.from(JSON.stringify(tokensInState)).toString('base64'); + + const cookieHeaders: Array<{ key?: string | undefined; value: string; }> = []; + for (const [name, value] of Object.entries(tokensInCookie)) { + cookieHeaders.push({key: 'cookie', value: `${authenticator._cookieBase}.${name}=${value}`}); + } + return { + clientIp: '', + method: '', + uri: '', + querystring: `state=${state}`, + headers: { + 'cookie': cookieHeaders, + }, + }; + } + + beforeEach(() => { + authenticator._csrfProtection = { + nonceSigningSecret: 'foo-bar', + }; + }); + + it('should throw error when nonce cookie is not present', () => { + const request = buildRequest( + {nonce: 'nonce-value'}, + {} + ); + expect(() => authenticator._validateCSRFCookies(request)).toThrow( + 'Your browser didn\'t send the nonce cookie along, but it is required for security (prevent CSRF).', + ); + }); + + it('should throw error when nonce cookie is different than the one encoded in state', () => { + const request = buildRequest( + {[NONCE_COOKIE_NAME_SUFFIX]: 'nonce-value'}, + {[NONCE_COOKIE_NAME_SUFFIX]: 'nonce-value-different'} + ); + expect(() => authenticator._validateCSRFCookies(request)).toThrow( + 'Nonce mismatch. This can happen if you start multiple authentication attempts in parallel (e.g. in separate tabs)', + ); + }); + + it('should throw error when pkce cookie is absent', () => { + const request = buildRequest( + {[NONCE_COOKIE_NAME_SUFFIX]: 'nonce-value', [PKCE_COOKIE_NAME_SUFFIX]: 'pkce-value'}, + {[NONCE_COOKIE_NAME_SUFFIX]: 'nonce-value'} + ); + expect(() => authenticator._validateCSRFCookies(request)).toThrow( + 'Your browser didn\'t send the pkce cookie along, but it is required for security (prevent CSRF).' + ); + }); + + it('should throw error when calculated Hmac is different than the one stored in the cookie', () => { + jest.mock('../src/util/csrf', () => ({signNonce: () => 'nonce-hmac-value-different'})); + const request = buildRequest( + {[NONCE_COOKIE_NAME_SUFFIX]: 'nonce-value', [PKCE_COOKIE_NAME_SUFFIX]: 'pkce-value'}, + {[NONCE_COOKIE_NAME_SUFFIX]: 'nonce-value', [PKCE_COOKIE_NAME_SUFFIX]: 'pkce-value', [NONCE_HMAC_COOKIE_NAME_SUFFIX]: 'nonce-hmac-value'} + ); + expect(() => authenticator._validateCSRFCookies(request)).toThrow( + 'Nonce signature mismatch!' + ); + }); + }); + + test('_revokeTokens', () => { + axios.request = jest.fn().mockResolvedValue({ data: tokenData }); + authenticator._revokeTokens({refreshToken: tokenData.refresh_token}); + expect(axios.request).toHaveBeenCalledWith(expect.objectContaining({ + url: 'https://my-cognito-domain.auth.us-east-1.amazoncognito.com/oauth2/revoke', + method: 'POST', + })); + }); + + describe('_clearCookies', () => { + it('should verify tokens and clear cookies', async () => { + jest.spyOn(authenticator._jwtVerifier, 'verify'); + authenticator._jwtVerifier.cacheJwks(jwksData); + authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({})); + const tokens = {idToken: tokenData.id_token, refreshToken: tokenData.refresh_token}; + const response = await (authenticator as any)._clearCookies(getCloudfrontRequest(), tokens); + expect(response).toEqual(expect.objectContaining({ + status: '302', + })); + expect(response.headers['set-cookie']).toBeDefined(); + expect(response.headers['set-cookie'].length).toBe(5); + }); + + it('should clear cookies even if tokens cannot be verified', async () => { + jest.spyOn(authenticator._jwtVerifier, 'verify'); + authenticator._jwtVerifier.cacheJwks(jwksData); + authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.reject({})); + const tokens = {idToken: tokenData.id_token, refreshToken: tokenData.refresh_token}; + const request = getCloudfrontRequest(); + const numCookiesToBeCleared = request.Records[0].cf.request.headers['cookie']?.length || 0; + const response = await (authenticator as any)._clearCookies(request, tokens); + expect(response).toEqual(expect.objectContaining({ + status: '302', + })); + expect(response.headers['set-cookie']).toBeDefined(); + expect(response.headers['set-cookie'].length).toBe(numCookiesToBeCleared); + }); + }); + }); describe('createAuthenticator', () => { @@ -289,7 +500,7 @@ describe('createAuthenticator', () => { expect(typeof new Authenticator(params)).toBe('object'); }); - test('should create authenticator without cookieExpirationDay', () => { + test('should create authenticator without cookieExpirationDays', () => { delete params.cookieExpirationDays; expect(typeof new Authenticator(params)).toBe('object'); }); @@ -299,6 +510,11 @@ describe('createAuthenticator', () => { expect(typeof new Authenticator(params)).toBe('object'); }); + test('should create authenticator without cookieDomain', () => { + delete params.cookieDomain; + expect(typeof new Authenticator(params)).toBe('object'); + }); + test('should create authenticator without httpOnly', () => { delete params.httpOnly; expect(typeof new Authenticator(params)).toBe('object'); @@ -361,7 +577,7 @@ describe('createAuthenticator', () => { expect(() => new Authenticator(params)).toThrow('userPoolDomain'); }); - test('should fail when creating authenticator with invalid cookieExpirationDay', () => { + test('should fail when creating authenticator with invalid cookieExpirationDays', () => { params.cookieExpirationDays = '123'; expect(() => new Authenticator(params)).toThrow('cookieExpirationDays'); }); @@ -371,6 +587,11 @@ describe('createAuthenticator', () => { expect(() => new Authenticator(params)).toThrow('disableCookieDomain'); }); + test('should fail when creating authenticator with invalid cookie domain', () => { + params.cookieDomain = 123; + expect(() => new Authenticator(params)).toThrow('cookieDomain'); + }); + test('should fail when creating authenticator with invalid httpOnly', () => { params.httpOnly = '123'; expect(() => new Authenticator(params)).toThrow('httpOnly'); @@ -380,6 +601,14 @@ describe('createAuthenticator', () => { params.cookiePath = 123; expect(() => new Authenticator(params)).toThrow('cookiePath'); }); + + test('should fail when creating authenticator with invalid logoutUri', () => { + params.logoutConfiguration = { logoutUri: '' }; + expect(() => new Authenticator(params)).toThrow('logoutUri'); + + params.logoutConfiguration = { logoutUri: '/' }; + expect(() => new Authenticator(params)).toThrow('logoutUri'); + }); }); describe('handle', () => { @@ -400,6 +629,8 @@ describe('handle', () => { jest.spyOn(authenticator, '_fetchTokensFromRefreshToken'); jest.spyOn(authenticator, '_getRedirectResponse'); jest.spyOn(authenticator, '_getRedirectToCognitoUserPoolResponse'); + jest.spyOn(authenticator, '_revokeTokens'); + jest.spyOn(authenticator, '_clearCookies'); jest.spyOn(authenticator._jwtVerifier, 'verify'); }); @@ -443,7 +674,7 @@ describe('handle', () => { }); test('should fetch and set token if code is present', () => { - authenticator._jwtVerifier.verify.mockImplementationOnce(async () => { throw new Error();}); + authenticator._jwtVerifier.verify.mockImplementationOnce(async () => { throw new Error(); }); authenticator._fetchTokensFromCode.mockResolvedValueOnce(tokenData); authenticator._getRedirectResponse.mockReturnValueOnce({ response: 'toto' }); const request = getCloudfrontRequest(); @@ -481,8 +712,301 @@ describe('handle', () => { expect(authenticator._jwtVerifier.verify).toHaveBeenCalled(); }); }); + + test('should redirect to auth domain and clear csrf cookies if unauthenticated and no code', async () => { + authenticator._jwtVerifier.verify.mockImplementationOnce(async () => { throw new Error(); }); + authenticator._csrfProtection = { + nonceSigningSecret: 'foo-bar', + }; + const response = await authenticator.handle(getCloudfrontRequest()); + expect(response).toMatchObject({ + status: '302', + headers: { + 'cache-control': [{ + key: 'Cache-Control', + value: 'no-cache, no-store, max-age=0, must-revalidate', + }], + 'pragma': [{ + key: 'Pragma', + value: 'no-cache', + }], + }, + }); + const url = new URL(response.headers['location'][0].value); + expect(url.origin).toEqual('https://my-cognito-domain.auth.us-east-1.amazoncognito.com'); + expect(url.pathname).toEqual('/authorize'); + expect(url.searchParams.get('redirect_uri')).toEqual('https://d111111abcdef8.cloudfront.net'); + expect(url.searchParams.get('response_type')).toEqual('code'); + expect(url.searchParams.get('client_id')).toEqual('123456789qwertyuiop987abcd'); + expect(url.searchParams.get('state')).toBeDefined(); + + // Cookies + expect(response.headers['set-cookie']).toBeDefined(); + const cookies = response.headers['set-cookie'].map(h => h.value); + expect(cookies.find(c => c.match(`.${NONCE_COOKIE_NAME_SUFFIX}=`))).toBeDefined(); + expect(cookies.find(c => c.match(`.${NONCE_HMAC_COOKIE_NAME_SUFFIX}=`))).toBeDefined(); + expect(cookies.find(c => c.match(`.${PKCE_COOKIE_NAME_SUFFIX}=`))).toBeDefined(); + }); + + test('should revoke tokens and clear cookies if logoutConfiguration is set', () => { + authenticator._logoutConfiguration = { logoutUri: '/logout' }; + authenticator._getTokensFromCookie.mockReturnValueOnce({ refreshToken: tokenData.refresh_token }); + authenticator._revokeTokens.mockReturnValueOnce(Promise.resolve()); + authenticator._clearCookies.mockReturnValueOnce(Promise.resolve({ status: '302' })); + const request = getCloudfrontRequest(); + request.Records[0].cf.request.uri = '/logout'; + return expect(authenticator.handle(request)).resolves.toEqual(expect.objectContaining({ status: '302' })) + .then(() => { + expect(authenticator._getTokensFromCookie).toHaveBeenCalled(); + expect(authenticator._revokeTokens).toHaveBeenCalled(); + expect(authenticator._clearCookies).toHaveBeenCalled(); + }); + }); + + test('should clear cookies if logoutConfiguration is set even if user is unauthenticated', async () => { + authenticator._logoutConfiguration = { logoutUri: '/logout' }; + authenticator._getTokensFromCookie.mockImplementationOnce(() => { throw new Error(); }); + authenticator._clearCookies.mockReturnValueOnce(Promise.resolve({ status: '302' })); + const request = getCloudfrontRequest(); + request.Records[0].cf.request.uri = '/logout'; + return expect(authenticator.handle(request)).resolves.toEqual(expect.objectContaining({ status: '302' })) + .then(() => { + expect(authenticator._getTokensFromCookie).toHaveBeenCalled(); + expect(authenticator._revokeTokens).not.toHaveBeenCalled(); + expect(authenticator._clearCookies).toHaveBeenCalled(); + }); + }); +}); + +describe('handleSignIn', () => { + let authenticator; + + beforeEach(() => { + authenticator = new Authenticator({ + region: 'us-east-1', + userPoolId: 'us-east-1_abcdef123', + userPoolAppId: '123456789qwertyuiop987abcd', + userPoolDomain: 'my-cognito-domain.auth.us-east-1.amazoncognito.com', + cookieExpirationDays: 365, + logLevel: 'debug', + parseAuthPath: 'parseAuth', + }); + authenticator._jwtVerifier.cacheJwks(jwksData); + jest.spyOn(authenticator, '_getTokensFromCookie'); + jest.spyOn(authenticator, '_getRedirectToCognitoUserPoolResponse'); + jest.spyOn(authenticator._jwtVerifier, 'verify'); + }); + + test('should forward request if authenticated', async () => { + authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({})); + const request = getCloudfrontRequest(); + request.Records[0].cf.request.querystring = 'redirect_uri=https://example.aws.com'; + const response = await authenticator.handleSignIn(request); + expect(response.status).toEqual('302'); + expect(response.headers?.location).toBeDefined(); + expect(response.headers.location[0].value).toEqual('https://example.aws.com'); + }); + + test('should redirect to cognito if refresh token is invalid', () => { + authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.reject({})); + authenticator._getTokensFromCookie.mockReturnValueOnce({refreshToken: tokenData.refresh_token}); + authenticator._getRedirectToCognitoUserPoolResponse.mockReturnValueOnce({ response: 'toto' }); + const request = getCloudfrontRequest(); + return expect(authenticator.handleSignIn(request)).resolves.toEqual({ response: 'toto' }) + .then(() => { + expect(authenticator._getTokensFromCookie).toHaveBeenCalled(); + expect(authenticator._jwtVerifier.verify).toHaveBeenCalled(); + expect(authenticator._getRedirectToCognitoUserPoolResponse).toHaveBeenCalled(); + }); + }); +}); + +describe('handleParseAuth', () => { + let authenticator; + + beforeEach(() => { + authenticator = new Authenticator({ + region: 'us-east-1', + userPoolId: 'us-east-1_abcdef123', + userPoolAppId: '123456789qwertyuiop987abcd', + userPoolDomain: 'my-cognito-domain.auth.us-east-1.amazoncognito.com', + cookieExpirationDays: 365, + logLevel: 'debug', + parseAuthPath: 'parseAuth', + }); + authenticator._jwtVerifier.cacheJwks(jwksData); + jest.spyOn(authenticator, '_validateCSRFCookies'); + jest.spyOn(authenticator, '_fetchTokensFromCode'); + jest.spyOn(authenticator, '_getTokensFromCookie'); + jest.spyOn(authenticator, '_getRedirectResponse'); + }); + + describe('if code is present', () => { + test('should redirect successfully if csrfProtection is not enabled', async () => { + authenticator._fetchTokensFromCode.mockReturnValueOnce(Promise.resolve({ + idToken: tokenData.id_token, + refreshToken: tokenData.refresh_token, + accessToken: tokenData.access_token, + })); + authenticator._getRedirectResponse.mockReturnValueOnce({ response: 'toto' }); + const state = Buffer.from(JSON.stringify({ + nonce: 'nonceValue', + nonceHmac: 'nonceHmacValue', + pkce: 'pkceValue', + })).toString('base64'); + const request = getCloudfrontRequest(); + request.Records[0].cf.request.querystring = `code=code&state=${state}`; + return expect(authenticator.handleParseAuth(request)).resolves.toEqual({ response: 'toto' }) + .then(() => { + expect(authenticator._validateCSRFCookies).not.toHaveBeenCalled(); + expect(authenticator._fetchTokensFromCode).toHaveBeenCalled(); + expect(authenticator._getRedirectResponse).toHaveBeenCalled(); + }); + }); + + test('should redirect successfully after validating CSRF tokens', async () => { + authenticator._csrfProtection = { + nonceSigningSecret: 'foo-bar', + }; + authenticator._validateCSRFCookies.mockReturnValueOnce(); + authenticator._fetchTokensFromCode.mockReturnValueOnce(Promise.resolve({ + idToken: tokenData.id_token, + refreshToken: tokenData.refresh_token, + accessToken: tokenData.access_token, + })); + authenticator._getRedirectResponse.mockReturnValueOnce({ response: 'toto' }); + const state = Buffer.from(JSON.stringify({ + nonce: 'nonceValue', + nonceHmac: 'nonceHmacValue', + pkce: 'pkceValue', + })).toString('base64'); + const request = getCloudfrontRequest(); + request.Records[0].cf.request.querystring = `code=code&state=${state}`; + return expect(authenticator.handleParseAuth(request)).resolves.toEqual({ response: 'toto' }) + .then(() => { + expect(authenticator._validateCSRFCookies).toHaveBeenCalled(); + expect(authenticator._fetchTokensFromCode).toHaveBeenCalled(); + expect(authenticator._getRedirectResponse).toHaveBeenCalled(); + }); + }); + }); + + test('should throw error when parseAuthPath is not set', async () => { + authenticator._parseAuthPath = ''; + authenticator._getRedirectResponse.mockReturnValueOnce({ response: 'toto' }); + return expect(authenticator.handleParseAuth(getCloudfrontRequest())).resolves.toEqual({ status: '400', body: expect.stringContaining('parseAuthPath')}) + .then(() => { + expect(authenticator._validateCSRFCookies).not.toHaveBeenCalled(); + expect(authenticator._fetchTokensFromCode).not.toHaveBeenCalled(); + expect(authenticator._getRedirectResponse).not.toHaveBeenCalled(); + }); + }); + + test('should throw if code is absent', async () => { + authenticator._validateCSRFCookies.mockImplementationOnce(async () => { throw new Error(); }); + return expect(authenticator.handleParseAuth(getCloudfrontRequest())).resolves.toEqual(expect.objectContaining({ status: '400' })) + .then(() => { + expect(authenticator._validateCSRFCookies).not.toHaveBeenCalled(); + expect(authenticator._fetchTokensFromCode).not.toHaveBeenCalled(); + expect(authenticator._getRedirectResponse).not.toHaveBeenCalled(); + }); + }); +}); + +describe('handleRefreshToken', () => { + let authenticator; + + beforeEach(() => { + authenticator = new Authenticator({ + region: 'us-east-1', + userPoolId: 'us-east-1_abcdef123', + userPoolAppId: '123456789qwertyuiop987abcd', + userPoolDomain: 'my-cognito-domain.auth.us-east-1.amazoncognito.com', + cookieExpirationDays: 365, + logLevel: 'debug', + }); + authenticator._jwtVerifier.cacheJwks(jwksData); + jest.spyOn(authenticator, '_getTokensFromCookie'); + jest.spyOn(authenticator._jwtVerifier, 'verify'); + jest.spyOn(authenticator, '_fetchTokensFromRefreshToken'); + jest.spyOn(authenticator, '_getRedirectResponse'); + jest.spyOn(authenticator, '_getRedirectToCognitoUserPoolResponse'); + }); + + test('should refresh tokens successfully', async () => { + const username = 'toto'; + authenticator._getTokensFromCookie.mockReturnValueOnce({ refreshToken: tokenData.refresh_token }); + authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({ token_use: 'id', 'cognito:username': username })); + authenticator._fetchTokensFromRefreshToken.mockReturnValueOnce(Promise.resolve({ + idToken: tokenData.id_token, + refreshToken: tokenData.refresh_token, + accessToken: tokenData.access_token, + })); + authenticator._getRedirectResponse.mockReturnValueOnce({ response: 'toto' }); + return expect(authenticator.handleRefreshToken(getCloudfrontRequest())).resolves.toEqual({ response: 'toto' }) + .then(() => { + expect(authenticator._getTokensFromCookie).toHaveBeenCalled(); + expect(authenticator._jwtVerifier.verify).toHaveBeenCalled(); + expect(authenticator._fetchTokensFromRefreshToken).toHaveBeenCalled(); + expect(authenticator._getRedirectResponse).toHaveBeenCalled(); + }); + }); + + test('should redirect to cognito user pool if refresh token is invalid', () => { + authenticator._getTokensFromCookie.mockReturnValueOnce({ refreshToken: tokenData.refresh_token }); + authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.reject()); + return expect(authenticator.handleRefreshToken(getCloudfrontRequest())).resolves.toEqual(expect.objectContaining({ status: '302' })) + .then(() => { + expect(authenticator._getTokensFromCookie).toHaveBeenCalled(); + expect(authenticator._jwtVerifier.verify).toHaveBeenCalled(); + expect(authenticator._fetchTokensFromRefreshToken).not.toHaveBeenCalled(); + expect(authenticator._getRedirectResponse).not.toHaveBeenCalled(); + }); + }); }); +describe('handleSignOut', () => { + let authenticator; + + beforeEach(() => { + authenticator = new Authenticator({ + region: 'us-east-1', + userPoolId: 'us-east-1_abcdef123', + userPoolAppId: '123456789qwertyuiop987abcd', + userPoolDomain: 'my-cognito-domain.auth.us-east-1.amazoncognito.com', + cookieExpirationDays: 365, + logLevel: 'debug', + }); + authenticator._jwtVerifier.cacheJwks(jwksData); + jest.spyOn(authenticator, '_getTokensFromCookie'); + jest.spyOn(authenticator, '_revokeTokens'); + jest.spyOn(authenticator, '_clearCookies'); + }); + + test('should revoke tokens and clear cookies successfully', async () => { + authenticator._getTokensFromCookie.mockReturnValueOnce({ refreshToken: tokenData.refresh_token }); + authenticator._revokeTokens.mockReturnValueOnce(Promise.resolve()); + authenticator._clearCookies.mockReturnValueOnce(Promise.resolve({ status: '302' })); + return expect(authenticator.handleSignOut(getCloudfrontRequest())).resolves.toEqual(expect.objectContaining({ status: '302' })) + .then(() => { + expect(authenticator._getTokensFromCookie).toHaveBeenCalled(); + expect(authenticator._revokeTokens).toHaveBeenCalled(); + expect(authenticator._clearCookies).toHaveBeenCalled(); + }); + }); + + test('should clear cookies successfully even if tokens cannot be revoked', async () => { + authenticator._getTokensFromCookie.mockReturnValueOnce({ refreshToken: tokenData.refresh_token }); + authenticator._revokeTokens.mockReturnValueOnce(Promise.reject()); + authenticator._clearCookies.mockReturnValueOnce(Promise.resolve({ status: '302' })); + return expect(authenticator.handleSignOut(getCloudfrontRequest())).resolves.toEqual(expect.objectContaining({ status: '302' })) + .then(() => { + expect(authenticator._getTokensFromCookie).toHaveBeenCalled(); + expect(authenticator._revokeTokens).toHaveBeenCalled(); + expect(authenticator._clearCookies).toHaveBeenCalled(); + }); + }); +}); /* eslint-disable quotes, comma-dangle */ const jwksData = { diff --git a/__tests__/util/cookie.test.ts b/__tests__/util/cookie.test.ts index 3ecc26a..f179e9e 100644 --- a/__tests__/util/cookie.test.ts +++ b/__tests__/util/cookie.test.ts @@ -1,4 +1,4 @@ -import { CookieAttributes, Cookies, SAME_SITE_VALUES } from '../../src/util/cookie'; +import { CookieAttributes, Cookies, SAME_SITE_VALUES, getCookieDomain } from '../../src/util/cookie'; describe('parse tests', () => { test('should parse valid cookie string', () => { @@ -102,3 +102,17 @@ describe('serialize tests', () => { expect(SAME_SITE_VALUES).toEqual(['Strict', 'Lax', 'None']); }); }); + +describe('getCookieDomain', () => { + it('should return cloudfront domain when disableCookieDomain is not set and cookieDomain is not set', () => { + expect(getCookieDomain('example.aws.com', false)).toEqual('example.aws.com'); + }); + + it('should return custom domain when cookieDomain is set', () => { + expect(getCookieDomain('example.aws.com', false, 'aws.com')).toEqual('aws.com'); + }); + + it('should return undefined when disableCookieDomain is set', () => { + expect(getCookieDomain('example.aws.com', true)).toBeUndefined(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 6fbe74c..a9ea1e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,8 @@ import { CloudFrontRequest, CloudFrontRequestEvent, CloudFrontRequestResult } fr import axios from 'axios'; import pino from 'pino'; import { parse, stringify } from 'querystring'; -import { CookieAttributes, Cookies, SameSite, SAME_SITE_VALUES } from './util/cookie'; +import { CookieAttributes, CookieSettingsOverrides, CookieType, Cookies, SAME_SITE_VALUES, SameSite, getCookieDomain } from './util/cookie'; +import { CSRFTokens, NONCE_COOKIE_NAME_SUFFIX, NONCE_HMAC_COOKIE_NAME_SUFFIX, PKCE_COOKIE_NAME_SUFFIX, generateCSRFTokens, signNonce, urlSafe } from './util/csrf'; interface AuthenticatorParams { region: string; @@ -17,6 +18,17 @@ interface AuthenticatorParams { sameSite?: SameSite; logLevel?: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'; cookiePath?: string; + cookieDomain?: string; + cookieSettingsOverrides?: CookieSettingsOverrides; + logoutConfiguration?: LogoutConfiguration; + parseAuthPath?: string; + csrfProtection?: { + nonceSigningSecret: string; + }, +} + +interface LogoutConfiguration { + logoutUri: string; } interface Tokens { @@ -37,6 +49,13 @@ export class Authenticator { _sameSite?: SameSite; _cookieBase: string; _cookiePath?: string; + _cookieDomain?: string; + _csrfProtection?: { + nonceSigningSecret?: string; + }; + _logoutConfiguration?: LogoutConfiguration; + _parseAuthPath?: string; + _cookieSettingsOverrides?: CookieSettingsOverrides; _logger; _jwtVerifier; @@ -49,10 +68,12 @@ export class Authenticator { this._userPoolDomain = params.userPoolDomain; this._cookieExpirationDays = params.cookieExpirationDays || 365; this._disableCookieDomain = ('disableCookieDomain' in params && params.disableCookieDomain === true); + this._cookieDomain = params.cookieDomain; this._httpOnly = ('httpOnly' in params && params.httpOnly === true); this._sameSite = params.sameSite; this._cookieBase = `CognitoIdentityServiceProvider.${params.userPoolAppId}`; this._cookiePath = params.cookiePath; + this._cookieSettingsOverrides = params.cookieSettingsOverrides || {}; this._logger = pino({ level: params.logLevel || 'silent', // Default to silent base: null, //Remove pid, hostname and name logging as not usefull for Lambda @@ -62,6 +83,9 @@ export class Authenticator { clientId: params.userPoolAppId, tokenUse: 'id', }); + this._csrfProtection = params.csrfProtection; + this._logoutConfiguration = params.logoutConfiguration; + this._parseAuthPath = (params.parseAuthPath || '').replace(/^\//, ''); } /** @@ -82,7 +106,10 @@ export class Authenticator { throw new Error('Expected params.cookieExpirationDays to be a number'); } if ('disableCookieDomain' in params && typeof params.disableCookieDomain !== 'boolean') { - throw new Error('Expected params.disableCookieDomain to be a boolean'); + throw new Error('Expected params.disableCookieDomain to be boolean'); + } + if ('cookieDomain' in params && typeof params.cookieDomain !== 'string') { + throw new Error('Expected params.cookieDomain to be a string'); } if ('httpOnly' in params && typeof params.httpOnly !== 'boolean') { throw new Error('Expected params.httpOnly to be a boolean'); @@ -93,6 +120,9 @@ export class Authenticator { if ('cookiePath' in params && typeof params.cookiePath !== 'string') { throw new Error('Expected params.cookiePath to be a string'); } + if ('logoutConfiguration' in params && !/\/\w+/.test(params.logoutConfiguration.logoutUri)) { + throw new Error('Expected params.logoutConfiguration.logoutUri to be a valid non-empty string starting with "/"'); + } } /** @@ -102,7 +132,7 @@ export class Authenticator { * @return {Promise} Authenticated user tokens. */ _fetchTokensFromCode(redirectURI, code): Promise { - const authorization = this._userPoolAppSecret && Buffer.from(`${this._userPoolAppId}:${this._userPoolAppSecret}`).toString('base64'); + const authorization = this._getAuthorization(); const request = { url: `https://${this._userPoolDomain}/oauth2/token`, method: 'POST', @@ -140,7 +170,7 @@ export class Authenticator { * @return {Promise} Refreshed user tokens. */ _fetchTokensFromRefreshToken(redirectURI: string, refreshToken: string): Promise { - const authorization = this._userPoolAppSecret && Buffer.from(`${this._userPoolAppId}:${this._userPoolAppSecret}`).toString('base64'); + const authorization = this._getAuthorization(); const request = { url: `https://${this._userPoolDomain}/oauth2/token`, method: 'POST', @@ -170,6 +200,68 @@ export class Authenticator { }); } + _getAuthorization(): string { + return this._userPoolAppSecret && Buffer.from(`${this._userPoolAppId}:${this._userPoolAppSecret}`).toString('base64'); + } + + _validateCSRFCookies(request: CloudFrontRequest) { + const requestParams = parse(request.querystring); + const requestCookies = request.headers.cookie?.flatMap(h => Cookies.parse(h.value)) || []; + this._logger.debug({ msg: 'Validating CSRF Cookies', requestCookies}); + + const parsedState = JSON.parse( + Buffer.from(urlSafe.parse(requestParams.state), 'base64').toString() + ); + + const {nonce: originalNonce, nonceHmac, pkce} = this._getCSRFTokensFromCookie(request.headers.cookie); + + if ( + !parsedState.nonce || + !originalNonce || + parsedState.nonce !== originalNonce + ) { + if (!originalNonce) { + throw new Error('Your browser didn\'t send the nonce cookie along, but it is required for security (prevent CSRF).'); + } + throw new Error('Nonce mismatch. This can happen if you start multiple authentication attempts in parallel (e.g. in separate tabs)'); + } + if (!pkce) { + throw new Error('Your browser didn\'t send the pkce cookie along, but it is required for security (prevent CSRF).'); + } + + const calculatedHmac = signNonce(parsedState.nonce, this._csrfProtection?.nonceSigningSecret); + + if (calculatedHmac !== nonceHmac) { + throw new Error(`Nonce signature mismatch! Expected ${calculatedHmac} but got ${nonceHmac}`); + } + } + + _getOverridenCookieAttributes(cookieAttributes: CookieAttributes = {}, cookieType: CookieType): CookieAttributes { + const res = {...cookieAttributes}; + if (cookieType in this._cookieSettingsOverrides) { + const overrides = this._cookieSettingsOverrides[cookieType]; + if ('httpOnly' in overrides) { + res.httpOnly = overrides.httpOnly; + } + if ('sameSite' in overrides) { + res.sameSite = overrides.sameSite; + } + if ('path' in overrides) { + res.path = overrides.path; + } + if ('expirationDays' in overrides) { + res.expires = new Date(Date.now() + overrides.expirationDays * 864e+5); + } + } + this._logger.debug({ + msg: 'Cookie settings overriden', + cookieAttributes, + cookieType, + cookieSettingsOverrides: this._cookieSettingsOverrides, + }); + return res; + } + /** * Create a Lambda@Edge redirection response to set the tokens on the user's browser cookies. * @param {Object} tokens Cognito User Pool tokens. @@ -181,8 +273,9 @@ export class Authenticator { const decoded = await this._jwtVerifier.verify(tokens.idToken); const username = decoded['cognito:username'] as string; const usernameBase = `${this._cookieBase}.${username}`; + const cookieDomain = getCookieDomain(domain, this._disableCookieDomain, this._cookieDomain); const cookieAttributes: CookieAttributes = { - domain: this._disableCookieDomain ? undefined : domain, + domain: cookieDomain, expires: new Date(Date.now() + this._cookieExpirationDays * 864e+5), secure: true, httpOnly: this._httpOnly, @@ -190,13 +283,25 @@ export class Authenticator { path: this._cookiePath, }; const cookies = [ - Cookies.serialize(`${usernameBase}.accessToken`, tokens.accessToken, cookieAttributes), - Cookies.serialize(`${usernameBase}.idToken`, tokens.idToken, cookieAttributes), - ...(tokens.refreshToken ? [Cookies.serialize(`${usernameBase}.refreshToken`, tokens.refreshToken, cookieAttributes)] : []), + Cookies.serialize(`${usernameBase}.accessToken`, tokens.accessToken, this._getOverridenCookieAttributes(cookieAttributes, 'accessToken')), + Cookies.serialize(`${usernameBase}.idToken`, tokens.idToken, this._getOverridenCookieAttributes(cookieAttributes, 'idToken')), + ...(tokens.refreshToken ? [Cookies.serialize(`${usernameBase}.refreshToken`, tokens.refreshToken, this._getOverridenCookieAttributes(cookieAttributes, 'refreshToken'))] : []), Cookies.serialize(`${usernameBase}.tokenScopesString`, 'phone email profile openid aws.cognito.signin.user.admin', cookieAttributes), Cookies.serialize(`${this._cookieBase}.LastAuthUser`, username, cookieAttributes), ]; + // Clear CSRF Token Cookies + if (this._csrfProtection) { + // Domain attribute is always not set here as CSRF cookies are used + // exclusively by the CF distribution + const csrfCookieAttributes = {...cookieAttributes, domain: undefined, expires: new Date()}; + cookies.push( + Cookies.serialize(`${this._cookieBase}.${PKCE_COOKIE_NAME_SUFFIX}`, '', csrfCookieAttributes), + Cookies.serialize(`${this._cookieBase}.${NONCE_COOKIE_NAME_SUFFIX}`, '', csrfCookieAttributes), + Cookies.serialize(`${this._cookieBase}.${NONCE_HMAC_COOKIE_NAME_SUFFIX}`, '', csrfCookieAttributes), + ); + } + const response: CloudFrontRequestResult = { status: '302' , headers: { @@ -259,6 +364,134 @@ export class Authenticator { return tokens; } + /** + * Extract values of the CSRF tokens from the request cookies. + * @param {Array} cookieHeaders 'Cookie' request headers. + * @return {CSRFTokens} Extracted CSRF Tokens from cookie. + */ + _getCSRFTokensFromCookie(cookieHeaders: Array<{ key?: string | undefined, value: string }> | undefined): CSRFTokens { + if (!cookieHeaders) { + this._logger.debug("Cookies weren't present in the request"); + throw new Error("Cookies weren't present in the request"); + } + + this._logger.debug({ msg: 'Extracting CSRF tokens from request cookie', cookieHeaders }); + + const cookies = cookieHeaders.flatMap(h => Cookies.parse(h.value)); + const csrfTokens: CSRFTokens = cookies.reduce((tokens, {name, value}) => { + if (name.startsWith(this._cookieBase)) { + [ + NONCE_COOKIE_NAME_SUFFIX, + NONCE_HMAC_COOKIE_NAME_SUFFIX, + PKCE_COOKIE_NAME_SUFFIX, + ].forEach(key => { + if (name.endsWith(`.${key}`)) { + tokens[key] = value; + } + }); + } + return tokens; + }, {}); + + this._logger.debug({ msg: 'Found CSRF tokens in cookie', csrfTokens }); + return csrfTokens; + } + + async _revokeTokens(tokens: Tokens) { + const authorization = this._getAuthorization(); + const revokeRequest = { + url: `https://${this._userPoolDomain}/oauth2/revoke`, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(authorization && {'Authorization': `Basic ${authorization}`}), + }, + data: stringify({ + client_id: this._userPoolAppId, + token: tokens.refreshToken, + }), + } as const; + this._logger.debug({ msg: 'Revoking refreshToken...', request: revokeRequest, refreshToken: tokens.refreshToken }); + return axios.request(revokeRequest) + .then(() => { + this._logger.debug({ msg: 'Revoked refreshToken', refreshToken: tokens.refreshToken }); + }) + .catch(err => { + this._logger.error({ msg: 'Unable to revoke refreshToken', request: revokeRequest, err: JSON.stringify(err) }); + throw err; + }); + } + + async _clearCookies(event: CloudFrontRequestEvent, tokens: Tokens = {}): Promise { + this._logger.info({ msg: 'Clearing cookies...', event, tokens }); + const { request } = event.Records[0].cf; + const cfDomain = request.headers.host[0].value; + const requestParams = parse(request.querystring); + const redirectURI = requestParams.redirect_uri as string; + + const cookieDomain = getCookieDomain(cfDomain, this._disableCookieDomain, this._cookieDomain); + const cookieAttributes: CookieAttributes = { + domain: cookieDomain, + expires: new Date(), + secure: true, + httpOnly: this._httpOnly, + sameSite: this._sameSite, + path: this._cookiePath, + }; + + let responseCookies = []; + try { + const decoded = await this._jwtVerifier.verify(tokens.idToken); + const username = decoded['cognito:username'] as string; + this._logger.info({ msg: 'Token verified. Clearing cookies...', idToken: tokens.idToken, username }); + + const usernameBase = `${this._cookieBase}.${username}`; + responseCookies = [ + Cookies.serialize(`${usernameBase}.accessToken`, '', cookieAttributes), + Cookies.serialize(`${usernameBase}.idToken`, '', cookieAttributes), + ...(tokens.refreshToken ? [Cookies.serialize(`${usernameBase}.refreshToken`, '', cookieAttributes)] : []), + Cookies.serialize(`${usernameBase}.tokenScopesString`, '', cookieAttributes), + Cookies.serialize(`${this._cookieBase}.LastAuthUser`, '', cookieAttributes), + ]; + } catch (err) { + this._logger.info({ + msg: 'Unable to verify token. Inferring data from request cookies and clearing them...', + idToken: tokens.idToken, + }); + const requestCookies = request.headers.cookie?.flatMap(h => Cookies.parse(h.value)) || []; + for (const { name } of requestCookies) { + if (name.startsWith(this._cookieBase)) { + responseCookies.push( + Cookies.serialize(name, '', cookieAttributes), + ); + } + } + } + + const response: CloudFrontRequestResult = { + status: '302' , + headers: { + 'location': [{ + key: 'Location', + value: redirectURI, + }], + 'cache-control': [{ + key: 'Cache-Control', + value: 'no-cache, no-store, max-age=0, must-revalidate', + }], + 'pragma': [{ + key: 'Pragma', + value: 'no-cache', + }], + 'set-cookie': responseCookies.map(c => ({ key: 'Set-Cookie', value: c })), + }, + }; + + this._logger.debug({ msg: 'Generated set-cookie response', response }); + + return response; + } + /** * Get redirect to cognito userpool response * @param {CloudFrontRequest} request The original request @@ -266,13 +499,28 @@ export class Authenticator { * @return {CloudFrontRequestResult} Redirect response. */ _getRedirectToCognitoUserPoolResponse(request: CloudFrontRequest, redirectURI: string): CloudFrontRequestResult { + const cfDomain = request.headers.host[0].value; let redirectPath = request.uri; if (request.querystring && request.querystring !== '') { redirectPath += encodeURIComponent('?' + request.querystring); } - const userPoolUrl = `https://${this._userPoolDomain}/authorize?redirect_uri=${redirectURI}&response_type=code&client_id=${this._userPoolAppId}&state=${redirectPath}`; + + let oauthRedirectUri = redirectURI; + if (this._parseAuthPath) { + oauthRedirectUri = `https://${cfDomain}/${this._parseAuthPath}`; + } + + let csrfTokens: CSRFTokens = {}; + let state = redirectPath; + if (this._csrfProtection) { + csrfTokens = generateCSRFTokens(redirectURI, this._csrfProtection?.nonceSigningSecret); + state = csrfTokens.state; + } + + const userPoolUrl = `https://${this._userPoolDomain}/authorize?redirect_uri=${oauthRedirectUri}&response_type=code&client_id=${this._userPoolAppId}&state=${state}`; + this._logger.debug(`Redirecting user to Cognito User Pool URL ${userPoolUrl}`); - return { + const response = { status: '302', headers: { 'location': [{ @@ -289,7 +537,26 @@ export class Authenticator { }], }, }; + + if (this._csrfProtection) { + const cookieAttributes: CookieAttributes = { + expires: new Date(Date.now() + 10 * 60 * 1000), + secure: true, + httpOnly: this._httpOnly, + sameSite: this._sameSite, + path: this._cookiePath, + }; + const cookies = [ + Cookies.serialize(`${this._cookieBase}.${PKCE_COOKIE_NAME_SUFFIX}`, csrfTokens.pkce, cookieAttributes), + Cookies.serialize(`${this._cookieBase}.${NONCE_COOKIE_NAME_SUFFIX}`, csrfTokens.nonce, cookieAttributes), + Cookies.serialize(`${this._cookieBase}.${NONCE_HMAC_COOKIE_NAME_SUFFIX}`, csrfTokens.nonceHmac, cookieAttributes), + ]; + response.headers['set-cookie'] = cookies.map(c => ({ key: 'Set-Cookie', value: c })); + } + + return response; } + /** * Handle Lambda@Edge event: * * if authentication cookie is present and valid: forward the request @@ -310,11 +577,19 @@ export class Authenticator { try { const tokens = this._getTokensFromCookie(request.headers.cookie); this._logger.debug({ msg: 'Verifying token...', tokens }); + if (this._logoutConfiguration && request.uri.startsWith(this._logoutConfiguration.logoutUri)) { + this._logger.info({ msg: 'Revoking tokens', tokens }); + await this._revokeTokens(tokens); + + this._logger.info({ msg: 'Revoked tokens. Clearing cookies', tokens }); + return this._clearCookies(event, tokens); + } try { const user = await this._jwtVerifier.verify(tokens.idToken); this._logger.info({ msg: 'Forwarding request', path: request.uri, user }); return request; } catch (err) { + this._logger.info({ msg: 'Token verification failed', tokens, refreshToken: tokens.refreshToken }); if (tokens.refreshToken) { this._logger.debug({ msg: 'Verifying idToken failed, verifying refresh token instead...', tokens, err }); return await this._fetchTokensFromRefreshToken(redirectURI, tokens.refreshToken) @@ -325,6 +600,10 @@ export class Authenticator { } } catch (err) { this._logger.debug("User isn't authenticated: %s", err); + if (this._logoutConfiguration && request.uri.startsWith(this._logoutConfiguration.logoutUri)) { + this._logger.info({ msg: 'Clearing cookies', path: redirectURI }); + return this._clearCookies(event); + } if (requestParams.code) { return this._fetchTokensFromCode(redirectURI, requestParams.code) .then(tokens => this._getRedirectResponse(tokens, cfDomain, requestParams.state as string)); @@ -333,5 +612,155 @@ export class Authenticator { } } } + + /** + * + * 1. If the token cookies are present in the request, send users to the redirect_uri + * 2. If cookies are not present, initiate the authentication flow + * + * @param event Event that triggers this Lambda function + * @returns Lambda response + */ + async handleSignIn(event: CloudFrontRequestEvent): Promise { + this._logger.debug({ msg: 'Handling Lambda@Edge event', event }); + + const { request } = event.Records[0].cf; + const requestParams = parse(request.querystring); + const redirectURI = requestParams.redirect_uri as string; + + try { + const tokens = this._getTokensFromCookie(request.headers.cookie); + + this._logger.debug({ msg: 'Verifying token...', tokens }); + const user = await this._jwtVerifier.verify(tokens.idToken); + + this._logger.info({ msg: 'Redirecting user to', path: redirectURI, user }); + return { + status: '302', + headers: { + 'location': [{ + key: 'Location', + value: redirectURI, + }], + }, + }; + } catch (err) { + this._logger.debug("User isn't authenticated: %s", err); + return this._getRedirectToCognitoUserPoolResponse(request, redirectURI); + } + } + + /** + * + * Handler that performs OAuth token exchange -- exchanges the authorization + * code obtained from the query parameter from server for tokens -- and sets + * tokens as cookies. This is done after performing CSRF checks, by verifying + * that the information encoded in the state query parameter is related to the + * one stored in the cookies. + * + * @param event Event that triggers this Lambda function + * @returns Lambda response + */ + async handleParseAuth(event: CloudFrontRequestEvent): Promise { + this._logger.debug({ msg: 'Handling Lambda@Edge event', event }); + + const { request } = event.Records[0].cf; + const cfDomain = request.headers.host[0].value; + const requestParams = parse(request.querystring); + + try { + if (!this._parseAuthPath) { + throw new Error('parseAuthPath is not set'); + } + const redirectURI = `https://${cfDomain}/${this._parseAuthPath}`; + if (requestParams.code) { + if (this._csrfProtection) { + this._validateCSRFCookies(request); + } + const tokens = await this._fetchTokensFromCode(redirectURI, requestParams.code); + + const parsedState = JSON.parse( + Buffer.from(urlSafe.parse(requestParams.state), 'base64').toString() + ); + this._logger.debug({msg: 'Parsed state param...', parsedState}); + + return this._getRedirectResponse(tokens, cfDomain, parsedState.redirect_uri); + } else { + this._logger.debug({msg: 'Code param not found', requestParams}); + throw new Error('OAuth code parameter not found'); + } + } catch (err) { + this._logger.debug({msg: 'Unable to exchange code for tokens', err}); + return { + status: '400', + body: `${err}`, + }; + } + } + + /** + * + * Uses the refreshToken present in the cookies to get a new set of tokens + * from the authorization server. After fetching the tokens, they are sent + * back to the client as cookies. + * + * @param event Event that triggers this Lambda function + * @returns Lambda response + */ + async handleRefreshToken(event: CloudFrontRequestEvent): Promise { + this._logger.debug({ msg: 'Handling Lambda@Edge event', event }); + + const { request } = event.Records[0].cf; + const cfDomain = request.headers.host[0].value; + const requestParams = parse(request.querystring); + const redirectURI = requestParams.redirect_uri as string; + + try { + let tokens = this._getTokensFromCookie(request.headers.cookie); + + this._logger.debug({ msg: 'Verifying token...', tokens }); + const user = await this._jwtVerifier.verify(tokens.idToken); + + this._logger.debug({ msg: 'Refreshing tokens...', tokens, user }); + tokens = await this._fetchTokensFromRefreshToken(redirectURI, tokens.refreshToken); + + this._logger.debug({ msg: 'Refreshed tokens...', tokens, user }); + return this._getRedirectResponse(tokens, cfDomain, redirectURI); + } catch (err) { + this._logger.debug("User isn't authenticated: %s", err); + return this._getRedirectToCognitoUserPoolResponse(request, redirectURI); + } + } + + /** + * + * Revokes the refreshToken (which also invalidates the accessToken obtained + * using that refreshToken) and clears the cookies. Even if the revoke + * operation fails, clear cookies based on the cookie names present in the + * request headers. + * + * @param event Event that triggers this Lambda function + * @returns Lambda response + */ + async handleSignOut(event: CloudFrontRequestEvent): Promise { + this._logger.debug({ msg: 'Handling Lambda@Edge event', event }); + + const { request } = event.Records[0].cf; + const requestParams = parse(request.querystring); + const redirectURI = requestParams.redirect_uri as string; + + try { + const tokens = this._getTokensFromCookie(request.headers.cookie); + + this._logger.info({ msg: 'Revoking tokens', tokens }); + await this._revokeTokens(tokens); + + this._logger.info({ msg: 'Revoked tokens. Clearing cookies...', tokens }); + return this._clearCookies(event, tokens); + } catch (err) { + this._logger.info({ msg: 'Unable to revoke tokens. Clearing cookies...', path: redirectURI }); + return this._clearCookies(event); + } + } } diff --git a/src/util/cookie.ts b/src/util/cookie.ts index 8ac522c..aff1ea4 100644 --- a/src/util/cookie.ts +++ b/src/util/cookie.ts @@ -56,6 +56,37 @@ export interface CookieAttributes { secure?: boolean; } +export type CookieType = 'idToken' | 'accessToken' | 'refreshToken'; + +export interface CookieSettings { + /** + * Indicates the maximum lifetime of the cookie. + */ + expirationDays?: number; + + /** + * Indicates the path that must exist in the requested URL for the browser to + * send the Cookie header. + */ + path?: string; + + /** + * Controls whether the cookie can be accessed by JavaScript. + */ + httpOnly?: boolean; + + /** + * Controls whether or not a cookie is sent with cross-site requests + */ + sameSite?: SameSite; +} + +export interface CookieSettingsOverrides { + idToken?: CookieSettings; + accessToken?: CookieSettings; + refreshToken?: CookieSettings; +} + export class Cookies { /** @@ -138,3 +169,13 @@ export class Cookies { str.replace(/(%[\dA-Fa-f]{2})+/g, decodeURIComponent); } + +export function getCookieDomain(cfDomain: string, disableCookieDomain: boolean, customCookieDomain: string | undefined = undefined): string | undefined { + if (disableCookieDomain) { + return undefined; + } + if (customCookieDomain) { + return customCookieDomain; + } + return cfDomain; +} \ No newline at end of file diff --git a/src/util/csrf.ts b/src/util/csrf.ts new file mode 100644 index 0000000..80f6083 --- /dev/null +++ b/src/util/csrf.ts @@ -0,0 +1,107 @@ +import { createHash, createHmac, randomInt } from 'crypto'; + +export interface CSRFTokens { + nonce?: string; + nonceHmac?: string; + pkce?: string; + pkceHash?: string; + state?: string; +} + +export const NONCE_COOKIE_NAME_SUFFIX = 'nonce'; +export const NONCE_HMAC_COOKIE_NAME_SUFFIX = 'nonceHmac'; +export const PKCE_COOKIE_NAME_SUFFIX = 'pkce'; + +export const CSRF_CONFIG = { + secretAllowedCharacters: + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~', + pkceLength: 43, // Should be between 43 and 128 - per spec + nonceLength: 16, + nonceMaxAge: 60 * 60 * 24, +}; + +export function generateNonce() { + const randomString = generateSecret( + CSRF_CONFIG.secretAllowedCharacters, + CSRF_CONFIG.nonceLength + ); + return `${getCurrentTimestampInSeconds()}T${randomString}`; +} + +export function generateCSRFTokens(redirectURI: string, signingSecret: string) { + const nonce = generateNonce(); + const nonceHmac = signNonce(nonce, signingSecret); + + const state = urlSafe.stringify( + Buffer.from( + JSON.stringify({ + nonce, + redirect_uri: redirectURI, + }) + ).toString('base64') + ); + + return { + nonce, + nonceHmac, + state, + ...generatePkceVerifier(), + }; +} + +export function getCurrentTimestampInSeconds(): number { + return (Date.now() / 1000) | 0; +} + +export function generateSecret(allowedCharacters: string, secretLength: number) { + return [...new Array(secretLength)] + .map(() => allowedCharacters[randomInt(0, allowedCharacters.length)]) + .join(''); +} + +export function sign(stringToSign: string, secret: string, signatureLength: number): string { + const digest = createHmac('sha256', secret) + .update(stringToSign) + .digest('base64') + .slice(0, signatureLength); + const signature = urlSafe.stringify(digest); + return signature; +} + +export function signNonce(nonce: string, signingSecret: string): string { + return sign(nonce, signingSecret, CSRF_CONFIG.nonceLength); +} + +export const urlSafe = { + /* + Functions to translate base64-encoded strings, so they can be used: + - in URL's without needing additional encoding + - in OAuth2 PKCE verifier + - in cookies (to be on the safe side, as = + / are in fact valid characters in cookies) + + stringify: + use this on a base64-encoded string to translate = + / into replacement characters + + parse: + use this on a string that was previously urlSafe.stringify'ed to return it to + its prior pure-base64 form. Note that trailing = are not added, but NodeJS does not care + */ + stringify: (b64encodedString) => + b64encodedString.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'), + parse: (b64encodedString) => + b64encodedString.replace(/-/g, '+').replace(/_/g, '/'), +}; + +export function generatePkceVerifier() { + const pkce = generateSecret( + CSRF_CONFIG.secretAllowedCharacters, + CSRF_CONFIG.pkceLength + ); + const verifier = { + pkce, + pkceHash: urlSafe.stringify( + createHash('sha256').update(pkce, 'utf8').digest('base64') + ), + }; + return verifier; +} \ No newline at end of file From 37e68660622d9a05891a9640911f60575034747e Mon Sep 17 00:00:00 2001 From: Vikas Reddy Date: Mon, 12 Jun 2023 13:02:28 -0700 Subject: [PATCH 2/4] Update README --- README.md | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f6574c9..bede11f 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,20 @@ For an explanation of the interactions between CloudFront, Cognito and Lambda@Ed * `httpOnly` *boolean* (Optional) Forbids JavaScript from accessing the cookies, defaults to false (eg: `false`). Note, if this is set to `true`, the cookies will not be accessible to Amplify auth if you are using it client side. * `sameSite` *Strict | Lax | None* (Optional) Allows you to declare if your cookie should be restricted to a first-party or same-site context (eg: `SameSite=None`). * `cookiePath` *string* (Optional) Sets Path attribute in cookies + * `cookieDomain` *string* (Optional) Sets the domain name used for the token cookies + * `cookieSettingsOverrides` *object* (Optional) Cookie settings overrides for different token cookies -- idToken, accessToken and refreshToken + * `idToken` *CookieSettings* (Optional) Setting overrides to use for idToken + * `expirationDays` *number* (Optional) Number of day to set cookies expiration date, default to 365 days (eg: `365`). It's recommended to set this value to match `refreshTokenValidity` parameter of the pool client. + * `path` *string* (Optional) Sets Path attribute in cookies + * `httpOnly` *boolean* (Optional) Forbids JavaScript from accessing the cookies, defaults to false (eg: `false`). Note, if this is set to `true`, the cookies will not be accessible to Amplify auth if you are using it client side. + * `sameSite` *Strict | Lax | None* (Optional) Allows you to declare if your cookie should be restricted to a first-party or same-site context (eg: `SameSite=None`). + * `accessToken` *CookieSettings* (Optional) Setting overrides to use for accessToken + * `refreshToken` *CookieSettings* (Optional) Setting overrides to use for refreshToken + * `logoutConfiguration` *object* (Optional) Enables logout functionality + * `logoutUri` *string* URI path, which when matched with request, logs user out by revoking tokens and clearing cookies + * `parseAuthPath` *string* (Optional) URI path to use for the parse auth handler, when the library is used in an authentication gateway setup + * `csrfProtection` *object* (Optional) Enables CSRF protection + * `nonceSigningSecret` *string* Secret used for signing nonce cookies * `logLevel` *string* (Optional) Logging level. Default: `'silent'`. One of `'fatal'`, `'error'`, `'warn'`, `'info'`, `'debug'`, `'trace'` or `'silent'`. *This is the class constructor.* @@ -72,11 +86,29 @@ For an explanation of the interactions between CloudFront, Cognito and Lambda@Ed Use it as your Lambda Handler. It will authenticate each query. -``` +```js const authenticator = new Authenticator( ... ); exports.handler = async (request) => authenticator.handle(request); ``` +### Authentication Gateway Setup +This library can also be used in an authentication gateway setup. If you have a frontend client application that uses AWS Cognito for authentication, it fetches and stores authentication tokens in the browser. Depending on where the tokens are stored in the browser (localStorage, cookies, sessionStorage), they may susceptible to token theft. In order to mitigate this risk, a set of Lambda@Edge handlers can be deployed that act as an authentication gateway intermediary between frontend and Cognito, whose job is to fetch and store tokens in HttpOnly cookies. + +Handlers +1. `handleSignIn` (Can be mapped to `/signIn` in Cloudfront setup): Redirect users to Cognito's authorize endpoint after replacing redirect uri with its own -- for instance, `/parseAuth`. +1. `handleParseAuth` (Can be mapped to `/parseAuth`): Exchange Cognito's OAuth code for tokens. Store tokens in browser as HttpOnly cookies +1. `handleRefreshToken` (Can be mapped to `/refreshToken`): Refresh idToken and accessToken using refreshToken +1. `handleSignOut` (Can be mapped to `/signOut`): Revoke tokens, clear cookies and redirect user to the URL supplied + +```js +// signIn Lambda Handler +const authenticator = new Authenticator( ... ); +exports.handler = async (request) => authenticator.handleSignIn(request); + +// Similar setup for parseAuth, refreshToken and signOut handlers +``` + + ### Getting Help The best way to interact with our team is through GitHub. You can [open an issue](https://github.com/awslabs/cognito-at-edge/issues/new/choose) From 9d6e36fe9b455dbfd6703bcfe2d8a35376f7ff80 Mon Sep 17 00:00:00 2001 From: Vikas Reddy Date: Fri, 30 Jun 2023 15:11:01 -0700 Subject: [PATCH 3/4] Addressed PR suggestions 1. `handle` will now use redirect uri from decoded state param when csrfProtection is enabled 2. `logoutConfiguration` now requires `logoutRedirectUri` param 3. Updated explanation of authentication gateway setup 4. `_clearCookies` will now use redirectURI from `logoutRedirectUri`, defaulting to one from url query param and then to cfDomain --- README.md | 3 ++- __tests__/index.test.ts | 55 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 44 ++++++++++++++++++++++++--------- src/util/csrf.ts | 2 +- 4 files changed, 90 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index bede11f..bf13e2b 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ For an explanation of the interactions between CloudFront, Cognito and Lambda@Ed * `refreshToken` *CookieSettings* (Optional) Setting overrides to use for refreshToken * `logoutConfiguration` *object* (Optional) Enables logout functionality * `logoutUri` *string* URI path, which when matched with request, logs user out by revoking tokens and clearing cookies + * `logoutRedirectUri` *string* The URI to which the user is redirected to after logging them out * `parseAuthPath` *string* (Optional) URI path to use for the parse auth handler, when the library is used in an authentication gateway setup * `csrfProtection` *object* (Optional) Enables CSRF protection * `nonceSigningSecret` *string* Secret used for signing nonce cookies @@ -92,7 +93,7 @@ exports.handler = async (request) => authenticator.handle(request); ``` ### Authentication Gateway Setup -This library can also be used in an authentication gateway setup. If you have a frontend client application that uses AWS Cognito for authentication, it fetches and stores authentication tokens in the browser. Depending on where the tokens are stored in the browser (localStorage, cookies, sessionStorage), they may susceptible to token theft. In order to mitigate this risk, a set of Lambda@Edge handlers can be deployed that act as an authentication gateway intermediary between frontend and Cognito, whose job is to fetch and store tokens in HttpOnly cookies. +This library can also be used in an authentication gateway setup. If you have a frontend client application that uses AWS Cognito for authentication, it fetches and stores authentication tokens in the browser. Depending on where the tokens are stored in the browser (localStorage, cookies, sessionStorage), they may susceptible to token theft and XSS (Cross-Site Scripting). In order to mitigate this risk, a set of Lambda@Edge handlers can be deployed on a CloudFront distribution which act as an authentication gateway intermediary between the frontend app and Cognito. These handlers will authenticate and fetch tokens on the frontend's behalf and set them as [Secure; HttpOnly](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies) tokens inside the browser, thereby restricting access to other scripts in the app. Handlers 1. `handleSignIn` (Can be mapped to `/signIn` in Cloudfront setup): Redirect users to Cognito's authorize endpoint after replacing redirect uri with its own -- for instance, `/parseAuth`. diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index ca4b92b..51a6484 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -477,6 +477,41 @@ describe('private functions', () => { expect(response.headers['set-cookie']).toBeDefined(); expect(response.headers['set-cookie'].length).toBe(numCookiesToBeCleared); }); + + it('should clear cookies and redirect to logoutRedirectUri', async () => { + jest.spyOn(authenticator._jwtVerifier, 'verify'); + authenticator._logoutConfiguration = { + logoutUri: '/logout', + logoutRedirectUri: 'https://foobar.com', + }; + authenticator._jwtVerifier.cacheJwks(jwksData); + authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({})); + const tokens = {idToken: tokenData.id_token, refreshToken: tokenData.refresh_token}; + const response = await (authenticator as any)._clearCookies(getCloudfrontRequest(), tokens); + expect(response).toEqual(expect.objectContaining({ status: '302' })); + expect(response.headers['location']?.[0]?.value).toEqual('https://foobar.com'); + }); + + it('should clear cookies and redirect to redirect_uri query param', async () => { + jest.spyOn(authenticator._jwtVerifier, 'verify'); + authenticator._jwtVerifier.cacheJwks(jwksData); + authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({})); + const request = getCloudfrontRequest(); + request.Records[0].cf.request.querystring = 'redirect_uri=https://foobar.com'; + const response = await (authenticator as any)._clearCookies(request); + expect(response).toEqual(expect.objectContaining({ status: '302' })); + expect(response.headers['location']?.[0]?.value).toEqual('https://foobar.com'); + }); + + it('should clear cookies and redirect to cf domain', async () => { + jest.spyOn(authenticator._jwtVerifier, 'verify'); + authenticator._jwtVerifier.cacheJwks(jwksData); + authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({})); + const request = getCloudfrontRequest(); + const response = await (authenticator as any)._clearCookies(request); + expect(response).toEqual(expect.objectContaining({ status: '302' })); + expect(response.headers['location']?.[0]?.value).toEqual('https://d111111abcdef8.cloudfront.net'); + }); }); }); @@ -687,6 +722,26 @@ describe('handle', () => { }); }); + test('should fetch and set token if code is present and when csrfProtection is enabled', () => { + authenticator._jwtVerifier.verify.mockImplementationOnce(async () => { throw new Error(); }); + authenticator._fetchTokensFromCode.mockResolvedValueOnce(tokenData); + authenticator._getRedirectResponse.mockReturnValueOnce({ response: 'toto' }); + authenticator._csrfProtection = { + nonceSigningSecret: 'foobar', + }; + const encodedState = Buffer.from( + JSON.stringify({ redirect_uri: '/lol' }) + ).toString('base64'); + const request = getCloudfrontRequest(); + request.Records[0].cf.request.querystring = `code=54fe5f4e&state=${encodedState}`; + return expect(authenticator.handle(request)).resolves.toEqual({ response: 'toto' }) + .then(() => { + expect(authenticator._jwtVerifier.verify).toHaveBeenCalled(); + expect(authenticator._fetchTokensFromCode).toHaveBeenCalled(); + expect(authenticator._getRedirectResponse).toHaveBeenCalledWith(tokenData, 'd111111abcdef8.cloudfront.net', '/lol'); + }); + }); + test('should redirect to auth domain if unauthenticated and no code', () => { authenticator._jwtVerifier.verify.mockImplementationOnce(async () => { throw new Error();}); return expect(authenticator.handle(getCloudfrontRequest())).resolves.toEqual( diff --git a/src/index.ts b/src/index.ts index a9ea1e8..fd45af6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,6 +29,7 @@ interface AuthenticatorParams { interface LogoutConfiguration { logoutUri: string; + logoutRedirectUri: string; } interface Tokens { @@ -397,6 +398,25 @@ export class Authenticator { return csrfTokens; } + /** + * Extracts the redirect uri from the state param. When CSRF protection is + * enabled, redirect uri is encoded inside state along with other data. So, it + * needs to be base64 decoded. When CSRF is not enabled, state can be used + * directly. + * @param {string} state + * @returns {string} + */ + _getRedirectUriFromState(state: string): string { + if (this._csrfProtection) { + const parsedState = JSON.parse( + Buffer.from(urlSafe.parse(state), 'base64').toString() + ); + this._logger.debug({ msg: 'Parsed state param to extract redirect uri', parsedState }); + return parsedState.redirect_uri; + } + return state; + } + async _revokeTokens(tokens: Tokens) { const authorization = this._getAuthorization(); const revokeRequest = { @@ -427,7 +447,9 @@ export class Authenticator { const { request } = event.Records[0].cf; const cfDomain = request.headers.host[0].value; const requestParams = parse(request.querystring); - const redirectURI = requestParams.redirect_uri as string; + const redirectURI = this._logoutConfiguration?.logoutRedirectUri || + requestParams.redirect_uri as string || + `https://${cfDomain}`; const cookieDomain = getCookieDomain(cfDomain, this._disableCookieDomain, this._cookieDomain); const cookieAttributes: CookieAttributes = { @@ -576,7 +598,6 @@ export class Authenticator { try { const tokens = this._getTokensFromCookie(request.headers.cookie); - this._logger.debug({ msg: 'Verifying token...', tokens }); if (this._logoutConfiguration && request.uri.startsWith(this._logoutConfiguration.logoutUri)) { this._logger.info({ msg: 'Revoking tokens', tokens }); await this._revokeTokens(tokens); @@ -585,6 +606,7 @@ export class Authenticator { return this._clearCookies(event, tokens); } try { + this._logger.debug({ msg: 'Verifying token...', tokens }); const user = await this._jwtVerifier.verify(tokens.idToken); this._logger.info({ msg: 'Forwarding request', path: request.uri, user }); return request; @@ -606,7 +628,7 @@ export class Authenticator { } if (requestParams.code) { return this._fetchTokensFromCode(redirectURI, requestParams.code) - .then(tokens => this._getRedirectResponse(tokens, cfDomain, requestParams.state as string)); + .then(tokens => this._getRedirectResponse(tokens, cfDomain, this._getRedirectUriFromState(requestParams.state as string))); } else { return this._getRedirectToCognitoUserPoolResponse(request, redirectURI); } @@ -626,7 +648,8 @@ export class Authenticator { const { request } = event.Records[0].cf; const requestParams = parse(request.querystring); - const redirectURI = requestParams.redirect_uri as string; + const cfDomain = request.headers.host[0].value; + const redirectURI = requestParams.redirect_uri as string || `https://${cfDomain}`; try { const tokens = this._getTokensFromCookie(request.headers.cookie); @@ -678,13 +701,9 @@ export class Authenticator { this._validateCSRFCookies(request); } const tokens = await this._fetchTokensFromCode(redirectURI, requestParams.code); + const location = this._getRedirectUriFromState(requestParams.state as string); - const parsedState = JSON.parse( - Buffer.from(urlSafe.parse(requestParams.state), 'base64').toString() - ); - this._logger.debug({msg: 'Parsed state param...', parsedState}); - - return this._getRedirectResponse(tokens, cfDomain, parsedState.redirect_uri); + return this._getRedirectResponse(tokens, cfDomain, location); } else { this._logger.debug({msg: 'Code param not found', requestParams}); throw new Error('OAuth code parameter not found'); @@ -713,7 +732,7 @@ export class Authenticator { const { request } = event.Records[0].cf; const cfDomain = request.headers.host[0].value; const requestParams = parse(request.querystring); - const redirectURI = requestParams.redirect_uri as string; + const redirectURI = requestParams.redirect_uri as string || `https://${cfDomain}`; try { let tokens = this._getTokensFromCookie(request.headers.cookie); @@ -747,7 +766,8 @@ export class Authenticator { const { request } = event.Records[0].cf; const requestParams = parse(request.querystring); - const redirectURI = requestParams.redirect_uri as string; + const cfDomain = request.headers.host[0].value; + const redirectURI = requestParams.redirect_uri as string || `https://${cfDomain}`; try { const tokens = this._getTokensFromCookie(request.headers.cookie); diff --git a/src/util/csrf.ts b/src/util/csrf.ts index 80f6083..82bebe1 100644 --- a/src/util/csrf.ts +++ b/src/util/csrf.ts @@ -50,7 +50,7 @@ export function generateCSRFTokens(redirectURI: string, signingSecret: string) { } export function getCurrentTimestampInSeconds(): number { - return (Date.now() / 1000) | 0; + return (Date.now() / 1000) || 0; } export function generateSecret(allowedCharacters: string, secretLength: number) { From e47fe297000deb6389f85b83c8f4635eb13e90cc Mon Sep 17 00:00:00 2001 From: Vikas Reddy Date: Thu, 6 Jul 2023 20:57:34 -0700 Subject: [PATCH 4/4] Moved logger statement after logout in catch block --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index fd45af6..f1fad2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -621,11 +621,11 @@ export class Authenticator { } } } catch (err) { - this._logger.debug("User isn't authenticated: %s", err); if (this._logoutConfiguration && request.uri.startsWith(this._logoutConfiguration.logoutUri)) { this._logger.info({ msg: 'Clearing cookies', path: redirectURI }); return this._clearCookies(event); } + this._logger.debug("User isn't authenticated: %s", err); if (requestParams.code) { return this._fetchTokensFromCode(redirectURI, requestParams.code) .then(tokens => this._getRedirectResponse(tokens, cfDomain, this._getRedirectUriFromState(requestParams.state as string)));