@cloudflare/workers-oauth-provider 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -81,6 +81,12 @@ export default new OAuthProvider({
81
81
  // Defaults to false.
82
82
  allowImplicitFlow: false,
83
83
 
84
+ // Optional: Controls whether the plain PKCE code_challenge_method is allowed.
85
+ // OAuth 2.1 recommends using S256 exclusively as plain offers no cryptographic protection.
86
+ // When false, only S256 is accepted and advertised in the metadata endpoint.
87
+ // Defaults to true for backward compatibility.
88
+ allowPlainPKCE: true,
89
+
84
90
  // Optional: Controls whether public clients (clients without a secret, like SPAs)
85
91
  // can register via the dynamic client registration endpoint.
86
92
  // When true, only confidential clients can register.
@@ -304,6 +310,18 @@ new OAuthProvider({
304
310
 
305
311
  By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``.
306
312
 
313
+ ## Standards Compliance
314
+
315
+ This library implements the following OAuth and MCP specifications:
316
+
317
+ - [OAuth 2.1 (draft-ietf-oauth-v2-1-13)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13) — Core authorization framework with PKCE
318
+ - [OAuth 2.0 Authorization Server Metadata (RFC 8414)](https://datatracker.ietf.org/doc/html/rfc8414) — `/.well-known/oauth-authorization-server` discovery endpoint
319
+ - [OAuth 2.0 Protected Resource Metadata (RFC 9728)](https://datatracker.ietf.org/doc/html/rfc9728) — `/.well-known/oauth-protected-resource` discovery endpoint
320
+ - [OAuth 2.0 Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591) — Dynamic client registration endpoint
321
+ - [OAuth Client ID Metadata Documents (draft-ietf-oauth-client-id-metadata-document)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document) — HTTPS URLs as client IDs
322
+
323
+ These are the specifications required by the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
324
+
307
325
  ## Implementation Notes
308
326
 
309
327
  ### End-to-end encryption
@@ -2,11 +2,21 @@ import { WorkerEntrypoint } from "cloudflare:workers";
2
2
 
3
3
  //#region src/oauth-provider.d.ts
4
4
 
5
+ /**
6
+ * Enum representing OAuth grant types
7
+ */
8
+ declare enum GrantType {
9
+ AUTHORIZATION_CODE = "authorization_code",
10
+ REFRESH_TOKEN = "refresh_token",
11
+ TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange",
12
+ }
5
13
  /**
6
14
  * Aliases for either type of Handler that makes .fetch required
7
15
  */
8
- type ExportedHandlerWithFetch = ExportedHandler & Pick<Required<ExportedHandler>, 'fetch'>;
9
- type WorkerEntrypointWithFetch = WorkerEntrypoint & Pick<Required<WorkerEntrypoint>, 'fetch'>;
16
+ type ExportedHandlerWithFetch<Env = Cloudflare.Env> = ExportedHandler<Env> & Pick<Required<ExportedHandler<Env>>, 'fetch'>;
17
+ type WorkerEntrypointWithFetch<Env = Cloudflare.Env> = WorkerEntrypoint<Env> & {
18
+ fetch: NonNullable<WorkerEntrypoint['fetch']>;
19
+ };
10
20
  /**
11
21
  * Configuration options for the OAuth Provider
12
22
  */
@@ -42,6 +52,11 @@ interface TokenExchangeCallbackResult {
42
52
  * refresh token exchange, it will be ignored.
43
53
  */
44
54
  refreshTokenTTL?: number;
55
+ /**
56
+ * List of scopes authorized for the new access token
57
+ * (If undefined, the granted scopes will be used)
58
+ */
59
+ accessTokenScope?: string[];
45
60
  }
46
61
  /**
47
62
  * Options for token exchange callback functions
@@ -49,10 +64,8 @@ interface TokenExchangeCallbackResult {
49
64
  interface TokenExchangeCallbackOptions {
50
65
  /**
51
66
  * The type of grant being processed.
52
- * 'authorization_code' for initial code exchange,
53
- * 'refresh_token' for refresh token exchange.
54
67
  */
55
- grantType: 'authorization_code' | 'refresh_token';
68
+ grantType: GrantType;
56
69
  /**
57
70
  * Client that received this grant
58
71
  */
@@ -65,6 +78,11 @@ interface TokenExchangeCallbackOptions {
65
78
  * List of scopes that were granted
66
79
  */
67
80
  scope: string[];
81
+ /**
82
+ * List of scopes that were requested for this token by the client
83
+ * (Will be the same as granted scopes unless client specifically requested a downscoping)
84
+ */
85
+ requestedScope: string[];
68
86
  /**
69
87
  * Application-specific properties currently associated with this grant
70
88
  */
@@ -103,7 +121,7 @@ interface ResolveExternalTokenResult {
103
121
  */
104
122
  audience?: string | string[];
105
123
  }
106
- interface OAuthProviderOptions {
124
+ interface OAuthProviderOptions<Env = Cloudflare.Env> {
107
125
  /**
108
126
  * URL(s) for API routes. Requests with URLs starting with any of these prefixes
109
127
  * will be treated as API requests and require a valid access token.
@@ -121,7 +139,7 @@ interface OAuthProviderOptions {
121
139
  * Used with `apiRoute` for the single-handler configuration. This is incompatible with
122
140
  * the `apiHandlers` property. You must use either `apiRoute` + `apiHandler` OR `apiHandlers`, not both.
123
141
  */
124
- apiHandler?: ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env: any) => WorkerEntrypointWithFetch);
142
+ apiHandler?: ExportedHandlerWithFetch<Env> | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch<Env>);
125
143
  /**
126
144
  * Map of API routes to their corresponding handlers for the multi-handler configuration.
127
145
  * The keys are the API routes (strings only, not arrays), and the values are the handlers.
@@ -132,12 +150,12 @@ interface OAuthProviderOptions {
132
150
  * `apiRoute` + `apiHandler` (single-handler configuration) OR `apiHandlers` (multi-handler
133
151
  * configuration), not both.
134
152
  */
135
- apiHandlers?: Record<string, ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env: any) => WorkerEntrypointWithFetch)>;
153
+ apiHandlers?: Record<string, ExportedHandlerWithFetch<Env> | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch<Env>)>;
136
154
  /**
137
155
  * Handler for all non-API requests or API requests without a valid token.
138
156
  * Can be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint.
139
157
  */
140
- defaultHandler: ExportedHandler | (new (ctx: ExecutionContext, env: any) => WorkerEntrypointWithFetch);
158
+ defaultHandler: ExportedHandler<Env> | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch<Env>);
141
159
  /**
142
160
  * URL of the OAuth authorization endpoint where users can grant permissions.
143
161
  * This URL is used in OAuth metadata and is not handled by the provider itself.
@@ -175,6 +193,20 @@ interface OAuthProviderOptions {
175
193
  * Defaults to false.
176
194
  */
177
195
  allowImplicitFlow?: boolean;
196
+ /**
197
+ * Controls whether the plain PKCE method is allowed.
198
+ * OAuth 2.1 recommends using S256 exclusively as plain offers no cryptographic protection.
199
+ * When set to false, only the S256 code_challenge_method will be accepted.
200
+ * Defaults to true for backward compatibility.
201
+ */
202
+ allowPlainPKCE?: boolean;
203
+ /**
204
+ * Controls whether OAuth 2.0 Token Exchange (RFC 8693) is allowed.
205
+ * When false, the token exchange grant type will not be advertised in metadata
206
+ * and token exchange requests will be rejected.
207
+ * Defaults to false.
208
+ */
209
+ allowTokenExchangeGrant?: boolean;
178
210
  /**
179
211
  * Controls whether public clients (clients without a secret, like SPAs) can register via the
180
212
  * dynamic client registration endpoint. When true, only confidential clients can register.
@@ -214,6 +246,40 @@ interface OAuthProviderOptions {
214
246
  status: number;
215
247
  headers: Record<string, string>;
216
248
  }) => Response | void;
249
+ /**
250
+ * Optional metadata for RFC 9728 OAuth 2.0 Protected Resource Metadata.
251
+ * Controls the response served at /.well-known/oauth-protected-resource.
252
+ *
253
+ * If not provided, the endpoint will be automatically generated using the request origin
254
+ * as the resource identifier, and the token endpoint's origin as the authorization server.
255
+ */
256
+ resourceMetadata?: {
257
+ /**
258
+ * The protected resource identifier URL (RFC 9728 `resource` field).
259
+ * If not set, defaults to the request URL's origin.
260
+ */
261
+ resource?: string;
262
+ /**
263
+ * List of authorization server issuer URLs that can issue tokens for this resource.
264
+ * If not set, defaults to the token endpoint's origin (consistent with the issuer
265
+ * in authorization server metadata).
266
+ */
267
+ authorization_servers?: string[];
268
+ /**
269
+ * Scopes supported by this protected resource.
270
+ * If not set, falls back to the top-level scopesSupported option.
271
+ */
272
+ scopes_supported?: string[];
273
+ /**
274
+ * Methods by which bearer tokens can be presented to this resource.
275
+ * Defaults to ["header"].
276
+ */
277
+ bearer_methods_supported?: string[];
278
+ /**
279
+ * Human-readable name for this resource.
280
+ */
281
+ resource_name?: string;
282
+ };
217
283
  }
218
284
  /**
219
285
  * Helper methods for OAuth operations provided to handler functions
@@ -285,6 +351,34 @@ interface OAuthHelpers {
285
351
  * @returns Promise resolving to token data with decrypted props, or null if token is invalid
286
352
  */
287
353
  unwrapToken<T = any>(token: string): Promise<TokenSummary<T> | null>;
354
+ /**
355
+ * Exchanges an existing access token for a new one with modified characteristics
356
+ * Implements OAuth 2.0 Token Exchange (RFC 8693)
357
+ * @param options - Options for token exchange including subject token and optional modifications
358
+ * @returns Promise resolving to token response with new access token
359
+ */
360
+ exchangeToken(options: ExchangeTokenOptions): Promise<TokenResponse>;
361
+ }
362
+ /**
363
+ * Options for token exchange operations (RFC 8693)
364
+ */
365
+ interface ExchangeTokenOptions {
366
+ /**
367
+ * The subject token to exchange (existing access token)
368
+ */
369
+ subjectToken: string;
370
+ /**
371
+ * Optional narrowed set of scopes for the new token (must be subset of original grant scopes)
372
+ */
373
+ scope?: string[];
374
+ /**
375
+ * Optional target audience/resource for the new token (maps to resource parameter per RFC 8707)
376
+ */
377
+ aud?: string | string[];
378
+ /**
379
+ * Optional TTL override for the new token in seconds (must not exceed subject token's remaining lifetime)
380
+ */
381
+ expiresIn?: number;
288
382
  }
