@cloudflare/workers-oauth-provider 0.5.0 → 0.7.0

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.
@@ -1,5 +1,675 @@
1
1
  import { WorkerEntrypoint } from "cloudflare:workers";
2
2
 
3
+ //#region src/ema/constants.ts
4
+ /**
5
+ * Constants for MCP Enterprise-Managed Authorization (EMA).
6
+ *
7
+ * Co-located so that anyone touching the EMA code path sees every magic
8
+ * number in one place. Public-facing defaults can be overridden via
9
+ * `EmaOptions`.
10
+ */
11
+ /** JWT `typ` header value required for ID-JAG assertions (RFC 8725 §3.11). */
12
+ const EMA_ID_JAG_JWT_TYPE = "oauth-id-jag+jwt";
13
+ /**
14
+ * Grant-profile URN advertised in `authorization_grant_profiles_supported`
15
+ * when EMA is configured (MCP Enterprise-Managed Authorization spec).
16
+ */
17
+ const EMA_ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag";
18
+ /** Maximum compact JWT assertion size accepted at the token endpoint. */
19
+ const EMA_MAX_JWT_BYTES = 16 * 1024;
20
+ /** Maximum JWKS response size accepted from a trusted enterprise IdP. */
21
+ const EMA_JWKS_MAX_SIZE_BYTES = 64 * 1024;
22
+ /** Request timeout for JWKS fetches. */
23
+ const EMA_JWKS_FETCH_TIMEOUT_MS = 1e4;
24
+ /** Default JWKS cache TTL. */
25
+ const EMA_DEFAULT_JWKS_CACHE_TTL_SECONDS = 300;
26
+ /** Default allowed clock skew for ID-JAG time claim validation. */
27
+ const EMA_DEFAULT_CLOCK_SKEW_SECONDS = 60;
28
+ /** Default maximum accepted ID-JAG lifetime. */
29
+ const EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS = 300;
30
+ /**
31
+ * Minimum cool-down between JWKS force-refreshes per issuer.
32
+ * Defends against attackers that send many assertions with random `kid`
33
+ * values to amplify load on the IdP's JWKS endpoint.
34
+ */
35
+ const EMA_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS = 30;
36
+ /** Default JWT signing algorithm assumed for a trusted issuer. */
37
+ const EMA_DEFAULT_JWT_ALGORITHM = "RS256";
38
+ /** JWT signing algorithms supported by the built-in WebCrypto verifier. */
39
+ const EMA_SUPPORTED_JWT_ALGORITHMS = new Set(["RS256", "ES256"]);
40
+
41
+ //#endregion
42
+ //#region src/ema/result.ts
43
+ const ok = (value) => ({
44
+ ok: true,
45
+ value
46
+ });
47
+ const err = (error) => ({
48
+ ok: false,
49
+ error
50
+ });
51
+ /**
52
+ * Map an internal `EmaValidationError` to its public OAuth error code and
53
+ * description. Most validation failures collapse to a single generic message
54
+ * to avoid leaking which check failed to attackers probing the IdP. The
55
+ * exceptions are RFC-prescribed distinct codes (`invalid_target` for
56
+ * RFC 8707 resource issues, `invalid_request` for malformed input).
57
+ */
58
+ function emaErrorToWire(e) {
59
+ switch (e.reason) {
60
+ case "assertion_missing": return {
61
+ code: "invalid_request",
62
+ message: "assertion is required"
63
+ };
64
+ case "invalid_scope_param": return {
65
+ code: "invalid_request",
66
+ message: "Invalid scope parameter format"
67
+ };
68
+ case "resource_invalid":
69
+ case "resource_mismatch": return {
70
+ code: "invalid_target",
71
+ message: "Invalid resource"
72
+ };
73
+ case "mapper_denied":
74
+ case "mapper_threw": return {
75
+ code: "invalid_grant",
76
+ message: "Assertion was not authorized"
77
+ };
78
+ case "invalid_mapped_user": return {
79
+ code: "invalid_grant",
80
+ message: "Invalid mapped user"
81
+ };
82
+ case "invalid_mapped_scope": return {
83
+ code: "invalid_grant",
84
+ message: "Invalid mapped scope"
85
+ };
86
+ case "invalid_mapped_props": return {
87
+ code: "invalid_grant",
88
+ message: "Invalid mapped props"
89
+ };
90
+ case "invalid_mapped_ttl": return {
91
+ code: "invalid_grant",
92
+ message: "Invalid access token TTL"
93
+ };
94
+ case "assertion_expired_after_processing": return {
95
+ code: "invalid_grant",
96
+ message: "Assertion has expired"
97
+ };
98
+ case "assertion_too_large":
99
+ case "assertion_malformed":
100
+ case "invalid_typ":
101
+ case "invalid_alg":
102
+ case "issuer_not_trusted":
103
+ case "no_matching_key":
104
+ case "signature_failed":
105
+ case "jwks_fetch_failed":
106
+ case "invalid_claim":
107
+ case "aud_mismatch":
108
+ case "expired":
109
+ case "iat_in_future":
110
+ case "nbf_in_future":
111
+ case "lifetime_too_long":
112
+ case "replayed":
113
+ case "client_id_mismatch": return {
114
+ code: "invalid_grant",
115
+ message: "Invalid assertion"
116
+ };
117
+ }
118
+ }
119
+
120
+ //#endregion
121
+ //#region src/ema/util.ts
122
+ /**
123
+ * Tiny utility helpers used by EMA adapters.
124
+ *
125
+ * Lives in `src/ema/util.ts` rather than `src/util.ts` to keep the EMA
126
+ * module self-contained — the main `oauth-provider.ts` reaches into here
127
+ * only through the adapters' public interfaces.
128
+ */
129
+ /** SHA-256 a string and return its hex digest. */
130
+ async function sha256Hex(input) {
131
+ const data = new TextEncoder().encode(input);
132
+ const buffer = await crypto.subtle.digest("SHA-256", data);
133
+ return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
134
+ }
135
+
136
+ //#endregion
137
+ //#region src/ema/jti.ts
138
+ /**
139
+ * Default `EmaJtiStore`: KV-backed `jti` replay marker.
140
+ *
141
+ * KV is eventually-consistent and does not provide compare-and-set, so two
142
+ * concurrent requests with the same `jti` can both observe "not seen" and
143
+ * succeed — the trade-off accepted here. Surrounding claim checks
144
+ * (signature, `exp`, `nbf`, `aud`, `resource`, client binding) constrain
145
+ * the practical attack window.
146
+ */
147
+ /** Storage key prefix for replay markers. Stable across versions. */
148
+ const EMA_JTI_KV_PREFIX = "enterprise-jti:";
149
+ /** Create the default KV-backed JTI store. KV TTL handles cleanup. */
150
+ function createKvJtiStore() {
151
+ return { async markUsed({ issuer, jti, exp, now, env }) {
152
+ const ttl = Math.max(1, exp - now);
153
+ const key = `${EMA_JTI_KV_PREFIX}${await sha256Hex(`${issuer}\n${jti}`)}`;
154
+ if (await env.OAUTH_KV.get(key)) return err({
155
+ reason: "replayed",
156
+ jti
157
+ });
158
+ await env.OAUTH_KV.put(key, "1", { expirationTtl: ttl });
159
+ return ok(void 0);
160
+ } };
161
+ }
162
+
163
+ //#endregion
164
+ //#region src/ema/jwks.ts
165
+ /**
166
+ * Default `EmaJwksProvider`: fetches IdP signing keys with caching and a
167
+ * force-refresh cool-down. The cool-down defends against attackers that
168
+ * spam random `kid` values to amplify load on the IdP's JWKS endpoint.
169
+ *
170
+ * The cache lives inside the returned provider closure, so each
171
+ * `OAuthProvider` instance has its own cache — no cross-instance bleed.
172
+ */
173
+ /** Create the default JWKS provider — a closure with its own private cache. */
174
+ function createDefaultJwksProvider(opts = {}) {
175
+ const cache = /* @__PURE__ */ new Map();
176
+ const cacheTtl = opts.cacheTtlSeconds ?? EMA_DEFAULT_JWKS_CACHE_TTL_SECONDS;
177
+ return { async fetch(issuer, { forceRefresh, now }) {
178
+ const cached = cache.get(issuer.issuer);
179
+ if (!forceRefresh && cached && cached.expiresAt > now) return ok(cached.jwks);
180
+ if (forceRefresh && cached && cached.nextForceRefreshAllowedAt > now) return ok(cached.jwks);
181
+ const abortController = new AbortController();
182
+ const timeoutId = setTimeout(() => abortController.abort(), EMA_JWKS_FETCH_TIMEOUT_MS);
183
+ try {
184
+ const response = await fetch(issuer.jwksUri, {
185
+ headers: { Accept: "application/json" },
186
+ signal: abortController.signal,
187
+ cf: { cacheEverything: true }
188
+ });
189
+ if (!response.ok) return err({
190
+ reason: "jwks_fetch_failed",
191
+ status: response.status
192
+ });
193
+ const contentLength = response.headers.get("content-length");
194
+ if (contentLength && parseInt(contentLength, 10) > EMA_JWKS_MAX_SIZE_BYTES) return err({
195
+ reason: "jwks_fetch_failed",
196
+ status: response.status
197
+ });
198
+ const rawJwks = await readJsonWithSizeLimit(response, EMA_JWKS_MAX_SIZE_BYTES);
199
+ if (!rawJwks.ok) return err({ reason: "jwks_fetch_failed" });
200
+ if (!Array.isArray(rawJwks.value.keys)) return err({ reason: "jwks_fetch_failed" });
201
+ const jwks = { keys: rawJwks.value.keys };
202
+ cache.set(issuer.issuer, {
203
+ jwks,
204
+ expiresAt: now + cacheTtl,
205
+ nextForceRefreshAllowedAt: now + EMA_JWKS_FORCE_REFRESH_COOLDOWN_SECONDS
206
+ });
207
+ return ok(jwks);
208
+ } catch {
209
+ return err({ reason: "jwks_fetch_failed" });
210
+ } finally {
211
+ clearTimeout(timeoutId);
212
+ }
213
+ } };
214
+ }
215
+ /**
216
+ * Streaming JSON reader that rejects responses exceeding `maxBytes`.
217
+ * Bounds memory consumption before we attempt to JSON-parse the body.
218
+ */
219
+ async function readJsonWithSizeLimit(response, maxBytes) {
220
+ if (!response.body) return { ok: false };
221
+ const reader = response.body.getReader();
222
+ const chunks = [];
223
+ let total = 0;
224
+ try {
225
+ while (true) {
226
+ const { done, value } = await reader.read();
227
+ if (done) break;
228
+ total += value.byteLength;
229
+ if (total > maxBytes) {
230
+ reader.cancel();
231
+ return { ok: false };
232
+ }
233
+ chunks.push(value);
234
+ }
235
+ } finally {
236
+ reader.releaseLock();
237
+ }
238
+ const merged = new Uint8Array(total);
239
+ let offset = 0;
240
+ for (const chunk of chunks) {
241
+ merged.set(chunk, offset);
242
+ offset += chunk.byteLength;
243
+ }
244
+ try {
245
+ const parsed = JSON.parse(new TextDecoder().decode(merged));
246
+ if (typeof parsed !== "object" || parsed === null) return { ok: false };
247
+ return {
248
+ ok: true,
249
+ value: parsed
250
+ };
251
+ } catch {
252
+ return { ok: false };
253
+ }
254
+ }
255
+
256
+ //#endregion
257
+ //#region src/ema/parser.ts
258
+ /**
259
+ * Pure JWT parsing for ID-JAG assertions.
260
+ *
261
+ * Splits a compact JWS (`<base64url-header>.<base64url-payload>.<base64url-signature>`)
262
+ * into its three parts, decodes header and payload as JSON, and exposes the
263
+ * raw signing input + signature bytes for downstream signature verification.
264
+ *
265
+ * No I/O. No `this`. Returns `Result<ParsedIdJag, EmaValidationError>`.
266
+ */
267
+ /**
268
+ * Parse a compact JWS assertion.
269
+ *
270
+ * @param assertion The raw assertion string from the token request body.
271
+ * @param maxBytes Reject assertions whose length exceeds this many bytes.
272
+ * Guards against memory exhaustion before any JSON parsing happens.
273
+ */
274
+ function parseIdJag(assertion, maxBytes) {
275
+ if (typeof assertion !== "string" || assertion.length === 0) return err({ reason: "assertion_missing" });
276
+ if (assertion.length > maxBytes) return err({
277
+ reason: "assertion_too_large",
278
+ size: assertion.length,
279
+ max: maxBytes
280
+ });
281
+ const parts = assertion.split(".");
282
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) return err({ reason: "assertion_malformed" });
283
+ const [encodedHeader, encodedClaims, encodedSignature] = parts;
284
+ let header;
285
+ let rawClaims;
286
+ let signature;
287
+ try {
288
+ header = parseJwtJsonPart(encodedHeader);
289
+ rawClaims = parseJwtJsonPart(encodedClaims);
290
+ signature = base64UrlToBytes(encodedSignature);
291
+ } catch {
292
+ return err({ reason: "assertion_malformed" });
293
+ }
294
+ const signingInput = new TextEncoder().encode(`${encodedHeader}.${encodedClaims}`);
295
+ return ok({
296
+ header,
297
+ rawClaims,
298
+ signingInput,
299
+ signature
300
+ });
301
+ }
302
+
303
+ //#endregion
304
+ //#region src/ema/signature.ts
305
+ /**
306
+ * JWK selection and ID-JAG signature verification.
307
+ *
308
+ * `selectJwk` is a pure picker; `verifyIdJagSignature` is the I/O-bearing
309
+ * WebCrypto call. Both operate over an already-fetched JWKS.
310
+ */
311
+ /**
312
+ * Pick a signing key from a JWKS that matches the assertion header.
313
+ *
314
+ * Filters by:
315
+ * - `kid` (if the assertion header carries one)
316
+ * - JWK `use === 'sig'` (when present)
317
+ * - JWK `key_ops` containing `verify` (when present)
318
+ * - JWK `alg` matching (when present)
319
+ * - `kty` compatible with the requested `alg`
320
+ *
321
+ * If the assertion has a `kid`, the first matching key wins. Without a `kid`,
322
+ * we only return a key if exactly one candidate matches — otherwise the
323
+ * selection is ambiguous and we reject (caller may then force-refresh JWKS).
324
+ */
325
+ function selectJwk(jwks, alg, kid) {
326
+ const matching = (jwks.keys ?? []).filter((key) => {
327
+ if (kid && key.kid !== kid) return false;
328
+ if (key.alg && key.alg !== alg) return false;
329
+ if (key.use && key.use !== "sig") return false;
330
+ if (Array.isArray(key.key_ops) && !key.key_ops.includes("verify")) return false;
331
+ if (alg.startsWith("RS") && key.kty !== "RSA") return false;
332
+ if (alg.startsWith("ES") && key.kty !== "EC") return false;
333
+ return true;
334
+ });
335
+ if (kid) {
336
+ const picked = matching[0];
337
+ if (!picked) return err({
338
+ reason: "no_matching_key",
339
+ kid
340
+ });
341
+ return ok(picked);
342
+ }
343
+ if (matching.length !== 1) return err({ reason: "no_matching_key" });
344
+ return ok(matching[0]);
345
+ }
346
+ /**
347
+ * Verify an ID-JAG's compact-JWS signature using WebCrypto.
348
+ *
349
+ * Returns `false` on any WebCrypto-level failure (import or verify).
350
+ * The caller is responsible for mapping `false` to the appropriate
351
+ * `EmaValidationError`.
352
+ */
353
+ async function verifyIdJagSignature(input) {
354
+ try {
355
+ const { importAlgorithm, verifyAlgorithm } = getJwtCryptoAlgorithms(input.alg);
356
+ const key = await crypto.subtle.importKey("jwk", input.jwk, importAlgorithm, false, ["verify"]);
357
+ return await crypto.subtle.verify(verifyAlgorithm, key, input.signature, input.signingInput);
358
+ } catch {
359
+ return false;
360
+ }
361
+ }
362
+
363
+ //#endregion
364
+ //#region src/ema/validators.ts
365
+ /**
366
+ * Validate the JOSE header of an ID-JAG. Enforces the `typ=oauth-id-jag+jwt`
367
+ * marker (RFC 8725 §3.11) and an `alg` within the AS's global allowlist.
368
+ * Per-issuer `algorithms` is checked separately in `resolveTrustedIssuer`.
369
+ */
370
+ function validateIdJagHeader(header, expectedTyp, supportedAlgs) {
371
+ const typ = header.typ;
372
+ if (typeof typ !== "string" || typ !== expectedTyp) return err({
373
+ reason: "invalid_typ",
374
+ got: typ
375
+ });
376
+ const alg = header.alg;
377
+ if (typeof alg !== "string" || alg === "none" || !supportedAlgs.has(alg)) return err({
378
+ reason: "invalid_alg",
379
+ got: alg
380
+ });
381
+ const kidRaw = header.kid;
382
+ return ok({
383
+ typ,
384
+ alg,
385
+ kid: typeof kidRaw === "string" && kidRaw.length > 0 ? kidRaw : void 0
386
+ });
387
+ }
388
+ /**
389
+ * Resolve the `iss` claim through the deployer-supplied resolver, then
390
+ * validate the returned configuration before signature verification ever
391
+ * runs against it.
392
+ *
393
+ * Every failure here collapses to `issuer_not_trusted` so an attacker
394
+ * cannot distinguish "unknown IdP" from "resolver returned a malformed
395
+ * config" from "alg not in the IdP's allowlist".
396
+ *
397
+ * The resolver's returned `issuer` field MUST equal the input `iss`.
398
+ * Otherwise a buggy resolver could be tricked into returning a config
399
+ * for IdP B while validating a JWT that claims to be from IdP A,
400
+ * which would let an attacker forge any IdP they like (since the JWKS
401
+ * would be fetched from B's URI).
402
+ */
403
+ async function resolveTrustedIssuer(input) {
404
+ const { iss, alg, resolver, env, request, clientInfo } = input;
405
+ if (typeof iss !== "string" || iss.length === 0) return err({
406
+ reason: "invalid_claim",
407
+ claim: "iss"
408
+ });
409
+ let resolved;
410
+ try {
411
+ resolved = await resolver({
412
+ iss,
413
+ env,
414
+ request,
415
+ clientInfo
416
+ });
417
+ } catch {
418
+ return err({
419
+ reason: "issuer_not_trusted",
420
+ iss
421
+ });
422
+ }
423
+ if (!resolved) return err({
424
+ reason: "issuer_not_trusted",
425
+ iss
426
+ });
427
+ if (resolved.issuer !== iss) return err({
428
+ reason: "issuer_not_trusted",
429
+ iss
430
+ });
431
+ if (!isWellFormedTrustedIssuer(resolved)) return err({
432
+ reason: "issuer_not_trusted",
433
+ iss
434
+ });
435
+ if (!(resolved.algorithms ?? [EMA_DEFAULT_JWT_ALGORITHM]).includes(alg)) return err({
436
+ reason: "issuer_not_trusted",
437
+ iss
438
+ });
439
+ return ok(resolved);
440
+ }
441
+ /**
442
+ * Per-request structural validation of an `EmaTrustedIssuer` returned by
443
+ * a dynamic resolver. Mirrors the construction-time checks that the
444
+ * static-array shape used to get for free.
445
+ */
446
+ function isWellFormedTrustedIssuer(issuer) {
447
+ let issuerUrl;
448
+ try {
449
+ issuerUrl = new URL(issuer.issuer);
450
+ } catch {
451
+ return false;
452
+ }
453
+ if (issuerUrl.protocol !== "https:") return false;
454
+ let jwksUrl;
455
+ try {
456
+ jwksUrl = new URL(issuer.jwksUri);
457
+ } catch {
458
+ return false;
459
+ }
460
+ if (jwksUrl.protocol !== "https:") return false;
461
+ const algorithms = issuer.algorithms ?? [EMA_DEFAULT_JWT_ALGORITHM];
462
+ if (algorithms.length === 0) return false;
463
+ for (const alg of algorithms) if (!EMA_SUPPORTED_JWT_ALGORITHMS.has(alg)) return false;
464
+ if (issuer.audience !== void 0) try {
465
+ new URL(issuer.audience);
466
+ } catch {
467
+ return false;
468
+ }
469
+ return true;
470
+ }
471
+ /**
472
+ * Validate every required ID-JAG claim and produce a typed `ValidatedIdJag`.
473
+ *
474
+ * Enforces (in order):
475
+ * - presence + type of `iss`, `sub`, `aud`, `resource`, `client_id`, `jti`, `exp`, `iat`
476
+ * - `aud` contains the AS's expected audience
477
+ * - `client_id` matches the authenticated client
478
+ * - `resource` is a valid RFC 8707 URI and matches the AS's configured resource
479
+ * - `exp` is in the future
480
+ * - `iat` is not more than `clockSkewSeconds` in the future
481
+ * - `nbf` (if present) is ≤ `now + clockSkewSeconds`
482
+ * - `exp - iat` does not exceed `maxAssertionLifetimeSeconds + clockSkewSeconds`
483
+ * - `scope` (if present) conforms to RFC 6749 §3.3 grammar
484
+ */
485
+ function validateIdJagClaims(input) {
486
+ const { rawClaims, trustedIssuer, expectedAudience, clientId, configuredResource, matchOriginOnly } = input;
487
+ const { now, clockSkewSeconds, maxAssertionLifetimeSeconds } = input;
488
+ const iss = readRequiredString(rawClaims, "iss");
489
+ if (!iss.ok) return iss;
490
+ if (iss.value !== trustedIssuer.issuer) return err({
491
+ reason: "issuer_not_trusted",
492
+ iss: iss.value
493
+ });
494
+ const sub = readRequiredString(rawClaims, "sub");
495
+ if (!sub.ok) return sub;
496
+ const aud = readAudienceClaim(rawClaims);
497
+ if (!aud.ok) return aud;
498
+ const resource = readRequiredString(rawClaims, "resource");
499
+ if (!resource.ok) return resource;
500
+ const claimClientId = readRequiredString(rawClaims, "client_id");
501
+ if (!claimClientId.ok) return claimClientId;
502
+ const jti = readRequiredString(rawClaims, "jti");
503
+ if (!jti.ok) return jti;
504
+ const exp = readNumericDateClaim(rawClaims, "exp");
505
+ if (!exp.ok) return exp;
506
+ const iat = readNumericDateClaim(rawClaims, "iat");
507
+ if (!iat.ok) return iat;
508
+ if (!(Array.isArray(aud.value) ? aud.value : [aud.value]).includes(expectedAudience)) return err({
509
+ reason: "aud_mismatch",
510
+ expected: expectedAudience,
511
+ got: aud.value
512
+ });
513
+ if (claimClientId.value !== clientId) return err({
514
+ reason: "client_id_mismatch",
515
+ expected: clientId,
516
+ got: claimClientId.value
517
+ });
518
+ if (!validateResourceUri(resource.value)) return err({
519
+ reason: "resource_invalid",
520
+ resource: resource.value
521
+ });
522
+ if (!resourceMatches(resource.value, configuredResource, matchOriginOnly)) return err({
523
+ reason: "resource_mismatch",
524
+ expected: configuredResource,
525
+ got: resource.value
526
+ });
527
+ if (exp.value + clockSkewSeconds <= now) return err({
528
+ reason: "expired",
529
+ exp: exp.value,
530
+ now
531
+ });
532
+ if (iat.value > now + clockSkewSeconds) return err({
533
+ reason: "iat_in_future",
534
+ iat: iat.value,
535
+ now,
536
+ skew: clockSkewSeconds
537
+ });
538
+ if (rawClaims.nbf !== void 0) {
539
+ const nbf = readNumericDateClaim(rawClaims, "nbf");
540
+ if (!nbf.ok) return nbf;
541
+ if (nbf.value > now + clockSkewSeconds) return err({
542
+ reason: "nbf_in_future",
543
+ nbf: nbf.value,
544
+ now,
545
+ skew: clockSkewSeconds
546
+ });
547
+ }
548
+ const lifetime = exp.value - iat.value;
549
+ if (lifetime > maxAssertionLifetimeSeconds + clockSkewSeconds) return err({
550
+ reason: "lifetime_too_long",
551
+ lifetime,
552
+ max: maxAssertionLifetimeSeconds
553
+ });
554
+ let scope;
555
+ let assertionScopes = [];
556
+ if (rawClaims.scope !== void 0) {
557
+ const parsed = readRequiredString(rawClaims, "scope");
558
+ if (!parsed.ok) return parsed;
559
+ const tokens = parsed.value.split(" ").filter(Boolean);
560
+ for (const token of tokens) if (!isValidOAuthScopeToken(token)) return err({
561
+ reason: "invalid_claim",
562
+ claim: "scope"
563
+ });
564
+ scope = parsed.value;
565
+ assertionScopes = tokens;
566
+ }
567
+ return ok({
568
+ claims: {
569
+ ...rawClaims,
570
+ iss: iss.value,
571
+ sub: sub.value,
572
+ aud: aud.value,
573
+ resource: resource.value,
574
+ client_id: claimClientId.value,
575
+ jti: jti.value,
576
+ exp: exp.value,
577
+ iat: iat.value,
578
+ scope
579
+ },
580
+ resource: resource.value,
581
+ assertionScopes
582
+ });
583
+ }
584
+ /**
585
+ * Parse the `scope` parameter of the token request and downscope it to the
586
+ * assertion's own scope claim. If the request omits `scope`, the assertion's
587
+ * scopes are used directly.
588
+ */
589
+ function parseEmaScopeParam(scope, assertionScopes) {
590
+ let requested;
591
+ if (scope === void 0) requested = [...assertionScopes];
592
+ else if (typeof scope === "string") {
593
+ const tokens = scope.split(" ").filter(Boolean);
594
+ for (const token of tokens) if (!isValidOAuthScopeToken(token)) return err({ reason: "invalid_scope_param" });
595
+ requested = tokens;
596
+ } else if (Array.isArray(scope) && scope.every((value) => typeof value === "string")) {
597
+ requested = [];
598
+ for (const part of scope) {
599
+ const tokens = part.split(" ").filter(Boolean);
600
+ for (const token of tokens) if (!isValidOAuthScopeToken(token)) return err({ reason: "invalid_scope_param" });
601
+ requested.push(...tokens);
602
+ }
603
+ } else return err({ reason: "invalid_scope_param" });
604
+ if (assertionScopes.length > 0) {
605
+ const allowed = new Set(assertionScopes);
606
+ requested = requested.filter((token) => allowed.has(token));
607
+ }
608
+ return ok(requested);
609
+ }
610
+ /**
611
+ * Validate the shape of the value returned by the deployer's `mapClaims`
612
+ * callback. A `null` return is treated as a deny decision.
613
+ *
614
+ * The `userId.includes(':')` rejection mirrors the opaque token format
615
+ * (`userId:grantId:secret`) used elsewhere in this provider.
616
+ */
617
+ function validateEmaMapperResult(result) {
618
+ if (result === null) return err({ reason: "mapper_denied" });
619
+ if (typeof result !== "object") return err({ reason: "invalid_mapped_user" });
620
+ const r = result;
621
+ if (typeof r.userId !== "string" || r.userId.length === 0 || r.userId.includes(":")) return err({ reason: "invalid_mapped_user" });
622
+ if (!Array.isArray(r.scope) || !r.scope.every((s) => typeof s === "string" && isValidOAuthScopeToken(s))) return err({ reason: "invalid_mapped_scope" });
623
+ if (!("props" in r) || r.props === void 0) return err({ reason: "invalid_mapped_props" });
624
+ if (r.accessTokenTTL !== void 0) {
625
+ if (typeof r.accessTokenTTL !== "number" || !Number.isFinite(r.accessTokenTTL) || r.accessTokenTTL <= 0) return err({ reason: "invalid_mapped_ttl" });
626
+ }
627
+ return ok({
628
+ userId: r.userId,
629
+ scope: r.scope,
630
+ props: r.props,
631
+ metadata: r.metadata,
632
+ accessTokenTTL: r.accessTokenTTL
633
+ });
634
+ }
635
+ /**
636
+ * Compute the access token TTL: mapper override wins, otherwise the AS
637
+ * default. The assertion's `exp` is the lifetime of the grant, not of the
638
+ * issued token (RFC 7523 §3); we only re-check it here to catch the
639
+ * TOCTOU window between claim validation and token mint.
640
+ */
641
+ function computeEmaAccessTokenTTL(input) {
642
+ const { configuredDefaultSeconds, assertionExp, mapperTtl, now } = input;
643
+ if (assertionExp - now <= 0) return err({ reason: "assertion_expired_after_processing" });
644
+ return ok(mapperTtl ?? configuredDefaultSeconds);
645
+ }
646
+ function readRequiredString(claims, claimName) {
647
+ const value = claims[claimName];
648
+ if (typeof value !== "string" || value.length === 0) return err({
649
+ reason: "invalid_claim",
650
+ claim: claimName
651
+ });
652
+ return ok(value);
653
+ }
654
+ function readAudienceClaim(claims) {
655
+ const aud = claims.aud;
656
+ if (typeof aud === "string" && aud.length > 0) return ok(aud);
657
+ if (Array.isArray(aud) && aud.length > 0 && aud.every((v) => typeof v === "string" && v.length > 0)) return ok(aud);
658
+ return err({
659
+ reason: "invalid_claim",
660
+ claim: "aud"
661
+ });
662
+ }
663
+ function readNumericDateClaim(claims, claimName) {
664
+ const value = claims[claimName];
665
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) return err({
666
+ reason: "invalid_claim",
667
+ claim: claimName
668
+ });
669
+ return ok(value);
670
+ }
671
+
672
+ //#endregion
3
673
  //#region src/oauth-provider.ts
