@cloudflare/workers-oauth-provider 0.4.0 → 0.6.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
 
@@ -297,6 +306,52 @@ The `accessTokenTTL` override is particularly useful when the application is als
297
306
 
298
307
  The `props` values are end-to-end encrypted, so they can safely contain sensitive information.
299
308
 
309
+ ### Reporting errors from the callback
310
+
311
+ Throw `OAuthError` from `tokenExchangeCallback` to return a structured OAuth `/token` error (`{ error, error_description }`) instead of a generic 500:
312
+
313
+ ```ts
314
+ import { OAuthError, OAuthProvider } from '@cloudflare/workers-oauth-provider';
315
+
316
+ new OAuthProvider({
317
+ // …
318
+ tokenExchangeCallback: async (options) => {
319
+ if (options.grantType === 'refresh_token') {
320
+ return { newProps: await refreshUpstream(options.props) };
321
+ }
322
+ },
323
+ });
324
+
325
+ async function refreshUpstream(props) {
326
+ const res = await fetch(/* upstream token endpoint */);
327
+
328
+ if (res.status === 401) {
329
+ throw new OAuthError('invalid_grant', {
330
+ description: 'upstream refresh token is invalid',
331
+ });
332
+ }
333
+
334
+ if (res.status === 429) {
335
+ throw new OAuthError('temporarily_unavailable', {
336
+ description: 'upstream rate limited',
337
+ statusCode: 429,
338
+ headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
339
+ });
340
+ }
341
+
342
+ return await res.json();
343
+ }
344
+ ```
345
+
346
+ `OAuthError(code, options)` takes:
347
+
348
+ - `code` — OAuth error code returned in the `error` field. This may be a standard code (`OAuthTokenErrorCode`) or an application-defined string.
349
+ - `options.description` — human-readable text returned in `error_description`.
350
+ - `options.statusCode` — HTTP status code (default `400`).
351
+ - `options.headers` — additional response headers, such as `Retry-After` for transient failures. There is no implicit `Retry-After` default for callback-thrown errors.
352
+
353
+ Only `OAuthError` from this package is converted into a structured `/token` response. Plain errors, plain objects with a `code` field, and app-local error classes continue to surface as 500s so unexpected failures stay visible. Import `OAuthError` from `@cloudflare/workers-oauth-provider` rather than copying or re-implementing it.
354
+
300
355
  ## Custom Error Responses
301
356
 
302
357
  By using the `onError` option, you can emit notifications or take other actions when an error response was to be emitted:
@@ -326,6 +381,33 @@ new OAuthProvider({
326
381
 
327
382
  By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``.
328
383
 
384
+ ## KV Namespace Cleanup
385
+
386
+ 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):
387
+
388
+ ```ts
389
+ const oauthProvider = new OAuthProvider({
390
+ // ... options ...
391
+ });
392
+
393
+ export default {
394
+ fetch(request, env, ctx) {
395
+ return oauthProvider.fetch(request, env, ctx);
396
+ },
397
+ async scheduled(event, env, ctx) {
398
+ const result = await oauthProvider.purgeExpiredData(env, { batchSize: 100 });
399
+ console.log(`Checked ${result.grantsChecked} grants, purged ${result.grantsPurged}`);
400
+ },
401
+ };
402
+ ```
403
+
404
+ The method processes records in configurable batches (default: 50) to stay within Cloudflare's subrequest limits. It performs two sweep phases:
405
+
406
+ 1. **Grant sweep**: Removes orphaned grants (whose client no longer exists) and expired grants.
407
+ 2. **Token sweep**: Removes orphaned tokens (whose grant no longer exists).
408
+
409
+ 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.
410
+
329
411
  ## Protected Resource Metadata (RFC 9728)
330
412
 
331
413
  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:
@@ -20,6 +20,16 @@ type WorkerEntrypointWithFetch<Env = Cloudflare.Env> = WorkerEntrypoint<Env> & {
20
20
  /**
21
21
  * Configuration options for the OAuth Provider
22
22
  */
23
+ /**
24
+ * Registered OAuth 2.0 error codes that the `/token` endpoint may return.
25
+ *
26
+ * Union of:
27
+ * - RFC 6749 §5.2 (token endpoint)
28
+ * - RFC 6750 §3.1 (bearer / resource server) — included so callbacks
29
+ * doing audience validation can use them
30
+ * - RFC 8693 §2.2.2 (token exchange)
31
+ */
32
+ type OAuthTokenErrorCode = 'invalid_request' | 'invalid_client' | 'invalid_grant' | 'unauthorized_client' | 'unsupported_grant_type' | 'invalid_scope' | 'invalid_token' | 'insufficient_scope' | 'invalid_target' | 'server_error' | 'temporarily_unavailable';
23
33
  /**
24
34
  * Result of a token exchange callback function.
25
35
  * Allows updating the props stored in both the access token and the grant.
@@ -178,10 +188,20 @@ interface OAuthProviderOptions<Env = Cloudflare.Env> {
178
188
  accessTokenTTL?: number;
179
189
  /**
180
190
  * Time-to-live for refresh tokens in seconds.
181
- * If not specified, refresh tokens do not expire.
191
+ * Defaults to 30 days (2,592,000 seconds).
192
+ * Set to 0 to disable refresh tokens entirely.
193
+ * Set to `undefined` explicitly for refresh tokens that never expire.
182
194
  * For example: 3600 = 1 hour, 2592000 = 30 days
183
195
  */
184
196
  refreshTokenTTL?: number;
197
+ /**
198
+ * Time-to-live for dynamically registered clients in seconds.
199
+ * Defaults to 90 days (7,776,000 seconds).
200
+ * Clients created via the DCR endpoint will automatically expire after this duration.
201
+ * Clients created via `OAuthHelpers.createClient()` are not affected by this setting.
202
+ * Set to `undefined` explicitly for clients that never expire.
203
+ */
204
+ clientRegistrationTTL?: number;
185
205
  /**
186
206
  * List of scopes supported by this OAuth provider.
187
207
  * If not provided, the 'scopes_supported' field will be omitted from the OAuth metadata.
@@ -375,6 +395,22 @@ interface OAuthHelpers {
375
395
  * @returns Promise resolving to token response with new access token
376
396
  */
377
397
  exchangeToken(options: ExchangeTokenOptions): Promise<TokenResponse>;
398
+ /**
399
+ * Purges expired and orphaned data from the KV namespace.
400
+ * Designed to be called from a scheduled handler (Cron Trigger) for periodic cleanup.
401
+ * Processes records in configurable batches to stay within Cloudflare's subrequest limits.
402
+ *
403
+ * Performs two sweep phases:
404
+ * 1. Grant sweep: removes orphaned grants (client deleted) and expired grants (defense-in-depth for KV TTL)
405
+ * 2. Token sweep: removes orphaned tokens (grant deleted) as defense-in-depth
406
+ *
407
+ * Safe to call repeatedly — deleted records disappear from KV, so subsequent invocations
408
+ * naturally process fresh records without needing a persisted cursor.
409
+ *
410
+ * @param options - Optional configuration for batch size and which purge types to enable
411
+ * @returns Statistics about what was checked and purged, and whether the full scan completed
412
+ */
413
+ purgeExpiredData(options?: PurgeOptions): Promise<PurgeResult>;
378
414
  }
379
415
  /**
380
416
  * Options for token exchange operations (RFC 8693)
@@ -742,6 +778,55 @@ interface ListResult<T> {
742
778
  */
743
779
  cursor?: string;
744
780
  }
