@cloudflare/workers-oauth-provider 0.3.3 → 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.
@@ -253,6 +263,16 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
253
263
  * Defaults to false.
254
264
  */
255
265
  clientIdMetadataDocumentEnabled?: boolean;
266
+ /**
267
+ * When true, resource validation during token exchange compares origins only
268
+ * (scheme + host + port) instead of exact URI matching. This allows grants issued
269
+ * with an origin-only resource (e.g. `https://server.com`) to be used with
270
+ * path-aware resource requests (e.g. `https://server.com/mcp`), enabling seamless
271
+ * migration from pre-0.4.0 versions that stored origin-only resource URIs.
272
+ *
273
+ * Defaults to false (strict exact matching per RFC 8707).
274
+ */
275
+ resourceMatchOriginOnly?: boolean;
256
276
  /**
257
277
  * Optional metadata for RFC 9728 OAuth 2.0 Protected Resource Metadata.
258
278
  * Controls the response served at /.well-known/oauth-protected-resource.
@@ -365,6 +385,22 @@ interface OAuthHelpers {
365
385
  * @returns Promise resolving to token response with new access token
366
386
  */
367
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>;
368
404
  }
369
405
  /**
370
406
  * Options for token exchange operations (RFC 8693)
@@ -732,6 +768,55 @@ interface ListResult<T> {
732
768
  */
733
769
  cursor?: string;
734
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
+ }
735
820
  /**
736
821
  * Public representation of a grant, with sensitive data removed
737
822
  * Used for list operations where the complete grant data isn't needed
@@ -787,6 +872,15 @@ declare class OAuthProvider<Env = Cloudflare.Env> {
787
872
  * @returns A Promise resolving to an HTTP Response
788
873
  */
789
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>;
790
884
  }
791
885
  /**
792
886
  * Gets OAuthHelpers for the given environment
@@ -796,4 +890,4 @@ declare class OAuthProvider<Env = Cloudflare.Env> {
796
890
  */
797
891
  declare function getOAuthApi<Env = Cloudflare.Env>(options: OAuthProviderOptions<Env>, env: Env): OAuthHelpers;
798
892
  //#endregion
799
- 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 };
@@ -1,6 +1,7 @@
1
1
  import { WorkerEntrypoint } from "cloudflare:workers";
2
2
 
3
3
  //#region src/oauth-provider.ts
4
+ const PROTECTED_RESOURCE_WELL_KNOWN_PREFIX = "/.well-known/oauth-protected-resource";
4
5
  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");