289
383
  /**
290
384
  * Parsed OAuth authorization request parameters
@@ -496,6 +590,22 @@ interface Grant {
496
590
  */
497
591
  resource?: string | string[];
498
592
  }
593
+ /**
594
+ * OAuth 2.0 Token Response
595
+ * The response returned when exchanging authorization codes or refresh tokens
596
+ */
597
+ interface TokenResponse {
598
+ access_token: string;
599
+ token_type: 'bearer';
600
+ expires_in: number;
601
+ refresh_token?: string;
602
+ scope: string;
603
+ /**
604
+ * Resource indicator(s) for the issued access token (RFC 8707 Section 2.2)
605
+ * SHOULD be included to indicate the resource server(s) for which the token is valid
606
+ */
607
+ resource?: string | string[];
608
+ }
499
609
  /**
500
610
  * Shared fields for Token and TokenSummary
501
611
  */
@@ -525,6 +635,10 @@ interface TokenBase {
525
635
  * Can be a single string or array of strings
526
636
  */
527
637
  audience?: string | string[];
638
+ /**
639
+ * List of scopes on this token
640
+ */
641
+ scope: string[];
528
642
  }
529
643
  /**
530
644
  * Token record stored in KV
@@ -643,13 +757,13 @@ interface GrantSummary {
643
757
  * Implements authorization code flow with support for refresh tokens
644
758
  * and dynamic client registration.
645
759
  */
