@cloudflare/workers-oauth-provider 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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.
@@ -94,6 +100,22 @@ export default new OAuthProvider({
94
100
  // Set to 0 to disable refresh tokens (only access tokens will be issued).
95
101
  // For example: 3600 = 1 hour, 86400 = 1 day, 2592000 = 30 days
96
102
  refreshTokenTTL: 2592000, // 30 days
103
+
104
+ // Optional: Time-to-live for access tokens in seconds.
105
+ // Defaults to 1 hour (3600 seconds) if not specified.
106
+ accessTokenTTL: 3600,
107
+
108
+ // Optional: Controls whether OAuth 2.0 Token Exchange (RFC 8693) is allowed.
109
+ // When false, the token exchange grant type will not be advertised in metadata
110
+ // and token exchange requests will be rejected.
111
+ // Defaults to false.
112
+ allowTokenExchangeGrant: false,
113
+
114
+ // Optional: Explicitly enable Client ID Metadata Document (CIMD) support.
115
+ // When true, URL-formatted client_ids will be fetched as metadata documents.
116
+ // Requires the 'global_fetch_strictly_public' compatibility flag.
117
+ // See the CIMD section below for details. Defaults to false.
118
+ clientIdMetadataDocumentEnabled: false,
97
119
  });
98
120
 
99
121
  // The default handler object - the OAuthProvider will pass through HTTP requests to this object's fetch method
