@cloudflare/workers-oauth-provider 1.2.1 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/oauth-provider.js
CHANGED
|
@@ -107,15 +107,27 @@ function negotiateTokenEndpointAuthMethod(options) {
|
|
|
107
107
|
const advertised = [...new Set([...preferredMethod === void 0 ? [] : [preferredMethod], ...advertisedMethods])];
|
|
108
108
|
throw new Error(`${context} does not support an accepted token endpoint authentication method. Supported methods: ${acceptedMethods.join(", ")}. Client advertised: ${advertised.length > 0 ? advertised.join(", ") : "(none)"}`);
|
|
109
109
|
}
|
|
110
|
-
|
|
110
|
+
/** Asymmetric method a CIMD client may use when its document supplies usable keys. */
|
|
111
|
+
const PRIVATE_KEY_JWT = "private_key_jwt";
|
|
112
|
+
/**
|
|
113
|
+
* Every token endpoint authentication method a CIMD client may use with this server, in the
|
|
114
|
+
* server's order of preference.
|
|
115
|
+
*
|
|
116
|
+
* A metadata document has no registration response, so the client learns nothing about which
|
|
117
|
+
* method this server picked. It chooses one per request from this server's metadata. So every
|
|
118
|
+
* method both sides support stays usable: a document that offers `none` and `private_key_jwt`
|
|
119
|
+
* (ChatGPT's does) works with either, and a document that offers only `private_key_jwt` must
|
|
120
|
+
* use it.
|
|
121
|
+
*/
|
|
122
|
+
function negotiateCimdTokenEndpointAuthMethods(server, preferredMethod, supportedMethods, privateKeyJwt) {
|
|
111
123
|
if (preferredMethod !== void 0 && SHARED_SECRET_TOKEN_ENDPOINT_AUTH_METHODS.has(preferredMethod)) throw new Error(`CIMD clients cannot use symmetric token endpoint authentication method: ${preferredMethod}`);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
});
|
|
124
|
+
if (preferredMethod !== void 0 && supportedMethods !== void 0 && !supportedMethods.includes(preferredMethod)) throw new Error("token_endpoint_auth_method must be included in token_endpoint_auth_methods_supported");
|
|
125
|
+
const acceptedMethods = [...server.tokenEndpointAuthMethods.filter((method) => !SHARED_SECRET_TOKEN_ENDPOINT_AUTH_METHODS.has(method)), ...privateKeyJwt ? [PRIVATE_KEY_JWT] : []];
|
|
126
|
+
const advertisedMethods = supportedMethods ?? [preferredMethod ?? "none"];
|
|
127
|
+
const methods = acceptedMethods.filter((method) => advertisedMethods.includes(method));
|
|
128
|
+
if (methods.length > 0) return methods;
|
|
129
|
+
const advertised = [...new Set([...preferredMethod === void 0 ? [] : [preferredMethod], ...advertisedMethods])];
|
|
130
|
+
throw new Error(`CIMD client does not support an accepted token endpoint authentication method. Supported methods: ${acceptedMethods.join(", ")}. Client advertised: ${advertised.length > 0 ? advertised.join(", ") : "(none)"}`);
|
|
119
131
|
}
|
|
120
132
|
/**
|
|
121
133
|
* Negotiates choice-valued authentication metadata while keeping DCR grant and
|
|
@@ -146,13 +158,18 @@ function negotiateDynamicClientRegistrationCapabilities(server, client) {
|
|
|
146
158
|
* The effective subset is still checked for grant/response consistency, and
|
|
147
159
|
* token endpoint authentication must have a mutually supported method.
|
|
148
160
|
*/
|
|
149
|
-
function negotiateCimdClientCapabilities(server, client) {
|
|
161
|
+
function negotiateCimdClientCapabilities(server, client, options = { privateKeyJwt: false }) {
|
|
162
|
+
const tokenEndpointAuthMethods = negotiateCimdTokenEndpointAuthMethods(server, client.tokenEndpointAuthMethod, client.tokenEndpointAuthMethodsSupported, options.privateKeyJwt);
|
|
150
163
|
const effective = {
|
|
151
164
|
grantTypes: client.grantTypes.filter((grantType) => server.grantTypes.includes(grantType)),
|
|
152
165
|
responseTypes: client.responseTypes.filter((responseType) => server.responseTypes.includes(responseType)),
|
|
153
|
-
tokenEndpointAuthMethod:
|
|
166
|
+
tokenEndpointAuthMethod: tokenEndpointAuthMethods[0],
|
|
167
|
+
tokenEndpointAuthMethods
|
|
154
168
|
};
|
|
155
|
-
validateClientCapabilities(
|
|
169
|
+
validateClientCapabilities({
|
|
170
|
+
...server,
|
|
171
|
+
tokenEndpointAuthMethods: [...server.tokenEndpointAuthMethods, PRIVATE_KEY_JWT]
|
|
172
|
+
}, effective);
|
|
156
173
|
return effective;
|
|
157
174
|
}
|
|
158
175
|
function validateAuthorizationResponseType(server, responseType, clientResponseTypes) {
|
|
@@ -190,6 +207,10 @@ function isValidOAuthScopeToken(scopeToken) {
|
|
|
190
207
|
return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
|
|
191
208
|
}
|
|
192
209
|
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region package.json
|
|
212
|
+
var version = "1.2.3";
|
|
213
|
+
|
|
193
214
|
//#endregion
|
|
194
215
|
//#region src/oauth-http.ts
|
|
195
216
|
/**
|
|
@@ -197,6 +218,13 @@ function isValidOAuthScopeToken(scopeToken) {
|
|
|
197
218
|
* and the resource-server host (`OAuthResourceServer`), so the two can't drift apart.
|
|
198
219
|
*/
|
|
199
220
|
/**
|
|
221
|
+
* `User-Agent` sent on the library's own outbound fetches (EMA JWKS, Client ID Metadata Documents).
|
|
222
|
+
* Workers' `fetch` sends none by default, and common WAF rule sets (AWS WAF's `NoUserAgent_HEADER`,
|
|
223
|
+
* for one) block requests without it, so an IdP or client behind one would answer `403`. The RFC 9110
|
|
224
|
+
* product token carries the release, and the comment points an operator at who is calling.
|
|
225
|
+
*/
|
|
226
|
+
const OUTBOUND_USER_AGENT = `workers-oauth-provider/${version} (+https://github.com/cloudflare/workers-oauth-provider)`;
|
|
227
|
+
/**
|
|
200
228
|
* Adds CORS headers for a browser client. The request's `Origin` is reflected: bearer tokens are
|
|
201
229
|
* not ambient credentials, so any origin may present one it holds. Browser OAuth and MCP clients
|
|
202
230
|
* need `WWW-Authenticate` (discovery, step-up) and `Retry-After` (backoff) exposed. Headers the
|
|
@@ -307,956 +335,1226 @@ var OAuthError = class extends Error {
|
|
|
307
335
|
};
|
|
308
336
|
|
|
309
337
|
//#endregion
|
|
310
|
-
//#region src/
|
|
338
|
+
//#region src/ema/constants.ts
|
|
311
339
|
/**
|
|
312
|
-
*
|
|
313
|
-
*
|
|
340
|
+
* Constants for MCP Enterprise-Managed Authorization (EMA).
|
|
341
|
+
*
|
|
342
|
+
* Co-located so that anyone touching the EMA code path sees every magic
|
|
343
|
+
* number in one place. Public-facing defaults can be overridden via
|
|
344
|
+
* `EmaOptions`.
|
|
314
345
|
*/
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (host === "localhost" || host === "::1" || host === "[::1]") return true;
|
|
318
|
-
const ipv4 = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
319
|
-
return ipv4 !== null && ipv4.slice(1).every((octet) => Number(octet) <= 255);
|
|
320
|
-
}
|
|
346
|
+
/** JWT `typ` header value required for ID-JAG assertions (RFC 8725 §3.11). */
|
|
347
|
+
const EMA_ID_JAG_JWT_TYPE = "oauth-id-jag+jwt";
|
|
321
348
|
/**
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
* http://localhost. Workers are always served over `https`, so there is no
|
|
325
|
-
* deployment in which cleartext OAuth traffic to a remote host is correct,
|
|
326
|
-
* and no option exists to allow it.
|
|
349
|
+
* Grant-profile URN advertised in `authorization_grant_profiles_supported`
|
|
350
|
+
* when EMA is configured (MCP Enterprise-Managed Authorization spec).
|
|
327
351
|
*/
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
/**
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
352
|
+
const EMA_ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag";
|
|
353
|
+
/** Maximum compact JWT assertion size accepted at the token endpoint. */
|
|
354
|
+
const EMA_MAX_JWT_BYTES = 16 * 1024;
|
|
355
|
+
/** Maximum JWKS response size accepted from a trusted enterprise IdP. */
|
|
356
|
+
const EMA_JWKS_MAX_SIZE_BYTES = 64 * 1024;
|
|
357
|
+
/** Request timeout for JWKS fetches. */
|
|
358
|
+
const EMA_JWKS_FETCH_TIMEOUT_MS = 1e4;
|
|
359
|
+
/** Default JWKS cache TTL. */
|
|
360
|
+
const EMA_DEFAULT_JWKS_CACHE_TTL_SECONDS = 300;
|
|
361
|
+
/** Default allowed clock skew for ID-JAG time claim validation. */
|
|
362
|
+
const EMA_DEFAULT_CLOCK_SKEW_SECONDS = 60;
|
|
363
|
+
/** Default maximum accepted ID-JAG lifetime. */
|
|
364
|
+
const EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS = 300;
|
|
342
365
|
/**
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
* `https://example.com` through a URL parser as `https://example.com/` still
|
|
347
|
-
* names it. Port, path, query, a trailing slash after a path segment, user
|
|
348
|
-
* information, and every other byte remain significant.
|
|
366
|
+
* Minimum cool-down between JWKS force-refreshes per issuer.
|
|
367
|
+
* Defends against attackers that send many assertions with random `kid`
|
|
368
|
+
* values to amplify load on the IdP's JWKS endpoint.
|
|
349
369
|
*/
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
370
|
+
const EMA_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS = 30;
|
|
371
|
+
/** Floor for the `jti` replay marker's KV TTL; KV rejects a sub-60s expiration. */
|
|
372
|
+
const EMA_JTI_MIN_TTL_SECONDS = 60;
|
|
373
|
+
/** Default JWT signing algorithm assumed for a trusted issuer. */
|
|
374
|
+
const EMA_DEFAULT_JWT_ALGORITHM = "RS256";
|
|
375
|
+
/** JWT signing algorithms supported by the built-in WebCrypto verifier. */
|
|
376
|
+
const EMA_SUPPORTED_JWT_ALGORITHMS = new Set(["RS256", "ES256"]);
|
|
377
|
+
|
|
378
|
+
//#endregion
|
|
379
|
+
//#region src/ema/result.ts
|
|
380
|
+
const ok = (value) => ({
|
|
381
|
+
ok: true,
|
|
382
|
+
value
|
|
383
|
+
});
|
|
384
|
+
const err = (error) => ({
|
|
385
|
+
ok: false,
|
|
386
|
+
error
|
|
387
|
+
});
|
|
356
388
|
/**
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
* the
|
|
389
|
+
* Map an internal `EmaValidationError` to its public OAuth error code and
|
|
390
|
+
* description. Most validation failures collapse to a single generic message
|
|
391
|
+
* to avoid leaking which check failed to attackers probing the IdP. The
|
|
392
|
+
* exceptions are RFC-prescribed distinct codes (`invalid_target` for
|
|
393
|
+
* RFC 8707 resource issues, `invalid_request` for malformed input).
|
|
360
394
|
*/
|
|
361
|
-
function
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
395
|
+
function emaErrorToWire(e) {
|
|
396
|
+
switch (e.reason) {
|
|
397
|
+
case "assertion_missing": return {
|
|
398
|
+
code: "invalid_request",
|
|
399
|
+
message: "assertion is required"
|
|
400
|
+
};
|
|
401
|
+
case "invalid_scope_param": return {
|
|
402
|
+
code: "invalid_request",
|
|
403
|
+
message: "Invalid scope parameter format"
|
|
404
|
+
};
|
|
405
|
+
case "resource_invalid":
|
|
406
|
+
case "resource_mismatch": return {
|
|
407
|
+
code: "invalid_target",
|
|
408
|
+
message: "Invalid resource"
|
|
409
|
+
};
|
|
410
|
+
case "mapper_denied":
|
|
411
|
+
case "mapper_threw": return {
|
|
412
|
+
code: "invalid_grant",
|
|
413
|
+
message: "Assertion was not authorized"
|
|
414
|
+
};
|
|
415
|
+
case "invalid_mapped_user": return {
|
|
416
|
+
code: "invalid_grant",
|
|
417
|
+
message: "Invalid mapped user"
|
|
418
|
+
};
|
|
419
|
+
case "invalid_mapped_scope": return {
|
|
420
|
+
code: "invalid_grant",
|
|
421
|
+
message: "Invalid mapped scope"
|
|
422
|
+
};
|
|
423
|
+
case "invalid_mapped_props": return {
|
|
424
|
+
code: "invalid_grant",
|
|
425
|
+
message: "Invalid mapped props"
|
|
426
|
+
};
|
|
427
|
+
case "invalid_mapped_ttl": return {
|
|
428
|
+
code: "invalid_grant",
|
|
429
|
+
message: "Invalid access token TTL"
|
|
430
|
+
};
|
|
431
|
+
case "assertion_expired_after_processing": return {
|
|
432
|
+
code: "invalid_grant",
|
|
433
|
+
message: "Assertion has expired"
|
|
434
|
+
};
|
|
435
|
+
case "assertion_too_large":
|
|
436
|
+
case "assertion_malformed":
|
|
437
|
+
case "invalid_typ":
|
|
438
|
+
case "invalid_alg":
|
|
439
|
+
case "issuer_not_trusted":
|
|
440
|
+
case "no_matching_key":
|
|
441
|
+
case "signature_failed":
|
|
442
|
+
case "jwks_fetch_failed":
|
|
443
|
+
case "invalid_claim":
|
|
444
|
+
case "aud_mismatch":
|
|
445
|
+
case "unsupported_claim":
|
|
446
|
+
case "expired":
|
|
447
|
+
case "iat_in_future":
|
|
448
|
+
case "nbf_in_future":
|
|
449
|
+
case "lifetime_too_long":
|
|
450
|
+
case "replayed":
|
|
451
|
+
case "client_id_mismatch": return {
|
|
452
|
+
code: "invalid_grant",
|
|
453
|
+
message: "Invalid assertion"
|
|
454
|
+
};
|
|
377
455
|
}
|
|
378
|
-
const asciiLower = (value) => value.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
|
379
|
-
return asciiLower(resource.slice(0, schemeSeparator)) + "://" + authority.slice(0, hostStart) + asciiLower(authority.slice(hostStart, hostEnd)) + authority.slice(hostEnd) + resource.slice(authorityEnd);
|
|
380
|
-
}
|
|
381
|
-
/** RFC 3986 §6.2.3: for http and https an empty path is equivalent to "/". */
|
|
382
|
-
function normalizeEmptyPath(resource) {
|
|
383
|
-
const authorityStart = resource.indexOf("://") + 3;
|
|
384
|
-
const pathOffset = resource.slice(authorityStart).search(/[/?#]/);
|
|
385
|
-
if (pathOffset === -1) return `${resource}/`;
|
|
386
|
-
const pathStart = authorityStart + pathOffset;
|
|
387
|
-
return resource[pathStart] === "/" ? resource : `${resource.slice(0, pathStart)}/${resource.slice(pathStart)}`;
|
|
388
456
|
}
|
|
457
|
+
|
|
458
|
+
//#endregion
|
|
459
|
+
//#region src/ema/util.ts
|
|
389
460
|
/**
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
*
|
|
461
|
+
* Tiny utility helpers used by EMA adapters.
|
|
462
|
+
*
|
|
463
|
+
* Lives in `src/ema/util.ts` rather than `src/util.ts` to keep the EMA
|
|
464
|
+
* module self-contained — the main `oauth-provider.ts` reaches into here
|
|
465
|
+
* only through the adapters' public interfaces.
|
|
393
466
|
*/
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
467
|
+
/** SHA-256 a string and return its hex digest. */
|
|
468
|
+
async function sha256Hex$1(input) {
|
|
469
|
+
const data = new TextEncoder().encode(input);
|
|
470
|
+
const buffer = await crypto.subtle.digest("SHA-256", data);
|
|
471
|
+
return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
397
472
|
}
|
|
398
473
|
|
|
399
474
|
//#endregion
|
|
400
|
-
//#region src/
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
return
|
|
475
|
+
//#region src/ema/jti.ts
|
|
476
|
+
/**
|
|
477
|
+
* Default `EmaJtiStore`: KV-backed `jti` replay marker.
|
|
478
|
+
*
|
|
479
|
+
* KV is eventually-consistent and does not provide compare-and-set, so two
|
|
480
|
+
* concurrent requests with the same `jti` can both observe "not seen" and
|
|
481
|
+
* succeed — the trade-off accepted here. Surrounding claim checks
|
|
482
|
+
* (signature, `exp`, `nbf`, `aud`, `resource`, client binding) constrain
|
|
483
|
+
* the practical attack window.
|
|
484
|
+
*/
|
|
485
|
+
/** Storage key prefix for replay markers. Stable across versions. */
|
|
486
|
+
const EMA_JTI_KV_PREFIX = "enterprise-jti:";
|
|
487
|
+
/**
|
|
488
|
+
* Create the default KV-backed JTI store. KV TTL handles cleanup.
|
|
489
|
+
*
|
|
490
|
+
* @param keyPrefix Storage key prefix, so each kind of assertion keeps its own replay namespace.
|
|
491
|
+
*/
|
|
492
|
+
function createKvJtiStore(keyPrefix = EMA_JTI_KV_PREFIX) {
|
|
493
|
+
return { async markUsed({ issuer, jti, exp, now, env }) {
|
|
494
|
+
const ttl = Math.max(EMA_JTI_MIN_TTL_SECONDS, exp - now);
|
|
495
|
+
const key = `${keyPrefix}${await sha256Hex$1(`${issuer}\n${jti}`)}`;
|
|
496
|
+
if (await env.OAUTH_KV.get(key)) return err({
|
|
497
|
+
reason: "replayed",
|
|
498
|
+
jti
|
|
499
|
+
});
|
|
500
|
+
await env.OAUTH_KV.put(key, "1", { expirationTtl: ttl });
|
|
501
|
+
return ok(void 0);
|
|
502
|
+
} };
|
|
419
503
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
504
|
+
|
|
505
|
+
//#endregion
|
|
506
|
+
//#region src/ema/jwks.ts
|
|
507
|
+
/**
|
|
508
|
+
* Default `EmaJwksProvider`: fetches IdP signing keys with caching and a
|
|
509
|
+
* force-refresh cool-down. The cool-down defends against attackers that
|
|
510
|
+
* spam random `kid` values to amplify load on the IdP's JWKS endpoint.
|
|
511
|
+
*
|
|
512
|
+
* The cache lives inside the returned provider closure, so each
|
|
513
|
+
* `OAuthProvider` instance has its own cache — no cross-instance bleed.
|
|
514
|
+
*/
|
|
515
|
+
/** Create the default JWKS provider — a closure with its own private cache. */
|
|
516
|
+
function createDefaultJwksProvider(opts = {}) {
|
|
517
|
+
const cache = /* @__PURE__ */ new Map();
|
|
518
|
+
const cacheTtl = opts.cacheTtlSeconds ?? EMA_DEFAULT_JWKS_CACHE_TTL_SECONDS;
|
|
519
|
+
return { async fetch(issuer, { forceRefresh, now }) {
|
|
520
|
+
const cached = cache.get(issuer.issuer);
|
|
521
|
+
if (!forceRefresh && cached && cached.expiresAt > now) return ok(cached.jwks);
|
|
522
|
+
if (forceRefresh && cached && cached.nextForceRefreshAllowedAt > now) return ok(cached.jwks);
|
|
523
|
+
const abortController = new AbortController();
|
|
524
|
+
const timeoutId = setTimeout(() => abortController.abort(), EMA_JWKS_FETCH_TIMEOUT_MS);
|
|
525
|
+
try {
|
|
526
|
+
const response = await fetch(issuer.jwksUri, {
|
|
527
|
+
headers: {
|
|
528
|
+
Accept: "application/json",
|
|
529
|
+
"User-Agent": OUTBOUND_USER_AGENT
|
|
530
|
+
},
|
|
531
|
+
signal: abortController.signal,
|
|
532
|
+
cf: { cacheEverything: true }
|
|
533
|
+
});
|
|
534
|
+
if (!response.ok) return err({
|
|
535
|
+
reason: "jwks_fetch_failed",
|
|
536
|
+
status: response.status
|
|
537
|
+
});
|
|
538
|
+
const contentLength = response.headers.get("content-length");
|
|
539
|
+
if (contentLength && parseInt(contentLength, 10) > EMA_JWKS_MAX_SIZE_BYTES) return err({
|
|
540
|
+
reason: "jwks_fetch_failed",
|
|
541
|
+
status: response.status
|
|
542
|
+
});
|
|
543
|
+
const rawJwks = await readJsonWithSizeLimit$1(response, EMA_JWKS_MAX_SIZE_BYTES);
|
|
544
|
+
if (!rawJwks.ok) return err({ reason: "jwks_fetch_failed" });
|
|
545
|
+
if (!Array.isArray(rawJwks.value.keys)) return err({ reason: "jwks_fetch_failed" });
|
|
546
|
+
const jwks = { keys: rawJwks.value.keys };
|
|
547
|
+
cache.delete(issuer.issuer);
|
|
548
|
+
if (opts.maxEntries !== void 0 && cache.size >= opts.maxEntries) {
|
|
549
|
+
const oldest = cache.keys().next();
|
|
550
|
+
if (!oldest.done) cache.delete(oldest.value);
|
|
551
|
+
}
|
|
552
|
+
cache.set(issuer.issuer, {
|
|
553
|
+
jwks,
|
|
554
|
+
expiresAt: now + cacheTtl,
|
|
555
|
+
nextForceRefreshAllowedAt: now + EMA_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS
|
|
556
|
+
});
|
|
557
|
+
return ok(jwks);
|
|
558
|
+
} catch {
|
|
559
|
+
return err({ reason: "jwks_fetch_failed" });
|
|
560
|
+
} finally {
|
|
561
|
+
clearTimeout(timeoutId);
|
|
562
|
+
}
|
|
563
|
+
} };
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Streaming JSON reader that rejects responses exceeding `maxBytes`.
|
|
567
|
+
* Bounds memory consumption before we attempt to JSON-parse the body.
|
|
568
|
+
*/
|
|
569
|
+
async function readJsonWithSizeLimit$1(response, maxBytes) {
|
|
570
|
+
if (!response.body) return { ok: false };
|
|
571
|
+
const reader = response.body.getReader();
|
|
572
|
+
const chunks = [];
|
|
573
|
+
let total = 0;
|
|
574
|
+
try {
|
|
575
|
+
while (true) {
|
|
576
|
+
const { done, value } = await reader.read();
|
|
577
|
+
if (done) break;
|
|
578
|
+
total += value.byteLength;
|
|
579
|
+
if (total > maxBytes) {
|
|
580
|
+
reader.cancel();
|
|
581
|
+
return { ok: false };
|
|
582
|
+
}
|
|
583
|
+
chunks.push(value);
|
|
584
|
+
}
|
|
585
|
+
} finally {
|
|
586
|
+
reader.releaseLock();
|
|
587
|
+
}
|
|
588
|
+
const merged = new Uint8Array(total);
|
|
589
|
+
let offset = 0;
|
|
590
|
+
for (const chunk of chunks) {
|
|
591
|
+
merged.set(chunk, offset);
|
|
592
|
+
offset += chunk.byteLength;
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
const parsed = JSON.parse(new TextDecoder().decode(merged));
|
|
596
|
+
if (typeof parsed !== "object" || parsed === null) return { ok: false };
|
|
597
|
+
return {
|
|
598
|
+
ok: true,
|
|
599
|
+
value: parsed
|
|
600
|
+
};
|
|
426
601
|
} catch {
|
|
427
|
-
|
|
602
|
+
return { ok: false };
|
|
428
603
|
}
|
|
429
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
|
|
430
|
-
return uri;
|
|
431
604
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
605
|
+
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/ema/parser.ts
|
|
608
|
+
/**
|
|
609
|
+
* Pure JWT parsing for ID-JAG assertions.
|
|
610
|
+
*
|
|
611
|
+
* Splits a compact JWS (`<base64url-header>.<base64url-payload>.<base64url-signature>`)
|
|
612
|
+
* into its three parts, decodes header and payload as JSON, and exposes the
|
|
613
|
+
* raw signing input + signature bytes for downstream signature verification.
|
|
614
|
+
*
|
|
615
|
+
* No I/O. No `this`. Returns `Result<ParsedIdJag, EmaValidationError>`.
|
|
616
|
+
*/
|
|
617
|
+
/**
|
|
618
|
+
* Parse a compact JWS assertion.
|
|
619
|
+
*
|
|
620
|
+
* @param assertion The raw assertion string from the token request body.
|
|
621
|
+
* @param maxBytes Reject assertions whose length exceeds this many bytes.
|
|
622
|
+
* Guards against memory exhaustion before any JSON parsing happens.
|
|
623
|
+
*/
|
|
624
|
+
function parseIdJag(assertion, maxBytes) {
|
|
625
|
+
if (typeof assertion !== "string" || assertion.length === 0) return err({ reason: "assertion_missing" });
|
|
626
|
+
if (assertion.length > maxBytes) return err({
|
|
627
|
+
reason: "assertion_too_large",
|
|
628
|
+
size: assertion.length,
|
|
629
|
+
max: maxBytes
|
|
630
|
+
});
|
|
631
|
+
const parts = assertion.split(".");
|
|
632
|
+
if (parts.length !== 3 || parts.some((part) => part.length === 0)) return err({ reason: "assertion_malformed" });
|
|
633
|
+
const [encodedHeader, encodedClaims, encodedSignature] = parts;
|
|
634
|
+
let header;
|
|
635
|
+
let rawClaims;
|
|
636
|
+
let signature;
|
|
637
|
+
try {
|
|
638
|
+
header = parseJwtJsonPart(encodedHeader);
|
|
639
|
+
rawClaims = parseJwtJsonPart(encodedClaims);
|
|
640
|
+
signature = base64UrlToBytes(encodedSignature);
|
|
641
|
+
} catch {
|
|
642
|
+
return err({ reason: "assertion_malformed" });
|
|
448
643
|
}
|
|
449
|
-
|
|
644
|
+
const signingInput = new TextEncoder().encode(`${encodedHeader}.${encodedClaims}`);
|
|
645
|
+
return ok({
|
|
646
|
+
header,
|
|
647
|
+
rawClaims,
|
|
648
|
+
signingInput,
|
|
649
|
+
signature
|
|
650
|
+
});
|
|
450
651
|
}
|
|
451
|
-
|
|
452
|
-
|
|
652
|
+
/**
|
|
653
|
+
* Decodes a base64url-encoded string to bytes.
|
|
654
|
+
*/
|
|
655
|
+
function base64UrlToBytes(base64Url) {
|
|
656
|
+
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
|
657
|
+
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
658
|
+
const binaryString = atob(padded);
|
|
659
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
660
|
+
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
|
|
661
|
+
return bytes;
|
|
453
662
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
logoUri: optionalHttpUri(raw.logo_uri, "logo_uri"),
|
|
467
|
-
policyUri: optionalHttpUri(raw.policy_uri, "policy_uri"),
|
|
468
|
-
tosUri: optionalHttpUri(raw.tos_uri, "tos_uri"),
|
|
469
|
-
jwksUri: optionalHttpUri(raw.jwks_uri, "jwks_uri"),
|
|
470
|
-
i18n: extractI18nFields(raw),
|
|
471
|
-
contacts: optionalStringArray(raw.contacts, "contacts"),
|
|
472
|
-
grantTypes: optionalStringArray(raw.grant_types, "grant_types"),
|
|
473
|
-
responseTypes: optionalStringArray(raw.response_types, "response_types"),
|
|
474
|
-
tokenEndpointAuthMethod,
|
|
475
|
-
tokenEndpointAuthMethodsSupported,
|
|
476
|
-
tokenEndpointAuthSigningAlg,
|
|
477
|
-
tokenEndpointAuthSigningAlgValuesSupported
|
|
478
|
-
};
|
|
663
|
+
/**
|
|
664
|
+
* Parses a base64url-encoded JWT JSON part into an object.
|
|
665
|
+
*/
|
|
666
|
+
function parseJwtJsonPart(encoded) {
|
|
667
|
+
try {
|
|
668
|
+
const json = new TextDecoder().decode(base64UrlToBytes(encoded));
|
|
669
|
+
const parsed = JSON.parse(json);
|
|
670
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JWT part must be an object");
|
|
671
|
+
return parsed;
|
|
672
|
+
} catch {
|
|
673
|
+
throw new Error("Malformed JWT part");
|
|
674
|
+
}
|
|
479
675
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
676
|
+
|
|
677
|
+
//#endregion
|
|
678
|
+
//#region src/ema/signature.ts
|
|
679
|
+
/**
|
|
680
|
+
* Pick a signing key from a JWKS that matches the assertion header.
|
|
681
|
+
*
|
|
682
|
+
* Filters by:
|
|
683
|
+
* - `kid` (if the assertion header carries one)
|
|
684
|
+
* - JWK `use === 'sig'` (when present)
|
|
685
|
+
* - JWK `key_ops` containing `verify` (when present)
|
|
686
|
+
* - JWK `alg` matching (when present)
|
|
687
|
+
* - `kty` compatible with the requested `alg`
|
|
688
|
+
*
|
|
689
|
+
* If the assertion has a `kid`, the first matching key wins. Without a `kid`,
|
|
690
|
+
* we only return a key if exactly one candidate matches — otherwise the
|
|
691
|
+
* selection is ambiguous and we reject (caller may then force-refresh JWKS).
|
|
692
|
+
*/
|
|
693
|
+
function selectJwk(jwks, alg, kid) {
|
|
694
|
+
const matching = (jwks.keys ?? []).filter((key) => {
|
|
695
|
+
if (kid && key.kid !== kid) return false;
|
|
696
|
+
if (key.alg && key.alg !== alg) return false;
|
|
697
|
+
if (key.use && key.use !== "sig") return false;
|
|
698
|
+
if (Array.isArray(key.key_ops) && !key.key_ops.includes("verify")) return false;
|
|
699
|
+
if (alg.startsWith("RS") && key.kty !== "RSA") return false;
|
|
700
|
+
if (alg.startsWith("ES") && key.kty !== "EC") return false;
|
|
701
|
+
return true;
|
|
702
|
+
});
|
|
703
|
+
if (kid) {
|
|
704
|
+
const picked = matching[0];
|
|
705
|
+
if (!picked) return err({
|
|
706
|
+
reason: "no_matching_key",
|
|
707
|
+
kid
|
|
708
|
+
});
|
|
709
|
+
return ok(picked);
|
|
710
|
+
}
|
|
711
|
+
if (matching.length !== 1) return err({ reason: "no_matching_key" });
|
|
712
|
+
return ok(matching[0]);
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Verify an ID-JAG's compact-JWS signature using WebCrypto.
|
|
716
|
+
*
|
|
717
|
+
* Returns `false` on any WebCrypto-level failure (import or verify).
|
|
718
|
+
* The caller is responsible for mapping `false` to the appropriate
|
|
719
|
+
* `EmaValidationError`.
|
|
720
|
+
*/
|
|
721
|
+
async function verifyIdJagSignature(input) {
|
|
722
|
+
try {
|
|
723
|
+
const { importAlgorithm, verifyAlgorithm } = getJwtCryptoAlgorithms(input.alg);
|
|
724
|
+
const key = await crypto.subtle.importKey("jwk", input.jwk, importAlgorithm, false, ["verify"]);
|
|
725
|
+
return await crypto.subtle.verify(verifyAlgorithm, key, input.signature, input.signingInput);
|
|
726
|
+
} catch {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Gets WebCrypto import and verify parameters for supported JOSE algorithms.
|
|
732
|
+
*/
|
|
733
|
+
function getJwtCryptoAlgorithms(alg) {
|
|
734
|
+
if (alg === "RS256") {
|
|
735
|
+
const algorithm = {
|
|
736
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
737
|
+
hash: "SHA-256"
|
|
738
|
+
};
|
|
739
|
+
return {
|
|
740
|
+
importAlgorithm: algorithm,
|
|
741
|
+
verifyAlgorithm: algorithm
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
if (alg === "ES256") return {
|
|
745
|
+
importAlgorithm: {
|
|
746
|
+
name: "ECDSA",
|
|
747
|
+
namedCurve: "P-256"
|
|
748
|
+
},
|
|
749
|
+
verifyAlgorithm: {
|
|
750
|
+
name: "ECDSA",
|
|
751
|
+
hash: "SHA-256"
|
|
752
|
+
}
|
|
491
753
|
};
|
|
754
|
+
throw new Error(`Unsupported JWT alg: ${alg}`);
|
|
492
755
|
}
|
|
756
|
+
|
|
757
|
+
//#endregion
|
|
758
|
+
//#region src/client-assertion.ts
|
|
759
|
+
/** RFC 7523 §2.2 `client_assertion_type` for a JWT client assertion. */
|
|
760
|
+
const JWT_BEARER_CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
|
|
761
|
+
/** JWS algorithms accepted on a client assertion. */
|
|
762
|
+
const CLIENT_ASSERTION_ALGORITHMS = ["RS256", "ES256"];
|
|
763
|
+
/** Largest compact JWS accepted as a client assertion. */
|
|
764
|
+
const CLIENT_ASSERTION_MAX_BYTES = 16 * 1024;
|
|
765
|
+
/** Allowed clock skew on `exp`, `nbf`, and `iat`. */
|
|
766
|
+
const CLIENT_ASSERTION_CLOCK_SKEW_SECONDS = 60;
|
|
493
767
|
/**
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
* Document is held to these alone; the URI a request uses is then held to {@link validateRedirectUri}.
|
|
497
|
-
* @throws Error when the redirect URI is not safe to store
|
|
768
|
+
* Longest remaining lifetime accepted on an assertion. A client assertion is minted per request,
|
|
769
|
+
* and the bound caps how long its replay marker stays in KV.
|
|
498
770
|
*/
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
771
|
+
const CLIENT_ASSERTION_MAX_LIFETIME_SECONDS = 3600;
|
|
772
|
+
/** Most client JWKS kept in the per-isolate cache; their URLs come from client metadata. */
|
|
773
|
+
const CLIENT_JWKS_CACHE_MAX_ENTRIES = 256;
|
|
774
|
+
/** KV prefix for client assertion replay markers. Stable across versions. */
|
|
775
|
+
const CLIENT_ASSERTION_JTI_KV_PREFIX = "client-assertion-jti:";
|
|
776
|
+
/** draft-ietf-oauth-rfc7523bis explicit type. A typed assertion must use the issuer as its sole audience. */
|
|
777
|
+
const CLIENT_AUTHENTICATION_JWT_TYPES = new Set(["client-authentication+jwt", "application/client-authentication+jwt"]);
|
|
778
|
+
/** Whether a public JWK could verify an assertion signed with `alg`, by key type and curve. */
|
|
779
|
+
function isKeyUsableForAlgorithm(key, alg) {
|
|
780
|
+
if (key.use !== void 0 && key.use !== "sig") return false;
|
|
781
|
+
if (key.alg !== void 0 && key.alg !== alg) return false;
|
|
782
|
+
if (Array.isArray(key.key_ops) && !key.key_ops.includes("verify")) return false;
|
|
783
|
+
return alg === "RS256" ? key.kty === "RSA" : key.kty === "EC" && key.crv === "P-256";
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Reads the unverified `iss` of a client assertion, so a request that omits `client_id` can still
|
|
787
|
+
* locate the client. The assertion is verified against that client's keys before it is trusted.
|
|
788
|
+
*/
|
|
789
|
+
function readClientAssertionIssuer(assertion) {
|
|
790
|
+
if (typeof assertion !== "string") return void 0;
|
|
791
|
+
const parsed = parseIdJag(assertion, CLIENT_ASSERTION_MAX_BYTES);
|
|
792
|
+
if (!parsed.ok) return void 0;
|
|
793
|
+
const iss = parsed.value.rawClaims.iss;
|
|
794
|
+
return typeof iss === "string" && iss.length > 0 ? iss : void 0;
|
|
795
|
+
}
|
|
796
|
+
/** Creates a verifier with its own JWKS cache and replay store. */
|
|
797
|
+
function createClientAssertionVerifier() {
|
|
798
|
+
const jwksProvider = createDefaultJwksProvider({ maxEntries: CLIENT_JWKS_CACHE_MAX_ENTRIES });
|
|
799
|
+
const jtiStore = createKvJtiStore(CLIENT_ASSERTION_JTI_KV_PREFIX);
|
|
800
|
+
return { async verify(input) {
|
|
801
|
+
if (typeof input.assertion !== "string") return err({ reason: "assertion_missing" });
|
|
802
|
+
const parsed = parseIdJag(input.assertion, CLIENT_ASSERTION_MAX_BYTES);
|
|
803
|
+
if (!parsed.ok) return parsed;
|
|
804
|
+
const header = validateHeader(parsed.value.header, input.keys.algorithms);
|
|
805
|
+
if (!header.ok) return header;
|
|
806
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
807
|
+
const claims = validateClaims(parsed.value.rawClaims, input, header.value.typed, now);
|
|
808
|
+
if (!claims.ok) return claims;
|
|
809
|
+
const verified = await verifySignature({
|
|
810
|
+
parsed: parsed.value,
|
|
811
|
+
alg: header.value.alg,
|
|
812
|
+
kid: header.value.kid,
|
|
813
|
+
keys: input.keys,
|
|
814
|
+
jwksProvider,
|
|
815
|
+
now
|
|
816
|
+
});
|
|
817
|
+
if (!verified.ok) return verified;
|
|
818
|
+
return jtiStore.markUsed({
|
|
819
|
+
issuer: input.clientId,
|
|
820
|
+
jti: claims.value.jti,
|
|
821
|
+
exp: claims.value.exp + CLIENT_ASSERTION_CLOCK_SKEW_SECONDS,
|
|
822
|
+
now: Math.floor(Date.now() / 1e3),
|
|
823
|
+
env: input.env
|
|
824
|
+
});
|
|
825
|
+
} };
|
|
826
|
+
}
|
|
827
|
+
function validateHeader(header, algorithms) {
|
|
828
|
+
if (header.crit !== void 0 || header.b64 !== void 0) return err({ reason: "assertion_malformed" });
|
|
829
|
+
const alg = header.alg;
|
|
830
|
+
if (typeof alg !== "string" || !algorithms.includes(alg)) return err({
|
|
831
|
+
reason: "invalid_alg",
|
|
832
|
+
got: alg
|
|
833
|
+
});
|
|
834
|
+
const typ = header.typ;
|
|
835
|
+
let typed = false;
|
|
836
|
+
if (typ !== void 0) {
|
|
837
|
+
const normalized = typeof typ === "string" ? typ.toLowerCase() : "";
|
|
838
|
+
typed = CLIENT_AUTHENTICATION_JWT_TYPES.has(normalized);
|
|
839
|
+
if (!typed && normalized !== "jwt") return err({
|
|
840
|
+
reason: "invalid_typ",
|
|
841
|
+
got: typ
|
|
842
|
+
});
|
|
512
843
|
}
|
|
513
|
-
const
|
|
514
|
-
if (
|
|
515
|
-
|
|
516
|
-
|
|
844
|
+
const kid = header.kid;
|
|
845
|
+
if (kid !== void 0 && (typeof kid !== "string" || kid.length === 0)) return err({ reason: "assertion_malformed" });
|
|
846
|
+
return ok({
|
|
847
|
+
alg,
|
|
848
|
+
kid,
|
|
849
|
+
typed
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
function validateClaims(claims, input, typed, now) {
|
|
853
|
+
if (claims.iss !== input.clientId) return err({
|
|
854
|
+
reason: "client_id_mismatch",
|
|
855
|
+
expected: input.clientId,
|
|
856
|
+
got: String(claims.iss)
|
|
857
|
+
});
|
|
858
|
+
if (claims.sub !== input.clientId) return err({
|
|
859
|
+
reason: "client_id_mismatch",
|
|
860
|
+
expected: input.clientId,
|
|
861
|
+
got: String(claims.sub)
|
|
862
|
+
});
|
|
863
|
+
const aud = claims.aud;
|
|
864
|
+
if (!(typed ? aud === input.issuer : aud === input.issuer || aud === input.tokenEndpoint || Array.isArray(aud) && aud.every((value) => typeof value === "string") && (aud.includes(input.issuer) || aud.includes(input.tokenEndpoint)))) return err({
|
|
865
|
+
reason: "aud_mismatch",
|
|
866
|
+
expected: input.issuer,
|
|
867
|
+
got: Array.isArray(aud) ? aud.map(String) : String(aud)
|
|
868
|
+
});
|
|
869
|
+
const exp = claims.exp;
|
|
870
|
+
if (typeof exp !== "number" || !Number.isInteger(exp)) return err({
|
|
871
|
+
reason: "invalid_claim",
|
|
872
|
+
claim: "exp"
|
|
873
|
+
});
|
|
874
|
+
if (exp + CLIENT_ASSERTION_CLOCK_SKEW_SECONDS <= now) return err({
|
|
875
|
+
reason: "expired",
|
|
876
|
+
exp,
|
|
877
|
+
now
|
|
878
|
+
});
|
|
879
|
+
if (exp - now > CLIENT_ASSERTION_MAX_LIFETIME_SECONDS) return err({
|
|
880
|
+
reason: "lifetime_too_long",
|
|
881
|
+
lifetime: exp - now,
|
|
882
|
+
max: CLIENT_ASSERTION_MAX_LIFETIME_SECONDS
|
|
883
|
+
});
|
|
884
|
+
const nbf = claims.nbf;
|
|
885
|
+
if (nbf !== void 0) {
|
|
886
|
+
if (typeof nbf !== "number" || !Number.isInteger(nbf)) return err({
|
|
887
|
+
reason: "invalid_claim",
|
|
888
|
+
claim: "nbf"
|
|
889
|
+
});
|
|
890
|
+
if (nbf > now + CLIENT_ASSERTION_CLOCK_SKEW_SECONDS) return err({
|
|
891
|
+
reason: "nbf_in_future",
|
|
892
|
+
nbf,
|
|
893
|
+
now,
|
|
894
|
+
skew: CLIENT_ASSERTION_CLOCK_SKEW_SECONDS
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
const iat = claims.iat;
|
|
898
|
+
if (iat !== void 0) {
|
|
899
|
+
if (typeof iat !== "number" || !Number.isInteger(iat)) return err({
|
|
900
|
+
reason: "invalid_claim",
|
|
901
|
+
claim: "iat"
|
|
902
|
+
});
|
|
903
|
+
if (iat > now + CLIENT_ASSERTION_CLOCK_SKEW_SECONDS) return err({
|
|
904
|
+
reason: "iat_in_future",
|
|
905
|
+
iat,
|
|
906
|
+
now,
|
|
907
|
+
skew: CLIENT_ASSERTION_CLOCK_SKEW_SECONDS
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
const jti = claims.jti;
|
|
911
|
+
if (typeof jti !== "string" || jti.length === 0) return err({
|
|
912
|
+
reason: "invalid_claim",
|
|
913
|
+
claim: "jti"
|
|
914
|
+
});
|
|
915
|
+
return ok({
|
|
916
|
+
jti,
|
|
917
|
+
exp
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
async function verifySignature(args) {
|
|
921
|
+
const { keys, alg, kid, parsed } = args;
|
|
922
|
+
const verifyWith = async (jwks) => {
|
|
923
|
+
const jwk = selectJwk(jwks, alg, kid);
|
|
924
|
+
if (!jwk.ok) return jwk;
|
|
925
|
+
return await verifyIdJagSignature({
|
|
926
|
+
alg,
|
|
927
|
+
jwk: jwk.value,
|
|
928
|
+
signingInput: parsed.signingInput,
|
|
929
|
+
signature: parsed.signature
|
|
930
|
+
}) ? ok(void 0) : err({ reason: "signature_failed" });
|
|
931
|
+
};
|
|
932
|
+
if (keys.jwks) return verifyWith(keys.jwks);
|
|
933
|
+
if (!keys.jwksUri) return err({ reason: "no_matching_key" });
|
|
934
|
+
const source = {
|
|
935
|
+
issuer: keys.jwksUri,
|
|
936
|
+
jwksUri: keys.jwksUri
|
|
937
|
+
};
|
|
938
|
+
const initial = await args.jwksProvider.fetch(source, {
|
|
939
|
+
forceRefresh: false,
|
|
940
|
+
now: args.now
|
|
941
|
+
});
|
|
942
|
+
if (!initial.ok) return initial;
|
|
943
|
+
const result = await verifyWith(initial.value);
|
|
944
|
+
if (result.ok) return result;
|
|
945
|
+
const refreshed = await args.jwksProvider.fetch(source, {
|
|
946
|
+
forceRefresh: true,
|
|
947
|
+
now: args.now
|
|
948
|
+
});
|
|
949
|
+
if (!refreshed.ok) return refreshed;
|
|
950
|
+
return verifyWith(refreshed.value);
|
|
517
951
|
}
|
|
952
|
+
|
|
953
|
+
//#endregion
|
|
954
|
+
//#region src/oauth-resource.ts
|
|
518
955
|
/**
|
|
519
|
-
*
|
|
520
|
-
*
|
|
521
|
-
* even an empty `#`) or userinfo.
|
|
522
|
-
* @throws Error when the redirect URI can't be registered at all
|
|
956
|
+
* Loopback hosts where a plain `http` scheme is accepted for local development.
|
|
957
|
+
* Matches RFC 8252 §7.3 loopback handling: 127.0.0.0/8, ::1, and `localhost`.
|
|
523
958
|
*/
|
|
524
|
-
function
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
959
|
+
function isLoopbackHostname(hostname) {
|
|
960
|
+
const host = hostname.toLowerCase();
|
|
961
|
+
if (host === "localhost" || host === "::1" || host === "[::1]") return true;
|
|
962
|
+
const ipv4 = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
963
|
+
return ipv4 !== null && ipv4.slice(1).every((octet) => Number(octet) <= 255);
|
|
964
|
+
}
|
|
965
|
+
/**
|
|
966
|
+
* Whether a canonical resource, issuer, or endpoint URL uses an accepted
|
|
967
|
+
* scheme: `https`, or `http` on a loopback host so `wrangler dev` works on
|
|
968
|
+
* http://localhost. Workers are always served over `https`, so there is no
|
|
969
|
+
* deployment in which cleartext OAuth traffic to a remote host is correct,
|
|
970
|
+
* and no option exists to allow it.
|
|
971
|
+
*/
|
|
972
|
+
function hasAcceptedCanonicalScheme(url) {
|
|
973
|
+
return url.protocol === "https:" || url.protocol === "http:" && isLoopbackHostname(url.hostname);
|
|
974
|
+
}
|
|
975
|
+
/** Validate an RFC 3986-safe HTTP(S) resource identifier for RFC 8707. */
|
|
976
|
+
function validateResourceUri(uri) {
|
|
977
|
+
if (!uri || typeof uri !== "string") return false;
|
|
978
|
+
if (!/^[\x21-\x7e]+$/.test(uri) || /["\\#]/.test(uri) || !/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]+(?:\/[^?#]*)?(?:\?[^#]*)?$/.test(uri) || /%(?![0-9A-Fa-f]{2})/.test(uri) || !/^[A-Za-z0-9:/?@!$&'()*+,;=._~%\[\]-]+$/.test(uri)) return false;
|
|
528
979
|
try {
|
|
529
|
-
|
|
980
|
+
const parsed = new URL(uri);
|
|
981
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
530
982
|
} catch {
|
|
531
|
-
|
|
983
|
+
return false;
|
|
532
984
|
}
|
|
533
|
-
if (normalized.includes("#") || url.username || url.password) throw new Error("Invalid redirect URI");
|
|
534
|
-
return url;
|
|
535
985
|
}
|
|
536
986
|
/**
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
* `
|
|
541
|
-
*
|
|
542
|
-
*
|
|
987
|
+
* Whether a requested resource identifies a granted or configured resource.
|
|
988
|
+
* Only ASCII case in the URI scheme and host is ignored, and an empty path is
|
|
989
|
+
* equivalent to `/` (RFC 3986 §6.2.3), so a client that round-trips
|
|
990
|
+
* `https://example.com` through a URL parser as `https://example.com/` still
|
|
991
|
+
* names it. Port, path, query, a trailing slash after a path segment, user
|
|
992
|
+
* information, and every other byte remain significant.
|
|
543
993
|
*/
|
|
544
|
-
function
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
if (
|
|
548
|
-
|
|
549
|
-
throw new Error("Redirect URI must use https, or http on a loopback host");
|
|
550
|
-
}
|
|
551
|
-
if (!server.allowPrivateUseRedirectUris) throw new Error("Redirect URI must use https, or http on a loopback host");
|
|
994
|
+
function resourceMatches(requested, granted) {
|
|
995
|
+
const foldedRequested = foldResourceSchemeAndHost(requested);
|
|
996
|
+
const foldedGranted = foldResourceSchemeAndHost(granted);
|
|
997
|
+
if (foldedRequested === void 0 || foldedGranted === void 0) return false;
|
|
998
|
+
return normalizeEmptyPath(foldedRequested) === normalizeEmptyPath(foldedGranted);
|
|
552
999
|
}
|
|
553
1000
|
/**
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
* callbacks. So each URI is held to {@link validateListedRedirectUri}, and at least one must satisfy
|
|
558
|
-
* {@link validateRedirectUri} so the client can sign in. The URI a request uses is held to the full
|
|
559
|
-
* policy by `parseAuthRequest()` and `completeAuthorization()`.
|
|
560
|
-
* @throws Error when a URI can't be registered, or none of them could ever receive a code
|
|
1001
|
+
* Fold only the URI components whose comparison is ASCII case-insensitive: the
|
|
1002
|
+
* scheme and the host. A configured identifier is canonical when folding it is
|
|
1003
|
+
* the identity.
|
|
561
1004
|
*/
|
|
562
|
-
function
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
1005
|
+
function foldResourceSchemeAndHost(resource) {
|
|
1006
|
+
const schemeSeparator = resource.indexOf("://");
|
|
1007
|
+
if (schemeSeparator <= 0) return void 0;
|
|
1008
|
+
const authorityStart = schemeSeparator + 3;
|
|
1009
|
+
const authorityEndOffset = resource.slice(authorityStart).search(/[/?#]/);
|
|
1010
|
+
const authorityEnd = authorityEndOffset === -1 ? resource.length : authorityStart + authorityEndOffset;
|
|
1011
|
+
const authority = resource.slice(authorityStart, authorityEnd);
|
|
1012
|
+
const hostStart = authority.lastIndexOf("@") + 1;
|
|
1013
|
+
let hostEnd;
|
|
1014
|
+
if (authority[hostStart] === "[") {
|
|
1015
|
+
const closingBracket = authority.indexOf("]", hostStart + 1);
|
|
1016
|
+
if (closingBracket === -1) return void 0;
|
|
1017
|
+
hostEnd = closingBracket + 1;
|
|
1018
|
+
} else {
|
|
1019
|
+
const portSeparator = authority.indexOf(":", hostStart);
|
|
1020
|
+
hostEnd = portSeparator === -1 ? authority.length : portSeparator;
|
|
571
1021
|
}
|
|
572
|
-
|
|
1022
|
+
const asciiLower = (value) => value.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
|
1023
|
+
return asciiLower(resource.slice(0, schemeSeparator)) + "://" + authority.slice(0, hostStart) + asciiLower(authority.slice(hostStart, hostEnd)) + authority.slice(hostEnd) + resource.slice(authorityEnd);
|
|
573
1024
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
1025
|
+
/** RFC 3986 §6.2.3: for http and https an empty path is equivalent to "/". */
|
|
1026
|
+
function normalizeEmptyPath(resource) {
|
|
1027
|
+
const authorityStart = resource.indexOf("://") + 3;
|
|
1028
|
+
const pathOffset = resource.slice(authorityStart).search(/[/?#]/);
|
|
1029
|
+
if (pathOffset === -1) return `${resource}/`;
|
|
1030
|
+
const pathStart = authorityStart + pathOffset;
|
|
1031
|
+
return resource[pathStart] === "/" ? resource : `${resource.slice(0, pathStart)}/${resource.slice(pathStart)}`;
|
|
578
1032
|
}
|
|
579
1033
|
/**
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
1034
|
+
* Whether a request carries every query parameter of a canonical resource. A
|
|
1035
|
+
* query-bearing resource identifier names a more specific resource: a request
|
|
1036
|
+
* may add parameters of its own but must preserve the resource's.
|
|
583
1037
|
*/
|
|
584
|
-
function
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
tokenEndpointAuthMethod: metadata.tokenEndpointAuthMethod,
|
|
588
|
-
tokenEndpointAuthMethodsSupported: metadata.tokenEndpointAuthMethodsSupported,
|
|
589
|
-
grantTypes: metadata.grantTypes ?? ["authorization_code"],
|
|
590
|
-
responseTypes: metadata.responseTypes ?? ["code"]
|
|
591
|
-
});
|
|
592
|
-
return {
|
|
593
|
-
...pickDisplayMetadata(metadata),
|
|
594
|
-
redirectUris: validateRegisteredRedirectUris(metadata.redirectUris, server),
|
|
595
|
-
...capabilities,
|
|
596
|
-
authMethodExplicit: metadata.tokenEndpointAuthMethod !== void 0 || metadata.tokenEndpointAuthMethodsSupported !== void 0
|
|
597
|
-
};
|
|
1038
|
+
function requestCarriesResourceQuery(requestUrl, resourceUrl) {
|
|
1039
|
+
for (const [name, value] of resourceUrl.searchParams) if (!requestUrl.searchParams.getAll(name).includes(value)) return false;
|
|
1040
|
+
return true;
|
|
598
1041
|
}
|
|
599
1042
|
/**
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
1043
|
+
* The configured resource a client-supplied resource indicator names when it is
|
|
1044
|
+
* not an exact match: one with the same origin and path whose query parameters
|
|
1045
|
+
* the indicator carries, as a request URL the resource covers would. With
|
|
1046
|
+
* several such resources, the one with the most query parameters wins and a tie
|
|
1047
|
+
* names none. A value without a query never matches here.
|
|
604
1048
|
*/
|
|
605
|
-
function
|
|
606
|
-
|
|
607
|
-
if (schemeEnd === -1) return "";
|
|
608
|
-
const authorityStart = schemeEnd + 3;
|
|
609
|
-
const authorityEndOffset = clientId.slice(authorityStart).search(/[/?#]/);
|
|
610
|
-
if (authorityEndOffset === -1) return "";
|
|
611
|
-
const pathStart = authorityStart + authorityEndOffset;
|
|
612
|
-
if (clientId[pathStart] !== "/") return "";
|
|
613
|
-
const pathEndOffset = clientId.slice(pathStart).search(/[?#]/);
|
|
614
|
-
return pathEndOffset === -1 ? clientId.slice(pathStart) : clientId.slice(pathStart, pathStart + pathEndOffset);
|
|
615
|
-
}
|
|
616
|
-
function validateClientIdentifierUrl(clientId) {
|
|
617
|
-
if (clientId !== clientId.trim() || /[\x00-\x20\x7f-\x9f\\]/.test(clientId)) throw new Error("Client Identifier URL contains invalid whitespace or characters");
|
|
618
|
-
if (!/^https:\/\//i.test(clientId)) throw new Error("Client Identifier URL must use an absolute HTTPS URL");
|
|
619
|
-
let parsed;
|
|
620
|
-
try {
|
|
621
|
-
parsed = new URL(clientId);
|
|
622
|
-
} catch {
|
|
623
|
-
throw new Error("Client Identifier URL must be a valid HTTPS URL");
|
|
624
|
-
}
|
|
625
|
-
if (parsed.protocol !== "https:") throw new Error("Client Identifier URL must use HTTPS");
|
|
626
|
-
if (parsed.username || parsed.password) throw new Error("Client Identifier URL must not contain userinfo");
|
|
627
|
-
if (parsed.hash) throw new Error("Client Identifier URL must not contain a fragment");
|
|
628
|
-
const path = rawPath(clientId);
|
|
629
|
-
if (!path) throw new Error("Client Identifier URL must contain a path component");
|
|
630
|
-
for (const segment of path.split("/")) {
|
|
631
|
-
let decodedSegment;
|
|
632
|
-
try {
|
|
633
|
-
decodedSegment = decodeURIComponent(segment);
|
|
634
|
-
} catch {
|
|
635
|
-
throw new Error("Client Identifier URL contains invalid percent encoding");
|
|
636
|
-
}
|
|
637
|
-
if (decodedSegment === "." || decodedSegment === "..") throw new Error("Client Identifier URL must not contain dot path segments");
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
/** Returns whether a client ID has the URL shape this package resolves through CIMD. */
|
|
641
|
-
function isClientIdMetadataDocumentUrl(clientId) {
|
|
1049
|
+
function findCoveringResource(requested, configured) {
|
|
1050
|
+
let url;
|
|
642
1051
|
try {
|
|
643
|
-
|
|
1052
|
+
url = new URL(requested);
|
|
644
1053
|
} catch {
|
|
645
|
-
return
|
|
1054
|
+
return;
|
|
646
1055
|
}
|
|
1056
|
+
if (url.search === "") return void 0;
|
|
1057
|
+
const covering = configured.map((resource) => ({
|
|
1058
|
+
resource,
|
|
1059
|
+
url: new URL(resource)
|
|
1060
|
+
})).filter((candidate) => candidate.url.origin === url.origin && (candidate.url.pathname || "/") === (url.pathname || "/") && requestCarriesResourceQuery(url, candidate.url)).map(({ resource, url: url$1 }) => ({
|
|
1061
|
+
resource,
|
|
1062
|
+
specificity: [...url$1.searchParams].length
|
|
1063
|
+
})).sort((a, b) => b.specificity - a.specificity);
|
|
1064
|
+
if (covering.length === 0) return void 0;
|
|
1065
|
+
if (covering.length > 1 && covering[0].specificity === covering[1].specificity) return void 0;
|
|
1066
|
+
return covering[0].resource;
|
|
647
1067
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
"qi",
|
|
659
|
-
"oth",
|
|
660
|
-
"k"
|
|
661
|
-
]);
|
|
662
|
-
for (const value$1 of jwks.keys) {
|
|
663
|
-
const key = requireJsonObject(value$1);
|
|
664
|
-
if (Object.keys(key).some((member) => privateMembers.has(member))) return true;
|
|
665
|
-
}
|
|
666
|
-
return false;
|
|
1068
|
+
|
|
1069
|
+
//#endregion
|
|
1070
|
+
//#region src/oauth-client-metadata.ts
|
|
1071
|
+
const CIMD_MAX_SIZE_BYTES = 5 * 1024;
|
|
1072
|
+
const CIMD_FETCH_TIMEOUT_MS = 1e4;
|
|
1073
|
+
const CIMD_CACHE_NAME = "workers-oauth-provider:cimd:v1";
|
|
1074
|
+
const CIMD_CACHE_MAX_TTL_SECONDS = 10080 * 60;
|
|
1075
|
+
function requireJsonObject(value) {
|
|
1076
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("Client metadata must be a JSON object");
|
|
1077
|
+
return value;
|
|
667
1078
|
}
|
|
668
|
-
function
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
if (!metadata.clientName?.trim()) throw new Error("client_name is required and must not be empty");
|
|
673
|
-
const redirectUris = requireValidRedirectUris(metadata.redirectUris, validateRedirectUriSafety);
|
|
674
|
-
if ("client_secret" in raw || "client_secret_expires_at" in raw) throw new Error("CIMD documents must not contain client secrets");
|
|
675
|
-
if (containsPrivateJwkMaterial(raw.jwks)) throw new Error("CIMD documents must not contain private key material");
|
|
676
|
-
const capabilities = negotiateCimdClientCapabilities(server, {
|
|
677
|
-
tokenEndpointAuthMethod: metadata.tokenEndpointAuthMethod,
|
|
678
|
-
tokenEndpointAuthMethodsSupported: metadata.tokenEndpointAuthMethodsSupported,
|
|
679
|
-
grantTypes: metadata.grantTypes ?? ["authorization_code"],
|
|
680
|
-
responseTypes: metadata.responseTypes ?? ["code"]
|
|
681
|
-
});
|
|
682
|
-
return {
|
|
683
|
-
...pickDisplayMetadata(metadata),
|
|
684
|
-
clientId: metadata.clientId,
|
|
685
|
-
clientName: metadata.clientName,
|
|
686
|
-
redirectUris,
|
|
687
|
-
...capabilities
|
|
688
|
-
};
|
|
1079
|
+
function optionalString(value, fieldName) {
|
|
1080
|
+
if (value === void 0) return void 0;
|
|
1081
|
+
if (typeof value !== "string") throw new Error(`Invalid ${fieldName}: expected string, got ${typeof value}`);
|
|
1082
|
+
return value;
|
|
689
1083
|
}
|
|
690
|
-
function
|
|
691
|
-
if (
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
reject(new DOMException("Aborted", "AbortError"));
|
|
696
|
-
};
|
|
697
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
698
|
-
reader.read().then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
699
|
-
});
|
|
1084
|
+
function optionalStringArray(value, fieldName) {
|
|
1085
|
+
if (value === void 0) return void 0;
|
|
1086
|
+
if (!Array.isArray(value)) throw new Error(`Invalid ${fieldName}: expected array, got ${typeof value}`);
|
|
1087
|
+
if (!value.every((item) => typeof item === "string")) throw new Error(`Invalid ${fieldName}: array must contain only strings`);
|
|
1088
|
+
return [...value];
|
|
700
1089
|
}
|
|
701
|
-
|
|
702
|
-
const
|
|
703
|
-
if (
|
|
704
|
-
|
|
705
|
-
if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
|
|
706
|
-
await response.body?.cancel().catch(() => void 0);
|
|
707
|
-
throw new Error(`Client metadata exceeds size limit: ${contentLength} bytes (max ${maxBytes})`);
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
const reader = response.body?.getReader();
|
|
711
|
-
if (!reader) throw new Error("Client metadata response body is empty");
|
|
712
|
-
const chunks = [];
|
|
713
|
-
let totalSize = 0;
|
|
714
|
-
while (true) {
|
|
715
|
-
const { done, value } = await readStreamChunk(reader, signal);
|
|
716
|
-
if (done) break;
|
|
717
|
-
if (!value) continue;
|
|
718
|
-
totalSize += value.length;
|
|
719
|
-
if (totalSize > maxBytes) {
|
|
720
|
-
await reader.cancel().catch(() => void 0);
|
|
721
|
-
throw new Error(`Response exceeded size limit of ${maxBytes} bytes`);
|
|
722
|
-
}
|
|
723
|
-
chunks.push(value);
|
|
724
|
-
}
|
|
725
|
-
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
726
|
-
const bytes = new Uint8Array(totalSize);
|
|
727
|
-
let offset = 0;
|
|
728
|
-
for (const chunk of chunks) {
|
|
729
|
-
bytes.set(chunk, offset);
|
|
730
|
-
offset += chunk.length;
|
|
731
|
-
}
|
|
732
|
-
let text;
|
|
1090
|
+
function optionalHttpUri(value, fieldName) {
|
|
1091
|
+
const uri = optionalString(value, fieldName);
|
|
1092
|
+
if (uri === void 0) return void 0;
|
|
1093
|
+
let parsed;
|
|
733
1094
|
try {
|
|
734
|
-
|
|
735
|
-
fatal: true,
|
|
736
|
-
ignoreBOM: false
|
|
737
|
-
}).decode(bytes);
|
|
1095
|
+
parsed = new URL(uri);
|
|
738
1096
|
} catch {
|
|
739
|
-
throw new Error(
|
|
1097
|
+
throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
|
|
740
1098
|
}
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1099
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error(`Invalid ${fieldName}: must be an absolute http: or https: URL`);
|
|
1100
|
+
return uri;
|
|
1101
|
+
}
|
|
1102
|
+
const I18N_FIELDS = {
|
|
1103
|
+
client_name: "string",
|
|
1104
|
+
client_uri: "uri",
|
|
1105
|
+
logo_uri: "uri",
|
|
1106
|
+
tos_uri: "uri",
|
|
1107
|
+
policy_uri: "uri"
|
|
1108
|
+
};
|
|
1109
|
+
function extractI18nFields(raw) {
|
|
1110
|
+
const result = {};
|
|
1111
|
+
for (const key of Object.keys(raw)) {
|
|
1112
|
+
const hashIndex = key.indexOf("#");
|
|
1113
|
+
if (hashIndex <= 0 || hashIndex === key.length - 1) continue;
|
|
1114
|
+
const kind = I18N_FIELDS[key.slice(0, hashIndex)];
|
|
1115
|
+
if (!kind) continue;
|
|
1116
|
+
const value = kind === "uri" ? optionalHttpUri(raw[key], key) : optionalString(raw[key], key);
|
|
1117
|
+
if (value !== void 0) result[key] = value;
|
|
748
1118
|
}
|
|
1119
|
+
return Object.keys(result).length > 0 ? result : void 0;
|
|
749
1120
|
}
|
|
750
|
-
function
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
1121
|
+
function validateChoiceConsistency(preferredName, preferredValue, choicesName, choices) {
|
|
1122
|
+
if (preferredValue !== void 0 && choices !== void 0 && !choices.includes(preferredValue)) throw new Error(`${preferredName} must be included in ${choicesName}`);
|
|
1123
|
+
}
|
|
1124
|
+
function parseOAuthClientMetadata(raw) {
|
|
1125
|
+
const tokenEndpointAuthMethod = optionalString(raw.token_endpoint_auth_method, "token_endpoint_auth_method");
|
|
1126
|
+
const tokenEndpointAuthMethodsSupported = optionalStringArray(raw.token_endpoint_auth_methods_supported, "token_endpoint_auth_methods_supported");
|
|
1127
|
+
const tokenEndpointAuthSigningAlg = optionalString(raw.token_endpoint_auth_signing_alg, "token_endpoint_auth_signing_alg");
|
|
1128
|
+
const tokenEndpointAuthSigningAlgValuesSupported = optionalStringArray(raw.token_endpoint_auth_signing_alg_values_supported, "token_endpoint_auth_signing_alg_values_supported");
|
|
1129
|
+
validateChoiceConsistency("token_endpoint_auth_method", tokenEndpointAuthMethod, "token_endpoint_auth_methods_supported", tokenEndpointAuthMethodsSupported);
|
|
1130
|
+
validateChoiceConsistency("token_endpoint_auth_signing_alg", tokenEndpointAuthSigningAlg, "token_endpoint_auth_signing_alg_values_supported", tokenEndpointAuthSigningAlgValuesSupported);
|
|
1131
|
+
return {
|
|
1132
|
+
clientId: optionalString(raw.client_id, "client_id"),
|
|
1133
|
+
redirectUris: optionalStringArray(raw.redirect_uris, "redirect_uris"),
|
|
1134
|
+
clientName: optionalString(raw.client_name, "client_name"),
|
|
1135
|
+
clientUri: optionalHttpUri(raw.client_uri, "client_uri"),
|
|
1136
|
+
logoUri: optionalHttpUri(raw.logo_uri, "logo_uri"),
|
|
1137
|
+
policyUri: optionalHttpUri(raw.policy_uri, "policy_uri"),
|
|
1138
|
+
tosUri: optionalHttpUri(raw.tos_uri, "tos_uri"),
|
|
1139
|
+
jwksUri: optionalHttpUri(raw.jwks_uri, "jwks_uri"),
|
|
1140
|
+
i18n: extractI18nFields(raw),
|
|
1141
|
+
contacts: optionalStringArray(raw.contacts, "contacts"),
|
|
1142
|
+
grantTypes: optionalStringArray(raw.grant_types, "grant_types"),
|
|
1143
|
+
responseTypes: optionalStringArray(raw.response_types, "response_types"),
|
|
1144
|
+
tokenEndpointAuthMethod,
|
|
1145
|
+
tokenEndpointAuthMethodsSupported,
|
|
1146
|
+
tokenEndpointAuthSigningAlg,
|
|
1147
|
+
tokenEndpointAuthSigningAlgValuesSupported
|
|
1148
|
+
};
|
|
759
1149
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1150
|
+
function pickDisplayMetadata(metadata) {
|
|
1151
|
+
const { clientName, clientUri, logoUri, policyUri, tosUri, jwksUri, i18n, contacts } = metadata;
|
|
1152
|
+
return {
|
|
1153
|
+
clientName,
|
|
1154
|
+
clientUri,
|
|
1155
|
+
logoUri,
|
|
1156
|
+
policyUri,
|
|
1157
|
+
tosUri,
|
|
1158
|
+
jwksUri,
|
|
1159
|
+
i18n,
|
|
1160
|
+
contacts
|
|
1161
|
+
};
|
|
767
1162
|
}
|
|
768
1163
|
/**
|
|
769
|
-
*
|
|
770
|
-
*
|
|
771
|
-
*
|
|
772
|
-
*
|
|
1164
|
+
* The checks every listed redirect URI passes, whatever the policy: no control characters, a
|
|
1165
|
+
* scheme, and not a dangerous one (`javascript:`, `data:` and the like). A Client ID Metadata
|
|
1166
|
+
* Document is held to these alone; the URI a request uses is then held to {@link validateRedirectUri}.
|
|
1167
|
+
* @throws Error when the redirect URI is not safe to store
|
|
773
1168
|
*/
|
|
774
|
-
function
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
for (const name of [
|
|
788
|
-
"Content-Type",
|
|
789
|
-
"ETag",
|
|
790
|
-
"Last-Modified"
|
|
791
|
-
]) {
|
|
792
|
-
const value = response.headers.get(name);
|
|
793
|
-
if (value !== null) headers.set(name, value);
|
|
1169
|
+
function validateRedirectUriSafety(redirectUri) {
|
|
1170
|
+
const dangerousSchemes = [
|
|
1171
|
+
"javascript:",
|
|
1172
|
+
"data:",
|
|
1173
|
+
"vbscript:",
|
|
1174
|
+
"file:",
|
|
1175
|
+
"mailto:",
|
|
1176
|
+
"blob:"
|
|
1177
|
+
];
|
|
1178
|
+
const normalized = redirectUri.trim();
|
|
1179
|
+
for (let i = 0; i < normalized.length; i++) {
|
|
1180
|
+
const code = normalized.charCodeAt(i);
|
|
1181
|
+
if (code >= 0 && code <= 31 || code >= 127 && code <= 159) throw new Error("Invalid redirect URI");
|
|
794
1182
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
}));
|
|
800
|
-
} catch {}
|
|
1183
|
+
const colonIndex = normalized.indexOf(":");
|
|
1184
|
+
if (colonIndex === -1) throw new Error("Invalid redirect URI");
|
|
1185
|
+
const scheme = normalized.slice(0, colonIndex + 1).toLowerCase();
|
|
1186
|
+
if (dangerousSchemes.includes(scheme)) throw new Error("Invalid redirect URI");
|
|
801
1187
|
}
|
|
802
1188
|
/**
|
|
803
|
-
*
|
|
804
|
-
*
|
|
805
|
-
*
|
|
806
|
-
*
|
|
1189
|
+
* The checks every redirect URI a client registers passes, whether or not this server would send a
|
|
1190
|
+
* code to it: the {@link validateRedirectUriSafety} checks, a parseable URI, and no fragment (not
|
|
1191
|
+
* even an empty `#`) or userinfo.
|
|
1192
|
+
* @throws Error when the redirect URI can't be registered at all
|
|
807
1193
|
*/
|
|
808
|
-
|
|
809
|
-
|
|
1194
|
+
function validateListedRedirectUri(redirectUri) {
|
|
1195
|
+
validateRedirectUriSafety(redirectUri);
|
|
1196
|
+
const normalized = redirectUri.trim();
|
|
1197
|
+
let url;
|
|
810
1198
|
try {
|
|
811
|
-
|
|
1199
|
+
url = new URL(normalized);
|
|
812
1200
|
} catch {
|
|
813
|
-
|
|
814
|
-
}
|
|
815
|
-
if (!cached) return void 0;
|
|
816
|
-
try {
|
|
817
|
-
const { value } = await readJsonWithSizeLimit$1(cached, CIMD_MAX_SIZE_BYTES, signal);
|
|
818
|
-
return resolveClientIdMetadataDocument(metadataUrl, value, server);
|
|
819
|
-
} catch (error) {
|
|
820
|
-
if (signal.aborted) throw error;
|
|
821
|
-
try {
|
|
822
|
-
await cache?.delete(metadataUrl);
|
|
823
|
-
} catch {}
|
|
824
|
-
return;
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
/**
|
|
828
|
-
* Fetches, bounds, parses, validates, and negotiates a Client ID Metadata
|
|
829
|
-
* Document into the single effective client configuration used by OAuth flows.
|
|
830
|
-
*/
|
|
831
|
-
async function fetchClientIdMetadataDocument(metadataUrl, server) {
|
|
832
|
-
validateClientIdentifierUrl(metadataUrl);
|
|
833
|
-
const abortController = new AbortController();
|
|
834
|
-
const timeoutId = setTimeout(() => abortController.abort(), CIMD_FETCH_TIMEOUT_MS);
|
|
835
|
-
try {
|
|
836
|
-
const cache = await openCimdCache();
|
|
837
|
-
const cachedDocument = await tryResolveFromCache(cache, metadataUrl, server, abortController.signal);
|
|
838
|
-
if (cachedDocument) return cachedDocument;
|
|
839
|
-
const response = await fetchCimdOrigin(metadataUrl, abortController.signal);
|
|
840
|
-
if (!response.ok) throw new Error(`Failed to fetch client metadata: HTTP ${response.status}`);
|
|
841
|
-
const { value, bytes } = await readJsonWithSizeLimit$1(response, CIMD_MAX_SIZE_BYTES, abortController.signal);
|
|
842
|
-
const resolved = resolveClientIdMetadataDocument(metadataUrl, value, server);
|
|
843
|
-
clearTimeout(timeoutId);
|
|
844
|
-
await cacheValidatedDocument(cache, metadataUrl, response, bytes);
|
|
845
|
-
return resolved;
|
|
846
|
-
} catch (error) {
|
|
847
|
-
if (abortController.signal.aborted) throw new Error(`Client metadata fetch timed out after ${CIMD_FETCH_TIMEOUT_MS}ms`);
|
|
848
|
-
throw error;
|
|
849
|
-
} finally {
|
|
850
|
-
clearTimeout(timeoutId);
|
|
1201
|
+
throw new Error("Invalid redirect URI");
|
|
851
1202
|
}
|
|
1203
|
+
if (normalized.includes("#") || url.username || url.password) throw new Error("Invalid redirect URI");
|
|
1204
|
+
return url;
|
|
852
1205
|
}
|
|
853
|
-
|
|
854
|
-
//#endregion
|
|
855
|
-
//#region src/ema/constants.ts
|
|
856
|
-
/**
|
|
857
|
-
* Constants for MCP Enterprise-Managed Authorization (EMA).
|
|
858
|
-
*
|
|
859
|
-
* Co-located so that anyone touching the EMA code path sees every magic
|
|
860
|
-
* number in one place. Public-facing defaults can be overridden via
|
|
861
|
-
* `EmaOptions`.
|
|
862
|
-
*/
|
|
863
|
-
/** JWT `typ` header value required for ID-JAG assertions (RFC 8725 §3.11). */
|
|
864
|
-
const EMA_ID_JAG_JWT_TYPE = "oauth-id-jag+jwt";
|
|
865
|
-
/**
|
|
866
|
-
* Grant-profile URN advertised in `authorization_grant_profiles_supported`
|
|
867
|
-
* when EMA is configured (MCP Enterprise-Managed Authorization spec).
|
|
868
|
-
*/
|
|
869
|
-
const EMA_ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag";
|
|
870
|
-
/** Maximum compact JWT assertion size accepted at the token endpoint. */
|
|
871
|
-
const EMA_MAX_JWT_BYTES = 16 * 1024;
|
|
872
|
-
/** Maximum JWKS response size accepted from a trusted enterprise IdP. */
|
|
873
|
-
const EMA_JWKS_MAX_SIZE_BYTES = 64 * 1024;
|
|
874
|
-
/** Request timeout for JWKS fetches. */
|
|
875
|
-
const EMA_JWKS_FETCH_TIMEOUT_MS = 1e4;
|
|
876
|
-
/** Default JWKS cache TTL. */
|
|
877
|
-
const EMA_DEFAULT_JWKS_CACHE_TTL_SECONDS = 300;
|
|
878
|
-
/** Default allowed clock skew for ID-JAG time claim validation. */
|
|
879
|
-
const EMA_DEFAULT_CLOCK_SKEW_SECONDS = 60;
|
|
880
|
-
/** Default maximum accepted ID-JAG lifetime. */
|
|
881
|
-
const EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS = 300;
|
|
882
|
-
/**
|
|
883
|
-
* Minimum cool-down between JWKS force-refreshes per issuer.
|
|
884
|
-
* Defends against attackers that send many assertions with random `kid`
|
|
885
|
-
* values to amplify load on the IdP's JWKS endpoint.
|
|
886
|
-
*/
|
|
887
|
-
const EMA_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS = 30;
|
|
888
|
-
/** Floor for the `jti` replay marker's KV TTL; KV rejects a sub-60s expiration. */
|
|
889
|
-
const EMA_JTI_MIN_TTL_SECONDS = 60;
|
|
890
|
-
/** Default JWT signing algorithm assumed for a trusted issuer. */
|
|
891
|
-
const EMA_DEFAULT_JWT_ALGORITHM = "RS256";
|
|
892
|
-
/** JWT signing algorithms supported by the built-in WebCrypto verifier. */
|
|
893
|
-
const EMA_SUPPORTED_JWT_ALGORITHMS = new Set(["RS256", "ES256"]);
|
|
894
|
-
|
|
895
|
-
//#endregion
|
|
896
|
-
//#region src/ema/result.ts
|
|
897
|
-
const ok = (value) => ({
|
|
898
|
-
ok: true,
|
|
899
|
-
value
|
|
900
|
-
});
|
|
901
|
-
const err = (error) => ({
|
|
902
|
-
ok: false,
|
|
903
|
-
error
|
|
904
|
-
});
|
|
905
1206
|
/**
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
910
|
-
*
|
|
1207
|
+
* Validates a redirect URI against the MCP / OAuth 2.1 redirect policy: `https`, or `http` on a
|
|
1208
|
+
* loopback host (`localhost`, `127.0.0.0/8`, `::1`), with no userinfo and no fragment (not even an
|
|
1209
|
+
* empty `#`). RFC 8252 private-use schemes are accepted only when the server enables
|
|
1210
|
+
* `allowPrivateUseRedirectUris`; remote `http` never is. The {@link validateRedirectUriSafety}
|
|
1211
|
+
* checks always apply.
|
|
1212
|
+
* @throws Error describing why the redirect URI is not acceptable
|
|
911
1213
|
*/
|
|
912
|
-
function
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
case "invalid_scope_param": return {
|
|
919
|
-
code: "invalid_request",
|
|
920
|
-
message: "Invalid scope parameter format"
|
|
921
|
-
};
|
|
922
|
-
case "resource_invalid":
|
|
923
|
-
case "resource_mismatch": return {
|
|
924
|
-
code: "invalid_target",
|
|
925
|
-
message: "Invalid resource"
|
|
926
|
-
};
|
|
927
|
-
case "mapper_denied":
|
|
928
|
-
case "mapper_threw": return {
|
|
929
|
-
code: "invalid_grant",
|
|
930
|
-
message: "Assertion was not authorized"
|
|
931
|
-
};
|
|
932
|
-
case "invalid_mapped_user": return {
|
|
933
|
-
code: "invalid_grant",
|
|
934
|
-
message: "Invalid mapped user"
|
|
935
|
-
};
|
|
936
|
-
case "invalid_mapped_scope": return {
|
|
937
|
-
code: "invalid_grant",
|
|
938
|
-
message: "Invalid mapped scope"
|
|
939
|
-
};
|
|
940
|
-
case "invalid_mapped_props": return {
|
|
941
|
-
code: "invalid_grant",
|
|
942
|
-
message: "Invalid mapped props"
|
|
943
|
-
};
|
|
944
|
-
case "invalid_mapped_ttl": return {
|
|
945
|
-
code: "invalid_grant",
|
|
946
|
-
message: "Invalid access token TTL"
|
|
947
|
-
};
|
|
948
|
-
case "assertion_expired_after_processing": return {
|
|
949
|
-
code: "invalid_grant",
|
|
950
|
-
message: "Assertion has expired"
|
|
951
|
-
};
|
|
952
|
-
case "assertion_too_large":
|
|
953
|
-
case "assertion_malformed":
|
|
954
|
-
case "invalid_typ":
|
|
955
|
-
case "invalid_alg":
|
|
956
|
-
case "issuer_not_trusted":
|
|
957
|
-
case "no_matching_key":
|
|
958
|
-
case "signature_failed":
|
|
959
|
-
case "jwks_fetch_failed":
|
|
960
|
-
case "invalid_claim":
|
|
961
|
-
case "aud_mismatch":
|
|
962
|
-
case "unsupported_claim":
|
|
963
|
-
case "expired":
|
|
964
|
-
case "iat_in_future":
|
|
965
|
-
case "nbf_in_future":
|
|
966
|
-
case "lifetime_too_long":
|
|
967
|
-
case "replayed":
|
|
968
|
-
case "client_id_mismatch": return {
|
|
969
|
-
code: "invalid_grant",
|
|
970
|
-
message: "Invalid assertion"
|
|
971
|
-
};
|
|
1214
|
+
function validateRedirectUri(redirectUri, server) {
|
|
1215
|
+
const url = validateListedRedirectUri(redirectUri);
|
|
1216
|
+
if (url.protocol === "https:") return;
|
|
1217
|
+
if (url.protocol === "http:") {
|
|
1218
|
+
if (isLoopbackHostname(url.hostname)) return;
|
|
1219
|
+
throw new Error("Redirect URI must use https, or http on a loopback host");
|
|
972
1220
|
}
|
|
1221
|
+
if (!server.allowPrivateUseRedirectUris) throw new Error("Redirect URI must use https, or http on a loopback host");
|
|
973
1222
|
}
|
|
974
|
-
|
|
975
|
-
//#endregion
|
|
976
|
-
//#region src/ema/util.ts
|
|
977
1223
|
/**
|
|
978
|
-
*
|
|
979
|
-
*
|
|
980
|
-
*
|
|
981
|
-
*
|
|
982
|
-
*
|
|
1224
|
+
* Validates the redirect URIs a client registers (dynamic registration, `createClient()`,
|
|
1225
|
+
* `updateClient()`). A client may list callbacks for surfaces this server won't send codes to:
|
|
1226
|
+
* Cursor lists `cursor://anysphere.cursor-mcp/oauth/callback` next to its https and loopback
|
|
1227
|
+
* callbacks. So each URI is held to {@link validateListedRedirectUri}, and at least one must satisfy
|
|
1228
|
+
* {@link validateRedirectUri} so the client can sign in. The URI a request uses is held to the full
|
|
1229
|
+
* policy by `parseAuthRequest()` and `completeAuthorization()`.
|
|
1230
|
+
* @throws Error when a URI can't be registered, or none of them could ever receive a code
|
|
983
1231
|
*/
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
const
|
|
987
|
-
|
|
988
|
-
|
|
1232
|
+
function validateRegisteredRedirectUris(redirectUris, server) {
|
|
1233
|
+
if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
|
|
1234
|
+
for (const redirectUri of redirectUris) validateListedRedirectUri(redirectUri);
|
|
1235
|
+
let firstRefusal;
|
|
1236
|
+
for (const redirectUri of redirectUris) try {
|
|
1237
|
+
validateRedirectUri(redirectUri, server);
|
|
1238
|
+
return redirectUris;
|
|
1239
|
+
} catch (error) {
|
|
1240
|
+
firstRefusal ??= error;
|
|
1241
|
+
}
|
|
1242
|
+
throw firstRefusal instanceof Error ? firstRefusal : /* @__PURE__ */ new Error("Invalid redirect URI");
|
|
1243
|
+
}
|
|
1244
|
+
function requireValidRedirectUris(redirectUris, validate) {
|
|
1245
|
+
if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
|
|
1246
|
+
for (const redirectUri of redirectUris) validate(redirectUri);
|
|
1247
|
+
return redirectUris;
|
|
989
1248
|
}
|
|
990
|
-
|
|
991
|
-
//#endregion
|
|
992
|
-
//#region src/ema/jti.ts
|
|
993
1249
|
/**
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
997
|
-
* concurrent requests with the same `jti` can both observe "not seen" and
|
|
998
|
-
* succeed — the trade-off accepted here. Surrounding claim checks
|
|
999
|
-
* (signature, `exp`, `nbf`, `aud`, `resource`, client binding) constrain
|
|
1000
|
-
* the practical attack window.
|
|
1250
|
+
* Resolves a Dynamic Client Registration request into the complete metadata
|
|
1251
|
+
* shape that can be stored, applying RFC 7591 defaults and server capability
|
|
1252
|
+
* validation exactly once.
|
|
1001
1253
|
*/
|
|
1002
|
-
|
|
1003
|
-
const
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
}
|
|
1254
|
+
function resolveDynamicClientRegistrationMetadata(raw, server) {
|
|
1255
|
+
const metadata = parseOAuthClientMetadata(raw);
|
|
1256
|
+
const capabilities = negotiateDynamicClientRegistrationCapabilities(server, {
|
|
1257
|
+
tokenEndpointAuthMethod: metadata.tokenEndpointAuthMethod,
|
|
1258
|
+
tokenEndpointAuthMethodsSupported: metadata.tokenEndpointAuthMethodsSupported,
|
|
1259
|
+
grantTypes: metadata.grantTypes ?? ["authorization_code"],
|
|
1260
|
+
responseTypes: metadata.responseTypes ?? ["code"]
|
|
1261
|
+
});
|
|
1262
|
+
return {
|
|
1263
|
+
...pickDisplayMetadata(metadata),
|
|
1264
|
+
redirectUris: validateRegisteredRedirectUris(metadata.redirectUris, server),
|
|
1265
|
+
...capabilities,
|
|
1266
|
+
authMethodExplicit: metadata.tokenEndpointAuthMethod !== void 0 || metadata.tokenEndpointAuthMethodsSupported !== void 0
|
|
1267
|
+
};
|
|
1016
1268
|
}
|
|
1017
|
-
|
|
1018
|
-
//#endregion
|
|
1019
|
-
//#region src/ema/jwks.ts
|
|
1020
1269
|
/**
|
|
1021
|
-
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
* The cache lives inside the returned provider closure, so each
|
|
1026
|
-
* `OAuthProvider` instance has its own cache — no cross-instance bleed.
|
|
1270
|
+
* Path component of the raw client ID string. Deliberately not
|
|
1271
|
+
* `new URL().pathname`: WHATWG parsing collapses the `.` and `..` segments
|
|
1272
|
+
* that CIMD §3 requires rejecting, and normalizes `\` and scheme-relative
|
|
1273
|
+
* forms this validation must see verbatim.
|
|
1027
1274
|
*/
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
const
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1275
|
+
function rawPath(clientId) {
|
|
1276
|
+
const schemeEnd = clientId.indexOf("://");
|
|
1277
|
+
if (schemeEnd === -1) return "";
|
|
1278
|
+
const authorityStart = schemeEnd + 3;
|
|
1279
|
+
const authorityEndOffset = clientId.slice(authorityStart).search(/[/?#]/);
|
|
1280
|
+
if (authorityEndOffset === -1) return "";
|
|
1281
|
+
const pathStart = authorityStart + authorityEndOffset;
|
|
1282
|
+
if (clientId[pathStart] !== "/") return "";
|
|
1283
|
+
const pathEndOffset = clientId.slice(pathStart).search(/[?#]/);
|
|
1284
|
+
return pathEndOffset === -1 ? clientId.slice(pathStart) : clientId.slice(pathStart, pathStart + pathEndOffset);
|
|
1285
|
+
}
|
|
1286
|
+
function validateClientIdentifierUrl(clientId) {
|
|
1287
|
+
if (clientId !== clientId.trim() || /[\x00-\x20\x7f-\x9f\\]/.test(clientId)) throw new Error("Client Identifier URL contains invalid whitespace or characters");
|
|
1288
|
+
if (!/^https:\/\//i.test(clientId)) throw new Error("Client Identifier URL must use an absolute HTTPS URL");
|
|
1289
|
+
let parsed;
|
|
1290
|
+
try {
|
|
1291
|
+
parsed = new URL(clientId);
|
|
1292
|
+
} catch {
|
|
1293
|
+
throw new Error("Client Identifier URL must be a valid HTTPS URL");
|
|
1294
|
+
}
|
|
1295
|
+
if (parsed.protocol !== "https:") throw new Error("Client Identifier URL must use HTTPS");
|
|
1296
|
+
if (parsed.username || parsed.password) throw new Error("Client Identifier URL must not contain userinfo");
|
|
1297
|
+
if (parsed.hash) throw new Error("Client Identifier URL must not contain a fragment");
|
|
1298
|
+
const path = rawPath(clientId);
|
|
1299
|
+
if (!path) throw new Error("Client Identifier URL must contain a path component");
|
|
1300
|
+
for (const segment of path.split("/")) {
|
|
1301
|
+
let decodedSegment;
|
|
1038
1302
|
try {
|
|
1039
|
-
|
|
1040
|
-
headers: { Accept: "application/json" },
|
|
1041
|
-
signal: abortController.signal,
|
|
1042
|
-
cf: { cacheEverything: true }
|
|
1043
|
-
});
|
|
1044
|
-
if (!response.ok) return err({
|
|
1045
|
-
reason: "jwks_fetch_failed",
|
|
1046
|
-
status: response.status
|
|
1047
|
-
});
|
|
1048
|
-
const contentLength = response.headers.get("content-length");
|
|
1049
|
-
if (contentLength && parseInt(contentLength, 10) > EMA_JWKS_MAX_SIZE_BYTES) return err({
|
|
1050
|
-
reason: "jwks_fetch_failed",
|
|
1051
|
-
status: response.status
|
|
1052
|
-
});
|
|
1053
|
-
const rawJwks = await readJsonWithSizeLimit(response, EMA_JWKS_MAX_SIZE_BYTES);
|
|
1054
|
-
if (!rawJwks.ok) return err({ reason: "jwks_fetch_failed" });
|
|
1055
|
-
if (!Array.isArray(rawJwks.value.keys)) return err({ reason: "jwks_fetch_failed" });
|
|
1056
|
-
const jwks = { keys: rawJwks.value.keys };
|
|
1057
|
-
cache.set(issuer.issuer, {
|
|
1058
|
-
jwks,
|
|
1059
|
-
expiresAt: now + cacheTtl,
|
|
1060
|
-
nextForceRefreshAllowedAt: now + EMA_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS
|
|
1061
|
-
});
|
|
1062
|
-
return ok(jwks);
|
|
1303
|
+
decodedSegment = decodeURIComponent(segment);
|
|
1063
1304
|
} catch {
|
|
1064
|
-
|
|
1065
|
-
} finally {
|
|
1066
|
-
clearTimeout(timeoutId);
|
|
1305
|
+
throw new Error("Client Identifier URL contains invalid percent encoding");
|
|
1067
1306
|
}
|
|
1068
|
-
|
|
1307
|
+
if (decodedSegment === "." || decodedSegment === "..") throw new Error("Client Identifier URL must not contain dot path segments");
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
/** Returns whether a client ID has the URL shape this package resolves through CIMD. */
|
|
1311
|
+
function isClientIdMetadataDocumentUrl(clientId) {
|
|
1312
|
+
try {
|
|
1313
|
+
return new URL(clientId).protocol === "https:" && rawPath(clientId) !== "";
|
|
1314
|
+
} catch {
|
|
1315
|
+
return false;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
const PRIVATE_JWK_MEMBERS = new Set([
|
|
1319
|
+
"d",
|
|
1320
|
+
"p",
|
|
1321
|
+
"q",
|
|
1322
|
+
"dp",
|
|
1323
|
+
"dq",
|
|
1324
|
+
"qi",
|
|
1325
|
+
"oth",
|
|
1326
|
+
"k"
|
|
1327
|
+
]);
|
|
1328
|
+
/** Parses an inline `jwks`, refusing private or symmetric key material, which a public document must never carry. */
|
|
1329
|
+
function parsePublicJwks(value) {
|
|
1330
|
+
if (value === void 0) return void 0;
|
|
1331
|
+
const jwks = requireJsonObject(value);
|
|
1332
|
+
if (!Array.isArray(jwks.keys)) throw new Error("Invalid jwks: keys must be an array");
|
|
1333
|
+
return { keys: jwks.keys.map((value$1) => {
|
|
1334
|
+
const key = requireJsonObject(value$1);
|
|
1335
|
+
if (Object.keys(key).some((member) => PRIVATE_JWK_MEMBERS.has(member))) throw new Error("CIMD documents must not contain private key material");
|
|
1336
|
+
return key;
|
|
1337
|
+
}) };
|
|
1069
1338
|
}
|
|
1070
1339
|
/**
|
|
1071
|
-
*
|
|
1072
|
-
*
|
|
1340
|
+
* The keys and algorithms a document offering `private_key_jwt` supplies, or why they can't be used.
|
|
1341
|
+
* An unusable offer only matters when `private_key_jwt` is the document's sole method.
|
|
1073
1342
|
*/
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1343
|
+
function resolvePrivateKeyJwtKeys(metadata, jwks) {
|
|
1344
|
+
const { jwksUri } = metadata;
|
|
1345
|
+
if (jwks && jwksUri) return { problem: "jwks and jwks_uri must not both be present" };
|
|
1346
|
+
if (!jwks && !jwksUri) return { problem: "private_key_jwt requires jwks or jwks_uri" };
|
|
1347
|
+
if (jwks && (jwks.keys ?? []).length === 0) return { problem: "private_key_jwt jwks must contain a key" };
|
|
1348
|
+
if (jwksUri) {
|
|
1349
|
+
const url = new URL(jwksUri);
|
|
1350
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash) return { problem: "private_key_jwt jwks_uri must be an https: URL without userinfo or a fragment" };
|
|
1351
|
+
}
|
|
1352
|
+
const signingAlgorithms = CLIENT_ASSERTION_ALGORITHMS.filter((alg) => (metadata.tokenEndpointAuthSigningAlg === void 0 || metadata.tokenEndpointAuthSigningAlg === alg) && (metadata.tokenEndpointAuthSigningAlgValuesSupported === void 0 || metadata.tokenEndpointAuthSigningAlgValuesSupported.includes(alg)));
|
|
1353
|
+
if (signingAlgorithms.length === 0) return { problem: `private_key_jwt requires a token_endpoint_auth_signing_alg of ${CLIENT_ASSERTION_ALGORITHMS.join(" or ")}` };
|
|
1354
|
+
const algorithms = jwks ? signingAlgorithms.filter((alg) => (jwks.keys ?? []).some((key) => isKeyUsableForAlgorithm(key, alg))) : signingAlgorithms;
|
|
1355
|
+
if (algorithms.length === 0) return { problem: `private_key_jwt jwks has no key for ${signingAlgorithms.join(" or ")}` };
|
|
1356
|
+
return { keys: jwks ? {
|
|
1357
|
+
jwks,
|
|
1358
|
+
algorithms
|
|
1359
|
+
} : {
|
|
1360
|
+
jwksUri,
|
|
1361
|
+
algorithms
|
|
1362
|
+
} };
|
|
1363
|
+
}
|
|
1364
|
+
function resolveClientIdMetadataDocument(metadataUrl, value, server) {
|
|
1365
|
+
const raw = requireJsonObject(value);
|
|
1366
|
+
const metadata = parseOAuthClientMetadata(raw);
|
|
1367
|
+
if (metadata.clientId !== metadataUrl) throw new Error(`client_id "${metadata.clientId}" does not match metadata URL "${metadataUrl}"`);
|
|
1368
|
+
if (!metadata.clientName?.trim()) throw new Error("client_name is required and must not be empty");
|
|
1369
|
+
const redirectUris = requireValidRedirectUris(metadata.redirectUris, validateRedirectUriSafety);
|
|
1370
|
+
if ("client_secret" in raw || "client_secret_expires_at" in raw) throw new Error("CIMD documents must not contain client secrets");
|
|
1371
|
+
const jwks = parsePublicJwks(raw.jwks);
|
|
1372
|
+
const privateKeyJwt = metadata.tokenEndpointAuthMethod === "private_key_jwt" || !!metadata.tokenEndpointAuthMethodsSupported?.includes("private_key_jwt") ? resolvePrivateKeyJwtKeys(metadata, jwks) : void 0;
|
|
1373
|
+
const clientAssertionKeys = privateKeyJwt && "keys" in privateKeyJwt ? privateKeyJwt.keys : void 0;
|
|
1374
|
+
let capabilities;
|
|
1079
1375
|
try {
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1376
|
+
capabilities = negotiateCimdClientCapabilities(server, {
|
|
1377
|
+
tokenEndpointAuthMethod: metadata.tokenEndpointAuthMethod,
|
|
1378
|
+
tokenEndpointAuthMethodsSupported: metadata.tokenEndpointAuthMethodsSupported,
|
|
1379
|
+
grantTypes: metadata.grantTypes ?? ["authorization_code"],
|
|
1380
|
+
responseTypes: metadata.responseTypes ?? ["code"]
|
|
1381
|
+
}, { privateKeyJwt: clientAssertionKeys !== void 0 });
|
|
1382
|
+
} catch (error) {
|
|
1383
|
+
if (privateKeyJwt && "problem" in privateKeyJwt) throw new Error(privateKeyJwt.problem);
|
|
1384
|
+
throw error;
|
|
1385
|
+
}
|
|
1386
|
+
return {
|
|
1387
|
+
...pickDisplayMetadata(metadata),
|
|
1388
|
+
clientId: metadata.clientId,
|
|
1389
|
+
clientName: metadata.clientName,
|
|
1390
|
+
redirectUris,
|
|
1391
|
+
...capabilities,
|
|
1392
|
+
...capabilities.tokenEndpointAuthMethods.includes("private_key_jwt") ? { clientAssertionKeys } : {}
|
|
1393
|
+
};
|
|
1394
|
+
}
|
|
1395
|
+
function readStreamChunk(reader, signal) {
|
|
1396
|
+
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
1397
|
+
return new Promise((resolve, reject) => {
|
|
1398
|
+
const abort = () => {
|
|
1399
|
+
reader.cancel().catch(() => void 0);
|
|
1400
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
1401
|
+
};
|
|
1402
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
1403
|
+
reader.read().then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
1404
|
+
});
|
|
1405
|
+
}
|
|
1406
|
+
async function readJsonWithSizeLimit(response, maxBytes, signal) {
|
|
1407
|
+
const contentLength = response.headers.get("Content-Length");
|
|
1408
|
+
if (contentLength !== null) {
|
|
1409
|
+
const declaredSize = Number(contentLength);
|
|
1410
|
+
if (Number.isFinite(declaredSize) && declaredSize > maxBytes) {
|
|
1411
|
+
await response.body?.cancel().catch(() => void 0);
|
|
1412
|
+
throw new Error(`Client metadata exceeds size limit: ${contentLength} bytes (max ${maxBytes})`);
|
|
1089
1413
|
}
|
|
1090
|
-
} finally {
|
|
1091
|
-
reader.releaseLock();
|
|
1092
1414
|
}
|
|
1093
|
-
const
|
|
1415
|
+
const reader = response.body?.getReader();
|
|
1416
|
+
if (!reader) throw new Error("Client metadata response body is empty");
|
|
1417
|
+
const chunks = [];
|
|
1418
|
+
let totalSize = 0;
|
|
1419
|
+
while (true) {
|
|
1420
|
+
const { done, value } = await readStreamChunk(reader, signal);
|
|
1421
|
+
if (done) break;
|
|
1422
|
+
if (!value) continue;
|
|
1423
|
+
totalSize += value.length;
|
|
1424
|
+
if (totalSize > maxBytes) {
|
|
1425
|
+
await reader.cancel().catch(() => void 0);
|
|
1426
|
+
throw new Error(`Response exceeded size limit of ${maxBytes} bytes`);
|
|
1427
|
+
}
|
|
1428
|
+
chunks.push(value);
|
|
1429
|
+
}
|
|
1430
|
+
if (signal.aborted) throw new DOMException("Aborted", "AbortError");
|
|
1431
|
+
const bytes = new Uint8Array(totalSize);
|
|
1094
1432
|
let offset = 0;
|
|
1095
1433
|
for (const chunk of chunks) {
|
|
1096
|
-
|
|
1097
|
-
offset += chunk.
|
|
1434
|
+
bytes.set(chunk, offset);
|
|
1435
|
+
offset += chunk.length;
|
|
1098
1436
|
}
|
|
1437
|
+
let text;
|
|
1099
1438
|
try {
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
value: parsed
|
|
1105
|
-
};
|
|
1439
|
+
text = new TextDecoder("utf-8", {
|
|
1440
|
+
fatal: true,
|
|
1441
|
+
ignoreBOM: false
|
|
1442
|
+
}).decode(bytes);
|
|
1106
1443
|
} catch {
|
|
1107
|
-
|
|
1444
|
+
throw new Error("Client metadata response is not valid UTF-8");
|
|
1108
1445
|
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
//#endregion
|
|
1112
|
-
//#region src/ema/parser.ts
|
|
1113
|
-
/**
|
|
1114
|
-
* Pure JWT parsing for ID-JAG assertions.
|
|
1115
|
-
*
|
|
1116
|
-
* Splits a compact JWS (`<base64url-header>.<base64url-payload>.<base64url-signature>`)
|
|
1117
|
-
* into its three parts, decodes header and payload as JSON, and exposes the
|
|
1118
|
-
* raw signing input + signature bytes for downstream signature verification.
|
|
1119
|
-
*
|
|
1120
|
-
* No I/O. No `this`. Returns `Result<ParsedIdJag, EmaValidationError>`.
|
|
1121
|
-
*/
|
|
1122
|
-
/**
|
|
1123
|
-
* Parse a compact JWS assertion.
|
|
1124
|
-
*
|
|
1125
|
-
* @param assertion The raw assertion string from the token request body.
|
|
1126
|
-
* @param maxBytes Reject assertions whose length exceeds this many bytes.
|
|
1127
|
-
* Guards against memory exhaustion before any JSON parsing happens.
|
|
1128
|
-
*/
|
|
1129
|
-
function parseIdJag(assertion, maxBytes) {
|
|
1130
|
-
if (typeof assertion !== "string" || assertion.length === 0) return err({ reason: "assertion_missing" });
|
|
1131
|
-
if (assertion.length > maxBytes) return err({
|
|
1132
|
-
reason: "assertion_too_large",
|
|
1133
|
-
size: assertion.length,
|
|
1134
|
-
max: maxBytes
|
|
1135
|
-
});
|
|
1136
|
-
const parts = assertion.split(".");
|
|
1137
|
-
if (parts.length !== 3 || parts.some((part) => part.length === 0)) return err({ reason: "assertion_malformed" });
|
|
1138
|
-
const [encodedHeader, encodedClaims, encodedSignature] = parts;
|
|
1139
|
-
let header;
|
|
1140
|
-
let rawClaims;
|
|
1141
|
-
let signature;
|
|
1142
1446
|
try {
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1447
|
+
return {
|
|
1448
|
+
value: JSON.parse(text),
|
|
1449
|
+
bytes
|
|
1450
|
+
};
|
|
1146
1451
|
} catch {
|
|
1147
|
-
|
|
1452
|
+
throw new Error("Client metadata response is not valid JSON");
|
|
1148
1453
|
}
|
|
1149
|
-
const signingInput = new TextEncoder().encode(`${encodedHeader}.${encodedClaims}`);
|
|
1150
|
-
return ok({
|
|
1151
|
-
header,
|
|
1152
|
-
rawClaims,
|
|
1153
|
-
signingInput,
|
|
1154
|
-
signature
|
|
1155
|
-
});
|
|
1156
1454
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1455
|
+
function fetchCimdOrigin(metadataUrl, signal) {
|
|
1456
|
+
return fetch(metadataUrl, {
|
|
1457
|
+
headers: {
|
|
1458
|
+
Accept: "application/json",
|
|
1459
|
+
"Cache-Control": "no-store",
|
|
1460
|
+
"User-Agent": OUTBOUND_USER_AGENT
|
|
1461
|
+
},
|
|
1462
|
+
signal,
|
|
1463
|
+
cache: "no-store"
|
|
1464
|
+
});
|
|
1167
1465
|
}
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
*/
|
|
1171
|
-
function parseJwtJsonPart(encoded) {
|
|
1466
|
+
async function openCimdCache() {
|
|
1467
|
+
if (typeof caches === "undefined") return void 0;
|
|
1172
1468
|
try {
|
|
1173
|
-
|
|
1174
|
-
const parsed = JSON.parse(json);
|
|
1175
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JWT part must be an object");
|
|
1176
|
-
return parsed;
|
|
1469
|
+
return await caches.open(CIMD_CACHE_NAME);
|
|
1177
1470
|
} catch {
|
|
1178
|
-
|
|
1471
|
+
return;
|
|
1179
1472
|
}
|
|
1180
1473
|
}
|
|
1181
|
-
|
|
1182
|
-
//#endregion
|
|
1183
|
-
//#region src/ema/signature.ts
|
|
1184
1474
|
/**
|
|
1185
|
-
*
|
|
1186
|
-
*
|
|
1187
|
-
*
|
|
1188
|
-
*
|
|
1189
|
-
* - JWK `use === 'sig'` (when present)
|
|
1190
|
-
* - JWK `key_ops` containing `verify` (when present)
|
|
1191
|
-
* - JWK `alg` matching (when present)
|
|
1192
|
-
* - `kty` compatible with the requested `alg`
|
|
1193
|
-
*
|
|
1194
|
-
* If the assertion has a `kid`, the first matching key wins. Without a `kid`,
|
|
1195
|
-
* we only return a key if exactly one candidate matches — otherwise the
|
|
1196
|
-
* selection is ambiguous and we reject (caller may then force-refresh JWKS).
|
|
1475
|
+
* Shared-cache lifetime derived from the origin's Cache-Control directives,
|
|
1476
|
+
* bounded by the server-side cap the CIMD draft permits (§5.2 "MAY define its
|
|
1477
|
+
* own upper and/or lower bounds on an acceptable cache lifetime"). Returns
|
|
1478
|
+
* undefined when the response must not be stored.
|
|
1197
1479
|
*/
|
|
1198
|
-
function
|
|
1199
|
-
const
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1480
|
+
function cacheTtlSeconds(response) {
|
|
1481
|
+
const cacheControl = response.headers.get("Cache-Control");
|
|
1482
|
+
if (cacheControl === null || /(?:^|,)\s*(?:no-cache|no-store|private)\b/i.test(cacheControl)) return void 0;
|
|
1483
|
+
const directive = /(?:^|,)\s*s-maxage\s*=\s*"?(\d+)/i.exec(cacheControl) ?? /(?:^|,)\s*max-age\s*=\s*"?(\d+)/i.exec(cacheControl);
|
|
1484
|
+
if (!directive) return void 0;
|
|
1485
|
+
const ttl = Math.min(Number(directive[1]), CIMD_CACHE_MAX_TTL_SECONDS);
|
|
1486
|
+
return ttl > 0 ? ttl : void 0;
|
|
1487
|
+
}
|
|
1488
|
+
async function cacheValidatedDocument(cache, metadataUrl, response, bytes) {
|
|
1489
|
+
if (!cache) return;
|
|
1490
|
+
const ttl = cacheTtlSeconds(response);
|
|
1491
|
+
if (ttl === void 0) return;
|
|
1492
|
+
const headers = new Headers({ "Cache-Control": `public, max-age=${ttl}` });
|
|
1493
|
+
for (const name of [
|
|
1494
|
+
"Content-Type",
|
|
1495
|
+
"ETag",
|
|
1496
|
+
"Last-Modified"
|
|
1497
|
+
]) {
|
|
1498
|
+
const value = response.headers.get(name);
|
|
1499
|
+
if (value !== null) headers.set(name, value);
|
|
1215
1500
|
}
|
|
1216
|
-
|
|
1217
|
-
|
|
1501
|
+
try {
|
|
1502
|
+
await cache.put(metadataUrl, new Response(bytes, {
|
|
1503
|
+
status: 200,
|
|
1504
|
+
headers
|
|
1505
|
+
}));
|
|
1506
|
+
} catch {}
|
|
1218
1507
|
}
|
|
1219
1508
|
/**
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1224
|
-
* `EmaValidationError`.
|
|
1509
|
+
* Resolves a previously validated cache entry. A stored document that stops
|
|
1510
|
+
* validating (changed server capabilities, stricter rules after an upgrade)
|
|
1511
|
+
* is evicted so the caller re-resolves from origin in the same request;
|
|
1512
|
+
* timeout aborts propagate instead.
|
|
1225
1513
|
*/
|
|
1226
|
-
async function
|
|
1514
|
+
async function tryResolveFromCache(cache, metadataUrl, server, signal) {
|
|
1515
|
+
let cached;
|
|
1227
1516
|
try {
|
|
1228
|
-
|
|
1229
|
-
const key = await crypto.subtle.importKey("jwk", input.jwk, importAlgorithm, false, ["verify"]);
|
|
1230
|
-
return await crypto.subtle.verify(verifyAlgorithm, key, input.signature, input.signingInput);
|
|
1517
|
+
cached = await cache?.match(metadataUrl);
|
|
1231
1518
|
} catch {
|
|
1232
|
-
return
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
if (!cached) return void 0;
|
|
1522
|
+
try {
|
|
1523
|
+
const { value } = await readJsonWithSizeLimit(cached, CIMD_MAX_SIZE_BYTES, signal);
|
|
1524
|
+
return resolveClientIdMetadataDocument(metadataUrl, value, server);
|
|
1525
|
+
} catch (error) {
|
|
1526
|
+
if (signal.aborted) throw error;
|
|
1527
|
+
try {
|
|
1528
|
+
await cache?.delete(metadataUrl);
|
|
1529
|
+
} catch {}
|
|
1530
|
+
return;
|
|
1233
1531
|
}
|
|
1234
1532
|
}
|
|
1235
1533
|
/**
|
|
1236
|
-
*
|
|
1534
|
+
* Fetches, bounds, parses, validates, and negotiates a Client ID Metadata
|
|
1535
|
+
* Document into the single effective client configuration used by OAuth flows.
|
|
1237
1536
|
*/
|
|
1238
|
-
function
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
};
|
|
1537
|
+
async function fetchClientIdMetadataDocument(metadataUrl, server) {
|
|
1538
|
+
validateClientIdentifierUrl(metadataUrl);
|
|
1539
|
+
const abortController = new AbortController();
|
|
1540
|
+
const timeoutId = setTimeout(() => abortController.abort(), CIMD_FETCH_TIMEOUT_MS);
|
|
1541
|
+
try {
|
|
1542
|
+
const cache = await openCimdCache();
|
|
1543
|
+
const cachedDocument = await tryResolveFromCache(cache, metadataUrl, server, abortController.signal);
|
|
1544
|
+
if (cachedDocument) return cachedDocument;
|
|
1545
|
+
const response = await fetchCimdOrigin(metadataUrl, abortController.signal);
|
|
1546
|
+
if (!response.ok) throw new Error(`Failed to fetch client metadata: HTTP ${response.status}`);
|
|
1547
|
+
const { value, bytes } = await readJsonWithSizeLimit(response, CIMD_MAX_SIZE_BYTES, abortController.signal);
|
|
1548
|
+
const resolved = resolveClientIdMetadataDocument(metadataUrl, value, server);
|
|
1549
|
+
clearTimeout(timeoutId);
|
|
1550
|
+
await cacheValidatedDocument(cache, metadataUrl, response, bytes);
|
|
1551
|
+
return resolved;
|
|
1552
|
+
} catch (error) {
|
|
1553
|
+
if (abortController.signal.aborted) throw new Error(`Client metadata fetch timed out after ${CIMD_FETCH_TIMEOUT_MS}ms`);
|
|
1554
|
+
throw error;
|
|
1555
|
+
} finally {
|
|
1556
|
+
clearTimeout(timeoutId);
|
|
1248
1557
|
}
|
|
1249
|
-
if (alg === "ES256") return {
|
|
1250
|
-
importAlgorithm: {
|
|
1251
|
-
name: "ECDSA",
|
|
1252
|
-
namedCurve: "P-256"
|
|
1253
|
-
},
|
|
1254
|
-
verifyAlgorithm: {
|
|
1255
|
-
name: "ECDSA",
|
|
1256
|
-
hash: "SHA-256"
|
|
1257
|
-
}
|
|
1258
|
-
};
|
|
1259
|
-
throw new Error(`Unsupported JWT alg: ${alg}`);
|
|
1260
1558
|
}
|
|
1261
1559
|
|
|
1262
1560
|
//#endregion
|
|
@@ -1289,9 +1587,11 @@ function validateIdJagHeader(header, expectedTyp, supportedAlgs) {
|
|
|
1289
1587
|
* validate the returned configuration before signature verification ever
|
|
1290
1588
|
* runs against it.
|
|
1291
1589
|
*
|
|
1292
|
-
* Every failure here collapses to `issuer_not_trusted` so an attacker
|
|
1590
|
+
* Every trust failure here collapses to `issuer_not_trusted` so an attacker
|
|
1293
1591
|
* cannot distinguish "unknown IdP" from "resolver returned a malformed
|
|
1294
|
-
* config"
|
|
1592
|
+
* config". An `alg` outside the issuer's allowlist is reported as
|
|
1593
|
+
* `invalid_alg` so the deployer can see it in `onError`; both reach the
|
|
1594
|
+
* client as the same generic `invalid_grant` "Invalid assertion".
|
|
1295
1595
|
*
|
|
1296
1596
|
* The resolver's returned `issuer` field MUST equal the input `iss`.
|
|
1297
1597
|
* Otherwise a buggy resolver could be tricked into returning a config
|
|
@@ -1332,8 +1632,8 @@ async function resolveTrustedIssuer(input) {
|
|
|
1332
1632
|
iss
|
|
1333
1633
|
});
|
|
1334
1634
|
if (!(resolved.algorithms ?? [EMA_DEFAULT_JWT_ALGORITHM]).includes(alg)) return err({
|
|
1335
|
-
reason: "
|
|
1336
|
-
|
|
1635
|
+
reason: "invalid_alg",
|
|
1636
|
+
got: alg
|
|
1337
1637
|
});
|
|
1338
1638
|
return ok(resolved);
|
|
1339
1639
|
}
|
|
@@ -2172,11 +2472,19 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
|
|
|
2172
2472
|
const DEFAULT_AUTHORIZE_PATH = "/authorize";
|
|
2173
2473
|
const DEFAULT_TOKEN_PATH = "/oauth/token";
|
|
2174
2474
|
function toPublicClientInfo(client) {
|
|
2175
|
-
const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, ...publicClient } = client;
|
|
2475
|
+
const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, tokenEndpointAuthMethods: _methods, clientAssertionKeys: _keys, ...publicClient } = client;
|
|
2176
2476
|
return publicClient;
|
|
2177
2477
|
}
|
|
2478
|
+
/** Token endpoint parameters that carry client authentication (RFC 6749 §2.3, RFC 7521 §4.2). */
|
|
2479
|
+
const CLIENT_AUTHENTICATION_PARAMETERS = new Set([
|
|
2480
|
+
"client_id",
|
|
2481
|
+
"client_secret",
|
|
2482
|
+
"client_assertion",
|
|
2483
|
+
"client_assertion_type"
|
|
2484
|
+
]);
|
|
2178
2485
|
function isClientAuthMethodAllowed(client, presentedMethod, isClientMetadataDocument) {
|
|
2179
2486
|
if (presentedMethod === client.tokenEndpointAuthMethod) return true;
|
|
2487
|
+
if (isClientMetadataDocument && client.tokenEndpointAuthMethods?.includes(presentedMethod)) return true;
|
|
2180
2488
|
const isSecretMethod = (method) => method === "client_secret_basic" || method === "client_secret_post";
|
|
2181
2489
|
return !isClientMetadataDocument && client.authMethodExplicit === void 0 && isSecretMethod(client.tokenEndpointAuthMethod) && isSecretMethod(presentedMethod);
|
|
2182
2490
|
}
|
|
@@ -2405,6 +2713,7 @@ var OAuthProviderImpl = class {
|
|
|
2405
2713
|
this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
|
|
2406
2714
|
this.jtiStore = createKvJtiStore();
|
|
2407
2715
|
}
|
|
2716
|
+
if (this.options.clientIdMetadataDocumentEnabled) this.clientAssertionVerifier = createClientAssertionVerifier();
|
|
2408
2717
|
}
|
|
2409
2718
|
/**
|
|
2410
2719
|
* Validates that an endpoint is either an absolute path or a full URL
|
|
@@ -2618,7 +2927,7 @@ var OAuthProviderImpl = class {
|
|
|
2618
2927
|
if (parsed instanceof Response) return withCorsHeaders(parsed, request);
|
|
2619
2928
|
let response;
|
|
2620
2929
|
if (parsed.isRevocationRequest) response = await this.revokeToken(parsed.body, parsed.clientInfo, env);
|
|
2621
|
-
else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
|
|
2930
|
+
else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, parsed.clientAuthMethod, env, url, request);
|
|
2622
2931
|
if (response.ok && !this.errorResponses.has(response)) await this.renewClientRegistrationIfDue(env, parsed.clientInfo, Math.floor(Date.now() / 1e3));
|
|
2623
2932
|
return withCorsHeaders(response, request);
|
|
2624
2933
|
}
|
|
@@ -2804,10 +3113,11 @@ var OAuthProviderImpl = class {
|
|
|
2804
3113
|
});
|
|
2805
3114
|
}
|
|
2806
3115
|
const processedKeys = /* @__PURE__ */ new Set();
|
|
2807
|
-
for (const
|
|
3116
|
+
for (const key of formData.keys()) {
|
|
2808
3117
|
if (processedKeys.has(key)) continue;
|
|
2809
3118
|
processedKeys.add(key);
|
|
2810
|
-
const allValues = formData.getAll(key);
|
|
3119
|
+
const allValues = CLIENT_AUTHENTICATION_PARAMETERS.has(key) ? formData.getAll(key).filter((entry) => entry !== "") : formData.getAll(key);
|
|
3120
|
+
if (allValues.length === 0) continue;
|
|
2811
3121
|
if (key !== "resource" && allValues.length > 1) return this.createErrorResponse("invalid_request", {
|
|
2812
3122
|
description: `Request parameter "${key}" must not be repeated`,
|
|
2813
3123
|
statusCode: 400
|
|
@@ -2816,14 +3126,16 @@ var OAuthProviderImpl = class {
|
|
|
2816
3126
|
reason: "repeated_parameter",
|
|
2817
3127
|
detail: { parameter: key }
|
|
2818
3128
|
});
|
|
2819
|
-
body[key] = allValues.length > 1 ? allValues :
|
|
3129
|
+
body[key] = allValues.length > 1 ? allValues : allValues[0];
|
|
2820
3130
|
}
|
|
2821
3131
|
const basicAuthorization = parseBasicAuthorizationHeader(request.headers.get("Authorization"));
|
|
2822
3132
|
const basicAuthenticationAttempted = basicAuthorization.kind !== "not-basic";
|
|
3133
|
+
const sent = (name) => body[name] !== void 0;
|
|
3134
|
+
const clientAssertionAttempted = sent("client_assertion") || sent("client_assertion_type");
|
|
2823
3135
|
let clientId = "";
|
|
2824
3136
|
let clientSecret = "";
|
|
2825
3137
|
if (basicAuthenticationAttempted) {
|
|
2826
|
-
if (
|
|
3138
|
+
if (sent("client_id") || sent("client_secret") || clientAssertionAttempted) return this.createErrorResponse("invalid_request", {
|
|
2827
3139
|
description: "Client must not use multiple authentication methods",
|
|
2828
3140
|
statusCode: 400
|
|
2829
3141
|
}, {
|
|
@@ -2836,6 +3148,22 @@ var OAuthProviderImpl = class {
|
|
|
2836
3148
|
});
|
|
2837
3149
|
clientId = basicAuthorization.clientId;
|
|
2838
3150
|
clientSecret = basicAuthorization.clientSecret;
|
|
3151
|
+
} else if (clientAssertionAttempted) {
|
|
3152
|
+
if (sent("client_secret")) return this.createErrorResponse("invalid_request", {
|
|
3153
|
+
description: "Client must not use multiple authentication methods",
|
|
3154
|
+
statusCode: 400
|
|
3155
|
+
}, {
|
|
3156
|
+
category: "client-authentication",
|
|
3157
|
+
reason: "multiple_authentication_methods"
|
|
3158
|
+
});
|
|
3159
|
+
if (!sent("client_assertion") || !sent("client_assertion_type")) return this.createErrorResponse("invalid_request", {
|
|
3160
|
+
description: "client_assertion and client_assertion_type must be sent together",
|
|
3161
|
+
statusCode: 400
|
|
3162
|
+
}, {
|
|
3163
|
+
category: "client-authentication",
|
|
3164
|
+
reason: "client_assertion_incomplete"
|
|
3165
|
+
});
|
|
3166
|
+
clientId = body.client_id || readClientAssertionIssuer(body.client_assertion) || "";
|
|
2839
3167
|
} else {
|
|
2840
3168
|
clientId = body.client_id;
|
|
2841
3169
|
clientSecret = body.client_secret || "";
|
|
@@ -2862,7 +3190,7 @@ var OAuthProviderImpl = class {
|
|
|
2862
3190
|
category: "client-authentication",
|
|
2863
3191
|
reason: "client_not_found"
|
|
2864
3192
|
});
|
|
2865
|
-
const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" :
|
|
3193
|
+
const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : clientAssertionAttempted ? "private_key_jwt" : sent("client_secret") ? "client_secret_post" : "none";
|
|
2866
3194
|
const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
|
|
2867
3195
|
if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
|
|
2868
3196
|
category: "client-authentication",
|
|
@@ -2873,7 +3201,10 @@ var OAuthProviderImpl = class {
|
|
|
2873
3201
|
presentedMethod: presentedAuthMethod
|
|
2874
3202
|
}
|
|
2875
3203
|
});
|
|
2876
|
-
if (presentedAuthMethod
|
|
3204
|
+
if (presentedAuthMethod === "private_key_jwt") {
|
|
3205
|
+
const failure = await this.verifyClientAssertion(body, clientInfo, env, request);
|
|
3206
|
+
if (failure) return failure;
|
|
3207
|
+
} else if (presentedAuthMethod !== "none") {
|
|
2877
3208
|
if (!clientSecret) return this.createInvalidClientResponse("Client authentication failed: missing client_secret", basicAuthenticationAttempted, {
|
|
2878
3209
|
category: "client-authentication",
|
|
2879
3210
|
reason: "client_secret_missing"
|
|
@@ -2891,10 +3222,47 @@ var OAuthProviderImpl = class {
|
|
|
2891
3222
|
return {
|
|
2892
3223
|
body,
|
|
2893
3224
|
clientInfo,
|
|
3225
|
+
clientAuthMethod: presentedAuthMethod,
|
|
2894
3226
|
isRevocationRequest
|
|
2895
3227
|
};
|
|
2896
3228
|
}
|
|
2897
3229
|
/**
|
|
3230
|
+
* Authenticates a `private_key_jwt` request (RFC 7523 §2.2). The method check has already
|
|
3231
|
+
* confirmed that the client registered the method, which only a CIMD client with usable keys can.
|
|
3232
|
+
* @returns An `invalid_client` response, or `undefined` once the assertion verified and was consumed
|
|
3233
|
+
*/
|
|
3234
|
+
async verifyClientAssertion(body, clientInfo, env, request) {
|
|
3235
|
+
const keys = clientInfo.clientAssertionKeys;
|
|
3236
|
+
const verifier = this.clientAssertionVerifier;
|
|
3237
|
+
if (!keys || !verifier) throw new Error("private_key_jwt client resolved without keys or a verifier");
|
|
3238
|
+
if (body.client_assertion_type !== JWT_BEARER_CLIENT_ASSERTION_TYPE) return this.createInvalidClientResponse("Client authentication failed", false, {
|
|
3239
|
+
category: "client-authentication",
|
|
3240
|
+
reason: "client_assertion_type_unsupported",
|
|
3241
|
+
detail: {
|
|
3242
|
+
clientId: clientInfo.clientId,
|
|
3243
|
+
clientAssertionType: String(body.client_assertion_type)
|
|
3244
|
+
}
|
|
3245
|
+
});
|
|
3246
|
+
const requestUrl = new URL(request.url);
|
|
3247
|
+
const verified = await verifier.verify({
|
|
3248
|
+
assertion: body.client_assertion,
|
|
3249
|
+
clientId: clientInfo.clientId,
|
|
3250
|
+
keys,
|
|
3251
|
+
issuer: this.getAuthorizationServerIssuer(requestUrl),
|
|
3252
|
+
tokenEndpoint: this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl),
|
|
3253
|
+
env
|
|
3254
|
+
});
|
|
3255
|
+
if (verified.ok) return void 0;
|
|
3256
|
+
return this.createInvalidClientResponse("Client authentication failed", false, {
|
|
3257
|
+
category: "client-authentication",
|
|
3258
|
+
reason: "client_assertion_invalid",
|
|
3259
|
+
detail: {
|
|
3260
|
+
clientId: clientInfo.clientId,
|
|
3261
|
+
...verified.error
|
|
3262
|
+
}
|
|
3263
|
+
}, request);
|
|
3264
|
+
}
|
|
3265
|
+
/**
|
|
2898
3266
|
* Checks if a URL matches a specific API route
|
|
2899
3267
|
* @param url - The URL to check
|
|
2900
3268
|
* @param route - The API route to check against
|
|
@@ -2953,6 +3321,7 @@ var OAuthProviderImpl = class {
|
|
|
2953
3321
|
const responseTypesSupported = this.serverCapabilities.responseTypes;
|
|
2954
3322
|
const grantTypesSupported = this.serverCapabilities.grantTypes;
|
|
2955
3323
|
const authorizationGrantProfilesSupported = this.options.enterpriseManagedAuthorization ? [EMA_ID_JAG_GRANT_PROFILE] : [];
|
|
3324
|
+
const cimdSupported = !!this.options.clientIdMetadataDocumentEnabled && this.hasGlobalFetchStrictlyPublic();
|
|
2956
3325
|
const metadata = {
|
|
2957
3326
|
issuer: this.getAuthorizationServerIssuer(requestUrl),
|
|
2958
3327
|
authorization_endpoint: authorizeEndpoint,
|
|
@@ -2964,11 +3333,12 @@ var OAuthProviderImpl = class {
|
|
|
2964
3333
|
response_modes_supported: ["query"],
|
|
2965
3334
|
grant_types_supported: grantTypesSupported,
|
|
2966
3335
|
...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
|
|
2967
|
-
token_endpoint_auth_methods_supported: this.serverCapabilities.tokenEndpointAuthMethods,
|
|
3336
|
+
token_endpoint_auth_methods_supported: [...this.serverCapabilities.tokenEndpointAuthMethods, ...cimdSupported ? ["private_key_jwt"] : []],
|
|
3337
|
+
...cimdSupported ? { token_endpoint_auth_signing_alg_values_supported: [...CLIENT_ASSERTION_ALGORITHMS] } : {},
|
|
2968
3338
|
revocation_endpoint: tokenEndpoint,
|
|
2969
3339
|
code_challenge_methods_supported: this.serverCapabilities.codeChallengeMethods,
|
|
2970
3340
|
authorization_response_iss_parameter_supported: true,
|
|
2971
|
-
client_id_metadata_document_supported:
|
|
3341
|
+
client_id_metadata_document_supported: cimdSupported
|
|
2972
3342
|
};
|
|
2973
3343
|
return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
|
|
2974
3344
|
}
|
|
@@ -3007,7 +3377,7 @@ var OAuthProviderImpl = class {
|
|
|
3007
3377
|
* @param env - Cloudflare Worker environment variables
|
|
3008
3378
|
* @returns Response with token data or error
|
|
3009
3379
|
*/
|
|
3010
|
-
async handleTokenRequest(body, clientInfo, env, requestUrl, request) {
|
|
3380
|
+
async handleTokenRequest(body, clientInfo, clientAuthMethod, env, requestUrl, request) {
|
|
3011
3381
|
try {
|
|
3012
3382
|
const grantType = body.grant_type;
|
|
3013
3383
|
if (!(grantType === GrantType.AUTHORIZATION_CODE || grantType === GrantType.REFRESH_TOKEN || grantType === GrantType.TOKEN_EXCHANGE && !!this.options.allowTokenExchangeGrant || grantType === GrantType.JWT_BEARER && !!this.options.enterpriseManagedAuthorization)) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" }, {
|
|
@@ -3025,7 +3395,7 @@ var OAuthProviderImpl = class {
|
|
|
3025
3395
|
if (grantType === GrantType.AUTHORIZATION_CODE) return await this.handleAuthorizationCodeGrant(body, clientInfo, env);
|
|
3026
3396
|
else if (grantType === GrantType.REFRESH_TOKEN) return await this.handleRefreshTokenGrant(body, clientInfo, env);
|
|
3027
3397
|
else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return await this.handleTokenExchangeGrant(body, clientInfo, env);
|
|
3028
|
-
else if (grantType === GrantType.JWT_BEARER) return await this.handleJwtBearerGrant(body, clientInfo, env, requestUrl, request);
|
|
3398
|
+
else if (grantType === GrantType.JWT_BEARER) return await this.handleJwtBearerGrant(body, clientInfo, clientAuthMethod, env, requestUrl, request);
|
|
3029
3399
|
throw new Error("Unreachable supported grant type");
|
|
3030
3400
|
} catch (error) {
|
|
3031
3401
|
const response = this.createOAuthErrorResponse(error);
|
|
@@ -3663,13 +4033,13 @@ var OAuthProviderImpl = class {
|
|
|
3663
4033
|
* standard OAuth wire response. All validation logic lives in pure
|
|
3664
4034
|
* functions in `src/ema/`.
|
|
3665
4035
|
*/
|
|
3666
|
-
async handleJwtBearerGrant(body, clientInfo, env, requestUrl, request) {
|
|
4036
|
+
async handleJwtBearerGrant(body, clientInfo, clientAuthMethod, env, requestUrl, request) {
|
|
3667
4037
|
const enterpriseOptions = this.options.enterpriseManagedAuthorization;
|
|
3668
4038
|
if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" }, {
|
|
3669
4039
|
category: "enterprise-managed-authorization",
|
|
3670
4040
|
reason: "grant_disabled"
|
|
3671
4041
|
});
|
|
3672
|
-
if (
|
|
4042
|
+
if (clientAuthMethod === "none" && !enterpriseOptions.allowPublicClients) return this.createErrorResponse("invalid_client", {
|
|
3673
4043
|
description: "Enterprise-managed authorization requires client authentication",
|
|
3674
4044
|
statusCode: 401
|
|
3675
4045
|
}, {
|
|
@@ -4361,6 +4731,27 @@ var OAuthProviderImpl = class {
|
|
|
4361
4731
|
if (typeof singular !== "string" || !validateResourceUri(singular)) return void 0;
|
|
4362
4732
|
return this.resourceServers.map((server) => server.resourceMetadata.resource).find((configured) => isExactResource(singular, configured));
|
|
4363
4733
|
}
|
|
4734
|
+
/**
|
|
4735
|
+
* Resolve a client-supplied RFC 8707 resource indicator to a configured resource.
|
|
4736
|
+
*
|
|
4737
|
+
* An exact match wins. Otherwise a request may name the URL it uses at the resource
|
|
4738
|
+
* when that URL differs from a configured resource only by query parameters the
|
|
4739
|
+
* configured resource does not constrain, such as `https://mcp.example.com/mcp?mode=x`
|
|
4740
|
+
* for `https://mcp.example.com/mcp`. The resource server already treats that URL as
|
|
4741
|
+
* covered by the resource (its 401 points at the resource's metadata, and the
|
|
4742
|
+
* resource's tokens are accepted there), and RFC 8707 §2.2 lets the authorization
|
|
4743
|
+
* server map a requested value to a more general resource. The audience stays the
|
|
4744
|
+
* configured resource, so the token is no broader. See {@link findCoveringResource}.
|
|
4745
|
+
* Only client requests use this; stored audiences and configuration stay exact.
|
|
4746
|
+
*/
|
|
4747
|
+
findRequestedResource(value) {
|
|
4748
|
+
const exact = this.findConfiguredResource(value);
|
|
4749
|
+
if (exact) return exact;
|
|
4750
|
+
const distinct = Array.isArray(value) ? [...new Set(value)] : [value];
|
|
4751
|
+
const singular = distinct.length === 1 ? distinct[0] : void 0;
|
|
4752
|
+
if (typeof singular !== "string" || !validateResourceUri(singular)) return void 0;
|
|
4753
|
+
return findCoveringResource(singular, this.resourceServers.map((server) => server.resourceMetadata.resource));
|
|
4754
|
+
}
|
|
4364
4755
|
/** Select the audience for a new interactive authorization. */
|
|
4365
4756
|
resolveAuthorizationRequestResource(requestedResource) {
|
|
4366
4757
|
if (requestedResource === void 0) {
|
|
@@ -4368,7 +4759,7 @@ var OAuthProviderImpl = class {
|
|
|
4368
4759
|
if (defaultResource) return defaultResource;
|
|
4369
4760
|
throw new AuthorizationError("invalid_target", { description: "The resource parameter is required when the authorization server has multiple resources" });
|
|
4370
4761
|
}
|
|
4371
|
-
const configured = this.
|
|
4762
|
+
const configured = this.findRequestedResource(requestedResource);
|
|
4372
4763
|
if (!configured) throw new AuthorizationError("invalid_target", { description: "The resource parameter must name exactly one configured protected resource" });
|
|
4373
4764
|
return configured;
|
|
4374
4765
|
}
|
|
@@ -4396,7 +4787,7 @@ var OAuthProviderImpl = class {
|
|
|
4396
4787
|
/** Validate explicit resource syntax and configured-resource policy. */
|
|
4397
4788
|
validateTokenRequestResourceIndicator(requestedResource) {
|
|
4398
4789
|
if (requestedResource === void 0) return;
|
|
4399
|
-
if (!this.
|
|
4790
|
+
if (!this.findRequestedResource(requestedResource)) throw new OAuthError("invalid_target", {
|
|
4400
4791
|
description: "The resource parameter must name exactly one configured protected resource",
|
|
4401
4792
|
internal: {
|
|
4402
4793
|
category: "resource-indicator",
|
|
@@ -4415,7 +4806,7 @@ var OAuthProviderImpl = class {
|
|
|
4415
4806
|
}
|
|
4416
4807
|
});
|
|
4417
4808
|
const resourceWasProvided = requestedResource !== void 0;
|
|
4418
|
-
const requestedAudience = resourceWasProvided ? this.
|
|
4809
|
+
const requestedAudience = resourceWasProvided ? this.findRequestedResource(requestedResource) : void 0;
|
|
4419
4810
|
if (resourceWasProvided && (!requestedAudience || requestedAudience !== subjectAudience)) throw new OAuthError("invalid_target", {
|
|
4420
4811
|
description: "The requested resource must exactly match the subject token audience",
|
|
4421
4812
|
internal: {
|
|
@@ -4434,7 +4825,7 @@ var OAuthProviderImpl = class {
|
|
|
4434
4825
|
const resourceWasProvided = requestedResource !== void 0;
|
|
4435
4826
|
const grantResourceWasStored = grantedResource !== void 0;
|
|
4436
4827
|
const canonicalGrantResource = this.findStoredConfiguredResource(grantedResource);
|
|
4437
|
-
const canonicalRequestedResource = resourceWasProvided ? this.
|
|
4828
|
+
const canonicalRequestedResource = resourceWasProvided ? this.findRequestedResource(requestedResource) : void 0;
|
|
4438
4829
|
if (grantResourceWasStored && !canonicalGrantResource) throw new OAuthError("invalid_grant", {
|
|
4439
4830
|
description: "The authorization grant is not bound to a configured resource",
|
|
4440
4831
|
internal: {
|