4
674
  const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
5
675
  if (!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags?.global_fetch_strictly_public === true)) console.warn("CIMD (Client ID Metadata Document) is disabled: add '\"compatibility_flags\": [\"global_fetch_strictly_public\"]' to your wrangler.jsonc to enable. See: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public");
@@ -18,6 +688,7 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
18
688
  GrantType$1["AUTHORIZATION_CODE"] = "authorization_code";
19
689
  GrantType$1["REFRESH_TOKEN"] = "refresh_token";
20
690
  GrantType$1["TOKEN_EXCHANGE"] = "urn:ietf:params:oauth:grant-type:token-exchange";
691
+ GrantType$1["JWT_BEARER"] = "urn:ietf:params:oauth:grant-type:jwt-bearer";
21
692
  return GrantType$1;
22
693
  }({});
23
694
  /**
@@ -110,6 +781,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
110
781
  onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
111
782
  ...options
112
783
  };
784
+ this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
785
+ if (this.options.enterpriseManagedAuthorization) {
786
+ this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
787
+ this.jtiStore = createKvJtiStore();
788
+ }
113
789
  }
114
790
  /**
115
791
  * Validates that an endpoint is either an absolute path or a full URL
@@ -145,6 +821,23 @@ var OAuthProviderImpl = class OAuthProviderImpl {
145
821
  throw new TypeError(`${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`);
146
822
  }
147
823
  /**
824
+ * Validates MCP Enterprise-Managed Authorization configuration at construction time.
825
+ *
826
+ * Presence of `enterpriseManagedAuthorization` on options enables the feature —
827
+ * there is no separate `enabled` flag (which would silently disable EMA when
828
+ * forgotten). Configuration is checked structurally; runtime concerns
829
+ * (JWKS reachability etc.) are checked when assertions arrive.
830
+ */
831
+ validateEmaOptions(options) {
832
+ if (!options) return;
833
+ if (typeof options.trustedIssuers !== "function") throw new TypeError("enterpriseManagedAuthorization.trustedIssuers must be a resolver function: (input) => EmaTrustedIssuer | null");
834
+ if (typeof options.mapClaims !== "function") throw new TypeError("enterpriseManagedAuthorization.mapClaims must be a function");
835
+ if (!this.options.resourceMetadata?.resource) throw new TypeError("enterpriseManagedAuthorization requires resourceMetadata.resource to be configured");
836
+ if (options.jwksCacheTtlSeconds !== void 0 && options.jwksCacheTtlSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.jwksCacheTtlSeconds must be greater than 0");
837
+ if (options.clockSkewSeconds !== void 0 && options.clockSkewSeconds < 0) throw new TypeError("enterpriseManagedAuthorization.clockSkewSeconds must be non-negative");
838
+ if (options.maxAssertionLifetimeSeconds !== void 0 && options.maxAssertionLifetimeSeconds <= 0) throw new TypeError("enterpriseManagedAuthorization.maxAssertionLifetimeSeconds must be greater than 0");
839
+ }
840
+ /**
148
841
  * Main fetch handler for the Worker
149
842
  * Routes requests to the appropriate handler based on the URL
150
843
  * @param request - The HTTP request
@@ -173,7 +866,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
173
866
  if (parsed instanceof Response) return this.addCorsHeaders(parsed, request);
174
867
  let response;
175
868
  if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, env);
176
- else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env);
869
+ else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
177
870
  return this.addCorsHeaders(response, request);
178
871
  }
179
872
  if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
@@ -285,10 +978,16 @@ var OAuthProviderImpl = class OAuthProviderImpl {
285
978
  * @returns Promise with parsed body and client info, or error response
286
979
  */
287
980
  async parseTokenEndpointRequest(request, env) {
288
- if (request.method !== "POST") return this.createErrorResponse("invalid_request", "Method not allowed", 405);
981
+ if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
982
+ description: "Method not allowed",
983
+ statusCode: 405
984
+ });
289
985
  let contentType = request.headers.get("Content-Type") || "";