5
6
  /**
6
7
  * Enum representing the type of handler (ExportedHandler or WorkerEntrypoint)
@@ -44,6 +45,17 @@ var OAuthProvider = class {
44
45
  fetch(request, env, ctx) {
45
46
  return this.#impl.fetch(request, env, ctx);
46
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
+ }
47
59
  };
48
60
  /**
49
61
  * Gets OAuthHelpers for the given environment
@@ -93,6 +105,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
93
105
  if (options.clientRegistrationEndpoint) this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
94
106
  this.options = {
95
107
  accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
108
+ refreshTokenTTL: DEFAULT_REFRESH_TOKEN_TTL,
109
+ clientRegistrationTTL: DEFAULT_CLIENT_REGISTRATION_TTL,
96
110
  onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
97
111
  ...options
98
112
  };
@@ -141,7 +155,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
141
155
  async fetch(request, env, ctx) {
142
156
  const url = new URL(request.url);
143
157
  if (request.method === "OPTIONS") {
144
- if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || url.pathname === "/.well-known/oauth-protected-resource" || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
158
+ if (this.isApiRequest(url) || url.pathname === "/.well-known/oauth-authorization-server" || this.isProtectedResourceMetadataRequest(url) || this.isTokenEndpoint(url) || this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) return this.addCorsHeaders(new Response(null, {
145
159
  status: 204,
146
160
  headers: { "Content-Length": "0" }
147
161
  }), request);
@@ -150,7 +164,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
150
164
  const response = await this.handleMetadataDiscovery(url);
151
165
  return this.addCorsHeaders(response, request);
152
166
  }
153
- if (url.pathname === "/.well-known/oauth-protected-resource") {
167
+ if (this.isProtectedResourceMetadataRequest(url)) {
154
168
  const response = this.handleProtectedResourceMetadata(url);
155
169
  return this.addCorsHeaders(response, request);
156
170
  }
@@ -245,6 +259,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
245
259
  return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
246
260
  }
247
261
  /**
262
+ * Checks if a URL is a request for OAuth Protected Resource Metadata (RFC 9728).
263
+ * Matches both the root well-known path and path-suffixed variants per RFC 9728 §3.1.
264
+ */
265
+ isProtectedResourceMetadataRequest(url) {
266
+ return url.pathname === PROTECTED_RESOURCE_WELL_KNOWN_PREFIX || url.pathname.startsWith(PROTECTED_RESOURCE_WELL_KNOWN_PREFIX + "/");
267
+ }
268
+ /**
269
+ * Derives the resource identifier from a protected resource metadata well-known URL.
270
+ * Per RFC 9728 §3.1, the well-known URI is inserted after the authority and before the path,
271
+ * so the resource identifier is reconstructed by removing the well-known prefix.
272
+ *
273
+ * Examples:
274
+ * /.well-known/oauth-protected-resource → origin (e.g. https://example.com)
275
+ * /.well-known/oauth-protected-resource/mcp → origin + /mcp (e.g. https://example.com/mcp)
276
+ */
277
+ deriveResourceIdentifier(requestUrl) {
278
+ const suffix = requestUrl.pathname.slice(37);
279
+ if (!suffix || suffix === "/") return requestUrl.origin;
280
+ return `${requestUrl.origin}${suffix}`;
281
+ }
282
+ /**
248
283
  * Parses and validates a token endpoint request (used for both token exchange and revocation)
249
284
  * @param request - The HTTP request to parse
250
285
  * @returns Promise with parsed body and client info, or error response
@@ -389,7 +424,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
389
424
  const tokenEndpointUrl = this.getFullEndpointUrl(this.options.tokenEndpoint, requestUrl);
390
425
  const authServerOrigin = new URL(tokenEndpointUrl).origin;
391
426
  const metadata = {
392
- resource: rm?.resource ?? requestUrl.origin,
427
+ resource: rm?.resource ?? this.deriveResourceIdentifier(requestUrl),
393
428
  authorization_servers: rm?.authorization_servers ?? [authServerOrigin],
394
429
  scopes_supported: rm?.scopes_supported ?? this.options.scopesSupported,
395
430
  bearer_methods_supported: rm?.bearer_methods_supported ?? ["header"]
@@ -515,10 +550,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
515
550
  grantData.expiresAt = expiresAt;
516
551
  }
517
552
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
553
+ const originOnly = !!this.options.resourceMatchOriginOnly;
518
554
  if (body.resource && grantData.resource) {
519
555
  const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
520
556
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
521
- for (const requested of requestedResources) if (!grantedResources.includes(requested)) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
557
+ for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
522
558
  }
523
559
  const audience = parseResourceParameter(body.resource || grantData.resource);
524
560
  if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
@@ -635,10 +671,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
635
671
  grantData.refreshTokenId = newRefreshTokenId;
636
672
  grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
637
673
  await this.saveGrantWithTTL(env, grantKey, grantData, now);
674
+ const originOnly = !!this.options.resourceMatchOriginOnly;
638
675
  if (body.resource && grantData.resource) {
639
676
  const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
640
677
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
641
- for (const requested of requestedResources) if (!grantedResources.includes(requested)) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
678
+ for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", "Requested resource was not included in the authorization request");
642
679
  }
643
680
  const audience = parseResourceParameter(body.resource || grantData.resource);
644
681
  if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
@@ -690,12 +727,13 @@ var OAuthProviderImpl = class OAuthProviderImpl {
690
727
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
691
728
  if (!grantData) throw new OAuthError("invalid_grant", "Grant not found");
692
729
  let tokenScopes = this.downscope(requestedScopes, grantData.scope);
730
+ const originOnly = !!this.options.resourceMatchOriginOnly;
693
731
  let newAudience = tokenSummary.audience;
694
732
  if (requestedResource) {
695
733
  if (grantData.resource) {
696
734
  const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
697
735
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
698
- for (const requested of requestedResources) if (!grantedResources.includes(requested)) throw new OAuthError("invalid_target", "Requested resource was not included in the authorization request");
736
+ for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", "Requested resource was not included in the authorization request");
699
737
  }
700
738
  const parsedResource = parseResourceParameter(requestedResource);
701
739
  if (!parsedResource) throw new OAuthError("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
@@ -920,7 +958,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
920
958
  } catch (error) {
921
959
  return this.createErrorResponse("invalid_client_metadata", error instanceof Error ? error.message : "Invalid client metadata");
922
960
  }
923
- 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);
924
964
  const response = {
925
965
  client_id: clientInfo.clientId,
926
966
  redirect_uris: clientInfo.redirectUris,
@@ -939,7 +979,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
939
979
  };
940
980
  if (clientSecret) {
941
981
  response.client_secret = clientSecret;
942
- response.client_secret_expires_at = 0;
982
+ response.client_secret_expires_at = this.options.clientRegistrationTTL && clientInfo.registrationDate ? clientInfo.registrationDate + this.options.clientRegistrationTTL : 0;
943
983
  response.client_secret_issued_at = clientInfo.registrationDate;
944
984
  }
945
985
  return new Response(JSON.stringify(response), {
@@ -956,7 +996,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
956
996
  */