@@ -304,6 +326,35 @@ new OAuthProvider({
304
326
 
305
327
  By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``.
306
328
 
329
+ ## Protected Resource Metadata (RFC 9728)
330
+
331
+ The library automatically serves a `/.well-known/oauth-protected-resource` endpoint. By default, it uses the request origin as the resource identifier and the token endpoint's origin as the authorization server. You can customize this with the `resourceMetadata` option:
332
+
333
+ ```ts
334
+ new OAuthProvider({
335
+ // ... other options ...
336
+ resourceMetadata: {
337
+ resource: 'https://api.example.com',
338
+ authorization_servers: ['https://auth.example.com'],
339
+ scopes_supported: ['read', 'write'],
340
+ bearer_methods_supported: ['header'],
341
+ resource_name: 'My API',
342
+ },
343
+ });
344
+ ```
345
+
346
+ ## Standards Compliance
347
+
348
+ This library implements the following OAuth and MCP specifications:
349
+
350
+ - [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
351
+ - [OAuth 2.0 Authorization Server Metadata (RFC 8414)](https://datatracker.ietf.org/doc/html/rfc8414) — `/.well-known/oauth-authorization-server` discovery endpoint
352
+ - [OAuth 2.0 Protected Resource Metadata (RFC 9728)](https://datatracker.ietf.org/doc/html/rfc9728) — `/.well-known/oauth-protected-resource` discovery endpoint
353
+ - [OAuth 2.0 Dynamic Client Registration (RFC 7591)](https://datatracker.ietf.org/doc/html/rfc7591) — Dynamic client registration endpoint
354
+ - [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
355
+
356
+ These are the specifications required by the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization).
357
+
307
358
  ## Implementation Notes
308
359
 
309
360
  ### End-to-end encryption
@@ -325,11 +376,22 @@ This library implements a compromise: At any particular time, a grant may have t
325
376
 
326
377
  ## Client ID Metadata Document (CIMD) Support
327
378
 
328
- This library supports [Client ID Metadata Documents](https://www.ietf.org/archive/id/draft-parecki-oauth-client-id-metadata-document-03.html), which allow clients to use HTTPS URLs as their `client_id`. When a client presents an HTTPS URL with a non-root path as its `client_id`, the library will fetch and validate the metadata document from that URL.
379
+ This library supports [Client ID Metadata Documents](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-client-id-metadata-document), which allow clients to use HTTPS URLs as their `client_id`. When a client presents an HTTPS URL with a non-root path as its `client_id`, the library will fetch and validate the metadata document from that URL.
329
380
 
330
381
  ### Enabling CIMD
331
382
 
332
- To enable CIMD support, you must add the `global_fetch_strictly_public` compatibility flag to your `wrangler.jsonc`:
383
+ CIMD support is opt-in and requires two things:
384
+
385
+ 1. Set `clientIdMetadataDocumentEnabled: true` in your OAuthProvider options:
386
+
387
+ ```ts
388
+ new OAuthProvider({
389
+ // ... other options ...
390
+ clientIdMetadataDocumentEnabled: true,
391
+ });
392
+ ```
393
+
394
+ 2. Add the `global_fetch_strictly_public` compatibility flag to your `wrangler.jsonc`:
333
395
 
334
396
  ```jsonc
335
397
  {
@@ -337,9 +399,11 @@ To enable CIMD support, you must add the `global_fetch_strictly_public` compatib
337
399
  }
338
400
  ```
339
401
 
340
- This flag is required for SSRF (Server-Side Request Forgery) protection. Due to a legacy quirk, `fetch()` requests to URLs within your zone's domain are sent directly to the origin server, bypassing Cloudflare. The `global_fetch_strictly_public` flag disables this behavior. See [Cloudflare's blog post](https://blog.cloudflare.com/workers-environment-live-object-bindings/) and [documentation](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public) for more details.
402
+ The compatibility flag is required for SSRF (Server-Side Request Forgery) protection. Due to a legacy quirk, `fetch()` requests to URLs within your zone's domain are sent directly to the origin server, bypassing Cloudflare. The `global_fetch_strictly_public` flag disables this behavior. See [Cloudflare's documentation](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public) for more details.
403
+
404
+ When CIMD is not enabled (the default), URL-formatted `client_id` values fall through to standard KV lookup. When enabled, if fetching the metadata document fails, the library logs a warning and returns an `invalid_client` error, allowing MCP clients to recover by falling back to Dynamic Client Registration.
341
405
 
342
- When this flag is not enabled, the OAuth metadata endpoint will report `client_id_metadata_document_supported: false` and MCP Clients should use DCR instead.
406
+ The OAuth metadata endpoint reports `client_id_metadata_document_supported: true` only when both the option is enabled and the compatibility flag is present.
343
407
 
344
408
  ## Written using Claude
345
409
 
@@ -13,8 +13,10 @@ declare enum GrantType {
13
13
  /**
14
14
  * Aliases for either type of Handler that makes .fetch required
15
15
  */
16
- type ExportedHandlerWithFetch = ExportedHandler & Pick<Required<ExportedHandler>, 'fetch'>;
17
- 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
+ };
18
20
  /**
19
21
  * Configuration options for the OAuth Provider
20
22
  */
@@ -119,7 +121,7 @@ interface ResolveExternalTokenResult {
119
121
  */
120
122
  audience?: string | string[];
121
123
  }
122
- interface OAuthProviderOptions {
124
+ interface OAuthProviderOptions<Env = Cloudflare.Env> {
123
125
  /**
124
126
  * URL(s) for API routes. Requests with URLs starting with any of these prefixes
125
127
  * will be treated as API requests and require a valid access token.
@@ -137,7 +139,7 @@ interface OAuthProviderOptions {
137
139
  * Used with `apiRoute` for the single-handler configuration. This is incompatible with
138
140
  * the `apiHandlers` property. You must use either `apiRoute` + `apiHandler` OR `apiHandlers`, not both.
139
141
  */
140
- apiHandler?: ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env: any) => WorkerEntrypointWithFetch);
142
+ apiHandler?: ExportedHandlerWithFetch<Env> | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch<Env>);
141
143
  /**
142
144
  * Map of API routes to their corresponding handlers for the multi-handler configuration.
143
145
  * The keys are the API routes (strings only, not arrays), and the values are the handlers.
@@ -148,12 +150,12 @@ interface OAuthProviderOptions {
148
150
  * `apiRoute` + `apiHandler` (single-handler configuration) OR `apiHandlers` (multi-handler
149
151
  * configuration), not both.
150
152
  */
151
- apiHandlers?: Record<string, ExportedHandlerWithFetch | (new (ctx: ExecutionContext, env: any) => WorkerEntrypointWithFetch)>;
153
+ apiHandlers?: Record<string, ExportedHandlerWithFetch<Env> | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch<Env>)>;
152
154
  /**
153
155
  * Handler for all non-API requests or API requests without a valid token.
154
156
  * Can be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint.
155
157
  */
156
- defaultHandler: ExportedHandler | (new (ctx: ExecutionContext, env: any) => WorkerEntrypointWithFetch);
158
+ defaultHandler: ExportedHandler<Env> | (new (ctx: ExecutionContext, env: Env) => WorkerEntrypointWithFetch<Env>);
157
159
  /**
158
160
  * URL of the OAuth authorization endpoint where users can grant permissions.
159
161
  * This URL is used in OAuth metadata and is not handled by the provider itself.
@@ -191,6 +193,13 @@ interface OAuthProviderOptions {
191
193
  * Defaults to false.
192
194
  */
193
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;
194
203
  /**
195
204
  * Controls whether OAuth 2.0 Token Exchange (RFC 8693) is allowed.
196
205
  * When false, the token exchange grant type will not be advertised in metadata
@@ -237,6 +246,47 @@ interface OAuthProviderOptions {
237
246
  status: number;
238
247
  headers: Record<string, string>;
239
248
  }) => Response | void;
249
+ /**
250
+ * Explicitly enable Client ID Metadata Document (CIMD) support.
251
+ * When true, URL-formatted client_ids will be fetched as metadata documents.
252
+ * Requires the 'global_fetch_strictly_public' compatibility flag.
253
+ * Defaults to false.
254
+ */
255
+ clientIdMetadataDocumentEnabled?: boolean;
256
+ /**
257
+ * Optional metadata for RFC 9728 OAuth 2.0 Protected Resource Metadata.
258
+ * Controls the response served at /.well-known/oauth-protected-resource.
259
+ *
260
+ * If not provided, the endpoint will be automatically generated using the request origin
261
+ * as the resource identifier, and the token endpoint's origin as the authorization server.
262
+ */
263
+ resourceMetadata?: {
264
+ /**
265
+ * The protected resource identifier URL (RFC 9728 `resource` field).
266
+ * If not set, defaults to the request URL's origin.
267
+ */
268
+ resource?: string;
269
+ /**
270
+ * List of authorization server issuer URLs that can issue tokens for this resource.
271
+ * If not set, defaults to the token endpoint's origin (consistent with the issuer
272
+ * in authorization server metadata).
273
+ */
274
+ authorization_servers?: string[];
275
+ /**
276
+ * Scopes supported by this protected resource.
277
+ * If not set, falls back to the top-level scopesSupported option.
278
+ */
279
+ scopes_supported?: string[];
280
+ /**
281
+ * Methods by which bearer tokens can be presented to this resource.
282
+ * Defaults to ["header"].
283
+ */
284
+ bearer_methods_supported?: string[];
285
+ /**
286
+ * Human-readable name for this resource.
287
+ */
288
+ resource_name?: string;
289
+ };
240
290
  }
241
291
  /**
242
292
  * Helper methods for OAuth operations provided to handler functions
@@ -467,6 +517,13 @@ interface CompleteAuthorizationOptions {
467
517
  * authorized by this grant
468
518
  */
469
519
  props: any;
520
+ /**
521
+ * Revokes all existing grants for this user+client combination
522
+ * after storing the new grant. Defaults to true. This prevents stale
523
+ * tokens from causing infinite re-auth loops when props change.
524
+ * Set to false to allow multiple concurrent grants per user+client.
525
+ */
526
+ revokeExistingGrants?: boolean;
470
527
  }
471
528
  /**
472
529
  * Authorization grant record
@@ -714,13 +771,13 @@ interface GrantSummary {
714
771
  * Implements authorization code flow with support for refresh tokens
715
772
  * and dynamic client registration.
716
773
  */
717
- declare class OAuthProvider {
774
+ declare class OAuthProvider<Env = Cloudflare.Env> {
718
775
  #private;
719
776
  /**
720
777
  * Creates a new OAuth provider instance
721
778
  * @param options - Configuration options for the provider
722
779
  */
723
- constructor(options: OAuthProviderOptions);
780
+ constructor(options: OAuthProviderOptions<Env>);
724
781
  /**
725
782
  * Main fetch handler for the Worker
726
783
  * Routes requests to the appropriate handler based on the URL
@@ -729,7 +786,7 @@ declare class OAuthProvider {
729
786
  * @param ctx - Cloudflare Worker execution context
730
787
  * @returns A Promise resolving to an HTTP Response
731
788
  */
732
- fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response>;
789
+ fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
733
790
  }
734
791
  /**
735
792
  * Gets OAuthHelpers for the given environment
@@ -737,6 +794,6 @@ declare class OAuthProvider {
737
794
  * @param env - Cloudflare Worker environment variables
738
795
  * @returns An instance of OAuthHelpers
739
796
  */
740
- declare function getOAuthApi(options: OAuthProviderOptions, env: any): OAuthHelpers;
797
+ declare function getOAuthApi<Env = Cloudflare.Env>(options: OAuthProviderOptions<Env>, env: Env): OAuthHelpers;
741
798
  //#endregion
742
799
  export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, getOAuthApi };
@@ -141,7 +141,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
141
141
  async fetch(request, env, ctx) {
142
142
  const url = new URL(request.url);
143
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, {
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, {
145
145
  status: 204,
146
146
  headers: { "Content-Length": "0" }
147
147
  }), request);
@@ -150,6 +150,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
150
150
  const response = await this.handleMetadataDiscovery(url);
151
151
  return this.addCorsHeaders(response, request);
152
152
  }
153
+ if (url.pathname === "/.well-known/oauth-protected-resource") {
154
+ const response = this.handleProtectedResourceMetadata(url);
155
+ return this.addCorsHeaders(response, request);
156
+ }
153
157
  if (this.isTokenEndpoint(url)) {
154
158
  const parsed = await this.parseTokenEndpointRequest(request, env);
155
159
  if (parsed instanceof Response) return this.addCorsHeaders(parsed, request);
@@ -287,8 +291,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
287
291
  * @returns True if the URL matches the API route
288
292
  */
289
293
  matchApiRoute(url, route) {
290
- if (this.isPath(route)) return url.pathname.startsWith(route);
291
- else {
294
+ if (this.isPath(route)) {
295
+ if (route === "/") return url.pathname === "/";
296
+ return url.pathname.startsWith(route);
297
+ } else {
292
298
  const apiUrl = new URL(route);
293
299
  return url.hostname === apiUrl.hostname && url.pathname.startsWith(apiUrl.pathname);
294
300
  }
@@ -367,9 +373,28 @@ var OAuthProviderImpl = class OAuthProviderImpl {
367
373
  "none"
368
374
  ],
369
375
  revocation_endpoint: tokenEndpoint,
370
- code_challenge_methods_supported: ["plain", "S256"],
371
- client_id_metadata_document_supported: this.hasGlobalFetchStrictlyPublic()
376
+ code_challenge_methods_supported: this.options.allowPlainPKCE !== false ? ["plain", "S256"] : ["S256"],
377
+ client_id_metadata_document_supported: !!this.options.clientIdMetadataDocumentEnabled && this.hasGlobalFetchStrictlyPublic()
378
+ };
379
+ return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
380
+ }
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"]
372
396
  };
397
+ if (rm?.resource_name) metadata.resource_name = rm.resource_name;
373
398
  return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
374
399
  }