290
986
  let body = {};
291
- if (!contentType.includes("application/x-www-form-urlencoded")) return this.createErrorResponse("invalid_request", "Content-Type must be application/x-www-form-urlencoded", 400);
987
+ if (!contentType.includes("application/x-www-form-urlencoded")) return this.createErrorResponse("invalid_request", {
988
+ description: "Content-Type must be application/x-www-form-urlencoded",
989
+ statusCode: 400
990
+ });
292
991
  const formData = await request.formData();
293
992
  for (const [key, value] of formData.entries()) {
294
993
  const allValues = formData.getAll(key);
@@ -305,13 +1004,28 @@ var OAuthProviderImpl = class OAuthProviderImpl {
305
1004
  clientId = body.client_id;
306
1005
  clientSecret = body.client_secret || "";
307
1006
  }
308
- if (!clientId) return this.createErrorResponse("invalid_client", "Client ID is required", 401);
1007
+ if (!clientId) return this.createErrorResponse("invalid_client", {
1008
+ description: "Client ID is required",
1009
+ statusCode: 401
1010
+ });
309
1011
  const clientInfo = await this.getClient(env, clientId);
310
- if (!clientInfo) return this.createErrorResponse("invalid_client", "Client not found", 401);
1012
+ if (!clientInfo) return this.createErrorResponse("invalid_client", {
1013
+ description: "Client not found",
1014
+ statusCode: 401
1015
+ });
311
1016
  if (!(clientInfo.tokenEndpointAuthMethod === "none")) {
312
- if (!clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: missing client_secret", 401);
313
- if (!clientInfo.clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: client has no registered secret", 401);
314
- if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: invalid client_secret", 401);
1017
+ if (!clientSecret) return this.createErrorResponse("invalid_client", {
1018
+ description: "Client authentication failed: missing client_secret",
1019
+ statusCode: 401
1020
+ });
1021
+ if (!clientInfo.clientSecret) return this.createErrorResponse("invalid_client", {
1022
+ description: "Client authentication failed: client has no registered secret",
1023
+ statusCode: 401
1024
+ });
1025
+ if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createErrorResponse("invalid_client", {
1026
+ description: "Client authentication failed: invalid client_secret",
1027
+ statusCode: 401
1028
+ });
315
1029
  }
316
1030
  return {
317
1031
  body,
@@ -363,6 +1077,13 @@ var OAuthProviderImpl = class OAuthProviderImpl {
363
1077
  else return endpoint;
364
1078
  }
365
1079
  /**
1080
+ * Gets the authorization server issuer using the same derivation as RFC 8414 metadata.
1081
+ */
1082
+ getAuthorizationServerIssuer(requestUrl) {
1083
+ const tokenEndpoint = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
1084
+ return new URL(tokenEndpoint).origin;
1085
+ }
1086
+ /**
366
1087
  * Adds CORS headers to a response
367
1088
  * @param response - The response to add CORS headers to
368
1089
  * @param request - The original request
@@ -393,6 +1114,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
393
1114
  if (this.options.allowImplicitFlow) responseTypesSupported.push("token");
394
1115
  const grantTypesSupported = [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN];
395
1116
  if (this.options.allowTokenExchangeGrant) grantTypesSupported.push(GrantType.TOKEN_EXCHANGE);
1117
+ const authorizationGrantProfilesSupported = [];
1118
+ if (this.options.enterpriseManagedAuthorization) {
1119
+ grantTypesSupported.push(GrantType.JWT_BEARER);
1120
+ authorizationGrantProfilesSupported.push(EMA_ID_JAG_GRANT_PROFILE);
1121
+ }
396
1122
  const metadata = {
397
1123
  issuer: new URL(tokenEndpoint).origin,
398
1124
  authorization_endpoint: authorizeEndpoint,
@@ -402,6 +1128,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
402
1128
  response_types_supported: responseTypesSupported,
403
1129
  response_modes_supported: ["query"],
404
1130
  grant_types_supported: grantTypesSupported,
1131
+ ...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
405
1132
  token_endpoint_auth_methods_supported: [
406
1133
  "client_secret_basic",
407
1134
  "client_secret_post",
@@ -440,12 +1167,33 @@ var OAuthProviderImpl = class OAuthProviderImpl {
440
1167
  * @param env - Cloudflare Worker environment variables
441
1168
  * @returns Response with token data or error
442
1169
  */
443
- async handleTokenRequest(body, clientInfo, env) {
444
- const grantType = body.grant_type;
445
- if (grantType === GrantType.AUTHORIZATION_CODE) return this.handleAuthorizationCodeGrant(body, clientInfo, env);
446
- else if (grantType === GrantType.REFRESH_TOKEN) return this.handleRefreshTokenGrant(body, clientInfo, env);
447
- else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return this.handleTokenExchangeGrant(body, clientInfo, env);
448
- else return this.createErrorResponse("unsupported_grant_type", "Grant type not supported");
1170
+ async handleTokenRequest(body, clientInfo, env, requestUrl, request) {
1171
+ try {
1172
+ const grantType = body.grant_type;
1173
+ if (grantType === GrantType.AUTHORIZATION_CODE) return await this.handleAuthorizationCodeGrant(body, clientInfo, env);
1174
+ else if (grantType === GrantType.REFRESH_TOKEN) return await this.handleRefreshTokenGrant(body, clientInfo, env);
1175
+ else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return await this.handleTokenExchangeGrant(body, clientInfo, env);
1176
+ else if (grantType === GrantType.JWT_BEARER) return await this.handleJwtBearerGrant(body, clientInfo, env, requestUrl, request);
1177
+ else return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
1178
+ } catch (error) {
1179
+ const response = this.createOAuthErrorResponse(error);
1180
+ if (response) return response;
1181
+ throw error;
1182
+ }
1183
+ }
1184
+ /**
1185
+ * Build a structured OAuth `/token` error response from an OAuth error.
1186
+ *
1187
+ * The supported form is throwing this package's exported `OAuthError`.
1188
+ * Anything else is re-thrown so unexpected failures still surface as 500s.
1189
+ *
1190
+ * Use `headers['Retry-After']` for rate-limit / transient-failure backoff
1191
+ * hints (see RFC 7231 §7.1.3 — either an integer seconds value or an
1192
+ * HTTP-date is allowed).
1193
+ */
1194
+ createOAuthErrorResponse(error) {
1195
+ if (!(error instanceof OAuthError)) return void 0;
1196
+ return this.createErrorResponse(error.code, error.options);
449
1197
  }
450
1198
  /**
451
1199
  * Handles the authorization code grant type
@@ -459,27 +1207,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
459
1207
  const code = body.code;
460
1208
  const redirectUri = body.redirect_uri;
461
1209
  const codeVerifier = body.code_verifier;
462
- if (!code) return this.createErrorResponse("invalid_request", "Authorization code is required");
1210
+ if (!code) return this.createErrorResponse("invalid_request", { description: "Authorization code is required" });
463
1211
  const codeParts = code.split(":");
464
- if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", "Invalid authorization code format");
1212
+ if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code format" });
465
1213
  const [userId, grantId, _] = codeParts;
466
1214
  const grantKey = `grant:${userId}:${grantId}`;
467
1215
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
468
- if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
1216
+ if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" });
469
1217
  if (!grantData.authCodeId) {
470
1218
  try {
471
1219
  await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
472
1220
  } catch {}
473
- return this.createErrorResponse("invalid_grant", "Authorization code already used");
1221
+ return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
474
1222
  }
475
- if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", "Invalid authorization code");
476
- if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
1223
+ if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" });
1224
+ if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
477
1225
  const isPkceEnabled = !!grantData.codeChallenge;
478
- if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
479
- if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
480
- if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier provided for a flow that did not use PKCE");
1226
+ if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
1227
+ if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
1228
+ if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier provided for a flow that did not use PKCE" });
481
1229
  if (isPkceEnabled) {
482
- if (!codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
1230
+ if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" });
483
1231
  let calculatedChallenge;
484
1232
  if (grantData.codeChallengeMethod === "S256") {
485
1233
  const data = new TextEncoder().encode(codeVerifier);
@@ -487,7 +1235,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
487
1235
  const hashArray = Array.from(new Uint8Array(hashBuffer));
488
1236
  calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
489
1237
  } else calculatedChallenge = codeVerifier;
490
- if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
1238
+ if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" });
491
1239
  }
492
1240
  let accessTokenTTL = this.options.accessTokenTTL;
493
1241
  let refreshTokenTTL = this.options.refreshTokenTTL;
@@ -504,6 +1252,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
504
1252
  grantType: GrantType.AUTHORIZATION_CODE,
505
1253
  clientId: clientInfo.clientId,
506
1254
  userId,
1255
+ grantId,
507
1256
  scope: grantData.scope,
508
1257
  requestedScope: tokenScopes,
509
1258
  props: decryptedProps
@@ -554,10 +1303,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
554
1303
  if (body.resource && grantData.resource) {
555
1304
  const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
556
1305
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
557
- for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
1306
+ for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
558
1307
  }
559
1308
  const audience = parseResourceParameter(body.resource || grantData.resource);
560
- if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
1309
+ if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
561
1310
  const tokenResponse = {
562
1311
  access_token: await this.createAccessToken({
563
1312
  userId,
@@ -588,20 +1337,20 @@ var OAuthProviderImpl = class OAuthProviderImpl {
588
1337
  */
589
1338
  async handleRefreshTokenGrant(body, clientInfo, env) {
590
1339
  const refreshToken = body.refresh_token;
591
- if (!refreshToken) return this.createErrorResponse("invalid_request", "Refresh token is required");
1340
+ if (!refreshToken) return this.createErrorResponse("invalid_request", { description: "Refresh token is required" });
592
1341
  const tokenParts = refreshToken.split(":");
593
- if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", "Invalid token format");
1342
+ if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid token format" });
594
1343
  const [userId, grantId, _] = tokenParts;
595
1344
  const providedTokenHash = await generateTokenId(refreshToken);
596
1345
  const grantKey = `grant:${userId}:${grantId}`;
597
1346
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
598
- if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found");
1347
+ if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found" });
599
1348
  const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
600
1349
  const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
601
- if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", "Invalid refresh token");
602
- if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
1350
+ if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" });
1351
+ if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
603
1352
  if (grantData.expiresAt !== void 0) {
604
- if (Math.floor(Date.now() / 1e3) >= grantData.expiresAt) return this.createErrorResponse("invalid_grant", "Refresh token has expired");
1353
+ if (Math.floor(Date.now() / 1e3) >= grantData.expiresAt) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
605
1354
  }
606
1355
  const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
607
1356
  const accessTokenId = await generateTokenId(newAccessToken);
@@ -623,6 +1372,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
623
1372
  grantType: GrantType.REFRESH_TOKEN,
624
1373
  clientId: clientInfo.clientId,
625
1374
  userId,
1375
+ grantId,
626
1376
  scope: grantData.scope,
627
1377
  requestedScope: tokenScopes,
628
1378
  props: decryptedProps
@@ -636,7 +1386,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
636
1386
  }
637
1387
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
638
1388
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
639
- if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", "refreshTokenTTL cannot be changed during refresh token exchange");
1389
+ if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", { description: "refreshTokenTTL cannot be changed during refresh token exchange" });
640
1390
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
641
1391
  }
642
1392
  if (grantPropsChanged) {
@@ -675,10 +1425,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
675
1425
  if (body.resource && grantData.resource) {
676
1426
  const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
677
1427
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
678
- for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
1428
+ for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
679
1429
  }
680
1430
  const audience = parseResourceParameter(body.resource || grantData.resource);
681
- if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
1431
+ if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
682
1432
  const accessTokenData = {
683
1433
  id: accessTokenId,
684
1434
  grantId,
@@ -694,7 +1444,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
694
1444
  encryptedProps: encryptedAccessTokenProps
695
1445
  }
696
1446
  };
697
- await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
1447
+ try {
1448
+ await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
1449
+ } catch (error) {
1450
+ this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
1451
+ throw error;
1452
+ }
698
1453
  const tokenResponse = {
699
1454
  access_token: newAccessToken,
700
1455
  token_type: "bearer",
@@ -722,10 +1477,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
722
1477
  */
723
1478
  async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
724
1479
  const tokenSummary = await this.unwrapToken(subjectToken, env);
725
- if (!tokenSummary) throw new OAuthError("invalid_grant", "Invalid or expired subject token");
1480
+ if (!tokenSummary) throw new OAuthError("invalid_grant", { description: "Invalid or expired subject token" });
726
1481
  const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
727
1482
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
728
- if (!grantData) throw new OAuthError("invalid_grant", "Grant not found");
1483
+ if (!grantData) throw new OAuthError("invalid_grant", { description: "Grant not found" });
729
1484
  let tokenScopes = this.downscope(requestedScopes, grantData.scope);
730
1485
  const originOnly = !!this.options.resourceMatchOriginOnly;
731
1486
  let newAudience = tokenSummary.audience;
@@ -733,21 +1488,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
733
1488
  if (grantData.resource) {
734
1489
  const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
735
1490
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
736
- for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", "Requested resource was not included in the authorization request");
1491
+ for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
737
1492
  }
738
1493
  const parsedResource = parseResourceParameter(requestedResource);
739
- if (!parsedResource) throw new OAuthError("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
1494
+ if (!parsedResource) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
740
1495
  newAudience = parsedResource;
741
1496
  }
742
1497
  const now = Math.floor(Date.now() / 1e3);
743
1498
  const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
744
1499
  let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
745
1500
  if (expiresIn !== void 0) {
746
- if (expiresIn <= 0) throw new OAuthError("invalid_request", "Invalid expires_in parameter");
1501
+ if (expiresIn <= 0) throw new OAuthError("invalid_request", { description: "Invalid expires_in parameter" });
747
1502
  accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
748
1503
  } else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
749
1504
  const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
750
- if (!subjectTokenData) throw new OAuthError("invalid_grant", "Subject token data not found");
1505
+ if (!subjectTokenData) throw new OAuthError("invalid_grant", { description: "Subject token data not found" });
751
1506
  const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
752
1507
  let accessTokenEncryptionKey = encryptionKey;
753
1508
  let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
@@ -757,6 +1512,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
757
1512
  grantType: GrantType.TOKEN_EXCHANGE,
758
1513
  clientId: clientInfo.clientId,
759
1514
  userId: tokenSummary.userId,
1515
+ grantId: tokenSummary.grantId,
760
1516
  scope: tokenSummary.grant.scope,
761
1517
  requestedScope: tokenScopes,
762
1518
  props: decryptedProps
@@ -811,29 +1567,241 @@ var OAuthProviderImpl = class OAuthProviderImpl {
811
1567
  const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
812
1568
  const requestedScope = body.scope;
813
1569
  const requestedResource = body.resource;
814
- if (!subjectToken) return this.createErrorResponse("invalid_request", "subject_token is required");
815
- if (!subjectTokenType) return this.createErrorResponse("invalid_request", "subject_token_type is required");
816
- if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token subject_token_type is supported");
817
- if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token requested_token_type is supported");
1570
+ if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" });
1571
+ if (!subjectTokenType) return this.createErrorResponse("invalid_request", { description: "subject_token_type is required" });
1572
+ if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token subject_token_type is supported" });
1573
+ if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token requested_token_type is supported" });
818
1574
  let requestedScopes;
819
1575
  if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
820
1576
  else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
821
- else return this.createErrorResponse("invalid_request", "Invalid scope parameter format");
1577
+ else return this.createErrorResponse("invalid_request", { description: "Invalid scope parameter format" });
822
1578
  let expiresIn;
823
1579
  if (body.expires_in !== void 0) {
824
1580
  const requestedTTL = parseInt(body.expires_in, 10);
825
- if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", "Invalid expires_in parameter");
1581
+ if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", { description: "Invalid expires_in parameter" });
826
1582
  expiresIn = requestedTTL;
827
1583
  }
828
1584
  try {
829
1585
  const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
830
1586
  return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
831
1587
  } catch (error) {
832
- if (error instanceof OAuthError) return this.createErrorResponse(error.code, error.message);
1588
+ const response = this.createOAuthErrorResponse(error);
1589
+ if (response) return response;
833
1590
  throw error;
834
1591
  }
835
1592
  }
836
1593
  /**
1594
+ * Handles the MCP Enterprise-Managed Authorization JWT-bearer grant.
1595
+ *
1596
+ * Acts as a thin shell around `runEmaPipeline`: gate non-EMA traffic, run
1597
+ * the pipeline, translate the typed `EmaValidationError` Result back to a
1598
+ * standard OAuth wire response. All validation logic lives in pure
1599
+ * functions in `src/ema/`.
1600
+ */
1601
+ async handleJwtBearerGrant(body, clientInfo, env, requestUrl, request) {
1602
+ const enterpriseOptions = this.options.enterpriseManagedAuthorization;
1603
+ if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
1604
+ if (clientInfo.tokenEndpointAuthMethod === "none") return this.createErrorResponse("invalid_client", {
1605
+ description: "Enterprise-managed authorization requires client authentication",
1606
+ statusCode: 401
1607
+ });
1608
+ const result = await this.runEmaPipeline({
1609
+ body,
1610
+ clientInfo,
1611
+ env,
1612
+ requestUrl,
1613
+ request,
1614
+ enterpriseOptions
1615
+ });
1616
+ const noCacheHeaders = {
1617
+ "Cache-Control": "no-store",
1618
+ Pragma: "no-cache"
1619
+ };
1620
+ if (!result.ok) {
1621
+ const wire = emaErrorToWire(result.error);
1622
+ return this.createErrorResponse(wire.code, {
1623
+ description: wire.message,
1624
+ headers: noCacheHeaders
1625
+ }, {
1626
+ category: "enterprise-managed-authorization",
1627
+ reason: result.error.reason,
1628
+ detail: result.error
1629
+ });
1630
+ }
1631
+ return new Response(JSON.stringify(result.value), { headers: {
1632
+ "Content-Type": "application/json",
1633
+ ...noCacheHeaders
1634
+ } });
1635
+ }
1636
+ /**
1637
+ * Runs the full EMA token-request pipeline as a chain of pure validators
1638
+ * and adapter calls. Each step short-circuits on the first failure.
1639
+ *
1640
+ * Sequence:
1641
+ * parse → validate header → trust issuer → fetch JWKS → select key →
1642
+ * verify signature → validate claims → record jti → parse scope →
1643
+ * run mapper → validate mapper result → compute TTL → mint token.
1644
+ */
1645
+ async runEmaPipeline(args) {
1646
+ const { body, clientInfo, env, requestUrl, request, enterpriseOptions } = args;
1647
+ const { jwksProvider, jtiStore } = this;
1648
+ const configuredResource = this.options.resourceMetadata?.resource;
1649
+ if (!jwksProvider || !jtiStore || !configuredResource) throw new Error("EMA pipeline invoked without configured adapters");
1650
+ const now = Math.floor(Date.now() / 1e3);
1651
+ const parsed = parseIdJag(body.assertion, EMA_MAX_JWT_BYTES);
1652
+ if (!parsed.ok) return parsed;
1653
+ const header = validateIdJagHeader(parsed.value.header, EMA_ID_JAG_JWT_TYPE, EMA_SUPPORTED_JWT_ALGORITHMS);
1654
+ if (!header.ok) return header;
1655
+ const alg = header.value.alg;
1656
+ const trustedIssuer = await resolveTrustedIssuer({
1657
+ iss: parsed.value.rawClaims.iss,
1658
+ alg,
1659
+ resolver: enterpriseOptions.trustedIssuers,
1660
+ env,
1661
+ request,
1662
+ clientInfo
1663
+ });
1664
+ if (!trustedIssuer.ok) return trustedIssuer;
1665
+ const verified = await this.verifyAssertionSignature({
1666
+ parsed: parsed.value,
1667
+ header: header.value,
1668
+ trustedIssuer: trustedIssuer.value,
1669
+ jwksProvider,
1670
+ now
1671
+ });
1672
+ if (!verified.ok) return verified;
1673
+ const claims = validateIdJagClaims({
1674
+ rawClaims: parsed.value.rawClaims,
1675
+ trustedIssuer: trustedIssuer.value,
1676
+ expectedAudience: trustedIssuer.value.audience ?? this.getAuthorizationServerIssuer(requestUrl),
1677
+ clientId: clientInfo.clientId,
1678
+ configuredResource,
1679
+ matchOriginOnly: !!this.options.resourceMatchOriginOnly,
1680
+ now,
1681
+ clockSkewSeconds: enterpriseOptions.clockSkewSeconds ?? EMA_DEFAULT_CLOCK_SKEW_SECONDS,
1682
+ maxAssertionLifetimeSeconds: enterpriseOptions.maxAssertionLifetimeSeconds ?? EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS
1683
+ });
1684
+ if (!claims.ok) return claims;
1685
+ const markNow = Math.floor(Date.now() / 1e3);
1686
+ const replay = await jtiStore.markUsed({
1687
+ issuer: claims.value.claims.iss,
1688
+ jti: claims.value.claims.jti,
1689
+ exp: claims.value.claims.exp,
1690
+ now: markNow,
1691
+ env
1692
+ });
1693
+ if (!replay.ok) return replay;
1694
+ const requestedScope = parseEmaScopeParam(body.scope, claims.value.assertionScopes);
1695
+ if (!requestedScope.ok) return requestedScope;
1696
+ let mapperOutput;
1697
+ try {
1698
+ mapperOutput = await enterpriseOptions.mapClaims({
1699
+ claims: claims.value.claims,
1700
+ clientInfo,
1701
+ resource: claims.value.resource,
1702
+ requestedScope: requestedScope.value,
1703
+ request: args.request,
1704
+ env
1705
+ });
1706
+ } catch {
1707
+ return err({ reason: "mapper_threw" });
1708
+ }
1709
+ const mapped = validateEmaMapperResult(mapperOutput);
1710
+ if (!mapped.ok) return mapped;
1711
+ const issueNow = Math.floor(Date.now() / 1e3);
1712
+ const ttl = computeEmaAccessTokenTTL({
1713
+ configuredDefaultSeconds: this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL,
1714
+ assertionExp: claims.value.claims.exp,
1715
+ mapperTtl: mapped.value.accessTokenTTL,
1716
+ now: issueNow
1717
+ });
1718
+ if (!ttl.ok) return ttl;
1719
+ return ok(await this.issueEmaAccessToken({
1720
+ clientId: clientInfo.clientId,
1721
+ userId: mapped.value.userId,
1722
+ mapperScope: mapped.value.scope,
1723
+ mapperProps: mapped.value.props,
1724
+ mapperMetadata: mapped.value.metadata,
1725
+ assertionScopes: claims.value.assertionScopes,
1726
+ resource: claims.value.resource,
1727
+ accessTokenTTLSeconds: ttl.value,
1728
+ env,
1729
+ now: issueNow
1730
+ }));
1731
+ }
1732
+ /**
1733
+ * Verifies the ID-JAG signature against the trusted issuer's JWKS,
1734
+ * force-refreshing once on a `kid` miss to accommodate IdP key rotation.
1735
+ * Uses the in-memory cached JWKS fetcher with anti-DoS cool-down.
1736
+ */
1737
+ async verifyAssertionSignature(args) {
1738
+ const alg = args.header.alg;
1739
+ const { jwksProvider } = args;
1740
+ const initialJwks = await jwksProvider.fetch(args.trustedIssuer, {
1741
+ forceRefresh: false,
1742
+ now: args.now
1743
+ });
1744
+ if (!initialJwks.ok) return initialJwks;
1745
+ let jwk = selectJwk(initialJwks.value, alg, args.header.kid);
1746
+ if (!jwk.ok && args.header.kid) {
1747
+ const refreshed = await jwksProvider.fetch(args.trustedIssuer, {
1748
+ forceRefresh: true,
1749
+ now: args.now
1750
+ });
1751
+ if (!refreshed.ok) return refreshed;
1752
+ jwk = selectJwk(refreshed.value, alg, args.header.kid);
1753
+ }
1754
+ if (!jwk.ok) return jwk;
1755
+ if (!await verifyIdJagSignature({
1756
+ alg,
1757
+ jwk: jwk.value,
1758
+ signingInput: args.parsed.signingInput,
1759
+ signature: args.parsed.signature
1760
+ })) return err({ reason: "signature_failed" });
1761
+ return ok(void 0);
1762
+ }
1763
+ /**
1764
+ * Mints the access token for an authorized EMA request.
1765
+ *
1766
+ * Uses the same grant + access-token machinery as the authorization-code
1767
+ * grant: encrypt the props, persist the grant under `grant:userId:grantId`,
1768
+ * and create an opaque access token bound to the resource as audience.
1769
+ */
1770
+ async issueEmaAccessToken(args) {
1771
+ const tokenScopes = args.assertionScopes.length > 0 ? this.downscope(args.mapperScope, args.assertionScopes) : args.mapperScope;
1772
+ const grantId = generateRandomString(16);
1773
+ const { encryptedData, key: encryptionKey } = await encryptProps(args.mapperProps);
1774
+ const grant = {
1775
+ id: grantId,
1776
+ clientId: args.clientId,
1777
+ userId: args.userId,
1778
+ scope: tokenScopes,
1779
+ metadata: args.mapperMetadata ?? null,
1780
+ encryptedProps: encryptedData,
1781
+ createdAt: args.now,
1782
+ expiresAt: args.now + args.accessTokenTTLSeconds,
1783
+ resource: args.resource
1784
+ };
1785
+ await this.saveGrantWithTTL(args.env, `grant:${args.userId}:${grantId}`, grant, args.now);
1786
+ return {
1787
+ access_token: await this.createAccessToken({
1788
+ userId: args.userId,
1789
+ grantId,
1790
+ clientId: args.clientId,
1791
+ scope: tokenScopes,
1792
+ encryptedProps: encryptedData,
1793
+ encryptionKey,
1794
+ expiresIn: args.accessTokenTTLSeconds,
1795
+ audience: args.resource,
1796
+ env: args.env
1797
+ }),
1798
+ token_type: "bearer",
1799
+ expires_in: args.accessTokenTTLSeconds,
1800
+ scope: tokenScopes.join(" "),
1801
+ resource: args.resource
1802
+ };
1803
+ }
1804
+ /**
837
1805
  * Handles OAuth 2.0 token revocation requests (RFC 7009)
838
1806
  * @param body - The parsed request body containing revocation parameters
839
1807
  * @param env - Cloudflare Worker environment variables
@@ -851,7 +1819,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
851
1819
  */
852
1820
  async revokeToken(body, env) {
853
1821
  const token = body.token;
854
- if (!token) return this.createErrorResponse("invalid_request", "Token parameter is required");
1822
+ if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" });
855
1823
  const tokenParts = token.split(":");
856
1824
  if (tokenParts.length !== 3) return new Response("", { status: 200 });
857
1825
  const [userId, grantId, _] = tokenParts;
@@ -909,20 +1877,35 @@ var OAuthProviderImpl = class OAuthProviderImpl {
909
1877
  * @returns Response with client registration data or error
910
1878
  */
911
1879
  async handleClientRegistration(request, env) {
912
- if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", "Client registration is not enabled", 501);
913
- if (request.method !== "POST") return this.createErrorResponse("invalid_request", "Method not allowed", 405);
914
- if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
1880
+ if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", {
1881
+ description: "Client registration is not enabled",
1882
+ statusCode: 501
1883
+ });
1884
+ if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
1885
+ description: "Method not allowed",
1886
+ statusCode: 405
1887
+ });
1888
+ if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", {
1889
+ description: "Request payload too large, must be under 1 MiB",
1890
+ statusCode: 413
1891
+ });
915
1892
  let clientMetadata;
916
1893
  try {
917
1894
  const text = await request.text();
918
- if (text.length > 1048576) return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
1895
+ if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
1896
+ description: "Request payload too large, must be under 1 MiB",
1897
+ statusCode: 413
1898
+ });
919
1899
  clientMetadata = JSON.parse(text);
920
1900
  } catch (error) {
921
- return this.createErrorResponse("invalid_request", "Invalid JSON payload", 400);
1901
+ return this.createErrorResponse("invalid_request", {
1902
+ description: "Invalid JSON payload",
1903
+ statusCode: 400
1904
+ });
922
1905
  }
