@cloudflare/workers-oauth-provider 0.1.0 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1697 +1,1809 @@
1
- var __typeError = (msg) => {
2
- throw TypeError(msg);
3
- };
4
- var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg);
5
- var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
6
- var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
7
- var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
8
-
9
- // src/oauth-provider.ts
10
1
  import { WorkerEntrypoint } from "cloudflare:workers";
11
- var _impl;
2
+
3
+ //#region src/oauth-provider.ts
4
+ if (!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags?.global_fetch_strictly_public === true)) console.warn("CIMD (Client ID Metadata Document) is disabled: add '\"compatibility_flags\": [\"global_fetch_strictly_public\"]' to your wrangler.jsonc to enable. See: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public");
5
+ /**
6
+ * Enum representing the type of handler (ExportedHandler or WorkerEntrypoint)
7
+ */
8
+ var HandlerType = /* @__PURE__ */ function(HandlerType$1) {
9
+ HandlerType$1[HandlerType$1["EXPORTED_HANDLER"] = 0] = "EXPORTED_HANDLER";
10
+ HandlerType$1[HandlerType$1["WORKER_ENTRYPOINT"] = 1] = "WORKER_ENTRYPOINT";
11
+ return HandlerType$1;
12
+ }(HandlerType || {});
13
+ /**
14
+ * Enum representing OAuth grant types
15
+ */
16
+ let GrantType = /* @__PURE__ */ function(GrantType$1) {
17
+ GrantType$1["AUTHORIZATION_CODE"] = "authorization_code";
18
+ GrantType$1["REFRESH_TOKEN"] = "refresh_token";
19
+ GrantType$1["TOKEN_EXCHANGE"] = "urn:ietf:params:oauth:grant-type:token-exchange";
20
+ return GrantType$1;
21
+ }({});
22
+ /**
23
+ * OAuth 2.0 Provider implementation for Cloudflare Workers
24
+ * Implements authorization code flow with support for refresh tokens
25
+ * and dynamic client registration.
26
+ */
12
27
  var OAuthProvider = class {
13
- /**
14
- * Creates a new OAuth provider instance
15
- * @param options - Configuration options for the provider
16
- */
17
- constructor(options) {
18
- __privateAdd(this, _impl);
19
- __privateSet(this, _impl, new OAuthProviderImpl(options));
20
- }
21
- /**
22
- * Main fetch handler for the Worker
23
- * Routes requests to the appropriate handler based on the URL
24
- * @param request - The HTTP request
25
- * @param env - Cloudflare Worker environment variables
26
- * @param ctx - Cloudflare Worker execution context
27
- * @returns A Promise resolving to an HTTP Response
28
- */
29
- fetch(request, env, ctx) {
30
- return __privateGet(this, _impl).fetch(request, env, ctx);
31
- }
28
+ #impl;
29
+ /**
30
+ * Creates a new OAuth provider instance
31
+ * @param options - Configuration options for the provider
32
+ */
33
+ constructor(options) {
34
+ this.#impl = new OAuthProviderImpl(options);
35
+ }
36
+ /**
37
+ * Main fetch handler for the Worker
38
+ * Routes requests to the appropriate handler based on the URL
39
+ * @param request - The HTTP request
40
+ * @param env - Cloudflare Worker environment variables
41
+ * @param ctx - Cloudflare Worker execution context
42
+ * @returns A Promise resolving to an HTTP Response
43
+ */
44
+ fetch(request, env, ctx) {
45
+ return this.#impl.fetch(request, env, ctx);
46
+ }
32
47
  };
