|
import { JOSEError, JWKSNoMatchingKey, JWKSTimeout } from '../util/errors.js'; |
|
import { createLocalJWKSet } from './local.js'; |
|
import isObject from '../lib/is_object.js'; |
|
function isCloudflareWorkers() { |
|
return (typeof WebSocketPair !== 'undefined' || |
|
(typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers') || |
|
(typeof EdgeRuntime !== 'undefined' && EdgeRuntime === 'vercel')); |
|
} |
|
let USER_AGENT; |
|
if (typeof navigator === 'undefined' || !navigator.userAgent?.startsWith?.('Mozilla/5.0 ')) { |
|
const NAME = 'jose'; |
|
const VERSION = 'v6.0.11'; |
|
USER_AGENT = `${NAME}/${VERSION}`; |
|
} |
|
export const customFetch = Symbol(); |
|
async function fetchJwks(url, headers, signal, fetchImpl = fetch) { |
|
const response = await fetchImpl(url, { |
|
method: 'GET', |
|
signal, |
|
redirect: 'manual', |
|
headers, |
|
}).catch((err) => { |
|
if (err.name === 'TimeoutError') { |
|
throw new JWKSTimeout(); |
|
} |
|
throw err; |
|
}); |
|
if (response.status !== 200) { |
|
throw new JOSEError('Expected 200 OK from the JSON Web Key Set HTTP response'); |
|
} |
|
try { |
|
return await response.json(); |
|
} |
|
catch { |
|
throw new JOSEError('Failed to parse the JSON Web Key Set HTTP response as JSON'); |
|
} |
|
} |
|
export const jwksCache = Symbol(); |
|
function isFreshJwksCache(input, cacheMaxAge) { |
|
if (typeof input !== 'object' || input === null) { |
|
return false; |
|
} |
|
if (!('uat' in input) || typeof input.uat !== 'number' || Date.now() - input.uat >= cacheMaxAge) { |
|
return false; |
|
} |
|
if (!('jwks' in input) || |
|
!isObject(input.jwks) || |
|
!Array.isArray(input.jwks.keys) || |
|
!Array.prototype.every.call(input.jwks.keys, isObject)) { |
|
return false; |
|
} |
|
return true; |
|
} |
|
class RemoteJWKSet { |
|
#url; |
|
#timeoutDuration; |
|
#cooldownDuration; |
|
#cacheMaxAge; |
|
#jwksTimestamp; |
|
#pendingFetch; |
|
#headers; |
|
#customFetch; |
|
#local; |
|
#cache; |
|
constructor(url, options) { |
|
if (!(url instanceof URL)) { |
|
throw new TypeError('url must be an instance of URL'); |
|
} |
|
this.#url = new URL(url.href); |
|
this.#timeoutDuration = |
|
typeof options?.timeoutDuration === 'number' ? options?.timeoutDuration : 5000; |
|
this.#cooldownDuration = |
|
typeof options?.cooldownDuration === 'number' ? options?.cooldownDuration : 30000; |
|
this.#cacheMaxAge = typeof options?.cacheMaxAge === 'number' ? options?.cacheMaxAge : 600000; |
|
this.#headers = new Headers(options?.headers); |
|
if (USER_AGENT && !this.#headers.has('User-Agent')) { |
|
this.#headers.set('User-Agent', USER_AGENT); |
|
} |
|
if (!this.#headers.has('accept')) { |
|
this.#headers.set('accept', 'application/json'); |
|
this.#headers.append('accept', 'application/jwk-set+json'); |
|
} |
|
this.#customFetch = options?.[customFetch]; |
|
if (options?.[jwksCache] !== undefined) { |
|
this.#cache = options?.[jwksCache]; |
|
if (isFreshJwksCache(options?.[jwksCache], this.#cacheMaxAge)) { |
|
this.#jwksTimestamp = this.#cache.uat; |
|
this.#local = createLocalJWKSet(this.#cache.jwks); |
|
} |
|
} |
|
} |
|
pendingFetch() { |
|
return !!this.#pendingFetch; |
|
} |
|
coolingDown() { |
|
return typeof this.#jwksTimestamp === 'number' |
|
? Date.now() < this.#jwksTimestamp + this.#cooldownDuration |
|
: false; |
|
} |
|
fresh() { |
|
return typeof this.#jwksTimestamp === 'number' |
|
? Date.now() < this.#jwksTimestamp + this.#cacheMaxAge |
|
: false; |
|
} |
|
jwks() { |
|
return this.#local?.jwks(); |
|
} |
|
async getKey(protectedHeader, token) { |
|
if (!this.#local || !this.fresh()) { |
|
await this.reload(); |
|
} |
|
try { |
|
return await this.#local(protectedHeader, token); |
|
} |
|
catch (err) { |
|
if (err instanceof JWKSNoMatchingKey) { |
|
if (this.coolingDown() === false) { |
|
await this.reload(); |
|
return this.#local(protectedHeader, token); |
|
} |
|
} |
|
throw err; |
|
} |
|
} |
|
async reload() { |
|
if (this.#pendingFetch && isCloudflareWorkers()) { |
|
this.#pendingFetch = undefined; |
|
} |
|
this.#pendingFetch ||= fetchJwks(this.#url.href, this.#headers, AbortSignal.timeout(this.#timeoutDuration), this.#customFetch) |
|
.then((json) => { |
|
this.#local = createLocalJWKSet(json); |
|
if (this.#cache) { |
|
this.#cache.uat = Date.now(); |
|
this.#cache.jwks = json; |
|
} |
|
this.#jwksTimestamp = Date.now(); |
|
this.#pendingFetch = undefined; |
|
}) |
|
.catch((err) => { |
|
this.#pendingFetch = undefined; |
|
throw err; |
|
}); |
|
await this.#pendingFetch; |
|
} |
|
} |
|
export function createRemoteJWKSet(url, options) { |
|
const set = new RemoteJWKSet(url, options); |
|
const remoteJWKSet = async (protectedHeader, token) => set.getKey(protectedHeader, token); |
|
Object.defineProperties(remoteJWKSet, { |
|
coolingDown: { |
|
get: () => set.coolingDown(), |
|
enumerable: true, |
|
configurable: false, |
|
}, |
|
fresh: { |
|
get: () => set.fresh(), |
|
enumerable: true, |
|
configurable: false, |
|
}, |
|
reload: { |
|
value: () => set.reload(), |
|
enumerable: true, |
|
configurable: false, |
|
writable: false, |
|
}, |
|
reloading: { |
|
get: () => set.pendingFetch(), |
|
enumerable: true, |
|
configurable: false, |
|
}, |
|
jwks: { |
|
value: () => set.jwks(), |
|
enumerable: true, |
|
configurable: false, |
|
writable: false, |
|
}, |
|
}); |
|
return remoteJWKSet; |
|
} |
|
|