375
400
  /**
@@ -406,12 +431,17 @@ var OAuthProviderImpl = class OAuthProviderImpl {
406
431
  const grantKey = `grant:${userId}:${grantId}`;
407
432
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
408
433
  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");
434
+ if (!grantData.authCodeId) {
435
+ try {
436
+ await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
437
+ } catch {}
438
+ return this.createErrorResponse("invalid_grant", "Authorization code already used");
439
+ }
410
440
  if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", "Invalid authorization code");
411
441
  if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
412
442
  const isPkceEnabled = !!grantData.codeChallenge;
413
443
  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");
444
+ if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
415
445
  if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier provided for a flow that did not use PKCE");
416
446
  if (isPkceEnabled) {
417
447
  if (!codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
@@ -907,7 +937,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
907
937
  registration_client_uri: `${this.options.clientRegistrationEndpoint}/${clientId}`,
908
938
  client_id_issued_at: clientInfo.registrationDate
909
939
  };
910
- if (clientSecret) response.client_secret = clientSecret;
940
+ if (clientSecret) {
941
+ response.client_secret = clientSecret;
942
+ response.client_secret_expires_at = 0;
943
+ response.client_secret_issued_at = clientInfo.registrationDate;
944
+ }
911
945
  return new Response(JSON.stringify(response), {
912
946
  status: 201,
913
947
  headers: { "Content-Type": "application/json" }
@@ -921,8 +955,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
921
955
  * @returns Response from the API handler or error
922
956
  */