33
- _impl = new WeakMap();
34
- var OAuthProviderImpl = class {
35
- /**
36
- * Creates a new OAuth provider instance
37
- * @param options - Configuration options for the provider
38
- */
39
- constructor(options) {
40
- this.typedApiHandlers = [];
41
- const hasSingleHandlerConfig = !!(options.apiRoute && options.apiHandler);
42
- const hasMultiHandlerConfig = !!options.apiHandlers;
43
- if (hasSingleHandlerConfig && hasMultiHandlerConfig) {
44
- throw new TypeError(
45
- "Cannot use both apiRoute/apiHandler and apiHandlers. Use either apiRoute + apiHandler OR apiHandlers, not both."
46
- );
47
- }
48
- if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) {
49
- throw new TypeError(
50
- "Must provide either apiRoute + apiHandler OR apiHandlers. No API route configuration provided."
51
- );
52
- }
53
- this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
54
- if (hasSingleHandlerConfig) {
55
- const apiHandler = this.validateHandler(options.apiHandler, "apiHandler");
56
- if (Array.isArray(options.apiRoute)) {
57
- options.apiRoute.forEach((route, index) => {
58
- this.validateEndpoint(route, `apiRoute[${index}]`);
59
- this.typedApiHandlers.push([route, apiHandler]);
60
- });
61
- } else {
62
- this.validateEndpoint(options.apiRoute, "apiRoute");
63
- this.typedApiHandlers.push([options.apiRoute, apiHandler]);
64
- }
65
- } else {
66
- for (const [route, handler] of Object.entries(options.apiHandlers)) {
67
- this.validateEndpoint(route, `apiHandlers key: ${route}`);
68
- this.typedApiHandlers.push([route, this.validateHandler(handler, `apiHandlers[${route}]`)]);
69
- }
70
- }
71
- this.validateEndpoint(options.authorizeEndpoint, "authorizeEndpoint");
72
- this.validateEndpoint(options.tokenEndpoint, "tokenEndpoint");
73
- if (options.clientRegistrationEndpoint) {
74
- this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
75
- }
76
- this.options = {
77
- accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
78
- onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
79
- ...options
80
- };
81
- }
82
- /**
83
- * Validates that an endpoint is either an absolute path or a full URL
84
- * @param endpoint - The endpoint to validate
85
- * @param name - The name of the endpoint property for error messages
86
- * @throws TypeError if the endpoint is invalid
87
- */
88
- validateEndpoint(endpoint, name) {
89
- if (this.isPath(endpoint)) {
90
- if (!endpoint.startsWith("/")) {
91
- throw new TypeError(`${name} path must be an absolute path starting with /`);
92
- }
93
- } else {
94
- try {
95
- new URL(endpoint);
96
- } catch (e) {
97
- throw new TypeError(`${name} must be either an absolute path starting with / or a valid URL`);
98
- }
99
- }
100
- }
101
- /**
102
- * Validates that a handler is either an ExportedHandler or a class extending WorkerEntrypoint
103
- * @param handler - The handler to validate
104
- * @param name - The name of the handler property for error messages
105
- * @returns The type of the handler (EXPORTED_HANDLER or WORKER_ENTRYPOINT)
106
- * @throws TypeError if the handler is invalid
107
- */
108
- validateHandler(handler, name) {
109
- if (typeof handler === "object" && handler !== null && typeof handler.fetch === "function") {
110
- return { type: 0 /* EXPORTED_HANDLER */, handler };
111
- }
112
- if (typeof handler === "function" && handler.prototype instanceof WorkerEntrypoint) {
113
- return { type: 1 /* WORKER_ENTRYPOINT */, handler };
114
- }
115
- throw new TypeError(
116
- `${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`
117
- );
118
- }
119
- /**
120
- * Main fetch handler for the Worker
121
- * Routes requests to the appropriate handler based on the URL
122
- * @param request - The HTTP request
123
- * @param env - Cloudflare Worker environment variables
124
- * @param ctx - Cloudflare Worker execution context
125
- * @returns A Promise resolving to an HTTP Response
126
- */
127
- async fetch(request, env, ctx) {
128
- const url = new URL(request.url);
129
- if (request.method === "OPTIONS") {
130
- if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
131
- return this.addCorsHeaders(
132
- new Response(null, {
133
- status: 204,
134
- headers: { "Content-Length": "0" }
135
- }),
136
- request
137
- );
138
- }
139
- }
140
- if (url.pathname === "/.well-known/oauth-authorization-server") {
141
- const response = await this.handleMetadataDiscovery(url);
142
- return this.addCorsHeaders(response, request);
143
- }
144
- if (this.isTokenEndpoint(url)) {
145
- const parsed = await this.parseTokenEndpointRequest(request, env);
146
- if (parsed instanceof Response) {
147
- return this.addCorsHeaders(parsed, request);
148
- }
149
- let response;
150
- if (parsed.isRevocationRequest) {
151
- response = await this.handleRevocationRequest(parsed.body, env);
152
- } else {
153
- response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env);
154
- }
155
- return this.addCorsHeaders(response, request);
156
- }
157
- if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
158
- const response = await this.handleClientRegistration(request, env);
159
- return this.addCorsHeaders(response, request);
160
- }
161
- if (this.isApiRequest(url)) {
162
- const response = await this.handleApiRequest(request, env, ctx);
163
- return this.addCorsHeaders(response, request);
164
- }
165
- if (!env.OAUTH_PROVIDER) {
166
- env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
167
- }
168
- if (this.typedDefaultHandler.type === 0 /* EXPORTED_HANDLER */) {
169
- return this.typedDefaultHandler.handler.fetch(
170
- request,
171
- env,
172
- ctx
173
- );
174
- } else {
175
- const handler = new this.typedDefaultHandler.handler(ctx, env);
176
- return handler.fetch(request);
177
- }
178
- }
179
- /**
180
- * Determines if an endpoint configuration is a path or a full URL
181
- * @param endpoint - The endpoint configuration
182
- * @returns True if the endpoint is a path (starts with /), false if it's a full URL
183
- */
184
- isPath(endpoint) {
185
- return endpoint.startsWith("/");
186
- }
187
- /**
188
- * Matches a URL against an endpoint pattern that can be a full URL or just a path
189
- * @param url - The URL to check
190
- * @param endpoint - The endpoint pattern (full URL or path)
191
- * @returns True if the URL matches the endpoint pattern
192
- */
193
- matchEndpoint(url, endpoint) {
194
- if (this.isPath(endpoint)) {
195
- return url.pathname === endpoint;
196
- } else {
197
- const endpointUrl = new URL(endpoint);
198
- return url.hostname === endpointUrl.hostname && url.pathname === endpointUrl.pathname;
199
- }
200
- }
201
- /**
202
- * Checks if a URL matches the configured token endpoint
203
- * @param url - The URL to check
204
- * @returns True if the URL matches the token endpoint
205
- */
206
- isTokenEndpoint(url) {
207
- return this.matchEndpoint(url, this.options.tokenEndpoint);
208
- }
209
- /**
210
- * Checks if a URL matches the configured client registration endpoint
211
- * @param url - The URL to check
212
- * @returns True if the URL matches the client registration endpoint
213
- */
214
- isClientRegistrationEndpoint(url) {
215
- if (!this.options.clientRegistrationEndpoint) return false;
216
- return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
217
- }
218
- /**
219
- * Parses and validates a token endpoint request (used for both token exchange and revocation)
220
- * @param request - The HTTP request to parse
221
- * @returns Promise with parsed body and client info, or error response
222
- */
223
- async parseTokenEndpointRequest(request, env) {
224
- if (request.method !== "POST") {
225
- return this.createErrorResponse("invalid_request", "Method not allowed", 405);
226
- }
227
- let contentType = request.headers.get("Content-Type") || "";
228
- let body = {};
229
- if (!contentType.includes("application/x-www-form-urlencoded")) {
230
- return this.createErrorResponse("invalid_request", "Content-Type must be application/x-www-form-urlencoded", 400);
231
- }
232
- const formData = await request.formData();
233
- for (const [key, value] of formData.entries()) {
234
- const allValues = formData.getAll(key);
235
- body[key] = allValues.length > 1 ? allValues : value;
236
- }
237
- const authHeader = request.headers.get("Authorization");
238
- let clientId = "";
239
- let clientSecret = "";
240
- if (authHeader && authHeader.startsWith("Basic ")) {
241
- const credentials = atob(authHeader.substring(6));
242
- const [id, secret] = credentials.split(":", 2);
243
- clientId = decodeURIComponent(id);
244
- clientSecret = decodeURIComponent(secret || "");
245
- } else {
246
- clientId = body.client_id;
247
- clientSecret = body.client_secret || "";
248
- }
249
- if (!clientId) {
250
- return this.createErrorResponse("invalid_client", "Client ID is required", 401);
251
- }
252
- const clientInfo = await this.getClient(env, clientId);
253
- if (!clientInfo) {
254
- return this.createErrorResponse("invalid_client", "Client not found", 401);
255
- }
256
- const isPublicClient = clientInfo.tokenEndpointAuthMethod === "none";
257
- if (!isPublicClient) {
258
- if (!clientSecret) {
259
- return this.createErrorResponse("invalid_client", "Client authentication failed: missing client_secret", 401);
260
- }
261
- if (!clientInfo.clientSecret) {
262
- return this.createErrorResponse(
263
- "invalid_client",
264
- "Client authentication failed: client has no registered secret",
265
- 401
266
- );
267
- }
268
- const providedSecretHash = await hashSecret(clientSecret);
269
- if (providedSecretHash !== clientInfo.clientSecret) {
270
- return this.createErrorResponse("invalid_client", "Client authentication failed: invalid client_secret", 401);
271
- }
272
- }
273
- const isRevocationRequest = !body.grant_type && !!body.token;
274
- return {
275
- body,
276
- clientInfo,
277
- isRevocationRequest
278
- };
279
- }
280
- /**
281
- * Checks if a URL matches a specific API route
282
- * @param url - The URL to check
283
- * @param route - The API route to check against
284
- * @returns True if the URL matches the API route
285
- */
286
- matchApiRoute(url, route) {
287
- if (this.isPath(route)) {
288
- return url.pathname.startsWith(route);
289
- } else {
290
- const apiUrl = new URL(route);
291
- return url.hostname === apiUrl.hostname && url.pathname.startsWith(apiUrl.pathname);
292
- }
293
- }
294
- /**
295
- * Checks if a URL is an API request based on the configured API route(s)
296
- * @param url - The URL to check
297
- * @returns True if the URL matches any of the API routes
298
- */
299
- isApiRequest(url) {
300
- for (const [route, _] of this.typedApiHandlers) {
301
- if (this.matchApiRoute(url, route)) {
302
- return true;
303
- }
304
- }
305
- return false;
306
- }
307
- /**
308
- * Finds the appropriate API handler for a URL
309
- * @param url - The URL to find a handler for
310
- * @returns The TypedHandler for the URL, or undefined if no handler matches
311
- */
312
- findApiHandlerForUrl(url) {
313
- for (const [route, handler] of this.typedApiHandlers) {
314
- if (this.matchApiRoute(url, route)) {
315
- return handler;
316
- }
317
- }
318
- return void 0;
319
- }
320
- /**
321
- * Gets the full URL for an endpoint, using the provided request URL's
322
- * origin for endpoints specified as just paths
323
- * @param endpoint - The endpoint configuration (path or full URL)
324
- * @param requestUrl - The URL of the incoming request
325
- * @returns The full URL for the endpoint
326
- */
327
- getFullEndpointUrl(endpoint, requestUrl) {
328
- if (this.isPath(endpoint)) {
329
- return `${requestUrl.origin}${endpoint}`;
330
- } else {
331
- return endpoint;
332
- }
333
- }
334
- /**
335
- * Adds CORS headers to a response
336
- * @param response - The response to add CORS headers to
337
- * @param request - The original request
338
- * @returns A new Response with CORS headers added
339
- */
340
- addCorsHeaders(response, request) {
341
- const origin = request.headers.get("Origin");
342
- if (!origin) {
343
- return response;
344
- }
345
- const newResponse = new Response(response.body, response);
346
- newResponse.headers.set("Access-Control-Allow-Origin", origin);
347
- newResponse.headers.set("Access-Control-Allow-Methods", "*");
348
- newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
349
- newResponse.headers.set("Access-Control-Max-Age", "86400");
350
- return newResponse;
351
- }
352
- /**
353
- * Handles the OAuth metadata discovery endpoint
354
- * Implements RFC 8414 for OAuth Server Metadata
355
- * @param requestUrl - The URL of the incoming request
356
- * @returns Response with OAuth server metadata
357
- */
358
- async handleMetadataDiscovery(requestUrl) {
359
- const tokenEndpoint = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
360
- const authorizeEndpoint = this.getFullEndpointUrl(this.options.authorizeEndpoint, requestUrl);
361
- let registrationEndpoint = void 0;
362
- if (this.options.clientRegistrationEndpoint) {
363
- registrationEndpoint = this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, requestUrl);
364
- }
365
- const responseTypesSupported = ["code"];
366
- if (this.options.allowImplicitFlow) {
367
- responseTypesSupported.push("token");
368
- }
369
- const metadata = {
370
- issuer: new URL(tokenEndpoint).origin,
371
- authorization_endpoint: authorizeEndpoint,
372
- token_endpoint: tokenEndpoint,
373
- // not implemented: jwks_uri
374
- registration_endpoint: registrationEndpoint,
375
- scopes_supported: this.options.scopesSupported,
376
- response_types_supported: responseTypesSupported,
377
- response_modes_supported: ["query"],
378
- grant_types_supported: ["authorization_code", "refresh_token"],
379
- // Support "none" auth method for public clients
380
- token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none"],
381
- // not implemented: token_endpoint_auth_signing_alg_values_supported
382
- // not implemented: service_documentation
383
- // not implemented: ui_locales_supported
384
- // not implemented: op_policy_uri
385
- // not implemented: op_tos_uri
386
- revocation_endpoint: tokenEndpoint,
387
- // Reusing token endpoint for revocation
388
- // not implemented: revocation_endpoint_auth_methods_supported
389
- // not implemented: revocation_endpoint_auth_signing_alg_values_supported
390
- // not implemented: introspection_endpoint
391
- // not implemented: introspection_endpoint_auth_methods_supported
392
- // not implemented: introspection_endpoint_auth_signing_alg_values_supported
393
- code_challenge_methods_supported: ["plain", "S256"]
394
- // PKCE support
395
- };
396
- return new Response(JSON.stringify(metadata), {
397
- headers: { "Content-Type": "application/json" }
398
- });
399
- }
400
- /**
401
- * Handles client authentication and token issuance via the token endpoint
402
- * Supports authorization_code and refresh_token grant types
403
- * @param body - The parsed request body
404
- * @param clientInfo - The authenticated client information
405
- * @param env - Cloudflare Worker environment variables
406
- * @returns Response with token data or error
407
- */
408
- async handleTokenRequest(body, clientInfo, env) {
409
- const grantType = body.grant_type;
410
- if (grantType === "authorization_code") {
411
- return this.handleAuthorizationCodeGrant(body, clientInfo, env);
412
- } else if (grantType === "refresh_token") {
413
- return this.handleRefreshTokenGrant(body, clientInfo, env);
414
- } else {
415
- return this.createErrorResponse("unsupported_grant_type", "Grant type not supported");
416
- }
417
- }
418
- /**
419
- * Handles the authorization code grant type
420
- * Exchanges an authorization code for access and refresh tokens
421
- * @param body - The parsed request body
422
- * @param clientInfo - The authenticated client information
423
- * @param env - Cloudflare Worker environment variables
424
- * @returns Response with token data or error
425
- */
426
- async handleAuthorizationCodeGrant(body, clientInfo, env) {
427
- const code = body.code;
428
- const redirectUri = body.redirect_uri;
429
- const codeVerifier = body.code_verifier;
430
- if (!code) {
431
- return this.createErrorResponse("invalid_request", "Authorization code is required");
432
- }
433
- const codeParts = code.split(":");
434
- if (codeParts.length !== 3) {
435
- return this.createErrorResponse("invalid_grant", "Invalid authorization code format");
436
- }
437
- const [userId, grantId, _] = codeParts;
438
- const grantKey = `grant:${userId}:${grantId}`;
439
- const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
440
- if (!grantData) {
441
- return this.createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
442
- }
443
- if (!grantData.authCodeId) {
444
- return this.createErrorResponse("invalid_grant", "Authorization code already used");
445
- }
446
- const codeHash = await hashSecret(code);
447
- if (codeHash !== grantData.authCodeId) {
448
- return this.createErrorResponse("invalid_grant", "Invalid authorization code");
449
- }
450
- if (grantData.clientId !== clientInfo.clientId) {
451
- return this.createErrorResponse("invalid_grant", "Client ID mismatch");
452
- }
453
- const isPkceEnabled = !!grantData.codeChallenge;
454
- if (!redirectUri && !isPkceEnabled) {
455
- return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
456
- }
457
- if (redirectUri && !clientInfo.redirectUris.includes(redirectUri)) {
458
- return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
459
- }
460
- if (!isPkceEnabled && codeVerifier) {
461
- return this.createErrorResponse("invalid_request", "code_verifier provided for a flow that did not use PKCE");
462
- }
463
- if (isPkceEnabled) {
464
- if (!codeVerifier) {
465
- return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
466
- }
467
- let calculatedChallenge;
468
- if (grantData.codeChallengeMethod === "S256") {
469
- const encoder = new TextEncoder();
470
- const data = encoder.encode(codeVerifier);
471
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
472
- const hashArray = Array.from(new Uint8Array(hashBuffer));
473
- calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
474
- } else {
475
- calculatedChallenge = codeVerifier;
476
- }
477
- if (calculatedChallenge !== grantData.codeChallenge) {
478
- return this.createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
479
- }
480
- }
481
- const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
482
- const accessToken = `${userId}:${grantId}:${accessTokenSecret}`;
483
- const accessTokenId = await generateTokenId(accessToken);
484
- let accessTokenTTL = this.options.accessTokenTTL;
485
- let refreshTokenTTL = this.options.refreshTokenTTL;
486
- const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
487
- let grantEncryptionKey = encryptionKey;
488
- let accessTokenEncryptionKey = encryptionKey;
489
- let encryptedAccessTokenProps = grantData.encryptedProps;
490
- if (this.options.tokenExchangeCallback) {
491
- const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
492
- let grantProps = decryptedProps;
493
- let accessTokenProps = decryptedProps;
494
- const callbackOptions = {
495
- grantType: "authorization_code",
496
- clientId: clientInfo.clientId,
497
- userId,
498
- scope: grantData.scope,
499
- props: decryptedProps
500
- };
501
- const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
502
- if (callbackResult) {
503
- if (callbackResult.newProps) {
504
- grantProps = callbackResult.newProps;
505
- if (!callbackResult.accessTokenProps) {
506
- accessTokenProps = callbackResult.newProps;
507
- }
508
- }
509
- if (callbackResult.accessTokenProps) {
510
- accessTokenProps = callbackResult.accessTokenProps;
511
- }
512
- if (callbackResult.accessTokenTTL !== void 0) {
513
- accessTokenTTL = callbackResult.accessTokenTTL;
514
- }
515
- if ("refreshTokenTTL" in callbackResult) {
516
- refreshTokenTTL = callbackResult.refreshTokenTTL;
517
- }
518
- }
519
- const grantResult = await encryptProps(grantProps);
520
- grantData.encryptedProps = grantResult.encryptedData;
521
- grantEncryptionKey = grantResult.key;
522
- if (accessTokenProps !== grantProps) {
523
- const tokenResult = await encryptProps(accessTokenProps);
524
- encryptedAccessTokenProps = tokenResult.encryptedData;
525
- accessTokenEncryptionKey = tokenResult.key;
526
- } else {
527
- encryptedAccessTokenProps = grantData.encryptedProps;
528
- accessTokenEncryptionKey = grantEncryptionKey;
529
- }
530
- }
531
- const now = Math.floor(Date.now() / 1e3);
532
- const accessTokenExpiresAt = now + accessTokenTTL;
533
- const useRefreshToken = refreshTokenTTL !== 0;
534
- const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, accessTokenEncryptionKey);
535
- delete grantData.authCodeId;
536
- delete grantData.codeChallenge;
537
- delete grantData.codeChallengeMethod;
538
- delete grantData.authCodeWrappedKey;
539
- let refreshToken;
540
- if (useRefreshToken) {
541
- const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
542
- refreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
543
- const refreshTokenId = await generateTokenId(refreshToken);
544
- const refreshTokenWrappedKey = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
545
- const expiresAt = refreshTokenTTL !== void 0 ? now + refreshTokenTTL : void 0;
546
- grantData.refreshTokenId = refreshTokenId;
547
- grantData.refreshTokenWrappedKey = refreshTokenWrappedKey;
548
- grantData.previousRefreshTokenId = void 0;
549
- grantData.previousRefreshTokenWrappedKey = void 0;
550
- grantData.expiresAt = expiresAt;
551
- }
552
- await this.saveGrantWithTTL(env, grantKey, grantData, now);
553
- if (body.resource && grantData.resource) {
554
- const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
555
- const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
556
- for (const requested of requestedResources) {
557
- if (!grantedResources.includes(requested)) {
558
- return this.createErrorResponse(
559
- "invalid_target",
560
- "Requested resource was not included in the authorization request"
561
- );
562
- }
563
- }
564
- }
565
- const audience = parseResourceParameter(body.resource || grantData.resource);
566
- if ((body.resource || grantData.resource) && !audience) {
567
- return this.createErrorResponse(
568
- "invalid_target",
569
- "The resource parameter must be a valid absolute URI without a fragment"
570
- );
571
- }
572
- const accessTokenData = {
573
- id: accessTokenId,
574
- grantId,
575
- userId,
576
- createdAt: now,
577
- expiresAt: accessTokenExpiresAt,
578
- audience,
579
- wrappedEncryptionKey: accessTokenWrappedKey,
580
- grant: {
581
- clientId: grantData.clientId,
582
- scope: grantData.scope,
583
- encryptedProps: encryptedAccessTokenProps
584
- }
585
- };
586
- await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), {
587
- expirationTtl: accessTokenTTL
588
- });
589
- const tokenResponse = {
590
- access_token: accessToken,
591
- token_type: "bearer",
592
- expires_in: accessTokenTTL,
593
- scope: grantData.scope.join(" ")
594
- };
595
- if (refreshToken) {
596
- tokenResponse.refresh_token = refreshToken;
597
- }
598
- if (audience) {
599
- tokenResponse.resource = audience;
600
- }
601
- return new Response(JSON.stringify(tokenResponse), {
602
- headers: { "Content-Type": "application/json" }
603
- });
604
- }
605
- /**
606
- * Handles the refresh token grant type
607
- * Issues a new access token using a refresh token
608
- * @param body - The parsed request body
609
- * @param clientInfo - The authenticated client information
610
- * @param env - Cloudflare Worker environment variables
611
- * @returns Response with token data or error
612
- */
613
- async handleRefreshTokenGrant(body, clientInfo, env) {
614
- const refreshToken = body.refresh_token;
615
- if (!refreshToken) {
616
- return this.createErrorResponse("invalid_request", "Refresh token is required");
617
- }
618
- const tokenParts = refreshToken.split(":");
619
- if (tokenParts.length !== 3) {
620
- return this.createErrorResponse("invalid_grant", "Invalid token format");
621
- }
622
- const [userId, grantId, _] = tokenParts;
623
- const providedTokenHash = await generateTokenId(refreshToken);
624
- const grantKey = `grant:${userId}:${grantId}`;
625
- const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
626
- if (!grantData) {
627
- return this.createErrorResponse("invalid_grant", "Grant not found");
628
- }
629
- const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
630
- const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
631
- if (!isCurrentToken && !isPreviousToken) {
632
- return this.createErrorResponse("invalid_grant", "Invalid refresh token");
633
- }
634
- if (grantData.clientId !== clientInfo.clientId) {
635
- return this.createErrorResponse("invalid_grant", "Client ID mismatch");
636
- }
637
- if (grantData.expiresAt !== void 0) {
638
- const now2 = Math.floor(Date.now() / 1e3);
639
- if (now2 >= grantData.expiresAt) {
640
- return this.createErrorResponse("invalid_grant", "Refresh token has expired");
641
- }
642
- }
643
- const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
644
- const newAccessToken = `${userId}:${grantId}:${accessTokenSecret}`;
645
- const accessTokenId = await generateTokenId(newAccessToken);
646
- let accessTokenTTL = this.options.accessTokenTTL;
647
- let wrappedKeyToUse;
648
- if (isCurrentToken) {
649
- wrappedKeyToUse = grantData.refreshTokenWrappedKey;
650
- } else {
651
- wrappedKeyToUse = grantData.previousRefreshTokenWrappedKey;
652
- }
653
- const encryptionKey = await unwrapKeyWithToken(refreshToken, wrappedKeyToUse);
654
- let grantEncryptionKey = encryptionKey;
655
- let accessTokenEncryptionKey = encryptionKey;
656
- let encryptedAccessTokenProps = grantData.encryptedProps;
657
- let grantPropsChanged = false;
658
- if (this.options.tokenExchangeCallback) {
659
- const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
660
- let grantProps = decryptedProps;
661
- let accessTokenProps = decryptedProps;
662
- const callbackOptions = {
663
- grantType: "refresh_token",
664
- clientId: clientInfo.clientId,
665
- userId,
666
- scope: grantData.scope,
667
- props: decryptedProps
668
- };
669
- const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
670
- if (callbackResult) {
671
- if (callbackResult.newProps) {
672
- grantProps = callbackResult.newProps;
673
- grantPropsChanged = true;
674
- if (!callbackResult.accessTokenProps) {
675
- accessTokenProps = callbackResult.newProps;
676
- }
677
- }
678
- if (callbackResult.accessTokenProps) {
679
- accessTokenProps = callbackResult.accessTokenProps;
680
- }
681
- if (callbackResult.accessTokenTTL !== void 0) {
682
- accessTokenTTL = callbackResult.accessTokenTTL;
683
- }
684
- if ("refreshTokenTTL" in callbackResult) {
685
- return this.createErrorResponse(
686
- "invalid_request",
687
- "refreshTokenTTL cannot be changed during refresh token exchange"
688
- );
689
- }
690
- }
691
- if (grantPropsChanged) {
692
- const grantResult = await encryptProps(grantProps);
693
- grantData.encryptedProps = grantResult.encryptedData;
694
- if (grantResult.key !== encryptionKey) {
695
- grantEncryptionKey = grantResult.key;
696
- wrappedKeyToUse = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
697
- } else {
698
- grantEncryptionKey = grantResult.key;
699
- }
700
- }
701
- if (accessTokenProps !== grantProps) {
702
- const tokenResult = await encryptProps(accessTokenProps);
703
- encryptedAccessTokenProps = tokenResult.encryptedData;
704
- accessTokenEncryptionKey = tokenResult.key;
705
- } else {
706
- encryptedAccessTokenProps = grantData.encryptedProps;
707
- accessTokenEncryptionKey = grantEncryptionKey;
708
- }
709
- }
710
- const now = Math.floor(Date.now() / 1e3);
711
- if (grantData.expiresAt !== void 0) {
712
- const remainingRefreshTokenLifetime = grantData.expiresAt - now;
713
- if (remainingRefreshTokenLifetime > 0) {
714
- accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
715
- }
716
- }
717
- const accessTokenExpiresAt = now + accessTokenTTL;
718
- const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
719
- const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
720
- const newRefreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
721
- const newRefreshTokenId = await generateTokenId(newRefreshToken);
722
- const newRefreshTokenWrappedKey = await wrapKeyWithToken(newRefreshToken, grantEncryptionKey);
723
- grantData.previousRefreshTokenId = providedTokenHash;
724
- grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
725
- grantData.refreshTokenId = newRefreshTokenId;
726
- grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
727
- await this.saveGrantWithTTL(env, grantKey, grantData, now);
728
- if (body.resource && grantData.resource) {
729
- const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
730
- const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
731
- for (const requested of requestedResources) {
732
- if (!grantedResources.includes(requested)) {
733
- return this.createErrorResponse(
734
- "invalid_target",
735
- "Requested resource was not included in the authorization request"
736
- );
737
- }
738
- }
739
- }
740
- const audience = parseResourceParameter(body.resource || grantData.resource);
741
- if ((body.resource || grantData.resource) && !audience) {
742
- return this.createErrorResponse(
743
- "invalid_target",
744
- "The resource parameter must be a valid absolute URI without a fragment"
745
- );
746
- }
747
- const accessTokenData = {
748
- id: accessTokenId,
749
- grantId,
750
- userId,
751
- createdAt: now,
752
- expiresAt: accessTokenExpiresAt,
753
- audience,
754
- wrappedEncryptionKey: accessTokenWrappedKey,
755
- grant: {
756
- clientId: grantData.clientId,
757
- scope: grantData.scope,
758
- encryptedProps: encryptedAccessTokenProps
759
- }
760
- };
761
- await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), {
762
- expirationTtl: accessTokenTTL
763
- });
764
- const tokenResponse = {
765
- access_token: newAccessToken,
766
- token_type: "bearer",
767
- expires_in: accessTokenTTL,
768
- refresh_token: newRefreshToken,
769
- scope: grantData.scope.join(" ")
770
- };
771
- if (audience) {
772
- tokenResponse.resource = audience;
773
- }
774
- return new Response(JSON.stringify(tokenResponse), {
775
- headers: { "Content-Type": "application/json" }
776
- });
777
- }
778
- /**
779
- * Handles OAuth 2.0 token revocation requests (RFC 7009)
780
- * @param body - The parsed request body containing revocation parameters
781
- * @param env - Cloudflare Worker environment variables
782
- * @returns Response confirming revocation or error
783
- */
784
- async handleRevocationRequest(body, env) {
785
- return this.revokeToken(body, env);
786
- }
787
- /**
788
- * - Access tokens: Revokes only the specific token
789
- * - Refresh tokens: Revokes the entire grant (access + refresh tokens)
790
- * @param body - The parsed request body containing token parameter
791
- * @param env - Cloudflare Worker environment variables
792
- * @returns Response confirming revocation or error
793
- */
794
- async revokeToken(body, env) {
795
- const token = body.token;
796
- if (!token) {
797
- return this.createErrorResponse("invalid_request", "Token parameter is required");
798
- }
799
- const tokenParts = token.split(":");
800
- if (tokenParts.length !== 3) {
801
- return new Response("", { status: 200 });
802
- }
803
- const [userId, grantId, _] = tokenParts;
804
- const tokenId = await generateTokenId(token);
805
- const isAccessToken = await this.validateAccessToken(tokenId, userId, grantId, env);
806
- const isRefreshToken = await this.validateRefreshToken(tokenId, userId, grantId, env);
807
- if (isAccessToken) {
808
- await this.revokeSpecificAccessToken(tokenId, userId, grantId, env);
809
- } else if (isRefreshToken) {
810
- await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
811
- }
812
- return new Response("", { status: 200 });
813
- }
814
- /**
815
- * Revokes a specific access token without affecting the refresh token
816
- * @param tokenId - The hashed token ID
817
- * @param userId - The user ID extracted from the token
818
- * @param grantId - The grant ID extracted from the token
819
- * @param env - Cloudflare Worker environment variables
820
- */
821
- async revokeSpecificAccessToken(tokenId, userId, grantId, env) {
822
- const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
823
- await env.OAUTH_KV.delete(tokenKey);
824
- }
825
- /**
826
- * Validates if a token is a valid access token
827
- * @param tokenId - The hashed token ID
828
- * @param userId - The user ID extracted from the token
829
- * @param grantId - The grant ID extracted from the token
830
- * @param env - Cloudflare Worker environment variables
831
- * @returns Promise<boolean> indicating if the token is valid
832
- */
833
- async validateAccessToken(tokenId, userId, grantId, env) {
834
- const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
835
- const tokenData = await env.OAUTH_KV.get(tokenKey, { type: "json" });
836
- if (!tokenData) {
837
- return false;
838
- }
839
- const now = Math.floor(Date.now() / 1e3);
840
- return tokenData.expiresAt >= now;
841
- }
842
- /**
843
- * Validates if a token is a valid refresh token
844
- * @param tokenId - The hashed token ID
845
- * @param userId - The user ID extracted from the token
846
- * @param grantId - The grant ID extracted from the token
847
- * @param env - Cloudflare Worker environment variables
848
- * @returns Promise<boolean> indicating if the token is valid
849
- */
850
- async validateRefreshToken(tokenId, userId, grantId, env) {
851
- const grantKey = `grant:${userId}:${grantId}`;
852
- const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
853
- if (!grantData) {
854
- return false;
855
- }
856
- return grantData.refreshTokenId === tokenId || grantData.previousRefreshTokenId === tokenId;
857
- }
858
- /**
859
- * Handles the dynamic client registration endpoint (RFC 7591)
860
- * @param request - The HTTP request
861
- * @param env - Cloudflare Worker environment variables
862
- * @returns Response with client registration data or error
863
- */
864
- async handleClientRegistration(request, env) {
865
- if (!this.options.clientRegistrationEndpoint) {
866
- return this.createErrorResponse("not_implemented", "Client registration is not enabled", 501);
867
- }
868
- if (request.method !== "POST") {
869
- return this.createErrorResponse("invalid_request", "Method not allowed", 405);
870
- }
871
- const contentLength = parseInt(request.headers.get("Content-Length") || "0", 10);
872
- if (contentLength > 1048576) {
873
- return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
874
- }
875
- let clientMetadata;
876
- try {
877
- const text = await request.text();
878
- if (text.length > 1048576) {
879
- return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
880
- }
881
- clientMetadata = JSON.parse(text);
882
- } catch (error) {
883
- return this.createErrorResponse("invalid_request", "Invalid JSON payload", 400);
884
- }
885
- const validateStringField = (field) => {
886
- if (field === void 0) {
887
- return void 0;
888
- }
889
- if (typeof field !== "string") {
890
- throw new Error("Field must be a string");
891
- }
892
- return field;
893
- };
894
- const validateStringArray = (arr) => {
895
- if (arr === void 0) {
896
- return void 0;
897
- }
898
- if (!Array.isArray(arr)) {
899
- throw new Error("Field must be an array");
900
- }
901
- for (const item of arr) {
902
- if (typeof item !== "string") {
903
- throw new Error("All array elements must be strings");
904
- }
905
- }
906
- return arr;
907
- };
908
- const authMethod = validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
909
- const isPublicClient = authMethod === "none";
910
- if (isPublicClient && this.options.disallowPublicClientRegistration) {
911
- return this.createErrorResponse("invalid_client_metadata", "Public client registration is not allowed");
912
- }
913
- const clientId = generateRandomString(16);
914
- let clientSecret;
915
- let hashedSecret;
916
- if (!isPublicClient) {
917
- clientSecret = generateRandomString(32);
918
- hashedSecret = await hashSecret(clientSecret);
919
- }
920
- let clientInfo;
921
- try {
922
- const redirectUris = validateStringArray(clientMetadata.redirect_uris);
923
- if (!redirectUris || redirectUris.length === 0) {
924
- throw new Error("At least one redirect URI is required");
925
- }
926
- for (const uri of redirectUris) {
927
- validateRedirectUriScheme(uri);
928
- }
929
- clientInfo = {
930
- clientId,
931
- redirectUris,
932
- clientName: validateStringField(clientMetadata.client_name),
933
- logoUri: validateStringField(clientMetadata.logo_uri),
934
- clientUri: validateStringField(clientMetadata.client_uri),
935
- policyUri: validateStringField(clientMetadata.policy_uri),
936
- tosUri: validateStringField(clientMetadata.tos_uri),
937
- jwksUri: validateStringField(clientMetadata.jwks_uri),
938
- contacts: validateStringArray(clientMetadata.contacts),
939
- grantTypes: validateStringArray(clientMetadata.grant_types) || ["authorization_code", "refresh_token"],
940
- responseTypes: validateStringArray(clientMetadata.response_types) || ["code"],
941
- registrationDate: Math.floor(Date.now() / 1e3),
942
- tokenEndpointAuthMethod: authMethod
943
- };
944
- if (!isPublicClient && hashedSecret) {
945
- clientInfo.clientSecret = hashedSecret;
946
- }
947
- } catch (error) {
948
- return this.createErrorResponse(
949
- "invalid_client_metadata",
950
- error instanceof Error ? error.message : "Invalid client metadata"
951
- );
952
- }
953
- await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo));
954
- const response = {
955
- client_id: clientInfo.clientId,
956
- redirect_uris: clientInfo.redirectUris,
957
- client_name: clientInfo.clientName,
958
- logo_uri: clientInfo.logoUri,
959
- client_uri: clientInfo.clientUri,
960
- policy_uri: clientInfo.policyUri,
961
- tos_uri: clientInfo.tosUri,
962
- jwks_uri: clientInfo.jwksUri,
963
- contacts: clientInfo.contacts,
964
- grant_types: clientInfo.grantTypes,
965
- response_types: clientInfo.responseTypes,
966
- token_endpoint_auth_method: clientInfo.tokenEndpointAuthMethod,
967
- registration_client_uri: `${this.options.clientRegistrationEndpoint}/${clientId}`,
968
- client_id_issued_at: clientInfo.registrationDate
969
- };
970
- if (clientSecret) {
971
- response.client_secret = clientSecret;
972
- }
973
- return new Response(JSON.stringify(response), {
974
- status: 201,
975
- headers: { "Content-Type": "application/json" }
976
- });
977
- }
978
- /**
979
- * Handles API requests by validating the access token and calling the API handler
980
- * @param request - The HTTP request
981
- * @param env - Cloudflare Worker environment variables
982
- * @param ctx - Cloudflare Worker execution context
983
- * @returns Response from the API handler or error
984
- */
985
- async handleApiRequest(request, env, ctx) {
986
- const authHeader = request.headers.get("Authorization");
987
- if (!authHeader || !authHeader.startsWith("Bearer ")) {
988
- return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, {
989
- "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"'
990
- });
991
- }
992
- const accessToken = authHeader.substring(7);
993
- const parts = accessToken.split(":");
994
- const isPossiblyInternalFormat = parts.length === 3;
995
- let tokenData = null;
996
- let userId = "";
997
- let grantId = "";
998
- if (isPossiblyInternalFormat) {
999
- [userId, grantId] = parts;
1000
- const id = await generateTokenId(accessToken);
1001
- tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
1002
- }
1003
- if (!tokenData && !this.options.resolveExternalToken) {
1004
- return this.createErrorResponse("invalid_token", "Invalid access token", 401, {
1005
- "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
1006
- });
1007
- }
1008
- if (tokenData) {
1009
- const now = Math.floor(Date.now() / 1e3);
1010
- if (tokenData.expiresAt < now) {
1011
- return this.createErrorResponse("invalid_token", "Access token expired", 401, {
1012
- "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
1013
- });
1014
- }
1015
- if (tokenData.audience) {
1016
- const requestUrl = new URL(request.url);
1017
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`;
1018
- const audiences = Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience];
1019
- const matches = audiences.some((aud) => audienceMatches(resourceServer, aud));
1020
- if (!matches) {
1021
- return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, {
1022
- "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token", error_description="Invalid audience"'
1023
- });
1024
- }
1025
- }
1026
- const encryptionKey = await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey);
1027
- const decryptedProps = await decryptProps(encryptionKey, tokenData.grant.encryptedProps);
1028
- ctx.props = decryptedProps;
1029
- } else if (this.options.resolveExternalToken) {
1030
- const ext = await this.options.resolveExternalToken({ token: accessToken, request, env });
1031
- if (!ext) {
1032
- return this.createErrorResponse("invalid_token", "Invalid access token", 401, {
1033
- "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
1034
- });
1035
- }
1036
- if (ext.audience) {
1037
- const requestUrl = new URL(request.url);
1038
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`;
1039
- const audiences = Array.isArray(ext.audience) ? ext.audience : [ext.audience];
1040
- const matches = audiences.some((aud) => audienceMatches(resourceServer, aud));
1041
- if (!matches) {
1042
- return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, {
1043
- "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token", error_description="Invalid audience"'
1044
- });
1045
- }
1046
- }
1047
- ctx.props = ext.props;
1048
- }
1049
- if (!env.OAUTH_PROVIDER) {
1050
- env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
1051
- }
1052
- const url = new URL(request.url);
1053
- const apiHandler = this.findApiHandlerForUrl(url);
1054
- if (!apiHandler) {
1055
- return this.createErrorResponse("invalid_request", "No handler found for API route", 404);
1056
- }
1057
- if (apiHandler.type === 0 /* EXPORTED_HANDLER */) {
1058
- return apiHandler.handler.fetch(request, env, ctx);
1059
- } else {
1060
- const handler = new apiHandler.handler(ctx, env);
1061
- return handler.fetch(request);
1062
- }
1063
- }
1064
- /**
1065
- * Creates the helper methods object for OAuth operations
1066
- * This is passed to the handler functions to allow them to interact with the OAuth system
1067
- * @param env - Cloudflare Worker environment variables
1068
- * @returns An instance of OAuthHelpers
1069
- */
1070
- createOAuthHelpers(env) {
1071
- return new OAuthHelpersImpl(env, this);
1072
- }
1073
- /**
1074
- * Saves a grant to KV with appropriate TTL based on expiration
1075
- * @param env - The environment bindings
1076
- * @param grantKey - The KV key for the grant
1077
- * @param grantData - The grant data to save
1078
- * @param now - Current timestamp in seconds
1079
- */
1080
- async saveGrantWithTTL(env, grantKey, grantData, now) {
1081
- const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
1082
- await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
1083
- }
1084
- /**
1085
- * Fetches client information from KV storage
1086
- * This method is not private because `OAuthHelpers` needs to call it. Note that since
1087
- * `OAuthProviderImpl` is not exposed outside this module, this is still effectively
1088
- * module-private.
1089
- * @param env - Cloudflare Worker environment variables
1090
- * @param clientId - The client ID to look up
1091
- * @returns The client information, or null if not found
1092
- */
1093
- getClient(env, clientId) {
1094
- const clientKey = `client:${clientId}`;
1095
- return env.OAUTH_KV.get(clientKey, { type: "json" });
1096
- }
1097
- /**
1098
- * Helper function to create OAuth error responses
1099
- * @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
1100
- * @param description - Human-readable error description
1101
- * @param status - HTTP status code (default: 400)
1102
- * @param headers - Additional headers to include
1103
- * @returns A Response object with the error
1104
- */
1105
- createErrorResponse(code, description, status = 400, headers = {}) {
1106
- const customErrorResponse = this.options.onError?.({ code, description, status, headers });
1107
- if (customErrorResponse) return customErrorResponse;
1108
- const body = JSON.stringify({
1109
- error: code,
1110
- error_description: description
1111
- });
1112
- return new Response(body, {
1113
- status,
1114
- headers: {
1115
- "Content-Type": "application/json",
1116
- ...headers
1117
- }
1118
- });
1119
- }
48
+ /**
49
+ * Gets OAuthHelpers for the given environment
50
+ * @param options - Configuration options for the OAuth provider
51
+ * @param env - Cloudflare Worker environment variables
52
+ * @returns An instance of OAuthHelpers
53
+ */
54
+ function getOAuthApi(options, env) {
55
+ return new OAuthProviderImpl(options).createOAuthHelpers(env);
56
+ }
57
+ /**
58
+ * Implementation class backing OAuthProvider.
59
+ *
60
+ * We use a PImpl pattern in `OAuthProvider` to make sure we don't inadvertently export any private
61
+ * methods over RPC. Unfortunately, declaring a method "private" in TypeScript is merely a type
62
+ * annotation, and does not actually prevent the method from being called from outside the class,
63
+ * including over RPC.
64
+ */
65
+ var OAuthProviderImpl = class OAuthProviderImpl {
66
+ /**
67
+ * Creates a new OAuth provider instance
68
+ * @param options - Configuration options for the provider
69
+ */
70
+ constructor(options) {
71
+ this.typedApiHandlers = [];
72
+ const hasSingleHandlerConfig = !!(options.apiRoute && options.apiHandler);
73
+ const hasMultiHandlerConfig = !!options.apiHandlers;
74
+ if (hasSingleHandlerConfig && hasMultiHandlerConfig) throw new TypeError("Cannot use both apiRoute/apiHandler and apiHandlers. Use either apiRoute + apiHandler OR apiHandlers, not both.");
75
+ if (!hasSingleHandlerConfig && !hasMultiHandlerConfig) throw new TypeError("Must provide either apiRoute + apiHandler OR apiHandlers. No API route configuration provided.");
76
+ this.typedDefaultHandler = this.validateHandler(options.defaultHandler, "defaultHandler");
77
+ if (hasSingleHandlerConfig) {
78
+ const apiHandler = this.validateHandler(options.apiHandler, "apiHandler");
79
+ if (Array.isArray(options.apiRoute)) options.apiRoute.forEach((route, index) => {
80
+ this.validateEndpoint(route, `apiRoute[${index}]`);
81
+ this.typedApiHandlers.push([route, apiHandler]);
82
+ });
83
+ else {
84
+ this.validateEndpoint(options.apiRoute, "apiRoute");
85
+ this.typedApiHandlers.push([options.apiRoute, apiHandler]);
86
+ }
87
+ } else for (const [route, handler] of Object.entries(options.apiHandlers)) {
88
+ this.validateEndpoint(route, `apiHandlers key: ${route}`);
89
+ this.typedApiHandlers.push([route, this.validateHandler(handler, `apiHandlers[${route}]`)]);
90
+ }
91
+ this.validateEndpoint(options.authorizeEndpoint, "authorizeEndpoint");
92
+ this.validateEndpoint(options.tokenEndpoint, "tokenEndpoint");
93
+ if (options.clientRegistrationEndpoint) this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
94
+ this.options = {
95
+ accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
96
+ onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
97
+ ...options
98
+ };
99
+ }
100
+ /**
101
+ * Validates that an endpoint is either an absolute path or a full URL
102
+ * @param endpoint - The endpoint to validate
103
+ * @param name - The name of the endpoint property for error messages
104
+ * @throws TypeError if the endpoint is invalid
105
+ */
106
+ validateEndpoint(endpoint, name) {
107
+ if (this.isPath(endpoint)) {
108
+ if (!endpoint.startsWith("/")) throw new TypeError(`${name} path must be an absolute path starting with /`);
109
+ } else try {
110
+ new URL(endpoint);
111
+ } catch (e) {
112
+ throw new TypeError(`${name} must be either an absolute path starting with / or a valid URL`);
113
+ }
114
+ }
115
+ /**
116
+ * Validates that a handler is either an ExportedHandler or a class extending WorkerEntrypoint
117
+ * @param handler - The handler to validate
118
+ * @param name - The name of the handler property for error messages
119
+ * @returns The type of the handler (EXPORTED_HANDLER or WORKER_ENTRYPOINT)
120
+ * @throws TypeError if the handler is invalid
121
+ */
122
+ validateHandler(handler, name) {
123
+ if (typeof handler === "object" && handler !== null && typeof handler.fetch === "function") return {
124
+ type: HandlerType.EXPORTED_HANDLER,
125
+ handler
126
+ };
127
+ if (typeof handler === "function" && handler.prototype instanceof WorkerEntrypoint) return {
128
+ type: HandlerType.WORKER_ENTRYPOINT,
129
+ handler
130
+ };
131
+ throw new TypeError(`${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`);
132
+ }
133
+ /**
134
+ * Main fetch handler for the Worker
135
+ * Routes requests to the appropriate handler based on the URL
136
+ * @param request - The HTTP request
137
+ * @param env - Cloudflare Worker environment variables
138
+ * @param ctx - Cloudflare Worker execution context
139
+ * @returns A Promise resolving to an HTTP Response
140
+ */
141
+ async fetch(request, env, ctx) {
142
+ const url = new URL(request.url);
143
+ if (request.method === "OPTIONS") {
144
+ if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
145
+ status: 204,
146
+ headers: { "Content-Length": "0" }
147
+ }), request);
148
+ }
149
+ if (url.pathname === "/.well-known/oauth-authorization-server") {
150
+ const response = await this.handleMetadataDiscovery(url);
151
+ return this.addCorsHeaders(response, request);
152
+ }
153
+ if (this.isTokenEndpoint(url)) {
154
+ const parsed = await this.parseTokenEndpointRequest(request, env);
155
+ if (parsed instanceof Response) return this.addCorsHeaders(parsed, request);
156
+ let response;
157
+ if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, env);
158
+ else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env);
159
+ return this.addCorsHeaders(response, request);
160
+ }
161
+ if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
162
+ const response = await this.handleClientRegistration(request, env);
163
+ return this.addCorsHeaders(response, request);
164
+ }
165
+ if (this.isApiRequest(url)) {
166
+ const response = await this.handleApiRequest(request, env, ctx);
167
+ return this.addCorsHeaders(response, request);
168
+ }
169
+ if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
170
+ if (this.typedDefaultHandler.type === HandlerType.EXPORTED_HANDLER) return this.typedDefaultHandler.handler.fetch(request, env, ctx);
171
+ else return new this.typedDefaultHandler.handler(ctx, env).fetch(request);
172
+ }
173
+ /**
174
+ * Decodes a token and returns token data with decrypted props
175
+ * @param token - The granted token
176
+ * @param env - Cloudflare Worker environment variables
177
+ * @returns Promise resolving to token data with decrypted props, or null if token is invalid
178
+ */
179
+ async unwrapToken(token, env) {
180
+ const parts = token.split(":");
181
+ if (!(parts.length === 3)) return null;
182
+ const [userId, grantId] = parts;
183
+ const id = await generateTokenId(token);
184
+ const tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
185
+ if (!tokenData) return null;
186
+ const now = Math.floor(Date.now() / 1e3);
187
+ if (tokenData.expiresAt < now) return null;
188
+ const decryptedProps = await decryptProps(await unwrapKeyWithToken(token, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
189
+ const { grant } = tokenData;
190
+ return {
191
+ id: tokenData.id,
192
+ grantId: tokenData.grantId,
193
+ userId: tokenData.userId,
194
+ createdAt: tokenData.createdAt,
195
+ expiresAt: tokenData.expiresAt,
196
+ audience: tokenData.audience,
197
+ scope: tokenData.scope || grant.scope,
198
+ grant: {
199
+ clientId: grant.clientId,
200
+ scope: grant.scope,
201
+ props: decryptedProps
202
+ }
203
+ };
204
+ }
205
+ /**
206
+ * Determines if an endpoint configuration is a path or a full URL
207
+ * @param endpoint - The endpoint configuration
208
+ * @returns True if the endpoint is a path (starts with /), false if it's a full URL
209
+ */
210
+ isPath(endpoint) {
211
+ return endpoint.startsWith("/");
212
+ }
213
+ /**
214
+ * Matches a URL against an endpoint pattern that can be a full URL or just a path
215
+ * @param url - The URL to check
216
+ * @param endpoint - The endpoint pattern (full URL or path)
217
+ * @returns True if the URL matches the endpoint pattern
218
+ */
219
+ matchEndpoint(url, endpoint) {
220
+ if (this.isPath(endpoint)) return url.pathname === endpoint;
221
+ else {
222
+ const endpointUrl = new URL(endpoint);
223
+ return url.hostname === endpointUrl.hostname && url.pathname === endpointUrl.pathname;
224
+ }
225
+ }
226
+ /**
227
+ * Checks if a URL matches the configured token endpoint
228
+ * @param url - The URL to check
229
+ * @returns True if the URL matches the token endpoint
230
+ */
231
+ isTokenEndpoint(url) {
232
+ return this.matchEndpoint(url, this.options.tokenEndpoint);
233
+ }
234
+ /**
235
+ * Checks if a URL matches the configured client registration endpoint
236
+ * @param url - The URL to check
237
+ * @returns True if the URL matches the client registration endpoint
238
+ */
239
+ isClientRegistrationEndpoint(url) {
240
+ if (!this.options.clientRegistrationEndpoint) return false;
241
+ return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
242
+ }
243
+ /**
244
+ * Parses and validates a token endpoint request (used for both token exchange and revocation)
245
+ * @param request - The HTTP request to parse
246
+ * @returns Promise with parsed body and client info, or error response
247
+ */
248
+ async parseTokenEndpointRequest(request, env) {
249
+ if (request.method !== "POST") return this.createErrorResponse("invalid_request", "Method not allowed", 405);
250
+ let contentType = request.headers.get("Content-Type") || "";
251
+ let body = {};
252
+ if (!contentType.includes("application/x-www-form-urlencoded")) return this.createErrorResponse("invalid_request", "Content-Type must be application/x-www-form-urlencoded", 400);
253
+ const formData = await request.formData();
254
+ for (const [key, value] of formData.entries()) {
255
+ const allValues = formData.getAll(key);
256
+ body[key] = allValues.length > 1 ? allValues : value;
257
+ }
258
+ const authHeader = request.headers.get("Authorization");
259
+ let clientId = "";
260
+ let clientSecret = "";
261
+ if (authHeader && authHeader.startsWith("Basic ")) {
262
+ const [id, secret] = atob(authHeader.substring(6)).split(":", 2);
263
+ clientId = decodeURIComponent(id);
264
+ clientSecret = decodeURIComponent(secret || "");
265
+ } else {
266
+ clientId = body.client_id;
267
+ clientSecret = body.client_secret || "";
268
+ }
269
+ if (!clientId) return this.createErrorResponse("invalid_client", "Client ID is required", 401);
270
+ const clientInfo = await this.getClient(env, clientId);
271
+ if (!clientInfo) return this.createErrorResponse("invalid_client", "Client not found", 401);
272
+ if (!(clientInfo.tokenEndpointAuthMethod === "none")) {
273
+ if (!clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: missing client_secret", 401);
274
+ if (!clientInfo.clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: client has no registered secret", 401);
275
+ if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: invalid client_secret", 401);
276
+ }
277
+ return {
278
+ body,
279
+ clientInfo,
280
+ isRevocationRequest: !body.grant_type && !!body.token
281
+ };
282
+ }
283
+ /**
284
+ * Checks if a URL matches a specific API route
285
+ * @param url - The URL to check
286
+ * @param route - The API route to check against
287
+ * @returns True if the URL matches the API route
288
+ */
289
+ matchApiRoute(url, route) {
290
+ if (this.isPath(route)) return url.pathname.startsWith(route);
291
+ else {
292
+ const apiUrl = new URL(route);
293
+ return url.hostname === apiUrl.hostname && url.pathname.startsWith(apiUrl.pathname);
294
+ }
295
+ }
296
+ /**
297
+ * Checks if a URL is an API request based on the configured API route(s)
298
+ * @param url - The URL to check
299
+ * @returns True if the URL matches any of the API routes
300
+ */
301
+ isApiRequest(url) {
302
+ for (const [route, _] of this.typedApiHandlers) if (this.matchApiRoute(url, route)) return true;
303
+ return false;
304
+ }
305
+ /**
306
+ * Finds the appropriate API handler for a URL
307
+ * @param url - The URL to find a handler for
308
+ * @returns The TypedHandler for the URL, or undefined if no handler matches
309
+ */
310
+ findApiHandlerForUrl(url) {
311
+ for (const [route, handler] of this.typedApiHandlers) if (this.matchApiRoute(url, route)) return handler;
312
+ }
313
+ /**
314
+ * Gets the full URL for an endpoint, using the provided request URL's
315
+ * origin for endpoints specified as just paths
316
+ * @param endpoint - The endpoint configuration (path or full URL)
317
+ * @param requestUrl - The URL of the incoming request
318
+ * @returns The full URL for the endpoint
319
+ */
320
+ getFullEndpointUrl(endpoint, requestUrl) {
321
+ if (this.isPath(endpoint)) return `${requestUrl.origin}${endpoint}`;
322
+ else return endpoint;
323
+ }
324
+ /**
325
+ * Adds CORS headers to a response
326
+ * @param response - The response to add CORS headers to
327
+ * @param request - The original request
328
+ * @returns A new Response with CORS headers added
329
+ */
330
+ addCorsHeaders(response, request) {
331
+ const origin = request.headers.get("Origin");
332
+ if (!origin) return response;
333
+ const newResponse = new Response(response.body, response);
334
+ newResponse.headers.set("Access-Control-Allow-Origin", origin);
335
+ newResponse.headers.set("Access-Control-Allow-Methods", "*");
336
+ newResponse.headers.set("Access-Control-Allow-Headers", "Authorization, *");
337
+ newResponse.headers.set("Access-Control-Max-Age", "86400");
338
+ return newResponse;
339
+ }
340
+ /**
341
+ * Handles the OAuth metadata discovery endpoint
342
+ * Implements RFC 8414 for OAuth Server Metadata
343
+ * @param requestUrl - The URL of the incoming request
344
+ * @returns Response with OAuth server metadata
345
+ */
346
+ async handleMetadataDiscovery(requestUrl) {
347
+ const tokenEndpoint = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
348
+ const authorizeEndpoint = this.getFullEndpointUrl(this.options.authorizeEndpoint, requestUrl);
349
+ let registrationEndpoint = void 0;
350
+ if (this.options.clientRegistrationEndpoint) registrationEndpoint = this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, requestUrl);
351
+ const responseTypesSupported = ["code"];
352
+ if (this.options.allowImplicitFlow) responseTypesSupported.push("token");
353
+ const grantTypesSupported = [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN];
354
+ if (this.options.allowTokenExchangeGrant) grantTypesSupported.push(GrantType.TOKEN_EXCHANGE);
355
+ const metadata = {
356
+ issuer: new URL(tokenEndpoint).origin,
357
+ authorization_endpoint: authorizeEndpoint,
358
+ token_endpoint: tokenEndpoint,
359
+ registration_endpoint: registrationEndpoint,
360
+ scopes_supported: this.options.scopesSupported,
361
+ response_types_supported: responseTypesSupported,
362
+ response_modes_supported: ["query"],
363
+ grant_types_supported: grantTypesSupported,
364
+ token_endpoint_auth_methods_supported: [
365
+ "client_secret_basic",
366
+ "client_secret_post",
367
+ "none"
368
+ ],
369
+ revocation_endpoint: tokenEndpoint,
370
+ code_challenge_methods_supported: ["plain", "S256"],
371
+ client_id_metadata_document_supported: this.hasGlobalFetchStrictlyPublic()
372
+ };
373
+ return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
374
+ }
375
+ /**
376
+ * Handles client authentication and token issuance via the token endpoint
377
+ * Supports authorization_code and refresh_token grant types
378
+ * @param body - The parsed request body
379
+ * @param clientInfo - The authenticated client information
380
+ * @param env - Cloudflare Worker environment variables
381
+ * @returns Response with token data or error
382
+ */
383
+ async handleTokenRequest(body, clientInfo, env) {
384
+ const grantType = body.grant_type;
385
+ if (grantType === GrantType.AUTHORIZATION_CODE) return this.handleAuthorizationCodeGrant(body, clientInfo, env);
386
+ else if (grantType === GrantType.REFRESH_TOKEN) return this.handleRefreshTokenGrant(body, clientInfo, env);
387
+ else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return this.handleTokenExchangeGrant(body, clientInfo, env);
388
+ else return this.createErrorResponse("unsupported_grant_type", "Grant type not supported");
389
+ }
390
+ /**
391
+ * Handles the authorization code grant type
392
+ * Exchanges an authorization code for access and refresh tokens
393
+ * @param body - The parsed request body
394
+ * @param clientInfo - The authenticated client information
395
+ * @param env - Cloudflare Worker environment variables
396
+ * @returns Response with token data or error
397
+ */
398
+ async handleAuthorizationCodeGrant(body, clientInfo, env) {
399
+ const code = body.code;
400
+ const redirectUri = body.redirect_uri;
401
+ const codeVerifier = body.code_verifier;
402
+ if (!code) return this.createErrorResponse("invalid_request", "Authorization code is required");
403
+ const codeParts = code.split(":");
404
+ if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", "Invalid authorization code format");
405
+ const [userId, grantId, _] = codeParts;
406
+ const grantKey = `grant:${userId}:${grantId}`;
407
+ const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
408
+ if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
409
+ if (!grantData.authCodeId) return this.createErrorResponse("invalid_grant", "Authorization code already used");
410
+ if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", "Invalid authorization code");
411
+ if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
412
+ const isPkceEnabled = !!grantData.codeChallenge;
413
+ if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
414
+ if (redirectUri && !clientInfo.redirectUris.includes(redirectUri)) return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
415
+ if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier provided for a flow that did not use PKCE");
416
+ if (isPkceEnabled) {
417
+ if (!codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
418
+ let calculatedChallenge;
419
+ if (grantData.codeChallengeMethod === "S256") {
420
+ const data = new TextEncoder().encode(codeVerifier);
421
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
422
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
423
+ calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
424
+ } else calculatedChallenge = codeVerifier;
425
+ if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
426
+ }
427
+ let accessTokenTTL = this.options.accessTokenTTL;
428
+ let refreshTokenTTL = this.options.refreshTokenTTL;
429
+ const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
430
+ let grantEncryptionKey = encryptionKey;
431
+ let accessTokenEncryptionKey = encryptionKey;
432
+ let encryptedAccessTokenProps = grantData.encryptedProps;
433
+ let tokenScopes = this.downscope(body.scope, grantData.scope);
434
+ if (this.options.tokenExchangeCallback) {
435
+ const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
436
+ let grantProps = decryptedProps;
437
+ let accessTokenProps = decryptedProps;
438
+ const callbackOptions = {
439
+ grantType: GrantType.AUTHORIZATION_CODE,
440
+ clientId: clientInfo.clientId,
441
+ userId,
442
+ scope: grantData.scope,
443
+ requestedScope: tokenScopes,
444
+ props: decryptedProps
445
+ };
446
+ const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
447
+ if (callbackResult) {
448
+ if (callbackResult.newProps) {
449
+ grantProps = callbackResult.newProps;
450
+ if (!callbackResult.accessTokenProps) accessTokenProps = callbackResult.newProps;
451
+ }
452
+ if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
453
+ if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
454
+ if ("refreshTokenTTL" in callbackResult) refreshTokenTTL = callbackResult.refreshTokenTTL;
455
+ if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
456
+ }
457
+ const grantResult = await encryptProps(grantProps);
458
+ grantData.encryptedProps = grantResult.encryptedData;
459
+ grantEncryptionKey = grantResult.key;
460
+ if (accessTokenProps !== grantProps) {
461
+ const tokenResult = await encryptProps(accessTokenProps);
462
+ encryptedAccessTokenProps = tokenResult.encryptedData;
463
+ accessTokenEncryptionKey = tokenResult.key;
464
+ } else {
465
+ encryptedAccessTokenProps = grantData.encryptedProps;
466
+ accessTokenEncryptionKey = grantEncryptionKey;
467
+ }
468
+ }
469
+ const now = Math.floor(Date.now() / 1e3);
470
+ const useRefreshToken = refreshTokenTTL !== 0;
471
+ delete grantData.authCodeId;
472
+ delete grantData.codeChallenge;
473
+ delete grantData.codeChallengeMethod;
474
+ delete grantData.authCodeWrappedKey;
475
+ let refreshToken;
476
+ if (useRefreshToken) {
477
+ refreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
478
+ const refreshTokenId = await generateTokenId(refreshToken);
479
+ const refreshTokenWrappedKey = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
480
+ const expiresAt = refreshTokenTTL !== void 0 ? now + refreshTokenTTL : void 0;
481
+ grantData.refreshTokenId = refreshTokenId;
482
+ grantData.refreshTokenWrappedKey = refreshTokenWrappedKey;
483
+ grantData.previousRefreshTokenId = void 0;
484
+ grantData.previousRefreshTokenWrappedKey = void 0;
485
+ grantData.expiresAt = expiresAt;
486
+ }
487
+ await this.saveGrantWithTTL(env, grantKey, grantData, now);
488
+ if (body.resource && grantData.resource) {
489
+ const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
490
+ const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
491
+ for (const requested of requestedResources) if (!grantedResources.includes(requested)) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
492
+ }
493
+ const audience = parseResourceParameter(body.resource || grantData.resource);
494
+ if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
495
+ const tokenResponse = {
496
+ access_token: await this.createAccessToken({
497
+ userId,
498
+ grantId,
499
+ clientId: grantData.clientId,
500
+ scope: tokenScopes,
501
+ encryptedProps: encryptedAccessTokenProps,
502
+ encryptionKey: accessTokenEncryptionKey,
503
+ expiresIn: accessTokenTTL,
504
+ audience,
505
+ env
506
+ }),
507
+ token_type: "bearer",
508
+ expires_in: accessTokenTTL,
509
+ scope: tokenScopes.join(" ")
510
+ };
511
+ if (refreshToken) tokenResponse.refresh_token = refreshToken;
512
+ if (audience) tokenResponse.resource = audience;
513
+ return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
514
+ }
515
+ /**
516
+ * Handles the refresh token grant type
517
+ * Issues a new access token using a refresh token
518
+ * @param body - The parsed request body
519
+ * @param clientInfo - The authenticated client information
520
+ * @param env - Cloudflare Worker environment variables
521
+ * @returns Response with token data or error
522
+ */
523
+ async handleRefreshTokenGrant(body, clientInfo, env) {
524
+ const refreshToken = body.refresh_token;
525
+ if (!refreshToken) return this.createErrorResponse("invalid_request", "Refresh token is required");
526
+ const tokenParts = refreshToken.split(":");
527
+ if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", "Invalid token format");
528
+ const [userId, grantId, _] = tokenParts;
529
+ const providedTokenHash = await generateTokenId(refreshToken);
530
+ const grantKey = `grant:${userId}:${grantId}`;
531
+ const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
532
+ if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found");
533
+ const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
534
+ const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
535
+ if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", "Invalid refresh token");
536
+ if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
537
+ if (grantData.expiresAt !== void 0) {
538
+ if (Math.floor(Date.now() / 1e3) >= grantData.expiresAt) return this.createErrorResponse("invalid_grant", "Refresh token has expired");
539
+ }
540
+ const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
541
+ const accessTokenId = await generateTokenId(newAccessToken);
542
+ let accessTokenTTL = this.options.accessTokenTTL;
543
+ let wrappedKeyToUse;
544
+ if (isCurrentToken) wrappedKeyToUse = grantData.refreshTokenWrappedKey;
545
+ else wrappedKeyToUse = grantData.previousRefreshTokenWrappedKey;
546
+ const encryptionKey = await unwrapKeyWithToken(refreshToken, wrappedKeyToUse);
547
+ let grantEncryptionKey = encryptionKey;
548
+ let accessTokenEncryptionKey = encryptionKey;
549
+ let encryptedAccessTokenProps = grantData.encryptedProps;
550
+ let tokenScopes = this.downscope(body.scope, grantData.scope);
551
+ let grantPropsChanged = false;
552
+ if (this.options.tokenExchangeCallback) {
553
+ const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
554
+ let grantProps = decryptedProps;
555
+ let accessTokenProps = decryptedProps;
556
+ const callbackOptions = {
557
+ grantType: GrantType.REFRESH_TOKEN,
558
+ clientId: clientInfo.clientId,
559
+ userId,
560
+ scope: grantData.scope,
561
+ requestedScope: tokenScopes,
562
+ props: decryptedProps
563
+ };
564
+ const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
565
+ if (callbackResult) {
566
+ if (callbackResult.newProps) {
567
+ grantProps = callbackResult.newProps;
568
+ grantPropsChanged = true;
569
+ if (!callbackResult.accessTokenProps) accessTokenProps = callbackResult.newProps;
570
+ }
571
+ if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
572
+ if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
573
+ if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", "refreshTokenTTL cannot be changed during refresh token exchange");
574
+ if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
575
+ }
576
+ if (grantPropsChanged) {
577
+ const grantResult = await encryptProps(grantProps);
578
+ grantData.encryptedProps = grantResult.encryptedData;
579
+ if (grantResult.key !== encryptionKey) {
580
+ grantEncryptionKey = grantResult.key;
581
+ wrappedKeyToUse = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
582
+ } else grantEncryptionKey = grantResult.key;
583
+ }
584
+ if (accessTokenProps !== grantProps) {
585
+ const tokenResult = await encryptProps(accessTokenProps);
586
+ encryptedAccessTokenProps = tokenResult.encryptedData;
587
+ accessTokenEncryptionKey = tokenResult.key;
588
+ } else {
589
+ encryptedAccessTokenProps = grantData.encryptedProps;
590
+ accessTokenEncryptionKey = grantEncryptionKey;
591
+ }
592
+ }
593
+ const now = Math.floor(Date.now() / 1e3);
594
+ if (grantData.expiresAt !== void 0) {
595
+ const remainingRefreshTokenLifetime = grantData.expiresAt - now;
596
+ if (remainingRefreshTokenLifetime > 0) accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
597
+ }
598
+ const accessTokenExpiresAt = now + accessTokenTTL;
599
+ const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
600
+ const newRefreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
601
+ const newRefreshTokenId = await generateTokenId(newRefreshToken);
602
+ const newRefreshTokenWrappedKey = await wrapKeyWithToken(newRefreshToken, grantEncryptionKey);
603
+ grantData.previousRefreshTokenId = providedTokenHash;
604
+ grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
605
+ grantData.refreshTokenId = newRefreshTokenId;
606
+ grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
607
+ await this.saveGrantWithTTL(env, grantKey, grantData, now);
608
+ if (body.resource && grantData.resource) {
609
+ const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
610
+ const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
611
+ for (const requested of requestedResources) if (!grantedResources.includes(requested)) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
612
+ }
613
+ const audience = parseResourceParameter(body.resource || grantData.resource);
614
+ if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
615
+ const accessTokenData = {
616
+ id: accessTokenId,
617
+ grantId,
618
+ userId,
619
+ createdAt: now,
620
+ expiresAt: accessTokenExpiresAt,
621
+ audience,
622
+ scope: tokenScopes,
623
+ wrappedEncryptionKey: accessTokenWrappedKey,
624
+ grant: {
625
+ clientId: grantData.clientId,
626
+ scope: grantData.scope,
627
+ encryptedProps: encryptedAccessTokenProps
628
+ }
629
+ };
630
+ await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
631
+ const tokenResponse = {
632
+ access_token: newAccessToken,
633
+ token_type: "bearer",
634
+ expires_in: accessTokenTTL,
635
+ refresh_token: newRefreshToken,
636
+ scope: tokenScopes.join(" ")
637
+ };
638
+ if (audience) tokenResponse.resource = audience;
639
+ return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
640
+ }
641
+ /**
642
+ * Core token exchange logic (RFC 8693)
643
+ * Performs the actual token exchange operation
644
+ * This method is not private because `OAuthHelpers` needs to call it. Note that since
645
+ * `OAuthProviderImpl` is not exposed outside this module, this is still effectively
646
+ * module-private.
647
+ * @param subjectToken - The subject token to exchange
648
+ * @param requestedScopes - Optional narrowed scopes (must be subset of original)
649
+ * @param requestedResource - Optional resource/audience (must be subset of original if original had resource)
650
+ * @param expiresIn - Optional TTL override in seconds
651
+ * @param clientInfo - The client making the exchange request
652
+ * @param env - Cloudflare Worker environment variables
653
+ * @returns Promise resolving to token response
654
+ * @throws OAuthError with OAuth error code and description
655
+ */
656
+ async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
657
+ const tokenSummary = await this.unwrapToken(subjectToken, env);
658
+ if (!tokenSummary) throw new OAuthError("invalid_grant", "Invalid or expired subject token");
659
+ const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
660
+ const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
661
+ if (!grantData) throw new OAuthError("invalid_grant", "Grant not found");
662
+ let tokenScopes = this.downscope(requestedScopes, grantData.scope);
663
+ let newAudience = tokenSummary.audience;
664
+ if (requestedResource) {
665
+ if (grantData.resource) {
666
+ const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
667
+ const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
668
+ for (const requested of requestedResources) if (!grantedResources.includes(requested)) throw new OAuthError("invalid_target", "Requested resource was not included in the authorization request");
669
+ }
670
+ const parsedResource = parseResourceParameter(requestedResource);
671
+ if (!parsedResource) throw new OAuthError("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
672
+ newAudience = parsedResource;
673
+ }
674
+ const now = Math.floor(Date.now() / 1e3);
675
+ const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
676
+ let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
677
+ if (expiresIn !== void 0) {
678
+ if (expiresIn <= 0) throw new OAuthError("invalid_request", "Invalid expires_in parameter");
679
+ accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
680
+ } else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
681
+ const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
682
+ if (!subjectTokenData) throw new OAuthError("invalid_grant", "Subject token data not found");
683
+ const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
684
+ let accessTokenEncryptionKey = encryptionKey;
685
+ let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
686
+ if (this.options.tokenExchangeCallback) {
687
+ const decryptedProps = await decryptProps(encryptionKey, subjectTokenData.grant.encryptedProps);
688
+ const callbackOptions = {
689
+ grantType: GrantType.TOKEN_EXCHANGE,
690
+ clientId: clientInfo.clientId,
691
+ userId: tokenSummary.userId,
692
+ scope: tokenSummary.grant.scope,
693
+ requestedScope: tokenScopes,
694
+ props: decryptedProps
695
+ };
696
+ const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
697
+ if (callbackResult) {
698
+ let accessTokenProps = decryptedProps;
699
+ if (callbackResult.newProps) {
700
+ if (!callbackResult.accessTokenProps) accessTokenProps = callbackResult.newProps;
701
+ }
702
+ if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
703
+ if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = Math.min(callbackResult.accessTokenTTL, subjectTokenRemainingLifetime);
704
+ if (accessTokenProps !== decryptedProps) {
705
+ const tokenResult = await encryptProps(accessTokenProps);
706
+ encryptedAccessTokenProps = tokenResult.encryptedData;
707
+ accessTokenEncryptionKey = tokenResult.key;
708
+ }
709
+ if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
710
+ }
711
+ }
712
+ const tokenResponse = {
713
+ access_token: await this.createAccessToken({
714
+ userId: tokenSummary.userId,
715
+ grantId: tokenSummary.grantId,
716
+ clientId: tokenSummary.grant.clientId,
717
+ scope: tokenScopes,
718
+ encryptedProps: encryptedAccessTokenProps,
719
+ encryptionKey: accessTokenEncryptionKey,
720
+ expiresIn: accessTokenTTL,
721
+ audience: newAudience,
722
+ env
723
+ }),
724
+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
725
+ token_type: "bearer",
726
+ expires_in: accessTokenTTL,
727
+ scope: tokenScopes.join(" ")
728
+ };
729
+ if (newAudience) tokenResponse.resource = newAudience;
730
+ return tokenResponse;
731
+ }
732
+ /**
733
+ * Handles OAuth 2.0 token exchange requests (RFC 8693)
734
+ * Exchanges an existing access token for a new one with modified characteristics
735
+ * @param body - The parsed request body
736
+ * @param clientInfo - The authenticated client information
737
+ * @param env - Cloudflare Worker environment variables
738
+ * @returns Response with new token data or error
739
+ */
740
+ async handleTokenExchangeGrant(body, clientInfo, env) {
741
+ const subjectToken = body.subject_token;
742
+ const subjectTokenType = body.subject_token_type;
743
+ const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
744
+ const requestedScope = body.scope;
745
+ const requestedResource = body.resource;
746
+ if (!subjectToken) return this.createErrorResponse("invalid_request", "subject_token is required");
747
+ if (!subjectTokenType) return this.createErrorResponse("invalid_request", "subject_token_type is required");
748
+ if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token subject_token_type is supported");
749
+ if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token requested_token_type is supported");
750
+ let requestedScopes;
751
+ if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
752
+ else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
753
+ else return this.createErrorResponse("invalid_request", "Invalid scope parameter format");
754
+ let expiresIn;
755
+ if (body.expires_in !== void 0) {
756
+ const requestedTTL = parseInt(body.expires_in, 10);
757
+ if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", "Invalid expires_in parameter");
758
+ expiresIn = requestedTTL;
759
+ }
760
+ try {
761
+ const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
762
+ return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
763
+ } catch (error) {
764
+ if (error instanceof OAuthError) return this.createErrorResponse(error.code, error.message);
765
+ throw error;
766
+ }
767
+ }
768
+ /**
769
+ * Handles OAuth 2.0 token revocation requests (RFC 7009)
770
+ * @param body - The parsed request body containing revocation parameters
771
+ * @param env - Cloudflare Worker environment variables
772
+ * @returns Response confirming revocation or error
773
+ */
774
+ async handleRevocationRequest(body, env) {
775
+ return this.revokeToken(body, env);
776
+ }
777
+ /**
778
+ * - Access tokens: Revokes only the specific token
779
+ * - Refresh tokens: Revokes the entire grant (access + refresh tokens)
780
+ * @param body - The parsed request body containing token parameter
781
+ * @param env - Cloudflare Worker environment variables
782
+ * @returns Response confirming revocation or error
783
+ */
784
+ async revokeToken(body, env) {
785
+ const token = body.token;
786
+ if (!token) return this.createErrorResponse("invalid_request", "Token parameter is required");
787
+ const tokenParts = token.split(":");
788
+ if (tokenParts.length !== 3) return new Response("", { status: 200 });
789
+ const [userId, grantId, _] = tokenParts;
790
+ const tokenId = await generateTokenId(token);
791
+ const isAccessToken = await this.validateAccessToken(tokenId, userId, grantId, env);
792
+ const isRefreshToken = await this.validateRefreshToken(tokenId, userId, grantId, env);
793
+ if (isAccessToken) await this.revokeSpecificAccessToken(tokenId, userId, grantId, env);
794
+ else if (isRefreshToken) await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
795
+ return new Response("", { status: 200 });
796
+ }
797
+ /**
798
+ * Revokes a specific access token without affecting the refresh token
799
+ * @param tokenId - The hashed token ID
800
+ * @param userId - The user ID extracted from the token
801
+ * @param grantId - The grant ID extracted from the token
802
+ * @param env - Cloudflare Worker environment variables
803
+ */
804
+ async revokeSpecificAccessToken(tokenId, userId, grantId, env) {
805
+ const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
806
+ await env.OAUTH_KV.delete(tokenKey);
807
+ }
808
+ /**
809
+ * Validates if a token is a valid access token
810
+ * @param tokenId - The hashed token ID
811
+ * @param userId - The user ID extracted from the token
812
+ * @param grantId - The grant ID extracted from the token
813
+ * @param env - Cloudflare Worker environment variables
814
+ * @returns Promise<boolean> indicating if the token is valid
815
+ */
816
+ async validateAccessToken(tokenId, userId, grantId, env) {
817
+ const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
818
+ const tokenData = await env.OAUTH_KV.get(tokenKey, { type: "json" });
819
+ if (!tokenData) return false;
820
+ const now = Math.floor(Date.now() / 1e3);
821
+ return tokenData.expiresAt >= now;
822
+ }
823
+ /**
824
+ * Validates if a token is a valid refresh token
825
+ * @param tokenId - The hashed token ID
826
+ * @param userId - The user ID extracted from the token
827
+ * @param grantId - The grant ID extracted from the token
828
+ * @param env - Cloudflare Worker environment variables
829
+ * @returns Promise<boolean> indicating if the token is valid
830
+ */
831
+ async validateRefreshToken(tokenId, userId, grantId, env) {
832
+ const grantKey = `grant:${userId}:${grantId}`;
833
+ const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
834
+ if (!grantData) return false;
835
+ return grantData.refreshTokenId === tokenId || grantData.previousRefreshTokenId === tokenId;
836
+ }
837
+ /**
838
+ * Handles the dynamic client registration endpoint (RFC 7591)
839
+ * @param request - The HTTP request
840
+ * @param env - Cloudflare Worker environment variables
841
+ * @returns Response with client registration data or error
842
+ */
843
+ async handleClientRegistration(request, env) {
844
+ if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", "Client registration is not enabled", 501);
845
+ if (request.method !== "POST") return this.createErrorResponse("invalid_request", "Method not allowed", 405);
846
+ if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
847
+ let clientMetadata;
848
+ try {
849
+ const text = await request.text();
850
+ if (text.length > 1048576) return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
851
+ clientMetadata = JSON.parse(text);
852
+ } catch (error) {
853
+ return this.createErrorResponse("invalid_request", "Invalid JSON payload", 400);
854
+ }
855
+ const authMethod = OAuthProviderImpl.validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
856
+ const isPublicClient = authMethod === "none";
857
+ if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", "Public client registration is not allowed");
858
+ const clientId = generateRandomString(16);
859
+ let clientSecret;
860
+ let hashedSecret;
861
+ if (!isPublicClient) {
862
+ clientSecret = generateRandomString(32);
863
+ hashedSecret = await hashSecret(clientSecret);
864
+ }
865
+ let clientInfo;
866
+ try {
867
+ const redirectUris = OAuthProviderImpl.validateStringArray(clientMetadata.redirect_uris);
868
+ if (!redirectUris || redirectUris.length === 0) throw new Error("At least one redirect URI is required");
869
+ for (const uri of redirectUris) validateRedirectUriScheme(uri);
870
+ clientInfo = {
871
+ clientId,
872
+ redirectUris,
873
+ clientName: OAuthProviderImpl.validateStringField(clientMetadata.client_name),
874
+ logoUri: OAuthProviderImpl.validateStringField(clientMetadata.logo_uri),
875
+ clientUri: OAuthProviderImpl.validateStringField(clientMetadata.client_uri),
876
+ policyUri: OAuthProviderImpl.validateStringField(clientMetadata.policy_uri),
877
+ tosUri: OAuthProviderImpl.validateStringField(clientMetadata.tos_uri),
878
+ jwksUri: OAuthProviderImpl.validateStringField(clientMetadata.jwks_uri),
879
+ contacts: OAuthProviderImpl.validateStringArray(clientMetadata.contacts),
880
+ grantTypes: OAuthProviderImpl.validateStringArray(clientMetadata.grant_types) || [
881
+ GrantType.AUTHORIZATION_CODE,
882
+ GrantType.REFRESH_TOKEN,
883
+ ...this.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
884
+ ],
885
+ responseTypes: OAuthProviderImpl.validateStringArray(clientMetadata.response_types) || ["code"],
886
+ registrationDate: Math.floor(Date.now() / 1e3),
887
+ tokenEndpointAuthMethod: authMethod
888
+ };
889
+ if (!isPublicClient && hashedSecret) clientInfo.clientSecret = hashedSecret;
890
+ } catch (error) {
891
+ return this.createErrorResponse("invalid_client_metadata", error instanceof Error ? error.message : "Invalid client metadata");
892
+ }
893
+ await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo));
894
+ const response = {
895
+ client_id: clientInfo.clientId,
896
+ redirect_uris: clientInfo.redirectUris,
897
+ client_name: clientInfo.clientName,
898
+ logo_uri: clientInfo.logoUri,
899
+ client_uri: clientInfo.clientUri,
900
+ policy_uri: clientInfo.policyUri,
901
+ tos_uri: clientInfo.tosUri,
902
+ jwks_uri: clientInfo.jwksUri,
903
+ contacts: clientInfo.contacts,
904
+ grant_types: clientInfo.grantTypes,
905
+ response_types: clientInfo.responseTypes,
906
+ token_endpoint_auth_method: clientInfo.tokenEndpointAuthMethod,
907
+ registration_client_uri: `${this.options.clientRegistrationEndpoint}/${clientId}`,
908
+ client_id_issued_at: clientInfo.registrationDate
909
+ };
910
+ if (clientSecret) response.client_secret = clientSecret;
911
+ return new Response(JSON.stringify(response), {
912
+ status: 201,
913
+ headers: { "Content-Type": "application/json" }
914
+ });
915
+ }
916
+ /**
917
+ * Handles API requests by validating the access token and calling the API handler
918
+ * @param request - The HTTP request
919
+ * @param env - Cloudflare Worker environment variables
920
+ * @param ctx - Cloudflare Worker execution context
921
+ * @returns Response from the API handler or error
922
+ */
923
+ async handleApiRequest(request, env, ctx) {
924
+ const authHeader = request.headers.get("Authorization");
925
+ if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\", error_description=\"Missing or invalid access token\"" });
926
+ const accessToken = authHeader.substring(7);
927
+ const parts = accessToken.split(":");
928
+ const isPossiblyInternalFormat = parts.length === 3;
929
+ let tokenData = null;
930
+ let userId = "";
931
+ let grantId = "";
932
+ if (isPossiblyInternalFormat) {
933
+ [userId, grantId] = parts;
934
+ const id = await generateTokenId(accessToken);
935
+ tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
936
+ }
937
+ if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\"" });
938
+ if (tokenData) {
939
+ const now = Math.floor(Date.now() / 1e3);
940
+ if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", "Access token expired", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\"" });
941
+ if (tokenData.audience) {
942
+ const requestUrl = new URL(request.url);
943
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
944
+ if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\", error_description=\"Invalid audience\"" });
945
+ }
946
+ ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
947
+ } else if (this.options.resolveExternalToken) {
948
+ const ext = await this.options.resolveExternalToken({
949
+ token: accessToken,
950
+ request,
951
+ env
952
+ });
953
+ if (!ext) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\"" });
954
+ if (ext.audience) {
955
+ const requestUrl = new URL(request.url);
956
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
957
+ if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\", error_description=\"Invalid audience\"" });
958
+ }
959
+ ctx.props = ext.props;
960
+ }
961
+ if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
962
+ const url = new URL(request.url);
963
+ const apiHandler = this.findApiHandlerForUrl(url);
964
+ if (!apiHandler) return this.createErrorResponse("invalid_request", "No handler found for API route", 404);
965
+ if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
966
+ else return new apiHandler.handler(ctx, env).fetch(request);
967
+ }
968
+ /**
969
+ * Creates the helper methods object for OAuth operations
970
+ * This is passed to the handler functions to allow them to interact with the OAuth system
971
+ * @param env - Cloudflare Worker environment variables
972
+ * @returns An instance of OAuthHelpers
973
+ */
974
+ createOAuthHelpers(env) {
975
+ return new OAuthHelpersImpl(env, this);
976
+ }
977
+ /**
978
+ * Saves a grant to KV with appropriate TTL based on expiration
979
+ * @param env - The environment bindings
980
+ * @param grantKey - The KV key for the grant
981
+ * @param grantData - The grant data to save
982
+ * @param now - Current timestamp in seconds
983
+ */
984
+ async saveGrantWithTTL(env, grantKey, grantData, now) {
985
+ const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
986
+ await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
987
+ }
988
+ /**
989
+ * Fetches client information from KV storage or via CIMD (Client ID Metadata Document)
990
+ * This method is not private because `OAuthHelpers` needs to call it. Note that since
991
+ * `OAuthProviderImpl` is not exposed outside this module, this is still effectively
992
+ * module-private.
993
+ *
994
+ * Supports CIMD: If clientId is an HTTPS URL with a non-root path, the metadata
995
+ * document will be fetched from that URL instead of looking up in KV storage.
996
+ *
997
+ * @param env - Cloudflare Worker environment variables
998
+ * @param clientId - The client ID to look up (can be a regular ID or an HTTPS URL for CIMD)
999
+ * @returns The client information, or null if not found
1000
+ */
1001
+ async getClient(env, clientId) {
1002
+ if (this.isClientMetadataUrl(clientId)) {
1003
+ if (!this.hasGlobalFetchStrictlyPublic()) throw new Error(`Client ID "${clientId}" appears to be a CIMD URL, but the 'global_fetch_strictly_public' compatibility flag is not enabled. Add this flag to your wrangler.jsonc to enable CIMD support.`);
1004
+ return this.fetchClientMetadataDocument(clientId);
1005
+ }
1006
+ const clientKey = `client:${clientId}`;
1007
+ return env.OAUTH_KV.get(clientKey, { type: "json" });
1008
+ }
1009
+ /**
1010
+ * Creates and stores an access token
1011
+ * @param params - Options for creating the access token
1012
+ * @returns The access token string
1013
+ */
1014
+ async createAccessToken(params) {
1015
+ const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
1016
+ const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
1017
+ const now = Math.floor(Date.now() / 1e3);
1018
+ const accessTokenId = await generateTokenId(accessToken);
1019
+ const accessTokenData = {
1020
+ id: accessTokenId,
1021
+ grantId,
1022
+ userId,
1023
+ createdAt: now,
1024
+ expiresAt: now + expiresIn,
1025
+ audience,
1026
+ scope,
1027
+ wrappedEncryptionKey: await wrapKeyWithToken(accessToken, encryptionKey),
1028
+ grant: {
1029
+ clientId,
1030
+ scope,
1031
+ encryptedProps
1032
+ }
1033
+ };
1034
+ await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: expiresIn });
1035
+ return accessToken;
1036
+ }
1037
+ /**
1038
+ * Downscopes requested scopes to only include those that are in the grant
1039
+ * Filters out any requested scopes that are not in the granted scopes
1040
+ * @param requestedScope - The scope parameter from the request (string or array)
1041
+ * @param grantedScopes - The scopes that were granted in the authorization
1042
+ * @returns The filtered scopes that are a subset of the granted scopes
1043
+ */
1044
+ downscope(requestedScope, grantedScopes) {
1045
+ if (!requestedScope) return grantedScopes;
1046
+ return (typeof requestedScope === "string" ? requestedScope.split(" ").filter(Boolean) : requestedScope).filter((scope) => grantedScopes.includes(scope));
1047
+ }
1048
+ /**
1049
+ * Checks if the global_fetch_strictly_public compatibility flag is enabled.
1050
+ * This flag is required for CIMD to prevent SSRF attacks.
1051
+ * See: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public
1052
+ */
1053
+ hasGlobalFetchStrictlyPublic() {
1054
+ return !!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags ? Cloudflare.compatibilityFlags : null)?.global_fetch_strictly_public;
1055
+ }
1056
+ /**
1057
+ * Checks if a client_id is a CIMD URL (HTTPS with non-root path)
1058
+ */
1059
+ isClientMetadataUrl(clientId) {
1060
+ try {
1061
+ const url = new URL(clientId);
1062
+ return url.protocol === "https:" && url.pathname !== "/";
1063
+ } catch {
1064
+ return false;
1065
+ }
1066
+ }
1067
+ static {
1068
+ this.CIMD_MAX_SIZE_BYTES = 5 * 1024;
1069
+ }
1070
+ static {
1071
+ this.CIMD_FETCH_TIMEOUT_MS = 1e4;
1072
+ }
1073
+ static {
1074
+ this.CIMD_ALLOWED_AUTH_METHODS = ["none", "private_key_jwt"];
1075
+ }
1076
+ /**
1077
+ * Validates that a field is a string or undefined
1078
+ * @param field - The field value to validate
1079
+ * @param fieldName - Name of the field for error messages
1080
+ * @returns The validated string or undefined
1081
+ * @throws Error if field is not a string or undefined
1082
+ */
1083
+ static validateStringField(field, fieldName) {
1084
+ if (field === void 0) return void 0;
1085
+ if (typeof field !== "string") throw new Error(fieldName ? `Invalid ${fieldName}: expected string, got ${typeof field}` : "Field must be a string");
1086
+ return field;
1087
+ }
1088
+ /**
1089
+ * Validates that a field is a string array or undefined
1090
+ * @param arr - The array to validate
1091
+ * @param fieldName - Name of the field for error messages
1092
+ * @returns The validated string array or undefined
1093
+ * @throws Error if field is not a string array or undefined
1094
+ */
1095
+ static validateStringArray(arr, fieldName) {
1096
+ if (arr === void 0) return void 0;
1097
+ if (!Array.isArray(arr)) throw new Error(fieldName ? `Invalid ${fieldName}: expected array, got ${typeof arr}` : "Field must be an array");
1098
+ if (!arr.every((item) => typeof item === "string")) throw new Error(fieldName ? `Invalid ${fieldName}: array must contain only strings` : "All array elements must be strings");
1099
+ return arr;
1100
+ }
1101
+ /**
1102
+ * Fetches and validates a Client ID Metadata Document from the given URL
1103
+ * Per the MCP spec, the client_id in the document must match the URL exactly
1104
+ *
1105
+ * Uses Cloudflare HTTP cache for caching (via cacheEverything option).
1106
+ * Response size is limited to 5KB per IETF spec.
1107
+ *
1108
+ * @param metadataUrl - The HTTPS URL to fetch metadata from
1109
+ * @returns The client information
1110
+ * @throws Error if fetch fails or validation fails
1111
+ */
1112
+ async fetchClientMetadataDocument(metadataUrl) {
1113
+ const abortController = new AbortController();
1114
+ const timeoutId = setTimeout(() => abortController.abort(), OAuthProviderImpl.CIMD_FETCH_TIMEOUT_MS);
1115
+ try {
1116
+ const response = await fetch(metadataUrl, {
1117
+ headers: { Accept: "application/json" },
1118
+ signal: abortController.signal,
1119
+ cf: { cacheEverything: true }
1120
+ });
1121
+ clearTimeout(timeoutId);
1122
+ if (!response.ok) throw new Error(`Failed to fetch client metadata: HTTP ${response.status}`);
1123
+ const contentLength = response.headers.get("content-length");
1124
+ if (contentLength && parseInt(contentLength, 10) > OAuthProviderImpl.CIMD_MAX_SIZE_BYTES) throw new Error(`Client metadata exceeds size limit: ${contentLength} bytes (max ${OAuthProviderImpl.CIMD_MAX_SIZE_BYTES})`);
1125
+ const rawMetadata = await this.readJsonWithSizeLimit(response, OAuthProviderImpl.CIMD_MAX_SIZE_BYTES);
1126
+ const clientId = OAuthProviderImpl.validateStringField(rawMetadata.client_id, "client_id");
1127
+ const redirectUris = OAuthProviderImpl.validateStringArray(rawMetadata.redirect_uris, "redirect_uris");
1128
+ const tokenEndpointAuthMethod = OAuthProviderImpl.validateStringField(rawMetadata.token_endpoint_auth_method, "token_endpoint_auth_method");
1129
+ if (clientId !== metadataUrl) throw new Error(`client_id "${clientId}" does not match metadata URL "${metadataUrl}"`);
1130
+ if (!redirectUris || redirectUris.length === 0) throw new Error("redirect_uris is required and must not be empty");
1131
+ if (tokenEndpointAuthMethod && !OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.includes(tokenEndpointAuthMethod)) throw new Error(`token_endpoint_auth_method "${tokenEndpointAuthMethod}" is not allowed for CIMD clients. Allowed methods: ${OAuthProviderImpl.CIMD_ALLOWED_AUTH_METHODS.join(", ")}`);
1132
+ return {
1133
+ clientId,
1134
+ redirectUris,
1135
+ clientName: OAuthProviderImpl.validateStringField(rawMetadata.client_name, "client_name"),
1136
+ clientUri: OAuthProviderImpl.validateStringField(rawMetadata.client_uri, "client_uri"),
1137
+ logoUri: OAuthProviderImpl.validateStringField(rawMetadata.logo_uri, "logo_uri"),
1138
+ policyUri: OAuthProviderImpl.validateStringField(rawMetadata.policy_uri, "policy_uri"),
1139
+ tosUri: OAuthProviderImpl.validateStringField(rawMetadata.tos_uri, "tos_uri"),
1140
+ jwksUri: OAuthProviderImpl.validateStringField(rawMetadata.jwks_uri, "jwks_uri"),
1141
+ contacts: OAuthProviderImpl.validateStringArray(rawMetadata.contacts, "contacts"),
1142
+ grantTypes: OAuthProviderImpl.validateStringArray(rawMetadata.grant_types, "grant_types") || ["authorization_code"],
1143
+ responseTypes: OAuthProviderImpl.validateStringArray(rawMetadata.response_types, "response_types") || ["code"],
1144
+ tokenEndpointAuthMethod: tokenEndpointAuthMethod || "none"
1145
+ };
1146
+ } finally {
1147
+ clearTimeout(timeoutId);
1148
+ }
1149
+ }
1150
+ /**
1151
+ * Reads JSON from a response with a size limit to prevent DoS attacks.
1152
+ * Streams the response body and aborts if it exceeds the limit.
1153
+ *
1154
+ * @param response - The fetch response
1155
+ * @param maxBytes - Maximum allowed size in bytes
1156
+ * @returns Parsed JSON object
1157
+ * @throws Error if response body is null, size exceeded, or JSON parse failed
1158
+ */
1159
+ async readJsonWithSizeLimit(response, maxBytes) {
1160
+ const reader = response.body?.getReader();
1161
+ if (!reader) throw new Error("Response body is null");
1162
+ const chunks = [];
1163
+ let totalSize = 0;
1164
+ while (true) {
1165
+ const { done, value } = await reader.read();
1166
+ if (done) break;
1167
+ if (value) {
1168
+ totalSize += value.length;
1169
+ if (totalSize > maxBytes) {
1170
+ await reader.cancel();
1171
+ throw new Error(`Response exceeded size limit of ${maxBytes} bytes`);
1172
+ }
1173
+ chunks.push(value);
1174
+ }
1175
+ }
1176
+ const allChunks = new Uint8Array(totalSize);
1177
+ let position = 0;
1178
+ for (const chunk of chunks) {
1179
+ allChunks.set(chunk, position);
1180
+ position += chunk.length;
1181
+ }
1182
+ const text = new TextDecoder().decode(allChunks);
1183
+ return JSON.parse(text);
1184
+ }
1185
+ /**
1186
+ * Helper function to create OAuth error responses
1187
+ * @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
1188
+ * @param description - Human-readable error description
1189
+ * @param status - HTTP status code (default: 400)
1190
+ * @param headers - Additional headers to include
1191
+ * @returns A Response object with the error
1192
+ */
1193
+ createErrorResponse(code, description, status = 400, headers = {}) {
1194
+ const customErrorResponse = this.options.onError?.({
1195
+ code,
1196
+ description,
1197
+ status,
1198
+ headers
1199
+ });
1200
+ if (customErrorResponse) return customErrorResponse;
1201
+ const body = JSON.stringify({
1202
+ error: code,
1203
+ error_description: description
1204
+ });
1205
+ return new Response(body, {
1206
+ status,
1207
+ headers: {
1208
+ "Content-Type": "application/json",
1209
+ ...headers
1210
+ }
1211
+ });
1212
+ }
1120
1213
  };
