@cloudflare/workers-oauth-provider 0.5.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
@@ -306,6 +306,52 @@ The `accessTokenTTL` override is particularly useful when the application is als
306
306
 
307
307
  The `props` values are end-to-end encrypted, so they can safely contain sensitive information.
308
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
+
309
355
  ## Custom Error Responses
310
356
 
311
357
  By using the `onError` option, you can emit notifications or take other actions when an error response was to be emitted:
@@ -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.
@@ -889,5 +899,86 @@ declare class OAuthProvider<Env = Cloudflare.Env> {
889
899
  * @returns An instance of OAuthHelpers
890
900
  */
891
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
+ }
892
983
  //#endregion
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 };
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 };
@@ -285,10 +285,16 @@ var OAuthProviderImpl = class OAuthProviderImpl {
285
285
  * @returns Promise with parsed body and client info, or error response
286
286
  */
287
287
  async parseTokenEndpointRequest(request, env) {
288
- 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
+ });
289
292
  let contentType = request.headers.get("Content-Type") || "";
290
293
  let body = {};
291
- 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
+ });
292
298
  const formData = await request.formData();
293
299
  for (const [key, value] of formData.entries()) {
294
300
  const allValues = formData.getAll(key);
@@ -305,13 +311,28 @@ var OAuthProviderImpl = class OAuthProviderImpl {
305
311
  clientId = body.client_id;
306
312
  clientSecret = body.client_secret || "";
307
313
  }
308
- 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
+ });
309
318
  const clientInfo = await this.getClient(env, clientId);
310
- 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
+ });
311
323
  if (!(clientInfo.tokenEndpointAuthMethod === "none")) {
312
- if (!clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: missing client_secret", 401);
313
- if (!clientInfo.clientSecret) return this.createErrorResponse("invalid_client", "Client authentication failed: client has no registered secret", 401);
314
- 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
+ });
315
336
  }