646
- declare class OAuthProvider {
760
+ declare class OAuthProvider<Env = Cloudflare.Env> {
647
761
  #private;
648
762
  /**
649
763
  * Creates a new OAuth provider instance
650
764
  * @param options - Configuration options for the provider
651
765
  */
652
- constructor(options: OAuthProviderOptions);
766
+ constructor(options: OAuthProviderOptions<Env>);
653
767
  /**
654
768
  * Main fetch handler for the Worker
655
769
  * Routes requests to the appropriate handler based on the URL
@@ -658,7 +772,14 @@ declare class OAuthProvider {
658
772
  * @param ctx - Cloudflare Worker execution context
659
773
  * @returns A Promise resolving to an HTTP Response
660
774
  */
661
- fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response>;
775
+ fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
662
776
  }
777
+ /**
778
+ * Gets OAuthHelpers for the given environment
779
+ * @param options - Configuration options for the OAuth provider
780
+ * @param env - Cloudflare Worker environment variables
781
+ * @returns An instance of OAuthHelpers
782
+ */
783
+ declare function getOAuthApi<Env = Cloudflare.Env>(options: OAuthProviderOptions<Env>, env: Env): OAuthHelpers;
663
784
  //#endregion
664
- export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, Grant, GrantSummary, ListOptions, ListResult, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary };
785
+ export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, getOAuthApi };
@@ -11,6 +11,15 @@ var HandlerType = /* @__PURE__ */ function(HandlerType$1) {
11
11
  return HandlerType$1;
12
12
  }(HandlerType || {});
13
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
+ /**
14
23
  * OAuth 2.0 Provider implementation for Cloudflare Workers
15
24
  * Implements authorization code flow with support for refresh tokens
16
25
  * and dynamic client registration.
@@ -37,6 +46,15 @@ var OAuthProvider = class {
37
46
  }
38
47
  };
39
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
+ /**
40
58
  * Implementation class backing OAuthProvider.
41
59
  *
42
60
  * We use a PImpl pattern in `OAuthProvider` to make sure we don't inadvertently export any private
@@ -123,7 +141,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
123
141
  async fetch(request, env, ctx) {
124
142
  const url = new URL(request.url);
125
143
  if (request.method === "OPTIONS") {
126
- 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, {
144
+ if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || url.pathname === "/.well-known/oauth-protected-resource" || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
127
145
  status: 204,
128
146
  headers: { "Content-Length": "0" }
129
147
  }), request);
@@ -132,6 +150,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
132
150
  const response = await this.handleMetadataDiscovery(url);
133
151
  return this.addCorsHeaders(response, request);
134
152
  }
153
+ if (url.pathname === "/.well-known/oauth-protected-resource") {
154
+ const response = this.handleProtectedResourceMetadata(url);
155
+ return this.addCorsHeaders(response, request);
156
+ }
135
157
  if (this.isTokenEndpoint(url)) {
136
158
  const parsed = await this.parseTokenEndpointRequest(request, env);
137
159
  if (parsed instanceof Response) return this.addCorsHeaders(parsed, request);
@@ -176,6 +198,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
176
198
  createdAt: tokenData.createdAt,
177
199
  expiresAt: tokenData.expiresAt,
178
200
  audience: tokenData.audience,
201
+ scope: tokenData.scope || grant.scope,
179
202
  grant: {
180
203
  clientId: grant.clientId,
181
204
  scope: grant.scope,
@@ -268,8 +291,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
268
291
  * @returns True if the URL matches the API route
269
292
  */