923
1906
  const authMethod = OAuthProviderImpl.validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
924
1907
  const isPublicClient = authMethod === "none";
925
- if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", "Public client registration is not allowed");
1908
+ if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
926
1909
  const clientId = generateRandomString(16);
927
1910
  let clientSecret;
928
1911
  let hashedSecret;
@@ -956,7 +1939,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
956
1939
  };
957
1940
  if (!isPublicClient && hashedSecret) clientInfo.clientSecret = hashedSecret;
958
1941
  } catch (error) {
959
- return this.createErrorResponse("invalid_client_metadata", error instanceof Error ? error.message : "Invalid client metadata");
1942
+ return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
960
1943
  }
961
1944
  const clientKvOptions = {};
962
1945
  if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
@@ -998,7 +1981,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
998
1981
  const url = new URL(request.url);
999
1982
  const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
1000
1983
  const authHeader = request.headers.get("Authorization");
1001
- if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Missing or invalid access token") });
1984
+ if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", {
1985
+ description: "Missing or invalid access token",
1986
+ statusCode: 401,
1987
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Missing or invalid access token") }
1988
+ });
1002
1989
  const accessToken = authHeader.substring(7);
1003
1990
  const parts = accessToken.split(":");
1004
1991
  const isPossiblyInternalFormat = parts.length === 3;