316
337
  return {
317
338
  body,
@@ -441,11 +462,31 @@ var OAuthProviderImpl = class OAuthProviderImpl {
441
462
  * @returns Response with token data or error
442
463
  */
443
464
  async handleTokenRequest(body, clientInfo, env) {
444
- const grantType = body.grant_type;
445
- if (grantType === GrantType.AUTHORIZATION_CODE) return this.handleAuthorizationCodeGrant(body, clientInfo, env);
446
- else if (grantType === GrantType.REFRESH_TOKEN) return this.handleRefreshTokenGrant(body, clientInfo, env);
447
- else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return this.handleTokenExchangeGrant(body, clientInfo, env);
448
- 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);
449
490
  }
450
491
  /**
451
492
  * Handles the authorization code grant type
@@ -459,27 +500,27 @@ var OAuthProviderImpl = class OAuthProviderImpl {
459
500
  const code = body.code;
460
501
  const redirectUri = body.redirect_uri;
461
502
  const codeVerifier = body.code_verifier;
462
- if (!code) return this.createErrorResponse("invalid_request", "Authorization code is required");
503
+ if (!code) return this.createErrorResponse("invalid_request", { description: "Authorization code is required" });
463
504
  const codeParts = code.split(":");
464
- 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" });
465
506
  const [userId, grantId, _] = codeParts;
466
507
  const grantKey = `grant:${userId}:${grantId}`;
467
508
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
468
- 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" });
469
510
  if (!grantData.authCodeId) {
470
511
  try {
471
512
  await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
472
513
  } catch {}
473
- return this.createErrorResponse("invalid_grant", "Authorization code already used");
514
+ return this.createErrorResponse("invalid_grant", { description: "Authorization code already used" });
474
515
  }
475
- if (await hashSecret(code) !== grantData.authCodeId) return this.createErrorResponse("invalid_grant", "Invalid authorization code");
476
- 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" });
477
518
  const isPkceEnabled = !!grantData.codeChallenge;
478
- if (!redirectUri && !isPkceEnabled) return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
479
- if (redirectUri && !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
480
- 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" });
481
522
  if (isPkceEnabled) {
482
- 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" });
483
524
  let calculatedChallenge;
484
525
  if (grantData.codeChallengeMethod === "S256") {
485
526
  const data = new TextEncoder().encode(codeVerifier);
@@ -487,7 +528,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
487
528
  const hashArray = Array.from(new Uint8Array(hashBuffer));
488
529
  calculatedChallenge = base64UrlEncode(String.fromCharCode(...hashArray));
489
530
  } else calculatedChallenge = codeVerifier;
490
- 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" });
491
532
  }
492
533
  let accessTokenTTL = this.options.accessTokenTTL;
493
534
  let refreshTokenTTL = this.options.refreshTokenTTL;
@@ -554,10 +595,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
554
595
  if (body.resource && grantData.resource) {
555
596
  const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
556
597
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
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");
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" });
558
599
  }
559
600
  const audience = parseResourceParameter(body.resource || grantData.resource);
560
- 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" });
561
602
  const tokenResponse = {
562
603
  access_token: await this.createAccessToken({
563
604
  userId,
@@ -588,20 +629,20 @@ var OAuthProviderImpl = class OAuthProviderImpl {
588
629
  */
589
630
  async handleRefreshTokenGrant(body, clientInfo, env) {
590
631
  const refreshToken = body.refresh_token;
591
- if (!refreshToken) return this.createErrorResponse("invalid_request", "Refresh token is required");
632
+ if (!refreshToken) return this.createErrorResponse("invalid_request", { description: "Refresh token is required" });
592
633
  const tokenParts = refreshToken.split(":");
593
- 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" });
594
635
  const [userId, grantId, _] = tokenParts;
595
636
  const providedTokenHash = await generateTokenId(refreshToken);
596
637
  const grantKey = `grant:${userId}:${grantId}`;
597
638
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
598
- if (!grantData) return this.createErrorResponse("invalid_grant", "Grant not found");
639
+ if (!grantData) return this.createErrorResponse("invalid_grant", { description: "Grant not found" });
599
640
  const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
600
641
  const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
601
- if (!isCurrentToken && !isPreviousToken) return this.createErrorResponse("invalid_grant", "Invalid refresh token");
602
- 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" });
603
644
  if (grantData.expiresAt !== void 0) {
604
- 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" });
605
646
  }
606
647
  const newAccessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
607
648
  const accessTokenId = await generateTokenId(newAccessToken);
@@ -636,7 +677,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
636
677
  }
637
678
  if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
638
679
  if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
639
- 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" });
640
681
  if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
641
682
  }
642
683
  if (grantPropsChanged) {
@@ -675,10 +716,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
675
716
  if (body.resource && grantData.resource) {
676
717
  const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
677
718
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
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");
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" });
679
720
  }
680
721
  const audience = parseResourceParameter(body.resource || grantData.resource);
681
- 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" });
682
723
  const accessTokenData = {
683
724
  id: accessTokenId,
684
725
  grantId,
@@ -694,7 +735,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
694
735
  encryptedProps: encryptedAccessTokenProps
695
736
  }
696
737
  };