923
957
  async handleApiRequest(request, env, ctx) {
958
+ const url = new URL(request.url);
959
+ const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
924
960
  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\"" });
961
+ 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") });
926
962
  const accessToken = authHeader.substring(7);
927
963
  const parts = accessToken.split(":");
928
964
  const isPossiblyInternalFormat = parts.length === 3;
@@ -934,14 +970,14 @@ var OAuthProviderImpl = class OAuthProviderImpl {
934
970
  const id = await generateTokenId(accessToken);
935
971
  tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
936
972
  }
937
- if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\"" });
973
+ if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
938
974
  if (tokenData) {
939
975
  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\"" });
976
+ if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", "Access token expired", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
941
977
  if (tokenData.audience) {
942
978
  const requestUrl = new URL(request.url);
943
979
  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\"" });
980
+ 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") });
945
981
  }
946
982
  ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
947
983
  } else if (this.options.resolveExternalToken) {
@@ -950,16 +986,15 @@ var OAuthProviderImpl = class OAuthProviderImpl {
950
986
  request,
951
987
  env
952
988
  });
953
- if (!ext) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\"" });
989
+ if (!ext) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
954
990
  if (ext.audience) {
955
991
  const requestUrl = new URL(request.url);
956
992
  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\"" });
993
+ 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") });
958
994
  }