781
+ /**
782
+ * Options for the purgeExpiredData garbage collection method
783
+ */
784
+ interface PurgeOptions {
785
+ /**
786
+ * Maximum number of KV keys to check per phase (grants and tokens) per invocation.
787
+ * Each phase (grant sweep, token sweep) gets its own budget of this size.
788
+ * Keep this conservative to stay within Cloudflare's 1000 subrequest limit per invocation,
789
+ * since each checked key requires at least one KV read, and orphaned grants trigger
790
+ * additional KV operations via revokeGrant().
791
+ * Defaults to 50.
792
+ */
793
+ batchSize?: number;
794
+ /**
795
+ * Whether to purge orphaned grants whose client no longer exists in KV.
796
+ * Grants for CIMD (Client ID Metadata Document) clients are always skipped
797
+ * since those clients are not stored in KV.
798
+ * Defaults to true.
799
+ */
800
+ purgeOrphanedGrants?: boolean;
801
+ /**
802
+ * Whether to purge expired grants as defense-in-depth for KV TTL.
803
+ * Normally KV auto-deletes expired entries, but this catches any stragglers.
804
+ * Defaults to true.
805
+ */
806
+ purgeExpiredGrants?: boolean;
807
+ /**
808
+ * Whether to purge orphaned tokens whose grant no longer exists.
809
+ * Tokens already auto-expire via KV TTL (default 1 hour), so this is
810
+ * defense-in-depth for partial revokeGrant() failures.
811
+ * Defaults to true.
812
+ */
813
+ purgeOrphanedTokens?: boolean;
814
+ }
815
+ /**
816
+ * Result of a purgeExpiredData garbage collection invocation
817
+ */
818
+ interface PurgeResult {
819
+ /** Number of grant records checked in this invocation */
820
+ grantsChecked: number;
821
+ /** Number of grant records purged (orphaned or expired) */
822
+ grantsPurged: number;
823
+ /** Number of token records checked in this invocation */
824
+ tokensChecked: number;
825
+ /** Number of token records purged (orphaned) */
826
+ tokensPurged: number;
827
+ /** True if the full key space was scanned in this invocation (both grants and tokens) */
828
+ done: boolean;
829
+ }
745
830
  /**
746
831
  * Public representation of a grant, with sensitive data removed
747
832
  * Used for list operations where the complete grant data isn't needed
@@ -797,6 +882,15 @@ declare class OAuthProvider<Env = Cloudflare.Env> {
797
882
  * @returns A Promise resolving to an HTTP Response
798
883
  */
799
884
  fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response>;
885
+ /**
886
+ * Purges expired and orphaned data from the KV namespace.
887
+ * Can be called directly from a scheduled handler without needing a request context.
888
+ *
889
+ * @param env - Cloudflare Worker environment variables (must include OAUTH_KV binding)
890
+ * @param options - Optional configuration for batch size and which purge types to enable
891
+ * @returns Statistics about what was checked and purged
892
+ */
893
+ purgeExpiredData(env: Env, options?: PurgeOptions): Promise<PurgeResult>;
800
894
  }
801
895
  /**
802
896
  * Gets OAuthHelpers for the given environment
@@ -805,5 +899,86 @@ declare class OAuthProvider<Env = Cloudflare.Env> {
805
899
  * @returns An instance of OAuthHelpers
806
900
  */
807
901
  declare function getOAuthApi<Env = Cloudflare.Env>(options: OAuthProviderOptions<Env>, env: Env): OAuthHelpers;
902
+ /**
903
+ * Error class for OAuth operations
904
+ * Carries OAuth error code and description for proper error responses
905
+ */
906
+ /**
907
+ * Options accepted by the {@link OAuthError} constructor.
908
+ */
909
+ interface OAuthErrorOptions {
910
+ /**
911
+ * Human-readable text returned in the `error_description` field.
912
+ */
913
+ description: string;
914
+ /**
915
+ * HTTP status code for the error response. Defaults to `400`.
916
+ */
917
+ statusCode?: number;
918
+ /**
919
+ * Additional response headers.
920
+ *
921
+ * For transient failures (e.g. upstream rate limits), set
922
+ * `Retry-After` here so well-behaved clients back off instead of
923
+ * retry-storming. Per RFC 7231 §7.1.3 the value may be either a
924
+ * number of seconds or an HTTP-date.
925
+ */
926
+ headers?: Record<string, string>;
927
+ }
928
+ /**
929
+ * Structured OAuth 2.0 error.
930
+ *
931
+ * Throw from a `tokenExchangeCallback` (or any code it calls — the error
932
+ * propagates naturally up through deep call stacks) to surface a standard
933
+ * `/token` error response (`{ error, error_description }`) instead of a
934
+ * generic `500 Internal Server Error`.
935
+ *
936
+ * Anything thrown that is **not** an `OAuthError` continues to surface as
937
+ * a 500 so unexpected failures remain visible — the provider does not
938
+ * catch-everything-and-return-400.
939
+ *
940
+ * @example
941
+ * ```ts
942
+ * import { OAuthError } from '@cloudflare/workers-oauth-provider';
943
+ *
944
+ * tokenExchangeCallback: async (options) => {
945
+ * if (options.grantType === 'refresh_token') {
946
+ * // refreshUpstream() may throw OAuthError from any depth
947
+ * return { newProps: await refreshUpstream(options.props) };
948
+ * }
949
+ * }
950
+ *
951
+ * async function refreshUpstream(props) {
952
+ * const res = await fetch(...);
953
+ * if (res.status === 401) {
954
+ * throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
955
+ * }
956
+ * if (res.status === 429) {
957
+ * // Mirror upstream's Retry-After if present, otherwise pick a default.
958
+ * throw new OAuthError('temporarily_unavailable', {
959
+ * description: 'upstream rate limited',
960
+ * statusCode: 429,
961
+ * headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
962
+ * });
963
+ * }
964
+ * return await res.json();
965
+ * }
966
+ * ```
967
+ */
968
+ declare class OAuthError extends Error {
969
+ /** OAuth 2.0 error code. */
970
+ readonly code: string;
971
+ /** Options controlling the OAuth error response. */
972
+ readonly options: OAuthErrorOptions & {
973
+ statusCode: number;
974
+ };
975
+ /** Human-readable description sent in the `error_description` field. */
976
+ readonly description: string;
977
+ /** HTTP status code for the error response. */
978
+ readonly statusCode: number;
979
+ /** Additional response headers. */
980
+ readonly headers?: Record<string, string>;
981
+ constructor(code: string, options: OAuthErrorOptions);
982
+ }
808
983
  //#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 };
