@cloudflare/workers-oauth-provider 0.7.1 → 0.8.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
@@ -229,6 +229,15 @@ class ApiHandler extends WorkerEntrypoint {
229
229
  }
230
230
  ```
231
231
 
232
+ By default, `completeAuthorization()` revokes existing grants for the same user and client after storing the new
233
+ grant. This prevents stale tokens from continuing to use old `props` after a user re-authorizes. Set
234
+ `revokeExistingGrants: false` only if your application intentionally allows multiple concurrent grants for the same
235
+ user and client.
236
+
237
+ For users with many grants, `revokeExistingGrantsBatchSize` controls the KV page size used while scanning existing
238
+ grants for revocation. It defaults to `50`, must be a positive integer, and is capped at Cloudflare KV's maximum page
239
+ size of `1000`.
240
+
232
241
  This implementation requires that your worker is configured with a Workers KV namespace binding called `OAUTH_KV`, which is used to store token information. See the file `storage-schema.md` for details on the schema of this namespace.
233
242
 
234
243
  The `env.OAUTH_PROVIDER` object available to the fetch handlers provides some methods to query the storage, including:
@@ -390,6 +399,19 @@ Setup:
390
399
 
391
400
  The AS enforces `resolved.issuer === iss` (confused-deputy guard) and validates ID-JAG `typ`, signature, audience, client binding, resource, `exp` / `iat` / `nbf`, max lifetime, and `jti` replay. Refresh tokens are not issued for this grant — the ID-JAG itself is the renewable assertion.
392
401
 
402
+ ### Public clients
403
+
404
+ By default the EMA grant requires client authentication, so public clients (`token_endpoint_auth_method: 'none'`) are rejected. Set `allowPublicClients: true` to also accept them:
405
+
406
+ ```ts
407
+ enterpriseManagedAuthorization: {
408
+ allowPublicClients: true,
409
+ // ... trustedIssuers, mapClaims ...
410
+ }
411
+ ```
412
+
413
+ This is useful for clients registered via a [Client ID Metadata Document (CIMD)](https://modelcontextprotocol.io/), which are always public and therefore cannot present a client secret. With this enabled, trust rests on the IdP-issued, signature-verified, short-lived, single-use ID-JAG assertion (audience-, resource-, and client-bound) rather than on a separately presented client secret. Leave it unset (default `false`) to keep the spec-default behavior of requiring client authentication.
414
+
393
415
  Experimental — the MCP extension is still a draft.
394
416
 
395
417
  ## Custom Error Responses
@@ -269,6 +269,22 @@ interface EmaOptions<Env = Cloudflare.Env> {
269
269
  clockSkewSeconds?: number;
270
270
  /** Maximum accepted assertion lifetime in seconds. Defaults to 300 seconds. */
271
271
  maxAssertionLifetimeSeconds?: number;
272
+ /**
273
+ * Allow public clients (`token_endpoint_auth_method: 'none'`) to use the
274
+ * enterprise-managed authorization (ID-JAG) grant.
275
+ *
276
+ * Defaults to `false`. By default the EMA grant requires client
277
+ * authentication, matching the MCP enterprise-managed-authorization draft.
278
+ *
279
+ * Set to `true` to also accept public clients on this grant — for example
280
+ * clients registered via a Client ID Metadata Document (CIMD), which are
281
+ * always public (`none`) and therefore cannot present a client secret. The
282
+ * security trade-off is documented in the README: the trust then rests on
283
+ * the IdP-issued, signature-verified, short-lived, single-use ID-JAG
284
+ * assertion (audience-, resource-, and client-bound) rather than on a
285
+ * separately presented client secret.
286
+ */
287
+ allowPublicClients?: boolean;
272
288
  }
273
289
  //#endregion
274
290
  //#region src/oauth-provider.d.ts
@@ -377,6 +393,48 @@ interface TokenExchangeCallbackOptions {
377
393
  */
378
394
  props: any;
379
395
  }
396
+ /**
397
+ * Options for the client registration callback (RFC 7591).
398
+ */
399
+ interface ClientRegistrationCallbackOptions {
400
+ /**
401
+ * Parsed client metadata from the registration request body.
402
+ *
403
+ * Note: This is the raw JSON body. RFC 7591 §3.1.1 `software_statement` claims
404
+ * are NOT currently merged in by the library — if `software_statement` is present
405
+ * the callback is responsible for verifying the JWT and applying its claims.
406
+ */
407
+ clientMetadata: Record<string, unknown>;
408
+ /**
409
+ * A clone of the registration HTTP request. The body has not been consumed,
410
+ * so the callback may call `request.text()` / `request.json()` if needed
411
+ * (e.g. to validate a signature over the raw body).
412
+ */
413
+ request: Request;
414
+ }
415
+ /**
416
+ * Result of the client registration callback.
417
+ *
418
+ * Return `undefined`/nothing to allow registration. Return an object to reject
419
+ * registration. By default, rejection follows RFC 7591 §3.2.2:
420
+ * `invalid_client_metadata` with HTTP 400.
421
+ */
422
+ interface ClientRegistrationCallbackResult {
423
+ /**
424
+ * OAuth error code when rejecting. Defaults to `invalid_client_metadata`.
425
+ * For non-metadata rejections (e.g. missing initial access token, untrusted
426
+ * origin), set this to a more specific code such as `access_denied` or
427
+ * `invalid_token`.
428
+ */
429
+ code?: string;
430
+ /** Error description when rejecting. */
431
+ description?: string;
432
+ /**
433
+ * HTTP status code when rejecting. Defaults to 400. Override for auth-style
434
+ * failures (e.g. 401 for missing IAT, 403 for policy denial).
435
+ */
436
+ status?: number;
437
+ }
380
438
  /**
381
439
  * Input parameters for the resolveExternalToken callback function
382
440
  */
@@ -522,6 +580,11 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
522
580
  * Defaults to false.
523
581
  */
524
582
  disallowPublicClientRegistration?: boolean;
583
+ /**
584
+ * Called during DCR (RFC 7591) before the client is stored. Return void/undefined
585
+ * to allow registration, or return an object to reject it.
586
+ */
587
+ clientRegistrationCallback?: (options: ClientRegistrationCallbackOptions) => Promise<ClientRegistrationCallbackResult | void> | ClientRegistrationCallbackResult | void;
525
588
  /**
526
589
  * Optional callback function that is called during token exchange.
527
590
  * This allows updating the props stored in both the access token and the grant.
@@ -880,6 +943,13 @@ interface CompleteAuthorizationOptions {
880
943
  * Set to false to allow multiple concurrent grants per user+client.
881
944
  */
882
945
  revokeExistingGrants?: boolean;
946
+ /**
947
+ * Maximum number of grants to fetch per page when revoking existing
948
+ * grants. Only used when revokeExistingGrants is not false.
949
+ * Must be a positive integer. Values above Cloudflare KV's 1000-key page
950
+ * limit are clamped to 1000. Defaults to 50.
951
+ */
952
+ revokeExistingGrantsBatchSize?: number;
883
953
  }
884
954
  /**
885
955
  * Authorization grant record
@@ -936,12 +1006,15 @@ interface Grant {
936
1006
  previousRefreshTokenWrappedKey?: string;
937
1007
  /**
938
1008
  * The hash of the authorization code associated with this grant
939
- * Only present during the authorization code exchange process
1009
+ * Retained after exchange so that a replay of the same code can be
1010
+ * verified before any action is taken on the grant. The code is
1011
+ * considered already exchanged once authCodeWrappedKey is removed.
940
1012
  */
941
1013
  authCodeId?: string;
942
1014
  /**
943
1015
  * Wrapped encryption key for the authorization code
944
- * Only present during the authorization code exchange process
1016
+ * Present only until the authorization code is exchanged; its absence
1017
+ * (with authCodeId still set) marks the code as already used.
945
1018
  */
946
1019
  authCodeWrappedKey?: string;
947
1020
  /**
@@ -1319,4 +1392,4 @@ declare function getJwtCryptoAlgorithms(alg: string): {
1319
1392
  verifyAlgorithm: Parameters<SubtleCrypto['verify']>[0];
1320
1393
  };
1321
1394
  //#endregion
1322
- export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, type EmaClaimsMapper, type EmaClaimsMapperInput, type EmaClaimsMapperResult, type EmaIdJagClaims, type EmaOptions, type EmaTrustedIssuer, type EmaTrustedIssuerResolver, type EmaTrustedIssuerResolverInput, type EmaValidationError, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthError, OAuthErrorOptions, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthTokenErrorCode, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, base64UrlToBytes, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
1395
+ export { AuthRequest, ClientInfo, ClientRegistrationCallbackOptions, ClientRegistrationCallbackResult, CompleteAuthorizationOptions, type EmaClaimsMapper, type EmaClaimsMapperInput, type EmaClaimsMapperResult, type EmaIdJagClaims, type EmaOptions, type EmaTrustedIssuer, type EmaTrustedIssuerResolver, type EmaTrustedIssuerResolverInput, type EmaValidationError, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthError, OAuthErrorOptions, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthTokenErrorCode, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, base64UrlToBytes, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
@@ -672,6 +672,10 @@ function readNumericDateClaim(claims, claimName) {
672
672
  //#endregion
673
673
  //#region src/oauth-provider.ts
674
674
  const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
675
+ const NO_CACHE_HEADERS = {
676
+ "Cache-Control": "no-store",
677
+ Pragma: "no-cache"
678
+ };
675
679
  if (!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags?.global_fetch_strictly_public === true)) console.warn("CIMD (Client ID Metadata Document) is disabled: add '\"compatibility_flags\": [\"global_fetch_strictly_public\"]' to your wrangler.jsonc to enable. See: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public");
676
680
  /**
677
681
  * Enum representing the type of handler (ExportedHandler or WorkerEntrypoint)
@@ -865,7 +869,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
865
869
  const parsed = await this.parseTokenEndpointRequest(request, env);
866
870
  if (parsed instanceof Response) return this.addCorsHeaders(parsed, request);
867
871
  let response;
868
- if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, env);
872
+ if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, parsed.clientInfo, env);
869
873
  else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
870
874
  return this.addCorsHeaders(response, request);
871
875
  }
@@ -989,17 +993,35 @@ var OAuthProviderImpl = class OAuthProviderImpl {
989
993
  statusCode: 400
990
994
  });
991
995
  const formData = await request.formData();
996
+ const processedKeys = /* @__PURE__ */ new Set();
992
997
  for (const [key, value] of formData.entries()) {
998
+ if (processedKeys.has(key)) continue;
999
+ processedKeys.add(key);
993
1000
  const allValues = formData.getAll(key);
1001
+ if (key !== "resource" && allValues.length > 1) return this.createErrorResponse("invalid_request", {
1002
+ description: `Request parameter "${key}" must not be repeated`,
1003
+ statusCode: 400
1004
+ });
994
1005
  body[key] = allValues.length > 1 ? allValues : value;
995
1006
  }
996
1007
  const authHeader = request.headers.get("Authorization");
997
1008
  let clientId = "";
998
1009
  let clientSecret = "";
999
1010
  if (authHeader && authHeader.startsWith("Basic ")) {
1000
- const [id, secret] = atob(authHeader.substring(6)).split(":", 2);
1001
- clientId = decodeURIComponent(id);
1002
- clientSecret = decodeURIComponent(secret || "");
1011
+ if (body.client_id || body.client_secret) return this.createErrorResponse("invalid_request", {
1012
+ description: "Client must not use multiple authentication methods",
1013
+ statusCode: 400
1014
+ });
1015
+ const credentials = atob(authHeader.substring(6));
1016
+ const separatorIndex = credentials.indexOf(":");
1017
+ if (separatorIndex === -1) return this.createErrorResponse("invalid_client", {
1018
+ description: "Client authentication failed: invalid Basic credentials",
1019
+ statusCode: 401
1020
+ });
1021
+ const id = credentials.substring(0, separatorIndex);
1022
+ const secret = credentials.substring(separatorIndex + 1);
1023
+ clientId = decodeFormUrlEncodedComponent(id);
1024
+ clientSecret = decodeFormUrlEncodedComponent(secret);
1003
1025
  } else {
1004
1026
  clientId = body.client_id;
1005
1027
  clientSecret = body.client_secret || "";
@@ -1126,7 +1148,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1126
1148
  registration_endpoint: registrationEndpoint,
1127
1149
  scopes_supported: this.options.scopesSupported,
1128
1150
  response_types_supported: responseTypesSupported,
1129
- response_modes_supported: ["query"],
1151
+ response_modes_supported: this.options.allowImplicitFlow ? ["query", "fragment"] : ["query"],
1130
1152
  grant_types_supported: grantTypesSupported,
1131
1153
  ...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
1132
1154
  token_endpoint_auth_methods_supported: [
@@ -1214,14 +1236,15 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1214
1236
  const grantKey = `grant:${userId}:${grantId}`;
1215
1237
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
1216
1238
  if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" });
1217
- if (!grantData.authCodeId) {
1239
+ const codeHash = await hashSecret(code);
1240
+ if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" });
1241
+ if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
1242
+ if (!grantData.authCodeWrappedKey) {
1218
1243
  try {
1219
1244
  await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
1220
1245
  } catch {}
1221
1246
  return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
1222
1247
  }
1223
- if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" });
1224
- if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
1225
1248
  const isPkceEnabled = !!grantData.codeChallenge;
1226
1249
  if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
1227
1250
  if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
@@ -1282,7 +1305,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1282
1305
  }
1283
1306
  const now = Math.floor(Date.now() / 1e3);
1284
1307
  const useRefreshToken = refreshTokenTTL !== 0;
1285
- delete grantData.authCodeId;
1286
1308
  delete grantData.codeChallenge;
1287
1309
  delete grantData.codeChallengeMethod;
1288
1310
  delete grantData.authCodeWrappedKey;
@@ -1325,7 +1347,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1325
1347
  };
1326
1348
  if (refreshToken) tokenResponse.refresh_token = refreshToken;
1327
1349
  if (audience) tokenResponse.resource = audience;
1328
- return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
1350
+ return new Response(JSON.stringify(tokenResponse), { headers: {
1351
+ "Content-Type": "application/json",
1352
+ ...NO_CACHE_HEADERS
1353
+ } });
1329
1354
  }
1330
1355
  /**
1331
1356
  * Handles the refresh token grant type
@@ -1458,7 +1483,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1458
1483
  scope: tokenScopes.join(" ")
1459
1484
  };
1460
1485
  if (audience) tokenResponse.resource = audience;
1461
- return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
1486
+ return new Response(JSON.stringify(tokenResponse), { headers: {
1487
+ "Content-Type": "application/json",
1488
+ ...NO_CACHE_HEADERS
1489
+ } });
1462
1490
  }
1463
1491
  /**
1464
1492
  * Core token exchange logic (RFC 8693)
@@ -1583,7 +1611,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1583
1611
  }
1584
1612
  try {
1585
1613
  const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
1586
- return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
1614
+ return new Response(JSON.stringify(tokenResponse), { headers: {
1615
+ "Content-Type": "application/json",
1616
+ ...NO_CACHE_HEADERS
1617
+ } });
1587
1618
  } catch (error) {
1588
1619
  const response = this.createOAuthErrorResponse(error);
1589
1620
  if (response) return response;
@@ -1601,7 +1632,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1601
1632
  async handleJwtBearerGrant(body, clientInfo, env, requestUrl, request) {
1602
1633
  const enterpriseOptions = this.options.enterpriseManagedAuthorization;
1603
1634
  if (!enterpriseOptions) return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
1604
- if (clientInfo.tokenEndpointAuthMethod === "none") return this.createErrorResponse("invalid_client", {
1635
+ if (clientInfo.tokenEndpointAuthMethod === "none" && !enterpriseOptions.allowPublicClients) return this.createErrorResponse("invalid_client", {
1605
1636
  description: "Enterprise-managed authorization requires client authentication",
1606
1637
  statusCode: 401
1607
1638
  });
@@ -1613,16 +1644,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1613
1644
  request,
1614
1645
  enterpriseOptions
1615
1646
  });
1616
- const noCacheHeaders = {
1617
- "Cache-Control": "no-store",
1618
- Pragma: "no-cache"
1619
- };
1620
1647
  if (!result.ok) {
1621
1648
  const wire = emaErrorToWire(result.error);
1622
- return this.createErrorResponse(wire.code, {
1623
- description: wire.message,
1624
- headers: noCacheHeaders
1625
- }, {
1649
+ return this.createErrorResponse(wire.code, { description: wire.message }, {
1626
1650
  category: "enterprise-managed-authorization",
1627
1651
  reason: result.error.reason,
1628
1652
  detail: result.error
@@ -1630,7 +1654,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1630
1654
  }
1631
1655
  return new Response(JSON.stringify(result.value), { headers: {
1632
1656
  "Content-Type": "application/json",
1633
- ...noCacheHeaders
1657
+ ...NO_CACHE_HEADERS
1634
1658
  } });
1635
1659
  }
1636
1660
  /**
@@ -1807,29 +1831,55 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1807
1831
  * @param env - Cloudflare Worker environment variables
1808
1832
  * @returns Response confirming revocation or error
1809
1833
  */
1810
- async handleRevocationRequest(body, env) {
1811
- return this.revokeToken(body, env);
1834
+ async handleRevocationRequest(body, clientInfo, env) {
1835
+ return this.revokeToken(body, clientInfo, env);
1812
1836
  }
1813
1837
  /**
1814
1838
  * - Access tokens: Revokes only the specific token
1815
1839
  * - Refresh tokens: Revokes the entire grant (access + refresh tokens)
1840
+ * Per RFC 7009 §2.1, the server MUST verify the token was issued to the client making the request.
1816
1841
  * @param body - The parsed request body containing token parameter
1842
+ * @param clientInfo - The authenticated client information
1817
1843
  * @param env - Cloudflare Worker environment variables
1818
1844
  * @returns Response confirming revocation or error
1819
1845
  */
1820
- async revokeToken(body, env) {
1846
+ async revokeToken(body, clientInfo, env) {
1821
1847
  const token = body.token;
1848
+ const tokenTypeHint = body.token_type_hint;
1822
1849
  if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" });
1823
1850
  const tokenParts = token.split(":");
1824
1851
  if (tokenParts.length !== 3) return new Response("", { status: 200 });
1825
1852
  const [userId, grantId, _] = tokenParts;
1826
1853
  const tokenId = await generateTokenId(token);
1827
- const isAccessToken = await this.validateAccessToken(tokenId, userId, grantId, env);
1828
- const isRefreshToken = await this.validateRefreshToken(tokenId, userId, grantId, env);
1829
- if (isAccessToken) await this.revokeSpecificAccessToken(tokenId, userId, grantId, env);
1830
- else if (isRefreshToken) await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
1854
+ if (tokenTypeHint === "refresh_token") {
1855
+ if (await this.revokeRefreshIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
1856
+ if (await this.revokeAccessIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
1857
+ } else {
1858
+ if (await this.revokeAccessIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
1859
+ if (await this.revokeRefreshIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
1860
+ }
1831
1861
  return new Response("", { status: 200 });
1832
1862
  }
1863
+ /** Revoke an access token if it exists and belongs to the requesting client. */
1864
+ async revokeAccessIfOwned(tokenId, userId, grantId, clientInfo, env) {
1865
+ const tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${tokenId}`, { type: "json" });
1866
+ if (!tokenData) return false;
1867
+ const tokenClientId = tokenData.grant?.clientId;
1868
+ if (tokenClientId !== void 0) {
1869
+ if (tokenClientId !== clientInfo.clientId) return false;
1870
+ } else if ((await env.OAUTH_KV.get(`grant:${userId}:${grantId}`, { type: "json" }))?.clientId !== clientInfo.clientId) return false;
1871
+ await this.revokeSpecificAccessToken(tokenId, userId, grantId, env);
1872
+ return true;
1873
+ }
1874
+ /** Revoke a refresh token (and its grant) if it exists and belongs to the requesting client. */
1875
+ async revokeRefreshIfOwned(tokenId, userId, grantId, clientInfo, env) {
1876
+ const grantData = await env.OAUTH_KV.get(`grant:${userId}:${grantId}`, { type: "json" });
1877
+ if (!grantData) return false;
1878
+ if (!(grantData.refreshTokenId === tokenId || grantData.previousRefreshTokenId === tokenId)) return false;
1879
+ if (grantData.clientId !== clientInfo.clientId) return false;
1880
+ await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
1881
+ return true;
1882
+ }
1833
1883
  /**
1834
1884
  * Revokes a specific access token without affecting the refresh token
1835
1885
  * @param tokenId - The hashed token ID
@@ -1842,35 +1892,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1842
1892
  await env.OAUTH_KV.delete(tokenKey);
1843
1893
  }
1844
1894
  /**
1845
- * Validates if a token is a valid access token
1846
- * @param tokenId - The hashed token ID
1847
- * @param userId - The user ID extracted from the token
1848
- * @param grantId - The grant ID extracted from the token
1849
- * @param env - Cloudflare Worker environment variables
1850
- * @returns Promise<boolean> indicating if the token is valid
1851
- */
1852
- async validateAccessToken(tokenId, userId, grantId, env) {
1853
- const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
1854
- const tokenData = await env.OAUTH_KV.get(tokenKey, { type: "json" });
1855
- if (!tokenData) return false;
1856
- const now = Math.floor(Date.now() / 1e3);
1857
- return tokenData.expiresAt >= now;
1858
- }
1859
- /**
1860
- * Validates if a token is a valid refresh token
1861
- * @param tokenId - The hashed token ID
1862
- * @param userId - The user ID extracted from the token
1863
- * @param grantId - The grant ID extracted from the token
1864
- * @param env - Cloudflare Worker environment variables
1865
- * @returns Promise<boolean> indicating if the token is valid
1866
- */
1867
- async validateRefreshToken(tokenId, userId, grantId, env) {
1868
- const grantKey = `grant:${userId}:${grantId}`;
1869
- const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
1870
- if (!grantData) return false;
1871
- return grantData.refreshTokenId === tokenId || grantData.previousRefreshTokenId === tokenId;
1872
- }
1873
- /**
1874
1895
  * Handles the dynamic client registration endpoint (RFC 7591)
1875
1896
  * @param request - The HTTP request
1876
1897
  * @param env - Cloudflare Worker environment variables
@@ -1889,6 +1910,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1889
1910
  description: "Request payload too large, must be under 1 MiB",
1890
1911
  statusCode: 413
1891
1912
  });
1913
+ const callbackRequest = request.clone();
1892
1914
  let clientMetadata;
1893
1915
  try {
1894
1916
  const text = await request.text();
@@ -1942,6 +1964,24 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1942
1964
  } catch (error) {
1943
1965
  return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
1944
1966
  }
1967
+ if (this.options.clientRegistrationCallback) {
1968
+ let callbackResult;
1969
+ try {
1970
+ callbackResult = await Promise.resolve(this.options.clientRegistrationCallback({
1971
+ clientMetadata,
1972
+ request: callbackRequest
1973
+ }));
1974
+ } catch (error) {
1975
+ return this.createErrorResponse("server_error", {
1976
+ description: error instanceof Error ? error.message : "Client registration callback failed",
1977
+ statusCode: 500
1978
+ });
1979
+ }
1980
+ if (callbackResult !== void 0) return this.createErrorResponse(callbackResult.code || "invalid_client_metadata", {
1981
+ description: callbackResult.description || "Client registration denied",
1982
+ statusCode: callbackResult.status ?? 400
1983
+ });
1984
+ }
1945
1985
  const clientKvOptions = {};
1946
1986
  if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
1947
1987
  await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo), clientKvOptions);
@@ -1971,7 +2011,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1971
2011
  }
1972
2012
  return new Response(JSON.stringify(response), {
1973
2013
  status: 201,
1974
- headers: { "Content-Type": "application/json" }
2014
+ headers: {
2015
+ "Content-Type": "application/json",
2016
+ ...NO_CACHE_HEADERS
2017
+ }
1975
2018
  });
1976
2019
  }
1977
2020
  /**
@@ -2387,7 +2430,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2387
2430
  createErrorResponse(code, options, internal) {
2388
2431
  const { description } = options;
2389
2432
  const responseStatus = options.statusCode ?? 400;
2390
- const responseHeaders = options.headers ?? {};
2433
+ const responseHeaders = {
2434
+ ...NO_CACHE_HEADERS,
2435
+ ...options.headers ?? {}
2436
+ };
2391
2437
  const customErrorResponse = this.options.onError?.({
2392
2438
  code,
2393
2439
  description,
@@ -2481,6 +2527,20 @@ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
2481
2527
  */
