@cloudflare/workers-oauth-provider 0.8.0 → 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.
@@ -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];
@@ -785,6 +787,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
785
787
  onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
786
788
  ...options
787
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).`);
788
791
  this.validateEmaOptions(this.options.enterpriseManagedAuthorization);
789
792
  if (this.options.enterpriseManagedAuthorization) {
790
793
  this.jwksProvider = createDefaultJwksProvider({ cacheTtlSeconds: this.options.enterpriseManagedAuthorization.jwksCacheTtlSeconds });
@@ -1375,7 +1378,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1375
1378
  if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" });
1376
1379
  if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
1377
1380
  if (grantData.expiresAt !== void 0) {
1378
- 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" });
1379
1383
  }
1380
1384
  const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
1381
1385
  const accessTokenId = await generateTokenId(newAccessToken);
@@ -1432,10 +1436,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1432
1436
  }
1433
1437
  }
1434
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" });
1435
1440
  if (grantData.expiresAt !== void 0) {
1436
1441
  const remainingRefreshTokenLifetime = grantData.expiresAt - now;
1437
1442
  if (remainingRefreshTokenLifetime > 0) accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
1438
1443
  }
1444
+ if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) return this.createErrorResponse("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
1439
1445
  const accessTokenExpiresAt = now + accessTokenTTL;
1440
1446
  const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
1441
1447
  const newRefreshToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
@@ -1524,6 +1530,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1524
1530
  }
1525
1531
  const now = Math.floor(Date.now() / 1e3);
1526
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" });
1527
1534
  let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
1528
1535
  if (expiresIn !== void 0) {
1529
1536
  if (expiresIn <= 0) throw new OAuthError("invalid_request", { description: "Invalid expires_in parameter" });
@@ -1561,6 +1568,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1561
1568
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
1562
1569
  }
1563
1570
  }
1571
+ if (accessTokenTTL < KV_MIN_EXPIRATION_TTL_SECONDS) throw new OAuthError("invalid_request", { description: "Requested token lifetime must be at least 60 seconds" });
1564
1572
  const tokenResponse = {
1565
1573
  access_token: await this.createAccessToken({
1566
1574
  userId: tokenSummary.userId,
@@ -1737,7 +1745,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1737
1745
  configuredDefaultSeconds: this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL,
1738
1746
  assertionExp: claims.value.claims.exp,
1739
1747
  mapperTtl: mapped.value.accessTokenTTL,
1740
- now: issueNow
1748
+ now: issueNow,
1749
+ minTtlSeconds: KV_MIN_EXPIRATION_TTL_SECONDS
1741
1750
  });
1742
1751
  if (!ttl.ok) return ttl;
1743
1752
  return ok(await this.issueEmaAccessToken({
@@ -2114,7 +2123,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2114
2123
  * @param now - Current timestamp in seconds
2115
2124
  */
2116
2125
  async saveGrantWithTTL(env, grantKey, grantData, now) {
2117
- 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) } : {};
2118
2128
  try {
2119
2129
  await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
2120
2130
  } catch (error) {
@@ -2171,6 +2181,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
2171
2181
  */
2172
2182
  async createAccessToken(params) {
2173
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" });
2174
2185
  const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
2175
2186
  const now = Math.floor(Date.now() / 1e3);
2176
2187
  const accessTokenId = await generateTokenId(accessToken);
@@ -2522,6 +2533,22 @@ const DEFAULT_REFRESH_TOKEN_TTL = 720 * 60 * 60;
2522
2533
  */
2523
2534
  const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
2524
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
+ /**
2525
2552
  * Default batch size for purgeExpiredData. Conservative to stay within
2526
2553
  * Cloudflare's 1000 subrequest limit per invocation.
2527
2554
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.8.0",
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",