984
+ export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthError, OAuthErrorOptions, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthTokenErrorCode, 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
  };
@@ -272,10 +285,16 @@ var OAuthProviderImpl = class OAuthProviderImpl {
272
285
  * @returns Promise with parsed body and client info, or error response
273
286
  */
274
287
  async parseTokenEndpointRequest(request, env) {
275
- if (request.method !== "POST") return this.createErrorResponse("invalid_request", "Method not allowed", 405);
288
+ if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
289
+ description: "Method not allowed",
290
+ statusCode: 405
291
+ });
276
292
  let contentType = request.headers.get("Content-Type") || "";
277
293
  let body = {};
278
- if (!contentType.includes("application/x-www-form-urlencoded")) return this.createErrorResponse("invalid_request", "Content-Type must be application/x-www-form-urlencoded", 400);
294
+ if (!contentType.includes("application/x-www-form-urlencoded")) return this.createErrorResponse("invalid_request", {
295
+ description: "Content-Type must be application/x-www-form-urlencoded",
296
+ statusCode: 400
297
+ });
279
298
  const formData = await request.formData();
280
299
  for (const [key, value] of formData.entries()) {
281
300
  const allValues = formData.getAll(key);
@@ -292,13 +311,28 @@ var OAuthProviderImpl = class OAuthProviderImpl {
292
311
  clientId = body.client_id;
293
312
  clientSecret = body.client_secret || "";
294
313
  }
295
- if (!clientId) return this.createErrorResponse("invalid_client", "Client ID is required", 401);
314
+ if (!clientId) return this.createErrorResponse("invalid_client", {
315
+ description: "Client ID is required",
316
+ statusCode: 401
317
+ });
296
318
  const clientInfo = await this.getClient(env, clientId);
297
- if (!clientInfo) return this.createErrorResponse("invalid_client", "Client not found", 401);
319
+ if (!clientInfo) return this.createErrorResponse("invalid_client", {
320
+ description: "Client not found",
321
+ statusCode: 401
322
+ });
298
323
  if (!(clientInfo.tokenEndpointAuthMethod === "none")) {
299
- if (!clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: missing client_secret", 401);
300
- if (!clientInfo.clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: client has no registered secret", 401);
301
- if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: invalid client_secret", 401);
324
+ if (!clientSecret) return this.createErrorResponse("invalid_client", {
325
+ description: "Client authentication failed: missing client_secret",
326
+ statusCode: 401
327
+ });
328
+ if (!clientInfo.clientSecret) return this.createErrorResponse("invalid_client", {
329
+ description: "Client authentication failed: client has no registered secret",
330
+ statusCode: 401
331
+ });
332
+ if (await hashSecret(clientSecret) !== clientInfo.clientSecret) return this.createErrorResponse("invalid_client", {
333
+ description: "Client authentication failed: invalid client_secret",
334
+ statusCode: 401
335
+ });
302
336
  }
303
337
  return {
304
338
  body,
@@ -428,11 +462,31 @@ var OAuthProviderImpl = class OAuthProviderImpl {
428
462
  * @returns Response with token data or error
429
463
  */
430
464
  async handleTokenRequest(body, clientInfo, env) {
431
- const grantType = body.grant_type;
432
- if (grantType === GrantType.AUTHORIZATION_CODE) return this.handleAuthorizationCodeGrant(body, clientInfo, env);
433
- else if (grantType === GrantType.REFRESH_TOKEN) return this.handleRefreshTokenGrant(body, clientInfo, env);
434
- else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return this.handleTokenExchangeGrant(body, clientInfo, env);
435
- else return this.createErrorResponse("unsupported_grant_type", "Grant type not supported");
465
+ try {
466
+ const grantType = body.grant_type;
467
+ if (grantType === GrantType.AUTHORIZATION_CODE) return await this.handleAuthorizationCodeGrant(body, clientInfo, env);
468
+ else if (grantType === GrantType.REFRESH_TOKEN) return await this.handleRefreshTokenGrant(body, clientInfo, env);
469
+ else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return await this.handleTokenExchangeGrant(body, clientInfo, env);
470
+ else return this.createErrorResponse("unsupported_grant_type", { description: "Grant type not supported" });
471
+ } catch (error) {
472
+ const response = this.createOAuthErrorResponse(error);
473
+ if (response) return response;
474
+ throw error;
475
+ }
476
+ }
477
+ /**
478
+ * Build a structured OAuth `/token` error response from an OAuth error.
479
+ *
480
+ * The supported form is throwing this package's exported `OAuthError`.
481
+ * Anything else is re-thrown so unexpected failures still surface as 500s.
482
+ *
483
+ * Use `headers['Retry-After']` for rate-limit / transient-failure backoff
484
+ * hints (see RFC 7231 §7.1.3 — either an integer seconds value or an
485
+ * HTTP-date is allowed).
486
+ */
487
+ createOAuthErrorResponse(error) {
488
+ if (!(error instanceof OAuthError)) return void 0;
489
+ return this.createErrorResponse(error.code, error.options);
436
490
  }
437
491
  /**
438
492
  * Handles the authorization code grant type
@@ -446,27 +500,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
446
500
  const code = body.code;
447
501
  const redirectUri = body.redirect_uri;
448
502
  const codeVerifier = body.code_verifier;
449
- if (!code) return this.createErrorResponse("invalid_request", "Authorization code is required");
503
+ if (!code) return this.createErrorResponse("invalid_request", { description: "Authorization code is required" });
450
504
  const codeParts = code.split(":");
451
- if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", "Invalid authorization code format");
505
+ if (codeParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code format" });
452
506
  const [userId, grantId, _] = codeParts;
453
507
  const grantKey = `grant:${userId}:${grantId}`;
454
508
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
455
- if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
509
+ if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found or authorization code expired" });
456
510
  if (!grantData.authCodeId) {
457
511
  try {
458
512
  await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
459
513
  } catch {}
460
- return this.createErrorResponse("invalid_grant", "Authorization code already used");
514
+ return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
461
515
  }
462
- if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", "Invalid authorization code");
463
- if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
516
+ if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", { description: "Invalid authorization code" });
517
+ if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
464
518
  const isPkceEnabled = !!grantData.codeChallenge;
465
- if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
466
- if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
467
- if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier provided for a flow that did not use PKCE");
519
+ if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", { description: "redirect_uri is required when not using PKCE" });
520
+ if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", { description: "Invalid redirect URI" });
521
+ if (!isPkceEnabled && codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier provided for a flow that did not use PKCE" });
468
522
  if (isPkceEnabled) {
469
- if (!codeVerifier) return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
523
+ if (!codeVerifier) return this.createErrorResponse("invalid_request", { description: "code_verifier is required for PKCE" });
470
524
  let calculatedChallenge;
471
525
  if (grantData.codeChallengeMethod === "S256") {
472
526
  const data = new TextEncoder().encode(codeVerifier);
@@ -474,7 +528,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
474
528
  const hashArray = Array.from(new Uint8Array(hashBuffer));
475
529
  calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
476
530
  } else calculatedChallenge = codeVerifier;
477
- if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
531
+ if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", { description: "Invalid PKCE code_verifier" });
478
532
  }
479
533
  let accessTokenTTL = this.options.accessTokenTTL;
480
534
  let refreshTokenTTL = this.options.refreshTokenTTL;
@@ -541,10 +595,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
541
595
  if (body.resource && grantData.resource) {
542
596
  const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
543
597
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
544
- 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");
598
+ for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
545
599
  }
546
600
  const audience = parseResourceParameter(body.resource || grantData.resource);
547
- if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
601
+ if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
548
602
  const tokenResponse = {
549
603
  access_token: await this.createAccessToken({
550
604
  userId,
@@ -575,20 +629,20 @@ var OAuthProviderImpl = class OAuthProviderImpl {
575
629
  */
576
630
  async handleRefreshTokenGrant(body, clientInfo, env) {
577
631
  const refreshToken = body.refresh_token;
578
- if (!refreshToken) return this.createErrorResponse("invalid_request", "Refresh token is required");
632
+ if (!refreshToken) return this.createErrorResponse("invalid_request", { description: "Refresh token is required" });
579
633
  const tokenParts = refreshToken.split(":");
580
- if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", "Invalid token format");
634
+ if (tokenParts.length !== 3) return this.createErrorResponse("invalid_grant", { description: "Invalid token format" });
581
635
  const [userId, grantId, _] = tokenParts;
582
636
  const providedTokenHash = await generateTokenId(refreshToken);
583
637
  const grantKey = `grant:${userId}:${grantId}`;
584
638
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
585
- if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found");
639
+ if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found" });
586
640
  const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
587
641
  const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
588
- if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", "Invalid refresh token");
589
- if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", "Client ID mismatch");
642
+ if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", { description: "Invalid refresh token" });
643
+ if (grantData.clientId !== clientInfo.clientId) return this.createErrorResponse("invalid_grant", { description: "Client ID mismatch" });
590
644
  if (grantData.expiresAt !== void 0) {
591
- if (Math.floor(Date.now() / 1e3) >= grantData.expiresAt) return this.createErrorResponse("invalid_grant", "Refresh token has expired");
645
+ if (Math.floor(Date.now() / 1e3) >= grantData.expiresAt) return this.createErrorResponse("invalid_grant", { description: "Refresh token has expired" });
592
646
  }
593
647
  const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
594
648
  const accessTokenId = await generateTokenId(newAccessToken);
@@ -623,7 +677,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
623
677
  }
624
678
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
625
679
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
626
- if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", "refreshTokenTTL cannot be changed during refresh token exchange");
680
+ if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", { description: "refreshTokenTTL cannot be changed during refresh token exchange" });
627
681
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
628
682
  }
629
683
  if (grantPropsChanged) {
@@ -662,10 +716,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
662
716
  if (body.resource && grantData.resource) {
663
717
  const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
664
718
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
665
- 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");
719
+ for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) return this.createErrorResponse("invalid_target", { description: "Requested resource was not included in the authorization request" });
666
720
  }
667
721
  const audience = parseResourceParameter(body.resource || grantData.resource);
668
- if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
722
+ if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
669
723
  const accessTokenData = {
670
724
  id: accessTokenId,
671
725
  grantId,
@@ -681,7 +735,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
681
735
  encryptedProps: encryptedAccessTokenProps
682
736
  }
683
737
  };
684
- await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
738
+ try {
739
+ await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
740
+ } catch (error) {
741
+ this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
742
+ throw error;
743
+ }
685
744
  const tokenResponse = {
686
745
  access_token: newAccessToken,
687
746
  token_type: "bearer",
@@ -709,10 +768,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
709
768
  */
710
769
  async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
711
770
  const tokenSummary = await this.unwrapToken(subjectToken, env);
712
- if (!tokenSummary) throw new OAuthError("invalid_grant", "Invalid or expired subject token");
771
+ if (!tokenSummary) throw new OAuthError("invalid_grant", { description: "Invalid or expired subject token" });
713
772
  const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
714
773
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
715
- if (!grantData) throw new OAuthError("invalid_grant", "Grant not found");
774
+ if (!grantData) throw new OAuthError("invalid_grant", { description: "Grant not found" });
716
775
  let tokenScopes = this.downscope(requestedScopes, grantData.scope);
717
776
  const originOnly = !!this.options.resourceMatchOriginOnly;
718
777
  let newAudience = tokenSummary.audience;
@@ -720,21 +779,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
720
779
  if (grantData.resource) {
721
780
  const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
722
781
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
723
- 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");
782
+ for (const requested of requestedResources) if (!grantedResources.some((granted) => resourceMatches(requested, granted, originOnly))) throw new OAuthError("invalid_target", { description: "Requested resource was not included in the authorization request" });
724
783
  }
725
784
  const parsedResource = parseResourceParameter(requestedResource);
726
- if (!parsedResource) throw new OAuthError("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
785
+ if (!parsedResource) throw new OAuthError("invalid_target", { description: "The resource parameter must be a valid absolute URI without a fragment" });
727
786
  newAudience = parsedResource;
728
787
  }
729
788
  const now = Math.floor(Date.now() / 1e3);
730
789
  const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
731
790
  let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
732
791
  if (expiresIn !== void 0) {
733
- if (expiresIn <= 0) throw new OAuthError("invalid_request", "Invalid expires_in parameter");
792
+ if (expiresIn <= 0) throw new OAuthError("invalid_request", { description: "Invalid expires_in parameter" });
734
793
  accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
735
794
  } else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
736
795
  const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
737
- if (!subjectTokenData) throw new OAuthError("invalid_grant", "Subject token data not found");
796
+ if (!subjectTokenData) throw new OAuthError("invalid_grant", { description: "Subject token data not found" });
738
797
  const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
739
798
  let accessTokenEncryptionKey = encryptionKey;
740
799
  let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
@@ -798,25 +857,26 @@ var OAuthProviderImpl = class OAuthProviderImpl {
798
857
  const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
799
858
  const requestedScope = body.scope;
800
859
  const requestedResource = body.resource;
801
- if (!subjectToken) return this.createErrorResponse("invalid_request", "subject_token is required");
802
- if (!subjectTokenType) return this.createErrorResponse("invalid_request", "subject_token_type is required");
803
- if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token subject_token_type is supported");
804
- if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token requested_token_type is supported");
860
+ if (!subjectToken) return this.createErrorResponse("invalid_request", { description: "subject_token is required" });
861
+ if (!subjectTokenType) return this.createErrorResponse("invalid_request", { description: "subject_token_type is required" });
862
+ if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token subject_token_type is supported" });
863
+ if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", { description: "Only access_token requested_token_type is supported" });
805
864
  let requestedScopes;
806
865
  if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
807
866
  else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
808
- else return this.createErrorResponse("invalid_request", "Invalid scope parameter format");
867
+ else return this.createErrorResponse("invalid_request", { description: "Invalid scope parameter format" });
809
868
  let expiresIn;
810
869
  if (body.expires_in !== void 0) {
811
870
  const requestedTTL = parseInt(body.expires_in, 10);
812
- if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", "Invalid expires_in parameter");
871
+ if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", { description: "Invalid expires_in parameter" });
813
872
  expiresIn = requestedTTL;
814
873
  }
815
874
  try {
816
875
  const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
817
876
  return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
818
877
  } catch (error) {
819
- if (error instanceof OAuthError) return this.createErrorResponse(error.code, error.message);
878
+ const response = this.createOAuthErrorResponse(error);
879
+ if (response) return response;
820
880
  throw error;
821
881
  }
822
882
  }
@@ -838,7 +898,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
838
898
  */
839
899
  async revokeToken(body, env) {
840
900
  const token = body.token;
841
- if (!token) return this.createErrorResponse("invalid_request", "Token parameter is required");
901
+ if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" });
842
902
  const tokenParts = token.split(":");
843
903
  if (tokenParts.length !== 3) return new Response("", { status: 200 });
844
904
  const [userId, grantId, _] = tokenParts;
@@ -896,20 +956,35 @@ var OAuthProviderImpl = class OAuthProviderImpl {
896
956
  * @returns Response with client registration data or error
897
957
  */
898
958
  async handleClientRegistration(request, env) {
899
- if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", "Client registration is not enabled", 501);
900
- if (request.method !== "POST") return this.createErrorResponse("invalid_request", "Method not allowed", 405);
901
- if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
959
+ if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", {
960
+ description: "Client registration is not enabled",
961
+ statusCode: 501
962
+ });
963
+ if (request.method !== "POST") return this.createErrorResponse("invalid_request", {
964
+ description: "Method not allowed",
965
+ statusCode: 405
966
+ });
967
+ if (parseInt(request.headers.get("Content-Length") || "0", 10) > 1048576) return this.createErrorResponse("invalid_request", {
968
+ description: "Request payload too large, must be under 1 MiB",
969
+ statusCode: 413
970
+ });
902
971
  let clientMetadata;
903
972
  try {
904
973
  const text = await request.text();
905
- if (text.length > 1048576) return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
974
+ if (text.length > 1048576) return this.createErrorResponse("invalid_request", {
975
+ description: "Request payload too large, must be under 1 MiB",
976
+ statusCode: 413
977
+ });
906
978
  clientMetadata = JSON.parse(text);
907
979
  } catch (error) {
908
- return this.createErrorResponse("invalid_request", "Invalid JSON payload", 400);
980
+ return this.createErrorResponse("invalid_request", {
981
+ description: "Invalid JSON payload",
982
+ statusCode: 400
983
+ });
909
984
  }
910
985
  const authMethod = OAuthProviderImpl.validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
911
986
  const isPublicClient = authMethod === "none";
912
- if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", "Public client registration is not allowed");
987
+ if (isPublicClient && this.options.disallowPublicClientRegistration) return this.createErrorResponse("invalid_client_metadata", { description: "Public client registration is not allowed" });
913
988
  const clientId = generateRandomString(16);
914
989
  let clientSecret;
915
990
  let hashedSecret;
@@ -943,9 +1018,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
943
1018
  };
944
1019
  if (!isPublicClient && hashedSecret) clientInfo.clientSecret = hashedSecret;
945
1020
  } catch (error) {
946
- return this.createErrorResponse("invalid_client_metadata", error instanceof Error ? error.message : "Invalid client metadata");
1021
+ return this.createErrorResponse("invalid_client_metadata", { description: error instanceof Error ? error.message : "Invalid client metadata" });
947
1022
  }
948
- await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo));
1023
+ const clientKvOptions = {};
1024
+ if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
1025
+ await env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(clientInfo), clientKvOptions);
949
1026
  const response = {
950
1027
  client_id: clientInfo.clientId,
951
1028
  redirect_uris: clientInfo.redirectUris,
@@ -964,7 +1041,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
964
1041
  };
