@cloudflare/workers-oauth-provider 0.2.4 → 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
@@ -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" });
@@ -1610,6 +1628,15 @@ var OAuthHelpersImpl = class {
1610
1628
  if (!clientId || !redirectUri) throw new Error("Client ID and Redirect URI are required in the authorization request.");
1611
1629
  const clientInfo = await this.lookupClient(clientId);
1612
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
+ }
1613
1640
  const grantId = generateRandomString(16);
1614
1641
  const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
1615
1642
  const now = Math.floor(Date.now() / 1e3);
@@ -1658,6 +1685,9 @@ var OAuthHelpersImpl = class {
1658
1685
  fragment.set("scope", options.scope.join(" "));
1659
1686
  if (options.request.state) fragment.set("state", options.request.state);
1660
1687
  redirectUrl.hash = fragment.toString();
1688
+ try {
1689
+ await Promise.allSettled(grantsToRevoke.map((oldGrantId) => this.revokeGrant(oldGrantId, options.userId)));
1690
+ } catch {}
1661
1691
  return { redirectTo: redirectUrl.toString() };
1662
1692
  } else {
1663
1693
  const authCodeSecret = generateRandomString(32);
@@ -1683,6 +1713,9 @@ var OAuthHelpersImpl = class {
1683
1713
  const redirectUrl = new URL(options.request.redirectUri);
1684
1714
  redirectUrl.searchParams.set("code", authCode);
1685
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 {}
1686
1719
  return { redirectTo: redirectUrl.toString() };
1687
1720
  }
1688
1721
  }
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.0",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",