import { CloudFrontHeaders } from "aws-lambda" ; import { readFileSync } from "fs" ; import { formatWithOptions } from "util" ; import { createHmac } from "crypto" ; import { parse } from "cookie" ; import { fetch } from "./https" ; import { Agent , RequestOptions } from "https" ; import html from "./error-page/template.html" ; import { CognitoJwtVerifier } from "aws-jwt-verify" ; export interface CookieSettings { idToken : string ; accessToken : string ; refreshToken : string ; nonce : string ; [ key ] : string ; } function getDefaultCookieSettings ( props ) { if ( props . compatibility === "amplify" ) { if ( props . mode === "spaMode" ) { return { idToken : "Path=/; Secure; SameSite=Lax" , accessToken : "Path=/; Secure; SameSite=Lax" , refreshToken : "Path=/; Secure; SameSite=Lax" , nonce : "Path=/; Secure; HttpOnly; SameSite=Lax" , } ; } else if ( props . mode === "staticSiteMode" ) { return { idToken : "Path=/; Secure; HttpOnly; SameSite=Lax" , accessToken : "Path=/; Secure; HttpOnly; SameSite=Lax" , refreshToken : "Path=/; Secure; HttpOnly; SameSite=Lax" , nonce : "Path=/; Secure; HttpOnly; SameSite=Lax" , } ; } } else if ( props . compatibility === "elasticsearch" ) { return { idToken : "Path=/; Secure; HttpOnly; SameSite=Lax" , accessToken : "Path=/; Secure; HttpOnly; SameSite=Lax" , refreshToken : "Path=/; Secure; HttpOnly; SameSite=Lax" , nonce : "Path=/; Secure; HttpOnly; SameSite=Lax" , cognitoEnabled : "Path=/; Secure; SameSite=Lax" , } ; } throw new Error ( `Cannot determine default cookiesettings for ${ props . mode } with compatibility ${ props . compatibility }` ) ; } export interface HttpHeaders { [ key ] : string ; } type Mode = "spaMode" | "staticSiteMode" ; interface ConfigFromDisk { logLevel : keyof typeof LogLevel ; } interface ConfigFromDiskWithHeaders extends ConfigFromDisk { httpHeaders : HttpHeaders ; } interface ConfigFromDiskComplete extends ConfigFromDiskWithHeaders { userPoolArn : string ; clientId : string ; oauthScopes : string [ ] ; cognitoAuthDomain : string ; redirectPathSignIn : string ; redirectPathSignOut : string ; redirectPathAuthRefresh : string ; cookieSettings : CookieSettings ; mode : Mode ; clientSecret : string ; nonceSigningSecret : string ; cookieCompatibility : "amplify" | "elasticsearch" ; additionalCookies : { [ name ] : string ; } ; requiredGroup : string ; secretAllowedCharacters ? : string ; pkceLength ? : number ; nonceLength ? : number ; nonceMaxAge ? : number ; } function isConfigWithHeaders ( config ) { return config [ "httpHeaders" ] !== undefined ; } function isCompleteConfig ( config ) { return config [ "userPoolArn" ] !== undefined ; } enum LogLevel { "none" = 0 , "error" = 10 , "warn" = 20 , "info" = 30 , "debug" = 40 } class Logger { constructor ( private logLevel ) { } private format ( args , depth = 10 ) { return args . map ( ( arg ) => formatWithOptions ( { depth } , arg ) ) . join ( " " ) ; } public info ( ... args ) { if ( this . logLevel >= LogLevel . info ) { console . log ( this . format ( args ) ) ; } } public warn ( ... args ) { if ( this . logLevel >= LogLevel . warn ) { console . warn ( this . format ( args ) ) ; } } public error ( ... args ) { if ( this . logLevel >= LogLevel . error ) { console . error ( this . format ( args ) ) ; } } public debug ( ... args ) { if ( this . logLevel >= LogLevel . debug ) { console . trace ( this . format ( args ) ) ; } } } export interface Config extends ConfigFromDisk { logger : Logger ; } export interface ConfigWithHeaders extends Config , ConfigFromDiskWithHeaders { cloudFrontHeaders : CloudFrontHeaders ; } export interface CompleteConfig extends ConfigWithHeaders , ConfigFromDiskComplete { cloudFrontHeaders : CloudFrontHeaders ; secretAllowedCharacters : string ; pkceLength : number ; nonceLength : number ; nonceMaxAge : number ; } export function getConfig ( ) { const config = JSON . parse ( readFileSync ( `${ __dirname }/configuration.json` ) . toString ( "utf8" ) ) as ConfigFromDisk ; return { logger : new Logger ( LogLevel [ config . logLevel ] ) , ... config , } ; } export function getConfigWithHeaders ( ) { const config = getConfig ( ) ; if ( ! isConfigWithHeaders ( config ) ) { throw new Error ( "Incomplete config in configuration.json" ) ; } return { cloudFrontHeaders : asCloudFrontHeaders ( config . httpHeaders ) , ... config , } ; } export function getCompleteConfig ( ) { const config = getConfigWithHeaders ( ) ; if ( ! isCompleteConfig ( config ) ) { throw new Error ( "Incomplete config in configuration.json" ) ; } const defaultCookieSettings = getDefaultCookieSettings ( { compatibility : config . cookieCompatibility , mode : config . mode , } ) ; const cookieSettings = config . cookieSettings ? ( Object . fromEntries ( Object . entries ( { ... defaultCookieSettings , ... config . cookieSettings , } ) . map ( ( [ k , v ] ) => [ k , v || defaultCookieSettings [ k as keyof CookieSettings ] , ] ) ) as CookieSettings ) : defaultCookieSettings ; const defaults = { secretAllowedCharacters : "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" , pkceLength : 43 , nonceLength : 16 , nonceMaxAge : ( cookieSettings ?. nonce && parseInt ( parse ( cookieSettings . nonce . toLowerCase ( ) ) [ "max-age" ] ) ) || 60 * 60 * 24 , } ; return { ... defaults , ... config , cookieSettings , } ; } export function getConfigWithJwtVerifier ( ) { const config = getCompleteConfig ( ) ; const userPoolId = config . userPoolArn . split ( "/" ) [ 1 ] ; return { ... config , jwtVerifier : CognitoJwtVerifier . create ( { userPoolId , clientId : config . clientId , tokenUse : "id" , groups : config . requiredGroup || undefined , } ) , } ; } type Cookies = { [ key ] : string ; } ; function extractCookiesFromHeaders ( headers ) { if ( ! headers [ "cookie" ] ) { return { } ; } const cookies = headers [ "cookie" ] . reduce ( ( reduced , header ) => Object . assign ( reduced , parse ( header . value ) ) , { } as Cookies ) ; return cookies ; } function withCookieDomain ( distributionDomainName , cookieSettings ) { if ( cookieSettings . toLowerCase ( ) . indexOf ( "domain" ) === - 1 ) { return `${ cookieSettings }; Domain=.${ distributionDomainName }` ; } return cookieSettings ; } export function asCloudFrontHeaders ( headers ) { if ( ! headers ) return { } ; return Object . entries ( headers ) . reduce ( ( reduced , [ key , value ] ) => Object . assign ( reduced , { [ key . toLowerCase ( ) ] : [ { key , value , } , ] , } ) , { } as CloudFrontHeaders ) ; } export function getAmplifyCookieNames ( clientId , cookiesOrUserName ) { const keyPrefix = `CognitoIdentityServiceProvider.${ clientId }` ; const lastUserKey = `${ keyPrefix }.LastAuthUser` ; let tokenUserName ; if ( typeof cookiesOrUserName === "string" ) { tokenUserName = cookiesOrUserName ; } else { tokenUserName = cookiesOrUserName [ lastUserKey ] ; } return { lastUserKey , userDataKey : `${ keyPrefix }.${ tokenUserName }.userData` , scopeKey : `${ keyPrefix }.${ tokenUserName }.tokenScopesString` , idTokenKey : `${ keyPrefix }.${ tokenUserName }.idToken` , accessTokenKey : `${ keyPrefix }.${ tokenUserName }.accessToken` , refreshTokenKey : `${ keyPrefix }.${ tokenUserName }.refreshToken` , } ; } export function getElasticsearchCookieNames ( ) { return { idTokenKey : "ID-TOKEN" , accessTokenKey : "ACCESS-TOKEN" , refreshTokenKey : "REFRESH-TOKEN" , cognitoEnabledKey : "COGNITO-ENABLED" , } ; } export function extractAndParseCookies ( headers , clientId , cookieCompatibility ) { const cookies = extractCookiesFromHeaders ( headers ) ; if ( ! cookies ) { return { } ; } let cookieNames ; if ( cookieCompatibility === "amplify" ) { cookieNames = getAmplifyCookieNames ( clientId , cookies ) ; } else { cookieNames = getElasticsearchCookieNames ( ) ; } return { tokenUserName : cookies [ cookieNames . lastUserKey ] , idToken : cookies [ cookieNames . idTokenKey ] , accessToken : cookies [ cookieNames . accessTokenKey ] , refreshToken : cookies [ cookieNames . refreshTokenKey ] , scopes : cookies [ cookieNames . scopeKey ] , nonce : cookies [ "spa-auth-edge-nonce" ] , nonceHmac : cookies [ "spa-auth-edge-nonce-hmac" ] , pkce : cookies [ "spa-auth-edge-pkce" ] , } ; } interface GenerateCookieHeadersParam { clientId : string ; oauthScopes : string [ ] ; domainName : string ; cookieSettings : CookieSettings ; mode : Mode ; cookieCompatibility : "amplify" | "elasticsearch" ; additionalCookies : { [ name ] : string ; } ; tokens : { id_token : string ; access_token : string ; refresh_token : string ; } ; } export const generateCookieHeaders = { newTokens : ( param ) => _generateCookieHeaders ( { ... param , event : "newTokens" } ) , signOut : ( param ) => _generateCookieHeaders ( { ... param , event : "signOut" } ) , refreshFailed : ( param ) => _generateCookieHeaders ( { ... param , event : "refreshFailed" } ) , } ; function _generateCookieHeaders ( param ) { const decodedIdToken = decodeToken ( param . tokens . id_token ) ; const tokenUserName = decodedIdToken [ "cognito:username" ] ; let cookies ; let cookieNames ; if ( param . cookieCompatibility === "amplify" ) { cookieNames = getAmplifyCookieNames ( param . clientId , tokenUserName ) ; const userData = JSON . stringify ( { UserAttributes : [ { Name : "sub" , Value : decodedIdToken [ "sub" ] , } , { Name : "email" , Value : decodedIdToken [ "email" ] , } , ] , Username : tokenUserName , } ) ; cookies = { [ cookieNames . lastUserKey ] : `${ tokenUserName }; ${ withCookieDomain ( param . domainName , param . cookieSettings . idToken ) }` , [ cookieNames . scopeKey ] : `${ param . oauthScopes . join ( " " ) }; ${ withCookieDomain ( param . domainName , param . cookieSettings . accessToken ) }` , [ cookieNames . userDataKey ] : `${ encodeURIComponent ( userData ) }; ${ withCookieDomain ( param . domainName , param . cookieSettings . idToken ) }` , "amplify-signin-with-hostedUI" : `true; ${ withCookieDomain ( param . domainName , param . cookieSettings . accessToken ) }` , } ; } else { cookieNames = getElasticsearchCookieNames ( ) ; cookies = { [ cookieNames . cognitoEnabledKey ] : `True; ${ withCookieDomain ( param . domainName , param . cookieSettings . cognitoEnabled ) }` , } ; } Object . assign ( cookies , { [ cookieNames . idTokenKey ] : `${ param . tokens . id_token }; ${ withCookieDomain ( param . domainName , param . cookieSettings . idToken ) }` , [ cookieNames . accessTokenKey ] : `${ param . tokens . access_token }; ${ withCookieDomain ( param . domainName , param . cookieSettings . accessToken ) }` , [ cookieNames . refreshTokenKey ] : `${ param . tokens . refresh_token }; ${ withCookieDomain ( param . domainName , param . cookieSettings . refreshToken ) }` , } ) ; if ( param . event === "signOut" ) { Object . keys ( cookies ) . forEach ( ( key ) => ( cookies [ key ] = expireCookie ( cookies [ key ] ) ) ) ; } else if ( param . event === "refreshFailed" ) { cookies [ cookieNames . refreshTokenKey ] = expireCookie ( cookies [ cookieNames . refreshTokenKey ] ) ; } [ "spa-auth-edge-nonce" , "spa-auth-edge-nonce-hmac" , "spa-auth-edge-pkce" , ] . forEach ( ( key ) => { cookies [ key ] = expireCookie ( ) ; } ) ; return Object . entries ( { ... param . additionalCookies , ... cookies , } ) . map ( ( [ k , v ] ) => ( { key : "set-cookie" , value : `${ k }=${ v }` } ) ) ; } function expireCookie ( cookie = "" ) { const cookieParts = cookie . split ( ";" ) . map ( ( part ) => part . trim ( ) ) . filter ( ( part ) => ! part . toLowerCase ( ) . startsWith ( "max-age" ) ) . filter ( ( part ) => ! part . toLowerCase ( ) . startsWith ( "expires" ) ) ; const expires = `Expires=${ new Date ( 0 ) . toUTCString ( ) }` ; const [ , ... settings ] = cookieParts ; return [ "" , ... settings , expires ] . join ( "; " ) ; } export function decodeToken ( jwt ) { const tokenBody = jwt . split ( "." ) [ 1 ] ; const decodableTokenBody = tokenBody . replace ( /-/g , "+" ) . replace ( /_/g , "/" ) ; return JSON . parse ( Buffer . from ( decodableTokenBody , "base64" ) . toString ( ) ) ; } const AGENT = new Agent ( { keepAlive : true } ) ; export async function httpPostToCognitoWithRetry ( url , data , options , logger ) { let attempts = 0 ; while ( true ) { ++ attempts ; try { return await fetch ( url , data , { agent : AGENT , ... options , method : "POST" , } ) . then ( ( res ) => { if ( res . status !== 200 ) { throw new Error ( `Status is ${ res . status }, expected 200` ) ; } if ( ! res . headers [ "content-type" ] ?. startsWith ( "application/json" ) ) { throw new Error ( `Content-Type is ${ res . headers [ "content-type" ] }, expected application/json` ) ; } return { ... res , data : JSON . parse ( res . data . toString ( ) ) , } ; } ) ; } catch ( err ) { logger . debug ( `HTTP POST to ${ url } failed (attempt ${ attempts }):` ) ; logger . debug ( err ) ; if ( attempts >= 5 ) { logger . error ( `No success after ${ attempts } attempts, seizing further attempts` ) ; throw err ; } if ( attempts >= 2 ) { logger . debug ( "Doing exponential backoff with jitter, before attempting HTTP POST again ..." ) ; await new Promise ( ( resolve ) => setTimeout ( resolve , 25 * ( Math . pow ( 2 , attempts ) + Math . random ( ) * attempts ) ) ) ; logger . debug ( "Done waiting, will try HTTP POST again now" ) ; } } } } export function createErrorHtml ( props ) { const params = { ... props , region : process . env . AWS_REGION } ; return html . replace ( /\${([^}]*)}/g , ( _ , v ) => escapeHtml ( params [ v ] ) ?? "" ) ; } function escapeHtml ( unsafe ) { if ( typeof unsafe !== "string" ) { return undefined ; } return unsafe . replace ( /&/g , "&amp;" ) . replace ( /</g , "&lt;" ) . replace ( />/g , "&gt;" ) . replace ( /"/g , "&quot;" ) . replace ( /'/g , "&#039;" ) ; } export const urlSafe = { stringify : ( b64encodedString ) => b64encodedString . replace ( /=/g , "" ) . replace ( /\+/g , "-" ) . replace ( /\//g , "_" ) , parse : ( b64encodedString ) => b64encodedString . replace ( /-/g , "+" ) . replace ( /_/g , "/" ) , } ; export function sign ( stringToSign , secret , signatureLength ) { const digest = createHmac ( "sha256" , secret ) . update ( stringToSign ) . digest ( "base64" ) . slice ( 0 , signatureLength ) ; const signature = urlSafe . stringify ( digest ) ; return signature ; } export function timestampInSeconds ( ) { return ( Date . now ( ) / 1000 ) | 0 ; } export class RequiresConfirmationError extends Error { }