965
1042
  if (clientSecret) {
966
1043
  response.client_secret = clientSecret;
967
- response.client_secret_expires_at = 0;
1044
+ response.client_secret_expires_at = this.options.clientRegistrationTTL && clientInfo.registrationDate ? clientInfo.registrationDate + this.options.clientRegistrationTTL : 0;
968
1045
  response.client_secret_issued_at = clientInfo.registrationDate;
969
1046
  }
970
1047
  return new Response(JSON.stringify(response), {
@@ -983,7 +1060,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
983
1060
  const url = new URL(request.url);
984
1061
  const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
985
1062
  const authHeader = request.headers.get("Authorization");
986
- 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") });
1063
+ if (!authHeader || !authHeader.startsWith("Bearer ")) return this.createErrorResponse("invalid_token", {
1064
+ description: "Missing or invalid access token",
1065
+ statusCode: 401,
1066
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Missing or invalid access token") }
1067
+ });
987
1068
  const accessToken = authHeader.substring(7);
988
1069
  const parts = accessToken.split(":");
989
1070
  const isPossiblyInternalFormat = parts.length === 3;
@@ -995,14 +1076,26 @@ var OAuthProviderImpl = class OAuthProviderImpl {
995
1076
  const id = await generateTokenId(accessToken);
996
1077
  tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
997
1078
  }
998
- if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
1079
+ if (!tokenData && !this.options.resolveExternalToken) return this.createErrorResponse("invalid_token", {
1080
+ description: "Invalid access token",
1081
+ statusCode: 401,
1082
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
1083
+ });
999
1084
  if (tokenData) {
1000
1085
  const now = Math.floor(Date.now() / 1e3);
1001
- if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", "Access token expired", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
1086
+ if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", {
1087
+ description: "Access token expired",
1088
+ statusCode: 401,
1089
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
1090
+ });
1002
1091
  if (tokenData.audience) {
1003
1092
  const requestUrl = new URL(request.url);
1004
1093
  const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
1005
- if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") });
1094
+ if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
1095
+ description: "Token audience does not match resource server",
1096
+ statusCode: 401,
1097
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") }
1098
+ });
1006
1099
  }
