diff --git a/README.md b/README.md index 640cf03..f6574c9 100644 --- a/README.md +++ b/README.md @@ -56,10 +56,11 @@ For an explanation of the interactions between CloudFront, Cognito and Lambda@Ed * `userPoolAppId` *string* Cognito UserPool Application ID (eg: `63gcbm2jmskokurt5ku9fhejc6`) * `userPoolAppSecret` *string* (Optional) Cognito UserPool Application Secret (eg: `oh470px2i0uvy4i2ha6sju0vxe4ata9ol3m63ufhs2t8yytwjn7p`) * `userPoolDomain` *string* Cognito UserPool domain (eg: `your-domain.auth.us-east-1.amazoncognito.com`) - * `cookieExpirationDays` *number* (Optional) Number of day to set cookies expiration date, default to 365 days (eg: `365`) + * `cookieExpirationDays` *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. * `disableCookieDomain` *boolean* (Optional) Sets domain attribute in cookies, defaults to false (eg: `false`) * `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 * `logLevel` *string* (Optional) Logging level. Default: `'silent'`. One of `'fatal'`, `'error'`, `'warn'`, `'info'`, `'debug'`, `'trace'` or `'silent'`. *This is the class constructor.* diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 47893eb..66b71fa 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -15,7 +15,7 @@ global.Date = class extends Date { }; describe('private functions', () => { - let authenticator; + let authenticator : Authenticator; beforeEach(() => { authenticator = new Authenticator({ @@ -35,7 +35,7 @@ describe('private functions', () => { return authenticator._fetchTokensFromCode('htt://redirect', 'AUTH_CODE') .then(res => { - expect(res).toEqual(tokenData); + expect(res).toMatchObject({refreshToken: tokenData.refresh_token, accessToken: tokenData.access_token, idToken: tokenData.id_token}); }); }); @@ -51,7 +51,7 @@ describe('private functions', () => { jest.spyOn(authenticator._jwtVerifier, 'verify'); authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({ token_use: 'id', 'cognito:username': username })); - const response = await authenticator._getRedirectResponse(tokenData, domain, path); + const response = await authenticator._getRedirectResponse({refreshToken: tokenData.refresh_token, accessToken: tokenData.access_token, idToken: tokenData.id_token}, domain, path); expect(response).toMatchObject({ status: '302', headers: { @@ -61,7 +61,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`}, {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.refreshToken=${tokenData.refresh_token}; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure`}, {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.tokenScopesString=phone%20email%20profile%20openid%20aws.cognito.signin.user.admin; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure`}, @@ -89,7 +89,7 @@ describe('private functions', () => { jest.spyOn(authenticatorWithNoCookieDomain._jwtVerifier, 'verify'); authenticatorWithNoCookieDomain._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({ token_use: 'id', 'cognito:username': username })); - const response = await authenticatorWithNoCookieDomain._getRedirectResponse(tokenData, domain, path); + const response = await authenticatorWithNoCookieDomain._getRedirectResponse({'accessToken': tokenData.access_token, 'idToken': tokenData.id_token, 'refreshToken': tokenData.refresh_token}, domain, path); expect(response).toMatchObject({ status: '302', headers: { @@ -99,7 +99,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}; Expires=${DATE.toUTCString()}; Secure`}, {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.refreshToken=${tokenData.refresh_token}; Expires=${DATE.toUTCString()}; Secure`}, {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.tokenScopesString=phone%20email%20profile%20openid%20aws.cognito.signin.user.admin; Expires=${DATE.toUTCString()}; Secure`}, @@ -128,7 +128,7 @@ describe('private functions', () => { jest.spyOn(authenticatorWithHttpOnly._jwtVerifier, 'verify'); authenticatorWithHttpOnly._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({ token_use: 'id', 'cognito:username': username })); - const response = await authenticatorWithHttpOnly._getRedirectResponse(tokenData, domain, path); + const response = await authenticatorWithHttpOnly._getRedirectResponse({ accessToken: tokenData.access_token, idToken: tokenData.id_token, refreshToken: tokenData.refresh_token }, domain, path); expect(response).toMatchObject({ status: '302', headers: { @@ -138,7 +138,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`}, {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.refreshToken=${tokenData.refresh_token}; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure; HttpOnly`}, {key: 'Set-Cookie', value: `CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${username}.tokenScopesString=phone%20email%20profile%20openid%20aws.cognito.signin.user.admin; Domain=${domain}; Expires=${DATE.toUTCString()}; Secure; HttpOnly`}, @@ -168,7 +168,7 @@ describe('private functions', () => { jest.spyOn(authenticatorWithSameSite._jwtVerifier, 'verify'); authenticatorWithSameSite._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({ token_use: 'id', 'cognito:username': username })); - const response = await authenticatorWithSameSite._getRedirectResponse(tokenData, domain, path); + const response = await authenticatorWithSameSite._getRedirectResponse({ accessToken: tokenData.access_token, idToken: tokenData.id_token, refreshToken: tokenData.refresh_token }, domain, path); expect(response).toMatchObject({ status: '302', headers: { @@ -188,10 +188,50 @@ describe('private functions', () => { expect(authenticatorWithSameSite._jwtVerifier.verify).toHaveBeenCalled(); }); + test('should set Path on cookies', 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, + }); + 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`}, + ])); + expect(authenticatorWithPath._jwtVerifier.verify).toHaveBeenCalled(); + }); + test('should getIdTokenFromCookie', () => { const appClientName = 'toto,./;;..-_lol123'; expect( - authenticator._getIdTokenFromCookie([{ + authenticator._getTokensFromCookie([{ key: 'Cookie', value: [ Cookies.serialize(`CognitoIdentityServiceProvider.5uka3k8840tap1g1i1617jh8pi.${appClientName}.idToken`, 'wrong'), @@ -200,25 +240,33 @@ describe('private functions', () => { Cookies.serialize(`CognitoIdentityServiceProvider.5ukasw8840tap1g1i1617jh8pi.${appClientName}.idToken`, 'wrong'), ].join('; '), }]), - ).toBe(tokenData.id_token); + ).toMatchObject({idToken: tokenData.id_token}); expect( - authenticator._getIdTokenFromCookie([{ + authenticator._getTokensFromCookie([{ key: 'Cookie', value: [ - Cookies.serialize(`CognitoIdentityServiceProvider.5uka3k8840tap1g1i1617jh8pi.${appClientName}.accessToken`, 'someValue'), + Cookies.serialize(`CognitoIdentityServiceProvider.5uka3k8840tap1g1i1617jh8pi.${appClientName}.accessToken`, tokenData.access_token), Cookies.serialize(`CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${appClientName}.idToken`, tokenData.id_token), ].join('; '), }]), - ).toBe(tokenData.id_token); - }); + ).toMatchObject({ idToken: tokenData.id_token}); - test('should getIdTokenFromCookie throw on cookies', () => { - expect(() => authenticator._getIdTokenFromCookie([])).toThrow('idToken'); + + expect( + authenticator._getTokensFromCookie([{ + key: 'Cookie', + value: [ + Cookies.serialize(`CognitoIdentityServiceProvider.5uka3k8840tap1g1i1617jh8pi.${appClientName}.accessToken`, tokenData.access_token), + Cookies.serialize(`CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${appClientName}.idToken`, tokenData.id_token), + Cookies.serialize(`CognitoIdentityServiceProvider.123456789qwertyuiop987abcd.${appClientName}.refreshToken`, tokenData.refresh_token), + ].join('; '), + }]), + ).toMatchObject({ idToken: tokenData.id_token, refreshToken: tokenData.refresh_token}); }); - test('should getIdTokenFromCookie throw on no cookies', () => { - expect(() => authenticator._getIdTokenFromCookie()).toThrow('Cookies weren\'t present in the request'); + test('should getTokensFromCookie throw on cookies', () => { + expect(() => authenticator._getTokensFromCookie([])).toThrow('idToken'); }); }); @@ -256,6 +304,11 @@ describe('createAuthenticator', () => { expect(typeof new Authenticator(params)).toBe('object'); }); + test('should create authenticator without cookiePath', () => { + delete params.cookiePath; + expect(typeof new Authenticator(params)).toBe('object'); + }); + test('should create authenticator with unvalidated samesite', () => { params.sameSite = '123'; expect(() => new Authenticator(params)).toThrow('Expected params'); @@ -322,6 +375,11 @@ describe('createAuthenticator', () => { params.httpOnly = '123'; expect(() => new Authenticator(params)).toThrow('httpOnly'); }); + + test('should fail when creating authenticator with invalid cookiePath', () => { + params.cookiePath = 123; + expect(() => new Authenticator(params)).toThrow('cookiePath'); + }); }); describe('handle', () => { @@ -337,9 +395,11 @@ describe('handle', () => { logLevel: 'debug', }); authenticator._jwtVerifier.cacheJwks(jwksData); - jest.spyOn(authenticator, '_getIdTokenFromCookie'); + jest.spyOn(authenticator, '_getTokensFromCookie'); jest.spyOn(authenticator, '_fetchTokensFromCode'); + jest.spyOn(authenticator, '_fetchTokensFromRefreshToken'); jest.spyOn(authenticator, '_getRedirectResponse'); + jest.spyOn(authenticator, '_getRedirectToCognitoUserPoolResponse'); jest.spyOn(authenticator._jwtVerifier, 'verify'); }); @@ -347,8 +407,38 @@ describe('handle', () => { authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.resolve({})); return expect(authenticator.handle(getCloudfrontRequest())).resolves.toEqual(getCloudfrontRequest().Records[0].cf.request) .then(() => { - expect(authenticator._getIdTokenFromCookie).toHaveBeenCalled(); + expect(authenticator._getTokensFromCookie).toHaveBeenCalled(); + expect(authenticator._jwtVerifier.verify).toHaveBeenCalled(); + }); + }); + + test('should fetch with refresh token if available', () => { + authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.reject({})); + authenticator._getTokensFromCookie.mockReturnValueOnce({refreshToken: tokenData.refresh_token}); + authenticator._fetchTokensFromRefreshToken.mockResolvedValueOnce(tokenData); + authenticator._getRedirectResponse.mockReturnValueOnce({ response: 'toto' }); + const request = getCloudfrontRequest(); + request.Records[0].cf.request.querystring = 'code=54fe5f4e&state=/lol'; + return expect(authenticator.handle(request)).resolves.toEqual({ response: 'toto' }) + .then(() => { + expect(authenticator._getTokensFromCookie).toHaveBeenCalled(); + expect(authenticator._jwtVerifier.verify).toHaveBeenCalled(); + expect(authenticator._fetchTokensFromRefreshToken).toHaveBeenCalled(); + expect(authenticator._getRedirectResponse).toHaveBeenCalledWith(tokenData, 'd111111abcdef8.cloudfront.net', '/lol'); + }); + }); + + test('should redirect to cognito if refresh token is invalid', () => { + authenticator._jwtVerifier.verify.mockReturnValueOnce(Promise.reject({})); + authenticator._getTokensFromCookie.mockReturnValueOnce({refreshToken: tokenData.refresh_token}); + authenticator._fetchTokensFromRefreshToken.mockReturnValueOnce(Promise.reject({})); + authenticator._getRedirectToCognitoUserPoolResponse.mockReturnValueOnce({ response: 'toto' }); + const request = getCloudfrontRequest(); + return expect(authenticator.handle(request)).resolves.toEqual({ response: 'toto' }) + .then(() => { + expect(authenticator._getTokensFromCookie).toHaveBeenCalled(); expect(authenticator._jwtVerifier.verify).toHaveBeenCalled(); + expect(authenticator._fetchTokensFromRefreshToken).toHaveBeenCalled(); }); }); diff --git a/src/index.ts b/src/index.ts index f0b7d10..6fbe74c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,10 @@ +import { CognitoJwtVerifier } from 'aws-jwt-verify'; +import { CloudFrontRequest, CloudFrontRequestEvent, CloudFrontRequestResult } from 'aws-lambda'; import axios from 'axios'; -import { parse, stringify } from 'querystring'; import pino from 'pino'; -import { CognitoJwtVerifier } from 'aws-jwt-verify'; -import { CloudFrontRequestEvent, CloudFrontRequestResult } from 'aws-lambda'; +import { parse, stringify } from 'querystring'; import { CookieAttributes, Cookies, SameSite, SAME_SITE_VALUES } from './util/cookie'; - - interface AuthenticatorParams { region: string; userPoolId: string; @@ -18,6 +16,13 @@ interface AuthenticatorParams { httpOnly?: boolean; sameSite?: SameSite; logLevel?: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'; + cookiePath?: string; +} + +interface Tokens { + accessToken?: string; + idToken?: string; + refreshToken?: string; } export class Authenticator { @@ -31,6 +36,7 @@ export class Authenticator { _httpOnly: boolean; _sameSite?: SameSite; _cookieBase: string; + _cookiePath?: string; _logger; _jwtVerifier; @@ -46,6 +52,7 @@ export class Authenticator { this._httpOnly = ('httpOnly' in params && params.httpOnly === true); this._sameSite = params.sameSite; this._cookieBase = `CognitoIdentityServiceProvider.${params.userPoolAppId}`; + this._cookiePath = params.cookiePath; this._logger = pino({ level: params.logLevel || 'silent', // Default to silent base: null, //Remove pid, hostname and name logging as not usefull for Lambda @@ -83,6 +90,9 @@ export class Authenticator { if ('sameSite' in params && !SAME_SITE_VALUES.includes(params.sameSite)) { throw new Error('Expected params.sameSite to be a Strict || Lax || None'); } + if ('cookiePath' in params && typeof params.cookiePath !== 'string') { + throw new Error('Expected params.cookiePath to be a string'); + } } /** @@ -91,7 +101,7 @@ export class Authenticator { * @param {String} code Authorization code. * @return {Promise} Authenticated user tokens. */ - _fetchTokensFromCode(redirectURI, code) { + _fetchTokensFromCode(redirectURI, code): Promise { const authorization = this._userPoolAppSecret && Buffer.from(`${this._userPoolAppId}:${this._userPoolAppSecret}`).toString('base64'); const request = { url: `https://${this._userPoolDomain}/oauth2/token`, @@ -111,7 +121,11 @@ export class Authenticator { return axios.request(request) .then(resp => { this._logger.debug({ msg: 'Fetched tokens', tokens: resp.data }); - return resp.data; + return { + idToken: resp.data.id_token, + accessToken: resp.data.access_token, + refreshToken: resp.data.refresh_token, + }; }) .catch(err => { this._logger.error({ msg: 'Unable to fetch tokens from grant code', request, code }); @@ -119,16 +133,53 @@ export class Authenticator { }); } + /** + * Fetch accessTokens from refreshToken. + * @param {String} redirectURI Redirection URI. + * @param {String} refreshToken Refresh token. + * @return {Promise} Refreshed user tokens. + */ + _fetchTokensFromRefreshToken(redirectURI: string, refreshToken: string): Promise { + const authorization = this._userPoolAppSecret && Buffer.from(`${this._userPoolAppId}:${this._userPoolAppSecret}`).toString('base64'); + const request = { + url: `https://${this._userPoolDomain}/oauth2/token`, + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(authorization && {'Authorization': `Basic ${authorization}`}), + }, + data: stringify({ + client_id: this._userPoolAppId, + refresh_token: refreshToken, + grant_type: 'refresh_token', + redirect_uri: redirectURI, + }), + } as const; + this._logger.debug({ msg: 'Fetching tokens from refreshToken...', request, refreshToken }); + return axios.request(request) + .then(resp => { + this._logger.debug({ msg: 'Fetched tokens', tokens: resp.data }); + return { + idToken: resp.data.id_token, + accessToken: resp.data.access_token, + }; + }) + .catch(err => { + this._logger.error({ msg: 'Unable to fetch tokens from refreshToken', request, refreshToken }); + throw err; + }); + } + /** * Create a Lambda@Edge redirection response to set the tokens on the user's browser cookies. * @param {Object} tokens Cognito User Pool tokens. * @param {String} domain Website domain. * @param {String} location Path to redirection. - * @return {Object} Lambda@Edge response. + * @return Lambda@Edge response. */ - async _getRedirectResponse(tokens, domain, location) { - const decoded = await this._jwtVerifier.verify(tokens.id_token); - const username = decoded['cognito:username']; + async _getRedirectResponse(tokens: Tokens, domain: string, location: string): Promise { + const decoded = await this._jwtVerifier.verify(tokens.idToken); + const username = decoded['cognito:username'] as string; const usernameBase = `${this._cookieBase}.${username}`; const cookieAttributes: CookieAttributes = { domain: this._disableCookieDomain ? undefined : domain, @@ -136,16 +187,17 @@ export class Authenticator { secure: true, httpOnly: this._httpOnly, sameSite: this._sameSite, + path: this._cookiePath, }; const cookies = [ - Cookies.serialize(`${usernameBase}.accessToken`, tokens.access_token, cookieAttributes), - Cookies.serialize(`${usernameBase}.idToken`, tokens.id_token, cookieAttributes), - Cookies.serialize(`${usernameBase}.refreshToken`, tokens.refresh_token, cookieAttributes), + 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}.tokenScopesString`, 'phone email profile openid aws.cognito.signin.user.admin', cookieAttributes), Cookies.serialize(`${this._cookieBase}.LastAuthUser`, username, cookieAttributes), ]; - const response = { + const response: CloudFrontRequestResult = { status: '302' , headers: { 'location': [{ @@ -172,34 +224,76 @@ export class Authenticator { /** * Extract value of the authentication token from the request cookies. * @param {Array} cookieHeaders 'Cookie' request headers. - * @return {String} Extracted access token. Throw if not found. + * @return {Tokens} Extracted id token or access token. Null if not found. */ - _getIdTokenFromCookie(cookieHeaders: Array<{ key?: string | undefined, value: string }> | undefined) { + _getTokensFromCookie(cookieHeaders: Array<{ key?: string | undefined, value: string }> | undefined): Tokens { 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 authentication token from request cookie', cookieHeaders }); + const cookies = cookieHeaders.flatMap(h => Cookies.parse(h.value)); + const tokenCookieNamePrefix = `${this._cookieBase}.`; - const tokenCookieNamePostfix = '.idToken'; + const idTokenCookieNamePostfix = '.idToken'; + const refreshTokenCookieNamePostfix = '.refreshToken'; - const cookies = cookieHeaders.flatMap(h => Cookies.parse(h.value)); - const token = cookies.find(c => c.name.startsWith(tokenCookieNamePrefix) && c.name.endsWith(tokenCookieNamePostfix))?.value; + const tokens: Tokens = {}; + for (const {name, value} of cookies){ + if (name.startsWith(tokenCookieNamePrefix) && name.endsWith(idTokenCookieNamePostfix)) { + tokens.idToken = value; + } + if (name.startsWith(tokenCookieNamePrefix) && name.endsWith(refreshTokenCookieNamePostfix)) { + tokens.refreshToken = value; + } + } - if (!token) { - this._logger.debug("idToken wasn't present in request cookies"); - throw new Error("idToken isn't present in the request cookies"); + if (!tokens.idToken && !tokens.refreshToken) { + this._logger.debug('Neither idToken, nor refreshToken was present in request cookies'); + throw new Error('Neither idToken, nor refreshToken was present in request cookies'); } - this._logger.debug({ msg: 'Found idToken in cookie', token }); - return token; + this._logger.debug({ msg: 'Found tokens in cookie', tokens }); + return tokens; } + /** + * Get redirect to cognito userpool response + * @param {CloudFrontRequest} request The original request + * @param {string} redirectURI Redirection URI. + * @return {CloudFrontRequestResult} Redirect response. + */ + _getRedirectToCognitoUserPoolResponse(request: CloudFrontRequest, redirectURI: string): CloudFrontRequestResult { + 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}`; + this._logger.debug(`Redirecting user to Cognito User Pool URL ${userPoolUrl}`); + return { + status: '302', + headers: { + 'location': [{ + key: 'Location', + value: userPoolUrl, + }], + 'cache-control': [{ + key: 'Cache-Control', + value: 'no-cache, no-store, max-age=0, must-revalidate', + }], + 'pragma': [{ + key: 'Pragma', + value: 'no-cache', + }], + }, + }; + } /** * Handle Lambda@Edge event: * * if authentication cookie is present and valid: forward the request + * * if authentication cookie is invalid, but refresh token is present: set cookies with refreshed tokens * * if ?code= is present: set cookies with new tokens * * else redirect to the Cognito UserPool to authenticate the user * @param {Object} event Lambda@Edge event. @@ -214,41 +308,30 @@ export class Authenticator { const redirectURI = `https://${cfDomain}`; try { - const token = this._getIdTokenFromCookie(request.headers.cookie); - this._logger.debug({ msg: 'Verifying token...', token }); - const user = await this._jwtVerifier.verify(token); - this._logger.info({ msg: 'Forwarding request', path: request.uri, user }); - return request; + const tokens = this._getTokensFromCookie(request.headers.cookie); + this._logger.debug({ msg: 'Verifying token...', tokens }); + try { + const user = await this._jwtVerifier.verify(tokens.idToken); + this._logger.info({ msg: 'Forwarding request', path: request.uri, user }); + return request; + } catch (err) { + if (tokens.refreshToken) { + this._logger.debug({ msg: 'Verifying idToken failed, verifying refresh token instead...', tokens, err }); + return await this._fetchTokensFromRefreshToken(redirectURI, tokens.refreshToken) + .then(tokens => this._getRedirectResponse(tokens, cfDomain, request.uri)); + } else { + throw err; + } + } } catch (err) { this._logger.debug("User isn't authenticated: %s", err); if (requestParams.code) { return this._fetchTokensFromCode(redirectURI, requestParams.code) - .then(tokens => this._getRedirectResponse(tokens, cfDomain, requestParams.state)); + .then(tokens => this._getRedirectResponse(tokens, cfDomain, requestParams.state as string)); } else { - 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}`; - this._logger.debug(`Redirecting user to Cognito User Pool URL ${userPoolUrl}`); - return { - status: '302', - headers: { - 'location': [{ - key: 'Location', - value: userPoolUrl, - }], - 'cache-control': [{ - key: 'Cache-Control', - value: 'no-cache, no-store, max-age=0, must-revalidate', - }], - 'pragma': [{ - key: 'Pragma', - value: 'no-cache', - }], - }, - }; + return this._getRedirectToCognitoUserPoolResponse(request, redirectURI); } } } } +