270
293
  matchApiRoute(url, route) {
271
- if (this.isPath(route)) return url.pathname.startsWith(route);
272
- else {
294
+ if (this.isPath(route)) {
295
+ if (route === "/") return url.pathname === "/";
296
+ return url.pathname.startsWith(route);
297
+ } else {
273
298
  const apiUrl = new URL(route);
274
299
  return url.hostname === apiUrl.hostname && url.pathname.startsWith(apiUrl.pathname);
275
300
  }
@@ -331,6 +356,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
331
356
  if (this.options.clientRegistrationEndpoint) registrationEndpoint = this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, requestUrl);
332
357
  const responseTypesSupported = ["code"];
333
358
  if (this.options.allowImplicitFlow) responseTypesSupported.push("token");
359
+ const grantTypesSupported = [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN];
360
+ if (this.options.allowTokenExchangeGrant) grantTypesSupported.push(GrantType.TOKEN_EXCHANGE);
334
361
  const metadata = {
335
362
  issuer: new URL(tokenEndpoint).origin,
336
363
  authorization_endpoint: authorizeEndpoint,
@@ -339,19 +366,38 @@ var OAuthProviderImpl = class OAuthProviderImpl {
339
366
  scopes_supported: this.options.scopesSupported,
340
367
  response_types_supported: responseTypesSupported,
341
368
  response_modes_supported: ["query"],
342
- grant_types_supported: ["authorization_code", "refresh_token"],
369
+ grant_types_supported: grantTypesSupported,
343
370
  token_endpoint_auth_methods_supported: [
344
371
  "client_secret_basic",
345
372
  "client_secret_post",
346
373
  "none"
347
374
  ],
348
375
  revocation_endpoint: tokenEndpoint,
349
- code_challenge_methods_supported: ["plain", "S256"],
376
+ code_challenge_methods_supported: this.options.allowPlainPKCE !== false ? ["plain", "S256"] : ["S256"],
350
377
  client_id_metadata_document_supported: this.hasGlobalFetchStrictlyPublic()
351
378
  };
352
379
  return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
353
380
  }
354
381
  /**
382
+ * Handles the OAuth Protected Resource Metadata endpoint
383
+ * Implements RFC 9728 for OAuth Protected Resource Metadata
384
+ * @param requestUrl - The URL of the incoming request
385
+ * @returns Response with protected resource metadata
386
+ */
387
+ handleProtectedResourceMetadata(requestUrl) {
388
+ const rm = this.options.resourceMetadata;
389
+ const tokenEndpointUrl = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
390
+ const authServerOrigin = new URL(tokenEndpointUrl).origin;
391
+ const metadata = {
392
+ resource: rm?.resource ?? requestUrl.origin,
393
+ authorization_servers: rm?.authorization_servers ?? [authServerOrigin],
394
+ scopes_supported: rm?.scopes_supported ?? this.options.scopesSupported,
395
+ bearer_methods_supported: rm?.bearer_methods_supported ?? ["header"]
396
+ };
397
+ if (rm?.resource_name) metadata.resource_name = rm.resource_name;
398
+ return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
399
+ }
400
+ /**
355
401
  * Handles client authentication and token issuance via the token endpoint
356
402
  * Supports authorization_code and refresh_token grant types
357
403
  * @param body - The parsed request body
@@ -361,8 +407,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
361
407
  */
362
408
  async handleTokenRequest(body, clientInfo, env) {
363
409
  const grantType = body.grant_type;
364
- if (grantType === "authorization_code") return this.handleAuthorizationCodeGrant(body, clientInfo, env);
365
- else if (grantType === "refresh_token") return this.handleRefreshTokenGrant(body, clientInfo, env);
410
+ if (grantType === GrantType.AUTHORIZATION_CODE) return this.handleAuthorizationCodeGrant(body, clientInfo, env);
411
+ else if (grantType === GrantType.REFRESH_TOKEN) return this.handleRefreshTokenGrant(body, clientInfo, env);
412
+ else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return this.handleTokenExchangeGrant(body, clientInfo, env);
366
413
  else return this.createErrorResponse("unsupported_grant_type", "Grant type not supported");
367
414
  }
368
415
  /**
@@ -389,7 +436,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
389
436
  if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
390
437
  const isPkceEnabled = !!grantData.codeChallenge;
391
438
  if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
392
- if (redirectUri && !clientInfo.redirectUris.includes(redirectUri)) return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
439
+ if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
393
440
  if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier provided for a flow that did not use PKCE");
394
441
  if (isPkceEnabled) {
395
442
  if (!codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
@@ -402,23 +449,23 @@ var OAuthProviderImpl = class OAuthProviderImpl {
402
449
  } else calculatedChallenge = codeVerifier;
403
450
  if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
404
451
  }
405
- const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
406
- const accessTokenId = await generateTokenId(accessToken);
407
452
  let accessTokenTTL = this.options.accessTokenTTL;
408
453
  let refreshTokenTTL = this.options.refreshTokenTTL;
409
454
  const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
410
455
  let grantEncryptionKey = encryptionKey;
411
456
  let accessTokenEncryptionKey = encryptionKey;
412
457
  let encryptedAccessTokenProps = grantData.encryptedProps;
458
+ let tokenScopes = this.downscope(body.scope, grantData.scope);
413
459
  if (this.options.tokenExchangeCallback) {
414
460
  const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
415
461
  let grantProps = decryptedProps;
416
462
  let accessTokenProps = decryptedProps;
417
463
  const callbackOptions = {
418
- grantType: "authorization_code",
464
+ grantType: GrantType.AUTHORIZATION_CODE,
419
465
  clientId: clientInfo.clientId,
420
466
  userId,
421
467
  scope: grantData.scope,
468
+ requestedScope: tokenScopes,
422
469
  props: decryptedProps
423
470
  };
424
471
  const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
@@ -430,6 +477,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
430
477
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
431
478
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
432
479
  if ("refreshTokenTTL" in callbackResult) refreshTokenTTL = callbackResult.refreshTokenTTL;
480
+ if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
433
481
  }
434
482
  const grantResult = await encryptProps(grantProps);
435
483
  grantData.encryptedProps = grantResult.encryptedData;
@@ -444,9 +492,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
444
492
  }
445
493
  }
446
494
  const now = Math.floor(Date.now() / 1e3);
447
- const accessTokenExpiresAt = now + accessTokenTTL;
448
495
  const useRefreshToken = refreshTokenTTL !== 0;
449
- const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, accessTokenEncryptionKey);
450
496
  delete grantData.authCodeId;
451
497
  delete grantData.codeChallenge;
452
498
  delete grantData.codeChallengeMethod;
@@ -471,26 +517,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
471
517
  }
472
518
  const audience = parseResourceParameter(body.resource || grantData.resource);
473
519
  if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
474
- const accessTokenData = {
475
- id: accessTokenId,
476
- grantId,
477
- userId,
478
- createdAt: now,
479
- expiresAt: accessTokenExpiresAt,
480
- audience,
481
- wrappedEncryptionKey: accessTokenWrappedKey,
482
- grant: {
483
- clientId: grantData.clientId,
484
- scope: grantData.scope,
485
- encryptedProps: encryptedAccessTokenProps
486
- }
487
- };
488
- await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
489
520
  const tokenResponse = {
490
- access_token: accessToken,
521
+ access_token: await this.createAccessToken({
522
+ userId,
523
+ grantId,
524
+ clientId: grantData.clientId,
525
+ scope: tokenScopes,
526
+ encryptedProps: encryptedAccessTokenProps,
527
+ encryptionKey: accessTokenEncryptionKey,
528
+ expiresIn: accessTokenTTL,
529
+ audience,
530
+ env
531
+ }),
491
532
  token_type: "bearer",
492
533
  expires_in: accessTokenTTL,
493
- scope: grantData.scope.join(" ")
534
+ scope: tokenScopes.join(" ")
494
535
  };
495
536
  if (refreshToken) tokenResponse.refresh_token = refreshToken;
496
537
  if (audience) tokenResponse.resource = audience;
@@ -531,16 +572,18 @@ var OAuthProviderImpl = class OAuthProviderImpl {
531
572
  let grantEncryptionKey = encryptionKey;
532
573
  let accessTokenEncryptionKey = encryptionKey;
533
574
  let encryptedAccessTokenProps = grantData.encryptedProps;
575
+ let tokenScopes = this.downscope(body.scope, grantData.scope);
534
576
  let grantPropsChanged = false;
535
577
  if (this.options.tokenExchangeCallback) {
536
578
  const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
537
579
  let grantProps = decryptedProps;
538
580
  let accessTokenProps = decryptedProps;
539
581
  const callbackOptions = {
540
- grantType: "refresh_token",
582
+ grantType: GrantType.REFRESH_TOKEN,
541
583
  clientId: clientInfo.clientId,
542
584
  userId,
543
585
  scope: grantData.scope,
586
+ requestedScope: tokenScopes,
544
587
  props: decryptedProps
545
588
  };
546
589
  const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
@@ -553,6 +596,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
553
596
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
554
597
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
555
598
  if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", "refreshTokenTTL cannot be changed during refresh token exchange");
599
+ if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
556
600
  }
557
601
  if (grantPropsChanged) {
558
602
  const grantResult = await encryptProps(grantProps);
@@ -600,6 +644,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
600
644
  createdAt: now,
601
645
  expiresAt: accessTokenExpiresAt,
602
646
  audience,
647
+ scope: tokenScopes,
603
648
  wrappedEncryptionKey: accessTokenWrappedKey,
604
649
  grant: {
605
650
  clientId: grantData.clientId,
@@ -613,12 +658,139 @@ var OAuthProviderImpl = class OAuthProviderImpl {
613
658
  token_type: "bearer",
614
659
  expires_in: accessTokenTTL,
615
660
  refresh_token: newRefreshToken,
616
- scope: grantData.scope.join(" ")
661
+ scope: tokenScopes.join(" ")
617
662
  };
618
663
  if (audience) tokenResponse.resource = audience;
619
664
  return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
620
665
  }
621
666
  /**
667
+ * Core token exchange logic (RFC 8693)
668
+ * Performs the actual token exchange operation
669
+ * This method is not private because `OAuthHelpers` needs to call it. Note that since
670
+ * `OAuthProviderImpl` is not exposed outside this module, this is still effectively
671
+ * module-private.
672
+ * @param subjectToken - The subject token to exchange
673
+ * @param requestedScopes - Optional narrowed scopes (must be subset of original)
674
+ * @param requestedResource - Optional resource/audience (must be subset of original if original had resource)
675
+ * @param expiresIn - Optional TTL override in seconds
676
+ * @param clientInfo - The client making the exchange request
677
+ * @param env - Cloudflare Worker environment variables
678
+ * @returns Promise resolving to token response
679
+ * @throws OAuthError with OAuth error code and description
680
+ */
681
+ async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
682
+ const tokenSummary = await this.unwrapToken(subjectToken, env);
683
+ if (!tokenSummary) throw new OAuthError("invalid_grant", "Invalid or expired subject token");
684
+ const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
685
+ const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
686
+ if (!grantData) throw new OAuthError("invalid_grant", "Grant not found");
687
+ let tokenScopes = this.downscope(requestedScopes, grantData.scope);
688
+ let newAudience = tokenSummary.audience;
689
+ if (requestedResource) {
690
+ if (grantData.resource) {
691
+ const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
692
+ const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
693
+ for (const requested of requestedResources) if (!grantedResources.includes(requested)) throw new OAuthError("invalid_target", "Requested resource was not included in the authorization request");
694
+ }
695
+ const parsedResource = parseResourceParameter(requestedResource);
696
+ if (!parsedResource) throw new OAuthError("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
697
+ newAudience = parsedResource;
698
+ }
699
+ const now = Math.floor(Date.now() / 1e3);
700
+ const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
701
+ let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
702
+ if (expiresIn !== void 0) {
703
+ if (expiresIn <= 0) throw new OAuthError("invalid_request", "Invalid expires_in parameter");
704
+ accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
705
+ } else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
706
+ const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
707
+ if (!subjectTokenData) throw new OAuthError("invalid_grant", "Subject token data not found");
708
+ const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
709
+ let accessTokenEncryptionKey = encryptionKey;
710
+ let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
711
+ if (this.options.tokenExchangeCallback) {
712
+ const decryptedProps = await decryptProps(encryptionKey, subjectTokenData.grant.encryptedProps);
713
+ const callbackOptions = {
714
+ grantType: GrantType.TOKEN_EXCHANGE,
715
+ clientId: clientInfo.clientId,
716
+ userId: tokenSummary.userId,
717
+ scope: tokenSummary.grant.scope,
718
+ requestedScope: tokenScopes,
719
+ props: decryptedProps
720
+ };
721
+ const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
722
+ if (callbackResult) {
723
+ let accessTokenProps = decryptedProps;
724
+ if (callbackResult.newProps) {
725
+ if (!callbackResult.accessTokenProps) accessTokenProps = callbackResult.newProps;
726
+ }
727
+ if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
728
+ if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = Math.min(callbackResult.accessTokenTTL, subjectTokenRemainingLifetime);
729
+ if (accessTokenProps !== decryptedProps) {
730
+ const tokenResult = await encryptProps(accessTokenProps);
731
+ encryptedAccessTokenProps = tokenResult.encryptedData;
732
+ accessTokenEncryptionKey = tokenResult.key;
733
+ }
734
+ if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
735
+ }
736
+ }
737
+ const tokenResponse = {
738
+ access_token: await this.createAccessToken({
739
+ userId: tokenSummary.userId,
740
+ grantId: tokenSummary.grantId,
741
+ clientId: tokenSummary.grant.clientId,
742
+ scope: tokenScopes,
743
+ encryptedProps: encryptedAccessTokenProps,
744
+ encryptionKey: accessTokenEncryptionKey,
745
+ expiresIn: accessTokenTTL,
746
+ audience: newAudience,
747
+ env
748
+ }),
749
+ issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
750
+ token_type: "bearer",
751
+ expires_in: accessTokenTTL,
752
+ scope: tokenScopes.join(" ")
753
+ };
754
+ if (newAudience) tokenResponse.resource = newAudience;
755
+ return tokenResponse;
756
+ }
757
+ /**
758
+ * Handles OAuth 2.0 token exchange requests (RFC 8693)
759
+ * Exchanges an existing access token for a new one with modified characteristics
760
+ * @param body - The parsed request body
761
+ * @param clientInfo - The authenticated client information
762
+ * @param env - Cloudflare Worker environment variables
763
+ * @returns Response with new token data or error
764
+ */
765
+ async handleTokenExchangeGrant(body, clientInfo, env) {
766
+ const subjectToken = body.subject_token;
767
+ const subjectTokenType = body.subject_token_type;
768
+ const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
769
+ const requestedScope = body.scope;
770
+ const requestedResource = body.resource;
771
+ if (!subjectToken) return this.createErrorResponse("invalid_request", "subject_token is required");
772
+ if (!subjectTokenType) return this.createErrorResponse("invalid_request", "subject_token_type is required");
773
+ if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token subject_token_type is supported");
774
+ if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token requested_token_type is supported");
775
+ let requestedScopes;
776
+ if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
777
+ else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
778
+ else return this.createErrorResponse("invalid_request", "Invalid scope parameter format");
779
+ let expiresIn;
780
+ if (body.expires_in !== void 0) {
781
+ const requestedTTL = parseInt(body.expires_in, 10);
782
+ if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", "Invalid expires_in parameter");
783
+ expiresIn = requestedTTL;
784
+ }
785
+ try {
786
+ const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
787
+ return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
788
+ } catch (error) {
789
+ if (error instanceof OAuthError) return this.createErrorResponse(error.code, error.message);
790
+ throw error;
791
+ }
792
+ }
793
+ /**
622
794
  * Handles OAuth 2.0 token revocation requests (RFC 7009)
623
795
  * @param body - The parsed request body containing revocation parameters
624
796
  * @param env - Cloudflare Worker environment variables
@@ -730,7 +902,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
730
902
  tosUri: OAuthProviderImpl.validateStringField(clientMetadata.tos_uri),
731
903
  jwksUri: OAuthProviderImpl.validateStringField(clientMetadata.jwks_uri),
732
904
  contacts: OAuthProviderImpl.validateStringArray(clientMetadata.contacts),
733
- grantTypes: OAuthProviderImpl.validateStringArray(clientMetadata.grant_types) || ["authorization_code", "refresh_token"],
905
+ grantTypes: OAuthProviderImpl.validateStringArray(clientMetadata.grant_types) || [
906
+ GrantType.AUTHORIZATION_CODE,
907
+ GrantType.REFRESH_TOKEN,
908
+ ...this.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
909
+ ],
734
910
  responseTypes: OAuthProviderImpl.validateStringArray(clientMetadata.response_types) || ["code"],
735
911
  registrationDate: Math.floor(Date.now() / 1e3),
736
912
  tokenEndpointAuthMethod: authMethod
@@ -770,8 +946,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
770
946
  * @returns Response from the API handler or error
771
947
  */
772
948
  async handleApiRequest(request, env, ctx) {
949
+ const url = new URL(request.url);
950
+ const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
773
951
  const authHeader = request.headers.get("Authorization");
774
- 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\"" });
952
+ if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Missing or invalid access token") });
775
953
  const accessToken = authHeader.substring(7);