@@ -1010,14 +1997,26 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1010
1997
  const id = await generateTokenId(accessToken);
1011
1998
  tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
1012
1999
  }
1013
- if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
2000
+ if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", {
2001
+ description: "Invalid access token",
2002
+ statusCode: 401,
2003
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
2004
+ });
1014
2005
  if (tokenData) {
1015
2006
  const now = Math.floor(Date.now() / 1e3);
1016
- if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", "Access token expired", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
2007
+ if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
2008
+ description: "Access token expired",
2009
+ statusCode: 401,
2010
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
2011
+ });
1017
2012
  if (tokenData.audience) {
1018
2013
  const requestUrl = new URL(request.url);
1019
2014
  const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
1020
- if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") });
2015
+ if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
2016
+ description: "Token audience does not match resource server",
2017
+ statusCode: 401,
2018
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") }
2019
+ });
1021
2020
  }
1022
2021
  ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
1023
2022
  } else if (this.options.resolveExternalToken) {
@@ -1026,17 +2025,28 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1026
2025
  request,
1027
2026
  env
1028
2027
  });
1029
- if (!ext) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
2028
+ if (!ext) return this.createErrorResponse("invalid_token", {
2029
+ description: "Invalid access token",
2030
+ statusCode: 401,
2031
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
2032
+ });
1030
2033
  if (ext.audience) {
1031
2034
  const requestUrl = new URL(request.url);
1032
2035
  const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
1033
- if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") });
2036
+ if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
2037
+ description: "Token audience does not match resource server",
2038
+ statusCode: 401,
2039
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") }
2040
+ });
1034
2041
  }
