import axios from 'axios' ; import { parse , stringify } from 'querystring' ; import pino from 'pino' ; import { CognitoJwtVerifier } from 'aws-jwt-verify' ; import { CloudFrontRequestEvent } from 'aws-lambda' ; interface AuthenticatorParams { region : string ; userPoolId : string ; userPoolAppId : string ; userPoolAppSecret ? : string ; userPoolDomain : string ; cookieExpirationDays ? : number ; disableCookieDomain ? : boolean ; logLevel ? : 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent' ; } export class Authenticator { _region : string ; _userPoolId : string ; _userPoolAppId : string ; _userPoolAppSecret : string ; _userPoolDomain : string ; _cookieExpirationDays : number ; _disableCookieDomain : boolean ; _cookieBase : string ; _logger ; _jwtVerifier ; constructor ( params ) { this . _verifyParams ( params ) ; this . _region = params . region ; this . _userPoolId = params . userPoolId ; this . _userPoolAppId = params . userPoolAppId ; this . _userPoolAppSecret = params . userPoolAppSecret ; this . _userPoolDomain = params . userPoolDomain ; this . _cookieExpirationDays = params . cookieExpirationDays || 365 ; this . _disableCookieDomain = ( 'disableCookieDomain' in params && params . disableCookieDomain === true ) ? true : false ; this . _cookieBase = `CognitoIdentityServiceProvider.${ params . userPoolAppId }` ; this . _logger = pino ( { level : params . logLevel || 'silent' , base : null , } ) ; this . _jwtVerifier = CognitoJwtVerifier . create ( { userPoolId : params . userPoolId , clientId : params . userPoolAppId , tokenUse : 'id' , } ) ; } param { object } params return { void } _verifyParams ( params ) { if ( typeof params !== 'object' ) { throw new Error ( 'Expected params to be an object' ) ; } [ 'region' , 'userPoolId' , 'userPoolAppId' , 'userPoolDomain' ] . forEach ( param => { if ( typeof params [ param ] !== 'string' ) { throw new Error ( `Expected params.${ param } to be a string` ) ; } } ) ; if ( params . cookieExpirationDays && typeof params . cookieExpirationDays !== 'number' ) { 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' ) ; } } param { String } redirectURI param { String } code return { Promise } _fetchTokensFromCode ( redirectURI , code ) { 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 , code : code , grant_type : 'authorization_code' , redirect_uri : redirectURI , } ) , } as const ; this . _logger . debug ( { msg : 'Fetching tokens from grant code...' , request , code } ) ; return axios . request ( request ) . then ( resp => { this . _logger . debug ( { msg : 'Fetched tokens' , tokens : resp . data } ) ; return resp . data ; } ) . catch ( err => { this . _logger . error ( { msg : 'Unable to fetch tokens from grant code' , request , code } ) ; throw err ; } ) ; } param { Object } tokens param { String } domain param { String } location return { Object } async _getRedirectResponse ( tokens , domain , location ) { const decoded = await this . _jwtVerifier . verify ( tokens . id_token ) ; const username = decoded [ 'cognito:username' ] ; const usernameBase = `${ this . _cookieBase }.${ username }` ; const directives = ( ! this . _disableCookieDomain ) ? `Domain=${ domain }; Expires=${ new Date ( Date . now ( ) + this . _cookieExpirationDays * 864e+5 ) }; Secure` : `Expires=${ new Date ( Date . now ( ) + this . _cookieExpirationDays * 864e+5 ) }; Secure` ; const response = { status : '302' , headers : { 'location' : [ { key : 'Location' , value : location , } ] , 'cache-control' : [ { key : 'Cache-Control' , value : 'no-cache, no-store, max-age=0, must-revalidate' , } ] , 'pragma' : [ { key : 'Pragma' , value : 'no-cache' , } ] , 'set-cookie' : [ { key : 'Set-Cookie' , value : `${ usernameBase }.accessToken=${ tokens . access_token }; ${ directives }` , } , { key : 'Set-Cookie' , value : `${ usernameBase }.idToken=${ tokens . id_token }; ${ directives }` , } , { key : 'Set-Cookie' , value : `${ usernameBase }.refreshToken=${ tokens . refresh_token }; ${ directives }` , } , { key : 'Set-Cookie' , value : `${ usernameBase }.tokenScopesString=phone email profile openid aws.cognito.signin.user.admin; ${ directives }` , } , { key : 'Set-Cookie' , value : `${ this . _cookieBase }.LastAuthUser=${ username }; ${ directives }` , } , ] , } , } ; this . _logger . debug ( { msg : 'Generated set-cookie response' , response } ) ; return response ; } param { Array } cookies return { String } _getIdTokenFromCookie ( cookies ) { this . _logger . debug ( { msg : 'Extracting authentication token from request cookie' , cookies } ) ; const regex = new RegExp ( `${ this . _userPoolAppId }\..+?\.idToken=(.*?);` ) ; if ( cookies ) { for ( let i = 0 ; i < cookies . length ; i ++ ) { const matches = cookies [ i ] . value . match ( regex ) ; if ( matches && matches . length > 1 ) { this . _logger . debug ( { msg : '  Found token in cookie' , token : matches [ 1 ] } ) ; return matches [ 1 ] ; } } } this . _logger . debug ( "  idToken wasn't present in request cookies" ) ; throw new Error ( "Id token isn't present in the request cookies" ) ; } param { Object } event return { Promise } async handle ( event ) { this . _logger . debug ( { msg : 'Handling Lambda@Edge event' , event } ) ; const { request } = event . Records [ 0 ] . cf ; const requestParams = parse ( request . querystring ) ; const cfDomain = request . headers . host [ 0 ] . value ; 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 ; } 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 ) ) ; } 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' , } ] , } , } ; } } } }