776
954
  const parts = accessToken.split(":");
777
955
  const isPossiblyInternalFormat = parts.length === 3;
@@ -783,14 +961,14 @@ var OAuthProviderImpl = class OAuthProviderImpl {
783
961
  const id = await generateTokenId(accessToken);
784
962
  tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
785
963
  }
786
- if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\"" });
964
+ if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
787
965
  if (tokenData) {
788
966
  const now = Math.floor(Date.now() / 1e3);
789
- if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", "Access token expired", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\"" });
967
+ if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", "Access token expired", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
790
968
  if (tokenData.audience) {
791
969
  const requestUrl = new URL(request.url);
792
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`;
793
- 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\"" });
970
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
971
+ 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": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") });
794
972
  }
795
973
  ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
796
974
  } else if (this.options.resolveExternalToken) {
@@ -799,16 +977,15 @@ var OAuthProviderImpl = class OAuthProviderImpl {
799
977
  request,
800
978
  env
801
979
  });
802
- if (!ext) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\"" });
980
+ if (!ext) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
803
981
  if (ext.audience) {
804
982
  const requestUrl = new URL(request.url);
805
- const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`;
806
- 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\"" });
983
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
984
+ 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": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") });
807
985
  }
808
986
  ctx.props = ext.props;
809
987
  }