697
- 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
+ }
698
744
  const tokenResponse = {
699
745
  access_token: newAccessToken,
700
746
  token_type: "bearer",
@@ -722,10 +768,10 @@ var OAuthProviderImpl = class OAuthProviderImpl {
722
768
  */
723
769
  async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
724
770
  const tokenSummary = await this.unwrapToken(subjectToken, env);
725
- 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" });
726
772
  const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
727
773
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
728
- if (!grantData) throw new OAuthError("invalid_grant", "Grant not found");
774
+ if (!grantData) throw new OAuthError("invalid_grant", { description: "Grant not found" });
729
775
  let tokenScopes = this.downscope(requestedScopes, grantData.scope);
730
776
  const originOnly = !!this.options.resourceMatchOriginOnly;
731
777
  let newAudience = tokenSummary.audience;
@@ -733,21 +779,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
733
779
  if (grantData.resource) {
734
780
  const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
735
781
  const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
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");
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" });
737
783
  }
738
784
  const parsedResource = parseResourceParameter(requestedResource);
739
- 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" });
740
786
  newAudience = parsedResource;
741
787
  }
742
788
  const now = Math.floor(Date.now() / 1e3);
743
789
  const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
744
790
  let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
745
791
  if (expiresIn !== void 0) {
746
- 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" });
747
793
  accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
748
794
  } else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
749
795
  const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
750
- 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" });
751
797
  const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
752
798
  let accessTokenEncryptionKey = encryptionKey;
753
799
  let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
@@ -811,25 +857,26 @@ var OAuthProviderImpl = class OAuthProviderImpl {
811
857
  const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
812
858
  const requestedScope = body.scope;
813
859
  const requestedResource = body.resource;
814
- if (!subjectToken) return this.createErrorResponse("invalid_request", "subject_token is required");
815
- if (!subjectTokenType) return this.createErrorResponse("invalid_request", "subject_token_type is required");
816
- if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token subject_token_type is supported");
817
- 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" });
818
864
  let requestedScopes;
819
865
  if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
820
866
  else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
821
- else return this.createErrorResponse("invalid_request", "Invalid scope parameter format");
867
+ else return this.createErrorResponse("invalid_request", { description: "Invalid scope parameter format" });
822
868
  let expiresIn;
823
869
  if (body.expires_in !== void 0) {
824
870
  const requestedTTL = parseInt(body.expires_in, 10);
825
- 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" });
826
872
  expiresIn = requestedTTL;
827
873
  }
828
874
  try {
829
875
  const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
830
876
  return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
831
877
  } catch (error) {
832
- if (error instanceof OAuthError) return this.createErrorResponse(error.code, error.message);
878
+ const response = this.createOAuthErrorResponse(error);
879
+ if (response) return response;
833
880
  throw error;
834
881
  }
835
882
  }
@@ -851,7 +898,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
851
898
  */
852
899
  async revokeToken(body, env) {
853
900
  const token = body.token;
854
- if (!token) return this.createErrorResponse("invalid_request", "Token parameter is required");
901
+ if (!token) return this.createErrorResponse("invalid_request", { description: "Token parameter is required" });
855
902
  const tokenParts = token.split(":");
856
903
  if (tokenParts.length !== 3) return new Response("", { status: 200 });
857
904
  const [userId, grantId, _] = tokenParts;
@@ -909,20 +956,35 @@ var OAuthProviderImpl = class OAuthProviderImpl {
909
956
  * @returns Response with client registration data or error
910
957
  */
911
958
  async handleClientRegistration(request, env) {
912
- if (!this.options.clientRegistrationEndpoint) return this.createErrorResponse("not_implemented", "Client registration is not enabled", 501);
913
- if (request.method !== "POST") return this.createErrorResponse("invalid_request", "Method not allowed", 405);
914
- 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
+ });
915
971
  let clientMetadata;
916
972
  try {
917
973
  const text = await request.text();
918
- 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
+ });
919
978
  clientMetadata = JSON.parse(text);
920
979
  } catch (error) {
921
- return this.createErrorResponse("invalid_request", "Invalid JSON payload", 400);
980
+ return this.createErrorResponse("invalid_request", {
981
+ description: "Invalid JSON payload",
982
+ statusCode: 400
983
+ });
922
984
  }
923
985
  const authMethod = OAuthProviderImpl.validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
924
986
  const isPublicClient = authMethod === "none";
925
- 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" });
926
988
  const clientId = generateRandomString(16);
927
989
  let clientSecret;
928
990
  let hashedSecret;
@@ -956,7 +1018,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
956
1018
  };
957
1019
  if (!isPublicClient && hashedSecret) clientInfo.clientSecret = hashedSecret;
958
1020
  } catch (error) {
959
- 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" });
960
1022
  }
961
1023
  const clientKvOptions = {};
962
1024
  if (this.options.clientRegistrationTTL !== void 0) clientKvOptions.expirationTtl = this.options.clientRegistrationTTL;
@@ -998,7 +1060,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
998
1060
  const url = new URL(request.url);
999
1061
  const resourceMetadataUrl = `${url.origin}/.well-known/oauth-protected-resource${url.pathname}`;