957
997
  async handleApiRequest(request, env, ctx) {
958
998
  const url = new URL(request.url);
959
- const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource`;
999
+ const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
960
1000
  const authHeader = request.headers.get("Authorization");
961
1001
  if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Missing or invalid access token") });
962
1002
  const accessToken = authHeader.substring(7);
@@ -1098,7 +1138,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1098
1138
  return !!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags ? Cloudflare.compatibilityFlags : null)?.global_fetch_strictly_public;
1099
1139
  }
1100
1140
  /**
1101
- * 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.
1102
1143
  */
1103
1144
  isClientMetadataUrl(clientId) {
1104
1145
  try {
@@ -1279,6 +1320,19 @@ var OAuthError = class extends Error {
1279
1320
  */
1280
1321
  const DEFAULT_ACCESS_TOKEN_TTL = 3600;
1281
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
+ /**
1282
1336
  * Length of generated token strings
1283
1337
  */
1284
1338
  const TOKEN_LENGTH = 32;
@@ -1331,6 +1385,19 @@ function parseResourceParameter(value) {
1331
1385
  return value;
1332
1386
  }
1333
1387
  /**
1388
+ * Checks if a requested resource matches a granted resource.
1389
+ * When originOnly is true, compares only the origin (scheme + host + port),
1390
+ * allowing path-aware resources to match origin-only grants.
1391
+ */
1392
+ function resourceMatches(requested, granted, originOnly) {
1393
+ if (!originOnly) return requested === granted;
1394
+ try {
1395
+ return new URL(requested).origin === new URL(granted).origin;
1396
+ } catch {
1397
+ return requested === granted;
1398
+ }
1399
+ }
1400
+ /**
1334
1401
  * Hashes a secret value using SHA-256
1335
1402
  * @param secret - The secret value to hash
1336
1403
  * @returns A hex string representation of the hash
@@ -1808,17 +1875,32 @@ var OAuthHelpersImpl = class {
1808
1875
  };
1809
1876
  if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
1810
1877
  else delete updatedClient.clientSecret;
1811
- 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);
1812
1881
  const response = { ...updatedClient };
1813
1882
  if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
1814
1883
  return response;
1815
1884
  }
1816
1885
  /**
1817
- * Deletes an OAuth client
1886
+ * Deletes an OAuth client and revokes all associated grants across all users.
1818
1887
  * @param clientId - The ID of the client to delete
1819
1888
  * @returns A Promise resolving when the deletion is confirmed.
1820
1889
  */
1821
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
+ }
1822
1904
  await this.env.OAUTH_KV.delete(`client:${clientId}`);
1823
1905
  }
1824
1906
  /**
@@ -1899,6 +1981,92 @@ var OAuthHelpersImpl = class {
1899
1981
  if (!clientInfo) throw new Error("Client not found");
1900
1982
  return await this.provider.exchangeToken(options.subjectToken, options.scope, options.aud, options.expiresIn, clientInfo, this.env);
1901
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
+ }
1902
2070
  };
1903
2071
  /**
1904
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.3.3",
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",