810
988
  if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
811
- const url = new URL(request.url);
812
989
  const apiHandler = this.findApiHandlerForUrl(url);
813
990
  if (!apiHandler) return this.createErrorResponse("invalid_request", "No handler found for API route", 404);
814
991
  if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
@@ -856,6 +1033,45 @@ var OAuthProviderImpl = class OAuthProviderImpl {
856
1033
  return env.OAUTH_KV.get(clientKey, { type: "json" });
857
1034
  }
858
1035
  /**
1036
+ * Creates and stores an access token
1037
+ * @param params - Options for creating the access token
1038
+ * @returns The access token string
1039
+ */
1040
+ async createAccessToken(params) {
1041
+ const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
1042
+ const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
1043
+ const now = Math.floor(Date.now() / 1e3);
1044
+ const accessTokenId = await generateTokenId(accessToken);
1045
+ const accessTokenData = {
1046
+ id: accessTokenId,
1047
+ grantId,
1048
+ userId,
1049
+ createdAt: now,
1050
+ expiresAt: now + expiresIn,
1051
+ audience,
1052
+ scope,
1053
+ wrappedEncryptionKey: await wrapKeyWithToken(accessToken, encryptionKey),
1054
+ grant: {
1055
+ clientId,
1056
+ scope,
1057
+ encryptedProps
1058
+ }
1059
+ };
1060
+ await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: expiresIn });
1061
+ return accessToken;
1062
+ }
1063
+ /**
1064
+ * Downscopes requested scopes to only include those that are in the grant
1065
+ * Filters out any requested scopes that are not in the granted scopes
1066
+ * @param requestedScope - The scope parameter from the request (string or array)
1067
+ * @param grantedScopes - The scopes that were granted in the authorization
1068
+ * @returns The filtered scopes that are a subset of the granted scopes
1069
+ */
1070
+ downscope(requestedScope, grantedScopes) {
1071
+ if (!requestedScope) return grantedScopes;
1072
+ return (typeof requestedScope === "string" ? requestedScope.split(" ").filter(Boolean) : requestedScope).filter((scope) => grantedScopes.includes(scope));
1073
+ }
1074
+ /**
859
1075
  * Checks if the global_fetch_strictly_public compatibility flag is enabled.
860
1076
  * This flag is required for CIMD to prevent SSRF attacks.
861
1077
  * See: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public
@@ -993,6 +1209,14 @@ var OAuthProviderImpl = class OAuthProviderImpl {
993
1209
  return JSON.parse(text);
994
1210
  }
995
1211
  /**
1212
+ * Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
1213
+ */
1214
+ buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription) {
1215
+ let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}", error="${error}"`;
1216
+ if (errorDescription) header += `, error_description="${errorDescription}"`;
1217
+ return header;
1218
+ }
1219
+ /**
996
1220
  * Helper function to create OAuth error responses
997
1221
  * @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
998
1222
  * @param description - Human-readable error description
@@ -1022,6 +1246,17 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1022
1246
  }
1023
1247
  };
1024
1248
  /**
1249
+ * Error class for OAuth operations
1250
+ * Carries OAuth error code and description for proper error responses
1251
+ */
1252
+ var OAuthError = class extends Error {
1253
+ constructor(code, message) {
1254
+ super(message);
1255
+ this.code = code;
1256
+ this.name = "OAuthError";
1257
+ }
1258
+ };
1259
+ /**
1025
1260
  * Default expiration time for access tokens (1 hour in seconds)
1026
1261
  */
