@cloudflare/workers-oauth-provider 0.4.0 → 0.5.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
@@ -96,15 +96,21 @@ export default new OAuthProvider({
96
96
  disallowPublicClientRegistration: false,
97
97
 
98
98
  // Optional: Time-to-live for refresh tokens in seconds.
99
- // If not specified, refresh tokens do not expire.
99
+ // Defaults to 30 days (2,592,000 seconds).
100
100
  // Set to 0 to disable refresh tokens (only access tokens will be issued).
101
- // For example: 3600 = 1 hour, 86400 = 1 day, 2592000 = 30 days
102
- refreshTokenTTL: 2592000, // 30 days
101
+ // Set to `undefined` explicitly for refresh tokens that never expire.
102
+ refreshTokenTTL: 2592000, // 30 days (the default)
103
103
 
104
104
  // Optional: Time-to-live for access tokens in seconds.
105
105
  // Defaults to 1 hour (3600 seconds) if not specified.
106
106
  accessTokenTTL: 3600,
107
107
 
108
+ // Optional: Time-to-live for dynamically registered clients in seconds.
109
+ // Defaults to 90 days (7,776,000 seconds).
110
+ // Clients created via OAuthHelpers.createClient() are not affected.
111
+ // Set to `undefined` explicitly for clients that never expire.
112
+ clientRegistrationTTL: 7776000, // 90 days (the default)
113
+
108
114
  // Optional: Controls whether OAuth 2.0 Token Exchange (RFC 8693) is allowed.
109
115
  // When false, the token exchange grant type will not be advertised in metadata
110
116
  // and token exchange requests will be rejected.
@@ -226,6 +232,9 @@ The `env.OAUTH_PROVIDER` object available to the fetch handlers provides some me
226
232
  - Create, list, modify, and delete client_id registrations (in addition to `lookupClient()`, already shown in the example code).
227
233
  - List all active authorization grants for a particular user.
228
234
  - Revoke (delete) an authorization grant.
235
+ - Purge expired and orphaned data from the KV namespace.
236
+
237
+ Note that `deleteClient()` cascades: it revokes all grants (and their associated tokens) for the deleted client across all users.
229
238
 
230
239
  See the `OAuthHelpers` interface definition for full API details.
231
240
 
@@ -326,6 +335,33 @@ new OAuthProvider({
326
335
 
327
336
  By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``.
328
337
 
338
+ ## KV Namespace Cleanup
339
+
340
+ The library uses KV TTLs to automatically expire access tokens, refresh tokens (grants), and dynamically registered clients. As defense-in-depth, the library also provides a `purgeExpiredData()` method that cleans up orphaned and expired records. This is designed to be called from a [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) (scheduled handler):
341
+
342
+ ```ts
343
+ const oauthProvider = new OAuthProvider({
344
+ // ... options ...
345
+ });
346
+
347
+ export default {
348
+ fetch(request, env, ctx) {
349
+ return oauthProvider.fetch(request, env, ctx);
350
+ },
351
+ async scheduled(event, env, ctx) {
352
+ const result = await oauthProvider.purgeExpiredData(env, { batchSize: 100 });
353
+ console.log(`Checked ${result.grantsChecked} grants, purged ${result.grantsPurged}`);
354
+ },
355
+ };
356
+ ```
357
+
358
+ The method processes records in configurable batches (default: 50) to stay within Cloudflare's subrequest limits. It performs two sweep phases:
359
+
360
+ 1. **Grant sweep**: Removes orphaned grants (whose client no longer exists) and expired grants.
361
+ 2. **Token sweep**: Removes orphaned tokens (whose grant no longer exists).
362
+
363
+ Call it repeatedly via a cron trigger — deleted records disappear from KV, so subsequent invocations naturally process fresh records without needing a persisted cursor. The `result.done` field indicates whether the full key space was scanned in this invocation.
364
+
329
365
  ## Protected Resource Metadata (RFC 9728)
330
366
 
331
367
  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:
@@ -178,10 +178,20 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
178
178
  accessTokenTTL?: number;
179
179
  /**
180
180
  * Time-to-live for refresh tokens in seconds.
181
- * If not specified, refresh tokens do not expire.
181
+ * Defaults to 30 days (2,592,000 seconds).
182
+ * Set to 0 to disable refresh tokens entirely.
183
+ * Set to `undefined` explicitly for refresh tokens that never expire.
182
184
  * For example: 3600 = 1 hour, 2592000 = 30 days
183
185
  */
184
186
  refreshTokenTTL?: number;
187
+ /**
188
+ * Time-to-live for dynamically registered clients in seconds.
189
+ * Defaults to 90 days (7,776,000 seconds).
190
+ * Clients created via the DCR endpoint will automatically expire after this duration.
191
+ * Clients created via `OAuthHelpers.createClient()` are not affected by this setting.
192
+ * Set to `undefined` explicitly for clients that never expire.
193
+ */
194
+ clientRegistrationTTL?: number;
185
195
  /**
186
196
  * List of scopes supported by this OAuth provider.
187
197
  * If not provided, the 'scopes_supported' field will be omitted from the OAuth metadata.
@@ -375,6 +385,22 @@ interface OAuthHelpers {
375
385
  * @returns Promise resolving to token response with new access token
376
386
  */
377
387
  exchangeToken(options: ExchangeTokenOptions): Promise<TokenResponse>;
388
+ /**
389
+ * Purges expired and orphaned data from the KV namespace.
390
+ * Designed to be called from a scheduled handler (Cron Trigger) for periodic cleanup.
391
+ * Processes records in configurable batches to stay within Cloudflare's subrequest limits.
392
+ *
393
+ * Performs two sweep phases:
394
+ * 1. Grant sweep: removes orphaned grants (client deleted) and expired grants (defense-in-depth for KV TTL)
395
+ * 2. Token sweep: removes orphaned tokens (grant deleted) as defense-in-depth
396
+ *
397
+ * Safe to call repeatedly — deleted records disappear from KV, so subsequent invocations
398
+ * naturally process fresh records without needing a persisted cursor.
399
+ *
400
+ * @param options - Optional configuration for batch size and which purge types to enable
401
+ * @returns Statistics about what was checked and purged, and whether the full scan completed
402
+ */
403
+ purgeExpiredData(options?: PurgeOptions): Promise<PurgeResult>;
378
404
  }
379
405
  /**
380
406
  * Options for token exchange operations (RFC 8693)
@@ -742,6 +768,55 @@ interface ListResult<T> {
742
768
  */
743
769
  cursor?: string;
744
770
  }
771
+ /**
772
+ * Options for the purgeExpiredData garbage collection method
773
+ */
774
+ interface PurgeOptions {
775
+ /**
776
+ * Maximum number of KV keys to check per phase (grants and tokens) per invocation.
777
+ * Each phase (grant sweep, token sweep) gets its own budget of this size.
778
+ * Keep this conservative to stay within Cloudflare's 1000 subrequest limit per invocation,
779
+ * since each checked key requires at least one KV read, and orphaned grants trigger
780
+ * additional KV operations via revokeGrant().
781
+ * Defaults to 50.
782
+ */
783
+ batchSize?: number;
784
+ /**
785
+ * Whether to purge orphaned grants whose client no longer exists in KV.
786
+ * Grants for CIMD (Client ID Metadata Document) clients are always skipped
787
+ * since those clients are not stored in KV.
788
+ * Defaults to true.
789
+ */
790
+ purgeOrphanedGrants?: boolean;
791
+ /**
792
+ * Whether to purge expired grants as defense-in-depth for KV TTL.
793
+ * Normally KV auto-deletes expired entries, but this catches any stragglers.
794
+ * Defaults to true.
795
+ */
796
+ purgeExpiredGrants?: boolean;
797
+ /**
798
+ * Whether to purge orphaned tokens whose grant no longer exists.
799
+ * Tokens already auto-expire via KV TTL (default 1 hour), so this is
800
+ * defense-in-depth for partial revokeGrant() failures.
801
+ * Defaults to true.
802
+ */
803
+ purgeOrphanedTokens?: boolean;
804
+ }
805
+ /**
806
+ * Result of a purgeExpiredData garbage collection invocation
807
+ */
808
+ interface PurgeResult {
809
+ /** Number of grant records checked in this invocation */
810
+ grantsChecked: number;
811
+ /** Number of grant records purged (orphaned or expired) */
812
+ grantsPurged: number;
813
+ /** Number of token records checked in this invocation */
814
+ tokensChecked: number;
815
+ /** Number of token records purged (orphaned) */
816
+ tokensPurged: number;
817
+ /** True if the full key space was scanned in this invocation (both grants and tokens) */
818
+ done: boolean;
819
+ }
745
820
  /**
746
821
  * Public representation of a grant, with sensitive data removed
747
822
  * Used for list operations where the complete grant data isn't needed
@@ -797,6 +872,15 @@ declare class OAuthProvider<Env = Cloudflare.Env> {
797
872
  * @returns A Promise resolving to an HTTP Response
798
873
  */
799
874
  fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
875
+ /**
876
+ * Purges expired and orphaned data from the KV namespace.
877
+ * Can be called directly from a scheduled handler without needing a request context.
878
+ *
879
+ * @param env - Cloudflare Worker environment variables (must include OAUTH_KV binding)
880
+ * @param options - Optional configuration for batch size and which purge types to enable
881
+ * @returns Statistics about what was checked and purged
882
+ */
883
+ purgeExpiredData(env: Env, options?: PurgeOptions): Promise<PurgeResult>;
800
884
  }
801
885
  /**
802
886
  * Gets OAuthHelpers for the given environment
@@ -806,4 +890,4 @@ declare class OAuthProvider<Env = Cloudflare.Env> {
806
890
  */
807
891
  declare function getOAuthApi<Env = Cloudflare.Env>(options: OAuthProviderOptions<Env>, env: Env): OAuthHelpers;
808
892
  //#endregion
809
- export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, getOAuthApi };
893
+ export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, getOAuthApi };
@@ -45,6 +45,17 @@ var OAuthProvider = class {
45
45
  fetch(request, env, ctx) {
46
46
  return this.#impl.fetch(request, env, ctx);
47
47
  }
48
+ /**
49
+ * Purges expired and orphaned data from the KV namespace.
50
+ * Can be called directly from a scheduled handler without needing a request context.
51
+ *
52
+ * @param env - Cloudflare Worker environment variables (must include OAUTH_KV binding)
53
+ * @param options - Optional configuration for batch size and which purge types to enable
54
+ * @returns Statistics about what was checked and purged
55
+ */
56
+ purgeExpiredData(env, options) {
57
+ return this.#impl.createOAuthHelpers(env).purgeExpiredData(options);
58
+ }
48
59
  };