1000
1062
  const authHeader = request.headers.get("Authorization");
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") });
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
+ });
1002
1068
  const accessToken = authHeader.substring(7);
1003
1069
  const parts = accessToken.split(":");
1004
1070
  const isPossiblyInternalFormat = parts.length === 3;
@@ -1010,14 +1076,26 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1010
1076
  const id = await generateTokenId(accessToken);
1011
1077
  tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
1012
1078
  }
1013
- 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
+ });
1014
1084
  if (tokenData) {
1015
1085
  const now = Math.floor(Date.now() / 1e3);
1016
- 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
+ });
1017
1091
  if (tokenData.audience) {
1018
1092
  const requestUrl = new URL(request.url);
1019
1093
  const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
1020
- 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
+ });
1021
1099
  }
1022
1100
  ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
1023
1101
  } else if (this.options.resolveExternalToken) {
@@ -1026,17 +1104,28 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1026
1104
  request,
1027
1105
  env
1028
1106
  });
1029
- 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
+ });
1030
1112
  if (ext.audience) {
1031
1113
  const requestUrl = new URL(request.url);
1032
1114
  const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
1033
- 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
+ });
1034
1120
  }
1035
1121
  ctx.props = ext.props;
1036
1122
  }
1037
1123
  if (!env.OAUTH_PROVIDER) env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
1038
1124
  const apiHandler = this.findApiHandlerForUrl(url);
1039
- 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
+ });
1040
1129
  if (apiHandler.type === HandlerType.EXPORTED_HANDLER) return apiHandler.handler.fetch(request, env, ctx);
1041
1130
  else return new apiHandler.handler(ctx, env).fetch(request);
1042
1131
  }
@@ -1058,7 +1147,24 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1058
1147
  */
1059
1148
  async saveGrantWithTTL(env, grantKey, grantData, now) {
1060
1149
  const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
1061
- 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);
1062
1168
  }
1063
1169
  /**
1064
1170
  * Fetches client information from KV storage or via CIMD (Client ID Metadata Document)
@@ -1115,7 +1221,12 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1115
1221
  encryptedProps
1116
1222
  }
1117
1223
  };
1118
- 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
+ }
1119
1230
  return accessToken;
1120
1231
  }
1121
1232
  /**
@@ -1278,17 +1389,18 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1278
1389
  /**
1279
1390
  * Helper function to create OAuth error responses
1280
1391
  * @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
1281
- * @param description - Human-readable error description
1282
- * @param status - HTTP status code (default: 400)
1283
- * @param headers - Additional headers to include
1392
+ * @param options - Error response options
1284
1393
  * @returns A Response object with the error
1285
1394
  */
1286
- 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 ?? {};
1287
1399
  const customErrorResponse = this.options.onError?.({
1288
1400
  code,
1289
1401
  description,
1290
- status,
1291
- headers
1402
+ status: responseStatus,
1403
+ headers: responseHeaders
1292
1404
  });
1293
1405
  if (customErrorResponse) return customErrorResponse;
1294
1406
  const body = JSON.stringify({
@@ -1296,23 +1408,66 @@ var OAuthProviderImpl = class OAuthProviderImpl {
1296
1408
  error_description: description
1297
1409
  });
1298
1410
  return new Response(body, {
1299
- status,
1411
+ status: responseStatus,
1300
1412
  headers: {
1301
1413
  "Content-Type": "application/json",
1302
- ...headers
1414
+ ...responseHeaders
1303
1415
  }
1304
1416
  });
1305
1417
  }
1306
1418
  };
1307
1419
  /**
1308
- * Error class for OAuth operations
1309
- * 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
+ * ```
1310
1458
  */
1311
1459
  var OAuthError = class extends Error {
1312
- constructor(code, message) {
1313
- super(message);
1314
- this.code = code;
1460
+ constructor(code, options) {
1461
+ super(options.description);
1315
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;
1316
1471
  }
1317
1472
  };
1318
1473
  /**
@@ -2075,4 +2230,4 @@ var OAuthHelpersImpl = class {
2075
2230
  var oauth_provider_default = OAuthProvider;
2076
2231
 
2077
2232
  //#endregion
2078
- 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.5.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",