import { SimpleJsonFetcher , JsonFetcher , fetchJson } from "./https.js" ; import { JwtHeader , JwtPayload } from "./jwt-model.js" ; import { Json , JsonObject , isJsonObject } from "./safe-json-parse.js" ; import { JwkValidationError , JwksNotAvailableInCacheError , JwksValidationError , KidNotFoundInJwksError , WaitPeriodNotYetEndedJwkError , JwtWithoutValidKidError , } from "./error.js" ; interface DecomposedJwt { header : JwtHeader ; payload : JwtPayload ; } const optionalJwkFieldNames = [ "alg" , ] as const ; const mandatoryJwkFieldNames = [ "e" , "kid" , "kty" , "n" , "use" , ] as const ; type OptionalJwkFieldNames = typeof optionalJwkFieldNames [ number ] ; type MandatoryJwkFieldNames = typeof mandatoryJwkFieldNames [ number ] ; type OptionalJwkFields = { [ key in OptionalJwkFieldNames ] ? : string ; } ; type MandatoryJwkFields = { [ key in MandatoryJwkFieldNames ] : string ; } ; export type Jwk = OptionalJwkFields & MandatoryJwkFields & JsonObject ; interface JwksFields { keys : readonly Jwk [ ] ; } export type Jwks = JwksFields & JsonObject ; export interface JwksCache { getJwk ( jwksUri , decomposedJwt ) : Promise < Jwk > ; getCachedJwk ( jwksUri , decomposedJwt ) : Jwk ; addJwks ( jwksUri , jwks ) : void ; getJwks ( jwksUri ) : Promise < Jwks > ; } export async function fetchJwks ( jwksUri ) { const jwks = await fetchJson ( jwksUri ) ; assertIsJwks ( jwks ) ; return jwks ; } export async function fetchJwk ( jwksUri , decomposedJwt ) { if ( ! decomposedJwt . header . kid ) { throw new JwtWithoutValidKidError ( "JWT header does not have valid kid claim" ) ; } const jwk = ( await fetchJwks ( jwksUri ) ) . keys . find ( ( key ) => key . kid === decomposedJwt . header . kid ) ; if ( ! jwk ) { throw new KidNotFoundInJwksError ( `JWK for kid "${ decomposedJwt . header . kid }" not found in the JWKS` ) ; } return jwk ; } export function assertIsJwks ( jwks ) { if ( ! jwks ) { throw new JwksValidationError ( "JWKS empty" ) ; } if ( ! isJsonObject ( jwks ) ) { throw new JwksValidationError ( "JWKS should be an object" ) ; } if ( ! Object . keys ( jwks ) . includes ( "keys" ) ) { throw new JwksValidationError ( "JWKS does not include keys" ) ; } if ( ! Array . isArray ( ( jwks as { keys : Json ; } ) . keys ) ) { throw new JwksValidationError ( "JWKS keys should be an array" ) ; } for ( const jwk of ( jwks as { keys : Json [ ] ; } ) . keys ) { assertIsJwk ( jwk ) ; } } export function assertIsJwk ( jwk ) { if ( ! jwk ) { throw new JwkValidationError ( "JWK empty" ) ; } if ( ! isJsonObject ( jwk ) ) { throw new JwkValidationError ( "JWK should be an object" ) ; } for ( const field of mandatoryJwkFieldNames ) { if ( typeof jwk [ field ] !== "string" ) { throw new JwkValidationError ( `JWK ${ field } should be a string` ) ; } } for ( const field of optionalJwkFieldNames ) { if ( field in jwk && typeof jwk [ field ] !== "string" ) { throw new JwkValidationError ( `JWK ${ field } should be a string` ) ; } } } export function isJwks ( jwks ) { try { assertIsJwks ( jwks ) ; return true ; } catch { return false ; } } export function isJwk ( jwk ) { try { assertIsJwk ( jwk ) ; return true ; } catch { return false ; } } export interface PenaltyBox { wait : ( jwksUri : string , kid : string ) => Promise < void > ; registerFailedAttempt : ( jwksUri : string , kid : string ) => void ; registerSuccessfulAttempt : ( jwksUri : string , kid : string ) => void ; } type JwksUri = string ; export class SimplePenaltyBox implements PenaltyBox { waitSeconds : number ; private waitingUris : Map < JwksUri , NodeJS . Timeout > = new Map ( ) ; constructor ( props ? ) { this . waitSeconds = props ?. waitSeconds ?? 10 ; } async wait ( jwksUri ) { if ( this . waitingUris . has ( jwksUri ) ) { throw new WaitPeriodNotYetEndedJwkError ( "Not allowed to fetch JWKS yet, still waiting for back off period to end" ) ; } } release ( jwksUri ) { const i = this . waitingUris . get ( jwksUri ) ; if ( i ) { clearTimeout ( i ) ; this . waitingUris . delete ( jwksUri ) ; } } registerFailedAttempt ( jwksUri ) { const i = setTimeout ( ( ) => { this . waitingUris . delete ( jwksUri ) ; } , this . waitSeconds * 1000 ) . unref ( ) ; this . waitingUris . set ( jwksUri , i ) ; } registerSuccessfulAttempt ( jwksUri ) { this . release ( jwksUri ) ; } } export class SimpleJwksCache implements JwksCache { fetcher : JsonFetcher ; penaltyBox : PenaltyBox ; private jwksCache : Map < JwksUri , Jwks > = new Map ( ) ; private fetchingJwks : Map < JwksUri , Promise < Jwks > > = new Map ( ) ; constructor ( props ? ) { this . penaltyBox = props ?. penaltyBox ?? new SimplePenaltyBox ( ) ; this . fetcher = props ?. fetcher ?? new SimpleJsonFetcher ( ) ; } public addJwks ( jwksUri , jwks ) { this . jwksCache . set ( jwksUri , jwks ) ; } public async getJwks ( jwksUri ) { const existingFetch = this . fetchingJwks . get ( jwksUri ) ; if ( existingFetch ) { return existingFetch ; } const jwksPromise = this . fetcher . fetch ( jwksUri ) . then ( ( res ) => { assertIsJwks ( res ) ; return res ; } ) ; this . fetchingJwks . set ( jwksUri , jwksPromise ) ; let jwks ; try { jwks = await jwksPromise ; } finally { this . fetchingJwks . delete ( jwksUri ) ; } this . jwksCache . set ( jwksUri , jwks ) ; return jwks ; } public getCachedJwk ( jwksUri , decomposedJwt ) { if ( typeof decomposedJwt . header . kid !== "string" ) { throw new JwtWithoutValidKidError ( "JWT header does not have valid kid claim" ) ; } if ( ! this . jwksCache . has ( jwksUri ) ) { throw new JwksNotAvailableInCacheError ( `JWKS for uri ${ jwksUri } not yet available in cache` ) ; } const jwk = this . jwksCache . get ( jwksUri ) ! . keys . find ( ( key ) => key . kid === decomposedJwt . header . kid ) ; if ( ! jwk ) { throw new KidNotFoundInJwksError ( `JWK for kid ${ decomposedJwt . header . kid } not found in the JWKS` ) ; } return jwk ; } public async getJwk ( jwksUri , decomposedJwt ) { if ( typeof decomposedJwt . header . kid !== "string" ) { throw new JwtWithoutValidKidError ( "JWT header does not have valid kid claim" ) ; } let jwk = this . jwksCache . get ( jwksUri ) ?. keys . find ( ( key ) => key . kid === decomposedJwt . header . kid ) ; if ( jwk ) { return jwk ; } await this . penaltyBox . wait ( jwksUri , decomposedJwt . header . kid ) ; const jwks = await this . getJwks ( jwksUri ) ; jwk = jwks . keys . find ( ( key ) => key . kid === decomposedJwt . header . kid ) ; if ( ! jwk ) { this . penaltyBox . registerFailedAttempt ( jwksUri , decomposedJwt . header . kid ) ; throw new KidNotFoundInJwksError ( `JWK for kid "${ decomposedJwt . header . kid }" not found in the JWKS` ) ; } else { this . penaltyBox . registerSuccessfulAttempt ( jwksUri , decomposedJwt . header . kid ) ; } return jwk ; } }