49
60
  /**
50
61
  * Gets OAuthHelpers for the given environment
@@ -94,6 +105,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
94
105
  if (options.clientRegistrationEndpoint) this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
95
106
  this.options = {
96
107
  accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
108
+ refreshTokenTTL: DEFAULT_REFRESH_TOKEN_TTL,
109
+ clientRegistrationTTL: DEFAULT_CLIENT_REGISTRATION_TTL,
97
110
  onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
98
111
  ...options
99
112
  };
@@ -945,7 +958,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
945
958
  } catch (error) {
946
959
  return this.createErrorResponse("invalid_client_metadata", error instanceof Error ? error.message : "Invalid client metadata");
947
960
  }
948
- await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo));
961
+ const clientKvOptions = {};
962
+ if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
963
+ await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo), clientKvOptions);
949
964
  const response = {
950
965
  client_id: clientInfo.clientId,
951
966
  redirect_uris: clientInfo.redirectUris,
@@ -964,7 +979,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
964
979
  };
965
980
  if (clientSecret) {
966
981
  response.client_secret = clientSecret;
967
- response.client_secret_expires_at = 0;
982
+ response.client_secret_expires_at = this.options.clientRegistrationTTL && clientInfo.registrationDate ? clientInfo.registrationDate + this.options.clientRegistrationTTL : 0;
968
983
  response.client_secret_issued_at = clientInfo.registrationDate;
969
984
  }
970
985
  return new Response(JSON.stringify(response), {
@@ -1123,7 +1138,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1123
1138
  return !!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags ? Cloudflare.compatibilityFlags : null)?.global_fetch_strictly_public;
1124
1139
  }
1125
1140
  /**
1126
- * Checks if a client_id is a CIMD URL (HTTPS with non-root path)
1141
+ * Checks if a client_id is a CIMD URL (HTTPS with non-root path).
1142
+ * Not private because OAuthHelpersImpl needs access for purgeExpiredData.
1127
1143
  */
