-
-
Notifications
You must be signed in to change notification settings - Fork 316
/
remote.ts
393 lines (352 loc) · 12.3 KB
/
remote.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
import fetchJwks from '../runtime/fetch_jwks.js'
import type { KeyLike, JWSHeaderParameters, FlattenedJWSInput, JSONWebKeySet } from '../types.d'
import { JWKSNoMatchingKey } from '../util/errors.js'
import { createLocalJWKSet } from './local.js'
import isObject from '../lib/is_object.js'
function isCloudflareWorkers() {
return (
// @ts-ignore
typeof WebSocketPair !== 'undefined' ||
// @ts-ignore
(typeof navigator !== 'undefined' && navigator.userAgent === 'Cloudflare-Workers') ||
// @ts-ignore
(typeof EdgeRuntime !== 'undefined' && EdgeRuntime === 'vercel')
)
}
// An explicit user-agent in browser environment is a trigger for CORS preflight requests which
// are not needed for our request, so we're omitting setting a default user-agent in browser
// environments.
let USER_AGENT: string
// @ts-ignore
if (typeof navigator === 'undefined' || !navigator.userAgent?.startsWith?.('Mozilla/5.0 ')) {
const NAME = 'jose'
const VERSION = 'v5.9.6'
USER_AGENT = `${NAME}/${VERSION}`
}
/**
* DANGER ZONE - This option has security implications that must be understood, assessed for
* applicability, and accepted before use. It is critical that the JSON Web Key Set cache only be
* writable by your own code.
*
* This option is intended for cloud computing runtimes that cannot keep an in memory cache between
* their code's invocations. Use in runtimes where an in memory cache between requests is available
* is not desirable.
*
* When passed to {@link jwks/remote.createRemoteJWKSet createRemoteJWKSet} this allows the passed in
* object to:
*
* - Serve as an initial value for the JSON Web Key Set that the module would otherwise need to
* trigger an HTTP request for
* - Have the JSON Web Key Set the function optionally ended up triggering an HTTP request for
* assigned to it as properties
*
* The intended use pattern is:
*
* - Before verifying with {@link jwks/remote.createRemoteJWKSet createRemoteJWKSet} you pull the
* previously cached object from a low-latency key-value store offered by the cloud computing
* runtime it is executed on;
* - Default to an empty object `{}` instead when there's no previously cached value;
* - Pass it in as {@link RemoteJWKSetOptions[jwksCache]};
* - Afterwards, update the key-value storage if the {@link ExportedJWKSCache.uat `uat`} property of
* the object has changed.
*
* @example
*
* ```ts
* // Prerequisites
* let url!: URL
* let jwt!: string
* let getPreviouslyCachedJWKS!: () => Promise<jose.ExportedJWKSCache>
* let storeNewJWKScache!: (cache: jose.ExportedJWKSCache) => Promise<void>
*
* // Load JSON Web Key Set cache
* const jwksCache: jose.JWKSCacheInput = (await getPreviouslyCachedJWKS()) || {}
* const { uat } = jwksCache
*
* const JWKS = jose.createRemoteJWKSet(url, {
* [jose.jwksCache]: jwksCache,
* })
*
* // Use JSON Web Key Set cache
* await jose.jwtVerify(jwt, JWKS)
*
* if (uat !== jwksCache.uat) {
* // Update JSON Web Key Set cache
* await storeNewJWKScache(jwksCache)
* }
* ```
*/
export const jwksCache: unique symbol = Symbol()
/** 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
/**
* Headers to be sent with the HTTP request. Default is that `User-Agent: jose/v${version}` header
* is added unless the runtime is a browser in which adding an explicit headers fetch
* configuration would cause an unnecessary CORS preflight request.
*/
headers?: Record<string, string>
/** See {@link jwksCache}. */
[jwksCache]?: JWKSCacheInput
}
export interface ExportedJWKSCache {
jwks: JSONWebKeySet
uat: number
}
export type JWKSCacheInput = ExportedJWKSCache | Record<string, never>
function isFreshJwksCache(input: unknown, cacheMaxAge: number): input is ExportedJWKSCache {
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<JSONWebKeySet>(input.jwks) ||
!Array.isArray(input.jwks.keys) ||
!Array.prototype.every.call(input.jwks.keys, isObject)
) {
return false
}
return true
}
class RemoteJWKSet<KeyLikeType extends KeyLike = KeyLike> {
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'>
private _local!: ReturnType<typeof createLocalJWKSet<KeyLikeType>>
private _cache?: JWKSCacheInput
constructor(url: unknown, options?: RemoteJWKSetOptions) {
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
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)
}
}
}
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<KeyLikeType> {
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() {
// 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
}
const headers = new Headers(this._options.headers)
if (USER_AGENT && !headers.has('User-Agent')) {
headers.set('User-Agent', USER_AGENT)
this._options.headers = Object.fromEntries(headers.entries())
}
this._pendingFetch ||= fetchJwks(this._url, this._timeoutDuration, this._options)
.then((json) => {
this._local = createLocalJWKSet(json as unknown as JSONWebKeySet)
if (this._cache) {
this._cache.uat = Date.now()
this._cache.jwks = json as unknown as JSONWebKeySet
}
this._jwksTimestamp = Date.now()
this._pendingFetch = undefined
})
.catch((err: Error) => {
this._pendingFetch = undefined
throw err
})
await this._pendingFetch
}
}
/**
* Returns a function that resolves a JWS JOSE Header to a public 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.
*
* Note: The function's purpose is to resolve public keys used for verifying signatures and will not
* work for public encryption keys.
*
* This function is exported (as a named export) from the main `'jose'` module entry point as well
* as from its subpath export `'jose/jwks/remote'`.
*
* @example
*
* ```js
* const JWKS = jose.createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs'))
*
* const { payload, protectedHeader } = await jose.jwtVerify(jwt, JWKS, {
* issuer: 'urn:example:issuer',
* audience: 'urn:example:audience',
* })
* console.log(protectedHeader)
* console.log(payload)
* ```
*
* @example
*
* Opting-in to multiple JWKS matches using `createRemoteJWKSet`
*
* ```js
* const options = {
* issuer: 'urn:example:issuer',
* audience: 'urn:example:audience',
* }
* const { payload, protectedHeader } = await jose
* .jwtVerify(jwt, JWKS, options)
* .catch(async (error) => {
* if (error?.code === 'ERR_JWKS_MULTIPLE_MATCHING_KEYS') {
* for await (const publicKey of error) {
* try {
* return await jose.jwtVerify(jwt, publicKey, options)
* } catch (innerError) {
* if (innerError?.code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED') {
* continue
* }
* throw innerError
* }
* }
* throw new jose.errors.JWSSignatureVerificationFailed()
* }
*
* throw error
* })
* console.log(protectedHeader)
* console.log(payload)
* ```
*
* @param url URL to fetch the JSON Web Key Set from.
* @param options Options for the remote JSON Web Key Set.
*/
export function createRemoteJWKSet<KeyLikeType extends KeyLike = KeyLike>(
url: URL,
options?: RemoteJWKSetOptions,
): {
(protectedHeader?: JWSHeaderParameters, token?: FlattenedJWSInput): Promise<KeyLikeType>
/** @ignore */
coolingDown: boolean
/** @ignore */
fresh: boolean
/** @ignore */
reloading: boolean
/** @ignore */
reload: () => Promise<void>
/** @ignore */
jwks: () => JSONWebKeySet | undefined
} {
const set = new RemoteJWKSet<KeyLikeType>(url, options)
const remoteJWKSet = async (
protectedHeader?: JWSHeaderParameters,
token?: FlattenedJWSInput,
): Promise<KeyLikeType> => 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: {
// @ts-expect-error
get: () => !!set._pendingFetch,
enumerable: true,
configurable: false,
},
jwks: {
// @ts-expect-error
value: () => set._local?.jwks(),
enumerable: true,
configurable: false,
writable: false,
},
})
// @ts-expect-error
return remoteJWKSet
}
/**
* @ignore
*
* @deprecated Use {@link jwksCache}.
*/
export const experimental_jwksCache = jwksCache