@cloudflare/workers-oauth-provider 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -100,6 +100,22 @@ export default new OAuthProvider({
100
100
  // Set to 0 to disable refresh tokens (only access tokens will be issued).
101
101
  // For example: 3600 = 1 hour, 86400 = 1 day, 2592000 = 30 days
102
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,
103
119
  });
104
120
 
105
121
  // The default handler object - the OAuthProvider will pass through HTTP requests to this object's fetch method
@@ -310,6 +326,23 @@ new OAuthProvider({
310
326
 
311
327
  By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``.
312
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
+
313
346
  ## Standards Compliance
314
347
 
315
348
  This library implements the following OAuth and MCP specifications:
@@ -343,11 +376,22 @@ This library implements a compromise: At any particular time, a grant may have t
343
376
 
344
377
  ## Client ID Metadata Document (CIMD) Support
345
378
 
346
- 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.
347
380
 
348
381
  ### Enabling CIMD
349
382
 
350
- 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`:
351
395
 
352
396
  ```jsonc
353
397
  {
@@ -355,9 +399,11 @@ To enable CIMD support, you must add the `global_fetch_strictly_public` compatib
355
399
  }
356
400
  ```
357
401
 
358
- 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.
359
405
 
360
- 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.
361
407
 
362
408
  ## Written using Claude
363
409
 
@@ -246,6 +246,13 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
246
246
  status: number;
247
247
  headers: Record<string, string>;
248
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;
249
256
  /**
250
257
  * Optional metadata for RFC 9728 OAuth 2.0 Protected Resource Metadata.
251
258
  * Controls the response served at /.well-known/oauth-protected-resource.
@@ -510,6 +517,13 @@ interface CompleteAuthorizationOptions {
510
517
  * authorized by this grant
511
518
  */
512
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;
513
527
  }
514
528
  /**
515
529
  * Authorization grant record
@@ -374,7 +374,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
374
374
  ],
375
375
  revocation_endpoint: tokenEndpoint,
376
376
  code_challenge_methods_supported: this.options.allowPlainPKCE !== false ? ["plain", "S256"] : ["S256"],
377
- client_id_metadata_document_supported: this.hasGlobalFetchStrictlyPublic()
377
+ client_id_metadata_document_supported: !!this.options.clientIdMetadataDocumentEnabled && this.hasGlobalFetchStrictlyPublic()
378
378
  };
379
379
  return new Response(JSON.stringify(metadata), { headers: { "Content-Type": "application/json" } });
380
380
  }
@@ -431,7 +431,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
431
431
  const grantKey = `grant:${userId}:${grantId}`;
432
432
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
433
433
  if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
434
- 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
+ }
435
440
  if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", "Invalid authorization code");
436
441
  if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
437
442
  const isPkceEnabled = !!grantData.codeChallenge;
@@ -932,7 +937,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
932
937
  registration_client_uri: `${this.options.clientRegistrationEndpoint}/${clientId}`,
933
938
  client_id_issued_at: clientInfo.registrationDate
934
939
  };
935
- 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
+ }
936
945
  return new Response(JSON.stringify(response), {
937
946
  status: 201,
938
947
  headers: { "Content-Type": "application/json" }
@@ -1026,8 +1035,17 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1026
1035
  */
1027
1036
  async getClient(env, clientId) {
1028
1037
  if (this.isClientMetadataUrl(clientId)) {
1029
- 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.`);
1030
- 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
+ }
1031
1049
  }
1032
1050
  const clientKey = `client:${clientId}`;
1033
1051
  return env.OAUTH_KV.get(clientKey, { type: "json" });
@@ -1372,14 +1390,16 @@ function validateRedirectUriScheme(redirectUri) {
1372
1390
  for (const dangerousScheme of dangerousSchemes) if (scheme === dangerousScheme) throw new Error("Invalid redirect URI");
1373
1391
  }
1374
1392
  /**
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
1393
+ * Checks if a URI is a loopback redirect URI (127.0.0.0/8, ::1, or localhost).
1394
+ * Per RFC 8252 Section 7.3, loopback IPs get special port handling. This library
1395
+ * applies the same port flexibility to localhost for native apps (e.g., Claude Code).
1377
1396
  */
1378
1397
  function isLoopbackUri(uri) {
1379
1398
  try {
1380
1399
  const host = new URL(uri).hostname;
1381
1400
  if (host.match(/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) return true;
1382
1401
  if (host === "::1" || host === "[::1]") return true;
1402
+ if (host.toLowerCase() === "localhost") return true;
1383
1403
  return false;
1384
1404
  } catch {
1385
1405
  return false;
@@ -1387,7 +1407,7 @@ function isLoopbackUri(uri) {
1387
1407
  }
1388
1408
  /**
1389
1409
  * 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.
1410
+ * For loopback URIs (127.x.x.x, ::1, localhost), any port is allowed as long as scheme, host, path, and query match.
1391
1411
  * For non-loopback URIs, exact match is required.
1392
1412
  */
1393
1413
  function isValidRedirectUri(requestUri, registeredUris) {
@@ -1610,6 +1630,15 @@ var OAuthHelpersImpl = class {
1610
1630
  if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
1611
1631
  const clientInfo = await this.lookupClient(clientId);
1612
1632
  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.");
1633
+ let grantsToRevoke = [];
1634
+ if (options.revokeExistingGrants !== false) {
1635
+ let cursor;
1636
+ do {
1637
+ const page = await this.listUserGrants(options.userId, { cursor });
1638
+ for (const grant of page.items) if (grant.clientId === clientId) grantsToRevoke.push(grant.id);
1639
+ cursor = page.cursor;
1640
+ } while (cursor);
1641
+ }
1613
1642
  const grantId = generateRandomString(16);
1614
1643
  const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
1615
1644
  const now = Math.floor(Date.now() / 1e3);
@@ -1658,6 +1687,9 @@ var OAuthHelpersImpl = class {
1658
1687
  fragment.set("scope", options.scope.join(" "));
1659
1688
  if (options.request.state) fragment.set("state", options.request.state);
1660
1689
  redirectUrl.hash = fragment.toString();
1690
+ try {
1691
+ await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
1692
+ } catch {}
1661
1693
  return { redirectTo: redirectUrl.toString() };
1662
1694
  } else {
1663
1695
  const authCodeSecret = generateRandomString(32);
@@ -1683,6 +1715,9 @@ var OAuthHelpersImpl = class {
1683
1715
  const redirectUrl = new URL(options.request.redirectUri);
1684
1716
  redirectUrl.searchParams.set("code", authCode);
1685
1717
  if (options.request.state) redirectUrl.searchParams.set("state", options.request.state);
1718
+ try {
1719
+ await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
1720
+ } catch {}
1686
1721
  return { redirectTo: redirectUrl.toString() };
1687
1722
  }
1688
1723
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",