1128
1144
  isClientMetadataUrl(clientId) {
1129
1145
  try {
@@ -1304,6 +1320,19 @@ var OAuthError = class extends Error {
1304
1320
  */
1305
1321
  const DEFAULT_ACCESS_TOKEN_TTL = 3600;
1306
1322
  /**
1323
+ * Default expiration time for refresh tokens (30 days in seconds)
1324
+ */
1325
+ const DEFAULT_REFRESH_TOKEN_TTL = 720 * 60 * 60;
1326
+ /**
1327
+ * Default expiration time for dynamically registered clients (90 days in seconds)
1328
+ */
1329
+ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
1330
+ /**
1331
+ * Default batch size for purgeExpiredData. Conservative to stay within
1332
+ * Cloudflare's 1000 subrequest limit per invocation.
1333
+ */
1334
+ const DEFAULT_PURGE_BATCH_SIZE = 50;
1335
+ /**
1307
1336
  * Length of generated token strings
1308
1337
  */
1309
1338
  const TOKEN_LENGTH = 32;
@@ -1846,17 +1875,32 @@ var OAuthHelpersImpl = class {
1846
1875
  };
1847
1876
  if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
1848
1877
  else delete updatedClient.clientSecret;
1849
- await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient));
1878
+ const clientKvOptions = {};
1879
+ if (this.provider.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.provider.options.clientRegistrationTTL;
1880
+ await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient), clientKvOptions);
1850
1881
  const response = { ...updatedClient };