959
995
  ctx.props = ext.props;
960
996
  }
961
997
  if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
962
- const url = new URL(request.url);
963
998
  const apiHandler = this.findApiHandlerForUrl(url);
964
999
  if (!apiHandler) return this.createErrorResponse("invalid_request", "No handler found for API route", 404);
965
1000
  if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
@@ -1000,8 +1035,17 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1000
1035
  */
1001
1036
  async getClient(env, clientId) {
1002
1037
  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);
1038
+ if (!this.options.clientIdMetadataDocumentEnabled) {
1039
+ const clientKey$1 = `client:${clientId}`;
1040
+ return env.OAUTH_KV.get(clientKey$1, { type: "json" });
1041
+ }
1042
+ if (!this.hasGlobalFetchStrictlyPublic()) throw new Error(`CIMD is enabled but 'global_fetch_strictly_public' compatibility flag is not set.`);
1043
+ try {
1044
+ return await this.fetchClientMetadataDocument(clientId);
1045
+ } catch (error) {
1046
+ console.warn(`CIMD fetch failed for ${clientId}:`, error instanceof Error ? error.message : error);
1047
+ return null;
1048
+ }
1005
1049
  }
1006
1050
  const clientKey = `client:${clientId}`;
1007
1051
  return env.OAUTH_KV.get(clientKey, { type: "json" });
@@ -1183,6 +1227,14 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1183
1227
  return JSON.parse(text);
1184
1228
  }