1007
1100
  ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
1008
1101
  } else if (this.options.resolveExternalToken) {
@@ -1011,17 +1104,28 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1011
1104
  request,
1012
1105
  env
1013
1106
  });
1014
- if (!ext) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") });
1107
+ if (!ext) return this.createErrorResponse("invalid_token", {
1108
+ description: "Invalid access token",
1109
+ statusCode: 401,
1110
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token") }
1111
+ });
1015
1112
  if (ext.audience) {
1016
1113
  const requestUrl = new URL(request.url);
1017
1114
  const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
1018
- if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") });
1115
+ if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", {
1116
+ description: "Token audience does not match resource server",
1117
+ statusCode: 401,
1118
+ headers: { "WWW-Authenticate": this.buildWwwAuthenticateHeader(resourceMetadataUrl, "invalid_token", "Invalid audience") }
1119
+ });
1019
1120
  }
1020
1121
  ctx.props = ext.props;
1021
1122
  }
1022
1123
  if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
1023
1124
  const apiHandler = this.findApiHandlerForUrl(url);
1024
- if (!apiHandler) return this.createErrorResponse("invalid_request", "No handler found for API route", 404);
1125
+ if (!apiHandler) return this.createErrorResponse("invalid_request", {
1126
+ description: "No handler found for API route",
1127
+ statusCode: 404
1128
+ });
1025
1129
  if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