1035
2042
  ctx.props = ext.props;
1036
2043
  }
1037
2044
  if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
1038
2045
  const apiHandler = this.findApiHandlerForUrl(url);
1039
- if (!apiHandler) return this.createErrorResponse("invalid_request", "No handler found for API route", 404);
2046
+ if (!apiHandler) return this.createErrorResponse("invalid_request", {
2047
+ description: "No handler found for API route",
2048
+ statusCode: 404
2049
+ });
1040
2050
  if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
1041
2051
  else return new apiHandler.handler(ctx, env).fetch(request);
1042
2052
  }
@@ -1058,7 +2068,24 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1058
2068
  */
1059
2069
  async saveGrantWithTTL(env, grantKey, grantData, now) {
1060
2070
  const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
1061
- await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
2071
+ try {
2072
+ await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
2073
+ } catch (error) {
2074
+ this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
2075
+ throw error;
2076
+ }
2077
+ }
2078
+ throwRetryableTokenStorageErrorIfKvRateLimited(error) {
2079
+ if (!this.isKvRateLimitError(error)) return;
2080
+ throw new OAuthError("temporarily_unavailable", {
2081
+ description: "Token issuance is temporarily unavailable; retry shortly",
2082
+ statusCode: 429,
2083
+ headers: { "Retry-After": "30" }
2084
+ });
2085
+ }
2086
+ isKvRateLimitError(error) {
2087
+ if (!(error instanceof Error)) return false;
2088
+ return /KV .*failed: 429 Too Many Requests/i.test(error.message) || /429 Too Many Requests/i.test(error.message);
1062
2089
  }
1063
2090
  /**
1064
2091
  * Fetches client information from KV storage or via CIMD (Client ID Metadata Document)
@@ -1115,7 +2142,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1115
2142
  encryptedProps
1116
2143
  }
1117
2144
  };
1118
- await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: expiresIn });
2145
+ try {
2146
+ await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: expiresIn });
2147
+ } catch (error) {
2148
+ this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
2149
+ throw error;
2150
+ }
1119
2151
  return accessToken;
1120
2152
  }
1121
2153
  /**
@@ -1276,19 +2308,23 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1276
2308
  return header;
1277
2309
  }
1278
2310
  /**
1279
- * Helper function to create OAuth error responses
1280
- * @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
1281
- * @param description - Human-readable error description
1282
- * @param status - HTTP status code (default: 400)
1283
- * @param headers - Additional headers to include
1284
- * @returns A Response object with the error
2311
+ * Helper function to create OAuth error responses.
2312
+ *
2313
+ * `internal` (optional) carries a tagged, server-side-only reason. It is
2314
+ * forwarded to the deployer's `onError` hook but never placed on the wire,
2315
+ * so the public response stays RFC-compliant and free of information leak
2316
+ * while the deployer can still observe which check failed.
1285
2317
  */
1286
- createErrorResponse(code, description, status = 400, headers = {}) {
2318
+ createErrorResponse(code, options, internal) {
2319
+ const { description } = options;
2320
+ const responseStatus = options.statusCode ?? 400;
2321
+ const responseHeaders = options.headers ?? {};
1287
2322
  const customErrorResponse = this.options.onError?.({
1288
2323
  code,
1289
2324
  description,
1290
- status,
1291
- headers
2325
+ status: responseStatus,
2326
+ headers: responseHeaders,
2327
+ ...internal ? { internal } : {}
1292
2328
  });
1293
2329
  if (customErrorResponse) return customErrorResponse;
1294
2330
  const body = JSON.stringify({
@@ -1296,23 +2332,66 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1296
2332
  error_description: description
1297
2333
  });
1298
2334
  return new Response(body, {
1299
- status,
2335
+ status: responseStatus,
1300
2336
  headers: {
1301
2337
  "Content-Type": "application/json",
1302
- ...headers
2338
+ ...responseHeaders
1303
2339
  }
1304
2340
  });
1305
2341
  }
1306
2342
  };
