@cloudflare/workers-oauth-provider 0.7.2 → 0.8.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
@@ -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:
@@ -393,6 +393,48 @@ interface TokenExchangeCallbackOptions {
393
393
  */
394
394
  props: any;
395
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
+ }
396
438
  /**
397
439
  * Input parameters for the resolveExternalToken callback function
398
440
  */
@@ -538,6 +580,11 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
538
580
  * Defaults to false.
539
581
  */
540
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;
541
588
  /**
542
589
  * Optional callback function that is called during token exchange.
543
590
  * This allows updating the props stored in both the access token and the grant.
@@ -896,6 +943,13 @@ interface CompleteAuthorizationOptions {
896
943
  * Set to false to allow multiple concurrent grants per user+client.
897
944
  */
898
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;
899
953
  }
900
954
  /**
901
955
  * Authorization grant record
@@ -952,12 +1006,15 @@ interface Grant {
952
1006
  previousRefreshTokenWrappedKey?: string;
953
1007
  /**
954
1008
  * The hash of the authorization code associated with this grant
955
- * 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.
956
1012
  */
957
1013
  authCodeId?: string;
958
1014
  /**
959
1015
  * Wrapped encryption key for the authorization code
960
- * 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.
961
1018
  */
962
1019
  authCodeWrappedKey?: string;
963
1020
  /**
@@ -1335,4 +1392,4 @@ declare function getJwtCryptoAlgorithms(alg: string): {
1335
1392
  verifyAlgorithm: Parameters<SubtleCrypto['verify']>[0];
1336
1393
  };
1337
1394
  //#endregion
1338
- 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 };
@@ -639,9 +639,11 @@ function validateEmaMapperResult(result) {
639
639
  * TOCTOU window between claim validation and token mint.
640
640
  */
641
641
  function computeEmaAccessTokenTTL(input) {
642
- const { configuredDefaultSeconds, assertionExp, mapperTtl, now } = input;
642
+ const { configuredDefaultSeconds, assertionExp, mapperTtl, now, minTtlSeconds } = input;
643
643
  if (assertionExp - now <= 0) return err({ reason: "assertion_expired_after_processing" });
644
- return ok(mapperTtl ?? configuredDefaultSeconds);
644
+ const ttl = mapperTtl ?? configuredDefaultSeconds;
645
+ if (ttl < minTtlSeconds) return err({ reason: "invalid_mapped_ttl" });
646
+ return ok(ttl);
645
647
  }