1026
1130
  else return new apiHandler.handler(ctx, env).fetch(request);
1027
1131
  }
@@ -1043,7 +1147,24 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1043
1147
  */
1044
1148
  async saveGrantWithTTL(env, grantKey, grantData, now) {
1045
1149
  const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
1046
- await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
1150
+ try {
1151
+ await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
1152
+ } catch (error) {
1153
+ this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
1154
+ throw error;
1155
+ }
1156
+ }
1157
+ throwRetryableTokenStorageErrorIfKvRateLimited(error) {
1158
+ if (!this.isKvRateLimitError(error)) return;
1159
+ throw new OAuthError("temporarily_unavailable", {
1160
+ description: "Token issuance is temporarily unavailable; retry shortly",
1161
+ statusCode: 429,
1162
+ headers: { "Retry-After": "30" }
1163
+ });
1164
+ }
1165
+ isKvRateLimitError(error) {
1166
+ if (!(error instanceof Error)) return false;
1167
+ return /KV .*failed: 429 Too Many Requests/i.test(error.message) || /429 Too Many Requests/i.test(error.message);
1047
1168
  }
1048
1169
  /**
1049
1170
  * Fetches client information from KV storage or via CIMD (Client ID Metadata Document)
@@ -1100,7 +1221,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1100
1221
  encryptedProps
1101
1222
  }
1102
1223
  };
1103
- await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: expiresIn });
1224
+ try {
1225
+ await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: expiresIn });
1226
+ } catch (error) {
1227
+ this.throwRetryableTokenStorageErrorIfKvRateLimited(error);
1228
+ throw error;
1229
+ }
1104
1230
  return accessToken;
1105
1231
  }
1106
1232
  /**
@@ -1123,7 +1249,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1123
1249
  return !!(typeof Cloudflare !== "undefined" && Cloudflare.compatibilityFlags ? Cloudflare.compatibilityFlags : null)?.global_fetch_strictly_public;
1124
1250
  }
1125
1251
  /**
1126
- * Checks if a client_id is a CIMD URL (HTTPS with non-root path)
1252
+ * Checks if a client_id is a CIMD URL (HTTPS with non-root path).
1253
+ * Not private because OAuthHelpersImpl needs access for purgeExpiredData.
1127
1254
  */
