@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.
- package/README.md +41 -0
- package/dist/oauth-provider.d.ts +329 -3
- package/dist/oauth-provider.js +990 -9
- package/package.json +1 -1
package/dist/oauth-provider.js
CHANGED
|
@@ -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
|
-
*
|
|
1392
|
-
*
|
|
1393
|
-
*
|
|
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 };
|