@cloudflare/workers-oauth-provider 0.6.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)) {
@@ -384,6 +1077,13 @@ var OAuthProviderImpl = class OAuthProviderImpl {
384
1077
  else return endpoint;
385
1078
  }
386
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
+ /**
387
1087
  * Adds CORS headers to a response
388
1088
  * @param response - The response to add CORS headers to
389
1089
  * @param request - The original request
@@ -414,6 +1114,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
414
1114
  if (this.options.allowImplicitFlow) responseTypesSupported.push("token");
415
1115
  const grantTypesSupported = [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN];
416
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
+ }
417
1122
  const metadata = {
418
1123
  issuer: new URL(tokenEndpoint).origin,
419
1124
  authorization_endpoint: authorizeEndpoint,
@@ -423,6 +1128,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
423
1128
  response_types_supported: responseTypesSupported,
424
1129
  response_modes_supported: ["query"],
425
1130
  grant_types_supported: grantTypesSupported,
1131
+ ...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
426
1132
  token_endpoint_auth_methods_supported: [
427
1133
  "client_secret_basic",
428
1134
  "client_secret_post",
@@ -461,12 +1167,13 @@ var OAuthProviderImpl = class OAuthProviderImpl {
461
1167
  * @param env - Cloudflare Worker environment variables
462
1168
  * @returns Response with token data or error
463
1169
  */
464
- async handleTokenRequest(body, clientInfo, env) {
1170
+ async handleTokenRequest(body, clientInfo, env, requestUrl, request) {
465
1171
  try {
466
1172
  const grantType = body.grant_type;
467
1173
  if (grantType === GrantType.AUTHORIZATION_CODE) return await this.handleAuthorizationCodeGrant(body, clientInfo, env);
468
1174
  else if (grantType === GrantType.REFRESH_TOKEN) return await this.handleRefreshTokenGrant(body, clientInfo, env);
469
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);
470
1177
  else return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
471
1178
  } catch (error) {
472
1179
  const response = this.createOAuthErrorResponse(error);
@@ -545,6 +1252,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
545
1252
  grantType: GrantType.AUTHORIZATION_CODE,
546
1253
  clientId: clientInfo.clientId,
547
1254
  userId,
1255
+ grantId,
548
1256
  scope: grantData.scope,
549
1257
  requestedScope: tokenScopes,
550
1258
  props: decryptedProps
@@ -664,6 +1372,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
664
1372
  grantType: GrantType.REFRESH_TOKEN,
665
1373
  clientId: clientInfo.clientId,
666
1374
  userId,
1375
+ grantId,
667
1376
  scope: grantData.scope,
668
1377
  requestedScope: tokenScopes,
669
1378
  props: decryptedProps
@@ -803,6 +1512,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
803
1512
  grantType: GrantType.TOKEN_EXCHANGE,
804
1513
  clientId: clientInfo.clientId,
805
1514
  userId: tokenSummary.userId,
1515
+ grantId: tokenSummary.grantId,
806
1516
  scope: tokenSummary.grant.scope,
807
1517
  requestedScope: tokenScopes,
808
1518
  props: decryptedProps
@@ -881,6 +1591,217 @@ var OAuthProviderImpl = class OAuthProviderImpl {
881
1591
  }
882
1592
  }
883
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
+ /**
884
1805
  * Handles OAuth 2.0 token revocation requests (RFC 7009)
885
1806
  * @param body - The parsed request body containing revocation parameters
886
1807
  * @param env - Cloudflare Worker environment variables
@@ -1387,12 +2308,14 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1387
2308
  return header;
1388
2309
  }
1389
2310
  /**
1390
- * Helper function to create OAuth error responses
1391
- * @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
1392
- * @param options - Error response options
1393
- * @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.
1394
2317
  */
1395
- createErrorResponse(code, options) {
2318
+ createErrorResponse(code, options, internal) {
1396
2319
  const { description } = options;
1397
2320
  const responseStatus = options.statusCode ?? 400;
1398
2321
  const responseHeaders = options.headers ?? {};
@@ -1400,7 +2323,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1400
2323
  code,
1401
2324
  description,
1402
2325
  status: responseStatus,
1403
- headers: responseHeaders
2326
+ headers: responseHeaders,
2327
+ ...internal ? { internal } : {}
1404
2328
  });
1405
2329
  if (customErrorResponse) return customErrorResponse;
1406
2330
  const body = JSON.stringify({
@@ -1492,6 +2416,10 @@ const DEFAULT_PURGE_BATCH_SIZE = 50;
1492
2416
  */
1493
2417
  const TOKEN_LENGTH = 32;
1494
2418
  /**
2419
+ * RFC 6749 Section 3.3 scope-token grammar.
2420
+ */
2421
+ const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
2422
+ /**
1495
2423
  * Validates a resource URI per RFC 8707 Section 2
1496
2424
  * @param uri - The URI string to validate
1497
2425
  * @returns true if valid, false otherwise
@@ -1653,6 +2581,59 @@ function base64UrlEncode(str) {
1653
2581
  return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
1654
2582
  }
1655
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
+ /**
1656
2637
  * Encodes an ArrayBuffer as base64 string
1657
2638
  * @param buffer - The ArrayBuffer to encode
1658
2639
  * @returns The base64 encoded string
@@ -2230,4 +3211,4 @@ var OAuthHelpersImpl = class {
2230
3211
  var oauth_provider_default = OAuthProvider;
2231
3212
 
2232
3213
  //#endregion
2233
- export { GrantType, OAuthError, OAuthProvider, oauth_provider_default as default, getOAuthApi };
3214
+ export { GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };