@cloudflare/workers-oauth-provider 1.1.0 → 1.2.1
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 +296 -179
- package/dist/oauth-provider.js +748 -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,65 @@ 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
|
+
* The checks every redirect URI a client registers passes, whether or not this server would send a
|
|
520
|
+
* code to it: the {@link validateRedirectUriSafety} checks, a parseable URI, and no fragment (not
|
|
521
|
+
* even an empty `#`) or userinfo.
|
|
522
|
+
* @throws Error when the redirect URI can't be registered at all
|
|
523
|
+
*/
|
|
524
|
+
function validateListedRedirectUri(redirectUri) {
|
|
525
|
+
validateRedirectUriSafety(redirectUri);
|
|
526
|
+
const normalized = redirectUri.trim();
|
|
527
|
+
let url;
|
|
528
|
+
try {
|
|
529
|
+
url = new URL(normalized);
|
|
530
|
+
} catch {
|
|
531
|
+
throw new Error("Invalid redirect URI");
|
|
532
|
+
}
|
|
533
|
+
if (normalized.includes("#") || url.username || url.password) throw new Error("Invalid redirect URI");
|
|
534
|
+
return url;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Validates a redirect URI against the MCP / OAuth 2.1 redirect policy: `https`, or `http` on a
|
|
538
|
+
* loopback host (`localhost`, `127.0.0.0/8`, `::1`), with no userinfo and no fragment (not even an
|
|
539
|
+
* empty `#`). RFC 8252 private-use schemes are accepted only when the server enables
|
|
540
|
+
* `allowPrivateUseRedirectUris`; remote `http` never is. The {@link validateRedirectUriSafety}
|
|
541
|
+
* checks always apply.
|
|
542
|
+
* @throws Error describing why the redirect URI is not acceptable
|
|
543
|
+
*/
|
|
544
|
+
function validateRedirectUri(redirectUri, server) {
|
|
545
|
+
const url = validateListedRedirectUri(redirectUri);
|
|
546
|
+
if (url.protocol === "https:") return;
|
|
547
|
+
if (url.protocol === "http:") {
|
|
548
|
+
if (isLoopbackHostname(url.hostname)) return;
|
|
549
|
+
throw new Error("Redirect URI must use https, or http on a loopback host");
|
|
550
|
+
}
|
|
551
|
+
if (!server.allowPrivateUseRedirectUris) throw new Error("Redirect URI must use https, or http on a loopback host");
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Validates the redirect URIs a client registers (dynamic registration, `createClient()`,
|
|
555
|
+
* `updateClient()`). A client may list callbacks for surfaces this server won't send codes to:
|
|
556
|
+
* Cursor lists `cursor://anysphere.cursor-mcp/oauth/callback` next to its https and loopback
|
|
557
|
+
* callbacks. So each URI is held to {@link validateListedRedirectUri}, and at least one must satisfy
|
|
558
|
+
* {@link validateRedirectUri} so the client can sign in. The URI a request uses is held to the full
|
|
559
|
+
* policy by `parseAuthRequest()` and `completeAuthorization()`.
|
|
560
|
+
* @throws Error when a URI can't be registered, or none of them could ever receive a code
|
|
561
|
+
*/
|
|
562
|
+
function validateRegisteredRedirectUris(redirectUris, server) {
|
|
563
|
+
if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
|
|
564
|
+
for (const redirectUri of redirectUris) validateListedRedirectUri(redirectUri);
|
|
565
|
+
let firstRefusal;
|
|
566
|
+
for (const redirectUri of redirectUris) try {
|
|
567
|
+
validateRedirectUri(redirectUri, server);
|
|
568
|
+
return redirectUris;
|
|
569
|
+
} catch (error) {
|
|
570
|
+
firstRefusal ??= error;
|
|
571
|
+
}
|
|
572
|
+
throw firstRefusal instanceof Error ? firstRefusal : /* @__PURE__ */ new Error("Invalid redirect URI");
|
|
573
|
+
}
|
|
574
|
+
function requireValidRedirectUris(redirectUris, validate) {
|
|
271
575
|
if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
|
|
272
|
-
for (const redirectUri of redirectUris)
|
|
576
|
+
for (const redirectUri of redirectUris) validate(redirectUri);
|
|
273
577
|
return redirectUris;
|
|
274
578
|
}
|
|
275
579
|
/**
|
|
@@ -287,7 +591,7 @@ function resolveDynamicClientRegistrationMetadata(raw, server) {
|
|
|
287
591
|
});
|
|
288
592
|
return {
|
|
289
593
|
...pickDisplayMetadata(metadata),
|
|
290
|
-
redirectUris:
|
|
594
|
+
redirectUris: validateRegisteredRedirectUris(metadata.redirectUris, server),
|
|
291
595
|
...capabilities,
|
|
292
596
|
authMethodExplicit: metadata.tokenEndpointAuthMethod !== void 0 || metadata.tokenEndpointAuthMethodsSupported !== void 0
|
|
293
597
|
};
|
|
@@ -366,7 +670,7 @@ function resolveClientIdMetadataDocument(metadataUrl, value, server) {
|
|
|
366
670
|
const metadata = parseOAuthClientMetadata(raw);
|
|
367
671
|
if (metadata.clientId !== metadataUrl) throw new Error(`client_id "${metadata.clientId}" does not match metadata URL "${metadataUrl}"`);
|
|
368
672
|
if (!metadata.clientName?.trim()) throw new Error("client_name is required and must not be empty");
|
|
369
|
-
const redirectUris = requireValidRedirectUris(metadata.redirectUris);
|
|
673
|
+
const redirectUris = requireValidRedirectUris(metadata.redirectUris, validateRedirectUriSafety);
|
|
370
674
|
if ("client_secret" in raw || "client_secret_expires_at" in raw) throw new Error("CIMD documents must not contain client secrets");
|
|
371
675
|
if (containsPrivateJwkMaterial(raw.jwks)) throw new Error("CIMD documents must not contain private key material");
|
|
372
676
|
const capabilities = negotiateCimdClientCapabilities(server, {
|
|
@@ -850,16 +1154,34 @@ function parseIdJag(assertion, maxBytes) {
|
|
|
850
1154
|
signature
|
|
851
1155
|
});
|
|
852
1156
|
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Decodes a base64url-encoded string to bytes.
|
|
1159
|
+
*/
|
|
1160
|
+
function base64UrlToBytes(base64Url) {
|
|
1161
|
+
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
|
1162
|
+
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
|
|
1163
|
+
const binaryString = atob(padded);
|
|
1164
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
1165
|
+
for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
|
|
1166
|
+
return bytes;
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* Parses a base64url-encoded JWT JSON part into an object.
|
|
1170
|
+
*/
|
|
1171
|
+
function parseJwtJsonPart(encoded) {
|
|
1172
|
+
try {
|
|
1173
|
+
const json = new TextDecoder().decode(base64UrlToBytes(encoded));
|
|
1174
|
+
const parsed = JSON.parse(json);
|
|
1175
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("JWT part must be an object");
|
|
1176
|
+
return parsed;
|
|
1177
|
+
} catch {
|
|
1178
|
+
throw new Error("Malformed JWT part");
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
853
1181
|
|
|
854
1182
|
//#endregion
|
|
855
1183
|
//#region src/ema/signature.ts
|
|
856
1184
|
/**
|
|
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
1185
|
* Pick a signing key from a JWKS that matches the assertion header.
|
|
864
1186
|
*
|
|
865
1187
|
* Filters by:
|
|
@@ -910,95 +1232,31 @@ async function verifyIdJagSignature(input) {
|
|
|
910
1232
|
return false;
|
|
911
1233
|
}
|
|
912
1234
|
}
|
|
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
|
-
/**
|
|
948
|
-
* Whether a requested resource identifies a granted or configured resource.
|
|
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
1235
|
/**
|
|
962
|
-
*
|
|
963
|
-
* scheme and the host. A configured identifier is canonical when folding it is
|
|
964
|
-
* the identity.
|
|
1236
|
+
* Gets WebCrypto import and verify parameters for supported JOSE algorithms.
|
|
965
1237
|
*/
|
|
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;
|
|
1238
|
+
function getJwtCryptoAlgorithms(alg) {
|
|
1239
|
+
if (alg === "RS256") {
|
|
1240
|
+
const algorithm = {
|
|
1241
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
1242
|
+
hash: "SHA-256"
|
|
1243
|
+
};
|
|
1244
|
+
return {
|
|
1245
|
+
importAlgorithm: algorithm,
|
|
1246
|
+
verifyAlgorithm: algorithm
|
|
1247
|
+
};
|
|
982
1248
|
}
|
|
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;
|
|
1249
|
+
if (alg === "ES256") return {
|
|
1250
|
+
importAlgorithm: {
|
|
1251
|
+
name: "ECDSA",
|
|
1252
|
+
namedCurve: "P-256"
|
|
1253
|
+
},
|
|
1254
|
+
verifyAlgorithm: {
|
|
1255
|
+
name: "ECDSA",
|
|
1256
|
+
hash: "SHA-256"
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
throw new Error(`Unsupported JWT alg: ${alg}`);
|
|
1002
1260
|
}
|
|
1003
1261
|
|
|
1004
1262
|
//#endregion
|
|
@@ -1336,7 +1594,7 @@ const NO_CACHE_HEADERS$1 = {
|
|
|
1336
1594
|
* ```
|
|
1337
1595
|
*/
|
|
1338
1596
|
function insufficientScope(auth, scope, description) {
|
|
1339
|
-
if (!Array.isArray(scope) || scope.length === 0 || scope.some((value) => !
|
|
1597
|
+
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
1598
|
let challenge = `Bearer realm="OAuth", error="insufficient_scope", scope="${[...new Set(scope)].join(" ")}"`;
|
|
1341
1599
|
challenge += `, resource_metadata="${getResourceMetadataUrl(auth.audience)}"`;
|
|
1342
1600
|
if (description !== void 0) {
|
|
@@ -1376,35 +1634,36 @@ var OAuthResourceServer = class {
|
|
|
1376
1634
|
const validated = this.#validated;
|
|
1377
1635
|
const url = new URL(request.url);
|
|
1378
1636
|
if (isProtectedResourceMetadataPath(url)) {
|
|
1379
|
-
if (request.method === "OPTIONS") return
|
|
1637
|
+
if (request.method === "OPTIONS") return withCorsHeaders(new Response(null, {
|
|
1380
1638
|
status: 204,
|
|
1381
1639
|
headers: { "Content-Length": "0" }
|
|
1382
1640
|
}), request);
|
|
1383
|
-
if (!isMetadataUrlRequest(url, validated.metadataUrl)) return
|
|
1384
|
-
if (request.method !== "GET" && request.method !== "HEAD") return
|
|
1641
|
+
if (!isMetadataUrlRequest(url, validated.metadataUrl)) return withCorsHeaders(new Response(null, { status: 404 }), request);
|
|
1642
|
+
if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
|
|
1385
1643
|
status: 405,
|
|
1386
1644
|
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
1387
1645
|
}), request);
|
|
1388
1646
|
const metadata = Response.json(validated.metadata, { headers: NO_CACHE_HEADERS$1 });
|
|
1389
|
-
return
|
|
1647
|
+
return withCorsHeaders(request.method === "HEAD" ? new Response(null, {
|
|
1390
1648
|
status: 200,
|
|
1391
1649
|
headers: metadata.headers
|
|
1392
1650
|
}) : metadata, request);
|
|
1393
1651
|
}
|
|
1394
1652
|
if (!isCanonicalResourceRequest(url, validated.resourceUrl)) return new Response(null, { status: 404 });
|
|
1395
|
-
if (request.method === "OPTIONS") return
|
|
1653
|
+
if (request.method === "OPTIONS") return withCorsHeaders(new Response(null, {
|
|
1396
1654
|
status: 204,
|
|
1397
1655
|
headers: { "Content-Length": "0" }
|
|
1398
1656
|
}), request);
|
|
1399
1657
|
const token = parseBearerToken(request.headers.get("Authorization"));
|
|
1400
|
-
if (!token) return
|
|
1658
|
+
if (!token) return withCorsHeaders(createBearerChallenge(url, validated, false), request);
|
|
1401
1659
|
let validation;
|
|
1402
1660
|
try {
|
|
1403
1661
|
validation = await options.validateToken(env, request)(validated.resource, token);
|
|
1404
|
-
} catch {
|
|
1405
|
-
return
|
|
1662
|
+
} catch (error) {
|
|
1663
|
+
if (error instanceof OAuthError) return withCorsHeaders(createOAuthErrorResponse(error, url, validated), request);
|
|
1664
|
+
return withCorsHeaders(createValidationUnavailableResponse(), request);
|
|
1406
1665
|
}
|
|
1407
|
-
if (!isValidTokenValidation(validation, validated.resource)) return
|
|
1666
|
+
if (!isValidTokenValidation(validation, validated.resource)) return withCorsHeaders(createBearerChallenge(url, validated, true), request);
|
|
1408
1667
|
const context = ctx;
|
|
1409
1668
|
context.props = validation.props;
|
|
1410
1669
|
context.auth = {
|
|
@@ -1416,7 +1675,7 @@ var OAuthResourceServer = class {
|
|
|
1416
1675
|
...validation.clientId !== void 0 ? { clientId: validation.clientId } : {}
|
|
1417
1676
|
};
|
|
1418
1677
|
const handler = options.handler;
|
|
1419
|
-
return
|
|
1678
|
+
return withCorsHeaders(isEntrypointClass(handler) ? await new handler(context, env).fetch(request) : await handler.fetch(request, env, context), request);
|
|
1420
1679
|
}
|
|
1421
1680
|
};
|
|
1422
1681
|
function validateOptions(options) {
|
|
@@ -1435,9 +1694,9 @@ function validateOptions(options) {
|
|
|
1435
1694
|
}
|
|
1436
1695
|
const bearerMethods = options.resourceMetadata.bearer_methods_supported;
|
|
1437
1696
|
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 =
|
|
1697
|
+
const configuredScopes = resolveRequiredScopes(options.requiredScopes, options.resourceMetadata.scopes_supported) ?? [];
|
|
1698
|
+
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");
|
|
1699
|
+
const resourceScopes = baselineResourceScopes(configuredScopes);
|
|
1441
1700
|
return {
|
|
1442
1701
|
resource,
|
|
1443
1702
|
resourceUrl,
|
|
@@ -1463,9 +1722,6 @@ function parseCanonicalUrl(value) {
|
|
|
1463
1722
|
if (parsed.href !== value && parsed.origin !== value) return null;
|
|
1464
1723
|
return parsed;
|
|
1465
1724
|
}
|
|
1466
|
-
function isValidScopeToken(scope) {
|
|
1467
|
-
return typeof scope === "string" && scope.length > 0 && /^[\x21\x23-\x5b\x5d-\x7e]+$/.test(scope);
|
|
1468
|
-
}
|
|
1469
1725
|
function getResourceMetadataUrl(resource) {
|
|
1470
1726
|
const parsed = new URL(resource);
|
|
1471
1727
|
const suffix = parsed.pathname === "/" ? "" : parsed.pathname;
|
|
@@ -1504,7 +1760,7 @@ function isValidTokenValidation(validation, canonicalResource) {
|
|
|
1504
1760
|
}
|
|
1505
1761
|
if (validation.scope !== void 0) {
|
|
1506
1762
|
if (!Array.isArray(validation.scope)) return false;
|
|
1507
|
-
if (validation.scope.some((scope) => typeof scope !== "string" || !
|
|
1763
|
+
if (validation.scope.some((scope) => typeof scope !== "string" || !isValidOAuthScopeToken(scope))) return false;
|
|
1508
1764
|
}
|
|
1509
1765
|
if (validation.userId !== void 0 && typeof validation.userId !== "string") return false;
|
|
1510
1766
|
if (validation.clientId !== void 0 && typeof validation.clientId !== "string") return false;
|
|
@@ -1524,30 +1780,44 @@ function createBearerChallenge(requestUrl, validated, invalidToken) {
|
|
|
1524
1780
|
}
|
|
1525
1781
|
});
|
|
1526
1782
|
}
|
|
1783
|
+
/**
|
|
1784
|
+
* A validator's `OAuthError` as the response. RFC 6750 fixes `invalid_token` at `401` and
|
|
1785
|
+
* `insufficient_scope` at `403`, each with a Bearer challenge (unless the error brings its own);
|
|
1786
|
+
* any other code keeps the error's status and headers, such as a `429` with `Retry-After`.
|
|
1787
|
+
*/
|
|
1788
|
+
function createOAuthErrorResponse(error, requestUrl, validated) {
|
|
1789
|
+
const headers = {
|
|
1790
|
+
...NO_CACHE_HEADERS$1,
|
|
1791
|
+
"Content-Type": "application/json",
|
|
1792
|
+
...error.headers
|
|
1793
|
+
};
|
|
1794
|
+
const hasChallenge = Object.keys(headers).some((name) => name.toLowerCase() === "www-authenticate");
|
|
1795
|
+
let status = error.statusCode;
|
|
1796
|
+
if (error.code === "invalid_token") {
|
|
1797
|
+
status = 401;
|
|
1798
|
+
if (!hasChallenge) headers["WWW-Authenticate"] = createBearerChallenge(requestUrl, validated, true).headers.get("WWW-Authenticate");
|
|
1799
|
+
} else if (error.code === "insufficient_scope") {
|
|
1800
|
+
status = 403;
|
|
1801
|
+
if (!hasChallenge) {
|
|
1802
|
+
const scopes = [...new Set(error.requiredScopes ?? validated.metadata.scopes_supported ?? [])];
|
|
1803
|
+
if (scopes.some((scope) => typeof scope !== "string" || !isValidOAuthScopeToken(scope))) return createValidationUnavailableResponse();
|
|
1804
|
+
headers["WWW-Authenticate"] = "Bearer realm=\"OAuth\", error=\"insufficient_scope\"" + (scopes.length > 0 ? `, scope="${scopes.join(" ")}"` : "") + `, resource_metadata="${validated.metadataUrl.href}"`;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
return new Response(JSON.stringify({
|
|
1808
|
+
error: error.code,
|
|
1809
|
+
error_description: error.description
|
|
1810
|
+
}), {
|
|
1811
|
+
status,
|
|
1812
|
+
headers
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1527
1815
|
function createValidationUnavailableResponse() {
|
|
1528
1816
|
return new Response(null, {
|
|
1529
1817
|
status: 503,
|
|
1530
1818
|
headers: NO_CACHE_HEADERS$1
|
|
1531
1819
|
});
|
|
1532
1820
|
}
|
|
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
1821
|
|
|
1552
1822
|
//#endregion
|
|
1553
1823
|
//#region src/oauth-consent.ts
|
|
@@ -1595,6 +1865,21 @@ function validateRememberConsentOptions(options) {
|
|
|
1595
1865
|
if (maxAge !== void 0 && (!Number.isInteger(maxAge) || maxAge <= 0)) throw new TypeError("remember.maxAgeSeconds must be a positive integer");
|
|
1596
1866
|
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
1867
|
}
|
|
1868
|
+
/** The facts a consent page must show for a request, given the client `lookupClient()` resolved. */
|
|
1869
|
+
function describeConsent(client, request, isClientIdMetadataDocument) {
|
|
1870
|
+
const redirectHost = new URL(request.redirectUri).hostname || request.redirectUri;
|
|
1871
|
+
return {
|
|
1872
|
+
clientId: request.clientId,
|
|
1873
|
+
clientName: client?.clientName?.trim() ? client.clientName : request.clientId,
|
|
1874
|
+
...isClientIdMetadataDocument ? { clientDomain: new URL(request.clientId).hostname } : {},
|
|
1875
|
+
...client?.clientUri ? { clientUri: client.clientUri } : {},
|
|
1876
|
+
...client?.logoUri ? { logoUri: client.logoUri } : {},
|
|
1877
|
+
redirectUri: request.redirectUri,
|
|
1878
|
+
redirectHost,
|
|
1879
|
+
redirectIsLoopback: isLoopbackHostname(redirectHost),
|
|
1880
|
+
scope: [...request.scope]
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1598
1883
|
/** Start a consent transaction for an authorization request that must be shown to the user. */
|
|
1599
1884
|
async function beginConsent(kv, cookies, request) {
|
|
1600
1885
|
const { handle, hash } = await createTransaction(kv, {
|
|
@@ -1647,19 +1932,15 @@ async function denyConsent(kv, cookies, request, handle, options = {}) {
|
|
|
1647
1932
|
const transaction = await openTransaction(kv, request, cookies.consent, handle, "consent");
|
|
1648
1933
|
await transaction.consume();
|
|
1649
1934
|
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);
|
|
1935
|
+
const redirectTo = authorizationErrorRedirect(record.request, "access_denied", options.description);
|
|
1655
1936
|
const headers = new Headers({
|
|
1656
1937
|
"Cache-Control": "no-store",
|
|
1657
|
-
Location:
|
|
1938
|
+
Location: redirectTo
|
|
1658
1939
|
});
|
|
1659
1940
|
headers.append("Set-Cookie", clearCookie(transaction.cookieName));
|
|
1660
1941
|
return {
|
|
1661
1942
|
request: record.request,
|
|
1662
|
-
redirectTo
|
|
1943
|
+
redirectTo,
|
|
1663
1944
|
headers
|
|
1664
1945
|
};
|
|
1665
1946
|
}
|
|
@@ -1887,6 +2168,9 @@ let GrantType = /* @__PURE__ */ function(GrantType$1) {
|
|
|
1887
2168
|
GrantType$1["JWT_BEARER"] = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
1888
2169
|
return GrantType$1;
|
|
1889
2170
|
}({});
|
|
2171
|
+
/** Where the split server's endpoints are unless configured otherwise, under the issuer. */
|
|
2172
|
+
const DEFAULT_AUTHORIZE_PATH = "/authorize";
|
|
2173
|
+
const DEFAULT_TOKEN_PATH = "/oauth/token";
|
|
1890
2174
|
function toPublicClientInfo(client) {
|
|
1891
2175
|
const { authMethodExplicit: _explicit, registrationExpiresAt: _expiry, ...publicClient } = client;
|
|
1892
2176
|
return publicClient;
|
|
@@ -1946,7 +2230,6 @@ var OAuthAuthorizationServer = class {
|
|
|
1946
2230
|
const { issuer, resources, defaultResource, legacyGrantResource, authorizeEndpoint, tokenEndpoint, clientRegistrationEndpoint, ...commonOptions } = options;
|
|
1947
2231
|
this.#impl = new OAuthProviderImpl({
|
|
1948
2232
|
...commonOptions,
|
|
1949
|
-
defaultHandler: { fetch: () => new Response(null, { status: 404 }) },
|
|
1950
2233
|
authorizationServer: {
|
|
1951
2234
|
issuer,
|
|
1952
2235
|
resources,
|
|
@@ -2018,7 +2301,7 @@ var OAuthProviderImpl = class {
|
|
|
2018
2301
|
constructor(options) {
|
|
2019
2302
|
this.errorResponses = /* @__PURE__ */ new WeakSet();
|
|
2020
2303
|
this.typedApiHandlers = [];
|
|
2021
|
-
this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
|
|
2304
|
+
this.typedDefaultHandler = "authorizationServer" in options ? void 0 : this.validateHandler(options.defaultHandler, "defaultHandler");
|
|
2022
2305
|
const roleBased = "authorizationServer" in options;
|
|
2023
2306
|
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
2307
|
let normalizedOptions;
|
|
@@ -2027,10 +2310,11 @@ var OAuthProviderImpl = class {
|
|
|
2027
2310
|
const { authorizationServer: authorizationServer$1, ...commonOptions } = options;
|
|
2028
2311
|
this.validateAuthorizationServerIssuer(authorizationServer$1.issuer);
|
|
2029
2312
|
this.explicitIssuer = authorizationServer$1.issuer;
|
|
2313
|
+
const issuerBase = authorizationServer$1.issuer.replace(/\/+$/, "");
|
|
2030
2314
|
normalizedOptions = {
|
|
2031
2315
|
...commonOptions,
|
|
2032
|
-
authorizeEndpoint: authorizationServer$1.authorizeEndpoint
|
|
2033
|
-
tokenEndpoint: authorizationServer$1.tokenEndpoint
|
|
2316
|
+
authorizeEndpoint: authorizationServer$1.authorizeEndpoint ?? `${issuerBase}${DEFAULT_AUTHORIZE_PATH}`,
|
|
2317
|
+
tokenEndpoint: authorizationServer$1.tokenEndpoint ?? `${issuerBase}${DEFAULT_TOKEN_PATH}`,
|
|
2034
2318
|
clientRegistrationEndpoint: authorizationServer$1.clientRegistrationEndpoint
|
|
2035
2319
|
};
|
|
2036
2320
|
const declared = authorizationServer$1.resources;
|
|
@@ -2042,7 +2326,7 @@ var OAuthProviderImpl = class {
|
|
|
2042
2326
|
this.explicitIssuer = void 0;
|
|
2043
2327
|
normalizedOptions = options;
|
|
2044
2328
|
configuredResourceServers = [{
|
|
2045
|
-
resourceMetadata: options.resourceMetadata,
|
|
2329
|
+
resourceMetadata: withRequiredScopes(options.resourceMetadata, options.requiredScopes),
|
|
2046
2330
|
resolveExternalToken: options.resolveExternalToken
|
|
2047
2331
|
}];
|
|
2048
2332
|
}
|
|
@@ -2106,11 +2390,14 @@ var OAuthProviderImpl = class {
|
|
|
2106
2390
|
}
|
|
2107
2391
|
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
2392
|
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).`);
|
|
2393
|
+
if (this.options.allowPlainPKCE) throw new TypeError("allowPlainPKCE has been removed: MCP and OAuth 2.1 clients use S256 PKCE.");
|
|
2394
|
+
if (this.options.allowImplicitFlow) throw new TypeError("allowImplicitFlow has been removed: OAuth 2.1 and MCP use the authorization code flow with PKCE.");
|
|
2395
|
+
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).`);
|
|
2396
|
+
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
2397
|
this.serverCapabilities = buildOAuthServerCapabilities({
|
|
2110
|
-
allowImplicitFlow: !!this.options.allowImplicitFlow,
|
|
2111
|
-
allowPlainPKCE: this.options.allowPlainPKCE === true,
|
|
2112
2398
|
allowTokenExchangeGrant: !!this.options.allowTokenExchangeGrant,
|
|
2113
|
-
enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization
|
|
2399
|
+
enterpriseManagedAuthorization: !!this.options.enterpriseManagedAuthorization,
|
|
2400
|
+
allowPrivateUseRedirectUris: this.options.allowPrivateUseRedirectUris === true
|
|
2114
2401
|
});
|
|
2115
2402
|
validateAuthorizationServerScopes(this.options.scopesSupported);
|
|
2116
2403
|
this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
|
|
@@ -2149,16 +2436,24 @@ var OAuthProviderImpl = class {
|
|
|
2149
2436
|
}
|
|
2150
2437
|
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
2438
|
}
|
|
2152
|
-
/**
|
|
2439
|
+
/**
|
|
2440
|
+
* Reject an endpoint another would claim, so it could never be reached. fetch() tries metadata,
|
|
2441
|
+
* then token, then registration; the application routes the authorization endpoint. Each check
|
|
2442
|
+
* asks the predicate fetch() dispatches on, so it agrees with routing (metadata ignores the
|
|
2443
|
+
* query, for one).
|
|
2444
|
+
*/
|
|
2153
2445
|
validateAuthorizationServerRouteIsolation() {
|
|
2154
2446
|
const issuerUrl = new URL(this.explicitIssuer);
|
|
2155
|
-
const discoveryEndpoint = this.getAuthorizationServerMetadataUrl(issuerUrl);
|
|
2156
2447
|
const tokenUrl = new URL(this.getFullEndpointUrl(this.options.tokenEndpoint, issuerUrl));
|
|
2157
|
-
if (this.
|
|
2448
|
+
if (this.isAuthorizationServerMetadataRequest(tokenUrl)) throw new TypeError("tokenEndpoint must not collide with the authorization server metadata endpoint");
|
|
2449
|
+
const authorizeUrl = new URL(this.getFullEndpointUrl(this.options.authorizeEndpoint, issuerUrl));
|
|
2450
|
+
if (this.isAuthorizationServerMetadataRequest(authorizeUrl)) throw new TypeError("authorizeEndpoint must not collide with the authorization server metadata endpoint");
|
|
2451
|
+
if (this.isTokenEndpoint(authorizeUrl)) throw new TypeError("authorizeEndpoint must not collide with tokenEndpoint");
|
|
2158
2452
|
if (!this.options.clientRegistrationEndpoint) return;
|
|
2453
|
+
if (this.isClientRegistrationEndpoint(authorizeUrl)) throw new TypeError("authorizeEndpoint must not collide with clientRegistrationEndpoint");
|
|
2159
2454
|
const registrationUrl = new URL(this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, issuerUrl));
|
|
2160
|
-
if (this.
|
|
2161
|
-
if (this.
|
|
2455
|
+
if (this.isTokenEndpoint(registrationUrl)) throw new TypeError("clientRegistrationEndpoint must not collide with tokenEndpoint");
|
|
2456
|
+
if (this.isAuthorizationServerMetadataRequest(registrationUrl)) throw new TypeError("clientRegistrationEndpoint must not collide with the authorization server metadata endpoint");
|
|
2162
2457
|
}
|
|
2163
2458
|
/**
|
|
2164
2459
|
* A protected route must be the canonical resource path or a path-boundary descendant of
|
|
@@ -2259,7 +2554,7 @@ var OAuthProviderImpl = class {
|
|
|
2259
2554
|
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
2555
|
}
|
|
2261
2556
|
}
|
|
2262
|
-
if (options.scopes_supported?.some((scope) => !isValidOAuthScopeToken(scope))) throw new TypeError("resourceMetadata.scopes_supported must contain valid OAuth scope tokens");
|
|
2557
|
+
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
2558
|
if (options.bearer_methods_supported?.some((method) => method !== "header")) throw new TypeError("resourceMetadata.bearer_methods_supported only supports 'header'");
|
|
2264
2559
|
}
|
|
2265
2560
|
/**
|
|
@@ -2298,49 +2593,42 @@ var OAuthProviderImpl = class {
|
|
|
2298
2593
|
const metadataResourceServer = servesProtectedResources ? this.findResourceServerForMetadataUrl(url) : void 0;
|
|
2299
2594
|
const apiRoute = servesProtectedResources ? this.findApiRouteForUrl(url) : void 0;
|
|
2300
2595
|
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
|
|
2596
|
+
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
2597
|
status: 204,
|
|
2303
2598
|
headers: { "Content-Length": "0" }
|
|
2304
2599
|
}), request);
|
|
2305
2600
|
}
|
|
2306
2601
|
if (this.isAuthorizationServerMetadataRequest(url)) {
|
|
2307
|
-
if (request.method !== "GET" && request.method !== "HEAD") return
|
|
2602
|
+
if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
|
|
2308
2603
|
status: 405,
|
|
2309
2604
|
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
2310
2605
|
}), request);
|
|
2311
|
-
|
|
2312
|
-
return this.addCorsHeaders(withoutBodyForHead(request, response), request);
|
|
2606
|
+
return withCorsHeaders(withoutBodyForHead(request, await this.handleMetadataDiscovery(url)), request);
|
|
2313
2607
|
}
|
|
2314
2608
|
if (servesProtectedResources && this.isProtectedResourceMetadataPath(url)) {
|
|
2315
|
-
if (!metadataResourceServer) return
|
|
2316
|
-
if (request.method !== "GET" && request.method !== "HEAD") return
|
|
2609
|
+
if (!metadataResourceServer) return withCorsHeaders(new Response(null, { status: 404 }), request);
|
|
2610
|
+
if (request.method !== "GET" && request.method !== "HEAD") return withCorsHeaders(new Response(null, {
|
|
2317
2611
|
status: 405,
|
|
2318
2612
|
headers: { Allow: "GET, HEAD, OPTIONS" }
|
|
2319
2613
|
}), request);
|
|
2320
|
-
|
|
2321
|
-
return this.addCorsHeaders(withoutBodyForHead(request, response), request);
|
|
2614
|
+
return withCorsHeaders(withoutBodyForHead(request, this.handleProtectedResourceMetadata(url, metadataResourceServer)), request);
|
|
2322
2615
|
}
|
|
2323
2616
|
if (this.isTokenEndpoint(url)) {
|
|
2324
2617
|
const parsed = await this.parseTokenEndpointRequest(request, env);
|
|
2325
|
-
if (parsed instanceof Response) return
|
|
2618
|
+
if (parsed instanceof Response) return withCorsHeaders(parsed, request);
|
|
2326
2619
|
let response;
|
|
2327
|
-
if (parsed.isRevocationRequest) response = await this.
|
|
2620
|
+
if (parsed.isRevocationRequest) response = await this.revokeToken(parsed.body, parsed.clientInfo, env);
|
|
2328
2621
|
else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
|
|
2329
2622
|
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);
|
|
2623
|
+
return withCorsHeaders(response, request);
|
|
2335
2624
|
}
|
|
2336
|
-
if (
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
}
|
|
2340
|
-
if (typeof role === "object") return new Response(null, { status: 404 });
|
|
2625
|
+
if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return withCorsHeaders(await this.handleClientRegistration(request, env), request);
|
|
2626
|
+
if (apiRoute) return withCorsHeaders(await this.handleApiRequest(request, env, ctx, apiRoute), request);
|
|
2627
|
+
const defaultHandler = this.typedDefaultHandler;
|
|
2628
|
+
if (!defaultHandler) return new Response(null, { status: 404 });
|
|
2341
2629
|
if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
|
|
2342
|
-
if (
|
|
2343
|
-
return new
|
|
2630
|
+
if (defaultHandler.type === HandlerType.EXPORTED_HANDLER) return defaultHandler.handler.fetch(request, env, ctx);
|
|
2631
|
+
return new defaultHandler.handler(ctx, env).fetch(request);
|
|
2344
2632
|
}
|
|
2345
2633
|
/**
|
|
2346
2634
|
* Decodes a token and returns token data with decrypted props
|
|
@@ -2576,7 +2864,7 @@ var OAuthProviderImpl = class {
|
|
|
2576
2864
|
});
|
|
2577
2865
|
const presentedAuthMethod = basicAuthenticationAttempted ? "client_secret_basic" : formData.has("client_secret") ? "client_secret_post" : "none";
|
|
2578
2866
|
const registeredAuthMethod = clientInfo.tokenEndpointAuthMethod;
|
|
2579
|
-
if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled &&
|
|
2867
|
+
if (!isClientAuthMethodAllowed(clientInfo, presentedAuthMethod, !!this.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(clientInfo.clientId))) return this.createInvalidClientResponse("Client authentication failed", basicAuthenticationAttempted, {
|
|
2580
2868
|
category: "client-authentication",
|
|
2581
2869
|
reason: "token_endpoint_auth_method_mismatch",
|
|
2582
2870
|
detail: {
|
|
@@ -2652,26 +2940,6 @@ var OAuthProviderImpl = class {
|
|
|
2652
2940
|
return new URL(tokenEndpoint).origin;
|
|
2653
2941
|
}
|
|
2654
2942
|
/**
|
|
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
2943
|
* Handles the OAuth metadata discovery endpoint
|
|
2676
2944
|
* Implements RFC 8414 for OAuth Server Metadata
|
|
2677
2945
|
* @param requestUrl - The URL of the incoming request
|
|
@@ -2693,7 +2961,7 @@ var OAuthProviderImpl = class {
|
|
|
2693
2961
|
registration_endpoint: registrationEndpoint,
|
|
2694
2962
|
scopes_supported: this.options.scopesSupported,
|
|
2695
2963
|
response_types_supported: responseTypesSupported,
|
|
2696
|
-
response_modes_supported:
|
|
2964
|
+
response_modes_supported: ["query"],
|
|
2697
2965
|
grant_types_supported: grantTypesSupported,
|
|
2698
2966
|
...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
|
|
2699
2967
|
token_endpoint_auth_methods_supported: this.serverCapabilities.tokenEndpointAuthMethods,
|
|
@@ -2710,7 +2978,7 @@ var OAuthProviderImpl = class {
|
|
|
2710
2978
|
}
|
|
2711
2979
|
/** Deduplicate resource-facing scopes and remove authorization-server-only capabilities. */
|
|
2712
2980
|
normalizeProtectedResourceScopes(scopes) {
|
|
2713
|
-
return
|
|
2981
|
+
return baselineResourceScopes(scopes);
|
|
2714
2982
|
}
|
|
2715
2983
|
/**
|
|
2716
2984
|
* Handles the OAuth Protected Resource Metadata endpoint
|
|
@@ -2780,7 +3048,10 @@ var OAuthProviderImpl = class {
|
|
|
2780
3048
|
*/
|
|
2781
3049
|
async callTokenExchangeCallback(options, env) {
|
|
2782
3050
|
try {
|
|
2783
|
-
return await Promise.resolve(this.options.tokenExchangeCallback(
|
|
3051
|
+
return await Promise.resolve(this.options.tokenExchangeCallback({
|
|
3052
|
+
...options,
|
|
3053
|
+
env
|
|
3054
|
+
}));
|
|
2784
3055
|
} catch (error) {
|
|
2785
3056
|
if (error instanceof OAuthError && error.code === "invalid_grant") try {
|
|
2786
3057
|
await this.createOAuthHelpers(env).revokeGrant(options.grantId, options.userId);
|
|
@@ -2875,9 +3146,9 @@ var OAuthProviderImpl = class {
|
|
|
2875
3146
|
reason: "code_replayed"
|
|
2876
3147
|
});
|
|
2877
3148
|
}
|
|
2878
|
-
let codeChallengeMethod;
|
|
2879
3149
|
try {
|
|
2880
|
-
|
|
3150
|
+
if (grantData.codeChallenge) validatePkceCodeChallengeMethod(this.serverCapabilities, grantData.codeChallengeMethod);
|
|
3151
|
+
else normalizePkceCodeChallengeMethod(grantData.codeChallengeMethod);
|
|
2881
3152
|
} catch (error) {
|
|
2882
3153
|
return this.createErrorResponse("invalid_grant", { description: error instanceof Error ? error.message : "Invalid PKCE code_challenge_method" }, {
|
|
2883
3154
|
category: "authorization-code-grant",
|
|
@@ -2907,14 +3178,8 @@ var OAuthProviderImpl = class {
|
|
|
2907
3178
|
category: "authorization-code-grant",
|
|
2908
3179
|
reason: "code_verifier_missing"
|
|
2909
3180
|
});
|
|
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" }, {
|
|
3181
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
|
|
3182
|
+
if (base64UrlEncode(String.fromCharCode(...new Uint8Array(hashBuffer))) !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" }, {
|
|
2918
3183
|
category: "authorization-code-grant",
|
|
2919
3184
|
reason: "code_verifier_mismatch"
|
|
2920
3185
|
});
|
|
@@ -2928,6 +3193,10 @@ var OAuthProviderImpl = class {
|
|
|
2928
3193
|
let accessTokenEncryptionKey = encryptionKey;
|
|
2929
3194
|
let encryptedAccessTokenProps = grantData.encryptedProps;
|
|
2930
3195
|
let tokenScopes = this.downscope(body.scope, grantData.scope);
|
|
3196
|
+
if (grantsNoneOfTheRequestedScopes(body.scope, tokenScopes)) return this.createErrorResponse("invalid_scope", { description: "None of the requested scopes were granted" }, {
|
|
3197
|
+
category: "authorization-code-grant",
|
|
3198
|
+
reason: "scope_not_granted"
|
|
3199
|
+
});
|
|
2931
3200
|
if (this.options.tokenExchangeCallback) {
|
|
2932
3201
|
const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
|
|
2933
3202
|
let grantProps = decryptedProps;
|
|
@@ -2951,11 +3220,13 @@ var OAuthProviderImpl = class {
|
|
|
2951
3220
|
}
|
|
2952
3221
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
2953
3222
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
2954
|
-
if (
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
3223
|
+
if (callbackResult.refreshTokenTTL !== void 0) {
|
|
3224
|
+
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` }, {
|
|
3225
|
+
category: "authorization-code-grant",
|
|
3226
|
+
reason: "refresh_token_ttl_invalid"
|
|
3227
|
+
});
|
|
3228
|
+
refreshTokenTTL = callbackResult.refreshTokenTTL;
|
|
3229
|
+
}
|
|
2959
3230
|
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
2960
3231
|
}
|
|
2961
3232
|
const grantResult = await encryptProps(grantProps);
|
|
@@ -2990,7 +3261,7 @@ var OAuthProviderImpl = class {
|
|
|
2990
3261
|
grantData.previousRefreshTokenId = void 0;
|
|
2991
3262
|
grantData.previousRefreshTokenWrappedKey = void 0;
|
|
2992
3263
|
grantData.expiresAt = expiresAt;
|
|
2993
|
-
}
|
|
3264
|
+
} else grantData.expiresAt = now + accessTokenTTL;
|
|
2994
3265
|
if (resourceResolution.grantResourceBackfill) grantData.resource = resourceResolution.grantResourceBackfill;
|
|
2995
3266
|
await this.saveGrantWithTTL(env, grantKey, grantData, now);
|
|
2996
3267
|
const tokenResponse = {
|
|
@@ -3074,6 +3345,10 @@ var OAuthProviderImpl = class {
|
|
|
3074
3345
|
let accessTokenEncryptionKey = encryptionKey;
|
|
3075
3346
|
let encryptedAccessTokenProps = grantData.encryptedProps;
|
|
3076
3347
|
let tokenScopes = this.downscope(body.scope, grantData.scope);
|
|
3348
|
+
if (grantsNoneOfTheRequestedScopes(body.scope, tokenScopes)) return this.createErrorResponse("invalid_scope", { description: "None of the requested scopes were granted" }, {
|
|
3349
|
+
category: "refresh-token-grant",
|
|
3350
|
+
reason: "scope_not_granted"
|
|
3351
|
+
});
|
|
3077
3352
|
let grantPropsChanged = false;
|
|
3078
3353
|
if (this.options.tokenExchangeCallback) {
|
|
3079
3354
|
const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
|
|
@@ -3099,10 +3374,6 @@ var OAuthProviderImpl = class {
|
|
|
3099
3374
|
}
|
|
3100
3375
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
3101
3376
|
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
3377
|
if (callbackResult.refreshTokenIdleTTL !== void 0) {
|
|
3107
3378
|
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
3379
|
category: "refresh-token-grant",
|
|
@@ -3231,6 +3502,13 @@ var OAuthProviderImpl = class {
|
|
|
3231
3502
|
});
|
|
3232
3503
|
if (crossClientExchange && !this.options.tokenExchangeCallback) throw crossClientRejection();
|
|
3233
3504
|
let tokenScopes = this.downscope(requestedScopes, tokenSummary.scope);
|
|
3505
|
+
if (grantsNoneOfTheRequestedScopes(requestedScopes, tokenScopes)) throw new OAuthError("invalid_scope", {
|
|
3506
|
+
description: "None of the requested scopes were granted",
|
|
3507
|
+
internal: {
|
|
3508
|
+
category: "token-exchange-grant",
|
|
3509
|
+
reason: "scope_not_granted"
|
|
3510
|
+
}
|
|
3511
|
+
});
|
|
3234
3512
|
const newAudience = this.resolveTokenExchangeResource(requestedResource, tokenSummary.audience);
|
|
3235
3513
|
const now = Math.floor(Date.now() / 1e3);
|
|
3236
3514
|
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
@@ -3285,13 +3563,6 @@ var OAuthProviderImpl = class {
|
|
|
3285
3563
|
}
|
|
3286
3564
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
3287
3565
|
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
3566
|
if (accessTokenProps !== decryptedProps) {
|
|
3296
3567
|
const tokenResult = await encryptProps(accessTokenProps);
|
|
3297
3568
|
encryptedAccessTokenProps = tokenResult.encryptedData;
|
|
@@ -3311,7 +3582,7 @@ var OAuthProviderImpl = class {
|
|
|
3311
3582
|
access_token: await this.createAccessToken({
|
|
3312
3583
|
userId: tokenSummary.userId,
|
|
3313
3584
|
grantId: tokenSummary.grantId,
|
|
3314
|
-
clientId:
|
|
3585
|
+
clientId: clientInfo.clientId,
|
|
3315
3586
|
scope: tokenScopes,
|
|
3316
3587
|
encryptedProps: encryptedAccessTokenProps,
|
|
3317
3588
|
encryptionKey: accessTokenEncryptionKey,
|
|
@@ -3595,15 +3866,7 @@ var OAuthProviderImpl = class {
|
|
|
3595
3866
|
};
|
|
3596
3867
|
}
|
|
3597
3868
|
/**
|
|
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
|
-
/**
|
|
3869
|
+
* Handles an RFC 7009 revocation request from an authenticated client.
|
|
3607
3870
|
* - Access tokens: Revokes only the specific token
|
|
3608
3871
|
* - Refresh tokens: Revokes the entire grant (access + refresh tokens)
|
|
3609
3872
|
* Per RFC 7009 §2.1, the server MUST verify the token was issued to the client making the request.
|
|
@@ -3695,14 +3958,17 @@ var OAuthProviderImpl = class {
|
|
|
3695
3958
|
const callbackRequest = request.clone();
|
|
3696
3959
|
let parsedJson;
|
|
3697
3960
|
try {
|
|
3698
|
-
const text = await request
|
|
3699
|
-
if (text
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3961
|
+
const text = await readTextWithLimit(request, 1048576);
|
|
3962
|
+
if (text === void 0) {
|
|
3963
|
+
callbackRequest.body?.cancel().catch(() => void 0);
|
|
3964
|
+
return this.createErrorResponse("invalid_request", {
|
|
3965
|
+
description: "Request payload too large, must be under 1 MiB",
|
|
3966
|
+
statusCode: 413
|
|
3967
|
+
}, {
|
|
3968
|
+
category: "client-registration",
|
|
3969
|
+
reason: "payload_too_large"
|
|
3970
|
+
});
|
|
3971
|
+
}
|
|
3706
3972
|
parsedJson = JSON.parse(text);
|
|
3707
3973
|
} catch {
|
|
3708
3974
|
return this.createErrorResponse("invalid_request", {
|
|
@@ -3717,8 +3983,16 @@ var OAuthProviderImpl = class {
|
|
|
3717
3983
|
let metadata;
|
|
3718
3984
|
try {
|
|
3719
3985
|
clientMetadata = requireJsonObject(parsedJson);
|
|
3720
|
-
|
|
3986
|
+
const capabilities = this.options.disallowPublicClientRegistration ? {
|
|
3987
|
+
...this.serverCapabilities,
|
|
3988
|
+
tokenEndpointAuthMethods: this.serverCapabilities.tokenEndpointAuthMethods.filter((method) => method !== "none")
|
|
3989
|
+
} : this.serverCapabilities;
|
|
3990
|
+
metadata = resolveDynamicClientRegistrationMetadata(clientMetadata, capabilities);
|
|
3721
3991
|
} catch (error) {
|
|
3992
|
+
if (this.options.disallowPublicClientRegistration && wouldRegisterPublicClient(clientMetadata, this.serverCapabilities)) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" }, {
|
|
3993
|
+
category: "client-registration",
|
|
3994
|
+
reason: "public_client_forbidden"
|
|
3995
|
+
});
|
|
3722
3996
|
return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" }, {
|
|
3723
3997
|
category: "client-registration",
|
|
3724
3998
|
reason: "metadata_invalid",
|
|
@@ -3727,10 +4001,6 @@ var OAuthProviderImpl = class {
|
|
|
3727
4001
|
}
|
|
3728
4002
|
const authMethod = metadata.tokenEndpointAuthMethod;
|
|
3729
4003
|
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
4004
|
const clientId = generateRandomString(16);
|
|
3735
4005
|
let clientSecret;
|
|
3736
4006
|
let hashedSecret;
|
|
@@ -3765,7 +4035,7 @@ var OAuthProviderImpl = class {
|
|
|
3765
4035
|
}));
|
|
3766
4036
|
} catch (error) {
|
|
3767
4037
|
return this.createErrorResponse("server_error", {
|
|
3768
|
-
description:
|
|
4038
|
+
description: "Client registration callback failed",
|
|
3769
4039
|
statusCode: 500
|
|
3770
4040
|
}, {
|
|
3771
4041
|
category: "client-registration",
|
|
@@ -3803,7 +4073,7 @@ var OAuthProviderImpl = class {
|
|
|
3803
4073
|
}
|
|
3804
4074
|
if (clientSecret) {
|
|
3805
4075
|
response.client_secret = clientSecret;
|
|
3806
|
-
response.client_secret_expires_at = this.options.clientRegistrationTTL && clientInfo.registrationDate ? clientInfo.registrationDate + this.options.clientRegistrationTTL : 0;
|
|
4076
|
+
response.client_secret_expires_at = this.options.clientRegistrationTTL !== void 0 && clientInfo.registrationDate ? clientInfo.registrationDate + this.options.clientRegistrationTTL : 0;
|
|
3807
4077
|
response.client_secret_issued_at = clientInfo.registrationDate;
|
|
3808
4078
|
}
|
|
3809
4079
|
return new Response(JSON.stringify(response), {
|
|
@@ -3848,7 +4118,7 @@ var OAuthProviderImpl = class {
|
|
|
3848
4118
|
const id = await generateTokenId(accessToken);
|
|
3849
4119
|
tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
|
|
3850
4120
|
}
|
|
3851
|
-
if (!tokenData && !externalTokenResolver) return this.createErrorResponse("invalid_token", {
|
|
4121
|
+
if (!tokenData && (!externalTokenResolver || isIssuedTokenFormat(accessToken))) return this.createErrorResponse("invalid_token", {
|
|
3852
4122
|
description: "Invalid access token",
|
|
3853
4123
|
statusCode: 401,
|
|
3854
4124
|
headers: { "WWW-Authenticate": challenge("invalid_token") }
|
|
@@ -4054,7 +4324,7 @@ var OAuthProviderImpl = class {
|
|
|
4054
4324
|
}
|
|
4055
4325
|
}
|
|
4056
4326
|
async getClient(env, clientId) {
|
|
4057
|
-
if (
|
|
4327
|
+
if (isClientIdMetadataDocumentUrl(clientId)) {
|
|
4058
4328
|
if (!this.options.clientIdMetadataDocumentEnabled) {
|
|
4059
4329
|
const clientKey$1 = `client:${clientId}`;
|
|
4060
4330
|
return env.OAUTH_KV.get(clientKey$1, { type: "json" });
|
|
@@ -4265,13 +4535,6 @@ var OAuthProviderImpl = class {
|
|
|
4265
4535
|
return !!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags ? Cloudflare.compatibilityFlags : null)?.global_fetch_strictly_public;
|
|
4266
4536
|
}
|
|
4267
4537
|
/**
|
|
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
4538
|
* Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
|
|
4276
4539
|
*/
|
|
4277
4540
|
buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription, requiredScopes = [], resourceServer = this.resourceServers[0]) {
|
|
@@ -4343,60 +4606,6 @@ var OAuthProviderImpl = class {
|
|
|
4343
4606
|
}
|
|
4344
4607
|
};
|
|
4345
4608
|
/**
|
|
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
4609
|
* Intentional public error from an external bearer-token validator.
|
|
4401
4610
|
*
|
|
4402
4611
|
* Throw only from `resolveExternalToken` when an expected validation outcome
|
|
@@ -4471,6 +4680,75 @@ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
|
|
|
4471
4680
|
* to clamp absolute expirations when writing grants back to KV.
|
|
4472
4681
|
*/
|
|
4473
4682
|
const KV_MIN_EXPIRATION_TTL_SECONDS = 60;
|
|
4683
|
+
/** Whether a registration refused without `none` on offer would otherwise have been a public client. */
|
|
4684
|
+
function wouldRegisterPublicClient(clientMetadata, server) {
|
|
4685
|
+
if (!clientMetadata) return false;
|
|
4686
|
+
try {
|
|
4687
|
+
return resolveDynamicClientRegistrationMetadata(clientMetadata, server).tokenEndpointAuthMethod === "none";
|
|
4688
|
+
} catch {
|
|
4689
|
+
return false;
|
|
4690
|
+
}
|
|
4691
|
+
}
|
|
4692
|
+
/** The combined provider's resource metadata with its required scopes resolved into `scopes_supported`. */
|
|
4693
|
+
function withRequiredScopes(metadata, requiredScopes) {
|
|
4694
|
+
if (!metadata || typeof metadata !== "object") return metadata;
|
|
4695
|
+
const { scopes_supported: deprecated, ...rest } = metadata;
|
|
4696
|
+
const scopes = resolveRequiredScopes(requiredScopes, deprecated);
|
|
4697
|
+
return scopes === void 0 ? rest : {
|
|
4698
|
+
...rest,
|
|
4699
|
+
scopes_supported: scopes
|
|
4700
|
+
};
|
|
4701
|
+
}
|
|
4702
|
+
/** A refresh-token lifetime: 0 for "issue no refresh token", else a lifetime KV can store. */
|
|
4703
|
+
function isValidRefreshTokenTTL(ttl) {
|
|
4704
|
+
return ttl === 0 || isValidAccessTokenTTL(ttl);
|
|
4705
|
+
}
|
|
4706
|
+
/**
|
|
4707
|
+
* Reads a `purgeExpiredData` cursor: the phase to resume and KV's opaque cursor within it
|
|
4708
|
+
* (empty for the start of that phase). No cursor starts a new sweep with the grants.
|
|
4709
|
+
*/
|
|
4710
|
+
function parsePurgeCursor(cursor) {
|
|
4711
|
+
if (cursor === void 0) return { phase: "grants" };
|
|
4712
|
+
const separator = cursor.indexOf(":");
|
|
4713
|
+
const phase = cursor.slice(0, separator);
|
|
4714
|
+
if (separator < 0 || phase !== "grants" && phase !== "tokens") throw new TypeError("Invalid purgeExpiredData cursor");
|
|
4715
|
+
const kvCursor = cursor.slice(separator + 1);
|
|
4716
|
+
return kvCursor ? {
|
|
4717
|
+
phase,
|
|
4718
|
+
kvCursor
|
|
4719
|
+
} : { phase };
|
|
4720
|
+
}
|
|
4721
|
+
/**
|
|
4722
|
+
* RFC 6749 §6: a token request may narrow the grant's scopes. One that names scopes but none the
|
|
4723
|
+
* grant holds would otherwise get a token with no scope at all; it is `invalid_scope` instead.
|
|
4724
|
+
*/
|
|
4725
|
+
function grantsNoneOfTheRequestedScopes(requested, granted) {
|
|
4726
|
+
return (typeof requested === "string" ? requested.split(" ").filter(Boolean) : requested ?? []).length > 0 && granted.length === 0;
|
|
4727
|
+
}
|
|
4728
|
+
/** Reads a request body as text, or returns undefined once it passes `maxBytes`. */
|
|
4729
|
+
async function readTextWithLimit(request, maxBytes) {
|
|
4730
|
+
const reader = request.body?.getReader();
|
|
4731
|
+
if (!reader) return "";
|
|
4732
|
+
const chunks = [];
|
|
4733
|
+
let total = 0;
|
|
4734
|
+
for (;;) {
|
|
4735
|
+
const { done, value } = await reader.read();
|
|
4736
|
+
if (done) break;
|
|
4737
|
+
total += value.byteLength;
|
|
4738
|
+
if (total > maxBytes) {
|
|
4739
|
+
reader.cancel().catch(() => void 0);
|
|
4740
|
+
return;
|
|
4741
|
+
}
|
|
4742
|
+
chunks.push(value);
|
|
4743
|
+
}
|
|
4744
|
+
const bytes = new Uint8Array(total);
|
|
4745
|
+
let offset = 0;
|
|
4746
|
+
for (const chunk of chunks) {
|
|
4747
|
+
bytes.set(chunk, offset);
|
|
4748
|
+
offset += chunk.byteLength;
|
|
4749
|
+
}
|
|
4750
|
+
return new TextDecoder().decode(bytes);
|
|
4751
|
+
}
|
|
4474
4752
|
function isValidAccessTokenTTL(value) {
|
|
4475
4753
|
return Number.isInteger(value) && value >= KV_MIN_EXPIRATION_TTL_SECONDS;
|
|
4476
4754
|
}
|
|
@@ -4497,11 +4775,6 @@ const MAX_KV_LIST_LIMIT = 1e3;
|
|
|
4497
4775
|
* matched from `list()` alone and never read.
|
|
4498
4776
|
*/
|
|
4499
4777
|
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
4778
|
/** KV serialises key metadata as JSON and rejects a write carrying more than this. */
|
|
4506
4779
|
const MAX_KV_METADATA_BYTES = 1024;
|
|
4507
4780
|
/**
|
|
@@ -4524,6 +4797,16 @@ function grantPutOptions(grant, expiry = {}) {
|
|
|
4524
4797
|
metadata
|
|
4525
4798
|
} : expiry;
|
|
4526
4799
|
}
|
|
4800
|
+
/** Splits `grant:{userId}:{grantId}`. Grant IDs never contain `:`, so the last one separates them. */
|
|
4801
|
+
function parseGrantKey(name) {
|
|
4802
|
+
const rest = name.slice(6);
|
|
4803
|
+
const separator = rest.lastIndexOf(":");
|
|
4804
|
+
if (!name.startsWith("grant:") || separator <= 0 || separator === rest.length - 1) return void 0;
|
|
4805
|
+
return {
|
|
4806
|
+
userId: rest.slice(0, separator),
|
|
4807
|
+
grantId: rest.slice(separator + 1)
|
|
4808
|
+
};
|
|
4809
|
+
}
|
|
4527
4810
|
/** Whether a listed key carries metadata this version can match a grant on. */
|
|
4528
4811
|
function isGrantKeyMetadata(value) {
|
|
4529
4812
|
if (typeof value !== "object" || value === null) return false;
|
|
@@ -4534,6 +4817,8 @@ function isGrantKeyMetadata(value) {
|
|
|
4534
4817
|
* Length of generated token strings
|
|
4535
4818
|
*/
|
|
4536
4819
|
const TOKEN_LENGTH = 32;
|
|
4820
|
+
const ISSUED_GRANT_ID_PATTERN = /^[A-Za-z0-9_-]{16}$/;
|
|
4821
|
+
const ISSUED_TOKEN_SECRET_PATTERN = /* @__PURE__ */ new RegExp(`^[A-Za-z0-9_-]{${TOKEN_LENGTH}}$`);
|
|
4537
4822
|
/**
|
|
4538
4823
|
* Checks if a resource server matches an audience claim.
|
|
4539
4824
|
* Uses origin comparison (case-insensitive hostname via URL normalization)
|
|
@@ -4555,11 +4840,6 @@ function audienceMatches(resourceServerUrl, audienceValue) {
|
|
|
4555
4840
|
return false;
|
|
4556
4841
|
}
|
|
4557
4842
|
}
|
|
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
4843
|
/** Whether a request or audience names one canonical configured resource. */
|
|
4564
4844
|
function isExactResource(value, configuredResource) {
|
|
4565
4845
|
if (Array.isArray(value)) return value.some((entry) => typeof entry === "string" && resourceMatches(entry, configuredResource));
|
|
@@ -4619,6 +4899,20 @@ function decodeFormUrlEncodedComponent(value) {
|
|
|
4619
4899
|
return decodeURIComponent(value.replace(/\+/g, " "));
|
|
4620
4900
|
}
|
|
4621
4901
|
/**
|
|
4902
|
+
* Whether a bearer credential has the exact shape of a token this provider issues:
|
|
4903
|
+
* `{userId}:{grantId}:{secret}`, with a 16-character grant ID and a TOKEN_LENGTH secret from
|
|
4904
|
+
* generateRandomString's alphabet. Parsed from the right, because a deployer's user ID may itself
|
|
4905
|
+
* contain `:`. An external credential with exactly this shape would be treated as ours; opaque API
|
|
4906
|
+
* keys, PATs and JWTs don't have it.
|
|
4907
|
+
*/
|
|
4908
|
+
function isIssuedTokenFormat(token) {
|
|
4909
|
+
const parts = token.split(":");
|
|
4910
|
+
if (parts.length < 3) return false;
|
|
4911
|
+
const secret = parts[parts.length - 1];
|
|
4912
|
+
const grantId = parts[parts.length - 2];
|
|
4913
|
+
return parts.slice(0, -2).join(":").length > 0 && ISSUED_GRANT_ID_PATTERN.test(grantId) && ISSUED_TOKEN_SECRET_PATTERN.test(secret);
|
|
4914
|
+
}
|
|
4915
|
+
/**
|
|
4622
4916
|
* Generates a cryptographically secure random string
|
|
4623
4917
|
* @param length - The length of the string to generate
|
|
4624
4918
|
* @returns A random string of the specified length
|
|
@@ -4679,56 +4973,6 @@ function base64UrlEncode(str) {
|
|
|
4679
4973
|
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
4680
4974
|
}
|
|
4681
4975
|
/**
|
|
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
4976
|
* Encodes an ArrayBuffer as base64 string
|
|
4733
4977
|
* @param buffer - The ArrayBuffer to encode
|
|
4734
4978
|
* @returns The base64 encoded string
|
|
@@ -4893,7 +5137,7 @@ var OAuthHelpersImpl = class {
|
|
|
4893
5137
|
const clientInfo = await this.lookupClient(clientId);
|
|
4894
5138
|
if (!clientInfo) throw new AuthorizationError("invalid_request", { description: "Invalid client_id" });
|
|
4895
5139
|
try {
|
|
4896
|
-
|
|
5140
|
+
validateRedirectUri(redirectUri, this.provider.serverCapabilities);
|
|
4897
5141
|
} catch {
|
|
4898
5142
|
throw new AuthorizationError("invalid_request", { description: "Invalid redirect URI" });
|
|
4899
5143
|
}
|
|
@@ -4947,9 +5191,7 @@ var OAuthHelpersImpl = class {
|
|
|
4947
5191
|
return client ? toPublicClientInfo(client) : null;
|
|
4948
5192
|
}
|
|
4949
5193
|
/**
|
|
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
|
|
5194
|
+
* Completes an authorization request by creating a grant and an authorization code.
|
|
4953
5195
|
* @param options - Options specifying the grant details
|
|
4954
5196
|
* @returns A Promise resolving to an object containing the redirect URL
|
|
4955
5197
|
* @throws Error when the request's response type is not permitted
|
|
@@ -4958,102 +5200,55 @@ var OAuthHelpersImpl = class {
|
|
|
4958
5200
|
async completeAuthorization(options) {
|
|
4959
5201
|
const { clientId, redirectUri } = options.request;
|
|
4960
5202
|
if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
|
|
5203
|
+
if (typeof options.userId !== "string" || options.userId.length === 0 || options.userId.includes(":")) throw new TypeError("userId must be a non-empty string without \":\"");
|
|
4961
5204
|
const clientInfo = await this.lookupClient(clientId);
|
|
4962
5205
|
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.");
|
|
5206
|
+
try {
|
|
5207
|
+
validateRedirectUri(redirectUri, this.provider.serverCapabilities);
|
|
5208
|
+
} catch {
|
|
5209
|
+
throw new AuthorizationError("invalid_request", { description: "Invalid redirect URI" });
|
|
5210
|
+
}
|
|
4963
5211
|
validateAuthorizationResponseType(this.provider.serverCapabilities, options.request.responseType, clientInfo.responseTypes);
|
|
4964
5212
|
const effectiveResource = this.provider.resolveAuthorizationRequestResource(options.request.resource);
|
|
4965
5213
|
validateAuthorizationPkce(this.provider.serverCapabilities, options.request, clientInfo);
|
|
4966
5214
|
let grantsToRevoke = [];
|
|
4967
5215
|
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);
|
|
5216
|
+
const isCimdClient = isClientIdMetadataDocumentUrl(clientId);
|
|
5217
|
+
const readConcurrency = DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE;
|
|
5218
|
+
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
5219
|
}
|
|
4972
5220
|
const grantId = generateRandomString(16);
|
|
4973
5221
|
const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
|
|
4974
5222
|
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
|
-
}
|
|
5223
|
+
const authCodeSecret = generateRandomString(32);
|
|
5224
|
+
const authCode = `${options.userId}:${grantId}:${authCodeSecret}`;
|
|
5225
|
+
const authCodeId = await hashSecret(authCode);
|
|
5226
|
+
const authCodeWrappedKey = await wrapKeyWithToken(authCode, encryptionKey);
|
|
5227
|
+
const grant = {
|
|
5228
|
+
id: grantId,
|
|
5229
|
+
clientId: options.request.clientId,
|
|
5230
|
+
userId: options.userId,
|
|
5231
|
+
scope: options.scope,
|
|
5232
|
+
metadata: options.metadata,
|
|
5233
|
+
encryptedProps: encryptedData,
|
|
5234
|
+
createdAt: now,
|
|
5235
|
+
authCodeId,
|
|
5236
|
+
authCodeWrappedKey,
|
|
5237
|
+
codeChallenge: options.request.codeChallenge,
|
|
5238
|
+
codeChallengeMethod: options.request.codeChallengeMethod,
|
|
5239
|
+
resource: effectiveResource,
|
|
5240
|
+
redirectUri: options.request.redirectUri
|
|
5241
|
+
};
|
|
5242
|
+
const grantKey = `grant:${options.userId}:${grantId}`;
|
|
5243
|
+
await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), grantPutOptions(grant, { expirationTtl: 600 }));
|
|
5244
|
+
const redirectUrl = new URL(options.request.redirectUri);
|
|
5245
|
+
redirectUrl.searchParams.set("code", authCode);
|
|
5246
|
+
if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
|
|
5247
|
+
if (options.request.issuer) redirectUrl.searchParams.set("iss", options.request.issuer);
|
|
5248
|
+
try {
|
|
5249
|
+
await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
|
|
5250
|
+
} catch {}
|
|
5251
|
+
return { redirectTo: redirectUrl.toString() };
|
|
5057
5252
|
}
|
|
5058
5253
|
/**
|
|
5059
5254
|
* IDs of the user's grants that satisfy `matches`, judged from KV key metadata
|
|
@@ -5067,8 +5262,18 @@ var OAuthHelpersImpl = class {
|
|
|
5067
5262
|
* share shrinks on its own.
|
|
5068
5263
|
*/
|
|
5069
5264
|
async findGrantIds(userId, matches, readConcurrency) {
|
|
5070
|
-
const
|
|
5071
|
-
const
|
|
5265
|
+
const grantIds = [];
|
|
5266
|
+
for await (const grants of this.findGrants(userId, matches, readConcurrency)) grantIds.push(...grants.map((grant) => grant.grantId));
|
|
5267
|
+
return grantIds;
|
|
5268
|
+
}
|
|
5269
|
+
/**
|
|
5270
|
+
* Finds grants matching `matches`, for one user or, with `userId` undefined, for every user.
|
|
5271
|
+
* Metadata-carrying keys are matched without a read; legacy grants are read,
|
|
5272
|
+
* `readConcurrency` at a time. Matches are yielded as they are found (each page's metadata
|
|
5273
|
+
* matches, then each batch of reads), so a caller can act on them before the scan goes on.
|
|
5274
|
+
*/
|
|
5275
|
+
async *findGrants(userId, matches, readConcurrency) {
|
|
5276
|
+
const prefix = userId === void 0 ? "grant:" : `grant:${userId}:`;
|
|
5072
5277
|
let cursor;
|
|
5073
5278
|
do {
|
|
5074
5279
|
const page = await this.env.OAUTH_KV.list({
|
|
@@ -5077,16 +5282,32 @@ var OAuthHelpersImpl = class {
|
|
|
5077
5282
|
cursor
|
|
5078
5283
|
});
|
|
5079
5284
|
const toRead = [];
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5285
|
+
const found = [];
|
|
5286
|
+
for (const key of page.keys) {
|
|
5287
|
+
const owner = parseGrantKey(key.name);
|
|
5288
|
+
if (!owner || userId !== void 0 && owner.userId !== userId) continue;
|
|
5289
|
+
if (isGrantKeyMetadata(key.metadata)) {
|
|
5290
|
+
if (matches(key.metadata)) found.push(owner);
|
|
5291
|
+
} else toRead.push({
|
|
5292
|
+
name: key.name,
|
|
5293
|
+
...owner
|
|
5294
|
+
});
|
|
5295
|
+
}
|
|
5296
|
+
if (found.length > 0) yield found;
|
|
5083
5297
|
for (let start = 0; start < toRead.length; start += readConcurrency) {
|
|
5084
|
-
const
|
|
5085
|
-
|
|
5298
|
+
const batch = toRead.slice(start, start + readConcurrency);
|
|
5299
|
+
const grants = await Promise.all(batch.map(({ name }) => this.env.OAUTH_KV.get(name, { type: "json" })));
|
|
5300
|
+
const read = batch.filter((_, index) => {
|
|
5301
|
+
const grant = grants[index];
|
|
5302
|
+
return grant !== null && matches(grant);
|
|
5303
|
+
});
|
|
5304
|
+
if (read.length > 0) yield read.map(({ userId: userId$1, grantId }) => ({
|
|
5305
|
+
userId: userId$1,
|
|
5306
|
+
grantId
|
|
5307
|
+
}));
|
|
5086
5308
|
}
|
|
5087
5309
|
cursor = page.list_complete ? void 0 : page.cursor;
|
|
5088
5310
|
} while (cursor);
|
|
5089
|
-
return found;
|
|
5090
5311
|
}
|
|
5091
5312
|
/**
|
|
5092
5313
|
* Creates a new OAuth client
|
|
@@ -5119,7 +5340,12 @@ var OAuthHelpersImpl = class {
|
|
|
5119
5340
|
tokenEndpointAuthMethod,
|
|
5120
5341
|
...authMethodWasExplicit ? { authMethodExplicit: true } : {}
|
|
5121
5342
|
};
|
|
5122
|
-
|
|
5343
|
+
validateRegisteredRedirectUris(newClient.redirectUris, this.provider.serverCapabilities);
|
|
5344
|
+
validateClientCapabilities(this.provider.serverCapabilities, {
|
|
5345
|
+
grantTypes: newClient.grantTypes,
|
|
5346
|
+
responseTypes: newClient.responseTypes,
|
|
5347
|
+
tokenEndpointAuthMethod
|
|
5348
|
+
});
|
|
5123
5349
|
let clientSecret;
|
|
5124
5350
|
if (!isPublicClient) {
|
|
5125
5351
|
clientSecret = generateRandomString(32);
|
|
@@ -5159,8 +5385,13 @@ var OAuthHelpersImpl = class {
|
|
|
5159
5385
|
* @returns A Promise resolving to the updated client info, or null if not found
|
|
5160
5386
|
*/
|
|
5161
5387
|
async updateClient(clientId, updates) {
|
|
5388
|
+
if (this.provider.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(clientId)) throw new TypeError("Client ID Metadata Document clients are updated by changing their document");
|
|
5162
5389
|
const client = await this.provider.getClient(this.env, clientId);
|
|
5163
5390
|
if (!client) return null;
|
|
5391
|
+
if (updates.redirectUris !== void 0) {
|
|
5392
|
+
if (!Array.isArray(updates.redirectUris) || updates.redirectUris.length === 0) throw new Error("redirectUris must not be empty");
|
|
5393
|
+
validateRegisteredRedirectUris(updates.redirectUris, this.provider.serverCapabilities);
|
|
5394
|
+
}
|
|
5164
5395
|
const authMethodWasExplicit = updates.tokenEndpointAuthMethod !== void 0;
|
|
5165
5396
|
const authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
|
|
5166
5397
|
const isPublicClient = authMethod === "none";
|
|
@@ -5178,6 +5409,11 @@ var OAuthHelpersImpl = class {
|
|
|
5178
5409
|
tokenEndpointAuthMethod: authMethod,
|
|
5179
5410
|
authMethodExplicit: authMethodWasExplicit ? true : client.authMethodExplicit
|
|
5180
5411
|
};
|
|
5412
|
+
if (updates.grantTypes !== void 0 || updates.responseTypes !== void 0 || updates.tokenEndpointAuthMethod !== void 0) validateClientCapabilities(this.provider.serverCapabilities, {
|
|
5413
|
+
grantTypes: updatedClient.grantTypes ?? [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN],
|
|
5414
|
+
responseTypes: updatedClient.responseTypes ?? ["code"],
|
|
5415
|
+
tokenEndpointAuthMethod: authMethod
|
|
5416
|
+
});
|
|
5181
5417
|
if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
|
|
5182
5418
|
else delete updatedClient.clientSecret;
|
|
5183
5419
|
if (client.registrationExpiresAt === void 0) await this.env.OAUTH_KV.put(`client:${updatedClient.clientId}`, JSON.stringify(updatedClient));
|
|
@@ -5192,20 +5428,9 @@ var OAuthHelpersImpl = class {
|
|
|
5192
5428
|
* @returns A Promise resolving when the deletion is confirmed.
|
|
5193
5429
|
*/
|
|
5194
5430
|
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
5431
|
await this.env.OAUTH_KV.delete(`client:${clientId}`);
|
|
5432
|
+
const grants = this.findGrants(void 0, (grant) => grant.clientId === clientId, DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE);
|
|
5433
|
+
for await (const found of grants) for (const { userId, grantId } of found) await this.revokeGrant(grantId, userId);
|
|
5209
5434
|
}
|
|
5210
5435
|
/**
|
|
5211
5436
|
* Lists all authorization grants for a specific user with pagination support
|
|
@@ -5218,9 +5443,18 @@ var OAuthHelpersImpl = class {
|
|
|
5218
5443
|
const listOptions = { prefix: `grant:${userId}:` };
|
|
5219
5444
|
if (options?.limit !== void 0) listOptions.limit = options.limit;
|
|
5220
5445
|
if (options?.cursor !== void 0) listOptions.cursor = options.cursor;
|
|
5221
|
-
const
|
|
5446
|
+
const isOwn = (key) => !key.name.slice(listOptions.prefix.length).includes(":");
|
|
5447
|
+
let response = await this.env.OAUTH_KV.list(listOptions);
|
|
5448
|
+
let ownKeys = response.keys.filter(isOwn);
|
|
5449
|
+
while (ownKeys.length === 0 && !response.list_complete) {
|
|
5450
|
+
response = await this.env.OAUTH_KV.list({
|
|
5451
|
+
...listOptions,
|
|
5452
|
+
cursor: response.cursor
|
|
5453
|
+
});
|
|
5454
|
+
ownKeys = response.keys.filter(isOwn);
|
|
5455
|
+
}
|
|
5222
5456
|
const grantSummaries = [];
|
|
5223
|
-
const promises =
|
|
5457
|
+
const promises = ownKeys.map(async (key) => {
|
|
5224
5458
|
const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
|
|
5225
5459
|
if (grantData) {
|
|
5226
5460
|
const summary = {
|
|
@@ -5249,6 +5483,12 @@ var OAuthHelpersImpl = class {
|
|
|
5249
5483
|
* @param userId - The ID of the user who owns the grant
|
|
5250
5484
|
* @returns A Promise resolving when the revocation is confirmed.
|
|
5251
5485
|
*/
|
|
5486
|
+
/** {@inheritDoc OAuthHelpers.describeConsent} */
|
|
5487
|
+
async describeConsent(authRequest) {
|
|
5488
|
+
const client = await this.lookupClient(authRequest.clientId);
|
|
5489
|
+
const isCimd = !!this.provider.options.clientIdMetadataDocumentEnabled && isClientIdMetadataDocumentUrl(authRequest.clientId);
|
|
5490
|
+
return describeConsent(client, authRequest, isCimd);
|
|
5491
|
+
}
|
|
5252
5492
|
/** {@inheritDoc OAuthHelpers.isConsentRemembered} */
|
|
5253
5493
|
isConsentRemembered(request, authRequest, remember) {
|
|
5254
5494
|
return isConsentRemembered(this.provider.consentCookies, request, authRequest, remember);
|
|
@@ -5317,6 +5557,7 @@ var OAuthHelpersImpl = class {
|
|
|
5317
5557
|
const purgeOrphanedGrants = options?.purgeOrphanedGrants !== false;
|
|
5318
5558
|
const purgeExpiredGrants = options?.purgeExpiredGrants !== false;
|
|
5319
5559
|
const purgeOrphanedTokens = options?.purgeOrphanedTokens !== false;
|
|
5560
|
+
const start = parsePurgeCursor(options?.cursor);
|
|
5320
5561
|
const now = Math.floor(Date.now() / 1e3);
|
|
5321
5562
|
const result = {
|
|
5322
5563
|
grantsChecked: 0,
|
|
@@ -5325,10 +5566,10 @@ var OAuthHelpersImpl = class {
|
|
|
5325
5566
|
tokensPurged: 0,
|
|
5326
5567
|
done: false
|
|
5327
5568
|
};
|
|
5328
|
-
if (purgeOrphanedGrants || purgeExpiredGrants) {
|
|
5569
|
+
if (start.phase === "grants" && (purgeOrphanedGrants || purgeExpiredGrants)) {
|
|
5329
5570
|
const knownGoodClients = /* @__PURE__ */ new Set();
|
|
5330
5571
|
const knownMissingClients = /* @__PURE__ */ new Set();
|
|
5331
|
-
let grantCursor;
|
|
5572
|
+
let grantCursor = start.kvCursor;
|
|
5332
5573
|
let grantsDone = false;
|
|
5333
5574
|
while (!grantsDone && result.grantsChecked < batchSize) {
|
|
5334
5575
|
const listOptions = {
|
|
@@ -5344,7 +5585,7 @@ var OAuthHelpersImpl = class {
|
|
|
5344
5585
|
if (!grantData) continue;
|
|
5345
5586
|
let shouldPurge = false;
|
|
5346
5587
|
if (purgeExpiredGrants && grantData.expiresAt !== void 0 && now >= grantData.expiresAt) shouldPurge = true;
|
|
5347
|
-
if (!shouldPurge && purgeOrphanedGrants && !
|
|
5588
|
+
if (!shouldPurge && purgeOrphanedGrants && !isClientIdMetadataDocumentUrl(grantData.clientId)) {
|
|
5348
5589
|
if (knownMissingClients.has(grantData.clientId)) shouldPurge = true;
|
|
5349
5590
|
else if (!knownGoodClients.has(grantData.clientId)) if (await this.env.OAUTH_KV.get(`client:${grantData.clientId}`, { type: "json" })) knownGoodClients.add(grantData.clientId);
|
|
5350
5591
|
else {
|
|
@@ -5360,12 +5601,15 @@ var OAuthHelpersImpl = class {
|
|
|
5360
5601
|
if (page.list_complete) grantsDone = true;
|
|
5361
5602
|
else grantCursor = page.cursor;
|
|
5362
5603
|
}
|
|
5363
|
-
if (!grantsDone) return
|
|
5604
|
+
if (!grantsDone) return {
|
|
5605
|
+
...result,
|
|
5606
|
+
cursor: `grants:${grantCursor ?? ""}`
|
|
5607
|
+
};
|
|
5364
5608
|
}
|
|
5365
5609
|
if (purgeOrphanedTokens) {
|
|
5366
5610
|
const knownGoodGrants = /* @__PURE__ */ new Set();
|
|
5367
5611
|
const knownMissingGrants = /* @__PURE__ */ new Set();
|
|
5368
|
-
let tokenCursor;
|
|
5612
|
+
let tokenCursor = start.phase === "tokens" ? start.kvCursor : void 0;
|
|
5369
5613
|
let tokensDone = false;
|
|
5370
5614
|
while (!tokensDone && result.tokensChecked < batchSize) {
|
|
5371
5615
|
const listOptions = {
|
|
@@ -5393,7 +5637,10 @@ var OAuthHelpersImpl = class {
|
|
|
5393
5637
|
if (page.list_complete) tokensDone = true;
|
|
5394
5638
|
else tokenCursor = page.cursor;
|
|
5395
5639
|
}
|
|
5396
|
-
if (!tokensDone) return
|
|
5640
|
+
if (!tokensDone) return {
|
|
5641
|
+
...result,
|
|
5642
|
+
cursor: `tokens:${tokenCursor ?? ""}`
|
|
5643
|
+
};
|
|
5397
5644
|
}
|
|
5398
5645
|
result.done = true;
|
|
5399
5646
|
return result;
|
|
@@ -5406,4 +5653,4 @@ var OAuthHelpersImpl = class {
|
|
|
5406
5653
|
var oauth_provider_default = OAuthProvider;
|
|
5407
5654
|
|
|
5408
5655
|
//#endregion
|
|
5409
|
-
export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer,
|
|
5656
|
+
export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthAuthorizationServer, OAuthError, OAuthProvider, OAuthResourceServer, authorizationErrorRedirect, oauth_provider_default as default, getOAuthApi, insufficientScope };
|