1128
1255
  isClientMetadataUrl(clientId) {
1129
1256
  try {
@@ -1262,17 +1389,18 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1262
1389
  /**
1263
1390
  * Helper function to create OAuth error responses
1264
1391
  * @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
1265
- * @param description - Human-readable error description
1266
- * @param status - HTTP status code (default: 400)
1267
- * @param headers - Additional headers to include
1392
+ * @param options - Error response options
1268
1393
  * @returns A Response object with the error
1269
1394
  */
1270
- createErrorResponse(code, description, status = 400, headers = {}) {
1395
+ createErrorResponse(code, options) {
1396
+ const { description } = options;
1397
+ const responseStatus = options.statusCode ?? 400;
1398
+ const responseHeaders = options.headers ?? {};
1271
1399
  const customErrorResponse = this.options.onError?.({
1272
1400
  code,
1273
1401
  description,
1274
- status,
1275
- headers
1402
+ status: responseStatus,
1403
+ headers: responseHeaders
1276
1404
  });
1277
1405
  if (customErrorResponse) return customErrorResponse;
1278
1406
  const body = JSON.stringify({
@@ -1280,23 +1408,66 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1280
1408
  error_description: description
1281
1409
  });
1282
1410
  return new Response(body, {
1283
- status,
1411
+ status: responseStatus,
1284
1412
  headers: {
1285
1413
  "Content-Type": "application/json",
1286
- ...headers
1414
+ ...responseHeaders
1287
1415
  }
1288
1416
  });
1289
1417
  }
1290
1418
  };
1291
1419
  /**
1292
- * Error class for OAuth operations
1293
- * Carries OAuth error code and description for proper error responses
1420
+ * Structured OAuth 2.0 error.
1421
+ *
1422
+ * Throw from a `tokenExchangeCallback` (or any code it calls — the error
1423
+ * propagates naturally up through deep call stacks) to surface a standard
1424
+ * `/token` error response (`{ error, error_description }`) instead of a
1425
+ * generic `500 Internal Server Error`.
1426
+ *
1427
+ * Anything thrown that is **not** an `OAuthError` continues to surface as
1428
+ * a 500 so unexpected failures remain visible — the provider does not
1429
+ * catch-everything-and-return-400.
1430
+ *
1431
+ * @example
1432
+ * ```ts
1433
+ * import { OAuthError } from '@cloudflare/workers-oauth-provider';
1434
+ *
1435
+ * tokenExchangeCallback: async (options) => {
1436
+ * if (options.grantType === 'refresh_token') {
1437
+ * // refreshUpstream() may throw OAuthError from any depth
1438
+ * return { newProps: await refreshUpstream(options.props) };
1439
+ * }
1440
+ * }
1441
+ *
1442
+ * async function refreshUpstream(props) {
1443
+ * const res = await fetch(...);
1444
+ * if (res.status === 401) {
1445
+ * throw new OAuthError('invalid_grant', { description: 'upstream refresh token is invalid' });
1446
+ * }
1447
+ * if (res.status === 429) {
1448
+ * // Mirror upstream's Retry-After if present, otherwise pick a default.
1449
+ * throw new OAuthError('temporarily_unavailable', {
1450
+ * description: 'upstream rate limited',
1451
+ * statusCode: 429,
1452
+ * headers: { 'Retry-After': res.headers.get('retry-after') ?? '60' },
1453
+ * });
1454
+ * }
1455
+ * return await res.json();
1456
+ * }
1457
+ * ```
1294
1458
  */
1295
1459
  var OAuthError = class extends Error {
1296
- constructor(code, message) {
1297
- super(message);
1298
- this.code = code;
1460
+ constructor(code, options) {
1461
+ super(options.description);
1299
1462
  this.name = "OAuthError";
1463
+ this.code = code;
1464
+ this.options = {
1465
+ ...options,
1466
+ statusCode: options.statusCode ?? 400
1467
+ };
1468
+ this.description = this.options.description;
1469
+ this.statusCode = this.options.statusCode;
1470
+ this.headers = this.options.headers;
1300
1471
  }
1301
1472
  };
1302
1473
  /**
@@ -1304,6 +1475,19 @@ var OAuthError = class extends Error {
1304
1475
  */
1305
1476
  const DEFAULT_ACCESS_TOKEN_TTL = 3600;
1306
1477
  /**
1478
+ * Default expiration time for refresh tokens (30 days in seconds)
1479
+ */
1480
+ const DEFAULT_REFRESH_TOKEN_TTL = 720 * 60 * 60;
1481
+ /**
1482
+ * Default expiration time for dynamically registered clients (90 days in seconds)
1483
+ */
1484
+ const DEFAULT_CLIENT_REGISTRATION_TTL = 2160 * 60 * 60;
1485
+ /**
1486
+ * Default batch size for purgeExpiredData. Conservative to stay within
1487
+ * Cloudflare's 1000 subrequest limit per invocation.
1488
+ */
1489
+ const DEFAULT_PURGE_BATCH_SIZE = 50;
1490
+ /**
1307
1491
  * Length of generated token strings
1308
1492
  */
1309
1493
  const TOKEN_LENGTH = 32;
@@ -1846,17 +2030,32 @@ var OAuthHelpersImpl = class {
1846
2030
  };
1847
2031
  if (!isPublicClient && secretToStore) updatedClient.clientSecret = secretToStore;
1848
2032
  else delete updatedClient.clientSecret;
1849
- await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient));
2033
+ const clientKvOptions = {};
2034
+ if (this.provider.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.provider.options.clientRegistrationTTL;
2035
+ await this.env.OAUTH_KV.put(`client:${clientId}`, JSON.stringify(updatedClient), clientKvOptions);
1850
2036
  const response = { ...updatedClient };
1851
2037
  if (!isPublicClient && originalSecret) response.clientSecret = originalSecret;
1852
2038
  return response;
1853
2039
  }
1854
2040
  /**
1855
- * Deletes an OAuth client
2041
+ * Deletes an OAuth client and revokes all associated grants across all users.
1856
2042
  * @param clientId - The ID of the client to delete
1857
2043
  * @returns A Promise resolving when the deletion is confirmed.
1858
2044
  */
1859
2045
  async deleteClient(clientId) {
2046
+ let cursor;
2047
+ let allProcessed = false;
2048
+ while (!allProcessed) {
2049
+ const listOptions = { prefix: "grant:" };
2050
+ if (cursor) listOptions.cursor = cursor;
2051
+ const result = await this.env.OAUTH_KV.list(listOptions);
2052
+ for (const key of result.keys) {
2053
+ const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
2054
+ if (grantData && grantData.clientId === clientId) await this.revokeGrant(grantData.id, grantData.userId);
2055
+ }
2056
+ if (result.list_complete) allProcessed = true;
2057
+ else cursor = result.cursor;
2058
+ }
1860
2059
  await this.env.OAUTH_KV.delete(`client:${clientId}`);
1861
2060
  }
1862
2061
  /**
@@ -1937,6 +2136,92 @@ var OAuthHelpersImpl = class {
1937
2136
  if (!clientInfo) throw new Error("Client not found");
1938
2137
  return await this.provider.exchangeToken(options.subjectToken, options.scope, options.aud, options.expiresIn, clientInfo, this.env);
1939
2138
  }
2139
+ async purgeExpiredData(options) {
2140
+ const batchSize = options?.batchSize ?? DEFAULT_PURGE_BATCH_SIZE;
2141
+ const purgeOrphanedGrants = options?.purgeOrphanedGrants !== false;
2142
+ const purgeExpiredGrants = options?.purgeExpiredGrants !== false;
2143
+ const purgeOrphanedTokens = options?.purgeOrphanedTokens !== false;
2144
+ const now = Math.floor(Date.now() / 1e3);
2145
+ const result = {
2146
+ grantsChecked: 0,
2147
+ grantsPurged: 0,
2148
+ tokensChecked: 0,
2149
+ tokensPurged: 0,
2150
+ done: false
2151
+ };
2152
+ if (purgeOrphanedGrants || purgeExpiredGrants) {
2153
+ const knownGoodClients = /* @__PURE__ */ new Set();
2154
+ const knownMissingClients = /* @__PURE__ */ new Set();
2155
+ let grantCursor;
2156
+ let grantsDone = false;
2157
+ while (!grantsDone && result.grantsChecked < batchSize) {
2158
+ const listOptions = {
2159
+ prefix: "grant:",
2160
+ limit: Math.min(1e3, batchSize - result.grantsChecked)
2161
+ };
2162
+ if (grantCursor) listOptions.cursor = grantCursor;
2163
+ const page = await this.env.OAUTH_KV.list(listOptions);
2164
+ for (const key of page.keys) {
2165
+ if (result.grantsChecked >= batchSize) break;
2166
+ result.grantsChecked++;
2167
+ const grantData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
2168
+ if (!grantData) continue;
2169
+ let shouldPurge = false;
2170
+ if (purgeExpiredGrants && grantData.expiresAt !== void 0 && now >= grantData.expiresAt) shouldPurge = true;
2171
+ if (!shouldPurge && purgeOrphanedGrants && !this.provider.isClientMetadataUrl(grantData.clientId)) {
2172
+ if (knownMissingClients.has(grantData.clientId)) shouldPurge = true;
2173
+ else if (!knownGoodClients.has(grantData.clientId)) if (await this.env.OAUTH_KV.get(`client:${grantData.clientId}`, { type: "json" })) knownGoodClients.add(grantData.clientId);
2174
+ else {
2175
+ knownMissingClients.add(grantData.clientId);
2176
+ shouldPurge = true;
2177
+ }
2178
+ }
2179
+ if (shouldPurge) {
2180
+ await this.revokeGrant(grantData.id, grantData.userId);
2181
+ result.grantsPurged++;
2182
+ }
2183
+ }
2184
+ if (page.list_complete) grantsDone = true;
2185
+ else grantCursor = page.cursor;
2186
+ }
2187
+ if (!grantsDone) return result;
2188
+ }
2189
+ if (purgeOrphanedTokens) {
2190
+ const knownGoodGrants = /* @__PURE__ */ new Set();
2191
+ const knownMissingGrants = /* @__PURE__ */ new Set();
2192
+ let tokenCursor;
2193
+ let tokensDone = false;
2194
+ while (!tokensDone && result.tokensChecked < batchSize) {
2195
+ const listOptions = {
2196
+ prefix: "token:",
2197
+ limit: Math.min(1e3, batchSize - result.tokensChecked)
2198
+ };
2199
+ if (tokenCursor) listOptions.cursor = tokenCursor;
2200
+ const page = await this.env.OAUTH_KV.list(listOptions);
2201
+ for (const key of page.keys) {
2202
+ if (result.tokensChecked >= batchSize) break;
2203
+ result.tokensChecked++;
2204
+ const tokenData = await this.env.OAUTH_KV.get(key.name, { type: "json" });
2205
+ if (!tokenData) continue;
2206
+ const grantKey = `grant:${tokenData.userId}:${tokenData.grantId}`;
2207
+ if (knownMissingGrants.has(grantKey)) {
2208
+ await this.env.OAUTH_KV.delete(key.name);
2209
+ result.tokensPurged++;
2210
+ } else if (!knownGoodGrants.has(grantKey)) if (await this.env.OAUTH_KV.get(grantKey)) knownGoodGrants.add(grantKey);
2211
+ else {
2212
+ knownMissingGrants.add(grantKey);
2213
+ await this.env.OAUTH_KV.delete(key.name);
2214
+ result.tokensPurged++;
2215
+ }
2216
+ }
2217
+ if (page.list_complete) tokensDone = true;
2218
+ else tokenCursor = page.cursor;
2219
+ }
2220
+ if (!tokensDone) return result;
2221
+ }
2222
+ result.done = true;
2223
+ return result;
2224
+ }
1940
2225
  };
1941
2226
  /**
1942
2227
  * Default export of the OAuth provider
@@ -1945,4 +2230,4 @@ var OAuthHelpersImpl = class {
1945
2230
  var oauth_provider_default = OAuthProvider;
1946
2231
 
1947
2232
  //#endregion
1948
- export { GrantType, OAuthProvider, oauth_provider_default as default, getOAuthApi };
2233
+ export { GrantType, OAuthError, OAuthProvider, oauth_provider_default as default, getOAuthApi };
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.6.0",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",