646
648
  function readRequiredString(claims, claimName) {
647
649
  const value = claims[claimName];
@@ -672,6 +674,10 @@ function readNumericDateClaim(claims, claimName) {
672
674
  //#endregion
673
675
  //#region src/oauth-provider.ts
674
676
  const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
677
+ const NO_CACHE_HEADERS = {
678
+ "Cache-Control": "no-store",
679
+ Pragma: "no-cache"
680
+ };
675
681
  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
682
  /**
677
683
  * Enum representing the type of handler (ExportedHandler or WorkerEntrypoint)
@@ -781,6 +787,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
781
787
  onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
782
788
  ...options
783
789
  };
790
+ if (!Number.isInteger(this.options.accessTokenTTL) || this.options.accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new TypeError(`accessTokenTTL must be an integer of at least ${KV_MIN_EXPIRATION_TTL_SECONDS} seconds (Cloudflare KV's minimum expiration window).`);
784
791
  this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
785
792
  if (this.options.enterpriseManagedAuthorization) {
786
793
  this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
@@ -865,7 +872,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
865
872
  const parsed = await this.parseTokenEndpointRequest(request, env);
866
873
  if (parsed instanceof Response) return this.addCorsHeaders(parsed, request);
867
874
  let response;
868
- if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, env);
875
+ if (parsed.isRevocationRequest) response = await this.handleRevocationRequest(parsed.body, parsed.clientInfo, env);
869
876
  else response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env, url, request);
870
877
  return this.addCorsHeaders(response, request);
871
878
  }
@@ -989,17 +996,35 @@ var OAuthProviderImpl = class OAuthProviderImpl {
989
996
  statusCode: 400
990
997
  });
991
998
  const formData = await request.formData();
999
+ const processedKeys = /* @__PURE__ */ new Set();
992
1000
  for (const [key, value] of formData.entries()) {
1001
+ if (processedKeys.has(key)) continue;
1002
+ processedKeys.add(key);
993
1003
  const allValues = formData.getAll(key);
1004
+ if (key !== "resource" && allValues.length > 1) return this.createErrorResponse("invalid_request", {
1005
+ description: `Request parameter "${key}" must not be repeated`,
1006
+ statusCode: 400
1007
+ });
994
1008
  body[key] = allValues.length > 1 ? allValues : value;
995
1009
  }
996
1010
  const authHeader = request.headers.get("Authorization");
997
1011
  let clientId = "";
998
1012
  let clientSecret = "";
999
1013
  if (authHeader && authHeader.startsWith("Basic ")) {
1000
- const [id, secret] = atob(authHeader.substring(6)).split(":", 2);
1001
- clientId = decodeURIComponent(id);
1002
- clientSecret = decodeURIComponent(secret || "");
1014
+ if (body.client_id || body.client_secret) return this.createErrorResponse("invalid_request", {
1015
+ description: "Client must not use multiple authentication methods",
1016
+ statusCode: 400
1017
+ });
1018
+ const credentials = atob(authHeader.substring(6));
1019
+ const separatorIndex = credentials.indexOf(":");
1020
+ if (separatorIndex === -1) return this.createErrorResponse("invalid_client", {
1021
+ description: "Client authentication failed: invalid Basic credentials",
1022
+ statusCode: 401
1023
+ });
1024
+ const id = credentials.substring(0, separatorIndex);
1025
+ const secret = credentials.substring(separatorIndex + 1);
1026
+ clientId = decodeFormUrlEncodedComponent(id);
1027
+ clientSecret = decodeFormUrlEncodedComponent(secret);
1003
1028
  } else {
1004
1029
  clientId = body.client_id;
1005
1030
  clientSecret = body.client_secret || "";
@@ -1126,7 +1151,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1126
1151
  registration_endpoint: registrationEndpoint,
1127
1152
  scopes_supported: this.options.scopesSupported,
1128
1153
  response_types_supported: responseTypesSupported,
1129
- response_modes_supported: ["query"],
1154
+ response_modes_supported: this.options.allowImplicitFlow ? ["query", "fragment"] : ["query"],
1130
1155
  grant_types_supported: grantTypesSupported,
1131
1156
  ...authorizationGrantProfilesSupported.length > 0 ? { authorization_grant_profiles_supported: authorizationGrantProfilesSupported } : {},
1132
1157
  token_endpoint_auth_methods_supported: [
@@ -1214,14 +1239,15 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1214
1239
  const grantKey = `grant:${userId}:${grantId}`;
1215
1240
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
1216
1241
  if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" });
1217
- if (!grantData.authCodeId) {
1242
+ const codeHash = await hashSecret(code);
1243
+ if (!grantData.authCodeId || codeHash !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" });
1244
+ if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
1245
+ if (!grantData.authCodeWrappedKey) {
1218
1246
  try {
1219
1247
  await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
1220
1248
  } catch {}
1221
1249
  return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
1222
1250
  }
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
1251
  const isPkceEnabled = !!grantData.codeChallenge;
1226
1252
  if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
1227
1253
  if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
@@ -1282,7 +1308,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1282
1308
  }
1283
1309
  const now = Math.floor(Date.now() / 1e3);
1284
1310
  const useRefreshToken = refreshTokenTTL !== 0;
1285
- delete grantData.authCodeId;
1286
1311
  delete grantData.codeChallenge;
1287
1312
  delete grantData.codeChallengeMethod;
1288
1313
  delete grantData.authCodeWrappedKey;
@@ -1325,7 +1350,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1325
1350
  };
1326
1351
  if (refreshToken) tokenResponse.refresh_token = refreshToken;
1327
1352
  if (audience) tokenResponse.resource = audience;
1328
- return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
1353
+ return new Response(JSON.stringify(tokenResponse), { headers: {
1354
+ "Content-Type": "application/json",
1355
+ ...NO_CACHE_HEADERS
1356
+ } });
1329
1357
  }
1330
1358
  /**
1331
1359
  * Handles the refresh token grant type
@@ -1350,7 +1378,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1350
1378
  if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" });
1351
1379
  if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
1352
1380
  if (grantData.expiresAt !== void 0) {
1353
- if (Math.floor(Date.now() / 1e3) >= grantData.expiresAt) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
1381
+ const now$1 = Math.floor(Date.now() / 1e3);
1382
+ if (grantData.expiresAt - now$1 < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
1354
1383
  }
1355
1384
  const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
1356
1385
  const accessTokenId = await generateTokenId(newAccessToken);
@@ -1407,10 +1436,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1407
1436
  }
1408
1437
  }
1409
1438
  const now = Math.floor(Date.now() / 1e3);
1439
+ if (grantData.expiresAt !== void 0 && grantData.expiresAt - now < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
1410
1440
  if (grantData.expiresAt !== void 0) {
1411
1441
  const remainingRefreshTokenLifetime = grantData.expiresAt - now;
1412
1442
  if (remainingRefreshTokenLifetime > 0) accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
1413
1443
  }
1444
+ if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
1414
1445
  const accessTokenExpiresAt = now + accessTokenTTL;
1415
1446
  const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
1416
1447
  const newRefreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
@@ -1458,7 +1489,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1458
1489
  scope: tokenScopes.join(" ")
1459
1490
  };
1460
1491
  if (audience) tokenResponse.resource = audience;
1461
- return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
1492
+ return new Response(JSON.stringify(tokenResponse), { headers: {
1493
+ "Content-Type": "application/json",
1494
+ ...NO_CACHE_HEADERS
1495
+ } });
1462
1496
  }
1463
1497
  /**
1464
1498
  * Core token exchange logic (RFC 8693)
@@ -1496,6 +1530,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1496
1530
  }
1497
1531
  const now = Math.floor(Date.now() / 1e3);
1498
1532
  const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
1533
+ if (subjectTokenRemainingLifetime < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_grant", { description: "Subject token is too close to expiry to exchange" });
1499
1534
  let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
1500
1535
  if (expiresIn !== void 0) {
1501
1536
  if (expiresIn <= 0) throw new OAuthError("invalid_request", { description: "Invalid expires_in parameter" });
@@ -1533,6 +1568,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1533
1568
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
1534
1569
  }
1535
1570
  }
1571
+ if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
1536
1572
  const tokenResponse = {
1537
1573
  access_token: await this.createAccessToken({
1538
1574
  userId: tokenSummary.userId,
@@ -1583,7 +1619,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1583
1619
  }
1584
1620
  try {
1585
1621
  const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
1586
- return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
1622
+ return new Response(JSON.stringify(tokenResponse), { headers: {
1623
+ "Content-Type": "application/json",
1624
+ ...NO_CACHE_HEADERS
1625
+ } });
1587
1626
  } catch (error) {
1588
1627
  const response = this.createOAuthErrorResponse(error);
1589
1628
  if (response) return response;
@@ -1613,16 +1652,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1613
1652
  request,
1614
1653
  enterpriseOptions
1615
1654
  });
1616
- const noCacheHeaders = {
1617
- "Cache-Control": "no-store",
1618
- Pragma: "no-cache"
1619
- };
1620
1655
  if (!result.ok) {
1621
1656
  const wire = emaErrorToWire(result.error);
1622
- return this.createErrorResponse(wire.code, {
1623
- description: wire.message,
1624
- headers: noCacheHeaders
1625
- }, {
1657
+ return this.createErrorResponse(wire.code, { description: wire.message }, {
1626
1658
  category: "enterprise-managed-authorization",
1627
1659
  reason: result.error.reason,
1628
1660
  detail: result.error
@@ -1630,7 +1662,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1630
1662
  }
1631
1663
  return new Response(JSON.stringify(result.value), { headers: {
1632
1664
  "Content-Type": "application/json",
1633
- ...noCacheHeaders
1665
+ ...NO_CACHE_HEADERS
1634
1666
  } });
1635
1667
  }
1636
1668
  /**
@@ -1713,7 +1745,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1713
1745
  configuredDefaultSeconds: this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL,
1714
1746
  assertionExp: claims.value.claims.exp,
1715
1747
  mapperTtl: mapped.value.accessTokenTTL,
1716
- now: issueNow
1748
+ now: issueNow,
1749
+ minTtlSeconds: KV_MIN_EXPIRATION_TTL_SECONDS
1717
1750
  });
1718
1751
  if (!ttl.ok) return ttl;
1719
1752
  return ok(await this.issueEmaAccessToken({
@@ -1807,29 +1840,55 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1807
1840
  * @param env - Cloudflare Worker environment variables
1808
1841
  * @returns Response confirming revocation or error
1809
1842
  */
1810
- async handleRevocationRequest(body, env) {
1811
- return this.revokeToken(body, env);
1843
+ async handleRevocationRequest(body, clientInfo, env) {
1844
+ return this.revokeToken(body, clientInfo, env);
1812
1845
  }
1813
1846
  /**
1814
1847
  * - Access tokens: Revokes only the specific token
1815
1848
  * - Refresh tokens: Revokes the entire grant (access + refresh tokens)
1849
+ * Per RFC 7009 §2.1, the server MUST verify the token was issued to the client making the request.
1816
1850
  * @param body - The parsed request body containing token parameter
1851
+ * @param clientInfo - The authenticated client information
1817
1852
  * @param env - Cloudflare Worker environment variables
1818
1853
  * @returns Response confirming revocation or error
1819
1854
  */
1820
- async revokeToken(body, env) {
1855
+ async revokeToken(body, clientInfo, env) {
1821
1856
  const token = body.token;
1857
+ const tokenTypeHint = body.token_type_hint;
1822
1858
  if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" });
1823
1859
  const tokenParts = token.split(":");
1824
1860
  if (tokenParts.length !== 3) return new Response("", { status: 200 });
1825
1861
  const [userId, grantId, _] = tokenParts;
1826
1862
  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);
1863
+ if (tokenTypeHint === "refresh_token") {
1864
+ if (await this.revokeRefreshIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
1865
+ if (await this.revokeAccessIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
1866
+ } else {
1867
+ if (await this.revokeAccessIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
1868
+ if (await this.revokeRefreshIfOwned(tokenId, userId, grantId, clientInfo, env)) return new Response("", { status: 200 });
1869
+ }
1831
1870
  return new Response("", { status: 200 });
1832
1871
  }
1872
+ /** Revoke an access token if it exists and belongs to the requesting client. */
1873
+ async revokeAccessIfOwned(tokenId, userId, grantId, clientInfo, env) {
1874
+ const tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${tokenId}`, { type: "json" });
1875
+ if (!tokenData) return false;
1876
+ const tokenClientId = tokenData.grant?.clientId;
1877
+ if (tokenClientId !== void 0) {
1878
+ if (tokenClientId !== clientInfo.clientId) return false;
1879
+ } else if ((await env.OAUTH_KV.get(`grant:${userId}:${grantId}`, { type: "json" }))?.clientId !== clientInfo.clientId) return false;
1880
+ await this.revokeSpecificAccessToken(tokenId, userId, grantId, env);
1881
+ return true;
1882
+ }
1883
+ /** Revoke a refresh token (and its grant) if it exists and belongs to the requesting client. */
1884
+ async revokeRefreshIfOwned(tokenId, userId, grantId, clientInfo, env) {
1885
+ const grantData = await env.OAUTH_KV.get(`grant:${userId}:${grantId}`, { type: "json" });
1886
+ if (!grantData) return false;
1887
+ if (!(grantData.refreshTokenId === tokenId || grantData.previousRefreshTokenId === tokenId)) return false;
1888
+ if (grantData.clientId !== clientInfo.clientId) return false;
1889
+ await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
1890
+ return true;
1891
+ }
1833
1892
  /**
1834
1893
  * Revokes a specific access token without affecting the refresh token
1835
1894
  * @param tokenId - The hashed token ID
@@ -1842,35 +1901,6 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1842
1901
  await env.OAUTH_KV.delete(tokenKey);
1843
1902
  }
1844
1903
  /**
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
1904
  * Handles the dynamic client registration endpoint (RFC 7591)
1875
1905
  * @param request - The HTTP request
1876
1906
  * @param env - Cloudflare Worker environment variables
@@ -1889,6 +1919,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1889
1919
  description: "Request payload too large, must be under 1 MiB",
1890
1920
  statusCode: 413
1891
1921
  });
1922
+ const callbackRequest = request.clone();
1892
1923
  let clientMetadata;
1893
1924
  try {
1894
1925
  const text = await request.text();
@@ -1942,6 +1973,24 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1942
1973
  } catch (error) {
1943
1974
  return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
1944
1975
  }
1976
+ if (this.options.clientRegistrationCallback) {
1977
+ let callbackResult;
1978
+ try {
1979
+ callbackResult = await Promise.resolve(this.options.clientRegistrationCallback({
1980
+ clientMetadata,
1981
+ request: callbackRequest
1982
+ }));
1983
+ } catch (error) {
1984
+ return this.createErrorResponse("server_error", {
1985
+ description: error instanceof Error ? error.message : "Client registration callback failed",
1986
+ statusCode: 500
1987
+ });
1988
+ }
1989
+ if (callbackResult !== void 0) return this.createErrorResponse(callbackResult.code || "invalid_client_metadata", {
1990
+ description: callbackResult.description || "Client registration denied",
1991
+ statusCode: callbackResult.status ?? 400
1992
+ });
1993
+ }
1945
1994
  const clientKvOptions = {};
1946
1995
  if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
1947
1996
  await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo), clientKvOptions);
@@ -1971,7 +2020,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1971
2020
  }
1972
2021
  return new Response(JSON.stringify(response), {
1973
2022
  status: 201,
1974
- headers: { "Content-Type": "application/json" }
2023
+ headers: {
2024
+ "Content-Type": "application/json",
2025
+ ...NO_CACHE_HEADERS
2026
+ }
1975
2027
  });
1976
2028
  }
1977
2029
  /**
@@ -2071,7 +2123,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2071
2123
  * @param now - Current timestamp in seconds
2072
2124
  */
2073
2125
  async saveGrantWithTTL(env, grantKey, grantData, now) {
2074
- const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
2126
+ const minExpiration = now + KV_MIN_EXPIRATION_TTL_SECONDS + KV_EXPIRATION_CLAMP_MARGIN_SECONDS;
2127
+ const kvOptions = grantData.expiresAt !== void 0 ? { expiration: Math.max(grantData.expiresAt, minExpiration) } : {};
2075
2128
  try {
2076
2129
  await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
2077
2130
  } catch (error) {
@@ -2128,6 +2181,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2128
2181
  */
2129
2182
  async createAccessToken(params) {
2130
2183
  const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
2184
+ if (expiresIn < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
2131
2185
  const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
2132
2186
  const now = Math.floor(Date.now() / 1e3);
2133
2187
  const accessTokenId = await generateTokenId(accessToken);
@@ -2387,7 +2441,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2387
2441
  createErrorResponse(code, options, internal) {
2388
2442
  const { description } = options;
2389
2443
  const responseStatus = options.statusCode ?? 400;
2390
- const responseHeaders = options.headers ?? {};
2444
+ const responseHeaders = {
2445
+ ...NO_CACHE_HEADERS,
2446
+ ...options.headers ?? {}
2447
+ };
2391
2448
  const customErrorResponse = this.options.onError?.({
2392
2449
  code,
2393
2450
  description,
@@ -2476,11 +2533,41 @@ const DEFAULT_REFRESH_TOKEN_TTL = 720 * 60 * 60;
2476
2533
  */
2477
2534
  const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
2478
2535
  /**
2536
+ * Minimum number of seconds an absolute KV expiration must be in the future.
2537
+ * Cloudflare KV rejects `put` calls whose `expiration` is less than 60 seconds
2538
+ * away with "400 Invalid expiration ... Expiration times must be at least 60
2539
+ * seconds in the future." We use this to treat near-expiry grants as expired and
2540
+ * to clamp absolute expirations when writing grants back to KV.
2541
+ */
2542
+ const KV_MIN_EXPIRATION_TTL_SECONDS = 60;
2543
+ /**
2544
+ * Safety margin (seconds) added on top of `KV_MIN_EXPIRATION_TTL_SECONDS` when clamping an
2545
+ * absolute KV expiration. Absolute expirations are validated against KV's clock at the
2546
+ * moment the write is processed, so writing exactly `now + 60` can be rejected once
2547
+ * worker→KV latency or minor clock skew is accounted for. The margin keeps clamped writes
2548
+ * comfortably above KV's hard minimum without meaningfully extending a grant's lifetime.
2549
+ */
2550
+ const KV_EXPIRATION_CLAMP_MARGIN_SECONDS = 5;
2551
+ /**
2479
2552
  * Default batch size for purgeExpiredData. Conservative to stay within
2480
2553
  * Cloudflare's 1000 subrequest limit per invocation.
2481
2554
  */
2482
2555
  const DEFAULT_PURGE_BATCH_SIZE = 50;
2483
2556
  /**
2557
+ * Maximum supported Cloudflare KV list page size.
2558
+ */
2559
+ const MAX_KV_LIST_LIMIT = 1e3;
2560
+ /**
2561
+ * Default batch size for paginating existing grants when revoking them
2562
+ * during completeAuthorization. Conservative for each KV list page.
2563
+ */
2564
+ const DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE = 50;
2565
+ function getRevokeExistingGrantsBatchSize(batchSize) {
2566
+ if (batchSize === void 0) return DEFAULT_REVOKE_EXISTING_GRANTS_BATCH_SIZE;
2567
+ if (!Number.isFinite(batchSize) || !Number.isInteger(batchSize) || batchSize < 1) throw new Error("revokeExistingGrantsBatchSize must be a positive integer.");
2568
+ return Math.min(batchSize, MAX_KV_LIST_LIMIT);
2569
+ }
2570
+ /**
2484
2571
  * Length of generated token strings
2485
2572
  */
2486
2573
  const TOKEN_LENGTH = 32;
@@ -2558,6 +2645,14 @@ async function hashSecret(secret) {
2558
2645
  return generateTokenId(secret);
2559
2646
  }
2560
2647
  /**
2648
+ * Decodes an application/x-www-form-urlencoded component.
2649
+ * @param value - The encoded component value
2650
+ * @returns The decoded component value
2651
+ */
2652
+ function decodeFormUrlEncodedComponent(value) {
2653
+ return decodeURIComponent(value.replace(/\+/g, " "));
2654
+ }
2655
+ /**
2561
2656
  * Generates a cryptographically secure random string
2562
2657
  * @param length - The length of the string to generate
2563
2658
  * @returns A random string of the specified length
@@ -2904,9 +2999,13 @@ var OAuthHelpersImpl = class {
2904
2999
  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
3000
  let grantsToRevoke = [];
2906
3001
  if (options.revokeExistingGrants !== false) {
3002
+ const batchSize = getRevokeExistingGrantsBatchSize(options.revokeExistingGrantsBatchSize);
2907
3003
  let cursor;
2908
3004
  do {
2909
- const page = await this.listUserGrants(options.userId, { cursor });
3005
+ const page = await this.listUserGrants(options.userId, {
3006
+ cursor,
3007
+ limit: batchSize
3008
+ });
2910
3009
  for (const grant of page.items) if (grant.clientId === clientId) grantsToRevoke.push(grant.id);
2911
3010
  cursor = page.cursor;
2912
3011
  } while (cursor);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
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",