1121
- var DEFAULT_ACCESS_TOKEN_TTL = 60 * 60;
1122
- var TOKEN_LENGTH = 32;
1214
+ /**
1215
+ * Error class for OAuth operations
1216
+ * Carries OAuth error code and description for proper error responses
1217
+ */
1218
+ var OAuthError = class extends Error {
1219
+ constructor(code, message) {
1220
+ super(message);
1221
+ this.code = code;
1222
+ this.name = "OAuthError";
1223
+ }
1224
+ };
1225
+ /**
1226
+ * Default expiration time for access tokens (1 hour in seconds)
1227
+ */
1228
+ const DEFAULT_ACCESS_TOKEN_TTL = 3600;
1229
+ /**
1230
+ * Length of generated token strings
1231
+ */
1232
+ const TOKEN_LENGTH = 32;
1233
+ /**
1234
+ * Validates a resource URI per RFC 8707 Section 2
1235
+ * @param uri - The URI string to validate
1236
+ * @returns true if valid, false otherwise
1237
+ */
1123
1238
  function validateResourceUri(uri) {
1124
- if (!uri || typeof uri !== "string") {
1125
- return false;
1126
- }
1127
- try {
1128
- const parsed = new URL(uri);
1129
- if (!parsed.protocol) {
1130
- return false;
1131
- }
1132
- if (parsed.hash) {
1133
- return false;
1134
- }
1135
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1136
- return false;
1137
- }
1138
- return true;
1139
- } catch {
1140
- return false;
1141
- }
1239
+ if (!uri || typeof uri !== "string") return false;
1240
+ try {
1241
+ const parsed = new URL(uri);
1242
+ if (!parsed.protocol) return false;
1243
+ if (parsed.hash) return false;
1244
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
1245
+ return true;
1246
+ } catch {
1247
+ return false;
1248
+ }
1142
1249
  }