1027
1262
  const DEFAULT_ACCESS_TOKEN_TTL = 3600;
@@ -1047,14 +1282,23 @@ function validateResourceUri(uri) {
1047
1282
  }
1048
1283
  }
1049
1284
  /**
1050
- * Checks if a resource server matches an audience claim
1051
- * RFC 7519 Section 4.1.3: audience values are case-sensitive strings
1285
+ * Checks if a resource server matches an audience claim.
1286
+ * Uses origin comparison (case-insensitive hostname via URL normalization)
1287
+ * and path-prefix matching on path boundaries for RFC 8707 resource indicators.
1052
1288
  * @param resourceServerUrl - The resource server URL (from request)
1053
1289
  * @param audienceValue - The audience value from token
1054
1290
  * @returns true if they match, false otherwise
1055
1291
  */
1056
1292
  function audienceMatches(resourceServerUrl, audienceValue) {
1057
- return resourceServerUrl === audienceValue;
1293
+ try {
1294
+ const resource = new URL(resourceServerUrl);
1295
+ const audience = new URL(audienceValue);
1296
+ if (resource.origin !== audience.origin) return false;
1297
+ if (audience.pathname === "/" || audience.pathname === "") return true;
1298
+ return resource.pathname === audience.pathname || resource.pathname.startsWith(audience.pathname + "/");
1299
+ } catch {
1300
+ return false;
1301
+ }
1058
1302
  }