1307
2343
  /**
1308
- * Error class for OAuth operations
1309
- * Carries OAuth error code and description for proper error responses
2344
+ * Structured OAuth 2.0 error.
2345
+ *
2346
+ * Throw from a `tokenExchangeCallback` (or any code it calls — the error
2347
+ * propagates naturally up through deep call stacks) to surface a standard
2348
+ * `/token` error response (`{ error, error_description }`) instead of a
2349
+ * generic `500 Internal Server Error`.
2350
+ *
2351
+ * Anything thrown that is **not** an `OAuthError` continues to surface as
2352
+ * a 500 so unexpected failures remain visible — the provider does not
2353
+ * catch-everything-and-return-400.
2354
+ *
2355
+ * @example
2356
+ * ```ts
2357
+ * import { OAuthError } from '@cloudflare/workers-oauth-provider';
2358
+ *
2359
+ * tokenExchangeCallback: async (options) => {
2360
+ * if (options.grantType === 'refresh_token') {
2361
+ * // refreshUpstream() may throw OAuthError from any depth
2362
+ * return { newProps: await refreshUpstream(options.props) };
2363
+ * }
2364
+ * }
2365
+ *
2366
+ * async function refreshUpstream(props) {
2367
+ * const res = await fetch(...);
2368
+ * if (res.status === 401) {
2369
+ * throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
2370
+ * }
2371
+ * if (res.status === 429) {
2372
+ * // Mirror upstream's Retry-After if present, otherwise pick a default.
2373
+ * throw new OAuthError('temporarily_unavailable', {
2374
+ * description: 'upstream rate limited',
2375
+ * statusCode: 429,
2376
+ * headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
2377
+ * });
2378
+ * }
2379
+ * return await res.json();
2380
+ * }
2381
+ * ```
1310
2382
  */
1311
2383
  var OAuthError = class extends Error {
1312
- constructor(code, message) {
1313
- super(message);
1314
- this.code = code;
2384
+ constructor(code, options) {
2385
+ super(options.description);
1315
2386
  this.name = "OAuthError";
2387
+ this.code = code;
2388
+ this.options = {
2389
+ ...options,
2390
+ statusCode: options.statusCode ?? 400
2391
+ };
2392
+ this.description = this.options.description;
2393
+ this.statusCode = this.options.statusCode;
2394
+ this.headers = this.options.headers;
1316
2395
  }
1317
2396
  };
1318
2397
  /**
@@ -1337,6 +2416,10 @@ const DEFAULT_PURGE_BATCH_SIZE = 50;
1337
2416
  */
1338
2417
  const TOKEN_LENGTH = 32;
1339
2418
  /**
2419
+ * RFC 6749 Section 3.3 scope-token grammar.
2420
+ */
2421
+ const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
2422
+ /**
1340
2423
  * Validates a resource URI per RFC 8707 Section 2
1341
2424
  * @param uri - The URI string to validate
1342
2425
  * @returns true if valid, false otherwise
@@ -1498,6 +2581,59 @@ function base64UrlEncode(str) {
1498
2581
  return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
1499
2582
  }
1500
2583
  /**
2584
+ * Decodes a base64url-encoded string to bytes.
2585
+ */
2586
+ function base64UrlToBytes(base64Url) {
2587
+ const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
2588
+ const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
2589
+ const binaryString = atob(padded);
2590
+ const bytes = new Uint8Array(binaryString.length);
2591
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
2592
+ return bytes;
2593
+ }
2594
+ /**
2595
+ * Parses a base64url-encoded JWT JSON part into an object.
2596
+ */
2597
+ function parseJwtJsonPart(encoded) {
2598
+ try {
2599
+ const json = new TextDecoder().decode(base64UrlToBytes(encoded));
2600
+ const parsed = JSON.parse(json);
2601
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JWT part must be an object");
2602
+ return parsed;
2603
+ } catch {
2604
+ throw new Error("Malformed JWT part");
2605
+ }
2606
+ }
2607
+ function isValidOAuthScopeToken(scopeToken) {
2608
+ return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
2609
+ }
2610
+ /**
2611
+ * Gets WebCrypto import and verify parameters for supported JOSE algorithms.
2612
+ */
2613
+ function getJwtCryptoAlgorithms(alg) {
2614
+ if (alg === "RS256") {
2615
+ const algorithm = {
2616
+ name: "RSASSA-PKCS1-v1_5",
2617
+ hash: "SHA-256"
2618
+ };
2619
+ return {
2620
+ importAlgorithm: algorithm,
2621
+ verifyAlgorithm: algorithm
2622
+ };
2623
+ }
2624
+ if (alg === "ES256") return {
2625
+ importAlgorithm: {
2626
+ name: "ECDSA",
2627
+ namedCurve: "P-256"
2628
+ },
2629
+ verifyAlgorithm: {
2630
+ name: "ECDSA",
2631
+ hash: "SHA-256"
2632
+ }
2633
+ };
2634
+ throw new Error(`Unsupported JWT alg: ${alg}`);
2635
+ }
2636
+ /**
1501
2637
  * Encodes an ArrayBuffer as base64 string
1502
2638
  * @param buffer - The ArrayBuffer to encode
1503
2639
  * @returns The base64 encoded string
@@ -2075,4 +3211,4 @@ var OAuthHelpersImpl = class {
2075
3211
  var oauth_provider_default = OAuthProvider;
2076
3212
 
2077
3213
  //#endregion
2078
- export { GrantType, OAuthProvider, oauth_provider_default as default, getOAuthApi };
3214
+ export { GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };