@cloudflare/workers-oauth-provider 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/dist/oauth-provider.d.ts +420 -3
- package/dist/oauth-provider.js +1220 -84
- 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)) {
|
|
@@ -285,10 +978,16 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
285
978
|
* @returns Promise with parsed body and client info, or error response
|
|
286
979
|
*/
|
|
287
980
|
async parseTokenEndpointRequest(request, env) {
|
|
288
|
-
if (request.method !== "POST") return this.createErrorResponse("invalid_request",
|
|
981
|
+
if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
|
|
982
|
+
description: "Method not allowed",
|
|
983
|
+
statusCode: 405
|
|
984
|
+
});
|
|
289
985
|
let contentType = request.headers.get("Content-Type") || "";
|
|
290
986
|
let body = {};
|
|
291
|
-
if (!contentType.includes("application/x-www-form-urlencoded")) return this.createErrorResponse("invalid_request",
|
|
987
|
+
if (!contentType.includes("application/x-www-form-urlencoded")) return this.createErrorResponse("invalid_request", {
|
|
988
|
+
description: "Content-Type must be application/x-www-form-urlencoded",
|
|
989
|
+
statusCode: 400
|
|
990
|
+
});
|
|
292
991
|
const formData = await request.formData();
|
|
293
992
|
for (const [key, value] of formData.entries()) {
|
|
294
993
|
const allValues = formData.getAll(key);
|
|
@@ -305,13 +1004,28 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
305
1004
|
clientId = body.client_id;
|
|
306
1005
|
clientSecret = body.client_secret || "";
|
|
307
1006
|
}
|
|
308
|
-
if (!clientId) return this.createErrorResponse("invalid_client",
|
|
1007
|
+
if (!clientId) return this.createErrorResponse("invalid_client", {
|
|
1008
|
+
description: "Client ID is required",
|
|
1009
|
+
statusCode: 401
|
|
1010
|
+
});
|
|
309
1011
|
const clientInfo = await this.getClient(env, clientId);
|
|
310
|
-
if (!clientInfo) return this.createErrorResponse("invalid_client",
|
|
1012
|
+
if (!clientInfo) return this.createErrorResponse("invalid_client", {
|
|
1013
|
+
description: "Client not found",
|
|
1014
|
+
statusCode: 401
|
|
1015
|
+
});
|
|
311
1016
|
if (!(clientInfo.tokenEndpointAuthMethod === "none")) {
|
|
312
|
-
if (!clientSecret) return this.createErrorResponse("invalid_client",
|
|
313
|
-
|
|
314
|
-
|
|
1017
|
+
if (!clientSecret) return this.createErrorResponse("invalid_client", {
|
|
1018
|
+
description: "Client authentication failed: missing client_secret",
|
|
1019
|
+
statusCode: 401
|
|
1020
|
+
});
|
|
1021
|
+
if (!clientInfo.clientSecret) return this.createErrorResponse("invalid_client", {
|
|
1022
|
+
description: "Client authentication failed: client has no registered secret",
|
|
1023
|
+
statusCode: 401
|
|
1024
|
+
});
|
|
1025
|
+
if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createErrorResponse("invalid_client", {
|
|
1026
|
+
description: "Client authentication failed: invalid client_secret",
|
|
1027
|
+
statusCode: 401
|
|
1028
|
+
});
|
|
315
1029
|
}
|
|
316
1030
|
return {
|
|
317
1031
|
body,
|
|
@@ -363,6 +1077,13 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
363
1077
|
else return endpoint;
|
|
364
1078
|
}
|
|
365
1079
|
/**
|
|
1080
|
+
* Gets the authorization server issuer using the same derivation as RFC 8414 metadata.
|
|
1081
|
+
*/
|
|
1082
|
+
getAuthorizationServerIssuer(requestUrl) {
|
|
1083
|
+
const tokenEndpoint = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
|
|
1084
|
+
return new URL(tokenEndpoint).origin;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
366
1087
|
* Adds CORS headers to a response
|
|
367
1088
|
* @param response - The response to add CORS headers to
|
|
368
1089
|
* @param request - The original request
|
|
@@ -393,6 +1114,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
393
1114
|
if (this.options.allowImplicitFlow) responseTypesSupported.push("token");
|
|
394
1115
|
const grantTypesSupported = [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN];
|
|
395
1116
|
if (this.options.allowTokenExchangeGrant) grantTypesSupported.push(GrantType.TOKEN_EXCHANGE);
|
|
1117
|
+
const authorizationGrantProfilesSupported = [];
|
|
1118
|
+
if (this.options.enterpriseManagedAuthorization) {
|
|
1119
|
+
grantTypesSupported.push(GrantType.JWT_BEARER);
|
|
1120
|
+
authorizationGrantProfilesSupported.push(EMA_ID_JAG_GRANT_PROFILE);
|
|
1121
|
+
}
|
|
396
1122
|
const metadata = {
|
|
397
1123
|
issuer: new URL(tokenEndpoint).origin,
|
|
398
1124
|
authorization_endpoint: authorizeEndpoint,
|
|
@@ -402,6 +1128,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
402
1128
|
response_types_supported: responseTypesSupported,
|
|
403
1129
|
response_modes_supported: ["query"],
|
|
404
1130
|
grant_types_supported: grantTypesSupported,
|
|
1131
|
+
...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
|
|
405
1132
|
token_endpoint_auth_methods_supported: [
|
|
406
1133
|
"client_secret_basic",
|
|
407
1134
|
"client_secret_post",
|
|
@@ -440,12 +1167,33 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
440
1167
|
* @param env - Cloudflare Worker environment variables
|
|
441
1168
|
* @returns Response with token data or error
|
|
442
1169
|
*/
|
|
443
|
-
async handleTokenRequest(body, clientInfo, env) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
1170
|
+
async handleTokenRequest(body, clientInfo, env, requestUrl, request) {
|
|
1171
|
+
try {
|
|
1172
|
+
const grantType = body.grant_type;
|
|
1173
|
+
if (grantType === GrantType.AUTHORIZATION_CODE) return await this.handleAuthorizationCodeGrant(body, clientInfo, env);
|
|
1174
|
+
else if (grantType === GrantType.REFRESH_TOKEN) return await this.handleRefreshTokenGrant(body, clientInfo, env);
|
|
1175
|
+
else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return await this.handleTokenExchangeGrant(body, clientInfo, env);
|
|
1176
|
+
else if (grantType === GrantType.JWT_BEARER) return await this.handleJwtBearerGrant(body, clientInfo, env, requestUrl, request);
|
|
1177
|
+
else return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
const response = this.createOAuthErrorResponse(error);
|
|
1180
|
+
if (response) return response;
|
|
1181
|
+
throw error;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Build a structured OAuth `/token` error response from an OAuth error.
|
|
1186
|
+
*
|
|
1187
|
+
* The supported form is throwing this package's exported `OAuthError`.
|
|
1188
|
+
* Anything else is re-thrown so unexpected failures still surface as 500s.
|
|
1189
|
+
*
|
|
1190
|
+
* Use `headers['Retry-After']` for rate-limit / transient-failure backoff
|
|
1191
|
+
* hints (see RFC 7231 §7.1.3 — either an integer seconds value or an
|
|
1192
|
+
* HTTP-date is allowed).
|
|
1193
|
+
*/
|
|
1194
|
+
createOAuthErrorResponse(error) {
|
|
1195
|
+
if (!(error instanceof OAuthError)) return void 0;
|
|
1196
|
+
return this.createErrorResponse(error.code, error.options);
|
|
449
1197
|
}
|
|
450
1198
|
/**
|
|
451
1199
|
* Handles the authorization code grant type
|
|
@@ -459,27 +1207,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
459
1207
|
const code = body.code;
|
|
460
1208
|
const redirectUri = body.redirect_uri;
|
|
461
1209
|
const codeVerifier = body.code_verifier;
|
|
462
|
-
if (!code) return this.createErrorResponse("invalid_request", "Authorization code is required");
|
|
1210
|
+
if (!code) return this.createErrorResponse("invalid_request", { description: "Authorization code is required" });
|
|
463
1211
|
const codeParts = code.split(":");
|
|
464
|
-
if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", "Invalid authorization code format");
|
|
1212
|
+
if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code format" });
|
|
465
1213
|
const [userId, grantId, _] = codeParts;
|
|
466
1214
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
467
1215
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
468
|
-
if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
|
|
1216
|
+
if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" });
|
|
469
1217
|
if (!grantData.authCodeId) {
|
|
470
1218
|
try {
|
|
471
1219
|
await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
|
|
472
1220
|
} catch {}
|
|
473
|
-
return this.createErrorResponse("invalid_grant", "Authorization code already used");
|
|
1221
|
+
return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
|
|
474
1222
|
}
|
|
475
|
-
if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", "Invalid authorization code");
|
|
476
|
-
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
|
|
1223
|
+
if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" });
|
|
1224
|
+
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
|
|
477
1225
|
const isPkceEnabled = !!grantData.codeChallenge;
|
|
478
|
-
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
|
|
479
|
-
if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
|
|
480
|
-
if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier provided for a flow that did not use PKCE");
|
|
1226
|
+
if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
|
|
1227
|
+
if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
|
|
1228
|
+
if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier provided for a flow that did not use PKCE" });
|
|
481
1229
|
if (isPkceEnabled) {
|
|
482
|
-
if (!codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
|
|
1230
|
+
if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" });
|
|
483
1231
|
let calculatedChallenge;
|
|
484
1232
|
if (grantData.codeChallengeMethod === "S256") {
|
|
485
1233
|
const data = new TextEncoder().encode(codeVerifier);
|
|
@@ -487,7 +1235,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
487
1235
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
488
1236
|
calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
|
|
489
1237
|
} else calculatedChallenge = codeVerifier;
|
|
490
|
-
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
|
|
1238
|
+
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" });
|
|
491
1239
|
}
|
|
492
1240
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
493
1241
|
let refreshTokenTTL = this.options.refreshTokenTTL;
|
|
@@ -504,6 +1252,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
504
1252
|
grantType: GrantType.AUTHORIZATION_CODE,
|
|
505
1253
|
clientId: clientInfo.clientId,
|
|
506
1254
|
userId,
|
|
1255
|
+
grantId,
|
|
507
1256
|
scope: grantData.scope,
|
|
508
1257
|
requestedScope: tokenScopes,
|
|
509
1258
|
props: decryptedProps
|
|
@@ -554,10 +1303,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
554
1303
|
if (body.resource && grantData.resource) {
|
|
555
1304
|
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
556
1305
|
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
557
|
-
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
|
|
1306
|
+
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
558
1307
|
}
|
|
559
1308
|
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
560
|
-
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
1309
|
+
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
561
1310
|
const tokenResponse = {
|
|
562
1311
|
access_token: await this.createAccessToken({
|
|
563
1312
|
userId,
|
|
@@ -588,20 +1337,20 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
588
1337
|
*/
|
|
589
1338
|
async handleRefreshTokenGrant(body, clientInfo, env) {
|
|
590
1339
|
const refreshToken = body.refresh_token;
|
|
591
|
-
if (!refreshToken) return this.createErrorResponse("invalid_request", "Refresh token is required");
|
|
1340
|
+
if (!refreshToken) return this.createErrorResponse("invalid_request", { description: "Refresh token is required" });
|
|
592
1341
|
const tokenParts = refreshToken.split(":");
|
|
593
|
-
if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", "Invalid token format");
|
|
1342
|
+
if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid token format" });
|
|
594
1343
|
const [userId, grantId, _] = tokenParts;
|
|
595
1344
|
const providedTokenHash = await generateTokenId(refreshToken);
|
|
596
1345
|
const grantKey = `grant:${userId}:${grantId}`;
|
|
597
1346
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
598
|
-
if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found");
|
|
1347
|
+
if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found" });
|
|
599
1348
|
const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
|
|
600
1349
|
const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
|
|
601
|
-
if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", "Invalid refresh token");
|
|
602
|
-
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
|
|
1350
|
+
if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" });
|
|
1351
|
+
if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
|
|
603
1352
|
if (grantData.expiresAt !== void 0) {
|
|
604
|
-
if (Math.floor(Date.now() / 1e3) >= grantData.expiresAt) return this.createErrorResponse("invalid_grant", "Refresh token has expired");
|
|
1353
|
+
if (Math.floor(Date.now() / 1e3) >= grantData.expiresAt) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
|
|
605
1354
|
}
|
|
606
1355
|
const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
607
1356
|
const accessTokenId = await generateTokenId(newAccessToken);
|
|
@@ -623,6 +1372,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
623
1372
|
grantType: GrantType.REFRESH_TOKEN,
|
|
624
1373
|
clientId: clientInfo.clientId,
|
|
625
1374
|
userId,
|
|
1375
|
+
grantId,
|
|
626
1376
|
scope: grantData.scope,
|
|
627
1377
|
requestedScope: tokenScopes,
|
|
628
1378
|
props: decryptedProps
|
|
@@ -636,7 +1386,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
636
1386
|
}
|
|
637
1387
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
638
1388
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
639
|
-
if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", "refreshTokenTTL cannot be changed during refresh token exchange");
|
|
1389
|
+
if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", { description: "refreshTokenTTL cannot be changed during refresh token exchange" });
|
|
640
1390
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
641
1391
|
}
|
|
642
1392
|
if (grantPropsChanged) {
|
|
@@ -675,10 +1425,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
675
1425
|
if (body.resource && grantData.resource) {
|
|
676
1426
|
const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
|
|
677
1427
|
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
678
|
-
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
|
|
1428
|
+
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
679
1429
|
}
|
|
680
1430
|
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
681
|
-
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
1431
|
+
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
682
1432
|
const accessTokenData = {
|
|
683
1433
|
id: accessTokenId,
|
|
684
1434
|
grantId,
|
|
@@ -694,7 +1444,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
694
1444
|
encryptedProps: encryptedAccessTokenProps
|
|
695
1445
|
}
|
|
696
1446
|
};
|
|
697
|
-
|
|
1447
|
+
try {
|
|
1448
|
+
await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
|
|
1449
|
+
} catch (error) {
|
|
1450
|
+
this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
|
|
1451
|
+
throw error;
|
|
1452
|
+
}
|
|
698
1453
|
const tokenResponse = {
|
|
699
1454
|
access_token: newAccessToken,
|
|
700
1455
|
token_type: "bearer",
|
|
@@ -722,10 +1477,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
722
1477
|
*/
|
|
723
1478
|
async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
|
|
724
1479
|
const tokenSummary = await this.unwrapToken(subjectToken, env);
|
|
725
|
-
if (!tokenSummary) throw new OAuthError("invalid_grant", "Invalid or expired subject token");
|
|
1480
|
+
if (!tokenSummary) throw new OAuthError("invalid_grant", { description: "Invalid or expired subject token" });
|
|
726
1481
|
const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
|
|
727
1482
|
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
728
|
-
if (!grantData) throw new OAuthError("invalid_grant", "Grant not found");
|
|
1483
|
+
if (!grantData) throw new OAuthError("invalid_grant", { description: "Grant not found" });
|
|
729
1484
|
let tokenScopes = this.downscope(requestedScopes, grantData.scope);
|
|
730
1485
|
const originOnly = !!this.options.resourceMatchOriginOnly;
|
|
731
1486
|
let newAudience = tokenSummary.audience;
|
|
@@ -733,21 +1488,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
733
1488
|
if (grantData.resource) {
|
|
734
1489
|
const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
|
|
735
1490
|
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
736
|
-
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", "Requested resource was not included in the authorization request");
|
|
1491
|
+
for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
|
|
737
1492
|
}
|
|
738
1493
|
const parsedResource = parseResourceParameter(requestedResource);
|
|
739
|
-
if (!parsedResource) throw new OAuthError("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
1494
|
+
if (!parsedResource) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
|
|
740
1495
|
newAudience = parsedResource;
|
|
741
1496
|
}
|
|
742
1497
|
const now = Math.floor(Date.now() / 1e3);
|
|
743
1498
|
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
744
1499
|
let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
|
|
745
1500
|
if (expiresIn !== void 0) {
|
|
746
|
-
if (expiresIn <= 0) throw new OAuthError("invalid_request", "Invalid expires_in parameter");
|
|
1501
|
+
if (expiresIn <= 0) throw new OAuthError("invalid_request", { description: "Invalid expires_in parameter" });
|
|
747
1502
|
accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
|
|
748
1503
|
} else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
|
|
749
1504
|
const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
|
|
750
|
-
if (!subjectTokenData) throw new OAuthError("invalid_grant", "Subject token data not found");
|
|
1505
|
+
if (!subjectTokenData) throw new OAuthError("invalid_grant", { description: "Subject token data not found" });
|
|
751
1506
|
const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
|
|
752
1507
|
let accessTokenEncryptionKey = encryptionKey;
|
|
753
1508
|
let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
|
|
@@ -757,6 +1512,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
757
1512
|
grantType: GrantType.TOKEN_EXCHANGE,
|
|
758
1513
|
clientId: clientInfo.clientId,
|
|
759
1514
|
userId: tokenSummary.userId,
|
|
1515
|
+
grantId: tokenSummary.grantId,
|
|
760
1516
|
scope: tokenSummary.grant.scope,
|
|
761
1517
|
requestedScope: tokenScopes,
|
|
762
1518
|
props: decryptedProps
|
|
@@ -811,29 +1567,241 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
811
1567
|
const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
|
|
812
1568
|
const requestedScope = body.scope;
|
|
813
1569
|
const requestedResource = body.resource;
|
|
814
|
-
if (!subjectToken) return this.createErrorResponse("invalid_request", "subject_token is required");
|
|
815
|
-
if (!subjectTokenType) return this.createErrorResponse("invalid_request", "subject_token_type is required");
|
|
816
|
-
if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token subject_token_type is supported");
|
|
817
|
-
if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token requested_token_type is supported");
|
|
1570
|
+
if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" });
|
|
1571
|
+
if (!subjectTokenType) return this.createErrorResponse("invalid_request", { description: "subject_token_type is required" });
|
|
1572
|
+
if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token subject_token_type is supported" });
|
|
1573
|
+
if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token requested_token_type is supported" });
|
|
818
1574
|
let requestedScopes;
|
|
819
1575
|
if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
|
|
820
1576
|
else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
|
|
821
|
-
else return this.createErrorResponse("invalid_request", "Invalid scope parameter format");
|
|
1577
|
+
else return this.createErrorResponse("invalid_request", { description: "Invalid scope parameter format" });
|
|
822
1578
|
let expiresIn;
|
|
823
1579
|
if (body.expires_in !== void 0) {
|
|
824
1580
|
const requestedTTL = parseInt(body.expires_in, 10);
|
|
825
|
-
if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", "Invalid expires_in parameter");
|
|
1581
|
+
if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", { description: "Invalid expires_in parameter" });
|
|
826
1582
|
expiresIn = requestedTTL;
|
|
827
1583
|
}
|
|
828
1584
|
try {
|
|
829
1585
|
const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
|
|
830
1586
|
return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
|
|
831
1587
|
} catch (error) {
|
|
832
|
-
|
|
1588
|
+
const response = this.createOAuthErrorResponse(error);
|
|
1589
|
+
if (response) return response;
|
|
833
1590
|
throw error;
|
|
834
1591
|
}
|
|
835
1592
|
}
|
|
836
1593
|
/**
|
|
1594
|
+
* Handles the MCP Enterprise-Managed Authorization JWT-bearer grant.
|
|
1595
|
+
*
|
|
1596
|
+
* Acts as a thin shell around `runEmaPipeline`: gate non-EMA traffic, run
|
|
1597
|
+
* the pipeline, translate the typed `EmaValidationError` Result back to a
|
|
1598
|
+
* standard OAuth wire response. All validation logic lives in pure
|
|
1599
|
+
* functions in `src/ema/`.
|
|
1600
|
+
*/
|
|
1601
|
+
async handleJwtBearerGrant(body, clientInfo, env, requestUrl, request) {
|
|
1602
|
+
const enterpriseOptions = this.options.enterpriseManagedAuthorization;
|
|
1603
|
+
if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
|
|
1604
|
+
if (clientInfo.tokenEndpointAuthMethod === "none") return this.createErrorResponse("invalid_client", {
|
|
1605
|
+
description: "Enterprise-managed authorization requires client authentication",
|
|
1606
|
+
statusCode: 401
|
|
1607
|
+
});
|
|
1608
|
+
const result = await this.runEmaPipeline({
|
|
1609
|
+
body,
|
|
1610
|
+
clientInfo,
|
|
1611
|
+
env,
|
|
1612
|
+
requestUrl,
|
|
1613
|
+
request,
|
|
1614
|
+
enterpriseOptions
|
|
1615
|
+
});
|
|
1616
|
+
const noCacheHeaders = {
|
|
1617
|
+
"Cache-Control": "no-store",
|
|
1618
|
+
Pragma: "no-cache"
|
|
1619
|
+
};
|
|
1620
|
+
if (!result.ok) {
|
|
1621
|
+
const wire = emaErrorToWire(result.error);
|
|
1622
|
+
return this.createErrorResponse(wire.code, {
|
|
1623
|
+
description: wire.message,
|
|
1624
|
+
headers: noCacheHeaders
|
|
1625
|
+
}, {
|
|
1626
|
+
category: "enterprise-managed-authorization",
|
|
1627
|
+
reason: result.error.reason,
|
|
1628
|
+
detail: result.error
|
|
1629
|
+
});
|
|
1630
|
+
}
|
|
1631
|
+
return new Response(JSON.stringify(result.value), { headers: {
|
|
1632
|
+
"Content-Type": "application/json",
|
|
1633
|
+
...noCacheHeaders
|
|
1634
|
+
} });
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* Runs the full EMA token-request pipeline as a chain of pure validators
|
|
1638
|
+
* and adapter calls. Each step short-circuits on the first failure.
|
|
1639
|
+
*
|
|
1640
|
+
* Sequence:
|
|
1641
|
+
* parse → validate header → trust issuer → fetch JWKS → select key →
|
|
1642
|
+
* verify signature → validate claims → record jti → parse scope →
|
|
1643
|
+
* run mapper → validate mapper result → compute TTL → mint token.
|
|
1644
|
+
*/
|
|
1645
|
+
async runEmaPipeline(args) {
|
|
1646
|
+
const { body, clientInfo, env, requestUrl, request, enterpriseOptions } = args;
|
|
1647
|
+
const { jwksProvider, jtiStore } = this;
|
|
1648
|
+
const configuredResource = this.options.resourceMetadata?.resource;
|
|
1649
|
+
if (!jwksProvider || !jtiStore || !configuredResource) throw new Error("EMA pipeline invoked without configured adapters");
|
|
1650
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1651
|
+
const parsed = parseIdJag(body.assertion, EMA_MAX_JWT_BYTES);
|
|
1652
|
+
if (!parsed.ok) return parsed;
|
|
1653
|
+
const header = validateIdJagHeader(parsed.value.header, EMA_ID_JAG_JWT_TYPE, EMA_SUPPORTED_JWT_ALGORITHMS);
|
|
1654
|
+
if (!header.ok) return header;
|
|
1655
|
+
const alg = header.value.alg;
|
|
1656
|
+
const trustedIssuer = await resolveTrustedIssuer({
|
|
1657
|
+
iss: parsed.value.rawClaims.iss,
|
|
1658
|
+
alg,
|
|
1659
|
+
resolver: enterpriseOptions.trustedIssuers,
|
|
1660
|
+
env,
|
|
1661
|
+
request,
|
|
1662
|
+
clientInfo
|
|
1663
|
+
});
|
|
1664
|
+
if (!trustedIssuer.ok) return trustedIssuer;
|
|
1665
|
+
const verified = await this.verifyAssertionSignature({
|
|
1666
|
+
parsed: parsed.value,
|
|
1667
|
+
header: header.value,
|
|
1668
|
+
trustedIssuer: trustedIssuer.value,
|
|
1669
|
+
jwksProvider,
|
|
1670
|
+
now
|
|
1671
|
+
});
|
|
1672
|
+
if (!verified.ok) return verified;
|
|
1673
|
+
const claims = validateIdJagClaims({
|
|
1674
|
+
rawClaims: parsed.value.rawClaims,
|
|
1675
|
+
trustedIssuer: trustedIssuer.value,
|
|
1676
|
+
expectedAudience: trustedIssuer.value.audience ?? this.getAuthorizationServerIssuer(requestUrl),
|
|
1677
|
+
clientId: clientInfo.clientId,
|
|
1678
|
+
configuredResource,
|
|
1679
|
+
matchOriginOnly: !!this.options.resourceMatchOriginOnly,
|
|
1680
|
+
now,
|
|
1681
|
+
clockSkewSeconds: enterpriseOptions.clockSkewSeconds ?? EMA_DEFAULT_CLOCK_SKEW_SECONDS,
|
|
1682
|
+
maxAssertionLifetimeSeconds: enterpriseOptions.maxAssertionLifetimeSeconds ?? EMA_DEFAULT_MAX_ASSERTION_LIFETIME_SECONDS
|
|
1683
|
+
});
|
|
1684
|
+
if (!claims.ok) return claims;
|
|
1685
|
+
const markNow = Math.floor(Date.now() / 1e3);
|
|
1686
|
+
const replay = await jtiStore.markUsed({
|
|
1687
|
+
issuer: claims.value.claims.iss,
|
|
1688
|
+
jti: claims.value.claims.jti,
|
|
1689
|
+
exp: claims.value.claims.exp,
|
|
1690
|
+
now: markNow,
|
|
1691
|
+
env
|
|
1692
|
+
});
|
|
1693
|
+
if (!replay.ok) return replay;
|
|
1694
|
+
const requestedScope = parseEmaScopeParam(body.scope, claims.value.assertionScopes);
|
|
1695
|
+
if (!requestedScope.ok) return requestedScope;
|
|
1696
|
+
let mapperOutput;
|
|
1697
|
+
try {
|
|
1698
|
+
mapperOutput = await enterpriseOptions.mapClaims({
|
|
1699
|
+
claims: claims.value.claims,
|
|
1700
|
+
clientInfo,
|
|
1701
|
+
resource: claims.value.resource,
|
|
1702
|
+
requestedScope: requestedScope.value,
|
|
1703
|
+
request: args.request,
|
|
1704
|
+
env
|
|
1705
|
+
});
|
|
1706
|
+
} catch {
|
|
1707
|
+
return err({ reason: "mapper_threw" });
|
|
1708
|
+
}
|
|
1709
|
+
const mapped = validateEmaMapperResult(mapperOutput);
|
|
1710
|
+
if (!mapped.ok) return mapped;
|
|
1711
|
+
const issueNow = Math.floor(Date.now() / 1e3);
|
|
1712
|
+
const ttl = computeEmaAccessTokenTTL({
|
|
1713
|
+
configuredDefaultSeconds: this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL,
|
|
1714
|
+
assertionExp: claims.value.claims.exp,
|
|
1715
|
+
mapperTtl: mapped.value.accessTokenTTL,
|
|
1716
|
+
now: issueNow
|
|
1717
|
+
});
|
|
1718
|
+
if (!ttl.ok) return ttl;
|
|
1719
|
+
return ok(await this.issueEmaAccessToken({
|
|
1720
|
+
clientId: clientInfo.clientId,
|
|
1721
|
+
userId: mapped.value.userId,
|
|
1722
|
+
mapperScope: mapped.value.scope,
|
|
1723
|
+
mapperProps: mapped.value.props,
|
|
1724
|
+
mapperMetadata: mapped.value.metadata,
|
|
1725
|
+
assertionScopes: claims.value.assertionScopes,
|
|
1726
|
+
resource: claims.value.resource,
|
|
1727
|
+
accessTokenTTLSeconds: ttl.value,
|
|
1728
|
+
env,
|
|
1729
|
+
now: issueNow
|
|
1730
|
+
}));
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Verifies the ID-JAG signature against the trusted issuer's JWKS,
|
|
1734
|
+
* force-refreshing once on a `kid` miss to accommodate IdP key rotation.
|
|
1735
|
+
* Uses the in-memory cached JWKS fetcher with anti-DoS cool-down.
|
|
1736
|
+
*/
|
|
1737
|
+
async verifyAssertionSignature(args) {
|
|
1738
|
+
const alg = args.header.alg;
|
|
1739
|
+
const { jwksProvider } = args;
|
|
1740
|
+
const initialJwks = await jwksProvider.fetch(args.trustedIssuer, {
|
|
1741
|
+
forceRefresh: false,
|
|
1742
|
+
now: args.now
|
|
1743
|
+
});
|
|
1744
|
+
if (!initialJwks.ok) return initialJwks;
|
|
1745
|
+
let jwk = selectJwk(initialJwks.value, alg, args.header.kid);
|
|
1746
|
+
if (!jwk.ok && args.header.kid) {
|
|
1747
|
+
const refreshed = await jwksProvider.fetch(args.trustedIssuer, {
|
|
1748
|
+
forceRefresh: true,
|
|
1749
|
+
now: args.now
|
|
1750
|
+
});
|
|
1751
|
+
if (!refreshed.ok) return refreshed;
|
|
1752
|
+
jwk = selectJwk(refreshed.value, alg, args.header.kid);
|
|
1753
|
+
}
|
|
1754
|
+
if (!jwk.ok) return jwk;
|
|
1755
|
+
if (!await verifyIdJagSignature({
|
|
1756
|
+
alg,
|
|
1757
|
+
jwk: jwk.value,
|
|
1758
|
+
signingInput: args.parsed.signingInput,
|
|
1759
|
+
signature: args.parsed.signature
|
|
1760
|
+
})) return err({ reason: "signature_failed" });
|
|
1761
|
+
return ok(void 0);
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Mints the access token for an authorized EMA request.
|
|
1765
|
+
*
|
|
1766
|
+
* Uses the same grant + access-token machinery as the authorization-code
|
|
1767
|
+
* grant: encrypt the props, persist the grant under `grant:userId:grantId`,
|
|
1768
|
+
* and create an opaque access token bound to the resource as audience.
|
|
1769
|
+
*/
|
|
1770
|
+
async issueEmaAccessToken(args) {
|
|
1771
|
+
const tokenScopes = args.assertionScopes.length > 0 ? this.downscope(args.mapperScope, args.assertionScopes) : args.mapperScope;
|
|
1772
|
+
const grantId = generateRandomString(16);
|
|
1773
|
+
const { encryptedData, key: encryptionKey } = await encryptProps(args.mapperProps);
|
|
1774
|
+
const grant = {
|
|
1775
|
+
id: grantId,
|
|
1776
|
+
clientId: args.clientId,
|
|
1777
|
+
userId: args.userId,
|
|
1778
|
+
scope: tokenScopes,
|
|
1779
|
+
metadata: args.mapperMetadata ?? null,
|
|
1780
|
+
encryptedProps: encryptedData,
|
|
1781
|
+
createdAt: args.now,
|
|
1782
|
+
expiresAt: args.now + args.accessTokenTTLSeconds,
|
|
1783
|
+
resource: args.resource
|
|
1784
|
+
};
|
|
1785
|
+
await this.saveGrantWithTTL(args.env, `grant:${args.userId}:${grantId}`, grant, args.now);
|
|
1786
|
+
return {
|
|
1787
|
+
access_token: await this.createAccessToken({
|
|
1788
|
+
userId: args.userId,
|
|
1789
|
+
grantId,
|
|
1790
|
+
clientId: args.clientId,
|
|
1791
|
+
scope: tokenScopes,
|
|
1792
|
+
encryptedProps: encryptedData,
|
|
1793
|
+
encryptionKey,
|
|
1794
|
+
expiresIn: args.accessTokenTTLSeconds,
|
|
1795
|
+
audience: args.resource,
|
|
1796
|
+
env: args.env
|
|
1797
|
+
}),
|
|
1798
|
+
token_type: "bearer",
|
|
1799
|
+
expires_in: args.accessTokenTTLSeconds,
|
|
1800
|
+
scope: tokenScopes.join(" "),
|
|
1801
|
+
resource: args.resource
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
837
1805
|
* Handles OAuth 2.0 token revocation requests (RFC 7009)
|
|
838
1806
|
* @param body - The parsed request body containing revocation parameters
|
|
839
1807
|
* @param env - Cloudflare Worker environment variables
|
|
@@ -851,7 +1819,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
851
1819
|
*/
|
|
852
1820
|
async revokeToken(body, env) {
|
|
853
1821
|
const token = body.token;
|
|
854
|
-
if (!token) return this.createErrorResponse("invalid_request", "Token parameter is required");
|
|
1822
|
+
if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" });
|
|
855
1823
|
const tokenParts = token.split(":");
|
|
856
1824
|
if (tokenParts.length !== 3) return new Response("", { status: 200 });
|
|
857
1825
|
const [userId, grantId, _] = tokenParts;
|
|
@@ -909,20 +1877,35 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
909
1877
|
* @returns Response with client registration data or error
|
|
910
1878
|
*/
|
|
911
1879
|
async handleClientRegistration(request, env) {
|
|
912
|
-
if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented",
|
|
913
|
-
|
|
914
|
-
|
|
1880
|
+
if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", {
|
|
1881
|
+
description: "Client registration is not enabled",
|
|
1882
|
+
statusCode: 501
|
|
1883
|
+
});
|
|
1884
|
+
if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
|
|
1885
|
+
description: "Method not allowed",
|
|
1886
|
+
statusCode: 405
|
|
1887
|
+
});
|
|
1888
|
+
if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", {
|
|
1889
|
+
description: "Request payload too large, must be under 1 MiB",
|
|
1890
|
+
statusCode: 413
|
|
1891
|
+
});
|
|
915
1892
|
let clientMetadata;
|
|
916
1893
|
try {
|
|
917
1894
|
const text = await request.text();
|
|
918
|
-
if (text.length > 1048576) return this.createErrorResponse("invalid_request",
|
|
1895
|
+
if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
|
|
1896
|
+
description: "Request payload too large, must be under 1 MiB",
|
|
1897
|
+
statusCode: 413
|
|
1898
|
+
});
|
|
919
1899
|
clientMetadata = JSON.parse(text);
|
|
920
1900
|
} catch (error) {
|
|
921
|
-
return this.createErrorResponse("invalid_request",
|
|
1901
|
+
return this.createErrorResponse("invalid_request", {
|
|
1902
|
+
description: "Invalid JSON payload",
|
|
1903
|
+
statusCode: 400
|
|
1904
|
+
});
|
|
922
1905
|
}
|
|
923
1906
|
const authMethod = OAuthProviderImpl.validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
|
|
924
1907
|
const isPublicClient = authMethod === "none";
|
|
925
|
-
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", "Public client registration is not allowed");
|
|
1908
|
+
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
|
|
926
1909
|
const clientId = generateRandomString(16);
|
|
927
1910
|
let clientSecret;
|
|
928
1911
|
let hashedSecret;
|
|
@@ -956,7 +1939,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
956
1939
|
};
|
|
957
1940
|
if (!isPublicClient && hashedSecret) clientInfo.clientSecret = hashedSecret;
|
|
958
1941
|
} catch (error) {
|
|
959
|
-
return this.createErrorResponse("invalid_client_metadata", error instanceof Error ? error.message : "Invalid client metadata");
|
|
1942
|
+
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
|
|
960
1943
|
}
|
|
961
1944
|
const clientKvOptions = {};
|
|
962
1945
|
if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
|
|
@@ -998,7 +1981,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
998
1981
|
const url = new URL(request.url);
|
|
999
1982
|
const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
|
|
1000
1983
|
const authHeader = request.headers.get("Authorization");
|
|
1001
|
-
if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token",
|
|
1984
|
+
if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", {
|
|
1985
|
+
description: "Missing or invalid access token",
|
|
1986
|
+
statusCode: 401,
|
|
1987
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Missing or invalid access token") }
|
|
1988
|
+
});
|
|
1002
1989
|
const accessToken = authHeader.substring(7);
|
|
1003
1990
|
const parts = accessToken.split(":");
|
|
1004
1991
|
const isPossiblyInternalFormat = parts.length === 3;
|
|
@@ -1010,14 +1997,26 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1010
1997
|
const id = await generateTokenId(accessToken);
|
|
1011
1998
|
tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
|
|
1012
1999
|
}
|
|
1013
|
-
if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token",
|
|
2000
|
+
if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", {
|
|
2001
|
+
description: "Invalid access token",
|
|
2002
|
+
statusCode: 401,
|
|
2003
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
2004
|
+
});
|
|
1014
2005
|
if (tokenData) {
|
|
1015
2006
|
const now = Math.floor(Date.now() / 1e3);
|
|
1016
|
-
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token",
|
|
2007
|
+
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
|
|
2008
|
+
description: "Access token expired",
|
|
2009
|
+
statusCode: 401,
|
|
2010
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
2011
|
+
});
|
|
1017
2012
|
if (tokenData.audience) {
|
|
1018
2013
|
const requestUrl = new URL(request.url);
|
|
1019
2014
|
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
1020
|
-
if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token",
|
|
2015
|
+
if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
|
|
2016
|
+
description: "Token audience does not match resource server",
|
|
2017
|
+
statusCode: 401,
|
|
2018
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") }
|
|
2019
|
+
});
|
|
1021
2020
|
}
|
|
1022
2021
|
ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
|
|
1023
2022
|
} else if (this.options.resolveExternalToken) {
|
|
@@ -1026,17 +2025,28 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1026
2025
|
request,
|
|
1027
2026
|
env
|
|
1028
2027
|
});
|
|
1029
|
-
if (!ext) return this.createErrorResponse("invalid_token",
|
|
2028
|
+
if (!ext) return this.createErrorResponse("invalid_token", {
|
|
2029
|
+
description: "Invalid access token",
|
|
2030
|
+
statusCode: 401,
|
|
2031
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
|
|
2032
|
+
});
|
|
1030
2033
|
if (ext.audience) {
|
|
1031
2034
|
const requestUrl = new URL(request.url);
|
|
1032
2035
|
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
1033
|
-
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token",
|
|
2036
|
+
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
|
|
2037
|
+
description: "Token audience does not match resource server",
|
|
2038
|
+
statusCode: 401,
|
|
2039
|
+
headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") }
|
|
2040
|
+
});
|
|
1034
2041
|
}
|
|
1035
2042
|
ctx.props = ext.props;
|
|
1036
2043
|
}
|
|
1037
2044
|
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
1038
2045
|
const apiHandler = this.findApiHandlerForUrl(url);
|
|
1039
|
-
if (!apiHandler) return this.createErrorResponse("invalid_request",
|
|
2046
|
+
if (!apiHandler) return this.createErrorResponse("invalid_request", {
|
|
2047
|
+
description: "No handler found for API route",
|
|
2048
|
+
statusCode: 404
|
|
2049
|
+
});
|
|
1040
2050
|
if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
|
|
1041
2051
|
else return new apiHandler.handler(ctx, env).fetch(request);
|
|
1042
2052
|
}
|
|
@@ -1058,7 +2068,24 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1058
2068
|
*/
|
|
1059
2069
|
async saveGrantWithTTL(env, grantKey, grantData, now) {
|
|
1060
2070
|
const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
|
|
1061
|
-
|
|
2071
|
+
try {
|
|
2072
|
+
await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
|
|
2073
|
+
} catch (error) {
|
|
2074
|
+
this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
|
|
2075
|
+
throw error;
|
|
2076
|
+
}
|
|
2077
|
+
}
|
|
2078
|
+
throwRetryableTokenStorageErrorIfKvRateLimited(error) {
|
|
2079
|
+
if (!this.isKvRateLimitError(error)) return;
|
|
2080
|
+
throw new OAuthError("temporarily_unavailable", {
|
|
2081
|
+
description: "Token issuance is temporarily unavailable; retry shortly",
|
|
2082
|
+
statusCode: 429,
|
|
2083
|
+
headers: { "Retry-After": "30" }
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
isKvRateLimitError(error) {
|
|
2087
|
+
if (!(error instanceof Error)) return false;
|
|
2088
|
+
return /KV .*failed: 429 Too Many Requests/i.test(error.message) || /429 Too Many Requests/i.test(error.message);
|
|
1062
2089
|
}
|
|
1063
2090
|
/**
|
|
1064
2091
|
* Fetches client information from KV storage or via CIMD (Client ID Metadata Document)
|
|
@@ -1115,7 +2142,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1115
2142
|
encryptedProps
|
|
1116
2143
|
}
|
|
1117
2144
|
};
|
|
1118
|
-
|
|
2145
|
+
try {
|
|
2146
|
+
await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: expiresIn });
|
|
2147
|
+
} catch (error) {
|
|
2148
|
+
this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
|
|
2149
|
+
throw error;
|
|
2150
|
+
}
|
|
1119
2151
|
return accessToken;
|
|
1120
2152
|
}
|
|
1121
2153
|
/**
|
|
@@ -1276,19 +2308,23 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1276
2308
|
return header;
|
|
1277
2309
|
}
|
|
1278
2310
|
/**
|
|
1279
|
-
* Helper function to create OAuth error responses
|
|
1280
|
-
*
|
|
1281
|
-
*
|
|
1282
|
-
*
|
|
1283
|
-
*
|
|
1284
|
-
*
|
|
2311
|
+
* Helper function to create OAuth error responses.
|
|
2312
|
+
*
|
|
2313
|
+
* `internal` (optional) carries a tagged, server-side-only reason. It is
|
|
2314
|
+
* forwarded to the deployer's `onError` hook but never placed on the wire,
|
|
2315
|
+
* so the public response stays RFC-compliant and free of information leak
|
|
2316
|
+
* while the deployer can still observe which check failed.
|
|
1285
2317
|
*/
|
|
1286
|
-
createErrorResponse(code,
|
|
2318
|
+
createErrorResponse(code, options, internal) {
|
|
2319
|
+
const { description } = options;
|
|
2320
|
+
const responseStatus = options.statusCode ?? 400;
|
|
2321
|
+
const responseHeaders = options.headers ?? {};
|
|
1287
2322
|
const customErrorResponse = this.options.onError?.({
|
|
1288
2323
|
code,
|
|
1289
2324
|
description,
|
|
1290
|
-
status,
|
|
1291
|
-
headers
|
|
2325
|
+
status: responseStatus,
|
|
2326
|
+
headers: responseHeaders,
|
|
2327
|
+
...internal ? { internal } : {}
|
|
1292
2328
|
});
|
|
1293
2329
|
if (customErrorResponse) return customErrorResponse;
|
|
1294
2330
|
const body = JSON.stringify({
|
|
@@ -1296,23 +2332,66 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1296
2332
|
error_description: description
|
|
1297
2333
|
});
|
|
1298
2334
|
return new Response(body, {
|
|
1299
|
-
status,
|
|
2335
|
+
status: responseStatus,
|
|
1300
2336
|
headers: {
|
|
1301
2337
|
"Content-Type": "application/json",
|
|
1302
|
-
...
|
|
2338
|
+
...responseHeaders
|
|
1303
2339
|
}
|
|
1304
2340
|
});
|
|
1305
2341
|
}
|
|
1306
2342
|
};
|
|
1307
2343
|
/**
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
2344
|
+
* Structured OAuth 2.0 error.
|
|
2345
|
+
*
|
|
2346
|
+
* Throw from a `tokenExchangeCallback` (or any code it calls — the error
|
|
2347
|
+
* propagates naturally up through deep call stacks) to surface a standard
|
|
2348
|
+
* `/token` error response (`{ error, error_description }`) instead of a
|
|
2349
|
+
* generic `500 Internal Server Error`.
|
|
2350
|
+
*
|
|
2351
|
+
* Anything thrown that is **not** an `OAuthError` continues to surface as
|
|
2352
|
+
* a 500 so unexpected failures remain visible — the provider does not
|
|
2353
|
+
* catch-everything-and-return-400.
|
|
2354
|
+
*
|
|
2355
|
+
* @example
|
|
2356
|
+
* ```ts
|
|
2357
|
+
* import { OAuthError } from '@cloudflare/workers-oauth-provider';
|
|
2358
|
+
*
|
|
2359
|
+
* tokenExchangeCallback: async (options) => {
|
|
2360
|
+
* if (options.grantType === 'refresh_token') {
|
|
2361
|
+
* // refreshUpstream() may throw OAuthError from any depth
|
|
2362
|
+
* return { newProps: await refreshUpstream(options.props) };
|
|
2363
|
+
* }
|
|
2364
|
+
* }
|
|
2365
|
+
*
|
|
2366
|
+
* async function refreshUpstream(props) {
|
|
2367
|
+
* const res = await fetch(...);
|
|
2368
|
+
* if (res.status === 401) {
|
|
2369
|
+
* throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
|
|
2370
|
+
* }
|
|
2371
|
+
* if (res.status === 429) {
|
|
2372
|
+
* // Mirror upstream's Retry-After if present, otherwise pick a default.
|
|
2373
|
+
* throw new OAuthError('temporarily_unavailable', {
|
|
2374
|
+
* description: 'upstream rate limited',
|
|
2375
|
+
* statusCode: 429,
|
|
2376
|
+
* headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
|
|
2377
|
+
* });
|
|
2378
|
+
* }
|
|
2379
|
+
* return await res.json();
|
|
2380
|
+
* }
|
|
2381
|
+
* ```
|
|
1310
2382
|
*/
|
|
1311
2383
|
var OAuthError = class extends Error {
|
|
1312
|
-
constructor(code,
|
|
1313
|
-
super(
|
|
1314
|
-
this.code = code;
|
|
2384
|
+
constructor(code, options) {
|
|
2385
|
+
super(options.description);
|
|
1315
2386
|
this.name = "OAuthError";
|
|
2387
|
+
this.code = code;
|
|
2388
|
+
this.options = {
|
|
2389
|
+
...options,
|
|
2390
|
+
statusCode: options.statusCode ?? 400
|
|
2391
|
+
};
|
|
2392
|
+
this.description = this.options.description;
|
|
2393
|
+
this.statusCode = this.options.statusCode;
|
|
2394
|
+
this.headers = this.options.headers;
|
|
1316
2395
|
}
|
|
1317
2396
|
};
|
|
1318
2397
|
/**
|
|
@@ -1337,6 +2416,10 @@ const DEFAULT_PURGE_BATCH_SIZE = 50;
|
|
|
1337
2416
|
*/
|
|
1338
2417
|
const TOKEN_LENGTH = 32;
|
|
1339
2418
|
/**
|
|
2419
|
+
* RFC 6749 Section 3.3 scope-token grammar.
|
|
2420
|
+
*/
|
|
2421
|
+
const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
|
|
2422
|
+
/**
|
|
1340
2423
|
* Validates a resource URI per RFC 8707 Section 2
|
|
1341
2424
|
* @param uri - The URI string to validate
|
|
1342
2425
|
* @returns true if valid, false otherwise
|
|
@@ -1498,6 +2581,59 @@ function base64UrlEncode(str) {
|
|
|
1498
2581
|
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
1499
2582
|
}
|
|
1500
2583
|
/**
|
|
2584
|
+
* Decodes a base64url-encoded string to bytes.
|
|
2585
|
+
*/
|
|
2586
|
+
function base64UrlToBytes(base64Url) {
|
|
2587
|
+
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
|
2588
|
+
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
2589
|
+
const binaryString = atob(padded);
|
|
2590
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
2591
|
+
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
|
|
2592
|
+
return bytes;
|
|
2593
|
+
}
|
|
2594
|
+
/**
|
|
2595
|
+
* Parses a base64url-encoded JWT JSON part into an object.
|
|
2596
|
+
*/
|
|
2597
|
+
function parseJwtJsonPart(encoded) {
|
|
2598
|
+
try {
|
|
2599
|
+
const json = new TextDecoder().decode(base64UrlToBytes(encoded));
|
|
2600
|
+
const parsed = JSON.parse(json);
|
|
2601
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JWT part must be an object");
|
|
2602
|
+
return parsed;
|
|
2603
|
+
} catch {
|
|
2604
|
+
throw new Error("Malformed JWT part");
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
function isValidOAuthScopeToken(scopeToken) {
|
|
2608
|
+
return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
|
|
2609
|
+
}
|
|
2610
|
+
/**
|
|
2611
|
+
* Gets WebCrypto import and verify parameters for supported JOSE algorithms.
|
|
2612
|
+
*/
|
|
2613
|
+
function getJwtCryptoAlgorithms(alg) {
|
|
2614
|
+
if (alg === "RS256") {
|
|
2615
|
+
const algorithm = {
|
|
2616
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
2617
|
+
hash: "SHA-256"
|
|
2618
|
+
};
|
|
2619
|
+
return {
|
|
2620
|
+
importAlgorithm: algorithm,
|
|
2621
|
+
verifyAlgorithm: algorithm
|
|
2622
|
+
};
|
|
2623
|
+
}
|
|
2624
|
+
if (alg === "ES256") return {
|
|
2625
|
+
importAlgorithm: {
|
|
2626
|
+
name: "ECDSA",
|
|
2627
|
+
namedCurve: "P-256"
|
|
2628
|
+
},
|
|
2629
|
+
verifyAlgorithm: {
|
|
2630
|
+
name: "ECDSA",
|
|
2631
|
+
hash: "SHA-256"
|
|
2632
|
+
}
|
|
2633
|
+
};
|
|
2634
|
+
throw new Error(`Unsupported JWT alg: ${alg}`);
|
|
2635
|
+
}
|
|
2636
|
+
/**
|
|
1501
2637
|
* Encodes an ArrayBuffer as base64 string
|
|
1502
2638
|
* @param buffer - The ArrayBuffer to encode
|
|
1503
2639
|
* @returns The base64 encoded string
|
|
@@ -2075,4 +3211,4 @@ var OAuthHelpersImpl = class {
|
|
|
2075
3211
|
var oauth_provider_default = OAuthProvider;
|
|
2076
3212
|
|
|
2077
3213
|
//#endregion
|
|
2078
|
-
export { GrantType, OAuthProvider, oauth_provider_default as default, getOAuthApi };
|
|
3214
|
+
export { GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
|