1059
1303
  /**
1060
1304
  * Parses and validates the resource parameter from a token request (RFC 8707)
@@ -1128,6 +1372,37 @@ function validateRedirectUriScheme(redirectUri) {
1128
1372
  for (const dangerousScheme of dangerousSchemes) if (scheme === dangerousScheme) throw new Error("Invalid redirect URI");
1129
1373
  }
1130
1374
  /**
1375
+ * Checks if a URI is a loopback redirect URI (127.0.0.0/8 or ::1)
1376
+ * Per RFC 8252 Section 7.3, these get special port handling
1377
+ */
1378
+ function isLoopbackUri(uri) {
1379
+ try {
1380
+ const host = new URL(uri).hostname;
1381
+ if (host.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) return true;
1382
+ if (host === "::1" || host === "[::1]") return true;
1383
+ return false;
1384
+ } catch {
1385
+ return false;
1386
+ }
1387
+ }
1388
+ /**
1389
+ * Validates a redirect URI against registered URIs with RFC 8252 loopback support.
1390
+ * For loopback URIs (127.x.x.x, ::1), any port is allowed as long as scheme, host, path, and query match.
1391
+ * For non-loopback URIs, exact match is required.
1392
+ */
1393
+ function isValidRedirectUri(requestUri, registeredUris) {
1394
+ return registeredUris.some((registered) => {
1395
+ if (isLoopbackUri(requestUri) && isLoopbackUri(registered)) try {
1396
+ const reqUrl = new URL(requestUri);
1397
+ const regUrl = new URL(registered);
1398
+ return reqUrl.protocol === regUrl.protocol && reqUrl.hostname === regUrl.hostname && reqUrl.pathname === regUrl.pathname && reqUrl.search === regUrl.search;
1399
+ } catch {
1400
+ return false;
1401
+ }
1402
+ return requestUri === registered;
1403
+ });
1404
+ }
1405
+ /**
1131
1406
  * Encodes a string as base64url (URL-safe base64)
1132
1407
  * @param str - The string to encode
1133
1408
  * @returns The base64url encoded string
@@ -1296,11 +1571,12 @@ var OAuthHelpersImpl = class {
1296
1571
  const resource = parseResourceParameter(resourceParam);
1297
1572
  if (resourceParam && !resource) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
1298
1573
  if (responseType === "token" && !this.provider.options.allowImplicitFlow) throw new Error("The implicit grant flow is not enabled for this provider");
1574
+ if (codeChallengeMethod === "plain" && this.provider.options.allowPlainPKCE === false) throw new Error("The plain PKCE method is not allowed. Use S256 instead.");
1299
1575
  if (clientId) {
1300
1576
  const clientInfo = await this.lookupClient(clientId);
1301
1577
  if (!clientInfo) throw new Error(`Invalid client. The clientId provided does not match to this client.`);
1302
1578
  if (clientInfo && redirectUri) {
1303
- if (!clientInfo.redirectUris.includes(redirectUri)) throw new Error(`Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`);
1579
+ if (!isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error(`Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`);
1304
1580
  }
1305
1581
  }
1306
1582
  return {
@@ -1333,7 +1609,7 @@ var OAuthHelpersImpl = class {
1333
1609
  const { clientId, redirectUri } = options.request;
1334
1610
  if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
1335
1611
  const clientInfo = await this.lookupClient(clientId);
1336
- 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.");
1612
+ if (!clientInfo || !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error("Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.");
1337
1613
  const grantId = generateRandomString(16);
1338
1614
  const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
1339
1615
  const now = Math.floor(Date.now() / 1e3);
@@ -1365,6 +1641,7 @@ var OAuthHelpersImpl = class {
1365
1641
  createdAt: now,
1366
1642
  expiresAt: accessTokenExpiresAt,
1367
1643
  audience,
1644
+ scope: options.scope,
1368
1645
  wrappedEncryptionKey: accessTokenWrappedKey,
1369
1646
  grant: {
1370
1647
  clientId: options.request.clientId,
@@ -1428,7 +1705,11 @@ var OAuthHelpersImpl = class {
1428
1705
  tosUri: clientInfo.tosUri,
1429
1706
  jwksUri: clientInfo.jwksUri,
1430
1707
  contacts: clientInfo.contacts,
1431
- grantTypes: clientInfo.grantTypes || ["authorization_code", "refresh_token"],
1708
+ grantTypes: clientInfo.grantTypes || [
1709
+ GrantType.AUTHORIZATION_CODE,
1710
+ GrantType.REFRESH_TOKEN,
1711
+ ...this.provider.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
1712
+ ],
1432
1713
  responseTypes: clientInfo.responseTypes || ["code"],
1433
1714
  registrationDate: Math.floor(Date.now() / 1e3),
1434
1715
  tokenEndpointAuthMethod
@@ -1570,6 +1851,19 @@ var OAuthHelpersImpl = class {
1570
1851
  async unwrapToken(token) {
1571
1852
  return await this.provider.unwrapToken(token, this.env);
1572
1853
  }
1854
+ /**
1855
+ * Exchanges an existing access token for a new one with modified characteristics
1856
+ * Implements OAuth 2.0 Token Exchange (RFC 8693)
1857
+ * @param options - Options for token exchange including subject token and optional modifications
1858
+ * @returns Promise resolving to token response with new access token
1859
+ */
1860
+ async exchangeToken(options) {
1861
+ const tokenSummary = await this.unwrapToken(options.subjectToken);
1862
+ if (!tokenSummary) throw new Error("Invalid or expired subject token");
1863
+ const clientInfo = await this.lookupClient(tokenSummary.grant.clientId);
1864
+ if (!clientInfo) throw new Error("Client not found");
1865
+ return await this.provider.exchangeToken(options.subjectToken, options.scope, options.aud, options.expiresIn, clientInfo, this.env);
1866
+ }
1573
1867
  };
1574
1868
  /**
1575
1869
  * Default export of the OAuth provider
@@ -1578,4 +1872,4 @@ var OAuthHelpersImpl = class {
1578
1872
  var oauth_provider_default = OAuthProvider;
1579
1873
 
1580
1874
  //#endregion
1581
- export { OAuthProvider, oauth_provider_default as default };
1875
+ export { GrantType, OAuthProvider, oauth_provider_default as default, getOAuthApi };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",
@@ -31,7 +31,6 @@
31
31
  "pkg-pr-new": "^0.0.62",
32
32
  "prettier": "^3.7.4",
33
33
  "tsdown": "^0.18.1",
34
- "tsx": "^4.21.0",
35
34
  "typescript": "^5.9.3",
36
35
  "vitest": "^3.2.4"
37
36
  },