import { IncomingMessage , ServerResponse } from 'http' ; import { strict as assert , AssertionError } from 'assert' ; import { JWE , JWK , JWKS , errors } from 'jose' ; import { encryption as deriveKey } from './utils/hkdf' ; import createDebug from './utils/debug' ; import { getAll as getCookies , clear as clearCookie , set as setCookie } from './utils/cookies' ; import { Config } from './config' ; import { CookieSerializeOptions , serialize } from 'cookie' ; const debug = createDebug ( 'cookie-store' ) ; const epoch = ( ) : number => ( Date . now ( ) / 1000 ) | 0 ; const MAX_COOKIE_SIZE = 4096 ; const alg = 'dir' ; const enc = 'A256GCM' ; const notNull = < T > ( value ) : value is T => value !== null ; export default class CookieStore { private keystore : JWKS . KeyStore ; private currentKey : JWK . OctKey | undefined ; private chunkSize : number ; constructor ( public config ) { const secrets = Array . isArray ( config . secret ) ? config . secret : [ config . secret ] ; this . keystore = new JWKS . KeyStore ( ) ; secrets . forEach ( ( secretString , i ) => { const key = JWK . asKey ( deriveKey ( secretString ) ) ; if ( i === 0 ) { this . currentKey = key as JWK . OctKey ; } this . keystore . add ( key ) ; } ) ; const { cookie : { transient , ... cookieConfig } , name : sessionName } = this . config . session ; const cookieOptions = { ... cookieConfig } ; if ( ! transient ) { cookieOptions . expires = new Date ( ) ; } const emptyCookie = serialize ( `${ sessionName }.0` , '' , cookieOptions ) ; this . chunkSize = MAX_COOKIE_SIZE - emptyCookie . length ; } private encrypt ( payload , headers ) { return JWE . encrypt ( payload , this . currentKey as JWK . OctKey , { alg , enc , ... headers } ) ; } private decrypt ( jwe ) { return JWE . decrypt ( jwe , this . keystore , { complete : true , contentEncryptionAlgorithms : [ enc ] , keyManagementAlgorithms : [ alg ] } ) ; } private calculateExp ( iat , uat ) { const { absoluteDuration } = this . config . session ; const { rolling , rollingDuration } = this . config . session ; if ( typeof absoluteDuration !== 'number' ) { return uat + rollingDuration ; } if ( ! rolling ) { return iat + absoluteDuration ; } return Math . min ( uat + rollingDuration , iat + absoluteDuration ) ; } public read ( req ) { const cookies = getCookies ( req ) ; const { name : sessionName , rollingDuration , absoluteDuration } = this . config . session ; let iat ; let uat ; let exp ; let existingSessionValue ; try { if ( sessionName in cookies ) { debug ( 'reading session from %s cookie' , sessionName ) ; existingSessionValue = cookies [ sessionName ] ; } else if ( `${ sessionName }.0` in cookies ) { existingSessionValue = Object . entries ( cookies ) . map ( ( [ cookie , value ] ) : [ string , string ] | null => { const match = cookie . match ( `^${ sessionName }\\.(\\d+)$` ) ; if ( match ) { return [ match [ 1 ] , value as string ] ; } return null ; } ) . filter ( notNull ) . sort ( ( [ a ] , [ b ] ) => { return parseInt ( a , 10 ) - parseInt ( b , 10 ) ; } ) . map ( ( [ i , chunk ] ) => { debug ( 'reading session chunk from %s.%d cookie' , sessionName , i ) ; return chunk ; } ) . join ( '' ) ; } if ( existingSessionValue ) { const { protected : header , cleartext } = this . decrypt ( existingSessionValue ) ; ( { iat , uat , exp } = header as { iat : number ; uat : number ; exp : number ; } ) ; assert ( exp > epoch ( ) , 'it is expired based on options when it was established' ) ; if ( rollingDuration ) { assert ( uat + rollingDuration > epoch ( ) , 'it is expired based on current rollingDuration rules' ) ; } if ( typeof absoluteDuration === 'number' ) { assert ( iat + absoluteDuration > epoch ( ) , 'it is expired based on current absoluteDuration rules' ) ; } return [ JSON . parse ( cleartext . toString ( ) ) , iat ] ; } } catch ( err ) { if ( err instanceof AssertionError ) { debug ( 'existing session was rejected because' , err . message ) ; } else if ( err instanceof errors . JOSEError ) { debug ( 'existing session was rejected because it could not be decrypted' , err ) ; } else { debug ( 'unexpected error handling session' , err ) ; } } return [ ] ; } public save ( req , res , session , createdAt ? ) { const { cookie : { transient , ... cookieConfig } , name : sessionName } = this . config . session ; const cookies = getCookies ( req ) ; if ( ! session ) { debug ( 'clearing all matching session cookies' ) ; for ( const cookieName of Object . keys ( cookies ) ) { if ( cookieName . match ( `^${ sessionName }(?:\\.\\d)?$` ) ) { clearCookie ( res , cookieName , cookieConfig ) ; } } return ; } const uat = epoch ( ) ; const iat = typeof createdAt === 'number' ? createdAt : uat ; const exp = this . calculateExp ( iat , uat ) ; const cookieOptions = { ... cookieConfig } ; if ( ! transient ) { cookieOptions . expires = new Date ( exp * 1000 ) ; } debug ( 'found session, creating signed session cookie(s) with name %o(.i)' , sessionName ) ; const value = this . encrypt ( JSON . stringify ( session ) , { iat , uat , exp } ) ; const chunkCount = Math . ceil ( value . length / this . chunkSize ) ; if ( chunkCount > 1 ) { debug ( 'cookie size greater than %d, chunking' , this . chunkSize ) ; for ( let i = 0 ; i < chunkCount ; i ++ ) { const chunkValue = value . slice ( i * this . chunkSize , ( i + 1 ) * this . chunkSize ) ; const chunkCookieName = `${ sessionName }.${ i }` ; setCookie ( res , chunkCookieName , chunkValue , cookieOptions ) ; } if ( sessionName in cookies ) { clearCookie ( res , sessionName , cookieConfig ) ; } } else { setCookie ( res , sessionName , value , cookieOptions ) ; for ( const cookieName of Object . keys ( cookies ) ) { if ( cookieName . match ( `^${ sessionName }\\.\\d$` ) ) { clearCookie ( res , cookieName , cookieConfig ) ; } } } } }