1250
+ /**
1251
+ * Checks if a resource server matches an audience claim.
1252
+ * Uses origin comparison (case-insensitive hostname via URL normalization)
1253
+ * and path-prefix matching on path boundaries for RFC 8707 resource indicators.
1254
+ * @param resourceServerUrl - The resource server URL (from request)
1255
+ * @param audienceValue - The audience value from token
1256
+ * @returns true if they match, false otherwise
1257
+ */
1143
1258
  function audienceMatches(resourceServerUrl, audienceValue) {
1144
- return resourceServerUrl === audienceValue;
1259
+ try {
1260
+ const resource = new URL(resourceServerUrl);
1261
+ const audience = new URL(audienceValue);
1262
+ if (resource.origin !== audience.origin) return false;
1263
+ if (audience.pathname === "/" || audience.pathname === "") return true;
1264
+ return resource.pathname === audience.pathname || resource.pathname.startsWith(audience.pathname + "/");
1265
+ } catch {
1266
+ return false;
1267
+ }
1145
1268
  }
1269
+ /**
1270
+ * Parses and validates the resource parameter from a token request (RFC 8707)
1271
+ * Handles single string or array of strings (from multiple form parameters)
1272
+ * @param value - The resource parameter value from the request body
1273
+ * @returns The validated value as string, string array, or undefined if validation fails
1274
+ */
1146
1275
  function parseResourceParameter(value) {
1147
- if (!value) {
1148
- return void 0;
1149
- }
1150
- const uris = Array.isArray(value) ? value : [value];
1151
- for (const uri of uris) {
1152
- if (typeof uri !== "string" || !validateResourceUri(uri)) {
1153
- return void 0;
1154
- }
1155
- }
1156
- return value;
1276
+ if (!value) return;
1277
+ const uris = Array.isArray(value) ? value : [value];
1278
+ for (const uri of uris) if (typeof uri !== "string" || !validateResourceUri(uri)) return;
1279
+ return value;
1157
1280
  }
