@cloudflare/workers-oauth-provider 1.1.0 → 1.2.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 +35 -484
- package/dist/oauth-provider.d.ts +294 -179
- package/dist/oauth-provider.js +717 -501
- package/docs/advanced-configuration.md +14 -7
- package/docs/authorization-server.md +232 -0
- package/docs/consent-page.md +14 -19
- package/docs/mcp-discovery.md +78 -0
- package/docs/migration-1.0.md +248 -43
- package/docs/resource-servers.md +27 -4
- package/docs/upstream-sign-in.md +2 -7
- package/package.json +3 -1
- package/skills/migrate-to-1.0/SKILL.md +38 -24
package/dist/oauth-provider.js
CHANGED
|
@@ -3,6 +3,34 @@ import { WorkerEntrypoint } from "cloudflare:workers";
|
|
|
3
3
|
//#region src/oauth-capabilities.ts
|
|
4
4
|
const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
|
|
5
5
|
/**
|
|
6
|
+
* The OAuth error redirect back to a client (RFC 6749 §4.1.2.1): `error`, an optional
|
|
7
|
+
* `error_description`, the client's `state`, and `iss` (RFC 9207). Pass only a request the
|
|
8
|
+
* library validated, from `parseAuthRequest()`, `finishUpstream()` or `approveConsent()`,
|
|
9
|
+
* never one rebuilt from user input.
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* if (new URL(req.url).searchParams.get('error')) {
|
|
13
|
+
* return Response.redirect(authorizationErrorRedirect(original, 'access_denied'), 302);
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
function authorizationErrorRedirect(request, code, description) {
|
|
18
|
+
const redirect = new URL(request.redirectUri);
|
|
19
|
+
for (const key of ERROR_RESPONSE_PARAMETERS) redirect.searchParams.delete(key);
|
|
20
|
+
redirect.searchParams.set("error", code);
|
|
21
|
+
if (description) redirect.searchParams.set("error_description", description);
|
|
22
|
+
if (request.state) redirect.searchParams.set("state", request.state);
|
|
23
|
+
if (request.issuer) redirect.searchParams.set("iss", request.issuer);
|
|
24
|
+
return redirect.href;
|
|
25
|
+
}
|
|
26
|
+
const ERROR_RESPONSE_PARAMETERS = [
|
|
27
|
+
"error",
|
|
28
|
+
"error_description",
|
|
29
|
+
"error_uri",
|
|
30
|
+
"state",
|
|
31
|
+
"iss"
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
6
34
|
* Expected authorization-request validation failure. Absence of `redirectUri`
|
|
7
35
|
* means a caller MUST render locally and MUST NOT redirect.
|
|
8
36
|
*/
|
|
@@ -15,8 +43,21 @@ var AuthorizationError = class extends Error {
|
|
|
15
43
|
this.redirectUri = options.redirectUri;
|
|
16
44
|
this.state = options.state;
|
|
17
45
|
this.issuer = options.issuer;
|
|
46
|
+
this.redirectTo = options.redirectUri ? safeErrorRedirect(code, options) : void 0;
|
|
18
47
|
}
|
|
19
48
|
};
|
|
49
|
+
/** The error redirect, or undefined when the registered URI can't be parsed: render locally then. */
|
|
50
|
+
function safeErrorRedirect(code, options) {
|
|
51
|
+
try {
|
|
52
|
+
return authorizationErrorRedirect({
|
|
53
|
+
redirectUri: options.redirectUri,
|
|
54
|
+
state: options.state,
|
|
55
|
+
issuer: options.issuer
|
|
56
|
+
}, code, options.description);
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
20
61
|
/** @internal Attach context after exact client redirect validation succeeds. */
|
|
21
62
|
function withAuthorizationRedirect(error, redirectUri, state, issuer) {
|
|
22
63
|
return new AuthorizationError(error.code, {
|
|
@@ -31,17 +72,17 @@ function buildOAuthServerCapabilities(options) {
|
|
|
31
72
|
grantTypes: [
|
|
32
73
|
"authorization_code",
|
|
33
74
|
"refresh_token",
|
|
34
|
-
...options.allowImplicitFlow ? ["implicit"] : [],
|
|
35
75
|
...options.allowTokenExchangeGrant ? ["urn:ietf:params:oauth:grant-type:token-exchange"] : [],
|
|
36
76
|
...options.enterpriseManagedAuthorization ? ["urn:ietf:params:oauth:grant-type:jwt-bearer"] : []
|
|
37
77
|
],
|
|
38
|
-
responseTypes:
|
|
78
|
+
responseTypes: ["code"],
|
|
39
79
|
tokenEndpointAuthMethods: [
|
|
40
80
|
"client_secret_basic",
|
|
41
81
|
"client_secret_post",
|
|
42
82
|
"none"
|
|
43
83
|
],
|
|
44
|
-
codeChallengeMethods:
|
|
84
|
+
codeChallengeMethods: ["S256"],
|
|
85
|
+
allowPrivateUseRedirectUris: options.allowPrivateUseRedirectUris
|
|
45
86
|
};
|
|
46
87
|
}
|
|
47
88
|
function validateClientCapabilities(server, client) {
|
|
@@ -51,7 +92,6 @@ function validateClientCapabilities(server, client) {
|
|
|
51
92
|
const unsupportedResponse = client.responseTypes.find((responseType) => !server.responseTypes.includes(responseType));
|
|
52
93
|
if (unsupportedResponse) throw new Error(`Unsupported response_type: ${unsupportedResponse}`);
|
|
53
94
|
if (client.grantTypes.includes("authorization_code") !== client.responseTypes.includes("code")) throw new Error("grant_types authorization_code and response_types code must be registered together");
|
|
54
|
-
if (client.grantTypes.includes("implicit") !== client.responseTypes.includes("token")) throw new Error("grant_types implicit and response_types token must be registered together");
|
|
55
95
|
}
|
|
56
96
|
const SHARED_SECRET_TOKEN_ENDPOINT_AUTH_METHODS = new Set([
|
|
57
97
|
"client_secret_basic",
|
|
@@ -150,6 +190,212 @@ function isValidOAuthScopeToken(scopeToken) {
|
|
|
150
190
|
return OAUTH_SCOPE_TOKEN_PATTERN.test(scopeToken);
|
|
151
191
|
}
|
|
152
192
|
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/oauth-http.ts
|
|
195
|
+
/**
|
|
196
|
+
* HTTP details shared by the authorization-server host (`OAuthProvider`, `OAuthAuthorizationServer`)
|
|
197
|
+
* and the resource-server host (`OAuthResourceServer`), so the two can't drift apart.
|
|
198
|
+
*/
|
|
199
|
+
/**
|
|
200
|
+
* Adds CORS headers for a browser client. The request's `Origin` is reflected: bearer tokens are
|
|
201
|
+
* not ambient credentials, so any origin may present one it holds. Browser OAuth and MCP clients
|
|
202
|
+
* need `WWW-Authenticate` (discovery, step-up) and `Retry-After` (backoff) exposed. Headers the
|
|
203
|
+
* response already carries are kept, so an API handler can narrow who may read its responses.
|
|
204
|
+
* Preflights (`OPTIONS`) are answered by the hosts before any handler runs and stay permissive, so
|
|
205
|
+
* an MCP client's preflight always succeeds; the browser enforces the handler's policy on the actual
|
|
206
|
+
* response, and sending the request carries no ambient credential to abuse.
|
|
207
|
+
*/
|
|
208
|
+
function withCorsHeaders(response, request) {
|
|
209
|
+
const origin = request.headers.get("Origin");
|
|
210
|
+
if (!origin) return response;
|
|
211
|
+
const cors = new Response(response.body, response);
|
|
212
|
+
const setUnlessPresent = (name, value) => {
|
|
213
|
+
if (!cors.headers.has(name)) cors.headers.set(name, value);
|
|
214
|
+
};
|
|
215
|
+
setUnlessPresent("Access-Control-Allow-Origin", origin);
|
|
216
|
+
setUnlessPresent("Access-Control-Allow-Methods", "*");
|
|
217
|
+
setUnlessPresent("Access-Control-Allow-Headers", "Authorization, *");
|
|
218
|
+
setUnlessPresent("Access-Control-Max-Age", "86400");
|
|
219
|
+
appendHeaderValue(cors.headers, "Vary", "Origin");
|
|
220
|
+
appendHeaderValue(cors.headers, "Access-Control-Expose-Headers", "WWW-Authenticate");
|
|
221
|
+
appendHeaderValue(cors.headers, "Access-Control-Expose-Headers", "Retry-After");
|
|
222
|
+
return cors;
|
|
223
|
+
}
|
|
224
|
+
/** Adds `value` to a comma-separated header unless it's already there (case-insensitively). */
|
|
225
|
+
function appendHeaderValue(headers, name, value) {
|
|
226
|
+
const values = (headers.get(name) ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
227
|
+
if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
|
|
228
|
+
headers.set(name, values.join(", "));
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* A resource's required scopes: `requiredScopes`, or the deprecated `resourceMetadata.scopes_supported`
|
|
232
|
+
* it replaces. Both at once is ambiguous and refused.
|
|
233
|
+
*/
|
|
234
|
+
function resolveRequiredScopes(requiredScopes, deprecatedScopesSupported) {
|
|
235
|
+
if (requiredScopes !== void 0 && deprecatedScopesSupported !== void 0) throw new TypeError("Set requiredScopes only: resourceMetadata.scopes_supported is deprecated in its favour");
|
|
236
|
+
const scopes = requiredScopes ?? deprecatedScopesSupported;
|
|
237
|
+
return scopes === void 0 ? void 0 : [...scopes];
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The scopes a protected resource advertises and challenges with: deduplicated, and without
|
|
241
|
+
* `offline_access`, which is an authorization-server capability rather than a resource requirement.
|
|
242
|
+
*/
|
|
243
|
+
function baselineResourceScopes(scopes) {
|
|
244
|
+
return [...new Set(scopes)].filter((scope) => scope !== "offline_access");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/oauth-error.ts
|
|
249
|
+
/**
|
|
250
|
+
* The OAuth error your code throws to the library, to answer with a standard OAuth error instead
|
|
251
|
+
* of a generic failure. It is honoured in two places:
|
|
252
|
+
*
|
|
253
|
+
* - **`tokenExchangeCallback`** (token endpoint): the response is `{ error, error_description }`
|
|
254
|
+
* with `statusCode` (default `400`) and `headers`. `invalid_grant` also revokes the grant.
|
|
255
|
+
* Anything else thrown is a `500`.
|
|
256
|
+
* - **`OAuthResourceServer`'s `validateToken`**: `invalid_token` is a `401` and
|
|
257
|
+
* `insufficient_scope` a `403`, each with a Bearer challenge (the latter naming
|
|
258
|
+
* `requiredScopes`); any other code keeps `statusCode` and `headers`, such as `429` with
|
|
259
|
+
* `Retry-After`. Anything else thrown is a `503`.
|
|
260
|
+
*
|
|
261
|
+
* Unexpected failures stay visible that way: the library doesn't catch everything and return 400.
|
|
262
|
+
* (`OAuthProvider`'s `resolveExternalToken` uses `ExternalTokenError` instead.)
|
|
263
|
+
*
|
|
264
|
+
* @example
|
|
265
|
+
* ```ts
|
|
266
|
+
* import { OAuthError } from '@cloudflare/workers-oauth-provider';
|
|
267
|
+
*
|
|
268
|
+
* tokenExchangeCallback: async (options) => {
|
|
269
|
+
* if (options.grantType === 'refresh_token') {
|
|
270
|
+
* // refreshUpstream() may throw OAuthError from any depth
|
|
271
|
+
* return { newProps: await refreshUpstream(options.props) };
|
|
272
|
+
* }
|
|
273
|
+
* }
|
|
274
|
+
*
|
|
275
|
+
* async function refreshUpstream(props) {
|
|
276
|
+
* const res = await fetch(...);
|
|
277
|
+
* if (res.status === 401) {
|
|
278
|
+
* // invalid_grant can never recover: the provider also revokes this grant and its tokens.
|
|
279
|
+
* throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
|
|
280
|
+
* }
|
|
281
|
+
* if (res.status === 429) {
|
|
282
|
+
* // Mirror upstream's Retry-After if present, otherwise pick a default.
|
|
283
|
+
* throw new OAuthError('temporarily_unavailable', {
|
|
284
|
+
* description: 'upstream rate limited',
|
|
285
|
+
* statusCode: 429,
|
|
286
|
+
* headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
|
|
287
|
+
* });
|
|
288
|
+
* }
|
|
289
|
+
* return await res.json();
|
|
290
|
+
* }
|
|
291
|
+
* ```
|
|
292
|
+
*/
|
|
293
|
+
var OAuthError = class extends Error {
|
|
294
|
+
constructor(code, options) {
|
|
295
|
+
super(options.description);
|
|
296
|
+
this.name = "OAuthError";
|
|
297
|
+
this.code = code;
|
|
298
|
+
this.options = {
|
|
299
|
+
...options,
|
|
300
|
+
statusCode: options.statusCode ?? 400
|
|
301
|
+
};
|
|
302
|
+
this.description = this.options.description;
|
|
303
|
+
this.statusCode = this.options.statusCode;
|
|
304
|
+
this.headers = this.options.headers;
|
|
305
|
+
this.requiredScopes = this.options.requiredScopes;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
//#endregion
|
|
310
|
+
//#region src/oauth-resource.ts
|
|
311
|
+
/**
|
|
312
|
+
* Loopback hosts where a plain `http` scheme is accepted for local development.
|
|
313
|
+
* Matches RFC 8252 §7.3 loopback handling: 127.0.0.0/8, ::1, and `localhost`.
|
|
314
|
+
*/
|
|
315
|
+
function isLoopbackHostname(hostname) {
|
|
316
|
+
const host = hostname.toLowerCase();
|
|
317
|
+
if (host === "localhost" || host === "::1" || host === "[::1]") return true;
|
|
318
|
+
const ipv4 = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
319
|
+
return ipv4 !== null && ipv4.slice(1).every((octet) => Number(octet) <= 255);
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Whether a canonical resource, issuer, or endpoint URL uses an accepted
|
|
323
|
+
* scheme: `https`, or `http` on a loopback host so `wrangler dev` works on
|
|
324
|
+
* http://localhost. Workers are always served over `https`, so there is no
|
|
325
|
+
* deployment in which cleartext OAuth traffic to a remote host is correct,
|
|
326
|
+
* and no option exists to allow it.
|
|
327
|
+
*/
|
|
328
|
+
function hasAcceptedCanonicalScheme(url) {
|
|
329
|
+
return url.protocol === "https:" || url.protocol === "http:" && isLoopbackHostname(url.hostname);
|
|
330
|
+
}
|
|
331
|
+
/** Validate an RFC 3986-safe HTTP(S) resource identifier for RFC 8707. */
|
|
332
|
+
function validateResourceUri(uri) {
|
|
333
|
+
if (!uri || typeof uri !== "string") return false;
|
|
334
|
+
if (!/^[\x21-\x7e]+$/.test(uri) || /["\\#]/.test(uri) || !/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]+(?:\/[^?#]*)?(?:\?[^#]*)?$/.test(uri) || /%(?![0-9A-Fa-f]{2})/.test(uri) || !/^[A-Za-z0-9:/?@!$&'()*+,;=._~%\[\]-]+$/.test(uri)) return false;
|
|
335
|
+
try {
|
|
336
|
+
const parsed = new URL(uri);
|
|
337
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Whether a requested resource identifies a granted or configured resource.
|
|
344
|
+
* Only ASCII case in the URI scheme and host is ignored, and an empty path is
|
|
345
|
+
* equivalent to `/` (RFC 3986 §6.2.3), so a client that round-trips
|
|
346
|
+
* `https://example.com` through a URL parser as `https://example.com/` still
|
|
347
|
+
* names it. Port, path, query, a trailing slash after a path segment, user
|
|
348
|
+
* information, and every other byte remain significant.
|
|
349
|
+
*/
|
|
350
|
+
function resourceMatches(requested, granted) {
|
|
351
|
+
const foldedRequested = foldResourceSchemeAndHost(requested);
|
|
352
|
+
const foldedGranted = foldResourceSchemeAndHost(granted);
|
|
353
|
+
if (foldedRequested === void 0 || foldedGranted === void 0) return false;
|
|
354
|
+
return normalizeEmptyPath(foldedRequested) === normalizeEmptyPath(foldedGranted);
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Fold only the URI components whose comparison is ASCII case-insensitive: the
|
|
358
|
+
* scheme and the host. A configured identifier is canonical when folding it is
|
|
359
|
+
* the identity.
|
|
360
|
+
*/
|
|
361
|
+
function foldResourceSchemeAndHost(resource) {
|
|
362
|
+
const schemeSeparator = resource.indexOf("://");
|
|
363
|
+
if (schemeSeparator <= 0) return void 0;
|
|
364
|
+
const authorityStart = schemeSeparator + 3;
|
|
365
|
+
const authorityEndOffset = resource.slice(authorityStart).search(/[/?#]/);
|
|
366
|
+
const authorityEnd = authorityEndOffset === -1 ? resource.length : authorityStart + authorityEndOffset;
|
|
367
|
+
const authority = resource.slice(authorityStart, authorityEnd);
|
|
368
|
+
const hostStart = authority.lastIndexOf("@") + 1;
|
|
369
|
+
let hostEnd;
|
|
370
|
+
if (authority[hostStart] === "[") {
|
|
371
|
+
const closingBracket = authority.indexOf("]", hostStart + 1);
|
|
372
|
+
if (closingBracket === -1) return void 0;
|
|
373
|
+
hostEnd = closingBracket + 1;
|
|
374
|
+
} else {
|
|
375
|
+
const portSeparator = authority.indexOf(":", hostStart);
|
|
376
|
+
hostEnd = portSeparator === -1 ? authority.length : portSeparator;
|
|
377
|
+
}
|
|
378
|
+
const asciiLower = (value) => value.replace(/[A-Z]/g, (character) => character.toLowerCase());
|
|
379
|
+
return asciiLower(resource.slice(0, schemeSeparator)) + "://" + authority.slice(0, hostStart) + asciiLower(authority.slice(hostStart, hostEnd)) + authority.slice(hostEnd) + resource.slice(authorityEnd);
|
|
380
|
+
}
|
|
381
|
+
/** RFC 3986 §6.2.3: for http and https an empty path is equivalent to "/". */
|
|
382
|
+
function normalizeEmptyPath(resource) {
|
|
383
|
+
const authorityStart = resource.indexOf("://") + 3;
|
|
384
|
+
const pathOffset = resource.slice(authorityStart).search(/[/?#]/);
|
|
385
|
+
if (pathOffset === -1) return `${resource}/`;
|
|
386
|
+
const pathStart = authorityStart + pathOffset;
|
|
387
|
+
return resource[pathStart] === "/" ? resource : `${resource.slice(0, pathStart)}/${resource.slice(pathStart)}`;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Whether a request carries every query parameter of a canonical resource. A
|
|
391
|
+
* query-bearing resource identifier names a more specific resource: a request
|
|
392
|
+
* may add parameters of its own but must preserve the resource's.
|
|
393
|
+
*/
|
|
394
|
+
function requestCarriesResourceQuery(requestUrl, resourceUrl) {
|
|
395
|
+
for (const [name, value] of resourceUrl.searchParams) if (!requestUrl.searchParams.getAll(name).includes(value)) return false;
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
153
399
|
//#endregion
|
|
154
400
|
//#region src/oauth-client-metadata.ts
|
|
155
401
|
const CIMD_MAX_SIZE_BYTES = 5 * 1024;
|
|
@@ -245,10 +491,12 @@ function pickDisplayMetadata(metadata) {
|
|
|
245
491
|
};
|
|
246
492
|
}
|
|
247
493
|
/**
|
|
248
|
-
*
|
|
249
|
-
*
|
|
494
|
+
* The checks every listed redirect URI passes, whatever the policy: no control characters, a
|
|
495
|
+
* scheme, and not a dangerous one (`javascript:`, `data:` and the like). A Client ID Metadata
|
|
496
|
+
* Document is held to these alone; the URI a request uses is then held to {@link validateRedirectUri}.
|
|
497
|
+
* @throws Error when the redirect URI is not safe to store
|
|
250
498
|
*/
|
|
251
|
-
function
|
|
499
|
+
function validateRedirectUriSafety(redirectUri) {
|
|
252
500
|
const dangerousSchemes = [
|
|
253
501
|
"javascript:",
|
|
254
502
|
"data:",
|
|
@@ -267,9 +515,34 @@ function validateRedirectUriScheme(redirectUri) {
|
|
|
267
515
|
const scheme = normalized.slice(0, colonIndex + 1).toLowerCase();
|
|
268
516
|
if (dangerousSchemes.includes(scheme)) throw new Error("Invalid redirect URI");
|
|
269
517
|
}
|
|
270
|
-
|
|
518
|
+
/**
|
|
519
|
+
* Validates a redirect URI against the MCP / OAuth 2.1 redirect policy: `https`, or `http` on a
|
|
520
|
+
* loopback host (`localhost`, `127.0.0.0/8`, `::1`), with no userinfo and no fragment (not even an
|
|
521
|
+
* empty `#`). RFC 8252 private-use schemes are accepted only when the server enables
|
|
522
|
+
* `allowPrivateUseRedirectUris`; remote `http` never is. The {@link validateRedirectUriSafety}
|
|
523
|
+
* checks always apply.
|
|
524
|
+
* @throws Error describing why the redirect URI is not acceptable
|
|
525
|
+
*/
|
|
526
|
+
function validateRedirectUri(redirectUri, server) {
|
|
527
|
+
validateRedirectUriSafety(redirectUri);
|
|
528
|
+
const normalized = redirectUri.trim();
|
|
529
|
+
let url;
|
|
530
|
+
try {
|
|
531
|
+
url = new URL(normalized);
|
|
532
|
+
} catch {
|
|
533
|
+
throw new Error("Invalid redirect URI");
|
|
534
|
+
}
|
|
535
|
+
if (normalized.includes("#") || url.username || url.password) throw new Error("Invalid redirect URI");
|
|
536
|
+
if (url.protocol === "https:") return;
|
|
537
|
+
if (url.protocol === "http:") {
|
|
538
|
+
if (isLoopbackHostname(url.hostname)) return;
|
|
539
|
+
throw new Error("Redirect URI must use https, or http on a loopback host");
|
|
540
|
+
}
|
|
541
|
+
if (!server.allowPrivateUseRedirectUris) throw new Error("Redirect URI must use https, or http on a loopback host");
|
|
542
|
+
}
|
|
543
|
+
function requireValidRedirectUris(redirectUris, validate) {
|
|
271
544
|
if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
|
|
272
|
-
for (const redirectUri of redirectUris)
|
|
545
|
+
for (const redirectUri of redirectUris) validate(redirectUri);
|
|
273
546
|
return redirectUris;
|
|
274
547
|
}
|
|
275
548
|
/**
|
|
@@ -287,7 +560,7 @@ function resolveDynamicClientRegistrationMetadata(raw, server) {
|
|
|
287
560
|
});
|
|
288
561
|
return {
|
|
289
562
|
...pickDisplayMetadata(metadata),
|
|
290
|
-
redirectUris: requireValidRedirectUris(metadata.redirectUris),
|
|
563
|
+
redirectUris: requireValidRedirectUris(metadata.redirectUris, (uri) => validateRedirectUri(uri, server)),
|
|
291
564
|
...capabilities,
|
|
292
565
|
authMethodExplicit: metadata.tokenEndpointAuthMethod !== void 0 || metadata.tokenEndpointAuthMethodsSupported !== void 0
|
|
293
566
|
};
|
|
@@ -366,7 +639,7 @@ function resolveClientIdMetadataDocument(metadataUrl, value, server) {
|
|
|
366
639
|
const metadata = parseOAuthClientMetadata(raw);
|
|
367
640
|
if (metadata.clientId !== metadataUrl) throw new Error(`client_id "${metadata.clientId}" does not match metadata URL "${metadataUrl}"`);
|
|
368
641
|
if (!metadata.clientName?.trim()) throw new Error("client_name is required and must not be empty");
|
|
369
|
-
const redirectUris = requireValidRedirectUris(metadata.redirectUris);
|
|
642
|
+
const redirectUris = requireValidRedirectUris(metadata.redirectUris, validateRedirectUriSafety);
|
|
370
643
|
if ("client_secret" in raw || "client_secret_expires_at" in raw) throw new Error("CIMD documents must not contain client secrets");
|
|
371
644
|
if (containsPrivateJwkMaterial(raw.jwks)) throw new Error("CIMD documents must not contain private key material");
|
|
372
645
|
const capabilities = negotiateCimdClientCapabilities(server, {
|
|
@@ -850,16 +1123,34 @@ function parseIdJag(assertion, maxBytes) {
|
|
|
850
1123
|
signature
|
|
851
1124
|
});
|
|
852
1125
|
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Decodes a base64url-encoded string to bytes.
|
|
1128
|
+
*/
|
|
1129
|
+
function base64UrlToBytes(base64Url) {
|
|
1130
|
+
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
|
1131
|
+
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
1132
|
+
const binaryString = atob(padded);
|
|
1133
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
1134
|
+
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
|
|
1135
|
+
return bytes;
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Parses a base64url-encoded JWT JSON part into an object.
|
|
1139
|
+
*/
|
|
1140
|
+
function parseJwtJsonPart(encoded) {
|
|
1141
|
+
try {
|
|
1142
|
+
const json = new TextDecoder().decode(base64UrlToBytes(encoded));
|
|
1143
|
+
const parsed = JSON.parse(json);
|
|
1144
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JWT part must be an object");
|
|
1145
|
+
return parsed;
|
|
1146
|
+
} catch {
|
|
1147
|
+
throw new Error("Malformed JWT part");
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
853
1150
|
|
|
854
1151
|
//#endregion
|
|
855
1152
|
//#region src/ema/signature.ts
|
|
856
1153
|
/**
|
|
857
|
-
* JWK selection and ID-JAG signature verification.
|
|
858
|
-
*
|
|
859
|
-
* `selectJwk` is a pure picker; `verifyIdJagSignature` is the I/O-bearing
|
|
860
|
-
* WebCrypto call. Both operate over an already-fetched JWKS.
|
|
861
|
-
*/
|
|
862
|
-
/**
|
|
863
1154
|
* Pick a signing key from a JWKS that matches the assertion header.
|
|
864
1155
|
*
|
|
865
1156
|
* Filters by:
|
|
@@ -910,95 +1201,31 @@ async function verifyIdJagSignature(input) {
|
|
|
910
1201
|
return false;
|
|
911
1202
|
}
|
|
912
1203
|
}
|
|
913
|
-
|
|
914
|
-
//#endregion
|
|
915
|
-
//#region src/oauth-resource.ts
|
|
916
|
-
/**
|
|
917
|
-
* Loopback hosts where a plain `http` scheme is accepted for local development.
|
|
918
|
-
* Matches RFC 8252 §7.3 loopback handling: 127.0.0.0/8, ::1, and `localhost`.
|
|
919
|
-
*/
|
|
920
|
-
function isLoopbackHostname(hostname) {
|
|
921
|
-
const host = hostname.toLowerCase();
|
|
922
|
-
if (host === "localhost" || host === "::1" || host === "[::1]") return true;
|
|
923
|
-
const ipv4 = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
|
|
924
|
-
return ipv4 !== null && ipv4.slice(1).every((octet) => Number(octet) <= 255);
|
|
925
|
-
}
|
|
926
|
-
/**
|
|
927
|
-
* Whether a canonical resource, issuer, or endpoint URL uses an accepted
|
|
928
|
-
* scheme: `https`, or `http` on a loopback host so `wrangler dev` works on
|
|
929
|
-
* http://localhost. Workers are always served over `https`, so there is no
|
|
930
|
-
* deployment in which cleartext OAuth traffic to a remote host is correct,
|
|
931
|
-
* and no option exists to allow it.
|
|
932
|
-
*/
|
|
933
|
-
function hasAcceptedCanonicalScheme(url) {
|
|
934
|
-
return url.protocol === "https:" || url.protocol === "http:" && isLoopbackHostname(url.hostname);
|
|
935
|
-
}
|
|
936
|
-
/** Validate an RFC 3986-safe HTTP(S) resource identifier for RFC 8707. */
|
|
937
|
-
function validateResourceUri(uri) {
|
|
938
|
-
if (!uri || typeof uri !== "string") return false;
|
|
939
|
-
if (!/^[\x21-\x7e]+$/.test(uri) || /["\\#]/.test(uri) || !/^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^/?#]+(?:\/[^?#]*)?(?:\?[^#]*)?$/.test(uri) || /%(?![0-9A-Fa-f]{2})/.test(uri) || !/^[A-Za-z0-9:/?@!$&'()*+,;=._~%\[\]-]+$/.test(uri)) return false;
|
|
940
|
-
try {
|
|
941
|
-
const parsed = new URL(uri);
|
|
942
|
-
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
943
|
-
} catch {
|
|
944
|
-
return false;
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
1204
|
/**
|
|
948
|
-
*
|
|
949
|
-
* Only ASCII case in the URI scheme and host is ignored, and an empty path is
|
|
950
|
-
* equivalent to `/` (RFC 3986 §6.2.3), so a client that round-trips
|
|
951
|
-
* `https://example.com` through a URL parser as `https://example.com/` still
|
|
952
|
-
* names it. Port, path, query, a trailing slash after a path segment, user
|
|
953
|
-
* information, and every other byte remain significant.
|
|
954
|
-
*/
|
|
955
|
-
function resourceMatches(requested, granted) {
|
|
956
|
-
const foldedRequested = foldResourceSchemeAndHost(requested);
|
|
957
|
-
const foldedGranted = foldResourceSchemeAndHost(granted);
|
|
958
|
-
if (foldedRequested === void 0 || foldedGranted === void 0) return false;
|
|
959
|
-
return normalizeEmptyPath(foldedRequested) === normalizeEmptyPath(foldedGranted);
|
|
960
|
-
}
|
|
961
|
-
/**
|
|
962
|
-
* Fold only the URI components whose comparison is ASCII case-insensitive: the
|
|
963
|
-
* scheme and the host. A configured identifier is canonical when folding it is
|
|
964
|
-
* the identity.
|
|
1205
|
+
* Gets WebCrypto import and verify parameters for supported JOSE algorithms.
|
|
965
1206
|
*/
|
|
966
|
-
function
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
const closingBracket = authority.indexOf("]", hostStart + 1);
|
|
977
|
-
if (closingBracket === -1) return void 0;
|
|
978
|
-
hostEnd = closingBracket + 1;
|
|
979
|
-
} else {
|
|
980
|
-
const portSeparator = authority.indexOf(":", hostStart);
|
|
981
|
-
hostEnd = portSeparator === -1 ? authority.length : portSeparator;
|
|
1207
|
+
function getJwtCryptoAlgorithms(alg) {
|
|
1208
|
+
if (alg === "RS256") {
|
|
1209
|
+
const algorithm = {
|
|
1210
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
1211
|
+
hash: "SHA-256"
|
|
1212
|
+
};
|
|
1213
|
+
return {
|
|
1214
|
+
importAlgorithm: algorithm,
|
|
1215
|
+
verifyAlgorithm: algorithm
|
|
1216
|
+
};
|
|
982
1217
|
}
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
}
|
|
994
|
-
/**
|
|
995
|
-
* Whether a request carries every query parameter of a canonical resource. A
|
|
996
|
-
* query-bearing resource identifier names a more specific resource: a request
|
|
997
|
-
* may add parameters of its own but must preserve the resource's.
|
|
998
|
-
*/
|
|
999
|
-
function requestCarriesResourceQuery(requestUrl, resourceUrl) {
|
|
1000
|
-
for (const [name, value] of resourceUrl.searchParams) if (!requestUrl.searchParams.getAll(name).includes(value)) return false;
|
|
1001
|
-
return true;
|
|
1218
|
+
if (alg === "ES256") return {
|
|
1219
|
+
importAlgorithm: {
|
|
1220
|
+
name: "ECDSA",
|
|
1221
|
+
namedCurve: "P-256"
|
|
1222
|
+
},
|
|
1223
|
+
verifyAlgorithm: {
|
|
1224
|
+
name: "ECDSA",
|
|
1225
|
+
hash: "SHA-256"
|
|
1226
|
+
}
|
|
1227
|
+
};
|
|
1228
|
+
throw new Error(`Unsupported JWT alg: ${alg}`);
|
|
1002
1229
|
}
|
|
1003
1230
|
|
|
1004
1231
|
//#endregion
|
|
@@ -1336,7 +1563,7 @@ const NO_CACHE_HEADERS$1 = {
|
|
|
1336
1563
|
* ```
|
|
1337
1564
|
*/
|
|
1338
1565
|
function insufficientScope(auth, scope, description) {
|
|
1339
|
-
if (!Array.isArray(scope) || scope.length === 0 || scope.some((value) => !
|
|
1566
|
+
if (!Array.isArray(scope) || scope.length === 0 || scope.some((value) => typeof value !== "string" || !isValidOAuthScopeToken(value))) throw new TypeError("insufficientScope requires at least one valid OAuth scope token");
|
|
1340
1567
|
let challenge = `Bearer realm="OAuth", error="insufficient_scope", scope="${[...new Set(scope)].join(" ")}"`;
|
|
1341
1568
|
challenge += `, resource_metadata="${getResourceMetadataUrl(auth.audience)}"`;
|
|
1342
1569
|
if (description !== void 0) {
|
|
@@ -1376,35 +1603,36 @@ var OAuthResourceServer = class {
|
|
|
1376
1603
|
const validated = this.#validated;
|
|
1377
1604
|
const url = new URL(request.url);
|
|
1378
1605
|
if (isProtectedResourceMetadataPath(url)) {
|
|
1379
|
-
if (request.method === "OPTIONS") return
|
|
1606
|
+
if (request.method === "OPTIONS") return withCorsHeaders(new Response(null, {
|
|
1380
1607
|
status: 204,
|
|
1381
1608
|
headers: { "Content-Length": "0" }
|
|
1382
1609
|
}), request);
|
|
1383
|
-
if (!isMetadataUrlRequest(url, validated.metadataUrl)) return
|
|
1384
|
-
if (request.method !== "GET" && request.method !== "HEAD") return
|
|
1610
|
+
if (!isMetadataUrlRequest(url, validated.metadataUrl)) return withCorsHeaders(new Response(null, { status: 404 }), request);
|
|
1611
|
+
if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
|
|
1385
1612
|
status: 405,
|
|
1386
1613
|
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
1387
1614
|
}), request);
|
|
1388
1615
|
const metadata = Response.json(validated.metadata, { headers: NO_CACHE_HEADERS$1 });
|
|
1389
|
-
return
|
|
1616
|
+
return withCorsHeaders(request.method === "HEAD" ? new Response(null, {
|
|
1390
1617
|
status: 200,
|
|
1391
1618
|
headers: metadata.headers
|
|
1392
1619
|
}) : metadata, request);
|
|
1393
1620
|
}
|
|
1394
1621
|
if (!isCanonicalResourceRequest(url, validated.resourceUrl)) return new Response(null, { status: 404 });
|
|
1395
|
-
if (request.method === "OPTIONS") return
|
|
1622
|
+
if (request.method === "OPTIONS") return withCorsHeaders(new Response(null, {
|
|
1396
1623
|
status: 204,
|
|
1397
1624
|
headers: { "Content-Length": "0" }
|
|
1398
1625
|
}), request);
|
|
1399
1626
|
const token = parseBearerToken(request.headers.get("Authorization"));
|
|
1400
|
-
if (!token) return
|
|
1627
|
+
if (!token) return withCorsHeaders(createBearerChallenge(url, validated, false), request);
|
|
1401
1628
|
let validation;
|
|
1402
1629
|
try {
|
|
1403
1630
|
validation = await options.validateToken(env, request)(validated.resource, token);
|
|
1404
|
-
} catch {
|
|
1405
|
-
return
|
|
1631
|
+
} catch (error) {
|
|
1632
|
+
if (error instanceof OAuthError) return withCorsHeaders(createOAuthErrorResponse(error, url, validated), request);
|
|
1633
|
+
return withCorsHeaders(createValidationUnavailableResponse(), request);
|
|
1406
1634
|
}
|
|
1407
|
-
if (!isValidTokenValidation(validation, validated.resource)) return
|
|
1635
|
+
if (!isValidTokenValidation(validation, validated.resource)) return withCorsHeaders(createBearerChallenge(url, validated, true), request);
|
|
1408
1636
|
const context = ctx;
|
|
1409
1637
|
context.props = validation.props;
|
|
1410
1638
|
context.auth = {
|
|
@@ -1416,7 +1644,7 @@ var OAuthResourceServer = class {
|
|
|
1416
1644
|
...validation.clientId !== void 0 ? { clientId: validation.clientId } : {}
|
|
1417
1645
|
};
|
|
1418
1646
|
const handler = options.handler;
|
|
1419
|
-
return
|
|
1647
|
+
return withCorsHeaders(isEntrypointClass(handler) ? await new handler(context, env).fetch(request) : await handler.fetch(request, env, context), request);
|
|
1420
1648
|
}
|
|
1421
1649
|
};
|
|
1422
1650
|
function validateOptions(options) {
|
|
@@ -1435,9 +1663,9 @@ function validateOptions(options) {
|
|
|
1435
1663
|
}
|
|
1436
1664
|
const bearerMethods = options.resourceMetadata.bearer_methods_supported;
|
|
1437
1665
|
if (bearerMethods !== void 0 && (bearerMethods.length !== 1 || bearerMethods[0] !== "header")) throw new TypeError("resourceMetadata.bearer_methods_supported only supports 'header'");
|
|
1438
|
-
const configuredScopes = options.resourceMetadata.scopes_supported ?? [];
|
|
1439
|
-
if (configuredScopes.some((scope) => !
|
|
1440
|
-
const resourceScopes =
|
|
1666
|
+
const configuredScopes = resolveRequiredScopes(options.requiredScopes, options.resourceMetadata.scopes_supported) ?? [];
|
|
1667
|
+
if (configuredScopes.some((scope) => typeof scope !== "string" || !isValidOAuthScopeToken(scope))) throw new TypeError("requiredScopes (or the deprecated resourceMetadata.scopes_supported) must contain valid OAuth scope tokens");
|
|
1668
|
+
const resourceScopes = baselineResourceScopes(configuredScopes);
|
|
1441
1669
|
return {
|
|
1442
1670
|
resource,
|
|
1443
1671
|
resourceUrl,
|
|
@@ -1463,9 +1691,6 @@ function parseCanonicalUrl(value) {
|
|
|
1463
1691
|
if (parsed.href !== value && parsed.origin !== value) return null;
|
|
1464
1692
|
return parsed;
|
|
1465
1693
|
}
|
|
1466
|
-
function isValidScopeToken(scope) {
|
|
1467
|
-
return typeof scope === "string" && scope.length > 0 && /^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope);
|
|
1468
|
-
}
|
|
1469
1694
|
function getResourceMetadataUrl(resource) {
|
|
1470
1695
|
const parsed = new URL(resource);
|
|
1471
1696
|
const suffix = parsed.pathname === "/" ? "" : parsed.pathname;
|
|
@@ -1504,7 +1729,7 @@ function isValidTokenValidation(validation, canonicalResource) {
|
|
|
1504
1729
|
}
|
|
1505
1730
|
if (validation.scope !== void 0) {
|
|
1506
1731
|
if (!Array.isArray(validation.scope)) return false;
|
|
1507
|
-
if (validation.scope.some((scope) => typeof scope !== "string" || !
|
|
1732
|
+
if (validation.scope.some((scope) => typeof scope !== "string" || !isValidOAuthScopeToken(scope))) return false;
|
|
1508
1733
|
}
|
|
1509
1734
|
if (validation.userId !== void 0 && typeof validation.userId !== "string") return false;
|
|
1510
1735
|
if (validation.clientId !== void 0 && typeof validation.clientId !== "string") return false;
|
|
@@ -1524,30 +1749,44 @@ function createBearerChallenge(requestUrl, validated, invalidToken) {
|
|
|
1524
1749
|
}
|
|
1525
1750
|
});
|
|
1526
1751
|
}
|
|
1752
|
+
/**
|
|
1753
|
+
* A validator's `OAuthError` as the response. RFC 6750 fixes `invalid_token` at `401` and
|
|
1754
|
+
* `insufficient_scope` at `403`, each with a Bearer challenge (unless the error brings its own);
|
|
1755
|
+
* any other code keeps the error's status and headers, such as a `429` with `Retry-After`.
|
|
1756
|
+
*/
|
|
1757
|
+
function createOAuthErrorResponse(error, requestUrl, validated) {
|
|
1758
|
+
const headers = {
|
|
1759
|
+
...NO_CACHE_HEADERS$1,
|
|
1760
|
+
"Content-Type": "application/json",
|
|
1761
|
+
...error.headers
|
|
1762
|
+
};
|
|
1763
|
+
const hasChallenge = Object.keys(headers).some((name) => name.toLowerCase() === "www-authenticate");
|
|
1764
|
+
let status = error.statusCode;
|
|
1765
|
+
if (error.code === "invalid_token") {
|
|
1766
|
+
status = 401;
|
|
1767
|
+
if (!hasChallenge) headers["WWW-Authenticate"] = createBearerChallenge(requestUrl, validated, true).headers.get("WWW-Authenticate");
|
|
1768
|
+
} else if (error.code === "insufficient_scope") {
|
|
1769
|
+
status = 403;
|
|
1770
|
+
if (!hasChallenge) {
|
|
1771
|
+
const scopes = [...new Set(error.requiredScopes ?? validated.metadata.scopes_supported ?? [])];
|
|
1772
|
+
if (scopes.some((scope) => typeof scope !== "string" || !isValidOAuthScopeToken(scope))) return createValidationUnavailableResponse();
|
|
1773
|
+
headers["WWW-Authenticate"] = "Bearer realm=\"OAuth\", error=\"insufficient_scope\"" + (scopes.length > 0 ? `, scope="${scopes.join(" ")}"` : "") + `, resource_metadata="${validated.metadataUrl.href}"`;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
return new Response(JSON.stringify({
|
|
1777
|
+
error: error.code,
|
|
1778
|
+
error_description: error.description
|
|
1779
|
+
}), {
|
|
1780
|
+
status,
|
|
1781
|
+
headers
|
|
1782
|
+
});
|
|
1783
|
+
}
|
|
1527
1784
|
function createValidationUnavailableResponse() {
|
|
1528
1785
|
return new Response(null, {
|
|
1529
1786
|
status: 503,
|
|
1530
1787
|
headers: NO_CACHE_HEADERS$1
|
|
1531
1788
|
});
|
|
1532
1789
|
}
|
|
1533
|
-
function addCorsHeaders(response, request) {
|
|
1534
|
-
const origin = request.headers.get("Origin");
|
|
1535
|
-
if (!origin) return response;
|
|
1536
|
-
const withCors = new Response(response.body, response);
|
|
1537
|
-
withCors.headers.set("Access-Control-Allow-Origin", origin);
|
|
1538
|
-
withCors.headers.set("Access-Control-Allow-Methods", "*");
|
|
1539
|
-
withCors.headers.set("Access-Control-Allow-Headers", "Authorization, *");
|
|
1540
|
-
appendHeaderValue$1(withCors.headers, "Vary", "Origin");
|
|
1541
|
-
appendHeaderValue$1(withCors.headers, "Access-Control-Expose-Headers", "WWW-Authenticate");
|
|
1542
|
-
appendHeaderValue$1(withCors.headers, "Access-Control-Expose-Headers", "Retry-After");
|
|
1543
|
-
withCors.headers.set("Access-Control-Max-Age", "86400");
|
|
1544
|
-
return withCors;
|
|
1545
|
-
}
|
|
1546
|
-
function appendHeaderValue$1(headers, name, value) {
|
|
1547
|
-
const values = (headers.get(name) ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
1548
|
-
if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
|
|
1549
|
-
headers.set(name, values.join(", "));
|
|
1550
|
-
}
|
|
1551
1790
|
|
|
1552
1791
|
//#endregion
|
|
1553
1792
|
//#region src/oauth-consent.ts
|
|
@@ -1595,6 +1834,21 @@ function validateRememberConsentOptions(options) {
|
|
|
1595
1834
|
if (maxAge !== void 0 && (!Number.isInteger(maxAge) || maxAge <= 0)) throw new TypeError("remember.maxAgeSeconds must be a positive integer");
|
|
1596
1835
|
if (options.subject !== void 0 && (typeof options.subject !== "string" || options.subject.length === 0)) throw new TypeError("remember.subject must be a non-empty string");
|
|
1597
1836
|
}
|
|
1837
|
+
/** The facts a consent page must show for a request, given the client `lookupClient()` resolved. */
|
|
1838
|
+
function describeConsent(client, request, isClientIdMetadataDocument) {
|
|
1839
|
+
const redirectHost = new URL(request.redirectUri).hostname || request.redirectUri;
|
|
1840
|
+
return {
|
|
1841
|
+
clientId: request.clientId,
|
|
1842
|
+
clientName: client?.clientName?.trim() ? client.clientName : request.clientId,
|
|
1843
|
+
...isClientIdMetadataDocument ? { clientDomain: new URL(request.clientId).hostname } : {},
|
|
1844
|
+
...client?.clientUri ? { clientUri: client.clientUri } : {},
|
|
1845
|
+
...client?.logoUri ? { logoUri: client.logoUri } : {},
|
|
1846
|
+
redirectUri: request.redirectUri,
|
|
1847
|
+
redirectHost,
|
|
1848
|
+
redirectIsLoopback: isLoopbackHostname(redirectHost),
|
|
1849
|
+
scope: [...request.scope]
|
|
1850
|
+
};
|
|
1851
|
+
}
|
|
1598
1852
|
/** Start a consent transaction for an authorization request that must be shown to the user. */
|
|
1599
1853
|
async function beginConsent(kv, cookies, request) {
|
|
1600
1854
|
const { handle, hash } = await createTransaction(kv, {
|
|
@@ -1647,19 +1901,15 @@ async function denyConsent(kv, cookies, request, handle, options = {}) {
|
|
|
1647
1901
|
const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
|
|
1648
1902
|
await transaction.consume();
|
|
1649
1903
|
const record = transaction.record;
|
|
1650
|
-
const
|
|
1651
|
-
redirect.searchParams.set("error", "access_denied");
|
|
1652
|
-
if (options.description) redirect.searchParams.set("error_description", options.description);
|
|
1653
|
-
if (record.request.state) redirect.searchParams.set("state", record.request.state);
|
|
1654
|
-
if (record.request.issuer) redirect.searchParams.set("iss", record.request.issuer);
|
|
1904
|
+
const redirectTo = authorizationErrorRedirect(record.request, "access_denied", options.description);
|
|
1655
1905
|
const headers = new Headers({
|
|
1656
1906
|
"Cache-Control": "no-store",
|
|
1657
|
-
Location:
|
|
1907
|
+
Location: redirectTo
|
|
1658
1908
|
});
|
|
1659
1909
|
headers.append("Set-Cookie", clearCookie(transaction.cookieName));
|
|
1660
1910
|
return {
|
|
1661
1911
|
request: record.request,
|
|
1662
|
-
redirectTo
|
|
1912
|
+
redirectTo,
|
|
1663
1913
|
headers
|
|
1664
1914
|
};
|
|
1665
1915
|
}
|
|
@@ -1887,6 +2137,9 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
|
|
|
1887
2137
|
GrantType$1["JWT_BEARER"] = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
1888
2138
|
return GrantType$1;
|
|
1889
2139
|
}({});
|
|
2140
|
+
/** Where the split server's endpoints are unless configured otherwise, under the issuer. */
|
|
2141
|
+
const DEFAULT_AUTHORIZE_PATH = "/authorize";
|
|
2142
|
+
const DEFAULT_TOKEN_PATH = "/oauth/token";
|
|
1890
2143
|
function toPublicClientInfo(client) {
|
|
1891
2144
|
const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, ...publicClient } = client;
|
|
1892
2145
|
return publicClient;
|
|
@@ -1946,7 +2199,6 @@ var OAuthAuthorizationServer = class {
|
|
|
1946
2199
|
const { issuer, resources, defaultResource, legacyGrantResource, authorizeEndpoint, tokenEndpoint, clientRegistrationEndpoint, ...commonOptions } = options;
|
|
1947
2200
|
this.#impl = new OAuthProviderImpl({
|
|
1948
2201
|
...commonOptions,
|
|
1949
|
-
defaultHandler: { fetch: () => new Response(null, { status: 404 }) },
|
|
1950
2202
|
authorizationServer: {
|
|
1951
2203
|
issuer,
|
|
1952
2204
|
resources,
|
|
@@ -2018,7 +2270,7 @@ var OAuthProviderImpl = class {
|
|
|
2018
2270
|
constructor(options) {
|
|
2019
2271
|
this.errorResponses = /* @__PURE__ */ new WeakSet();
|
|
2020
2272
|
this.typedApiHandlers = [];
|
|
2021
|
-
this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
|
|
2273
|
+
this.typedDefaultHandler = "authorizationServer" in options ? void 0 : this.validateHandler(options.defaultHandler, "defaultHandler");
|
|
2022
2274
|
const roleBased = "authorizationServer" in options;
|
|
2023
2275
|
if (!roleBased && "resourceMatchOriginOnly" in options) throw new TypeError("resourceMatchOriginOnly was removed in 1.0. Configure resourceMetadata.resource; audiences are compared exactly against that canonical resource. See the migration guide.");
|
|
2024
2276
|
let normalizedOptions;
|
|
@@ -2027,10 +2279,11 @@ var OAuthProviderImpl = class {
|
|
|
2027
2279
|
const { authorizationServer: authorizationServer$1, ...commonOptions } = options;
|
|
2028
2280
|
this.validateAuthorizationServerIssuer(authorizationServer$1.issuer);
|
|
2029
2281
|
this.explicitIssuer = authorizationServer$1.issuer;
|
|
2282
|
+
const issuerBase = authorizationServer$1.issuer.replace(/\/+$/, "");
|
|
2030
2283
|
normalizedOptions = {
|
|
2031
2284
|
...commonOptions,
|
|
2032
|
-
authorizeEndpoint: authorizationServer$1.authorizeEndpoint
|
|
2033
|
-
tokenEndpoint: authorizationServer$1.tokenEndpoint
|
|
2285
|
+
authorizeEndpoint: authorizationServer$1.authorizeEndpoint ?? `${issuerBase}${DEFAULT_AUTHORIZE_PATH}`,
|
|
2286
|
+
tokenEndpoint: authorizationServer$1.tokenEndpoint ?? `${issuerBase}${DEFAULT_TOKEN_PATH}`,
|
|
2034
2287
|
clientRegistrationEndpoint: authorizationServer$1.clientRegistrationEndpoint
|
|
2035
2288
|
};
|
|
2036
2289
|
const declared = authorizationServer$1.resources;
|
|
@@ -2042,7 +2295,7 @@ var OAuthProviderImpl = class {
|
|
|
2042
2295
|
this.explicitIssuer = void 0;
|
|
2043
2296
|
normalizedOptions = options;
|
|
2044
2297
|
configuredResourceServers = [{
|
|
2045
|
-
resourceMetadata: options.resourceMetadata,
|
|
2298
|
+
resourceMetadata: withRequiredScopes(options.resourceMetadata, options.requiredScopes),
|
|
2046
2299
|
resolveExternalToken: options.resolveExternalToken
|
|
2047
2300
|
}];
|
|
2048
2301
|
}
|
|
@@ -2106,11 +2359,14 @@ var OAuthProviderImpl = class {
|
|
|
2106
2359
|
}
|
|
2107
2360
|
if (!isValidAccessTokenTTL(this.options.accessTokenTTL)) throw new TypeError(`accessTokenTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
|
|
2108
2361
|
if (this.options.refreshTokenIdleTTL !== void 0 && !isValidAccessTokenTTL(this.options.refreshTokenIdleTTL)) throw new TypeError(`refreshTokenIdleTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
|
|
2362
|
+
if (this.options.allowPlainPKCE) throw new TypeError("allowPlainPKCE has been removed: MCP and OAuth 2.1 clients use S256 PKCE.");
|
|
2363
|
+
if (this.options.allowImplicitFlow) throw new TypeError("allowImplicitFlow has been removed: OAuth 2.1 and MCP use the authorization code flow with PKCE.");
|
|
2364
|
+
if (this.options.refreshTokenTTL !== void 0 && !isValidRefreshTokenTTL(this.options.refreshTokenTTL)) throw new TypeError(`refreshTokenTTL must be 0 (no refresh tokens), undefined (no expiry), or an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
|
|
2365
|
+
if (this.options.clientRegistrationTTL !== void 0 && !isValidAccessTokenTTL(this.options.clientRegistrationTTL)) throw new TypeError(`clientRegistrationTTL must be undefined (no expiry) or an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
|
|
2109
2366
|
this.serverCapabilities = buildOAuthServerCapabilities({
|
|
2110
|
-
allowImplicitFlow: !!this.options.allowImplicitFlow,
|
|
2111
|
-
allowPlainPKCE: this.options.allowPlainPKCE === true,
|
|
2112
2367
|
allowTokenExchangeGrant: !!this.options.allowTokenExchangeGrant,
|
|
2113
|
-
enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization
|
|
2368
|
+
enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization,
|
|
2369
|
+
allowPrivateUseRedirectUris: this.options.allowPrivateUseRedirectUris === true
|
|
2114
2370
|
});
|
|
2115
2371
|
validateAuthorizationServerScopes(this.options.scopesSupported);
|
|
2116
2372
|
this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
|
|
@@ -2149,16 +2405,24 @@ var OAuthProviderImpl = class {
|
|
|
2149
2405
|
}
|
|
2150
2406
|
if (!validateResourceUri(issuer) || !hasAcceptedCanonicalScheme(parsed) || parsed.username || parsed.password || parsed.search || parsed.hash || foldResourceSchemeAndHost(issuer) !== issuer || parsed.href !== issuer && parsed.origin !== issuer) throw new TypeError("authorizationServer.issuer must be a canonical absolute HTTPS URL (http is accepted only on a loopback host)");
|
|
2151
2407
|
}
|
|
2152
|
-
/**
|
|
2408
|
+
/**
|
|
2409
|
+
* Reject an endpoint another would claim, so it could never be reached. fetch() tries metadata,
|
|
2410
|
+
* then token, then registration; the application routes the authorization endpoint. Each check
|
|
2411
|
+
* asks the predicate fetch() dispatches on, so it agrees with routing (metadata ignores the
|
|
2412
|
+
* query, for one).
|
|
2413
|
+
*/
|
|
2153
2414
|
validateAuthorizationServerRouteIsolation() {
|
|
2154
2415
|
const issuerUrl = new URL(this.explicitIssuer);
|
|
2155
|
-
const discoveryEndpoint = this.getAuthorizationServerMetadataUrl(issuerUrl);
|
|
2156
2416
|
const tokenUrl = new URL(this.getFullEndpointUrl(this.options.tokenEndpoint, issuerUrl));
|
|
2157
|
-
if (this.
|
|
2417
|
+
if (this.isAuthorizationServerMetadataRequest(tokenUrl)) throw new TypeError("tokenEndpoint must not collide with the authorization server metadata endpoint");
|
|
2418
|
+
const authorizeUrl = new URL(this.getFullEndpointUrl(this.options.authorizeEndpoint, issuerUrl));
|
|
2419
|
+
if (this.isAuthorizationServerMetadataRequest(authorizeUrl)) throw new TypeError("authorizeEndpoint must not collide with the authorization server metadata endpoint");
|
|
2420
|
+
if (this.isTokenEndpoint(authorizeUrl)) throw new TypeError("authorizeEndpoint must not collide with tokenEndpoint");
|
|
2158
2421
|
if (!this.options.clientRegistrationEndpoint) return;
|
|
2422
|
+
if (this.isClientRegistrationEndpoint(authorizeUrl)) throw new TypeError("authorizeEndpoint must not collide with clientRegistrationEndpoint");
|
|
2159
2423
|
const registrationUrl = new URL(this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, issuerUrl));
|
|
2160
|
-
if (this.
|
|
2161
|
-
if (this.
|
|
2424
|
+
if (this.isTokenEndpoint(registrationUrl)) throw new TypeError("clientRegistrationEndpoint must not collide with tokenEndpoint");
|
|
2425
|
+
if (this.isAuthorizationServerMetadataRequest(registrationUrl)) throw new TypeError("clientRegistrationEndpoint must not collide with the authorization server metadata endpoint");
|
|
2162
2426
|
}
|
|
2163
2427
|
/**
|
|
2164
2428
|
* A protected route must be the canonical resource path or a path-boundary descendant of
|
|
@@ -2259,7 +2523,7 @@ var OAuthProviderImpl = class {
|
|
|
2259
2523
|
if (!validateResourceUri(issuer) || !hasAcceptedCanonicalScheme(parsed) || parsed.username || parsed.password || foldResourceSchemeAndHost(issuer) !== issuer || parsed.href !== issuer && parsed.origin !== issuer || issuer.includes("?") || issuer.includes("#")) throw new TypeError("resourceMetadata.authorization_servers must contain valid HTTPS issuer URLs (http is accepted only on a loopback host)");
|
|
2260
2524
|
}
|
|
2261
2525
|
}
|
|
2262
|
-
if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
|
|
2526
|
+
if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("requiredScopes (or the deprecated resourceMetadata.scopes_supported) must contain valid OAuth scope tokens");
|
|
2263
2527
|
if (options.bearer_methods_supported?.some((method) => method !== "header")) throw new TypeError("resourceMetadata.bearer_methods_supported only supports 'header'");
|
|
2264
2528
|
}
|
|
2265
2529
|
/**
|
|
@@ -2298,49 +2562,42 @@ var OAuthProviderImpl = class {
|
|
|
2298
2562
|
const metadataResourceServer = servesProtectedResources ? this.findResourceServerForMetadataUrl(url) : void 0;
|
|
2299
2563
|
const apiRoute = servesProtectedResources ? this.findApiRouteForUrl(url) : void 0;
|
|
2300
2564
|
if (request.method === "OPTIONS") {
|
|
2301
|
-
if (apiRoute !== void 0 || this.isAuthorizationServerMetadataRequest(url) || servesProtectedResources && this.isProtectedResourceMetadataPath(url) || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return
|
|
2565
|
+
if (apiRoute !== void 0 || this.isAuthorizationServerMetadataRequest(url) || servesProtectedResources && this.isProtectedResourceMetadataPath(url) || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return withCorsHeaders(new Response(null, {
|
|
2302
2566
|
status: 204,
|
|
2303
2567
|
headers: { "Content-Length": "0" }
|
|
2304
2568
|
}), request);
|
|
2305
2569
|
}
|
|
2306
2570
|
if (this.isAuthorizationServerMetadataRequest(url)) {
|
|
2307
|
-
if (request.method !== "GET" && request.method !== "HEAD") return
|
|
2571
|
+
if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
|
|
2308
2572
|
status: 405,
|
|
2309
2573
|
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
2310
2574
|
}), request);
|
|
2311
|
-
|
|
2312
|
-
return this.addCorsHeaders(withoutBodyForHead(request, response), request);
|
|
2575
|
+
return withCorsHeaders(withoutBodyForHead(request, await this.handleMetadataDiscovery(url)), request);
|
|
2313
2576
|
}
|
|
2314
2577
|
if (servesProtectedResources && this.isProtectedResourceMetadataPath(url)) {
|
|
2315
|
-
if (!metadataResourceServer) return
|
|
2316
|
-
if (request.method !== "GET" && request.method !== "HEAD") return
|
|
2578
|
+
if (!metadataResourceServer) return withCorsHeaders(new Response(null, { status: 404 }), request);
|
|
2579
|
+
if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
|
|
2317
2580
|
status: 405,
|
|
2318
2581
|
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
2319
2582
|
}), request);
|
|
2320
|
-
|
|
2321
|
-
return this.addCorsHeaders(withoutBodyForHead(request, response), request);
|
|
2583
|
+
return withCorsHeaders(withoutBodyForHead(request, this.handleProtectedResourceMetadata(url, metadataResourceServer)), request);
|
|
2322
2584
|
}
|
|
2323
2585
|
if (this.isTokenEndpoint(url)) {
|
|
2324
2586
|
const parsed = await this.parseTokenEndpointRequest(request, env);
|
|
2325
|
-
if (parsed instanceof Response) return
|
|
2587
|
+
if (parsed instanceof Response) return withCorsHeaders(parsed, request);
|
|
2326
2588
|
let response;
|
|
2327
|
-
if (parsed.isRevocationRequest) response = await this.
|
|
2589
|
+
if (parsed.isRevocationRequest) response = await this.revokeToken(parsed.body, parsed.clientInfo, env);
|
|
2328
2590
|
else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
|
|
2329
2591
|
if (response.ok && !this.errorResponses.has(response)) await this.renewClientRegistrationIfDue(env, parsed.clientInfo, Math.floor(Date.now() / 1e3));
|
|
2330
|
-
return
|
|
2331
|
-
}
|
|
2332
|
-
if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
|
|
2333
|
-
const response = await this.handleClientRegistration(request, env);
|
|
2334
|
-
return this.addCorsHeaders(response, request);
|
|
2592
|
+
return withCorsHeaders(response, request);
|
|
2335
2593
|
}
|
|
2336
|
-
if (
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
}
|
|
2340
|
-
if (typeof role === "object") return new Response(null, { status: 404 });
|
|
2594
|
+
if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return withCorsHeaders(await this.handleClientRegistration(request, env), request);
|
|
2595
|
+
if (apiRoute) return withCorsHeaders(await this.handleApiRequest(request, env, ctx, apiRoute), request);
|
|
2596
|
+
const defaultHandler = this.typedDefaultHandler;
|
|
2597
|
+
if (!defaultHandler) return new Response(null, { status: 404 });
|
|
2341
2598
|
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
2342
|
-
if (
|
|
2343
|
-
return new
|
|
2599
|
+
if (defaultHandler.type === HandlerType.EXPORTED_HANDLER) return defaultHandler.handler.fetch(request, env, ctx);
|
|
2600
|
+
return new defaultHandler.handler(ctx, env).fetch(request);
|
|
2344
2601
|
}
|
|
2345
2602
|
/**
|
|
2346
2603
|
* Decodes a token and returns token data with decrypted props
|
|
@@ -2576,7 +2833,7 @@ var OAuthProviderImpl = class {
|
|
|
2576
2833
|
});
|
|
2577
2834
|
const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
|
|
2578
2835
|
const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
|
|
2579
|
-
if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled &&
|
|
2836
|
+
if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
|
|
2580
2837
|
category: "client-authentication",
|
|
2581
2838
|
reason: "token_endpoint_auth_method_mismatch",
|
|
2582
2839
|
detail: {
|
|
@@ -2652,26 +2909,6 @@ var OAuthProviderImpl = class {
|
|
|
2652
2909
|
return new URL(tokenEndpoint).origin;
|
|
2653
2910
|
}
|
|
2654
2911
|
/**
|
|
2655
|
-
* Adds CORS headers to a response
|
|
2656
|
-
* @param response - The response to add CORS headers to
|
|
2657
|
-
* @param request - The original request
|
|
2658
|
-
* @returns A new Response with CORS headers added
|
|
2659
|
-
*/
|
|
2660
|
-
addCorsHeaders(response, request) {
|
|
2661
|
-
const origin = request.headers.get("Origin");
|
|
2662
|
-
if (!origin) return response;
|
|
2663
|
-
const newResponse = new Response(response.body, response);
|
|
2664
|
-
newResponse.headers.set("Access-Control-Allow-Origin", origin);
|
|
2665
|
-
newResponse.headers.set("Access-Control-Allow-Methods", "*");
|
|
2666
|
-
newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
|
|
2667
|
-
appendHeaderValue(newResponse.headers, "Vary", "Origin");
|
|
2668
|
-
const exposedHeaders = (newResponse.headers.get("Access-Control-Expose-Headers") ?? "").split(",").map((name) => name.trim()).filter(Boolean);
|
|
2669
|
-
for (const requiredHeader of ["WWW-Authenticate", "Retry-After"]) if (!exposedHeaders.some((name) => name.toLowerCase() === requiredHeader.toLowerCase())) exposedHeaders.push(requiredHeader);
|
|
2670
|
-
newResponse.headers.set("Access-Control-Expose-Headers", exposedHeaders.join(", "));
|
|
2671
|
-
newResponse.headers.set("Access-Control-Max-Age", "86400");
|
|
2672
|
-
return newResponse;
|
|
2673
|
-
}
|
|
2674
|
-
/**
|
|
2675
2912
|
* Handles the OAuth metadata discovery endpoint
|
|
2676
2913
|
* Implements RFC 8414 for OAuth Server Metadata
|
|
2677
2914
|
* @param requestUrl - The URL of the incoming request
|
|
@@ -2693,7 +2930,7 @@ var OAuthProviderImpl = class {
|
|
|
2693
2930
|
registration_endpoint: registrationEndpoint,
|
|
2694
2931
|
scopes_supported: this.options.scopesSupported,
|
|
2695
2932
|
response_types_supported: responseTypesSupported,
|
|
2696
|
-
response_modes_supported:
|
|
2933
|
+
response_modes_supported: ["query"],
|
|
2697
2934
|
grant_types_supported: grantTypesSupported,
|
|
2698
2935
|
...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
|
|
2699
2936
|
token_endpoint_auth_methods_supported: this.serverCapabilities.tokenEndpointAuthMethods,
|
|
@@ -2710,7 +2947,7 @@ var OAuthProviderImpl = class {
|
|
|
2710
2947
|
}
|
|
2711
2948
|
/** Deduplicate resource-facing scopes and remove authorization-server-only capabilities. */
|
|
2712
2949
|
normalizeProtectedResourceScopes(scopes) {
|
|
2713
|
-
return
|
|
2950
|
+
return baselineResourceScopes(scopes);
|
|
2714
2951
|
}
|
|
2715
2952
|
/**
|
|
2716
2953
|
* Handles the OAuth Protected Resource Metadata endpoint
|
|
@@ -2780,7 +3017,10 @@ var OAuthProviderImpl = class {
|
|
|
2780
3017
|
*/
|
|
2781
3018
|
async callTokenExchangeCallback(options, env) {
|
|
2782
3019
|
try {
|
|
2783
|
-
return await Promise.resolve(this.options.tokenExchangeCallback(
|
|
3020
|
+
return await Promise.resolve(this.options.tokenExchangeCallback({
|
|
3021
|
+
...options,
|
|
3022
|
+
env
|
|
3023
|
+
}));
|
|
2784
3024
|
} catch (error) {
|
|
2785
3025
|
if (error instanceof OAuthError && error.code === "invalid_grant") try {
|
|
2786
3026
|
await this.createOAuthHelpers(env).revokeGrant(options.grantId, options.userId);
|
|
@@ -2875,9 +3115,9 @@ var OAuthProviderImpl = class {
|
|
|
2875
3115
|
reason: "code_replayed"
|
|
2876
3116
|
});
|
|
2877
3117
|
}
|
|
2878
|
-
let codeChallengeMethod;
|
|
2879
3118
|
try {
|
|
2880
|
-
|
|
3119
|
+
if (grantData.codeChallenge) validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod);
|
|
3120
|
+
else normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
|
|
2881
3121
|
} catch (error) {
|
|
2882
3122
|
return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" }, {
|
|
2883
3123
|
category: "authorization-code-grant",
|
|
@@ -2907,14 +3147,8 @@ var OAuthProviderImpl = class {
|
|
|
2907
3147
|
category: "authorization-code-grant",
|
|
2908
3148
|
reason: "code_verifier_missing"
|
|
2909
3149
|
});
|
|
2910
|
-
|
|
2911
|
-
if (
|
|
2912
|
-
const data = new TextEncoder().encode(codeVerifier);
|
|
2913
|
-
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
2914
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
2915
|
-
calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
|
|
2916
|
-
} else calculatedChallenge = codeVerifier;
|
|
2917
|
-
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" }, {
|
|
3150
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
|
|
3151
|
+
if (base64UrlEncode(String.fromCharCode(...new Uint8Array(hashBuffer))) !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" }, {
|
|
2918
3152
|
category: "authorization-code-grant",
|
|
2919
3153
|
reason: "code_verifier_mismatch"
|
|
2920
3154
|
});
|
|
@@ -2928,6 +3162,10 @@ var OAuthProviderImpl = class {
|
|
|
2928
3162
|
let accessTokenEncryptionKey = encryptionKey;
|
|
2929
3163
|
let encryptedAccessTokenProps = grantData.encryptedProps;
|
|
2930
3164
|
let tokenScopes = this.downscope(body.scope, grantData.scope);
|
|
3165
|
+
if (grantsNoneOfTheRequestedScopes(body.scope, tokenScopes)) return this.createErrorResponse("invalid_scope", { description: "None of the requested scopes were granted" }, {
|
|
3166
|
+
category: "authorization-code-grant",
|
|
3167
|
+
reason: "scope_not_granted"
|
|
3168
|
+
});
|
|
2931
3169
|
if (this.options.tokenExchangeCallback) {
|
|
2932
3170
|
const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
|
|
2933
3171
|
let grantProps = decryptedProps;
|
|
@@ -2951,11 +3189,13 @@ var OAuthProviderImpl = class {
|
|
|
2951
3189
|
}
|
|
2952
3190
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
2953
3191
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
2954
|
-
if (
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
3192
|
+
if (callbackResult.refreshTokenTTL !== void 0) {
|
|
3193
|
+
if (!isValidRefreshTokenTTL(callbackResult.refreshTokenTTL)) return this.createErrorResponse("invalid_request", { description: `refreshTokenTTL must be 0 (no refresh token) or an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds` }, {
|
|
3194
|
+
category: "authorization-code-grant",
|
|
3195
|
+
reason: "refresh_token_ttl_invalid"
|
|
3196
|
+
});
|
|
3197
|
+
refreshTokenTTL = callbackResult.refreshTokenTTL;
|
|
3198
|
+
}
|
|
2959
3199
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
2960
3200
|
}
|
|
2961
3201
|
const grantResult = await encryptProps(grantProps);
|
|
@@ -2990,7 +3230,7 @@ var OAuthProviderImpl = class {
|
|
|
2990
3230
|
grantData.previousRefreshTokenId = void 0;
|
|
2991
3231
|
grantData.previousRefreshTokenWrappedKey = void 0;
|
|
2992
3232
|
grantData.expiresAt = expiresAt;
|
|
2993
|
-
}
|
|
3233
|
+
} else grantData.expiresAt = now + accessTokenTTL;
|
|
2994
3234
|
if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
|
|
2995
3235
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
2996
3236
|
const tokenResponse = {
|
|
@@ -3074,6 +3314,10 @@ var OAuthProviderImpl = class {
|
|
|
3074
3314
|
let accessTokenEncryptionKey = encryptionKey;
|
|
3075
3315
|
let encryptedAccessTokenProps = grantData.encryptedProps;
|
|
3076
3316
|
let tokenScopes = this.downscope(body.scope, grantData.scope);
|
|
3317
|
+
if (grantsNoneOfTheRequestedScopes(body.scope, tokenScopes)) return this.createErrorResponse("invalid_scope", { description: "None of the requested scopes were granted" }, {
|
|
3318
|
+
category: "refresh-token-grant",
|
|
3319
|
+
reason: "scope_not_granted"
|
|
3320
|
+
});
|
|
3077
3321
|
let grantPropsChanged = false;
|
|
3078
3322
|
if (this.options.tokenExchangeCallback) {
|
|
3079
3323
|
const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
|
|
@@ -3099,10 +3343,6 @@ var OAuthProviderImpl = class {
|
|
|
3099
3343
|
}
|
|
3100
3344
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
3101
3345
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
3102
|
-
if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", { description: "refreshTokenTTL cannot be changed during refresh token exchange" }, {
|
|
3103
|
-
category: "refresh-token-grant",
|
|
3104
|
-
reason: "refresh_token_ttl_immutable"
|
|
3105
|
-
});
|
|
3106
3346
|
if (callbackResult.refreshTokenIdleTTL !== void 0) {
|
|
3107
3347
|
if (!isValidAccessTokenTTL(callbackResult.refreshTokenIdleTTL)) return this.createErrorResponse("invalid_request", { description: `refreshTokenIdleTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds` }, {
|
|
3108
3348
|
category: "refresh-token-grant",
|
|
@@ -3231,6 +3471,13 @@ var OAuthProviderImpl = class {
|
|
|
3231
3471
|
});
|
|
3232
3472
|
if (crossClientExchange && !this.options.tokenExchangeCallback) throw crossClientRejection();
|
|
3233
3473
|
let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
|
|
3474
|
+
if (grantsNoneOfTheRequestedScopes(requestedScopes, tokenScopes)) throw new OAuthError("invalid_scope", {
|
|
3475
|
+
description: "None of the requested scopes were granted",
|
|
3476
|
+
internal: {
|
|
3477
|
+
category: "token-exchange-grant",
|
|
3478
|
+
reason: "scope_not_granted"
|
|
3479
|
+
}
|
|
3480
|
+
});
|
|
3234
3481
|
const newAudience = this.resolveTokenExchangeResource(requestedResource, tokenSummary.audience);
|
|
3235
3482
|
const now = Math.floor(Date.now() / 1e3);
|
|
3236
3483
|
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
@@ -3285,13 +3532,6 @@ var OAuthProviderImpl = class {
|
|
|
3285
3532
|
}
|
|
3286
3533
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
3287
3534
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = Math.min(callbackResult.accessTokenTTL, subjectTokenRemainingLifetime);
|
|
3288
|
-
if (callbackResult.refreshTokenIdleTTL !== void 0) throw new OAuthError("invalid_request", {
|
|
3289
|
-
description: "refreshTokenIdleTTL is only honored during refresh token exchange",
|
|
3290
|
-
internal: {
|
|
3291
|
-
category: "token-exchange-grant",
|
|
3292
|
-
reason: "refresh_token_idle_ttl_wrong_grant"
|
|
3293
|
-
}
|
|
3294
|
-
});
|
|
3295
3535
|
if (accessTokenProps !== decryptedProps) {
|
|
3296
3536
|
const tokenResult = await encryptProps(accessTokenProps);
|
|
3297
3537
|
encryptedAccessTokenProps = tokenResult.encryptedData;
|
|
@@ -3311,7 +3551,7 @@ var OAuthProviderImpl = class {
|
|
|
3311
3551
|
access_token: await this.createAccessToken({
|
|
3312
3552
|
userId: tokenSummary.userId,
|
|
3313
3553
|
grantId: tokenSummary.grantId,
|
|
3314
|
-
clientId:
|
|
3554
|
+
clientId: clientInfo.clientId,
|
|
3315
3555
|
scope: tokenScopes,
|
|
3316
3556
|
encryptedProps: encryptedAccessTokenProps,
|
|
3317
3557
|
encryptionKey: accessTokenEncryptionKey,
|
|
@@ -3595,15 +3835,7 @@ var OAuthProviderImpl = class {
|
|
|
3595
3835
|
};
|
|
3596
3836
|
}
|
|
3597
3837
|
/**
|
|
3598
|
-
* Handles
|
|
3599
|
-
* @param body - The parsed request body containing revocation parameters
|
|
3600
|
-
* @param env - Cloudflare Worker environment variables
|
|
3601
|
-
* @returns Response confirming revocation or error
|
|
3602
|
-
*/
|
|
3603
|
-
async handleRevocationRequest(body, clientInfo, env) {
|
|
3604
|
-
return this.revokeToken(body, clientInfo, env);
|
|
3605
|
-
}
|
|
3606
|
-
/**
|
|
3838
|
+
* Handles an RFC 7009 revocation request from an authenticated client.
|
|
3607
3839
|
* - Access tokens: Revokes only the specific token
|
|
3608
3840
|
* - Refresh tokens: Revokes the entire grant (access + refresh tokens)
|
|
3609
3841
|
* Per RFC 7009 §2.1, the server MUST verify the token was issued to the client making the request.
|
|
@@ -3695,14 +3927,17 @@ var OAuthProviderImpl = class {
|
|
|
3695
3927
|
const callbackRequest = request.clone();
|
|
3696
3928
|
let parsedJson;
|
|
3697
3929
|
try {
|
|
3698
|
-
const text = await request
|
|
3699
|
-
if (text
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3930
|
+
const text = await readTextWithLimit(request, 1048576);
|
|
3931
|
+
if (text === void 0) {
|
|
3932
|
+
callbackRequest.body?.cancel().catch(() => void 0);
|
|
3933
|
+
return this.createErrorResponse("invalid_request", {
|
|
3934
|
+
description: "Request payload too large, must be under 1 MiB",
|
|
3935
|
+
statusCode: 413
|
|
3936
|
+
}, {
|
|
3937
|
+
category: "client-registration",
|
|
3938
|
+
reason: "payload_too_large"
|
|
3939
|
+
});
|
|
3940
|
+
}
|
|
3706
3941
|
parsedJson = JSON.parse(text);
|
|
3707
3942
|
} catch {
|
|
3708
3943
|
return this.createErrorResponse("invalid_request", {
|
|
@@ -3717,8 +3952,16 @@ var OAuthProviderImpl = class {
|
|
|
3717
3952
|
let metadata;
|
|
3718
3953
|
try {
|
|
3719
3954
|
clientMetadata = requireJsonObject(parsedJson);
|
|
3720
|
-
|
|
3955
|
+
const capabilities = this.options.disallowPublicClientRegistration ? {
|
|
3956
|
+
...this.serverCapabilities,
|
|
3957
|
+
tokenEndpointAuthMethods: this.serverCapabilities.tokenEndpointAuthMethods.filter((method) => method !== "none")
|
|
3958
|
+
} : this.serverCapabilities;
|
|
3959
|
+
metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, capabilities);
|
|
3721
3960
|
} catch (error) {
|
|
3961
|
+
if (this.options.disallowPublicClientRegistration && wouldRegisterPublicClient(clientMetadata, this.serverCapabilities)) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" }, {
|
|
3962
|
+
category: "client-registration",
|
|
3963
|
+
reason: "public_client_forbidden"
|
|
3964
|
+
});
|
|
3722
3965
|
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" }, {
|
|
3723
3966
|
category: "client-registration",
|
|
3724
3967
|
reason: "metadata_invalid",
|
|
@@ -3727,10 +3970,6 @@ var OAuthProviderImpl = class {
|
|
|
3727
3970
|
}
|
|
3728
3971
|
const authMethod = metadata.tokenEndpointAuthMethod;
|
|
3729
3972
|
const isPublicClient = authMethod === "none";
|
|
3730
|
-
if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" }, {
|
|
3731
|
-
category: "client-registration",
|
|
3732
|
-
reason: "public_client_forbidden"
|
|
3733
|
-
});
|
|
3734
3973
|
const clientId = generateRandomString(16);
|
|
3735
3974
|
let clientSecret;
|
|
3736
3975
|
let hashedSecret;
|
|
@@ -3765,7 +4004,7 @@ var OAuthProviderImpl = class {
|
|
|
3765
4004
|
}));
|
|
3766
4005
|
} catch (error) {
|
|
3767
4006
|
return this.createErrorResponse("server_error", {
|
|
3768
|
-
description:
|
|
4007
|
+
description: "Client registration callback failed",
|
|
3769
4008
|
statusCode: 500
|
|
3770
4009
|
}, {
|
|
3771
4010
|
category: "client-registration",
|
|
@@ -3803,7 +4042,7 @@ var OAuthProviderImpl = class {
|
|
|
3803
4042
|
}
|
|
3804
4043
|
if (clientSecret) {
|
|
3805
4044
|
response.client_secret = clientSecret;
|
|
3806
|
-
response.client_secret_expires_at = this.options.clientRegistrationTTL && clientInfo.registrationDate ? clientInfo.registrationDate + this.options.clientRegistrationTTL : 0;
|
|
4045
|
+
response.client_secret_expires_at = this.options.clientRegistrationTTL !== void 0 && clientInfo.registrationDate ? clientInfo.registrationDate + this.options.clientRegistrationTTL : 0;
|
|
3807
4046
|
response.client_secret_issued_at = clientInfo.registrationDate;
|
|
3808
4047
|
}
|
|
3809
4048
|
return new Response(JSON.stringify(response), {
|
|
@@ -3848,7 +4087,7 @@ var OAuthProviderImpl = class {
|
|
|
3848
4087
|
const id = await generateTokenId(accessToken);
|
|
3849
4088
|
tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
|
|
3850
4089
|
}
|
|
3851
|
-
if (!tokenData && !externalTokenResolver) return this.createErrorResponse("invalid_token", {
|
|
4090
|
+
if (!tokenData && (!externalTokenResolver || isIssuedTokenFormat(accessToken))) return this.createErrorResponse("invalid_token", {
|
|
3852
4091
|
description: "Invalid access token",
|
|
3853
4092
|
statusCode: 401,
|
|
3854
4093
|
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
@@ -4054,7 +4293,7 @@ var OAuthProviderImpl = class {
|
|
|
4054
4293
|
}
|
|
4055
4294
|
}
|
|
4056
4295
|
async getClient(env, clientId) {
|
|
4057
|
-
if (
|
|
4296
|
+
if (isClientIdMetadataDocumentUrl(clientId)) {
|
|
4058
4297
|
if (!this.options.clientIdMetadataDocumentEnabled) {
|
|
4059
4298
|
const clientKey$1 = `client:${clientId}`;
|
|
4060
4299
|
return env.OAUTH_KV.get(clientKey$1, { type: "json" });
|
|
@@ -4265,13 +4504,6 @@ var OAuthProviderImpl = class {
|
|
|
4265
4504
|
return !!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags ? Cloudflare.compatibilityFlags : null)?.global_fetch_strictly_public;
|
|
4266
4505
|
}
|
|
4267
4506
|
/**
|
|
4268
|
-
* Checks if a client_id is a CIMD URL (HTTPS with non-root path).
|
|
4269
|
-
* Not private because OAuthHelpersImpl needs access for purgeExpiredData.
|
|
4270
|
-
*/
|
|
4271
|
-
isClientMetadataUrl(clientId) {
|
|
4272
|
-
return isClientIdMetadataDocumentUrl(clientId);
|
|
4273
|
-
}
|
|
4274
|
-
/**
|
|
4275
4507
|
* Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
|
|
4276
4508
|
*/
|
|
4277
4509
|
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = [], resourceServer = this.resourceServers[0]) {
|
|
@@ -4343,60 +4575,6 @@ var OAuthProviderImpl = class {
|
|
|
4343
4575
|
}
|
|
4344
4576
|
};
|
|
4345
4577
|
/**
|
|
4346
|
-
* Structured OAuth 2.0 token-endpoint error.
|
|
4347
|
-
*
|
|
4348
|
-
* Throw from a `tokenExchangeCallback` or any code it calls to surface a
|
|
4349
|
-
* standard OAuth token response (`{ error, error_description }`) instead of a
|
|
4350
|
-
* generic `500 Internal Server Error`.
|
|
4351
|
-
*
|
|
4352
|
-
* Anything thrown that is **not** an `OAuthError` continues to surface as
|
|
4353
|
-
* a 500 so unexpected failures remain visible — the provider does not
|
|
4354
|
-
* catch-everything-and-return-400.
|
|
4355
|
-
*
|
|
4356
|
-
* @example
|
|
4357
|
-
* ```ts
|
|
4358
|
-
* import { OAuthError } from '@cloudflare/workers-oauth-provider';
|
|
4359
|
-
*
|
|
4360
|
-
* tokenExchangeCallback: async (options) => {
|
|
4361
|
-
* if (options.grantType === 'refresh_token') {
|
|
4362
|
-
* // refreshUpstream() may throw OAuthError from any depth
|
|
4363
|
-
* return { newProps: await refreshUpstream(options.props) };
|
|
4364
|
-
* }
|
|
4365
|
-
* }
|
|
4366
|
-
*
|
|
4367
|
-
* async function refreshUpstream(props) {
|
|
4368
|
-
* const res = await fetch(...);
|
|
4369
|
-
* if (res.status === 401) {
|
|
4370
|
-
* // invalid_grant can never recover: the provider also revokes this grant and its tokens.
|
|
4371
|
-
* throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
|
|
4372
|
-
* }
|
|
4373
|
-
* if (res.status === 429) {
|
|
4374
|
-
* // Mirror upstream's Retry-After if present, otherwise pick a default.
|
|
4375
|
-
* throw new OAuthError('temporarily_unavailable', {
|
|
4376
|
-
* description: 'upstream rate limited',
|
|
4377
|
-
* statusCode: 429,
|
|
4378
|
-
* headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
|
|
4379
|
-
* });
|
|
4380
|
-
* }
|
|
4381
|
-
* return await res.json();
|
|
4382
|
-
* }
|
|
4383
|
-
* ```
|
|
4384
|
-
*/
|
|
4385
|
-
var OAuthError = class extends Error {
|
|
4386
|
-
constructor(code, options) {
|
|
4387
|
-
super(options.description);
|
|
4388
|
-
this.name = "OAuthError";
|
|
4389
|
-
this.code = code;
|
|
4390
|
-
this.options = {
|
|
4391
|
-
...options,
|
|
4392
|
-
statusCode: options.statusCode ?? 400
|
|
4393
|
-
};
|
|
4394
|
-
this.description = this.options.description;
|
|
4395
|
-
this.statusCode = this.options.statusCode;
|
|
4396
|
-
this.headers = this.options.headers;
|
|
4397
|
-
}
|
|
4398
|
-
};
|
|
4399
|
-
/**
|
|
4400
4578
|
* Intentional public error from an external bearer-token validator.
|
|
4401
4579
|
*
|
|
4402
4580
|
* Throw only from `resolveExternalToken` when an expected validation outcome
|
|
@@ -4471,6 +4649,75 @@ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
|
|
|
4471
4649
|
* to clamp absolute expirations when writing grants back to KV.
|
|
4472
4650
|
*/
|
|
4473
4651
|
const KV_MIN_EXPIRATION_TTL_SECONDS = 60;
|
|
4652
|
+
/** Whether a registration refused without `none` on offer would otherwise have been a public client. */
|
|
4653
|
+
function wouldRegisterPublicClient(clientMetadata, server) {
|
|
4654
|
+
if (!clientMetadata) return false;
|
|
4655
|
+
try {
|
|
4656
|
+
return resolveDynamicClientRegistrationMetadata(clientMetadata, server).tokenEndpointAuthMethod === "none";
|
|
4657
|
+
} catch {
|
|
4658
|
+
return false;
|
|
4659
|
+
}
|
|
4660
|
+
}
|
|
4661
|
+
/** The combined provider's resource metadata with its required scopes resolved into `scopes_supported`. */
|
|
4662
|
+
function withRequiredScopes(metadata, requiredScopes) {
|
|
4663
|
+
if (!metadata || typeof metadata !== "object") return metadata;
|
|
4664
|
+
const { scopes_supported: deprecated, ...rest } = metadata;
|
|
4665
|
+
const scopes = resolveRequiredScopes(requiredScopes, deprecated);
|
|
4666
|
+
return scopes === void 0 ? rest : {
|
|
4667
|
+
...rest,
|
|
4668
|
+
scopes_supported: scopes
|
|
4669
|
+
};
|
|
4670
|
+
}
|
|
4671
|
+
/** A refresh-token lifetime: 0 for "issue no refresh token", else a lifetime KV can store. */
|
|
4672
|
+
function isValidRefreshTokenTTL(ttl) {
|
|
4673
|
+
return ttl === 0 || isValidAccessTokenTTL(ttl);
|
|
4674
|
+
}
|
|
4675
|
+
/**
|
|
4676
|
+
* Reads a `purgeExpiredData` cursor: the phase to resume and KV's opaque cursor within it
|
|
4677
|
+
* (empty for the start of that phase). No cursor starts a new sweep with the grants.
|
|
4678
|
+
*/
|
|
4679
|
+
function parsePurgeCursor(cursor) {
|
|
4680
|
+
if (cursor === void 0) return { phase: "grants" };
|
|
4681
|
+
const separator = cursor.indexOf(":");
|
|
4682
|
+
const phase = cursor.slice(0, separator);
|
|
4683
|
+
if (separator < 0 || phase !== "grants" && phase !== "tokens") throw new TypeError("Invalid purgeExpiredData cursor");
|
|
4684
|
+
const kvCursor = cursor.slice(separator + 1);
|
|
4685
|
+
return kvCursor ? {
|
|
4686
|
+
phase,
|
|
4687
|
+
kvCursor
|
|
4688
|
+
} : { phase };
|
|
4689
|
+
}
|
|
4690
|
+
/**
|
|
4691
|
+
* RFC 6749 §6: a token request may narrow the grant's scopes. One that names scopes but none the
|
|
4692
|
+
* grant holds would otherwise get a token with no scope at all; it is `invalid_scope` instead.
|
|
4693
|
+
*/
|
|
4694
|
+
function grantsNoneOfTheRequestedScopes(requested, granted) {
|
|
4695
|
+
return (typeof requested === "string" ? requested.split(" ").filter(Boolean) : requested ?? []).length > 0 && granted.length === 0;
|
|
4696
|
+
}
|
|
4697
|
+
/** Reads a request body as text, or returns undefined once it passes `maxBytes`. */
|
|
4698
|
+
async function readTextWithLimit(request, maxBytes) {
|
|
4699
|
+
const reader = request.body?.getReader();
|
|
4700
|
+
if (!reader) return "";
|
|
4701
|
+
const chunks = [];
|
|
4702
|
+
let total = 0;
|
|
4703
|
+
for (;;) {
|
|
4704
|
+
const { done, value } = await reader.read();
|
|
4705
|
+
if (done) break;
|
|
4706
|
+
total += value.byteLength;
|
|
4707
|
+
if (total > maxBytes) {
|
|
4708
|
+
reader.cancel().catch(() => void 0);
|
|
4709
|
+
return;
|
|
4710
|
+
}
|
|
4711
|
+
chunks.push(value);
|
|
4712
|
+
}
|
|
4713
|
+
const bytes = new Uint8Array(total);
|
|
4714
|
+
let offset = 0;
|
|
4715
|
+
for (const chunk of chunks) {
|
|
4716
|
+
bytes.set(chunk, offset);
|
|
4717
|
+
offset += chunk.byteLength;
|
|
4718
|
+
}
|
|
4719
|
+
return new TextDecoder().decode(bytes);
|
|
4720
|
+
}
|
|
4474
4721
|
function isValidAccessTokenTTL(value) {
|
|
4475
4722
|
return Number.isInteger(value) && value >= KV_MIN_EXPIRATION_TTL_SECONDS;
|
|
4476
4723
|
}
|
|
@@ -4497,11 +4744,6 @@ const MAX_KV_LIST_LIMIT = 1e3;
|
|
|
4497
4744
|
* matched from `list()` alone and never read.
|
|
4498
4745
|
*/
|
|
4499
4746
|
const DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE = 50;
|
|
4500
|
-
function getRevokeExistingGrantsBatchSize(batchSize) {
|
|
4501
|
-
if (batchSize === void 0) return DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE;
|
|
4502
|
-
if (!Number.isFinite(batchSize) || !Number.isInteger(batchSize) || batchSize < 1) throw new Error("revokeExistingGrantsBatchSize must be a positive integer.");
|
|
4503
|
-
return Math.min(batchSize, MAX_KV_LIST_LIMIT);
|
|
4504
|
-
}
|
|
4505
4747
|
/** KV serialises key metadata as JSON and rejects a write carrying more than this. */
|
|
4506
4748
|
const MAX_KV_METADATA_BYTES = 1024;
|
|
4507
4749
|
/**
|
|
@@ -4524,6 +4766,16 @@ function grantPutOptions(grant, expiry = {}) {
|
|
|
4524
4766
|
metadata
|
|
4525
4767
|
} : expiry;
|
|
4526
4768
|
}
|
|
4769
|
+
/** Splits `grant:{userId}:{grantId}`. Grant IDs never contain `:`, so the last one separates them. */
|
|
4770
|
+
function parseGrantKey(name) {
|
|
4771
|
+
const rest = name.slice(6);
|
|
4772
|
+
const separator = rest.lastIndexOf(":");
|
|
4773
|
+
if (!name.startsWith("grant:") || separator <= 0 || separator === rest.length - 1) return void 0;
|
|
4774
|
+
return {
|
|
4775
|
+
userId: rest.slice(0, separator),
|
|
4776
|
+
grantId: rest.slice(separator + 1)
|
|
4777
|
+
};
|
|
4778
|
+
}
|
|
4527
4779
|
/** Whether a listed key carries metadata this version can match a grant on. */
|
|
4528
4780
|
function isGrantKeyMetadata(value) {
|
|
4529
4781
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -4534,6 +4786,8 @@ function isGrantKeyMetadata(value) {
|
|
|
4534
4786
|
* Length of generated token strings
|
|
4535
4787
|
*/
|
|
4536
4788
|
const TOKEN_LENGTH = 32;
|
|
4789
|
+
const ISSUED_GRANT_ID_PATTERN = /^[A-Za-z0-9_-]{16}$/;
|
|
4790
|
+
const ISSUED_TOKEN_SECRET_PATTERN = /* @__PURE__ */ new RegExp(`^[A-Za-z0-9_-]{${TOKEN_LENGTH}}$`);
|
|
4537
4791
|
/**
|
|
4538
4792
|
* Checks if a resource server matches an audience claim.
|
|
4539
4793
|
* Uses origin comparison (case-insensitive hostname via URL normalization)
|
|
@@ -4555,11 +4809,6 @@ function audienceMatches(resourceServerUrl, audienceValue) {
|
|
|
4555
4809
|
return false;
|
|
4556
4810
|
}
|
|
4557
4811
|
}
|
|
4558
|
-
function appendHeaderValue(headers, name, value) {
|
|
4559
|
-
const values = (headers.get(name) ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
4560
|
-
if (!values.some((item) => item.toLowerCase() === value.toLowerCase())) values.push(value);
|
|
4561
|
-
headers.set(name, values.join(", "));
|
|
4562
|
-
}
|
|
4563
4812
|
/** Whether a request or audience names one canonical configured resource. */
|
|
4564
4813
|
function isExactResource(value, configuredResource) {
|
|
4565
4814
|
if (Array.isArray(value)) return value.some((entry) => typeof entry === "string" && resourceMatches(entry, configuredResource));
|
|
@@ -4619,6 +4868,20 @@ function decodeFormUrlEncodedComponent(value) {
|
|
|
4619
4868
|
return decodeURIComponent(value.replace(/\+/g, " "));
|
|
4620
4869
|
}
|
|
4621
4870
|
/**
|
|
4871
|
+
* Whether a bearer credential has the exact shape of a token this provider issues:
|
|
4872
|
+
* `{userId}:{grantId}:{secret}`, with a 16-character grant ID and a TOKEN_LENGTH secret from
|
|
4873
|
+
* generateRandomString's alphabet. Parsed from the right, because a deployer's user ID may itself
|
|
4874
|
+
* contain `:`. An external credential with exactly this shape would be treated as ours; opaque API
|
|
4875
|
+
* keys, PATs and JWTs don't have it.
|
|
4876
|
+
*/
|
|
4877
|
+
function isIssuedTokenFormat(token) {
|
|
4878
|
+
const parts = token.split(":");
|
|
4879
|
+
if (parts.length < 3) return false;
|
|
4880
|
+
const secret = parts[parts.length - 1];
|
|
4881
|
+
const grantId = parts[parts.length - 2];
|
|
4882
|
+
return parts.slice(0, -2).join(":").length > 0 && ISSUED_GRANT_ID_PATTERN.test(grantId) && ISSUED_TOKEN_SECRET_PATTERN.test(secret);
|
|
4883
|
+
}
|
|
4884
|
+
/**
|
|
4622
4885
|
* Generates a cryptographically secure random string
|
|
4623
4886
|
* @param length - The length of the string to generate
|
|
4624
4887
|
* @returns A random string of the specified length
|
|
@@ -4679,56 +4942,6 @@ function base64UrlEncode(str) {
|
|
|
4679
4942
|
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
4680
4943
|
}
|
|
4681
4944
|
/**
|
|
4682
|
-
* Decodes a base64url-encoded string to bytes.
|
|
4683
|
-
*/
|
|
4684
|
-
function base64UrlToBytes(base64Url) {
|
|
4685
|
-
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
|
4686
|
-
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
4687
|
-
const binaryString = atob(padded);
|
|
4688
|
-
const bytes = new Uint8Array(binaryString.length);
|
|
4689
|
-
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
|
|
4690
|
-
return bytes;
|
|
4691
|
-
}
|
|
4692
|
-
/**
|
|
4693
|
-
* Parses a base64url-encoded JWT JSON part into an object.
|
|
4694
|
-
*/
|
|
4695
|
-
function parseJwtJsonPart(encoded) {
|
|
4696
|
-
try {
|
|
4697
|
-
const json = new TextDecoder().decode(base64UrlToBytes(encoded));
|
|
4698
|
-
const parsed = JSON.parse(json);
|
|
4699
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JWT part must be an object");
|
|
4700
|
-
return parsed;
|
|
4701
|
-
} catch {
|
|
4702
|
-
throw new Error("Malformed JWT part");
|
|
4703
|
-
}
|
|
4704
|
-
}
|
|
4705
|
-
/**
|
|
4706
|
-
* Gets WebCrypto import and verify parameters for supported JOSE algorithms.
|
|
4707
|
-
*/
|
|
4708
|
-
function getJwtCryptoAlgorithms(alg) {
|
|
4709
|
-
if (alg === "RS256") {
|
|
4710
|
-
const algorithm = {
|
|
4711
|
-
name: "RSASSA-PKCS1-v1_5",
|
|
4712
|
-
hash: "SHA-256"
|
|
4713
|
-
};
|
|
4714
|
-
return {
|
|
4715
|
-
importAlgorithm: algorithm,
|
|
4716
|
-
verifyAlgorithm: algorithm
|
|
4717
|
-
};
|
|
4718
|
-
}
|
|
4719
|
-
if (alg === "ES256") return {
|
|
4720
|
-
importAlgorithm: {
|
|
4721
|
-
name: "ECDSA",
|
|
4722
|
-
namedCurve: "P-256"
|
|
4723
|
-
},
|
|
4724
|
-
verifyAlgorithm: {
|
|
4725
|
-
name: "ECDSA",
|
|
4726
|
-
hash: "SHA-256"
|
|
4727
|
-
}
|
|
4728
|
-
};
|
|
4729
|
-
throw new Error(`Unsupported JWT alg: ${alg}`);
|
|
4730
|
-
}
|
|
4731
|
-
/**
|
|
4732
4945
|
* Encodes an ArrayBuffer as base64 string
|
|
4733
4946
|
* @param buffer - The ArrayBuffer to encode
|
|
4734
4947
|
* @returns The base64 encoded string
|
|
@@ -4893,7 +5106,7 @@ var OAuthHelpersImpl = class {
|
|
|
4893
5106
|
const clientInfo = await this.lookupClient(clientId);
|
|
4894
5107
|
if (!clientInfo) throw new AuthorizationError("invalid_request", { description: "Invalid client_id" });
|
|
4895
5108
|
try {
|
|
4896
|
-
|
|
5109
|
+
validateRedirectUri(redirectUri, this.provider.serverCapabilities);
|
|
4897
5110
|
} catch {
|
|
4898
5111
|
throw new AuthorizationError("invalid_request", { description: "Invalid redirect URI" });
|
|
4899
5112
|
}
|
|
@@ -4947,9 +5160,7 @@ var OAuthHelpersImpl = class {
|
|
|
4947
5160
|
return client ? toPublicClientInfo(client) : null;
|
|
4948
5161
|
}
|
|
4949
5162
|
/**
|
|
4950
|
-
* Completes an authorization request by creating a grant and
|
|
4951
|
-
* - For authorization code flow: generating an authorization code
|
|
4952
|
-
* - For implicit flow: generating an access token directly
|
|
5163
|
+
* Completes an authorization request by creating a grant and an authorization code.
|
|
4953
5164
|
* @param options - Options specifying the grant details
|
|
4954
5165
|
* @returns A Promise resolving to an object containing the redirect URL
|
|
4955
5166
|
* @throws Error when the request's response type is not permitted
|
|
@@ -4958,102 +5169,55 @@ var OAuthHelpersImpl = class {
|
|
|
4958
5169
|
async completeAuthorization(options) {
|
|
4959
5170
|
const { clientId, redirectUri } = options.request;
|
|
4960
5171
|
if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
|
|
5172
|
+
if (typeof options.userId !== "string" || options.userId.length === 0 || options.userId.includes(":")) throw new TypeError("userId must be a non-empty string without \":\"");
|
|
4961
5173
|
const clientInfo = await this.lookupClient(clientId);
|
|
4962
5174
|
if (!clientInfo || !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error("Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.");
|
|
5175
|
+
try {
|
|
5176
|
+
validateRedirectUri(redirectUri, this.provider.serverCapabilities);
|
|
5177
|
+
} catch {
|
|
5178
|
+
throw new AuthorizationError("invalid_request", { description: "Invalid redirect URI" });
|
|
5179
|
+
}
|
|
4963
5180
|
validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
|
|
4964
5181
|
const effectiveResource = this.provider.resolveAuthorizationRequestResource(options.request.resource);
|
|
4965
5182
|
validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
|
|
4966
5183
|
let grantsToRevoke = [];
|
|
4967
5184
|
if (options.revokeExistingGrants !== false) {
|
|
4968
|
-
const isCimdClient =
|
|
4969
|
-
const readConcurrency =
|
|
4970
|
-
grantsToRevoke = await this.findGrantIds(options.userId, (grant) => grant.clientId === clientId && (!isCimdClient || grant.redirectUri === options.request.redirectUri) && this.provider.shouldReplaceGrantForResource(grant.resource, effectiveResource), readConcurrency);
|
|
5185
|
+
const isCimdClient = isClientIdMetadataDocumentUrl(clientId);
|
|
5186
|
+
const readConcurrency = DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE;
|
|
5187
|
+
grantsToRevoke = await this.findGrantIds(options.userId, (grant$1) => grant$1.clientId === clientId && (!isCimdClient || grant$1.redirectUri === options.request.redirectUri) && this.provider.shouldReplaceGrantForResource(grant$1.resource, effectiveResource), readConcurrency);
|
|
4971
5188
|
}
|
|
4972
5189
|
const grantId = generateRandomString(16);
|
|
4973
5190
|
const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
|
|
4974
5191
|
const now = Math.floor(Date.now() / 1e3);
|
|
4975
|
-
|
|
4976
|
-
|
|
4977
|
-
|
|
4978
|
-
|
|
4979
|
-
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5003
|
-
|
|
5004
|
-
wrappedEncryptionKey: accessTokenWrappedKey,
|
|
5005
|
-
grant: {
|
|
5006
|
-
clientId: options.request.clientId,
|
|
5007
|
-
scope: options.scope,
|
|
5008
|
-
encryptedProps: encryptedData
|
|
5009
|
-
}
|
|
5010
|
-
};
|
|
5011
|
-
await this.env.OAUTH_KV.put(`token:${options.userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
|
|
5012
|
-
const redirectUrl = new URL(options.request.redirectUri);
|
|
5013
|
-
const fragment = new URLSearchParams();
|
|
5014
|
-
fragment.set("access_token", accessToken);
|
|
5015
|
-
fragment.set("token_type", "bearer");
|
|
5016
|
-
fragment.set("expires_in", accessTokenTTL.toString());
|
|
5017
|
-
fragment.set("scope", options.scope.join(" "));
|
|
5018
|
-
fragment.set("resource", effectiveResource);
|
|
5019
|
-
if (options.request.state) fragment.set("state", options.request.state);
|
|
5020
|
-
if (options.request.issuer) fragment.set("iss", options.request.issuer);
|
|
5021
|
-
redirectUrl.hash = fragment.toString();
|
|
5022
|
-
try {
|
|
5023
|
-
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
5024
|
-
} catch {}
|
|
5025
|
-
return { redirectTo: redirectUrl.toString() };
|
|
5026
|
-
} else {
|
|
5027
|
-
const authCodeSecret = generateRandomString(32);
|
|
5028
|
-
const authCode = `${options.userId}:${grantId}:${authCodeSecret}`;
|
|
5029
|
-
const authCodeId = await hashSecret(authCode);
|
|
5030
|
-
const authCodeWrappedKey = await wrapKeyWithToken(authCode, encryptionKey);
|
|
5031
|
-
const grant = {
|
|
5032
|
-
id: grantId,
|
|
5033
|
-
clientId: options.request.clientId,
|
|
5034
|
-
userId: options.userId,
|
|
5035
|
-
scope: options.scope,
|
|
5036
|
-
metadata: options.metadata,
|
|
5037
|
-
encryptedProps: encryptedData,
|
|
5038
|
-
createdAt: now,
|
|
5039
|
-
authCodeId,
|
|
5040
|
-
authCodeWrappedKey,
|
|
5041
|
-
codeChallenge: options.request.codeChallenge,
|
|
5042
|
-
codeChallengeMethod: options.request.codeChallengeMethod,
|
|
5043
|
-
resource: effectiveResource,
|
|
5044
|
-
redirectUri: options.request.redirectUri
|
|
5045
|
-
};
|
|
5046
|
-
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
5047
|
-
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant, { expirationTtl: 600 }));
|
|
5048
|
-
const redirectUrl = new URL(options.request.redirectUri);
|
|
5049
|
-
redirectUrl.searchParams.set("code", authCode);
|
|
5050
|
-
if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
|
|
5051
|
-
if (options.request.issuer) redirectUrl.searchParams.set("iss", options.request.issuer);
|
|
5052
|
-
try {
|
|
5053
|
-
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
5054
|
-
} catch {}
|
|
5055
|
-
return { redirectTo: redirectUrl.toString() };
|
|
5056
|
-
}
|
|
5192
|
+
const authCodeSecret = generateRandomString(32);
|
|
5193
|
+
const authCode = `${options.userId}:${grantId}:${authCodeSecret}`;
|
|
5194
|
+
const authCodeId = await hashSecret(authCode);
|
|
5195
|
+
const authCodeWrappedKey = await wrapKeyWithToken(authCode, encryptionKey);
|
|
5196
|
+
const grant = {
|
|
5197
|
+
id: grantId,
|
|
5198
|
+
clientId: options.request.clientId,
|
|
5199
|
+
userId: options.userId,
|
|
5200
|
+
scope: options.scope,
|
|
5201
|
+
metadata: options.metadata,
|
|
5202
|
+
encryptedProps: encryptedData,
|
|
5203
|
+
createdAt: now,
|
|
5204
|
+
authCodeId,
|
|
5205
|
+
authCodeWrappedKey,
|
|
5206
|
+
codeChallenge: options.request.codeChallenge,
|
|
5207
|
+
codeChallengeMethod: options.request.codeChallengeMethod,
|
|
5208
|
+
resource: effectiveResource,
|
|
5209
|
+
redirectUri: options.request.redirectUri
|
|
5210
|
+
};
|
|
5211
|
+
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
5212
|
+
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant, { expirationTtl: 600 }));
|
|
5213
|
+
const redirectUrl = new URL(options.request.redirectUri);
|
|
5214
|
+
redirectUrl.searchParams.set("code", authCode);
|
|
5215
|
+
if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
|
|
5216
|
+
if (options.request.issuer) redirectUrl.searchParams.set("iss", options.request.issuer);
|
|
5217
|
+
try {
|
|
5218
|
+
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
5219
|
+
} catch {}
|
|
5220
|
+
return { redirectTo: redirectUrl.toString() };
|
|
5057
5221
|
}
|
|
5058
5222
|
/**
|
|
5059
5223
|
* IDs of the user's grants that satisfy `matches`, judged from KV key metadata
|
|
@@ -5067,8 +5231,18 @@ var OAuthHelpersImpl = class {
|
|
|
5067
5231
|
* share shrinks on its own.
|
|
5068
5232
|
*/
|
|
5069
5233
|
async findGrantIds(userId, matches, readConcurrency) {
|
|
5070
|
-
const
|
|
5071
|
-
const
|
|
5234
|
+
const grantIds = [];
|
|
5235
|
+
for await (const grants of this.findGrants(userId, matches, readConcurrency)) grantIds.push(...grants.map((grant) => grant.grantId));
|
|
5236
|
+
return grantIds;
|
|
5237
|
+
}
|
|
5238
|
+
/**
|
|
5239
|
+
* Finds grants matching `matches`, for one user or, with `userId` undefined, for every user.
|
|
5240
|
+
* Metadata-carrying keys are matched without a read; legacy grants are read,
|
|
5241
|
+
* `readConcurrency` at a time. Matches are yielded as they are found (each page's metadata
|
|
5242
|
+
* matches, then each batch of reads), so a caller can act on them before the scan goes on.
|
|
5243
|
+
*/
|
|
5244
|
+
async *findGrants(userId, matches, readConcurrency) {
|
|
5245
|
+
const prefix = userId === void 0 ? "grant:" : `grant:${userId}:`;
|
|
5072
5246
|
let cursor;
|
|
5073
5247
|
do {
|
|
5074
5248
|
const page = await this.env.OAUTH_KV.list({
|
|
@@ -5077,16 +5251,32 @@ var OAuthHelpersImpl = class {
|
|
|
5077
5251
|
cursor
|
|
5078
5252
|
});
|
|
5079
5253
|
const toRead = [];
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5254
|
+
const found = [];
|
|
5255
|
+
for (const key of page.keys) {
|
|
5256
|
+
const owner = parseGrantKey(key.name);
|
|
5257
|
+
if (!owner || userId !== void 0 && owner.userId !== userId) continue;
|
|
5258
|
+
if (isGrantKeyMetadata(key.metadata)) {
|
|
5259
|
+
if (matches(key.metadata)) found.push(owner);
|
|
5260
|
+
} else toRead.push({
|
|
5261
|
+
name: key.name,
|
|
5262
|
+
...owner
|
|
5263
|
+
});
|
|
5264
|
+
}
|
|
5265
|
+
if (found.length > 0) yield found;
|
|
5083
5266
|
for (let start = 0; start < toRead.length; start += readConcurrency) {
|
|
5084
|
-
const
|
|
5085
|
-
|
|
5267
|
+
const batch = toRead.slice(start, start + readConcurrency);
|
|
5268
|
+
const grants = await Promise.all(batch.map(({ name }) => this.env.OAUTH_KV.get(name, { type: "json" })));
|
|
5269
|
+
const read = batch.filter((_, index) => {
|
|
5270
|
+
const grant = grants[index];
|
|
5271
|
+
return grant !== null && matches(grant);
|
|
5272
|
+
});
|
|
5273
|
+
if (read.length > 0) yield read.map(({ userId: userId$1, grantId }) => ({
|
|
5274
|
+
userId: userId$1,
|
|
5275
|
+
grantId
|
|
5276
|
+
}));
|
|
5086
5277
|
}
|
|
5087
5278
|
cursor = page.list_complete ? void 0 : page.cursor;
|
|
5088
5279
|
} while (cursor);
|
|
5089
|
-
return found;
|
|
5090
5280
|
}
|
|
5091
5281
|
/**
|
|
5092
5282
|
* Creates a new OAuth client
|
|
@@ -5119,7 +5309,12 @@ var OAuthHelpersImpl = class {
|
|
|
5119
5309
|
tokenEndpointAuthMethod,
|
|
5120
5310
|
...authMethodWasExplicit ? { authMethodExplicit: true } : {}
|
|
5121
5311
|
};
|
|
5122
|
-
for (const uri of newClient.redirectUris)
|
|
5312
|
+
for (const uri of newClient.redirectUris) validateRedirectUri(uri, this.provider.serverCapabilities);
|
|
5313
|
+
validateClientCapabilities(this.provider.serverCapabilities, {
|
|
5314
|
+
grantTypes: newClient.grantTypes,
|
|
5315
|
+
responseTypes: newClient.responseTypes,
|
|
5316
|
+
tokenEndpointAuthMethod
|
|
5317
|
+
});
|
|
5123
5318
|
let clientSecret;
|
|
5124
5319
|
if (!isPublicClient) {
|
|
5125
5320
|
clientSecret = generateRandomString(32);
|
|
@@ -5159,8 +5354,13 @@ var OAuthHelpersImpl = class {
|
|
|
5159
5354
|
* @returns A Promise resolving to the updated client info, or null if not found
|
|
5160
5355
|
*/
|
|
5161
5356
|
async updateClient(clientId, updates) {
|
|
5357
|
+
if (this.provider.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(clientId)) throw new TypeError("Client ID Metadata Document clients are updated by changing their document");
|
|
5162
5358
|
const client = await this.provider.getClient(this.env, clientId);
|
|
5163
5359
|
if (!client) return null;
|
|
5360
|
+
if (updates.redirectUris !== void 0) {
|
|
5361
|
+
if (!Array.isArray(updates.redirectUris) || updates.redirectUris.length === 0) throw new Error("redirectUris must not be empty");
|
|
5362
|
+
for (const uri of updates.redirectUris) validateRedirectUri(uri, this.provider.serverCapabilities);
|
|
5363
|
+
}
|
|
5164
5364
|
const authMethodWasExplicit = updates.tokenEndpointAuthMethod !== void 0;
|
|
5165
5365
|
const authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
|
|
5166
5366
|
const isPublicClient = authMethod === "none";
|
|
@@ -5178,6 +5378,11 @@ var OAuthHelpersImpl = class {
|
|
|
5178
5378
|
tokenEndpointAuthMethod: authMethod,
|
|
5179
5379
|
authMethodExplicit: authMethodWasExplicit ? true : client.authMethodExplicit
|
|
5180
5380
|
};
|
|
5381
|
+
if (updates.grantTypes !== void 0 || updates.responseTypes !== void 0 || updates.tokenEndpointAuthMethod !== void 0) validateClientCapabilities(this.provider.serverCapabilities, {
|
|
5382
|
+
grantTypes: updatedClient.grantTypes ?? [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN],
|
|
5383
|
+
responseTypes: updatedClient.responseTypes ?? ["code"],
|
|
5384
|
+
tokenEndpointAuthMethod: authMethod
|
|
5385
|
+
});
|
|
5181
5386
|
if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
|
|
5182
5387
|
else delete updatedClient.clientSecret;
|
|
5183
5388
|
if (client.registrationExpiresAt === void 0) await this.env.OAUTH_KV.put(`client:${updatedClient.clientId}`, JSON.stringify(updatedClient));
|
|
@@ -5192,20 +5397,9 @@ var OAuthHelpersImpl = class {
|
|
|
5192
5397
|
* @returns A Promise resolving when the deletion is confirmed.
|
|
5193
5398
|
*/
|
|
5194
5399
|
async deleteClient(clientId) {
|
|
5195
|
-
let cursor;
|
|
5196
|
-
let allProcessed = false;
|
|
5197
|
-
while (!allProcessed) {
|
|
5198
|
-
const listOptions = { prefix: "grant:" };
|
|
5199
|
-
if (cursor) listOptions.cursor = cursor;
|
|
5200
|
-
const result = await this.env.OAUTH_KV.list(listOptions);
|
|
5201
|
-
for (const key of result.keys) {
|
|
5202
|
-
const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
|
|
5203
|
-
if (grantData && grantData.clientId === clientId) await this.revokeGrant(grantData.id, grantData.userId);
|
|
5204
|
-
}
|
|
5205
|
-
if (result.list_complete) allProcessed = true;
|
|
5206
|
-
else cursor = result.cursor;
|
|
5207
|
-
}
|
|
5208
5400
|
await this.env.OAUTH_KV.delete(`client:${clientId}`);
|
|
5401
|
+
const grants = this.findGrants(void 0, (grant) => grant.clientId === clientId, DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE);
|
|
5402
|
+
for await (const found of grants) for (const { userId, grantId } of found) await this.revokeGrant(grantId, userId);
|
|
5209
5403
|
}
|
|
5210
5404
|
/**
|
|
5211
5405
|
* Lists all authorization grants for a specific user with pagination support
|
|
@@ -5218,9 +5412,18 @@ var OAuthHelpersImpl = class {
|
|
|
5218
5412
|
const listOptions = { prefix: `grant:${userId}:` };
|
|
5219
5413
|
if (options?.limit !== void 0) listOptions.limit = options.limit;
|
|
5220
5414
|
if (options?.cursor !== void 0) listOptions.cursor = options.cursor;
|
|
5221
|
-
const
|
|
5415
|
+
const isOwn = (key) => !key.name.slice(listOptions.prefix.length).includes(":");
|
|
5416
|
+
let response = await this.env.OAUTH_KV.list(listOptions);
|
|
5417
|
+
let ownKeys = response.keys.filter(isOwn);
|
|
5418
|
+
while (ownKeys.length === 0 && !response.list_complete) {
|
|
5419
|
+
response = await this.env.OAUTH_KV.list({
|
|
5420
|
+
...listOptions,
|
|
5421
|
+
cursor: response.cursor
|
|
5422
|
+
});
|
|
5423
|
+
ownKeys = response.keys.filter(isOwn);
|
|
5424
|
+
}
|
|
5222
5425
|
const grantSummaries = [];
|
|
5223
|
-
const promises =
|
|
5426
|
+
const promises = ownKeys.map(async (key) => {
|
|
5224
5427
|
const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
|
|
5225
5428
|
if (grantData) {
|
|
5226
5429
|
const summary = {
|
|
@@ -5249,6 +5452,12 @@ var OAuthHelpersImpl = class {
|
|
|
5249
5452
|
* @param userId - The ID of the user who owns the grant
|
|
5250
5453
|
* @returns A Promise resolving when the revocation is confirmed.
|
|
5251
5454
|
*/
|
|
5455
|
+
/** {@inheritDoc OAuthHelpers.describeConsent} */
|
|
5456
|
+
async describeConsent(authRequest) {
|
|
5457
|
+
const client = await this.lookupClient(authRequest.clientId);
|
|
5458
|
+
const isCimd = !!this.provider.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(authRequest.clientId);
|
|
5459
|
+
return describeConsent(client, authRequest, isCimd);
|
|
5460
|
+
}
|
|
5252
5461
|
/** {@inheritDoc OAuthHelpers.isConsentRemembered} */
|
|
5253
5462
|
isConsentRemembered(request, authRequest, remember) {
|
|
5254
5463
|
return isConsentRemembered(this.provider.consentCookies, request, authRequest, remember);
|
|
@@ -5317,6 +5526,7 @@ var OAuthHelpersImpl = class {
|
|
|
5317
5526
|
const purgeOrphanedGrants = options?.purgeOrphanedGrants !== false;
|
|
5318
5527
|
const purgeExpiredGrants = options?.purgeExpiredGrants !== false;
|
|
5319
5528
|
const purgeOrphanedTokens = options?.purgeOrphanedTokens !== false;
|
|
5529
|
+
const start = parsePurgeCursor(options?.cursor);
|
|
5320
5530
|
const now = Math.floor(Date.now() / 1e3);
|
|
5321
5531
|
const result = {
|
|
5322
5532
|
grantsChecked: 0,
|
|
@@ -5325,10 +5535,10 @@ var OAuthHelpersImpl = class {
|
|
|
5325
5535
|
tokensPurged: 0,
|
|
5326
5536
|
done: false
|
|
5327
5537
|
};
|
|
5328
|
-
if (purgeOrphanedGrants || purgeExpiredGrants) {
|
|
5538
|
+
if (start.phase === "grants" && (purgeOrphanedGrants || purgeExpiredGrants)) {
|
|
5329
5539
|
const knownGoodClients = /* @__PURE__ */ new Set();
|
|
5330
5540
|
const knownMissingClients = /* @__PURE__ */ new Set();
|
|
5331
|
-
let grantCursor;
|
|
5541
|
+
let grantCursor = start.kvCursor;
|
|
5332
5542
|
let grantsDone = false;
|
|
5333
5543
|
while (!grantsDone && result.grantsChecked < batchSize) {
|
|
5334
5544
|
const listOptions = {
|
|
@@ -5344,7 +5554,7 @@ var OAuthHelpersImpl = class {
|
|
|
5344
5554
|
if (!grantData) continue;
|
|
5345
5555
|
let shouldPurge = false;
|
|
5346
5556
|
if (purgeExpiredGrants && grantData.expiresAt !== void 0 && now >= grantData.expiresAt) shouldPurge = true;
|
|
5347
|
-
if (!shouldPurge && purgeOrphanedGrants && !
|
|
5557
|
+
if (!shouldPurge && purgeOrphanedGrants && !isClientIdMetadataDocumentUrl(grantData.clientId)) {
|
|
5348
5558
|
if (knownMissingClients.has(grantData.clientId)) shouldPurge = true;
|
|
5349
5559
|
else if (!knownGoodClients.has(grantData.clientId)) if (await this.env.OAUTH_KV.get(`client:${grantData.clientId}`, { type: "json" })) knownGoodClients.add(grantData.clientId);
|
|
5350
5560
|
else {
|
|
@@ -5360,12 +5570,15 @@ var OAuthHelpersImpl = class {
|
|
|
5360
5570
|
if (page.list_complete) grantsDone = true;
|
|
5361
5571
|
else grantCursor = page.cursor;
|
|
5362
5572
|
}
|
|
5363
|
-
if (!grantsDone) return
|
|
5573
|
+
if (!grantsDone) return {
|
|
5574
|
+
...result,
|
|
5575
|
+
cursor: `grants:${grantCursor ?? ""}`
|
|
5576
|
+
};
|
|
5364
5577
|
}
|
|
5365
5578
|
if (purgeOrphanedTokens) {
|
|
5366
5579
|
const knownGoodGrants = /* @__PURE__ */ new Set();
|
|
5367
5580
|
const knownMissingGrants = /* @__PURE__ */ new Set();
|
|
5368
|
-
let tokenCursor;
|
|
5581
|
+
let tokenCursor = start.phase === "tokens" ? start.kvCursor : void 0;
|
|
5369
5582
|
let tokensDone = false;
|
|
5370
5583
|
while (!tokensDone && result.tokensChecked < batchSize) {
|
|
5371
5584
|
const listOptions = {
|
|
@@ -5393,7 +5606,10 @@ var OAuthHelpersImpl = class {
|
|
|
5393
5606
|
if (page.list_complete) tokensDone = true;
|
|
5394
5607
|
else tokenCursor = page.cursor;
|
|
5395
5608
|
}
|
|
5396
|
-
if (!tokensDone) return
|
|
5609
|
+
if (!tokensDone) return {
|
|
5610
|
+
...result,
|
|
5611
|
+
cursor: `tokens:${tokenCursor ?? ""}`
|
|
5612
|
+
};
|
|
5397
5613
|
}
|
|
5398
5614
|
result.done = true;
|
|
5399
5615
|
return result;
|
|
@@ -5406,4 +5622,4 @@ var OAuthHelpersImpl = class {
|
|
|
5406
5622
|
var oauth_provider_default = OAuthProvider;
|
|
5407
5623
|
|
|
5408
5624
|
//#endregion
|
|
5409
|
-
export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer,
|
|
5625
|
+
export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer, authorizationErrorRedirect, oauth_provider_default as default, getOAuthApi, insufficientScope };
|