1185
1229
  /**
1230
+ * Builds a WWW-Authenticate header value with resource_metadata per RFC 9728 §5.1
1231
+ */
1232
+ buildWwwAuthenticateHeader(resourceMetadataUrl, error, errorDescription) {
1233
+ let header = `Bearer realm="OAuth", resource_metadata="${resourceMetadataUrl}", error="${error}"`;
1234
+ if (errorDescription) header += `, error_description="${errorDescription}"`;
1235
+ return header;
1236
+ }
1237
+ /**
1186
1238
  * Helper function to create OAuth error responses
1187
1239
  * @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
1188
1240
  * @param description - Human-readable error description
@@ -1338,6 +1390,37 @@ function validateRedirectUriScheme(redirectUri) {
1338
1390
  for (const dangerousScheme of dangerousSchemes) if (scheme === dangerousScheme) throw new Error("Invalid redirect URI");
1339
1391
  }
1340
1392
  /**
1393
+ * Checks if a URI is a loopback redirect URI (127.0.0.0/8 or ::1)
1394
+ * Per RFC 8252 Section 7.3, these get special port handling
1395
+ */
1396
+ function isLoopbackUri(uri) {
1397
+ try {
1398
+ const host = new URL(uri).hostname;
1399
+ if (host.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) return true;
1400
+ if (host === "::1" || host === "[::1]") return true;
1401
+ return false;
1402
+ } catch {
1403
+ return false;
1404
+ }
1405
+ }
1406
+ /**
1407
+ * Validates a redirect URI against registered URIs with RFC 8252 loopback support.
1408
+ * For loopback URIs (127.x.x.x, ::1), any port is allowed as long as scheme, host, path, and query match.
1409
+ * For non-loopback URIs, exact match is required.
1410
+ */
1411
+ function isValidRedirectUri(requestUri, registeredUris) {
1412
+ return registeredUris.some((registered) => {
1413
+ if (isLoopbackUri(requestUri) && isLoopbackUri(registered)) try {
1414
+ const reqUrl = new URL(requestUri);
1415
+ const regUrl = new URL(registered);
1416
+ return reqUrl.protocol === regUrl.protocol && reqUrl.hostname === regUrl.hostname && reqUrl.pathname === regUrl.pathname && reqUrl.search === regUrl.search;
1417
+ } catch {
1418
+ return false;
1419
+ }
1420
+ return requestUri === registered;
1421
+ });
1422
+ }
1423
+ /**
1341
1424
  * Encodes a string as base64url (URL-safe base64)
1342
1425
  * @param str - The string to encode
1343
1426
  * @returns The base64url encoded string
@@ -1506,11 +1589,12 @@ var OAuthHelpersImpl = class {
1506
1589
  const resource = parseResourceParameter(resourceParam);
1507
1590
  if (resourceParam && !resource) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
1508
1591
  if (responseType === "token" && !this.provider.options.allowImplicitFlow) throw new Error("The implicit grant flow is not enabled for this provider");
1592
+ if (codeChallengeMethod === "plain" && this.provider.options.allowPlainPKCE === false) throw new Error("The plain PKCE method is not allowed. Use S256 instead.");
1509
1593
  if (clientId) {
1510
1594
  const clientInfo = await this.lookupClient(clientId);
1511
1595
  if (!clientInfo) throw new Error(`Invalid client. The clientId provided does not match to this client.`);
1512
1596
  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.`);
1597
+ if (!isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error(`Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`);
1514
1598
  }
1515
1599
  }
1516
1600
  return {
@@ -1543,7 +1627,16 @@ var OAuthHelpersImpl = class {
1543
1627
  const { clientId, redirectUri } = options.request;
1544
1628
  if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
1545
1629
  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.");
1630
+ 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.");
1631
+ let grantsToRevoke = [];
1632
+ if (options.revokeExistingGrants !== false) {
1633
+ let cursor;
1634
+ do {
1635
+ const page = await this.listUserGrants(options.userId, { cursor });
1636
+ for (const grant of page.items) if (grant.clientId === clientId) grantsToRevoke.push(grant.id);
1637
+ cursor = page.cursor;
1638
+ } while (cursor);
1639
+ }
1547
1640
  const grantId = generateRandomString(16);
1548
1641
  const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
1549
1642
  const now = Math.floor(Date.now() / 1e3);
@@ -1592,6 +1685,9 @@ var OAuthHelpersImpl = class {
1592
1685
  fragment.set("scope", options.scope.join(" "));
1593
1686
  if (options.request.state) fragment.set("state", options.request.state);
1594
1687
  redirectUrl.hash = fragment.toString();
1688
+ try {
1689
+ await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
1690
+ } catch {}
1595
1691
  return { redirectTo: redirectUrl.toString() };
1596
1692
  } else {
1597
1693
  const authCodeSecret = generateRandomString(32);
@@ -1617,6 +1713,9 @@ var OAuthHelpersImpl = class {
1617
1713
  const redirectUrl = new URL(options.request.redirectUri);
1618
1714
  redirectUrl.searchParams.set("code", authCode);
1619
1715
  if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
1716
+ try {
1717
+ await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
1718
+ } catch {}
1620
1719
  return { redirectTo: redirectUrl.toString() };
1621
1720
  }
1622
1721
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",