@cloudflare/workers-oauth-provider 0.0.2 → 0.0.3

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
@@ -2,9 +2,9 @@
2
2
 
3
3
  This is a TypeScript library that implements the provider side of the OAuth 2.1 protocol with PKCE support. The library is intended to be used on Cloudflare Workers.
4
4
 
5
- ## EXPERIMENTAL
5
+ ## Beta
6
6
 
7
- As of March, 2025, this library is very new. Please use with caution as additional security reviews are still in progress.
7
+ As of March, 2025, this library is very new, prerelease software. The API is still subject to change.
8
8
 
9
9
  ## Benefits of this library
10
10
 
@@ -262,11 +262,34 @@ The `accessTokenTTL` override is particularly useful when the application is als
262
262
 
263
263
  The `props` values are end-to-end encrypted, so they can safely contain sensitive information.
264
264
 
265
- ## Written by Claude
265
+ ## Custom Error Responses
266
266
 
267
- This library (including the schema documentation) was largely written by [Claude](https://claude.ai), the AI model by Anthropic. Claude's output was thoroughly reviewed by Cloudflare engineers with careful attention paid to security and compliance with standards. Many improvements were made on the initial output, mostly again by prompting Claude (and reviewing the results). Check out the commit history to see how Claude was prompted and what code it produced.
267
+ By using the `onError` option, you can emit notifications or take other actions when an error response was to be emitted:
268
268
 
269
- (@kentonv, the lead engineer, was actually an AI skeptic, and started this project with the intent to prove that LLMs cannot code. He ended up deciding he had proven himself wrong.)
269
+ ```ts
270
+ new OAuthProvider({
271
+ // ... other options ...
272
+ onError({ code, description, status, headers }) {
273
+ Sentry.captureMessage(/* ... */)
274
+ }
275
+ })
276
+ ```
277
+
278
+ By returning a `Response` you can also override what the OAuthProvider returns to your users:
279
+
280
+ ```ts
281
+ new OAuthProvider({
282
+ // ... other options ...
283
+ onError({ code, description, status, headers }) {
284
+ if (code === 'unsupported_grant_type') {
285
+ return new Response('...', { status, headers })
286
+ }
287
+ // returning undefined (i.e. void) uses the default Response generation
288
+ }
289
+ })
290
+ ```
291
+
292
+ By default, the `onError` callback is set to ``({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`)``.
270
293
 
271
294
  ## Implementation Notes
272
295
 
@@ -286,3 +309,17 @@ OAuth 2.1 requires that refresh tokens are either "cryptographically bound" to t
286
309
  This requirement is seemingly fundamentally flawed as it assumes that every refresh request will complete with no errors. In the real world, a transient network error, machine failure, or software fault could mean that the client fails to store the new refresh token after a refresh request. In this case, the client would be permanently unable to make any further requests, as the only token it has is no longer valid.
287
310
 
288
311
  This library implements a compromise: At any particular time, a grant may have two valid refresh tokens. When the client uses one of them, the other one is invalidated, and a new one is generated and returned. Thus, if the client correctly uses the new refresh token each time, then older refresh tokens are continuously invalidated. But if a transient failure prevents the client from updating its token, it can always retry the request with the token it used previously.
312
+
313
+ ## Written using Claude
314
+
315
+ This library (including the schema documentation) was largely written with the help of [Claude](https://claude.ai), the AI model by Anthropic. Claude's output was thoroughly reviewed by Cloudflare engineers with careful attention paid to security and compliance with standards. Many improvements were made on the initial output, mostly again by prompting Claude (and reviewing the results). Check out the commit history to see how Claude was prompted and what code it produced.
316
+
317
+ **"NOOOOOOOO!!!! You can't just use an LLM to write an auth library!"**
318
+
319
+ "haha gpus go brrr"
320
+
321
+ In all seriousness, two months ago (January 2025), I ([@kentonv](https://github.com/kentonv)) would have agreed. I was an AI skeptic. I thoughts LLMs were glorified Markov chain generators that didn't actually understand code and couldn't produce anything novel. I started this project on a lark, fully expecting the AI to produce terrible code for me to laugh at. And then, uh... the code actually looked pretty good. Not perfect, but I just told the AI to fix things, and it did. I was shocked.
322
+
323
+ To emphasize, **this is not "vibe coded"**. Every line was thoroughly reviewed and cross-referenced with relevant RFCs, by security experts with previous experience with those RFCs. I was *trying* to validate my skepticism. I ended up proving myself wrong.
324
+
325
+ Again, please check out the commit history -- especially early commits -- to understand how this went.
@@ -128,6 +128,18 @@ interface OAuthProviderOptions {
128
128
  * If the callback returns nothing or undefined for a props field, the original props will be used.
129
129
  */
130
130
  tokenExchangeCallback?: (options: TokenExchangeCallbackOptions) => Promise<TokenExchangeCallbackResult | void> | TokenExchangeCallbackResult | void;
131
+ /**
132
+ * Optional callback function that is called whenever the OAuthProvider returns an error response
133
+ * This allows the client to emit notifications or perform other actions when an error occurs.
134
+ *
135
+ * If the function returns a Response, that will be used in place of the OAuthProvider's default one.
136
+ */
137
+ onError?: (error: {
138
+ code: string;
139
+ description: string;
140
+ status: number;
141
+ headers: Record<string, string>;
142
+ }) => Response | void;
131
143
  }
132
144
  /**
133
145
  * Helper methods for OAuth operations provided to handler functions
@@ -52,8 +52,9 @@ var OAuthProviderImpl = class {
52
52
  this.validateEndpoint(options.clientRegistrationEndpoint, "clientRegistrationEndpoint");
53
53
  }
54
54
  this.options = {
55
- ...options,
56
- accessTokenTTL: options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL
55
+ accessTokenTTL: DEFAULT_ACCESS_TOKEN_TTL,
56
+ onError: ({ status, code, description }) => console.warn(`OAuth error response: ${status} ${code} - ${description}`),
57
+ ...options
57
58
  };
58
59
  }
59
60
  /**
@@ -298,12 +299,12 @@ var OAuthProviderImpl = class {
298
299
  */
299
300
  async handleTokenRequest(request, env) {
300
301
  if (request.method !== "POST") {
301
- return createErrorResponse("invalid_request", "Method not allowed", 405);
302
+ return this.createErrorResponse("invalid_request", "Method not allowed", 405);
302
303
  }
303
304
  let contentType = request.headers.get("Content-Type") || "";
304
305
  let body = {};
305
306
  if (!contentType.includes("application/x-www-form-urlencoded")) {
306
- return createErrorResponse("invalid_request", "Content-Type must be application/x-www-form-urlencoded", 400);
307
+ return this.createErrorResponse("invalid_request", "Content-Type must be application/x-www-form-urlencoded", 400);
307
308
  }
308
309
  const formData = await request.formData();
309
310
  for (const [key, value] of formData.entries()) {
@@ -322,19 +323,19 @@ var OAuthProviderImpl = class {
322
323
  clientSecret = body.client_secret || "";
323
324
  }
324
325
  if (!clientId) {
325
- return createErrorResponse("invalid_client", "Client ID is required", 401);
326
+ return this.createErrorResponse("invalid_client", "Client ID is required", 401);
326
327
  }
327
328
  const clientInfo = await this.getClient(env, clientId);
328
329
  if (!clientInfo) {
329
- return createErrorResponse("invalid_client", "Client not found", 401);
330
+ return this.createErrorResponse("invalid_client", "Client not found", 401);
330
331
  }
331
332
  const isPublicClient = clientInfo.tokenEndpointAuthMethod === "none";
332
333
  if (!isPublicClient) {
333
334
  if (!clientSecret) {
334
- return createErrorResponse("invalid_client", "Client authentication failed: missing client_secret", 401);
335
+ return this.createErrorResponse("invalid_client", "Client authentication failed: missing client_secret", 401);
335
336
  }
336
337
  if (!clientInfo.clientSecret) {
337
- return createErrorResponse(
338
+ return this.createErrorResponse(
338
339
  "invalid_client",
339
340
  "Client authentication failed: client has no registered secret",
340
341
  401
@@ -342,7 +343,7 @@ var OAuthProviderImpl = class {
342
343
  }
343
344
  const providedSecretHash = await hashSecret(clientSecret);
344
345
  if (providedSecretHash !== clientInfo.clientSecret) {
345
- return createErrorResponse("invalid_client", "Client authentication failed: invalid client_secret", 401);
346
+ return this.createErrorResponse("invalid_client", "Client authentication failed: invalid client_secret", 401);
346
347
  }
347
348
  }
348
349
  const grantType = body.grant_type;
@@ -351,7 +352,7 @@ var OAuthProviderImpl = class {
351
352
  } else if (grantType === "refresh_token") {
352
353
  return this.handleRefreshTokenGrant(body, clientInfo, env);
353
354
  } else {
354
- return createErrorResponse("unsupported_grant_type", "Grant type not supported");
355
+ return this.createErrorResponse("unsupported_grant_type", "Grant type not supported");
355
356
  }
356
357
  }
357
358
  /**
@@ -367,38 +368,38 @@ var OAuthProviderImpl = class {
367
368
  const redirectUri = body.redirect_uri;
368
369
  const codeVerifier = body.code_verifier;
369
370
  if (!code) {
370
- return createErrorResponse("invalid_request", "Authorization code is required");
371
+ return this.createErrorResponse("invalid_request", "Authorization code is required");
371
372
  }
372
373
  const codeParts = code.split(":");
373
374
  if (codeParts.length !== 3) {
374
- return createErrorResponse("invalid_grant", "Invalid authorization code format");
375
+ return this.createErrorResponse("invalid_grant", "Invalid authorization code format");
375
376
  }
376
377
  const [userId, grantId, _] = codeParts;
377
378
  const grantKey = `grant:${userId}:${grantId}`;
378
379
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
379
380
  if (!grantData) {
380
- return createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
381
+ return this.createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
381
382
  }
382
383
  if (!grantData.authCodeId) {
383
- return createErrorResponse("invalid_grant", "Authorization code already used");
384
+ return this.createErrorResponse("invalid_grant", "Authorization code already used");
384
385
  }
385
386
  const codeHash = await hashSecret(code);
386
387
  if (codeHash !== grantData.authCodeId) {
387
- return createErrorResponse("invalid_grant", "Invalid authorization code");
388
+ return this.createErrorResponse("invalid_grant", "Invalid authorization code");
388
389
  }
389
390
  if (grantData.clientId !== clientInfo.clientId) {
390
- return createErrorResponse("invalid_grant", "Client ID mismatch");
391
+ return this.createErrorResponse("invalid_grant", "Client ID mismatch");
391
392
  }
392
393
  const isPkceEnabled = !!grantData.codeChallenge;
393
394
  if (!redirectUri && !isPkceEnabled) {
394
- return createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
395
+ return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
395
396
  }
396
397
  if (redirectUri && !clientInfo.redirectUris.includes(redirectUri)) {
397
- return createErrorResponse("invalid_grant", "Invalid redirect URI");
398
+ return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
398
399
  }
399
400
  if (isPkceEnabled) {
400
401
  if (!codeVerifier) {
401
- return createErrorResponse("invalid_request", "code_verifier is required for PKCE");
402
+ return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
402
403
  }
403
404
  let calculatedChallenge;
404
405
  if (grantData.codeChallengeMethod === "S256") {
@@ -411,7 +412,7 @@ var OAuthProviderImpl = class {
411
412
  calculatedChallenge = codeVerifier;
412
413
  }
413
414
  if (calculatedChallenge !== grantData.codeChallenge) {
414
- return createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
415
+ return this.createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
415
416
  }
416
417
  }
417
418
  const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
@@ -516,26 +517,26 @@ var OAuthProviderImpl = class {
516
517
  async handleRefreshTokenGrant(body, clientInfo, env) {
517
518
  const refreshToken = body.refresh_token;
518
519
  if (!refreshToken) {
519
- return createErrorResponse("invalid_request", "Refresh token is required");
520
+ return this.createErrorResponse("invalid_request", "Refresh token is required");
520
521
  }
521
522
  const tokenParts = refreshToken.split(":");
522
523
  if (tokenParts.length !== 3) {
523
- return createErrorResponse("invalid_grant", "Invalid token format");
524
+ return this.createErrorResponse("invalid_grant", "Invalid token format");
524
525
  }
525
526
  const [userId, grantId, _] = tokenParts;
526
527
  const providedTokenHash = await generateTokenId(refreshToken);
527
528
  const grantKey = `grant:${userId}:${grantId}`;
528
529
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
529
530
  if (!grantData) {
530
- return createErrorResponse("invalid_grant", "Grant not found");
531
+ return this.createErrorResponse("invalid_grant", "Grant not found");
531
532
  }
532
533
  const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
533
534
  const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
534
535
  if (!isCurrentToken && !isPreviousToken) {
535
- return createErrorResponse("invalid_grant", "Invalid refresh token");
536
+ return this.createErrorResponse("invalid_grant", "Invalid refresh token");
536
537
  }
537
538
  if (grantData.clientId !== clientInfo.clientId) {
538
- return createErrorResponse("invalid_grant", "Client ID mismatch");
539
+ return this.createErrorResponse("invalid_grant", "Client ID mismatch");
539
540
  }
540
541
  const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
541
542
  const newAccessToken = `${userId}:${grantId}:${accessTokenSecret}`;
@@ -647,24 +648,24 @@ var OAuthProviderImpl = class {
647
648
  */
648
649
  async handleClientRegistration(request, env) {
649
650
  if (!this.options.clientRegistrationEndpoint) {
650
- return createErrorResponse("not_implemented", "Client registration is not enabled", 501);
651
+ return this.createErrorResponse("not_implemented", "Client registration is not enabled", 501);
651
652
  }
652
653
  if (request.method !== "POST") {
653
- return createErrorResponse("invalid_request", "Method not allowed", 405);
654
+ return this.createErrorResponse("invalid_request", "Method not allowed", 405);
654
655
  }
655
656
  const contentLength = parseInt(request.headers.get("Content-Length") || "0", 10);
656
657
  if (contentLength > 1048576) {
657
- return createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
658
+ return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
658
659
  }
659
660
  let clientMetadata;
660
661
  try {
661
662
  const text = await request.text();
662
663
  if (text.length > 1048576) {
663
- return createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
664
+ return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
664
665
  }
665
666
  clientMetadata = JSON.parse(text);
666
667
  } catch (error) {
667
- return createErrorResponse("invalid_request", "Invalid JSON payload", 400);
668
+ return this.createErrorResponse("invalid_request", "Invalid JSON payload", 400);
668
669
  }
669
670
  const validateStringField = (field) => {
670
671
  if (field === void 0) {
@@ -692,7 +693,7 @@ var OAuthProviderImpl = class {
692
693
  const authMethod = validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
693
694
  const isPublicClient = authMethod === "none";
694
695
  if (isPublicClient && this.options.disallowPublicClientRegistration) {
695
- return createErrorResponse("invalid_client_metadata", "Public client registration is not allowed");
696
+ return this.createErrorResponse("invalid_client_metadata", "Public client registration is not allowed");
696
697
  }
697
698
  const clientId = generateRandomString(16);
698
699
  let clientSecret;
@@ -726,7 +727,7 @@ var OAuthProviderImpl = class {
726
727
  clientInfo.clientSecret = hashedSecret;
727
728
  }
728
729
  } catch (error) {
729
- return createErrorResponse(
730
+ return this.createErrorResponse(
730
731
  "invalid_client_metadata",
731
732
  error instanceof Error ? error.message : "Invalid client metadata"
732
733
  );
@@ -766,14 +767,14 @@ var OAuthProviderImpl = class {
766
767
  async handleApiRequest(request, env, ctx) {
767
768
  const authHeader = request.headers.get("Authorization");
768
769
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
769
- return createErrorResponse("invalid_token", "Missing or invalid access token", 401, {
770
+ return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, {
770
771
  "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"'
771
772
  });
772
773
  }
773
774
  const accessToken = authHeader.substring(7);
774
775
  const tokenParts = accessToken.split(":");
775
776
  if (tokenParts.length !== 3) {
776
- return createErrorResponse("invalid_token", "Invalid token format", 401, {
777
+ return this.createErrorResponse("invalid_token", "Invalid token format", 401, {
777
778
  "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
778
779
  });
779
780
  }
@@ -782,13 +783,13 @@ var OAuthProviderImpl = class {
782
783
  const tokenKey = `token:${userId}:${grantId}:${accessTokenId}`;
783
784
  const tokenData = await env.OAUTH_KV.get(tokenKey, { type: "json" });
784
785
  if (!tokenData) {
785
- return createErrorResponse("invalid_token", "Invalid access token", 401, {
786
+ return this.createErrorResponse("invalid_token", "Invalid access token", 401, {
786
787
  "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
787
788
  });
788
789
  }
789
790
  const now = Math.floor(Date.now() / 1e3);
790
791
  if (tokenData.expiresAt < now) {
791
- return createErrorResponse("invalid_token", "Access token expired", 401, {
792
+ return this.createErrorResponse("invalid_token", "Access token expired", 401, {
792
793
  "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
793
794
  });
794
795
  }
@@ -827,22 +828,32 @@ var OAuthProviderImpl = class {
827
828
  const clientKey = `client:${clientId}`;
828
829
  return env.OAUTH_KV.get(clientKey, { type: "json" });
829
830
  }
831
+ /**
832
+ * Helper function to create OAuth error responses
833
+ * @param code - OAuth error code (e.g., 'invalid_request', 'invalid_token')
834
+ * @param description - Human-readable error description
835
+ * @param status - HTTP status code (default: 400)
836
+ * @param headers - Additional headers to include
837
+ * @returns A Response object with the error
838
+ */
839
+ createErrorResponse(code, description, status = 400, headers = {}) {
840
+ const customErrorResponse = this.options.onError?.({ code, description, status, headers });
841
+ if (customErrorResponse) return customErrorResponse;
842
+ const body = JSON.stringify({
843
+ error: code,
844
+ error_description: description
845
+ });
846
+ return new Response(body, {
847
+ status,
848
+ headers: {
849
+ "Content-Type": "application/json",
850
+ ...headers
851
+ }
852
+ });
853
+ }
830
854
  };
831
855
  var DEFAULT_ACCESS_TOKEN_TTL = 60 * 60;
832
856
  var TOKEN_LENGTH = 32;
833
- function createErrorResponse(code, description, status = 400, headers = {}) {
834
- const body = JSON.stringify({
835
- error: code,
836
- error_description: description
837
- });
838
- return new Response(body, {
839
- status,
840
- headers: {
841
- "Content-Type": "application/json",
842
- ...headers
843
- }
844
- });
845
- }
846
857
  async function hashSecret(secret) {
847
858
  return generateTokenId(secret);
848
859
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",
@@ -15,7 +15,9 @@
15
15
  },
16
16
  "scripts": {
17
17
  "build": "tsup",
18
+ "build:watch": "tsup --watch",
18
19
  "test": "vitest run",
20
+ "test:watch": "vitest",
19
21
  "prepublishOnly": "npm run build",
20
22
  "prettier": "prettier -w ."
21
23
  },