1281
+ /**
1282
+ * Hashes a secret value using SHA-256
1283
+ * @param secret - The secret value to hash
1284
+ * @returns A hex string representation of the hash
1285
+ */
1158
1286
  async function hashSecret(secret) {
1159
- return generateTokenId(secret);
1287
+ return generateTokenId(secret);
1160
1288
  }
1289
+ /**
1290
+ * Generates a cryptographically secure random string
1291
+ * @param length - The length of the string to generate
1292
+ * @returns A random string of the specified length
1293
+ */
1161
1294
  function generateRandomString(length) {
1162
- const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1163
- let result = "";
1164
- const values = new Uint8Array(length);
1165
- crypto.getRandomValues(values);
1166
- for (let i = 0; i < length; i++) {
1167
- result += characters.charAt(values[i] % characters.length);
1168
- }
1169
- return result;
1295
+ const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1296
+ let result = "";
1297
+ const values = new Uint8Array(length);
1298
+ crypto.getRandomValues(values);
1299
+ for (let i = 0; i < length; i++) result += characters.charAt(values[i] % 64);
1300
+ return result;
1170
1301
  }
1302
+ /**
1303
+ * Generates a token ID by hashing the token value using SHA-256
1304
+ * @param token - The token to hash
1305
+ * @returns A hex string representation of the hash
1306
+ */
1171
1307
  async function generateTokenId(token) {
1172
- const encoder = new TextEncoder();
1173
- const data = encoder.encode(token);
1174
- const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1175
- const hashArray = Array.from(new Uint8Array(hashBuffer));
1176
- const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
1177
- return hashHex;
1308
+ const data = new TextEncoder().encode(token);
1309
+ const hashBuffer = await crypto.subtle.digest("SHA-256", data);
1310
+ return Array.from(new Uint8Array(hashBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
1178
1311
  }
1312
+ /**
1313
+ * Validates that a redirect URI does not use a dangerous pseudo-scheme.
1314
+ * Normalizes the URI by trimming whitespace and checking the scheme in a
1315
+ * case-insensitive manner to prevent bypass attacks.
1316
+ * Per RFC 3986, control characters are explicitly disallowed in URIs and
1317
+ * will cause rejection rather than silent removal.
1318
+ * @param redirectUri - The redirect URI to validate
1319
+ * @throws Error if the URI uses a blacklisted scheme or contains control characters
1320
+ */
1179
1321
  function validateRedirectUriScheme(redirectUri) {
1180
- const dangerousSchemes = ["javascript:", "data:", "vbscript:", "file:", "mailto:", "blob:"];
1181
- const normalized = redirectUri.trim();
1182
- for (let i = 0; i < normalized.length; i++) {
1183
- const code = normalized.charCodeAt(i);
1184
- if (code >= 0 && code <= 31 || code >= 127 && code <= 159) {
1185
- throw new Error("Invalid redirect URI");
1186
- }
1187
- }
1188
- const colonIndex = normalized.indexOf(":");
1189
- if (colonIndex === -1) {
1190
- throw new Error("Invalid redirect URI");
1191
- }
1192
- const scheme = normalized.substring(0, colonIndex + 1).toLowerCase();
1193
- for (const dangerousScheme of dangerousSchemes) {
1194
- if (scheme === dangerousScheme) {
1195
- throw new Error("Invalid redirect URI");
1196
- }
1197
- }
1322
+ const dangerousSchemes = [
1323
+ "javascript:",
1324
+ "data:",
1325
+ "vbscript:",
1326
+ "file:",
1327
+ "mailto:",
1328
+ "blob:"
1329
+ ];
1330
+ const normalized = redirectUri.trim();
1331
+ for (let i = 0; i < normalized.length; i++) {
1332
+ const code = normalized.charCodeAt(i);
1333
+ if (code >= 0 && code <= 31 || code >= 127 && code <= 159) throw new Error("Invalid redirect URI");
1334
+ }
1335
+ const colonIndex = normalized.indexOf(":");
1336
+ if (colonIndex === -1) throw new Error("Invalid redirect URI");
1337
+ const scheme = normalized.substring(0, colonIndex + 1).toLowerCase();
1338
+ for (const dangerousScheme of dangerousSchemes) if (scheme === dangerousScheme) throw new Error("Invalid redirect URI");
1198
1339
  }
1340
+ /**
1341
+ * Encodes a string as base64url (URL-safe base64)
1342
+ * @param str - The string to encode
1343
+ * @returns The base64url encoded string
1344
+ */
1199
1345
  function base64UrlEncode(str) {
1200
- return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
1346
+ return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
1201
1347
  }
1348
+ /**
1349
+ * Encodes an ArrayBuffer as base64 string
1350
+ * @param buffer - The ArrayBuffer to encode
1351
+ * @returns The base64 encoded string
1352
+ */
1202
1353
  function arrayBufferToBase64(buffer) {
1203
- return btoa(String.fromCharCode(...new Uint8Array(buffer)));
1354
+ return btoa(String.fromCharCode(...new Uint8Array(buffer)));
1204
1355
  }
1356
+ /**
1357
+ * Decodes a base64 string to an ArrayBuffer
1358
+ * @param base64 - The base64 string to decode
1359
+ * @returns The decoded ArrayBuffer
1360
+ */
1205
1361
  function base64ToArrayBuffer(base64) {
1206
- const binaryString = atob(base64);
1207
- const bytes = new Uint8Array(binaryString.length);
1208
- for (let i = 0; i < binaryString.length; i++) {
1209
- bytes[i] = binaryString.charCodeAt(i);
1210
- }
1211
- return bytes.buffer;
1362
+ const binaryString = atob(base64);
1363
+ const bytes = new Uint8Array(binaryString.length);
1364
+ for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i);
1365
+ return bytes.buffer;
1212
1366
  }
1367
+ /**
1368
+ * Encrypts props data with a newly generated key
1369
+ * @param data - The data to encrypt
1370
+ * @returns An object containing the encrypted data and the generated key
1371
+ */
1213
1372
  async function encryptProps(data) {
1214
- const key = await crypto.subtle.generateKey(
1215
- {
1216
- name: "AES-GCM",
1217
- length: 256
1218
- },
1219
- true,
1220
- // extractable
1221
- ["encrypt", "decrypt"]
1222
- );
1223
- const iv = new Uint8Array(12);
1224
- const jsonData = JSON.stringify(data);
1225
- const encoder = new TextEncoder();
1226
- const encodedData = encoder.encode(jsonData);
1227
- const encryptedBuffer = await crypto.subtle.encrypt(
1228
- {
1229
- name: "AES-GCM",
1230
- iv
1231
- },
1232
- key,
1233
- encodedData
1234
- );
1235
- return {
1236
- encryptedData: arrayBufferToBase64(encryptedBuffer),
1237
- key
1238
- };
1373
+ const key = await crypto.subtle.generateKey({
1374
+ name: "AES-GCM",
1375
+ length: 256
1376
+ }, true, ["encrypt", "decrypt"]);
1377
+ const iv = new Uint8Array(12);
1378
+ const jsonData = JSON.stringify(data);
1379
+ const encodedData = new TextEncoder().encode(jsonData);
1380
+ return {
1381
+ encryptedData: arrayBufferToBase64(await crypto.subtle.encrypt({
1382
+ name: "AES-GCM",
1383
+ iv
1384
+ }, key, encodedData)),
1385
+ key
1386
+ };
1239
1387
  }
1388
+ /**
1389
+ * Decrypts encrypted props data using the provided key
1390
+ * @param key - The CryptoKey to use for decryption
1391
+ * @param encryptedData - The encrypted data as a base64 string
1392
+ * @returns The decrypted data object
1393
+ */
1240
1394
  async function decryptProps(key, encryptedData) {
1241
- const encryptedBuffer = base64ToArrayBuffer(encryptedData);
1242
- const iv = new Uint8Array(12);
1243
- const decryptedBuffer = await crypto.subtle.decrypt(
1244
- {
1245
- name: "AES-GCM",
1246
- iv
1247
- },
1248
- key,
1249
- encryptedBuffer
1250
- );
1251
- const decoder = new TextDecoder();
1252
- const jsonData = decoder.decode(decryptedBuffer);
1253
- return JSON.parse(jsonData);
1395
+ const encryptedBuffer = base64ToArrayBuffer(encryptedData);
1396
+ const iv = new Uint8Array(12);
1397
+ const decryptedBuffer = await crypto.subtle.decrypt({
1398
+ name: "AES-GCM",
1399
+ iv
1400
+ }, key, encryptedBuffer);
1401
+ const jsonData = new TextDecoder().decode(decryptedBuffer);
1402
+ return JSON.parse(jsonData);
1254
1403
  }
1255
- var WRAPPING_KEY_HMAC_KEY = new Uint8Array([
1256
- 34,
1257
- 126,
1258
- 38,
1259
- 134,
1260
- 141,
1261
- 241,
1262
- 225,
1263
- 109,
1264
- 128,
1265
- 112,
1266
- 234,
1267
- 23,
1268
- 151,
1269
- 91,
1270
- 71,
1271
- 166,
1272
- 130,
1273
- 24,
1274
- 250,
1275
- 135,
1276
- 40,
1277
- 174,
1278
- 222,
1279
- 133,
1280
- 181,
1281
- 29,
1282
- 74,
1283
- 217,
1284
- 150,
1285
- 202,
1286
- 202,
1287
- 67
1404
+ const WRAPPING_KEY_HMAC_KEY = new Uint8Array([
1405
+ 34,
1406
+ 126,
1407
+ 38,
1408
+ 134,
1409
+ 141,
1410
+ 241,
1411
+ 225,
1412
+ 109,
1413
+ 128,
1414
+ 112,
1415
+ 234,
1416
+ 23,
1417
+ 151,
1418
+ 91,
1419
+ 71,
1420
+ 166,
1421
+ 130,
1422
+ 24,
1423
+ 250,
1424
+ 135,
1425
+ 40,
1426
+ 174,
1427
+ 222,
1428
+ 133,
1429
+ 181,
1430
+ 29,
1431
+ 74,
1432
+ 217,
1433
+ 150,
1434
+ 202,
1435
+ 202,
1436
+ 67
1288
1437
  ]);
1438
+ /**
1439
+ * Derives a wrapping key from a token string
1440
+ * This intentionally uses a different method than token ID generation
1441
+ * to ensure the token ID cannot be used to derive the wrapping key
1442
+ * @param tokenStr - The token string to use as key material
1443
+ * @returns A Promise resolving to the derived CryptoKey
1444
+ */
1289
1445
  async function deriveKeyFromToken(tokenStr) {
1290
- const encoder = new TextEncoder();
1291
- const hmacKey = await crypto.subtle.importKey(
1292
- "raw",
1293
- WRAPPING_KEY_HMAC_KEY,
1294
- { name: "HMAC", hash: "SHA-256" },
1295
- false,
1296
- ["sign"]
1297
- );
1298
- const hmacResult = await crypto.subtle.sign("HMAC", hmacKey, encoder.encode(tokenStr));
1299
- return await crypto.subtle.importKey(
1300
- "raw",
1301
- hmacResult,
1302
- { name: "AES-KW" },
1303
- false,
1304
- // not extractable
1305
- ["wrapKey", "unwrapKey"]
1306
- );
1446
+ const encoder = new TextEncoder();
1447
+ const hmacKey = await crypto.subtle.importKey("raw", WRAPPING_KEY_HMAC_KEY, {
1448
+ name: "HMAC",
1449
+ hash: "SHA-256"
1450
+ }, false, ["sign"]);
1451
+ const hmacResult = await crypto.subtle.sign("HMAC", hmacKey, encoder.encode(tokenStr));
1452
+ return await crypto.subtle.importKey("raw", hmacResult, { name: "AES-KW" }, false, ["wrapKey", "unwrapKey"]);
1307
1453
  }
1454
+ /**
1455
+ * Wraps an encryption key using a token-derived key
1456
+ * @param tokenStr - The token string to use for key wrapping
1457
+ * @param keyToWrap - The encryption key to wrap
1458
+ * @returns A Promise resolving to the wrapped key as a base64 string
1459
+ */
1308
1460
  async function wrapKeyWithToken(tokenStr, keyToWrap) {
1309
- const wrappingKey = await deriveKeyFromToken(tokenStr);
1310
- const wrappedKeyBuffer = await crypto.subtle.wrapKey("raw", keyToWrap, wrappingKey, { name: "AES-KW" });
1311
- return arrayBufferToBase64(wrappedKeyBuffer);
1461
+ const wrappingKey = await deriveKeyFromToken(tokenStr);
1462
+ return arrayBufferToBase64(await crypto.subtle.wrapKey("raw", keyToWrap, wrappingKey, { name: "AES-KW" }));
1312
1463
  }
1464
+ /**
1465
+ * Unwraps an encryption key using a token-derived key
1466
+ * @param tokenStr - The token string used for key wrapping
1467
+ * @param wrappedKeyBase64 - The wrapped key as a base64 string
1468
+ * @returns A Promise resolving to the unwrapped CryptoKey
1469
+ */
1313
1470
  async function unwrapKeyWithToken(tokenStr, wrappedKeyBase64) {
1314
- const wrappingKey = await deriveKeyFromToken(tokenStr);
1315
- const wrappedKeyBuffer = base64ToArrayBuffer(wrappedKeyBase64);
1316
- return await crypto.subtle.unwrapKey(
1317
- "raw",
1318
- wrappedKeyBuffer,
1319
- wrappingKey,
1320
- { name: "AES-KW" },
1321
- { name: "AES-GCM" },
1322
- true,
1323
- // extractable
1324
- ["encrypt", "decrypt"]
1325
- );
1471
+ const wrappingKey = await deriveKeyFromToken(tokenStr);
1472
+ const wrappedKeyBuffer = base64ToArrayBuffer(wrappedKeyBase64);
1473
+ return await crypto.subtle.unwrapKey("raw", wrappedKeyBuffer, wrappingKey, { name: "AES-KW" }, { name: "AES-GCM" }, true, ["encrypt", "decrypt"]);
1326
1474
  }
1475
+ /**
1476
+ * Class that implements the OAuth helper methods
1477
+ * Provides methods for OAuth operations needed by handlers
1478
+ */
1327
1479
  var OAuthHelpersImpl = class {
1328
- /**
1329
- * Creates a new OAuthHelpers instance
1330
- * @param env - Cloudflare Worker environment variables
1331
- * @param provider - Reference to the parent provider instance
1332
- */
1333
- constructor(env, provider) {
1334
- this.env = env;
1335
- this.provider = provider;
1336
- }
1337
- /**
1338
- * Parses an OAuth authorization request from the HTTP request
1339
- * @param request - The HTTP request containing OAuth parameters
1340
- * @returns The parsed authorization request parameters
1341
- */
1342
- async parseAuthRequest(request) {
1343
- const url = new URL(request.url);
1344
- const responseType = url.searchParams.get("response_type") || "";
1345
- const clientId = url.searchParams.get("client_id") || "";
1346
- const redirectUri = url.searchParams.get("redirect_uri") || "";
1347
- const scope = (url.searchParams.get("scope") || "").split(" ").filter(Boolean);
1348
- const state = url.searchParams.get("state") || "";
1349
- const codeChallenge = url.searchParams.get("code_challenge") || void 0;
1350
- const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "plain";
1351
- const resourceParams = url.searchParams.getAll("resource");
1352
- const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
1353
- validateRedirectUriScheme(redirectUri);
1354
- const resource = parseResourceParameter(resourceParam);
1355
- if (resourceParam && !resource) {
1356
- throw new Error("The resource parameter must be a valid absolute URI without a fragment");
1357
- }
1358
- if (responseType === "token" && !this.provider.options.allowImplicitFlow) {
1359
- throw new Error("The implicit grant flow is not enabled for this provider");
1360
- }
1361
- if (clientId) {
1362
- const clientInfo = await this.lookupClient(clientId);
1363
- if (!clientInfo) {
1364
- throw new Error(`Invalid client. The clientId provided does not match to this client.`);
1365
- }
1366
- if (clientInfo && redirectUri) {
1367
- if (!clientInfo.redirectUris.includes(redirectUri)) {
1368
- throw new Error(
1369
- `Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`
1370
- );
1371
- }
1372
- }
1373
- }
1374
- return {
1375
- responseType,
1376
- clientId,
1377
- redirectUri,
1378
- scope,
1379
- state,
1380
- codeChallenge,
1381
- codeChallengeMethod,
1382
- resource
1383
- };
1384
- }
1385
- /**
1386
- * Looks up a client by its client ID
1387
- * @param clientId - The client ID to look up
1388
- * @returns A Promise resolving to the client info, or null if not found
1389
- */
1390
- async lookupClient(clientId) {
1391
- return await this.provider.getClient(this.env, clientId);
1392
- }
1393
- /**
1394
- * Completes an authorization request by creating a grant and either:
1395
- * - For authorization code flow: generating an authorization code
1396
- * - For implicit flow: generating an access token directly
1397
- * @param options - Options specifying the grant details
1398
- * @returns A Promise resolving to an object containing the redirect URL
1399
- */
1400
- async completeAuthorization(options) {
1401
- const { clientId, redirectUri } = options.request;
1402
- if (!clientId || !redirectUri) {
1403
- throw new Error("Client ID and Redirect URI are required in the authorization request.");
1404
- }
1405
- const clientInfo = await this.lookupClient(clientId);
1406
- if (!clientInfo || !clientInfo.redirectUris.includes(redirectUri)) {
1407
- throw new Error(
1408
- "Invalid redirect URI. The redirect URI provided does not match any registered URI for this client."
1409
- );
1410
- }
1411
- const grantId = generateRandomString(16);
1412
- const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
1413
- const now = Math.floor(Date.now() / 1e3);
1414
- if (options.request.responseType === "token") {
1415
- const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
1416
- const accessToken = `${options.userId}:${grantId}:${accessTokenSecret}`;
1417
- const accessTokenId = await generateTokenId(accessToken);
1418
- const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
1419
- const accessTokenExpiresAt = now + accessTokenTTL;
1420
- const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
1421
- const audience = parseResourceParameter(options.request.resource);
1422
- if (options.request.resource && !audience) {
1423
- throw new Error("The resource parameter must be a valid absolute URI without a fragment");
1424
- }
1425
- const grant = {
1426
- id: grantId,
1427
- clientId: options.request.clientId,
1428
- userId: options.userId,
1429
- scope: options.scope,
1430
- metadata: options.metadata,
1431
- encryptedProps: encryptedData,
1432
- createdAt: now,
1433
- resource: options.request.resource
1434
- };
1435
- const grantKey = `grant:${options.userId}:${grantId}`;
1436
- await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
1437
- const accessTokenData = {
1438
- id: accessTokenId,
1439
- grantId,
1440
- userId: options.userId,
1441
- createdAt: now,
1442
- expiresAt: accessTokenExpiresAt,
1443
- audience,
1444
- wrappedEncryptionKey: accessTokenWrappedKey,
1445
- grant: {
1446
- clientId: options.request.clientId,
1447
- scope: options.scope,
1448
- encryptedProps: encryptedData
1449
- }
1450
- };
1451
- await this.env.OAUTH_KV.put(
1452
- `token:${options.userId}:${grantId}:${accessTokenId}`,
1453
- JSON.stringify(accessTokenData),
1454
- { expirationTtl: accessTokenTTL }
1455
- );
1456
- const redirectUrl = new URL(options.request.redirectUri);
1457
- const fragment = new URLSearchParams();
1458
- fragment.set("access_token", accessToken);
1459
- fragment.set("token_type", "bearer");
1460
- fragment.set("expires_in", accessTokenTTL.toString());
1461
- fragment.set("scope", options.scope.join(" "));
1462
- if (options.request.state) {
1463
- fragment.set("state", options.request.state);
1464
- }
1465
- redirectUrl.hash = fragment.toString();
1466
- return { redirectTo: redirectUrl.toString() };
1467
- } else {
1468
- const authCodeSecret = generateRandomString(32);
1469
- const authCode = `${options.userId}:${grantId}:${authCodeSecret}`;
1470
- const authCodeId = await hashSecret(authCode);
1471
- const authCodeWrappedKey = await wrapKeyWithToken(authCode, encryptionKey);
1472
- const grant = {
1473
- id: grantId,
1474
- clientId: options.request.clientId,
1475
- userId: options.userId,
1476
- scope: options.scope,
1477
- metadata: options.metadata,
1478
- encryptedProps: encryptedData,
1479
- createdAt: now,
1480
- authCodeId,
1481
- // Store the auth code hash in the grant
1482
- authCodeWrappedKey,
1483
- // Store the wrapped key
1484
- // Store PKCE parameters if provided
1485
- codeChallenge: options.request.codeChallenge,
1486
- codeChallengeMethod: options.request.codeChallengeMethod,
1487
- resource: options.request.resource
1488
- };
1489
- const grantKey = `grant:${options.userId}:${grantId}`;
1490
- const codeExpiresIn = 600;
1491
- await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: codeExpiresIn });
1492
- const redirectUrl = new URL(options.request.redirectUri);
1493
- redirectUrl.searchParams.set("code", authCode);
1494
- if (options.request.state) {
1495
- redirectUrl.searchParams.set("state", options.request.state);
1496
- }
1497
- return { redirectTo: redirectUrl.toString() };
1498
- }
1499
- }
1500
- /**
1501
- * Creates a new OAuth client
1502
- * @param clientInfo - Partial client information to create the client with
1503
- * @returns A Promise resolving to the created client info
1504
- */
1505
- async createClient(clientInfo) {
1506
- const clientId = generateRandomString(16);
1507
- const tokenEndpointAuthMethod = clientInfo.tokenEndpointAuthMethod || "client_secret_basic";
1508
- const isPublicClient = tokenEndpointAuthMethod === "none";
1509
- const newClient = {
1510
- clientId,
1511
- redirectUris: clientInfo.redirectUris || [],
1512
- clientName: clientInfo.clientName,
1513
- logoUri: clientInfo.logoUri,
1514
- clientUri: clientInfo.clientUri,
1515
- policyUri: clientInfo.policyUri,
1516
- tosUri: clientInfo.tosUri,
1517
- jwksUri: clientInfo.jwksUri,
1518
- contacts: clientInfo.contacts,
1519
- grantTypes: clientInfo.grantTypes || ["authorization_code", "refresh_token"],
1520
- responseTypes: clientInfo.responseTypes || ["code"],
1521
- registrationDate: Math.floor(Date.now() / 1e3),
1522
- tokenEndpointAuthMethod
1523
- };
1524
- for (const uri of newClient.redirectUris) {
1525
- validateRedirectUriScheme(uri);
1526
- }
1527
- let clientSecret;
1528
- if (!isPublicClient) {
1529
- clientSecret = generateRandomString(32);
1530
- newClient.clientSecret = await hashSecret(clientSecret);
1531
- }
1532
- await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(newClient));
1533
- const clientResponse = { ...newClient };
1534
- if (!isPublicClient && clientSecret) {
1535
- clientResponse.clientSecret = clientSecret;
1536
- }
1537
- return clientResponse;
1538
- }
1539
- /**
1540
- * Lists all registered OAuth clients with pagination support
1541
- * @param options - Optional pagination parameters (limit and cursor)
1542
- * @returns A Promise resolving to the list result with items and optional cursor
1543
- */
1544
- async listClients(options) {
1545
- const listOptions = {
1546
- prefix: "client:"
1547
- };
1548
- if (options?.limit !== void 0) {
1549
- listOptions.limit = options.limit;
1550
- }
1551
- if (options?.cursor !== void 0) {
1552
- listOptions.cursor = options.cursor;
1553
- }
1554
- const response = await this.env.OAUTH_KV.list(listOptions);
1555
- const clients = [];
1556
- const promises = response.keys.map(async (key) => {
1557
- const clientId = key.name.substring("client:".length);
1558
- const client = await this.provider.getClient(this.env, clientId);
1559
- if (client) {
1560
- clients.push(client);
1561
- }
1562
- });
1563
- await Promise.all(promises);
1564
- return {
1565
- items: clients,
1566
- cursor: response.list_complete ? void 0 : response.cursor
1567
- };
1568
- }
1569
- /**
1570
- * Updates an existing OAuth client
1571
- * @param clientId - The ID of the client to update
1572
- * @param updates - Partial client information with fields to update
1573
- * @returns A Promise resolving to the updated client info, or null if not found
1574
- */
1575
- async updateClient(clientId, updates) {
1576
- const client = await this.provider.getClient(this.env, clientId);
1577
- if (!client) {
1578
- return null;
1579
- }
1580
- let authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
1581
- const isPublicClient = authMethod === "none";
1582
- let secretToStore = client.clientSecret;
1583
- let originalSecret = void 0;
1584
- if (isPublicClient) {
1585
- secretToStore = void 0;
1586
- } else if (updates.clientSecret) {
1587
- originalSecret = updates.clientSecret;
1588
- secretToStore = await hashSecret(updates.clientSecret);
1589
- }
1590
- const updatedClient = {
1591
- ...client,
1592
- ...updates,
1593
- clientId: client.clientId,
1594
- // Ensure clientId doesn't change
1595
- tokenEndpointAuthMethod: authMethod
1596
- // Use determined auth method
1597
- };
1598
- if (!isPublicClient && secretToStore) {
1599
- updatedClient.clientSecret = secretToStore;
1600
- } else {
1601
- delete updatedClient.clientSecret;
1602
- }
1603
- await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient));
1604
- const response = { ...updatedClient };
1605
- if (!isPublicClient && originalSecret) {
1606
- response.clientSecret = originalSecret;
1607
- }
1608
- return response;
1609
- }
1610
- /**
1611
- * Deletes an OAuth client
1612
- * @param clientId - The ID of the client to delete
1613
- * @returns A Promise resolving when the deletion is confirmed.
1614
- */
1615
- async deleteClient(clientId) {
1616
- await this.env.OAUTH_KV.delete(`client:${clientId}`);
1617
- }
1618
- /**
1619
- * Lists all authorization grants for a specific user with pagination support
1620
- * Returns a summary of each grant without sensitive information
1621
- * @param userId - The ID of the user whose grants to list
1622
- * @param options - Optional pagination parameters (limit and cursor)
1623
- * @returns A Promise resolving to the list result with grant summaries and optional cursor
1624
- */
1625
- async listUserGrants(userId, options) {
1626
- const listOptions = {
1627
- prefix: `grant:${userId}:`
1628
- };
1629
- if (options?.limit !== void 0) {
1630
- listOptions.limit = options.limit;
1631
- }
1632
- if (options?.cursor !== void 0) {
1633
- listOptions.cursor = options.cursor;
1634
- }
1635
- const response = await this.env.OAUTH_KV.list(listOptions);
1636
- const grantSummaries = [];
1637
- const promises = response.keys.map(async (key) => {
1638
- const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
1639
- if (grantData) {
1640
- const summary = {
1641
- id: grantData.id,
1642
- clientId: grantData.clientId,
1643
- userId: grantData.userId,
1644
- scope: grantData.scope,
1645
- metadata: grantData.metadata,
1646
- createdAt: grantData.createdAt,
1647
- expiresAt: grantData.expiresAt
1648
- };
1649
- grantSummaries.push(summary);
1650
- }
1651
- });
1652
- await Promise.all(promises);
1653
- return {
1654
- items: grantSummaries,
1655
- cursor: response.list_complete ? void 0 : response.cursor
1656
- };
1657
- }
1658
- /**
1659
- * Revokes an authorization grant and all its associated access tokens
1660
- * @param grantId - The ID of the grant to revoke
1661
- * @param userId - The ID of the user who owns the grant
1662
- * @returns A Promise resolving when the revocation is confirmed.
1663
- */
1664
- async revokeGrant(grantId, userId) {
1665
- const grantKey = `grant:${userId}:${grantId}`;
1666
- const tokenPrefix = `token:${userId}:${grantId}:`;
1667
- let cursor;
1668
- let allTokensDeleted = false;
1669
- while (!allTokensDeleted) {
1670
- const listOptions = {
1671
- prefix: tokenPrefix
1672
- };
1673
- if (cursor) {
1674
- listOptions.cursor = cursor;
1675
- }
1676
- const result = await this.env.OAUTH_KV.list(listOptions);
1677
- if (result.keys.length > 0) {
1678
- await Promise.all(
1679
- result.keys.map((key) => {
1680
- return this.env.OAUTH_KV.delete(key.name);
1681
- })
1682
- );
1683
- }
1684
- if (result.list_complete) {
1685
- allTokensDeleted = true;
1686
- } else {
1687
- cursor = result.cursor;
1688
- }
1689
- }
1690
- await this.env.OAUTH_KV.delete(grantKey);
1691
- }
1480
+ /**
1481
+ * Creates a new OAuthHelpers instance
1482
+ * @param env - Cloudflare Worker environment variables
1483
+ * @param provider - Reference to the parent provider instance
1484
+ */
1485
+ constructor(env, provider) {
1486
+ this.env = env;
1487
+ this.provider = provider;
1488
+ }
1489
+ /**
1490
+ * Parses an OAuth authorization request from the HTTP request
1491
+ * @param request - The HTTP request containing OAuth parameters
1492
+ * @returns The parsed authorization request parameters
1493
+ */
1494
+ async parseAuthRequest(request) {
1495
+ const url = new URL(request.url);
1496
+ const responseType = url.searchParams.get("response_type") || "";
1497
+ const clientId = url.searchParams.get("client_id") || "";
1498
+ const redirectUri = url.searchParams.get("redirect_uri") || "";
1499
+ const scope = (url.searchParams.get("scope") || "").split(" ").filter(Boolean);
1500
+ const state = url.searchParams.get("state") || "";
1501
+ const codeChallenge = url.searchParams.get("code_challenge") || void 0;
1502
+ const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "plain";
1503
+ const resourceParams = url.searchParams.getAll("resource");
1504
+ const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
1505
+ validateRedirectUriScheme(redirectUri);
1506
+ const resource = parseResourceParameter(resourceParam);
1507
+ if (resourceParam && !resource) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
1508
+ if (responseType === "token" && !this.provider.options.allowImplicitFlow) throw new Error("The implicit grant flow is not enabled for this provider");
1509
+ if (clientId) {
1510
+ const clientInfo = await this.lookupClient(clientId);
1511
+ if (!clientInfo) throw new Error(`Invalid client. The clientId provided does not match to this client.`);
1512
+ if (clientInfo && redirectUri) {
1513
+ if (!clientInfo.redirectUris.includes(redirectUri)) throw new Error(`Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`);
1514
+ }
1515
+ }
1516
+ return {
1517
+ responseType,
1518
+ clientId,
1519
+ redirectUri,
1520
+ scope,
1521
+ state,
1522
+ codeChallenge,
1523
+ codeChallengeMethod,
1524
+ resource
1525
+ };
1526
+ }
1527
+ /**
1528
+ * Looks up a client by its client ID
1529
+ * @param clientId - The client ID to look up
1530
+ * @returns A Promise resolving to the client info, or null if not found
1531
+ */
1532
+ async lookupClient(clientId) {
1533
+ return await this.provider.getClient(this.env, clientId);
1534
+ }
1535
+ /**
1536
+ * Completes an authorization request by creating a grant and either:
1537
+ * - For authorization code flow: generating an authorization code
1538
+ * - For implicit flow: generating an access token directly
1539
+ * @param options - Options specifying the grant details
1540
+ * @returns A Promise resolving to an object containing the redirect URL
1541
+ */
1542
+ async completeAuthorization(options) {
1543
+ const { clientId, redirectUri } = options.request;
1544
+ if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
1545
+ const clientInfo = await this.lookupClient(clientId);
1546
+ if (!clientInfo || !clientInfo.redirectUris.includes(redirectUri)) throw new Error("Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.");
1547
+ const grantId = generateRandomString(16);
1548
+ const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
1549
+ const now = Math.floor(Date.now() / 1e3);
1550
+ if (options.request.responseType === "token") {
1551
+ const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
1552
+ const accessToken = `${options.userId}:${grantId}:${accessTokenSecret}`;
1553
+ const accessTokenId = await generateTokenId(accessToken);
1554
+ const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
1555
+ const accessTokenExpiresAt = now + accessTokenTTL;
1556
+ const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
1557
+ const audience = parseResourceParameter(options.request.resource);
1558
+ if (options.request.resource && !audience) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
1559
+ const grant = {
1560
+ id: grantId,
1561
+ clientId: options.request.clientId,
1562
+ userId: options.userId,
1563
+ scope: options.scope,
1564
+ metadata: options.metadata,
1565
+ encryptedProps: encryptedData,
1566
+ createdAt: now,
1567
+ resource: options.request.resource
1568
+ };
1569
+ const grantKey = `grant:${options.userId}:${grantId}`;
1570
+ await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
1571
+ const accessTokenData = {
1572
+ id: accessTokenId,
1573
+ grantId,
1574
+ userId: options.userId,
1575
+ createdAt: now,
1576
+ expiresAt: accessTokenExpiresAt,
1577
+ audience,
1578
+ scope: options.scope,
1579
+ wrappedEncryptionKey: accessTokenWrappedKey,
1580
+ grant: {
1581
+ clientId: options.request.clientId,
1582
+ scope: options.scope,
1583
+ encryptedProps: encryptedData
1584
+ }
1585
+ };
1586
+ await this.env.OAUTH_KV.put(`token:${options.userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
1587
+ const redirectUrl = new URL(options.request.redirectUri);
1588
+ const fragment = new URLSearchParams();
1589
+ fragment.set("access_token", accessToken);
1590
+ fragment.set("token_type", "bearer");
1591
+ fragment.set("expires_in", accessTokenTTL.toString());
1592
+ fragment.set("scope", options.scope.join(" "));
1593
+ if (options.request.state) fragment.set("state", options.request.state);
1594
+ redirectUrl.hash = fragment.toString();
1595
+ return { redirectTo: redirectUrl.toString() };
1596
+ } else {
1597
+ const authCodeSecret = generateRandomString(32);
1598
+ const authCode = `${options.userId}:${grantId}:${authCodeSecret}`;
1599
+ const authCodeId = await hashSecret(authCode);
1600
+ const authCodeWrappedKey = await wrapKeyWithToken(authCode, encryptionKey);
1601
+ const grant = {
1602
+ id: grantId,
1603
+ clientId: options.request.clientId,
1604
+ userId: options.userId,
1605
+ scope: options.scope,
1606
+ metadata: options.metadata,
1607
+ encryptedProps: encryptedData,
1608
+ createdAt: now,
1609
+ authCodeId,
1610
+ authCodeWrappedKey,
1611
+ codeChallenge: options.request.codeChallenge,
1612
+ codeChallengeMethod: options.request.codeChallengeMethod,
1613
+ resource: options.request.resource
1614
+ };
1615
+ const grantKey = `grant:${options.userId}:${grantId}`;
1616
+ await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant), { expirationTtl: 600 });
1617
+ const redirectUrl = new URL(options.request.redirectUri);
1618
+ redirectUrl.searchParams.set("code", authCode);
1619
+ if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
1620
+ return { redirectTo: redirectUrl.toString() };
1621
+ }
1622
+ }
1623
+ /**
1624
+ * Creates a new OAuth client
1625
+ * @param clientInfo - Partial client information to create the client with
1626
+ * @returns A Promise resolving to the created client info
1627
+ */
1628
+ async createClient(clientInfo) {
1629
+ const clientId = generateRandomString(16);
1630
+ const tokenEndpointAuthMethod = clientInfo.tokenEndpointAuthMethod || "client_secret_basic";
1631
+ const isPublicClient = tokenEndpointAuthMethod === "none";
1632
+ const newClient = {
1633
+ clientId,
1634
+ redirectUris: clientInfo.redirectUris || [],
1635
+ clientName: clientInfo.clientName,
1636
+ logoUri: clientInfo.logoUri,
1637
+ clientUri: clientInfo.clientUri,
1638
+ policyUri: clientInfo.policyUri,
1639
+ tosUri: clientInfo.tosUri,
1640
+ jwksUri: clientInfo.jwksUri,
1641
+ contacts: clientInfo.contacts,
1642
+ grantTypes: clientInfo.grantTypes || [
1643
+ GrantType.AUTHORIZATION_CODE,
1644
+ GrantType.REFRESH_TOKEN,
1645
+ ...this.provider.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
1646
+ ],
1647
+ responseTypes: clientInfo.responseTypes || ["code"],
1648
+ registrationDate: Math.floor(Date.now() / 1e3),
1649
+ tokenEndpointAuthMethod
1650
+ };
1651
+ for (const uri of newClient.redirectUris) validateRedirectUriScheme(uri);
1652
+ let clientSecret;
1653
+ if (!isPublicClient) {
1654
+ clientSecret = generateRandomString(32);
1655
+ newClient.clientSecret = await hashSecret(clientSecret);
1656
+ }
1657
+ await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(newClient));
1658
+ const clientResponse = { ...newClient };
1659
+ if (!isPublicClient && clientSecret) clientResponse.clientSecret = clientSecret;
1660
+ return clientResponse;
1661
+ }
1662
+ /**
1663
+ * Lists all registered OAuth clients with pagination support
1664
+ * @param options - Optional pagination parameters (limit and cursor)
1665
+ * @returns A Promise resolving to the list result with items and optional cursor
1666
+ */
1667
+ async listClients(options) {
1668
+ const listOptions = { prefix: "client:" };
1669
+ if (options?.limit !== void 0) listOptions.limit = options.limit;
1670
+ if (options?.cursor !== void 0) listOptions.cursor = options.cursor;
1671
+ const response = await this.env.OAUTH_KV.list(listOptions);
1672
+ const clients = [];
1673
+ const promises = response.keys.map(async (key) => {
1674
+ const clientId = key.name.substring(7);
1675
+ const client = await this.provider.getClient(this.env, clientId);
1676
+ if (client) clients.push(client);
1677
+ });
1678
+ await Promise.all(promises);
1679
+ return {
1680
+ items: clients,
1681
+ cursor: response.list_complete ? void 0 : response.cursor
1682
+ };
1683
+ }
1684
+ /**
1685
+ * Updates an existing OAuth client
1686
+ * @param clientId - The ID of the client to update
1687
+ * @param updates - Partial client information with fields to update
1688
+ * @returns A Promise resolving to the updated client info, or null if not found
1689
+ */
1690
+ async updateClient(clientId, updates) {
1691
+ const client = await this.provider.getClient(this.env, clientId);
1692
+ if (!client) return null;
1693
+ let authMethod = updates.tokenEndpointAuthMethod || client.tokenEndpointAuthMethod || "client_secret_basic";
1694
+ const isPublicClient = authMethod === "none";
1695
+ let secretToStore = client.clientSecret;
1696
+ let originalSecret = void 0;
1697
+ if (isPublicClient) secretToStore = void 0;
1698
+ else if (updates.clientSecret) {
1699
+ originalSecret = updates.clientSecret;
1700
+ secretToStore = await hashSecret(updates.clientSecret);
1701
+ }
1702
+ const updatedClient = {
1703
+ ...client,
1704
+ ...updates,
1705
+ clientId: client.clientId,
1706
+ tokenEndpointAuthMethod: authMethod
1707
+ };
1708
+ if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
1709
+ else delete updatedClient.clientSecret;
1710
+ await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient));
1711
+ const response = { ...updatedClient };
1712
+ if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
1713
+ return response;
1714
+ }
1715
+ /**
1716
+ * Deletes an OAuth client
1717
+ * @param clientId - The ID of the client to delete
1718
+ * @returns A Promise resolving when the deletion is confirmed.
1719
+ */
1720
+ async deleteClient(clientId) {
1721
+ await this.env.OAUTH_KV.delete(`client:${clientId}`);
1722
+ }
1723
+ /**
1724
+ * Lists all authorization grants for a specific user with pagination support
1725
+ * Returns a summary of each grant without sensitive information
1726
+ * @param userId - The ID of the user whose grants to list
1727
+ * @param options - Optional pagination parameters (limit and cursor)
1728
+ * @returns A Promise resolving to the list result with grant summaries and optional cursor
1729
+ */
1730
+ async listUserGrants(userId, options) {
1731
+ const listOptions = { prefix: `grant:${userId}:` };
1732
+ if (options?.limit !== void 0) listOptions.limit = options.limit;
1733
+ if (options?.cursor !== void 0) listOptions.cursor = options.cursor;
1734
+ const response = await this.env.OAUTH_KV.list(listOptions);
1735
+ const grantSummaries = [];
1736
+ const promises = response.keys.map(async (key) => {
1737
+ const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
1738
+ if (grantData) {
1739
+ const summary = {
1740
+ id: grantData.id,
1741
+ clientId: grantData.clientId,
1742
+ userId: grantData.userId,
1743
+ scope: grantData.scope,
1744
+ metadata: grantData.metadata,
1745
+ createdAt: grantData.createdAt,
1746
+ expiresAt: grantData.expiresAt
1747
+ };
1748
+ grantSummaries.push(summary);
1749
+ }
1750
+ });
1751
+ await Promise.all(promises);
1752
+ return {
1753
+ items: grantSummaries,
1754
+ cursor: response.list_complete ? void 0 : response.cursor
1755
+ };
1756
+ }
1757
+ /**
1758
+ * Revokes an authorization grant and all its associated access tokens
1759
+ * @param grantId - The ID of the grant to revoke
1760
+ * @param userId - The ID of the user who owns the grant
1761
+ * @returns A Promise resolving when the revocation is confirmed.
1762
+ */
1763
+ async revokeGrant(grantId, userId) {
1764
+ const grantKey = `grant:${userId}:${grantId}`;
1765
+ const tokenPrefix = `token:${userId}:${grantId}:`;
1766
+ let cursor;
1767
+ let allTokensDeleted = false;
1768
+ while (!allTokensDeleted) {
1769
+ const listOptions = { prefix: tokenPrefix };
1770
+ if (cursor) listOptions.cursor = cursor;
1771
+ const result = await this.env.OAUTH_KV.list(listOptions);
1772
+ if (result.keys.length > 0) await Promise.all(result.keys.map((key) => {
1773
+ return this.env.OAUTH_KV.delete(key.name);
1774
+ }));
1775
+ if (result.list_complete) allTokensDeleted = true;
1776
+ else cursor = result.cursor;
1777
+ }
1778
+ await this.env.OAUTH_KV.delete(grantKey);
1779
+ }
1780
+ /**
1781
+ * Decodes a token and returns token data with decrypted props
1782
+ * @param token - The token
1783
+ * @returns Promise resolving to token data with decrypted props, or null if token is invalid
1784
+ */
1785
+ async unwrapToken(token) {
1786
+ return await this.provider.unwrapToken(token, this.env);
1787
+ }
1788
+ /**
1789
+ * Exchanges an existing access token for a new one with modified characteristics
1790
+ * Implements OAuth 2.0 Token Exchange (RFC 8693)
1791
+ * @param options - Options for token exchange including subject token and optional modifications
1792
+ * @returns Promise resolving to token response with new access token
1793
+ */
1794
+ async exchangeToken(options) {
1795
+ const tokenSummary = await this.unwrapToken(options.subjectToken);
1796
+ if (!tokenSummary) throw new Error("Invalid or expired subject token");
1797
+ const clientInfo = await this.lookupClient(tokenSummary.grant.clientId);
1798
+ if (!clientInfo) throw new Error("Client not found");
1799
+ return await this.provider.exchangeToken(options.subjectToken, options.scope, options.aud, options.expiresIn, clientInfo, this.env);
1800
+ }
1692
1801
  };
1802
+ /**
1803
+ * Default export of the OAuth provider
1804
+ * This allows users to import the library and use it directly as in the example
1805
+ */
1693
1806
  var oauth_provider_default = OAuthProvider;
1694
- export {
1695
- OAuthProvider,
1696
- oauth_provider_default as default
1697
- };
1807
+
1808
+ //#endregion
1809
+ export { GrantType, OAuthProvider, oauth_provider_default as default, getOAuthApi };