1851
1882
  if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
1852
1883
  return response;
1853
1884
  }
1854
1885
  /**
1855
- * Deletes an OAuth client
1886
+ * Deletes an OAuth client and revokes all associated grants across all users.
1856
1887
  * @param clientId - The ID of the client to delete
1857
1888
  * @returns A Promise resolving when the deletion is confirmed.
1858
1889
  */
1859
1890
  async deleteClient(clientId) {
1891
+ let cursor;
1892
+ let allProcessed = false;
1893
+ while (!allProcessed) {
1894
+ const listOptions = { prefix: "grant:" };
1895
+ if (cursor) listOptions.cursor = cursor;
1896
+ const result = await this.env.OAUTH_KV.list(listOptions);
1897
+ for (const key of result.keys) {
1898
+ const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
1899
+ if (grantData && grantData.clientId === clientId) await this.revokeGrant(grantData.id, grantData.userId);
1900
+ }
1901
+ if (result.list_complete) allProcessed = true;
1902
+ else cursor = result.cursor;
1903
+ }
1860
1904
  await this.env.OAUTH_KV.delete(`client:${clientId}`);
1861
1905
  }
1862
1906
  /**
@@ -1937,6 +1981,92 @@ var OAuthHelpersImpl = class {
1937
1981
  if (!clientInfo) throw new Error("Client not found");
1938
1982
  return await this.provider.exchangeToken(options.subjectToken, options.scope, options.aud, options.expiresIn, clientInfo, this.env);
1939
1983
  }
1984
+ async purgeExpiredData(options) {
1985
+ const batchSize = options?.batchSize ?? DEFAULT_PURGE_BATCH_SIZE;
1986
+ const purgeOrphanedGrants = options?.purgeOrphanedGrants !== false;
1987
+ const purgeExpiredGrants = options?.purgeExpiredGrants !== false;
1988
+ const purgeOrphanedTokens = options?.purgeOrphanedTokens !== false;
1989
+ const now = Math.floor(Date.now() / 1e3);
1990
+ const result = {
1991
+ grantsChecked: 0,
1992
+ grantsPurged: 0,
1993
+ tokensChecked: 0,
1994
+ tokensPurged: 0,
1995
+ done: false
1996
+ };
1997
+ if (purgeOrphanedGrants || purgeExpiredGrants) {
1998
+ const knownGoodClients = /* @__PURE__ */ new Set();
1999
+ const knownMissingClients = /* @__PURE__ */ new Set();
2000
+ let grantCursor;
2001
+ let grantsDone = false;
2002
+ while (!grantsDone && result.grantsChecked < batchSize) {
2003
+ const listOptions = {
2004
+ prefix: "grant:",
2005
+ limit: Math.min(1e3, batchSize - result.grantsChecked)
2006
+ };
2007
+ if (grantCursor) listOptions.cursor = grantCursor;
2008
+ const page = await this.env.OAUTH_KV.list(listOptions);
2009
+ for (const key of page.keys) {
2010
+ if (result.grantsChecked >= batchSize) break;
2011
+ result.grantsChecked++;
2012
+ const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
2013
+ if (!grantData) continue;
2014
+ let shouldPurge = false;
2015
+ if (purgeExpiredGrants && grantData.expiresAt !== void 0 && now >= grantData.expiresAt) shouldPurge = true;
2016
+ if (!shouldPurge && purgeOrphanedGrants && !this.provider.isClientMetadataUrl(grantData.clientId)) {
2017
+ if (knownMissingClients.has(grantData.clientId)) shouldPurge = true;
2018
+ else if (!knownGoodClients.has(grantData.clientId)) if (await this.env.OAUTH_KV.get(`client:${grantData.clientId}`, { type: "json" })) knownGoodClients.add(grantData.clientId);
2019
+ else {
2020
+ knownMissingClients.add(grantData.clientId);
2021
+ shouldPurge = true;
2022
+ }
2023
+ }
2024
+ if (shouldPurge) {
2025
+ await this.revokeGrant(grantData.id, grantData.userId);
2026
+ result.grantsPurged++;
2027
+ }
2028
+ }
2029
+ if (page.list_complete) grantsDone = true;
2030
+ else grantCursor = page.cursor;
2031
+ }
2032
+ if (!grantsDone) return result;
2033
+ }
2034
+ if (purgeOrphanedTokens) {
2035
+ const knownGoodGrants = /* @__PURE__ */ new Set();
2036
+ const knownMissingGrants = /* @__PURE__ */ new Set();
2037
+ let tokenCursor;
2038
+ let tokensDone = false;
2039
+ while (!tokensDone && result.tokensChecked < batchSize) {
2040
+ const listOptions = {
2041
+ prefix: "token:",
2042
+ limit: Math.min(1e3, batchSize - result.tokensChecked)
2043
+ };
2044
+ if (tokenCursor) listOptions.cursor = tokenCursor;
2045
+ const page = await this.env.OAUTH_KV.list(listOptions);
2046
+ for (const key of page.keys) {
2047
+ if (result.tokensChecked >= batchSize) break;
2048
+ result.tokensChecked++;
2049
+ const tokenData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
2050
+ if (!tokenData) continue;
2051
+ const grantKey = `grant:${tokenData.userId}:${tokenData.grantId}`;
2052
+ if (knownMissingGrants.has(grantKey)) {
2053
+ await this.env.OAUTH_KV.delete(key.name);
2054
+ result.tokensPurged++;
2055
+ } else if (!knownGoodGrants.has(grantKey)) if (await this.env.OAUTH_KV.get(grantKey)) knownGoodGrants.add(grantKey);
2056
+ else {
2057
+ knownMissingGrants.add(grantKey);
2058
+ await this.env.OAUTH_KV.delete(key.name);
2059
+ result.tokensPurged++;
2060
+ }
2061
+ }
2062
+ if (page.list_complete) tokensDone = true;
2063
+ else tokenCursor = page.cursor;
2064
+ }
2065
+ if (!tokensDone) return result;
2066
+ }
2067
+ result.done = true;
2068
+ return result;
2069
+ }
1940
2070
  };
1941
2071
  /**
1942
2072
  * Default export of the OAuth provider
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",