mirror of
https://github.com/hanzoai/jose.git
synced 2026-10-30 14:46:24 +00:00
171 lines
5.6 KiB
TypeScript
171 lines
5.6 KiB
TypeScript
import fetchJwks from '../runtime/fetch_jwks.ts'
|
|
|
|
import type { KeyLike, JWSHeaderParameters, FlattenedJWSInput } from '../types.d.ts'
|
|
import { JWKSInvalid, JWKSNoMatchingKey } from '../util/errors.ts'
|
|
|
|
import { isJWKSLike, LocalJWKSet } from './local.ts'
|
|
|
|
function isCloudflareWorkers() {
|
|
return (
|
|
// @ts-ignore
|
|
typeof WebSocketPair !== 'undefined' ||
|
|
// @ts-ignore
|
|
(typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers') ||
|
|
// @ts-ignore
|
|
(typeof EdgeRuntime !== 'undefined' && EdgeRuntime === 'vercel')
|
|
)
|
|
}
|
|
|
|
/** Options for the remote JSON Web Key Set. */
|
|
export interface RemoteJWKSetOptions {
|
|
/**
|
|
* Timeout (in milliseconds) for the HTTP request. When reached the request will be aborted and
|
|
* the verification will fail. Default is 5000 (5 seconds).
|
|
*/
|
|
timeoutDuration?: number
|
|
|
|
/**
|
|
* Duration (in milliseconds) for which no more HTTP requests will be triggered after a previous
|
|
* successful fetch. Default is 30000 (30 seconds).
|
|
*/
|
|
cooldownDuration?: number
|
|
|
|
/**
|
|
* Maximum time (in milliseconds) between successful HTTP requests. Default is 600000 (10
|
|
* minutes).
|
|
*/
|
|
cacheMaxAge?: number | typeof Infinity
|
|
|
|
/**
|
|
* An instance of {@link https://nodejs.org/api/http.html#class-httpagent http.Agent} or
|
|
* {@link https://nodejs.org/api/https.html#class-httpsagent https.Agent} to pass to the
|
|
* {@link https://nodejs.org/api/http.html#httpgetoptions-callback http.get} or
|
|
* {@link https://nodejs.org/api/https.html#httpsgetoptions-callback https.get} method's options.
|
|
* Use when behind an http(s) proxy. This is a Node.js runtime specific option, it is ignored when
|
|
* used outside of Node.js runtime.
|
|
*/
|
|
agent?: any
|
|
|
|
/** Optional headers to be sent with the HTTP request. */
|
|
headers?: Record<string, string>
|
|
}
|
|
|
|
class RemoteJWKSet<T extends KeyLike = KeyLike> extends LocalJWKSet<T> {
|
|
private _url: URL
|
|
|
|
private _timeoutDuration: number
|
|
|
|
private _cooldownDuration: number
|
|
|
|
private _cacheMaxAge: number
|
|
|
|
private _jwksTimestamp?: number
|
|
|
|
private _pendingFetch?: Promise<unknown>
|
|
|
|
private _options: Pick<RemoteJWKSetOptions, 'agent' | 'headers'>
|
|
|
|
constructor(url: unknown, options?: RemoteJWKSetOptions) {
|
|
super({ keys: [] })
|
|
|
|
this._jwks = undefined
|
|
|
|
if (!(url instanceof URL)) {
|
|
throw new TypeError('url must be an instance of URL')
|
|
}
|
|
this._url = new URL(url.href)
|
|
this._options = { agent: options?.agent, headers: options?.headers }
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
async getKey(protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput): Promise<T> {
|
|
if (!this._jwks || !this.fresh()) {
|
|
await this.reload()
|
|
}
|
|
|
|
try {
|
|
return await super.getKey(protectedHeader, token)
|
|
} catch (err) {
|
|
if (err instanceof JWKSNoMatchingKey) {
|
|
if (this.coolingDown() === false) {
|
|
await this.reload()
|
|
return super.getKey(protectedHeader, token)
|
|
}
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
async reload() {
|
|
// Do not assume a fetch created in another request reliably resolves
|
|
// see https://github.com/panva/jose/issues/355 and https://github.com/panva/jose/issues/509
|
|
if (this._pendingFetch && isCloudflareWorkers()) {
|
|
this._pendingFetch = undefined
|
|
}
|
|
|
|
this._pendingFetch ||= fetchJwks(this._url, this._timeoutDuration, this._options)
|
|
.then((json) => {
|
|
if (!isJWKSLike(json)) {
|
|
throw new JWKSInvalid('JSON Web Key Set malformed')
|
|
}
|
|
|
|
this._jwks = { keys: json.keys }
|
|
this._jwksTimestamp = Date.now()
|
|
this._pendingFetch = undefined
|
|
})
|
|
.catch((err: Error) => {
|
|
this._pendingFetch = undefined
|
|
throw err
|
|
})
|
|
|
|
await this._pendingFetch
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a function that resolves to a key object downloaded from a remote endpoint returning a
|
|
* JSON Web Key Set, that is, for example, an OAuth 2.0 or OIDC jwks_uri. The JSON Web Key Set is
|
|
* fetched when no key matches the selection process but only as frequently as the
|
|
* `cooldownDuration` option allows to prevent abuse.
|
|
*
|
|
* It uses the "alg" (JWS Algorithm) Header Parameter to determine the right JWK "kty" (Key Type),
|
|
* then proceeds to match the JWK "kid" (Key ID) with one found in the JWS Header Parameters (if
|
|
* there is one) while also respecting the JWK "use" (Public Key Use) and JWK "key_ops" (Key
|
|
* Operations) Parameters (if they are present on the JWK).
|
|
*
|
|
* Only a single public key must match the selection process. As shown in the example below when
|
|
* multiple keys get matched it is possible to opt-in to iterate over the matched keys and attempt
|
|
* verification in an iterative manner.
|
|
*
|
|
* @param url URL to fetch the JSON Web Key Set from.
|
|
* @param options Options for the remote JSON Web Key Set.
|
|
*/
|
|
export function createRemoteJWKSet<T extends KeyLike = KeyLike>(
|
|
url: URL,
|
|
options?: RemoteJWKSetOptions,
|
|
) {
|
|
const set = new RemoteJWKSet<T>(url, options)
|
|
return async function (
|
|
protectedHeader?: JWSHeaderParameters,
|
|
token?: FlattenedJWSInput,
|
|
): Promise<T> {
|
|
return set.getKey(protectedHeader, token)
|
|
}
|
|
}
|