@cloudflare/workers-oauth-provider 0.9.1 → 0.10.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
@@ -36,7 +36,12 @@ See [Client registration](#client-registration) for the matching provider option
36
36
  The provider accepts either plain `ExportedHandler` objects or classes extending `WorkerEntrypoint`. This example uses both.
37
37
 
38
38
  ```ts
39
- import { OAuthProvider, type AuthRequest, type OAuthHelpers } from '@cloudflare/workers-oauth-provider';
39
+ import {
40
+ AuthorizationError,
41
+ OAuthProvider,
42
+ type AuthRequest,
43
+ type OAuthHelpers,
44
+ } from '@cloudflare/workers-oauth-provider';
40
45
  import { WorkerEntrypoint } from 'cloudflare:workers';
41
46
 
42
47
  interface AuthProps {
@@ -72,9 +77,18 @@ const defaultHandler: ExportedHandler<Env> = {
72
77
  let oauthRequest: AuthRequest;
73
78
  try {
74
79
  oauthRequest = await env.OAUTH_PROVIDER.parseAuthRequest(request);
75
- } catch {
76
- // Do not redirect until the client and redirect URI have been validated.
77
- return new Response('Invalid authorization request', { status: 400 });
80
+ } catch (error) {
81
+ if (!(error instanceof AuthorizationError)) throw error;
82
+ if (!error.redirectUri) {
83
+ // Unknown clients and invalid redirects must be rendered locally.
84
+ return new Response(error.description, { status: 400 });
85
+ }
86
+ const redirect = new URL(error.redirectUri);
87
+ redirect.searchParams.set('error', error.code);
88
+ redirect.searchParams.set('error_description', error.description);
89
+ if (error.state) redirect.searchParams.set('state', error.state);
90
+ if (error.issuer) redirect.searchParams.set('iss', error.issuer);
91
+ return Response.redirect(redirect, 302);
78
92
  }
79
93
 
80
94
  const client = await env.OAUTH_PROVIDER.lookupClient(oauthRequest.clientId);
@@ -228,7 +242,9 @@ A typical flow has three steps:
228
242
  2. Authenticate the user, show consent, and decide which scopes to grant.
229
243
  3. Call `completeAuthorization()` and redirect to its returned `redirectTo` URL.
230
244
 
231
- `completeAuthorization()` repeats response-type validation before writing a grant or revoking existing grants. The application remains responsible for rendering local authorization errors and for constructing any terminal OAuth error redirect only after client and redirect URI validation.
245
+ `parseAuthRequest()` throws an exported `AuthorizationError` for expected request validation failures. Its optional `redirectUri` is present only after the client and exact registered redirect URI have been validated. Without it, render the error locally and never redirect. With it, the application can safely construct an OAuth error redirect using the error's `code`, `description`, original `state`, and RFC 9207 `issuer`, as shown in the quick start.
246
+
247
+ `completeAuthorization()` repeats response-type validation before writing a grant or revoking existing grants. Validation errors from reconstructed requests are also typed as `AuthorizationError`, but applications should not construct redirects from untrusted reconstructed values; the redirect context is attached only by `parseAuthRequest()`.
232
248
 
233
249
  `completeAuthorization()` stores a new grant and, by default, revokes existing grants for the same user and client after the new grant is safely stored. Set `revokeExistingGrants: false` only when the application intentionally allows concurrent grants for the same user and client.
234
250
 
@@ -299,6 +299,29 @@ interface EmaOptions<Env = Cloudflare.Env> {
299
299
  }
300
300
  //#endregion
301
301
  //#region src/oauth-capabilities.d.ts
302
+ type AuthorizationErrorCode = 'invalid_request' | 'unauthorized_client' | 'access_denied' | 'unsupported_response_type' | 'invalid_scope' | 'server_error' | 'temporarily_unavailable';
303
+ interface AuthorizationErrorOptions {
304
+ /** Wire-safe OAuth authorization error description. */
305
+ description: string;
306
+ /** Exact registered redirect URI. Present only after client and redirect validation. */
307
+ redirectUri?: string;
308
+ /** Original client state, when supplied. */
309
+ state?: string;
310
+ /** Authorization server issuer for RFC 9207 error responses. */
311
+ issuer?: string;
312
+ }
313
+ /**
314
+ * Expected authorization-request validation failure. Absence of `redirectUri`
315
+ * means a caller MUST render locally and MUST NOT redirect.
316
+ */
317
+ declare class AuthorizationError extends Error {
318
+ readonly code: AuthorizationErrorCode;
319
+ readonly description: string;
320
+ readonly redirectUri?: string;
321
+ readonly state?: string;
322
+ readonly issuer?: string;
323
+ constructor(code: AuthorizationErrorCode, options: AuthorizationErrorOptions);
324
+ }
302
325
  declare function isValidOAuthScopeToken(scopeToken: string): boolean;
303
326
  //#endregion
304
327
  //#region src/oauth-provider.d.ts
@@ -1511,4 +1534,4 @@ declare function getJwtCryptoAlgorithms(alg: string): {
1511
1534
  verifyAlgorithm: Parameters<SubtleCrypto['verify']>[0];
1512
1535
  };
1513
1536
  //#endregion
1514
- export { AuthRequest, CimdFetchError, ClientInfo, ClientRegistrationCallbackOptions, ClientRegistrationCallbackResult, CompleteAuthorizationOptions, type EmaClaimsMapper, type EmaClaimsMapperInput, type EmaClaimsMapperResult, type EmaIdJagClaims, type EmaOptions, type EmaTrustedIssuer, type EmaTrustedIssuerResolver, type EmaTrustedIssuerResolverInput, type EmaValidationError, ExchangeTokenOptions, ExternalTokenError, ExternalTokenErrorOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthError, OAuthErrorOptions, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthTokenErrorCode, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, base64UrlToBytes, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
1537
+ export { AuthRequest, AuthorizationError, type AuthorizationErrorCode, type AuthorizationErrorOptions, CimdFetchError, ClientInfo, ClientRegistrationCallbackOptions, ClientRegistrationCallbackResult, CompleteAuthorizationOptions, type EmaClaimsMapper, type EmaClaimsMapperInput, type EmaClaimsMapperResult, type EmaIdJagClaims, type EmaOptions, type EmaTrustedIssuer, type EmaTrustedIssuerResolver, type EmaTrustedIssuerResolverInput, type EmaValidationError, ExchangeTokenOptions, ExternalTokenError, ExternalTokenErrorOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthError, OAuthErrorOptions, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, OAuthTokenErrorCode, PurgeOptions, PurgeResult, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, base64UrlToBytes, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
@@ -2,6 +2,30 @@ import { WorkerEntrypoint } from "cloudflare:workers";
2
2
 
3
3
  //#region src/oauth-capabilities.ts
4
4
  const OAUTH_SCOPE_TOKEN_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/;
5
+ /**
6
+ * Expected authorization-request validation failure. Absence of `redirectUri`
7
+ * means a caller MUST render locally and MUST NOT redirect.
8
+ */
9
+ var AuthorizationError = class extends Error {
10
+ constructor(code, options) {
11
+ super(options.description);
12
+ this.name = "AuthorizationError";
13
+ this.code = code;
14
+ this.description = options.description;
15
+ this.redirectUri = options.redirectUri;
16
+ this.state = options.state;
17
+ this.issuer = options.issuer;
18
+ }
19
+ };
20
+ /** @internal Attach context after exact client redirect validation succeeds. */
21
+ function withAuthorizationRedirect(error, redirectUri, state, issuer) {
22
+ return new AuthorizationError(error.code, {
23
+ description: error.description,
24
+ redirectUri,
25
+ state,
26
+ issuer
27
+ });
28
+ }
5
29
  function buildOAuthServerCapabilities(options) {
6
30
  return {
7
31
  grantTypes: [
@@ -30,20 +54,20 @@ function validateClientCapabilities(server, client) {
30
54
  if (client.grantTypes.includes("implicit") !== client.responseTypes.includes("token")) throw new Error("grant_types implicit and response_types token must be registered together");
31
55
  }
32
56
  function validateAuthorizationResponseType(server, responseType, clientResponseTypes) {
33
- if (!responseType) throw new Error("invalid_request: response_type is required");
34
- if (!server.responseTypes.includes(responseType)) throw new Error(`unsupported_response_type: the authorization server does not support ${responseType}`);
35
- if (!(clientResponseTypes ?? ["code"]).includes(responseType)) throw new Error(`unauthorized_client: the client is not registered for response_type ${responseType}`);
57
+ if (!responseType) throw new AuthorizationError("invalid_request", { description: "response_type is required" });
58
+ if (!server.responseTypes.includes(responseType)) throw new AuthorizationError("unsupported_response_type", { description: `The authorization server does not support response_type ${responseType}` });
59
+ if (!(clientResponseTypes ?? ["code"]).includes(responseType)) throw new AuthorizationError("unauthorized_client", { description: `The client is not registered for response_type ${responseType}` });
36
60
  }
37
61
  /** Parse a PKCE method, applying RFC 7636's default of `plain`. */
38
62
  function normalizePkceCodeChallengeMethod(method) {
39
63
  const effectiveMethod = method ?? "plain";
40
- if (effectiveMethod !== "plain" && effectiveMethod !== "S256") throw new Error(`Unsupported PKCE code_challenge_method: ${effectiveMethod}`);
64
+ if (effectiveMethod !== "plain" && effectiveMethod !== "S256") throw new AuthorizationError("invalid_request", { description: `Unsupported PKCE code_challenge_method: ${effectiveMethod}` });
41
65
  return effectiveMethod;
42
66
  }
43
67
  /** Require a syntactically valid PKCE method that the server advertises. */
44
68
  function validatePkceCodeChallengeMethod(server, method) {
45
69
  const effectiveMethod = normalizePkceCodeChallengeMethod(method);
46
- if (!server.codeChallengeMethods.includes(effectiveMethod)) throw new Error("The plain PKCE method is not allowed. Use S256 instead.");
70
+ if (!server.codeChallengeMethods.includes(effectiveMethod)) throw new AuthorizationError("invalid_request", { description: "The plain PKCE method is not allowed. Use S256 instead." });
47
71
  return effectiveMethod;
48
72
  }
49
73
  /** Validate authorization-request PKCE against server and client capabilities. */
@@ -52,8 +76,8 @@ function validateAuthorizationPkce(server, request, client) {
52
76
  validatePkceCodeChallengeMethod(server, request.codeChallengeMethod);
53
77
  return;
54
78
  }
55
- if (request.codeChallengeMethod) throw new Error("PKCE code_challenge is required when code_challenge_method is provided.");
56
- if (request.responseType === "code" && client.tokenEndpointAuthMethod === "none") throw new Error("Public clients must use PKCE with the authorization code flow.");
79
+ if (request.codeChallengeMethod) throw new AuthorizationError("invalid_request", { description: "PKCE code_challenge is required when code_challenge_method is provided." });
80
+ if (request.responseType === "code" && client.tokenEndpointAuthMethod === "none") throw new AuthorizationError("invalid_request", { description: "Public clients must use PKCE with the authorization code flow." });
57
81
  }
58
82
  function validateAuthorizationServerScopes(scopes) {
59
83
  if (!scopes) return;
@@ -3207,7 +3231,7 @@ var OAuthHelpersImpl = class {
3207
3231
  * Parses an OAuth authorization request from the HTTP request
3208
3232
  * @param request - The HTTP request containing OAuth parameters
3209
3233
  * @returns The parsed authorization request parameters
3210
- * @throws Error when the response type is missing, unsupported, or not registered for the client
3234
+ * @throws AuthorizationError for expected authorization-request validation failures
3211
3235
  * @throws CimdFetchError when the client ID is a CIMD URL whose document cannot be resolved
3212
3236
  */
3213
3237
  async parseAuthRequest(request) {
@@ -3222,22 +3246,33 @@ var OAuthHelpersImpl = class {
3222
3246
  const issuer = this.provider.getAuthorizationServerIssuer(url);
3223
3247
  const resourceParams = url.searchParams.getAll("resource");
3224
3248
  const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
3225
- validateRedirectUriScheme(redirectUri);
3249
+ if (!clientId) throw new AuthorizationError("invalid_request", { description: "client_id is required" });
3250
+ const clientInfo = await this.lookupClient(clientId);
3251
+ if (!clientInfo) throw new AuthorizationError("invalid_request", { description: "Invalid client_id" });
3252
+ try {
3253
+ validateRedirectUriScheme(redirectUri);
3254
+ } catch {
3255
+ throw new AuthorizationError("invalid_request", { description: "Invalid redirect URI" });
3256
+ }
3257
+ if (!redirectUri || !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new AuthorizationError("invalid_request", { description: "Invalid redirect URI" });
3258
+ const withRedirect = (error) => {
3259
+ throw withAuthorizationRedirect(error, redirectUri, state || void 0, issuer);
3260
+ };
3226
3261
  let resource = parseResourceParameter(resourceParam);
3227
- if (resourceParam && !resource) throw new Error("The resource parameter must be a valid absolute URI without a fragment");
3262
+ if (resourceParam && !resource) withRedirect(new AuthorizationError("invalid_request", { description: "The resource parameter must be a valid absolute URI without a fragment" }));
3228
3263
  const configuredResource = this.provider.options.resourceMetadata?.resource;
3229
- if (configuredResource && !isExactResource(resource, configuredResource)) throw new Error(`The resource parameter must exactly match ${configuredResource}`);
3264
+ if (configuredResource && !isExactResource(resource, configuredResource)) withRedirect(new AuthorizationError("invalid_request", { description: `The resource parameter must exactly match ${configuredResource}` }));
3230
3265
  resource ??= url.origin;
3231
- if (clientId) {
3232
- const clientInfo = await this.lookupClient(clientId);
3233
- if (!clientInfo) throw new Error(`Invalid client. The clientId provided does not match to this client.`);
3234
- if (!redirectUri || !isValidRedirectUri(redirectUri, clientInfo.redirectUris)) throw new Error(`Invalid redirect URI. The redirect URI provided does not match any registered URI for this client.`);
3266
+ try {
3235
3267
  validateAuthorizationResponseType(this.provider.serverCapabilities, responseType, clientInfo.responseTypes);
3236
3268
  validateAuthorizationPkce(this.provider.serverCapabilities, {
3237
3269
  responseType,
3238
3270
  codeChallenge,
3239
3271
  codeChallengeMethod
3240
3272
  }, clientInfo);
3273
+ } catch (error) {
3274
+ if (error instanceof AuthorizationError) withRedirect(error);
3275
+ throw error;
3241
3276
  }
3242
3277
  return {
3243
3278
  responseType,
@@ -3670,4 +3705,4 @@ var OAuthHelpersImpl = class {
3670
3705
  var oauth_provider_default = OAuthProvider;
3671
3706
 
3672
3707
  //#endregion
3673
- export { CimdFetchError, ExternalTokenError, GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
3708
+ export { AuthorizationError, CimdFetchError, ExternalTokenError, GrantType, OAuthError, OAuthProvider, base64UrlToBytes, oauth_provider_default as default, getJwtCryptoAlgorithms, getOAuthApi, isValidOAuthScopeToken, parseJwtJsonPart, resourceMatches, validateResourceUri };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",