2482
2528
  const DEFAULT_PURGE_BATCH_SIZE = 50;
2483
2529
  /**
2530
+ * Maximum supported Cloudflare KV list page size.
2531
+ */
2532
+ const MAX_KV_LIST_LIMIT = 1e3;
2533
+ /**
2534
+ * Default batch size for paginating existing grants when revoking them
2535
+ * during completeAuthorization. Conservative for each KV list page.
2536
+ */
2537
+ const DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE = 50;
2538
+ function getRevokeExistingGrantsBatchSize(batchSize) {
2539
+ if (batchSize === void 0) return DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE;
2540
+ if (!Number.isFinite(batchSize) || !Number.isInteger(batchSize) || batchSize < 1) throw new Error("revokeExistingGrantsBatchSize must be a positive integer.");
2541
+ return Math.min(batchSize, MAX_KV_LIST_LIMIT);
2542
+ }
2543
+ /**
2484
2544
  * Length of generated token strings
2485
2545
  */
2486
2546
  const TOKEN_LENGTH = 32;
@@ -2558,6 +2618,14 @@ async function hashSecret(secret) {
2558
2618
  return generateTokenId(secret);
2559
2619
  }
2560
2620
  /**
2621
+ * Decodes an application/x-www-form-urlencoded component.
2622
+ * @param value - The encoded component value
2623
+ * @returns The decoded component value
2624
+ */
2625
+ function decodeFormUrlEncodedComponent(value) {
2626
+ return decodeURIComponent(value.replace(/\+/g, " "));
2627
+ }
2628
+ /**
2561
2629
  * Generates a cryptographically secure random string
2562
2630
  * @param length - The length of the string to generate
2563
2631
  * @returns A random string of the specified length
@@ -2904,9 +2972,13 @@ var OAuthHelpersImpl = class {
2904
2972
  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.");
2905
2973
  let grantsToRevoke = [];
2906
2974
  if (options.revokeExistingGrants !== false) {
2975
+ const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
2907
2976
  let cursor;
2908
2977
  do {
2909
- const page = await this.listUserGrants(options.userId, { cursor });
2978
+ const page = await this.listUserGrants(options.userId, {
2979
+ cursor,
2980
+ limit: batchSize
2981
+ });
2910
2982
  for (const grant of page.items) if (grant.clientId === clientId) grantsToRevoke.push(grant.id);
2911
2983
  cursor = page.cursor;
2912
2984
  } while (cursor);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",
@@ -32,7 +32,7 @@
32
32
  "prettier": "^3.7.4",
33
33
  "tsdown": "^0.18.1",
34
34
  "typescript": "^5.9.3",
35
- "vitest": "^3.2.4"
35
+ "vitest": "^4.1.8"
36
36
  },
37
37
  "repository": {
38
38
  "type": "git",