'use strict'; const Jwk = require('./Jwk.js'); const debug = require('debug'); const debugTrace = debug('xssec:JwksReplica'); const debugError = debug('xssec:JwksReplica'); debugTrace.log = console.log.bind(console); debugError.log = console.error.bind(console); class JwksReplica { static get DEFAULT_EXPIRATION_TIME() { return 30 * 60 * 1000; } // 30 minutes static get DEFAULT_REFRESH_PERIOD() { return 15 * 60 * 1000; } // 15 minutes #oAuth2Service; // OAuth2 service from the JWKS is fetched #params; // params to use when retrieving the JWKS #keys; // map that holds the JWKS mapped by kid #jwksUpdate; // promise for ongoing update of JWKS or undefined #expirationTime; // default expiration time that will be used for new JWK cache entries #refreshPeriod; // time before expiration in which a JWT is considered stale get params() {return this.#params;} constructor(oAuth2Service, expirationTime = JwksReplica.DEFAULT_EXPIRATION_TIME, refreshPeriod = JwksReplica.DEFAULT_REFRESH_PERIOD) { if(!oAuth2Service) { throw new Error("JwksReplica requires an oAuth2Service to fetch the JWKS from.") } if (expirationTime < 0) { throw new Error("JwksReplica expirationTime must be >=0.") } if (refreshPeriod < 0 || refreshPeriod > expirationTime) { throw new Error("JwksReplica refreshPeriod must be between 0 and .") } this.#oAuth2Service = oAuth2Service; this.#keys = new Map(); this.#expirationTime = expirationTime; this.#refreshPeriod = refreshPeriod; } get oAuth2Service() { return this.#oAuth2Service; } get expirationTime() { return this.#expirationTime; } get refreshPeriod() { return this.#refreshPeriod; } /** Configures the replica with parameters that are used when retrieving the JWKS from the server. */ withParams(params) { this.#params = params; return this; } put(kid, value, expirationTime = this.expirationTime) { const jwk = new Jwk(kid, value, expirationTime); this.#keys.set(kid, jwk); return jwk; } async get(kid) { let jwk = this.#keys.get(kid); if (jwk === undefined || jwk.expired) { debugTrace(`Awaiting JWKS refresh because JWK with kid=${kid} is ${jwk === undefined ? "missing" : "expired"}. OAuth2Service: (${JSON.stringify(this.#oAuth2Service)})`); await this.updateJwks(); jwk = this.#keys.get(kid); if (jwk === undefined) { const err = new Error(`JWKS does not contain JWK with kid ${kid}.`); err.kidMissingInJwks = true; throw err; } return jwk; } // trigger asynchronous JWKS update if this JWK is stale if (jwk.stale(this.#refreshPeriod)) { if(!this.#jwksUpdate) { debugTrace(`Asynchronous JWKS refresh scheduled because JWK with kid=${kid} is stale (remaining time = ${jwk.remainingTime}ms < ${this.refreshPeriod}ms = refresh period). OAuth2Service: (${JSON.stringify(this.#oAuth2Service)})`); } this.updateJwks().catch((e) => { debugError("Asynchronous JWKS refresh failed.", e) }); } return jwk; } async updateJwks() { if (this.#jwksUpdate === undefined) { this.#jwksUpdate = this.#doUpdateJwks(); } return this.#jwksUpdate; } async #doUpdateJwks() { debugTrace(`Fetching JWKS from service ${JSON.stringify(this.#oAuth2Service)}.`) try { const jwks = await this.#oAuth2Service.fetchJwks(this.params); debugTrace(`JWKS received from service ${JSON.stringify(this.#oAuth2Service)}.`) this.#keys.clear(); for (let key of jwks) { this.put(key.kid, key.value); } return jwks; } catch(e) { debugError(`Error fetching JWKS from service ${JSON.stringify(this.#oAuth2Service)}.`, e); throw e; } finally { this.#jwksUpdate = undefined; } } } module.exports = JwksReplica;