@cloudflare/workers-oauth-provider 0.0.1 → 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
 
@@ -196,11 +196,100 @@ The `env.OAUTH_PROVIDER` object available to the fetch handlers provides some me
196
196
 
197
197
  See the `OAuthHelpers` interface definition for full API details.
198
198
 
199
- ## Written by Claude
199
+ ## Token Exchange Callback
200
200
 
201
- 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.
201
+ This library allows you to update the `props` value during token exchanges by configuring a callback function. This is useful for scenarios where the application needs to perform additional processing when tokens are issued or refreshed.
202
202
 
203
- (@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.)
203
+ For example, if your application is also a client to some other OAuth API, you might want to perform an equivalent upstream token exchange and store the result in the `props`. The callback can be used to update the props for both the grant record and specific access tokens.
204
+
205
+ To use this feature, provide a `tokenExchangeCallback` in your OAuthProvider options:
206
+
207
+ ```ts
208
+ new OAuthProvider({
209
+ // ... other options ...
210
+ tokenExchangeCallback: async (options) => {
211
+ // options.grantType is either 'authorization_code' or 'refresh_token'
212
+ // options.props contains the current props
213
+ // options.clientId, options.userId, and options.scope are also available
214
+
215
+ if (options.grantType === 'authorization_code') {
216
+ // For authorization code exchange, might want to obtain upstream tokens
217
+ const upstreamTokens = await exchangeUpstreamToken(options.props.someCode);
218
+
219
+ return {
220
+ // Update the props stored in the access token
221
+ accessTokenProps: {
222
+ ...options.props,
223
+ upstreamAccessToken: upstreamTokens.access_token
224
+ },
225
+ // Update the props stored in the grant (for future token refreshes)
226
+ newProps: {
227
+ ...options.props,
228
+ upstreamRefreshToken: upstreamTokens.refresh_token
229
+ }
230
+ };
231
+ }
232
+
233
+ if (options.grantType === 'refresh_token') {
234
+ // For refresh token exchanges, might want to refresh upstream tokens too
235
+ const upstreamTokens = await refreshUpstreamToken(options.props.upstreamRefreshToken);
236
+
237
+ return {
238
+ accessTokenProps: {
239
+ ...options.props,
240
+ upstreamAccessToken: upstreamTokens.access_token
241
+ },
242
+ newProps: {
243
+ ...options.props,
244
+ upstreamRefreshToken: upstreamTokens.refresh_token || options.props.upstreamRefreshToken
245
+ },
246
+ // Optionally override the default access token TTL to match the upstream token
247
+ accessTokenTTL: upstreamTokens.expires_in
248
+ };
249
+ }
250
+ }
251
+ });
252
+ ```
253
+
254
+ The callback can:
255
+ - Return both `accessTokenProps` and `newProps` to update both
256
+ - Return only `accessTokenProps` to update just the current access token
257
+ - Return only `newProps` to update both the grant and access token (the access token inherits these props)
258
+ - Return `accessTokenTTL` to override the default TTL for this specific access token
259
+ - Return nothing to keep the original props unchanged
260
+
261
+ The `accessTokenTTL` override is particularly useful when the application is also an OAuth client to another service and wants to match its access token TTL to the upstream access token TTL. This helps prevent situations where the downstream token is still valid but the upstream token has expired.
262
+
263
+ The `props` values are end-to-end encrypted, so they can safely contain sensitive information.
264
+
265
+ ## Custom Error Responses
266
+
267
+ By using the `onError` option, you can emit notifications or take other actions when an error response was to be emitted:
268
+
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}`)``.
204
293
 
205
294
  ## Implementation Notes
206
295
 
@@ -220,3 +309,17 @@ OAuth 2.1 requires that refresh tokens are either "cryptographically bound" to t
220
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.
221
310
 
222
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.
@@ -9,6 +9,59 @@ type WorkerEntrypointWithFetch = WorkerEntrypoint & Pick<Required<WorkerEntrypoi
9
9
  /**
10
10
  * Configuration options for the OAuth Provider
11
11
  */
12
+ /**
13
+ * Result of a token exchange callback function.
14
+ * Allows updating the props stored in both the access token and the grant.
15
+ */
16
+ interface TokenExchangeCallbackResult {
17
+ /**
18
+ * New props to be stored specifically with the access token.
19
+ * If not provided but newProps is, the access token will use newProps.
20
+ * If neither is provided, the original props will be used.
21
+ */
22
+ accessTokenProps?: any;
23
+ /**
24
+ * New props to replace the props stored in the grant itself.
25
+ * These props will be used for all future token refreshes.
26
+ * If accessTokenProps is not provided, these props will also be used for the current access token.
27
+ * If not provided, the original props will be used.
28
+ */
29
+ newProps?: any;
30
+ /**
31
+ * Override the default access token TTL (time-to-live) for this specific token.
32
+ * This is especially useful when the application is also an OAuth client to another service
33
+ * and wants to match its access token TTL to the upstream access token TTL.
34
+ * Value should be in seconds.
35
+ */
36
+ accessTokenTTL?: number;
37
+ }
38
+ /**
39
+ * Options for token exchange callback functions
40
+ */
41
+ interface TokenExchangeCallbackOptions {
42
+ /**
43
+ * The type of grant being processed.
44
+ * 'authorization_code' for initial code exchange,
45
+ * 'refresh_token' for refresh token exchange.
46
+ */
47
+ grantType: 'authorization_code' | 'refresh_token';
48
+ /**
49
+ * Client that received this grant
50
+ */
51
+ clientId: string;
52
+ /**
53
+ * User who authorized this grant
54
+ */
55
+ userId: string;
56
+ /**
57
+ * List of scopes that were granted
58
+ */
59
+ scope: string[];
60
+ /**
61
+ * Application-specific properties currently associated with this grant
62
+ */
63
+ props: any;
64
+ }
12
65
  interface OAuthProviderOptions {
13
66
  /**
14
67
  * URL(s) for API routes. Requests with URLs starting with any of these prefixes
@@ -65,6 +118,28 @@ interface OAuthProviderOptions {
65
118
  * Defaults to false.
66
119
  */
67
120
  disallowPublicClientRegistration?: boolean;
121
+ /**
122
+ * Optional callback function that is called during token exchange.
123
+ * This allows updating the props stored in both the access token and the grant.
124
+ * For example, if the application itself is also a client to some other OAuth API,
125
+ * it may want to perform the equivalent upstream token exchange, and store the result in the props.
126
+ *
127
+ * The callback can return new props values that will be stored with the token or grant.
128
+ * If the callback returns nothing or undefined for a props field, the original props will be used.
129
+ */
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;
68
143
  }
69
144
  /**
70
145
  * Helper methods for OAuth operations provided to handler functions
@@ -456,4 +531,4 @@ declare class OAuthProvider {
456
531
  fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response>;
457
532
  }
458
533
 
459
- export { type AuthRequest, type ClientInfo, type CompleteAuthorizationOptions, type Grant, type GrantSummary, type ListOptions, type ListResult, type OAuthHelpers, OAuthProvider, type OAuthProviderOptions, type Token, OAuthProvider as default };
534
+ export { type AuthRequest, type ClientInfo, type CompleteAuthorizationOptions, type Grant, type GrantSummary, type ListOptions, type ListResult, type OAuthHelpers, OAuthProvider, type OAuthProviderOptions, type Token, type TokenExchangeCallbackOptions, type TokenExchangeCallbackResult, OAuthProvider as default };
@@ -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
  /**
@@ -89,7 +90,9 @@ var OAuthProviderImpl = class {
89
90
  if (typeof handler === "function" && handler.prototype instanceof WorkerEntrypoint) {
90
91
  return { type: 1 /* WORKER_ENTRYPOINT */, handler };
91
92
  }
92
- throw new TypeError(`${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`);
93
+ throw new TypeError(
94
+ `${name} must be either an ExportedHandler object with a fetch method or a class extending WorkerEntrypoint`
95
+ );
93
96
  }
94
97
  /**
95
98
  * Main fetch handler for the Worker
@@ -132,7 +135,11 @@ var OAuthProviderImpl = class {
132
135
  env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
133
136
  }
134
137
  if (this.typedDefaultHandler.type === 0 /* EXPORTED_HANDLER */) {
135
- return this.typedDefaultHandler.handler.fetch(request, env, ctx);
138
+ return this.typedDefaultHandler.handler.fetch(
139
+ request,
140
+ env,
141
+ ctx
142
+ );
136
143
  } else {
137
144
  const handler = new this.typedDefaultHandler.handler(ctx, env);
138
145
  return handler.fetch(request);
@@ -292,20 +299,12 @@ var OAuthProviderImpl = class {
292
299
  */
293
300
  async handleTokenRequest(request, env) {
294
301
  if (request.method !== "POST") {
295
- return createErrorResponse(
296
- "invalid_request",
297
- "Method not allowed",
298
- 405
299
- );
302
+ return this.createErrorResponse("invalid_request", "Method not allowed", 405);
300
303
  }
301
304
  let contentType = request.headers.get("Content-Type") || "";
302
305
  let body = {};
303
306
  if (!contentType.includes("application/x-www-form-urlencoded")) {
304
- return createErrorResponse(
305
- "invalid_request",
306
- "Content-Type must be application/x-www-form-urlencoded",
307
- 400
308
- );
307
+ return this.createErrorResponse("invalid_request", "Content-Type must be application/x-www-form-urlencoded", 400);
309
308
  }
310
309
  const formData = await request.formData();
311
310
  for (const [key, value] of formData.entries()) {
@@ -324,31 +323,19 @@ var OAuthProviderImpl = class {
324
323
  clientSecret = body.client_secret || "";
325
324
  }
326
325
  if (!clientId) {
327
- return createErrorResponse(
328
- "invalid_client",
329
- "Client ID is required",
330
- 401
331
- );
326
+ return this.createErrorResponse("invalid_client", "Client ID is required", 401);
332
327
  }
333
328
  const clientInfo = await this.getClient(env, clientId);
334
329
  if (!clientInfo) {
335
- return createErrorResponse(
336
- "invalid_client",
337
- "Client not found",
338
- 401
339
- );
330
+ return this.createErrorResponse("invalid_client", "Client not found", 401);
340
331
  }
341
332
  const isPublicClient = clientInfo.tokenEndpointAuthMethod === "none";
342
333
  if (!isPublicClient) {
343
334
  if (!clientSecret) {
344
- return createErrorResponse(
345
- "invalid_client",
346
- "Client authentication failed: missing client_secret",
347
- 401
348
- );
335
+ return this.createErrorResponse("invalid_client", "Client authentication failed: missing client_secret", 401);
349
336
  }
350
337
  if (!clientInfo.clientSecret) {
351
- return createErrorResponse(
338
+ return this.createErrorResponse(
352
339
  "invalid_client",
353
340
  "Client authentication failed: client has no registered secret",
354
341
  401
@@ -356,11 +343,7 @@ var OAuthProviderImpl = class {
356
343
  }
357
344
  const providedSecretHash = await hashSecret(clientSecret);
358
345
  if (providedSecretHash !== clientInfo.clientSecret) {
359
- return createErrorResponse(
360
- "invalid_client",
361
- "Client authentication failed: invalid client_secret",
362
- 401
363
- );
346
+ return this.createErrorResponse("invalid_client", "Client authentication failed: invalid client_secret", 401);
364
347
  }
365
348
  }
366
349
  const grantType = body.grant_type;
@@ -369,10 +352,7 @@ var OAuthProviderImpl = class {
369
352
  } else if (grantType === "refresh_token") {
370
353
  return this.handleRefreshTokenGrant(body, clientInfo, env);
371
354
  } else {
372
- return createErrorResponse(
373
- "unsupported_grant_type",
374
- "Grant type not supported"
375
- );
355
+ return this.createErrorResponse("unsupported_grant_type", "Grant type not supported");
376
356
  }
377
357
  }
378
358
  /**
@@ -388,65 +368,38 @@ var OAuthProviderImpl = class {
388
368
  const redirectUri = body.redirect_uri;
389
369
  const codeVerifier = body.code_verifier;
390
370
  if (!code) {
391
- return createErrorResponse(
392
- "invalid_request",
393
- "Authorization code is required"
394
- );
371
+ return this.createErrorResponse("invalid_request", "Authorization code is required");
395
372
  }
396
373
  const codeParts = code.split(":");
397
374
  if (codeParts.length !== 3) {
398
- return createErrorResponse(
399
- "invalid_grant",
400
- "Invalid authorization code format"
401
- );
375
+ return this.createErrorResponse("invalid_grant", "Invalid authorization code format");
402
376
  }
403
377
  const [userId, grantId, _] = codeParts;
404
378
  const grantKey = `grant:${userId}:${grantId}`;
405
379
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
406
380
  if (!grantData) {
407
- return createErrorResponse(
408
- "invalid_grant",
409
- "Grant not found or authorization code expired"
410
- );
381
+ return this.createErrorResponse("invalid_grant", "Grant not found or authorization code expired");
411
382
  }
412
383
  if (!grantData.authCodeId) {
413
- return createErrorResponse(
414
- "invalid_grant",
415
- "Authorization code already used"
416
- );
384
+ return this.createErrorResponse("invalid_grant", "Authorization code already used");
417
385
  }
418
386
  const codeHash = await hashSecret(code);
419
387
  if (codeHash !== grantData.authCodeId) {
420
- return createErrorResponse(
421
- "invalid_grant",
422
- "Invalid authorization code"
423
- );
388
+ return this.createErrorResponse("invalid_grant", "Invalid authorization code");
424
389
  }
425
390
  if (grantData.clientId !== clientInfo.clientId) {
426
- return createErrorResponse(
427
- "invalid_grant",
428
- "Client ID mismatch"
429
- );
391
+ return this.createErrorResponse("invalid_grant", "Client ID mismatch");
430
392
  }
431
393
  const isPkceEnabled = !!grantData.codeChallenge;
432
394
  if (!redirectUri && !isPkceEnabled) {
433
- return createErrorResponse(
434
- "invalid_request",
435
- "redirect_uri is required when not using PKCE"
436
- );
395
+ return this.createErrorResponse("invalid_request", "redirect_uri is required when not using PKCE");
437
396
  }
438
397
  if (redirectUri && !clientInfo.redirectUris.includes(redirectUri)) {
439
- return createErrorResponse(
440
- "invalid_grant",
441
- "Invalid redirect URI"
442
- );
398
+ return this.createErrorResponse("invalid_grant", "Invalid redirect URI");
443
399
  }
444
400
  if (isPkceEnabled) {
445
401
  if (!codeVerifier) {
446
- return createErrorResponse(
447
- "invalid_request",
448
- "code_verifier is required for PKCE"
449
- );
402
+ return this.createErrorResponse("invalid_request", "code_verifier is required for PKCE");
450
403
  }
451
404
  let calculatedChallenge;
452
405
  if (grantData.codeChallengeMethod === "S256") {
@@ -459,10 +412,7 @@ var OAuthProviderImpl = class {
459
412
  calculatedChallenge = codeVerifier;
460
413
  }
461
414
  if (calculatedChallenge !== grantData.codeChallenge) {
462
- return createErrorResponse(
463
- "invalid_grant",
464
- "Invalid PKCE code_verifier"
465
- );
415
+ return this.createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
466
416
  }
467
417
  }
468
418
  const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
@@ -471,11 +421,53 @@ var OAuthProviderImpl = class {
471
421
  const refreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
472
422
  const accessTokenId = await generateTokenId(accessToken);
473
423
  const refreshTokenId = await generateTokenId(refreshToken);
474
- const now = Math.floor(Date.now() / 1e3);
475
- const accessTokenExpiresAt = now + this.options.accessTokenTTL;
424
+ let accessTokenTTL = this.options.accessTokenTTL;
476
425
  const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
477
- const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
478
- const refreshTokenWrappedKey = await wrapKeyWithToken(refreshToken, encryptionKey);
426
+ let grantEncryptionKey = encryptionKey;
427
+ let accessTokenEncryptionKey = encryptionKey;
428
+ let encryptedAccessTokenProps = grantData.encryptedProps;
429
+ if (this.options.tokenExchangeCallback) {
430
+ const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
431
+ let grantProps = decryptedProps;
432
+ let accessTokenProps = decryptedProps;
433
+ const callbackOptions = {
434
+ grantType: "authorization_code",
435
+ clientId: clientInfo.clientId,
436
+ userId,
437
+ scope: grantData.scope,
438
+ props: decryptedProps
439
+ };
440
+ const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
441
+ if (callbackResult) {
442
+ if (callbackResult.newProps) {
443
+ grantProps = callbackResult.newProps;
444
+ if (!callbackResult.accessTokenProps) {
445
+ accessTokenProps = callbackResult.newProps;
446
+ }
447
+ }
448
+ if (callbackResult.accessTokenProps) {
449
+ accessTokenProps = callbackResult.accessTokenProps;
450
+ }
451
+ if (callbackResult.accessTokenTTL !== void 0) {
452
+ accessTokenTTL = callbackResult.accessTokenTTL;
453
+ }
454
+ }
455
+ const grantResult = await encryptProps(grantProps);
456
+ grantData.encryptedProps = grantResult.encryptedData;
457
+ grantEncryptionKey = grantResult.key;
458
+ if (accessTokenProps !== grantProps) {
459
+ const tokenResult = await encryptProps(accessTokenProps);
460
+ encryptedAccessTokenProps = tokenResult.encryptedData;
461
+ accessTokenEncryptionKey = tokenResult.key;
462
+ } else {
463
+ encryptedAccessTokenProps = grantData.encryptedProps;
464
+ accessTokenEncryptionKey = grantEncryptionKey;
465
+ }
466
+ }
467
+ const now = Math.floor(Date.now() / 1e3);
468
+ const accessTokenExpiresAt = now + accessTokenTTL;
469
+ const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, accessTokenEncryptionKey);
470
+ const refreshTokenWrappedKey = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
479
471
  delete grantData.authCodeId;
480
472
  delete grantData.codeChallenge;
481
473
  delete grantData.codeChallengeMethod;
@@ -495,23 +487,24 @@ var OAuthProviderImpl = class {
495
487
  grant: {
496
488
  clientId: grantData.clientId,
497
489
  scope: grantData.scope,
498
- encryptedProps: grantData.encryptedProps
490
+ encryptedProps: encryptedAccessTokenProps
499
491
  }
500
492
  };
501
- await env.OAUTH_KV.put(
502
- `token:${userId}:${grantId}:${accessTokenId}`,
503
- JSON.stringify(accessTokenData),
504
- { expirationTtl: this.options.accessTokenTTL }
505
- );
506
- return new Response(JSON.stringify({
507
- access_token: accessToken,
508
- token_type: "bearer",
509
- expires_in: this.options.accessTokenTTL,
510
- refresh_token: refreshToken,
511
- scope: grantData.scope.join(" ")
512
- }), {
513
- headers: { "Content-Type": "application/json" }
493
+ await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), {
494
+ expirationTtl: accessTokenTTL
514
495
  });
496
+ return new Response(
497
+ JSON.stringify({
498
+ access_token: accessToken,
499
+ token_type: "bearer",
500
+ expires_in: accessTokenTTL,
501
+ refresh_token: refreshToken,
502
+ scope: grantData.scope.join(" ")
503
+ }),
504
+ {
505
+ headers: { "Content-Type": "application/json" }
506
+ }
507
+ );
515
508
  }
516
509
  /**
517
510
  * Handles the refresh token grant type
@@ -524,41 +517,26 @@ var OAuthProviderImpl = class {
524
517
  async handleRefreshTokenGrant(body, clientInfo, env) {
525
518
  const refreshToken = body.refresh_token;
526
519
  if (!refreshToken) {
527
- return createErrorResponse(
528
- "invalid_request",
529
- "Refresh token is required"
530
- );
520
+ return this.createErrorResponse("invalid_request", "Refresh token is required");
531
521
  }
532
522
  const tokenParts = refreshToken.split(":");
533
523
  if (tokenParts.length !== 3) {
534
- return createErrorResponse(
535
- "invalid_grant",
536
- "Invalid token format"
537
- );
524
+ return this.createErrorResponse("invalid_grant", "Invalid token format");
538
525
  }
539
526
  const [userId, grantId, _] = tokenParts;
540
527
  const providedTokenHash = await generateTokenId(refreshToken);
541
528
  const grantKey = `grant:${userId}:${grantId}`;
542
529
  const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
543
530
  if (!grantData) {
544
- return createErrorResponse(
545
- "invalid_grant",
546
- "Grant not found"
547
- );
531
+ return this.createErrorResponse("invalid_grant", "Grant not found");
548
532
  }
549
533
  const isCurrentToken = grantData.refreshTokenId === providedTokenHash;
550
534
  const isPreviousToken = grantData.previousRefreshTokenId === providedTokenHash;
551
535
  if (!isCurrentToken && !isPreviousToken) {
552
- return createErrorResponse(
553
- "invalid_grant",
554
- "Invalid refresh token"
555
- );
536
+ return this.createErrorResponse("invalid_grant", "Invalid refresh token");
556
537
  }
557
538
  if (grantData.clientId !== clientInfo.clientId) {
558
- return createErrorResponse(
559
- "invalid_grant",
560
- "Client ID mismatch"
561
- );
539
+ return this.createErrorResponse("invalid_grant", "Client ID mismatch");
562
540
  }
563
541
  const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
564
542
  const newAccessToken = `${userId}:${grantId}:${accessTokenSecret}`;
@@ -566,8 +544,7 @@ var OAuthProviderImpl = class {
566
544
  const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
567
545
  const newRefreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
568
546
  const newRefreshTokenId = await generateTokenId(newRefreshToken);
569
- const now = Math.floor(Date.now() / 1e3);
570
- const accessTokenExpiresAt = now + this.options.accessTokenTTL;
547
+ let accessTokenTTL = this.options.accessTokenTTL;
571
548
  let wrappedKeyToUse;
572
549
  if (isCurrentToken) {
573
550
  wrappedKeyToUse = grantData.refreshTokenWrappedKey;
@@ -575,8 +552,60 @@ var OAuthProviderImpl = class {
575
552
  wrappedKeyToUse = grantData.previousRefreshTokenWrappedKey;
576
553
  }
577
554
  const encryptionKey = await unwrapKeyWithToken(refreshToken, wrappedKeyToUse);
578
- const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, encryptionKey);
579
- const newRefreshTokenWrappedKey = await wrapKeyWithToken(newRefreshToken, encryptionKey);
555
+ let grantEncryptionKey = encryptionKey;
556
+ let accessTokenEncryptionKey = encryptionKey;
557
+ let encryptedAccessTokenProps = grantData.encryptedProps;
558
+ if (this.options.tokenExchangeCallback) {
559
+ const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
560
+ let grantProps = decryptedProps;
561
+ let accessTokenProps = decryptedProps;
562
+ const callbackOptions = {
563
+ grantType: "refresh_token",
564
+ clientId: clientInfo.clientId,
565
+ userId,
566
+ scope: grantData.scope,
567
+ props: decryptedProps
568
+ };
569
+ const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
570
+ let grantPropsChanged = false;
571
+ if (callbackResult) {
572
+ if (callbackResult.newProps) {
573
+ grantProps = callbackResult.newProps;
574
+ grantPropsChanged = true;
575
+ if (!callbackResult.accessTokenProps) {
576
+ accessTokenProps = callbackResult.newProps;
577
+ }
578
+ }
579
+ if (callbackResult.accessTokenProps) {
580
+ accessTokenProps = callbackResult.accessTokenProps;
581
+ }
582
+ if (callbackResult.accessTokenTTL !== void 0) {
583
+ accessTokenTTL = callbackResult.accessTokenTTL;
584
+ }
585
+ }
586
+ if (grantPropsChanged) {
587
+ const grantResult = await encryptProps(grantProps);
588
+ grantData.encryptedProps = grantResult.encryptedData;
589
+ if (grantResult.key !== encryptionKey) {
590
+ grantEncryptionKey = grantResult.key;
591
+ wrappedKeyToUse = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
592
+ } else {
593
+ grantEncryptionKey = grantResult.key;
594
+ }
595
+ }
596
+ if (accessTokenProps !== grantProps) {
597
+ const tokenResult = await encryptProps(accessTokenProps);
598
+ encryptedAccessTokenProps = tokenResult.encryptedData;
599
+ accessTokenEncryptionKey = tokenResult.key;
600
+ } else {
601
+ encryptedAccessTokenProps = grantData.encryptedProps;
602
+ accessTokenEncryptionKey = grantEncryptionKey;
603
+ }
604
+ }
605
+ const now = Math.floor(Date.now() / 1e3);
606
+ const accessTokenExpiresAt = now + accessTokenTTL;
607
+ const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
608
+ const newRefreshTokenWrappedKey = await wrapKeyWithToken(newRefreshToken, grantEncryptionKey);
580
609
  grantData.previousRefreshTokenId = providedTokenHash;
581
610
  grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
582
611
  grantData.refreshTokenId = newRefreshTokenId;
@@ -592,23 +621,24 @@ var OAuthProviderImpl = class {
592
621
  grant: {
593
622
  clientId: grantData.clientId,
594
623
  scope: grantData.scope,
595
- encryptedProps: grantData.encryptedProps
624
+ encryptedProps: encryptedAccessTokenProps
596
625
  }
597
626
  };
598
- await env.OAUTH_KV.put(
599
- `token:${userId}:${grantId}:${accessTokenId}`,
600
- JSON.stringify(accessTokenData),
601
- { expirationTtl: this.options.accessTokenTTL }
602
- );
603
- return new Response(JSON.stringify({
604
- access_token: newAccessToken,
605
- token_type: "bearer",
606
- expires_in: this.options.accessTokenTTL,
607
- refresh_token: newRefreshToken,
608
- scope: grantData.scope.join(" ")
609
- }), {
610
- headers: { "Content-Type": "application/json" }
627
+ await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), {
628
+ expirationTtl: accessTokenTTL
611
629
  });
630
+ return new Response(
631
+ JSON.stringify({
632
+ access_token: newAccessToken,
633
+ token_type: "bearer",
634
+ expires_in: accessTokenTTL,
635
+ refresh_token: newRefreshToken,
636
+ scope: grantData.scope.join(" ")
637
+ }),
638
+ {
639
+ headers: { "Content-Type": "application/json" }
640
+ }
641
+ );
612
642
  }
613
643
  /**
614
644
  * Handles the dynamic client registration endpoint (RFC 7591)
@@ -618,44 +648,24 @@ var OAuthProviderImpl = class {
618
648
  */
619
649
  async handleClientRegistration(request, env) {
620
650
  if (!this.options.clientRegistrationEndpoint) {
621
- return createErrorResponse(
622
- "not_implemented",
623
- "Client registration is not enabled",
624
- 501
625
- );
651
+ return this.createErrorResponse("not_implemented", "Client registration is not enabled", 501);
626
652
  }
627
653
  if (request.method !== "POST") {
628
- return createErrorResponse(
629
- "invalid_request",
630
- "Method not allowed",
631
- 405
632
- );
654
+ return this.createErrorResponse("invalid_request", "Method not allowed", 405);
633
655
  }
634
656
  const contentLength = parseInt(request.headers.get("Content-Length") || "0", 10);
635
657
  if (contentLength > 1048576) {
636
- return createErrorResponse(
637
- "invalid_request",
638
- "Request payload too large, must be under 1 MiB",
639
- 413
640
- );
658
+ return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
641
659
  }
642
660
  let clientMetadata;
643
661
  try {
644
662
  const text = await request.text();
645
663
  if (text.length > 1048576) {
646
- return createErrorResponse(
647
- "invalid_request",
648
- "Request payload too large, must be under 1 MiB",
649
- 413
650
- );
664
+ return this.createErrorResponse("invalid_request", "Request payload too large, must be under 1 MiB", 413);
651
665
  }
652
666
  clientMetadata = JSON.parse(text);
653
667
  } catch (error) {
654
- return createErrorResponse(
655
- "invalid_request",
656
- "Invalid JSON payload",
657
- 400
658
- );
668
+ return this.createErrorResponse("invalid_request", "Invalid JSON payload", 400);
659
669
  }
660
670
  const validateStringField = (field) => {
661
671
  if (field === void 0) {
@@ -683,10 +693,7 @@ var OAuthProviderImpl = class {
683
693
  const authMethod = validateStringField(clientMetadata.token_endpoint_auth_method) || "client_secret_basic";
684
694
  const isPublicClient = authMethod === "none";
685
695
  if (isPublicClient && this.options.disallowPublicClientRegistration) {
686
- return createErrorResponse(
687
- "invalid_client_metadata",
688
- "Public client registration is not allowed"
689
- );
696
+ return this.createErrorResponse("invalid_client_metadata", "Public client registration is not allowed");
690
697
  }
691
698
  const clientId = generateRandomString(16);
692
699
  let clientSecret;
@@ -720,7 +727,7 @@ var OAuthProviderImpl = class {
720
727
  clientInfo.clientSecret = hashedSecret;
721
728
  }
722
729
  } catch (error) {
723
- return createErrorResponse(
730
+ return this.createErrorResponse(
724
731
  "invalid_client_metadata",
725
732
  error instanceof Error ? error.message : "Invalid client metadata"
726
733
  );
@@ -760,49 +767,34 @@ var OAuthProviderImpl = class {
760
767
  async handleApiRequest(request, env, ctx) {
761
768
  const authHeader = request.headers.get("Authorization");
762
769
  if (!authHeader || !authHeader.startsWith("Bearer ")) {
763
- return createErrorResponse(
764
- "invalid_token",
765
- "Missing or invalid access token",
766
- 401,
767
- { "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"' }
768
- );
770
+ return this.createErrorResponse("invalid_token", "Missing or invalid access token", 401, {
771
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token", error_description="Missing or invalid access token"'
772
+ });
769
773
  }
770
774
  const accessToken = authHeader.substring(7);
771
775
  const tokenParts = accessToken.split(":");
772
776
  if (tokenParts.length !== 3) {
773
- return createErrorResponse(
774
- "invalid_token",
775
- "Invalid token format",
776
- 401,
777
- { "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"' }
778
- );
777
+ return this.createErrorResponse("invalid_token", "Invalid token format", 401, {
778
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
779
+ });
779
780
  }
780
781
  const [userId, grantId, _] = tokenParts;
781
782
  const accessTokenId = await generateTokenId(accessToken);
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(
786
- "invalid_token",
787
- "Invalid access token",
788
- 401,
789
- { "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"' }
790
- );
786
+ return this.createErrorResponse("invalid_token", "Invalid access token", 401, {
787
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
788
+ });
791
789
  }
792
790
  const now = Math.floor(Date.now() / 1e3);
793
791
  if (tokenData.expiresAt < now) {
794
- return createErrorResponse(
795
- "invalid_token",
796
- "Access token expired",
797
- 401,
798
- { "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"' }
799
- );
792
+ return this.createErrorResponse("invalid_token", "Access token expired", 401, {
793
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
794
+ });
800
795
  }
801
796
  const encryptionKey = await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey);
802
- const decryptedProps = await decryptProps(
803
- encryptionKey,
804
- tokenData.grant.encryptedProps
805
- );
797
+ const decryptedProps = await decryptProps(encryptionKey, tokenData.grant.encryptedProps);
806
798
  ctx.props = decryptedProps;
807
799
  if (!env.OAUTH_PROVIDER) {
808
800
  env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
@@ -836,22 +828,32 @@ var OAuthProviderImpl = class {
836
828
  const clientKey = `client:${clientId}`;
837
829
  return env.OAUTH_KV.get(clientKey, { type: "json" });
838
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
+ }
839
854
  };
840
855
  var DEFAULT_ACCESS_TOKEN_TTL = 60 * 60;
841
856
  var TOKEN_LENGTH = 32;
842
- function createErrorResponse(code, description, status = 400, headers = {}) {
843
- const body = JSON.stringify({
844
- error: code,
845
- error_description: description
846
- });
847
- return new Response(body, {
848
- status,
849
- headers: {
850
- "Content-Type": "application/json",
851
- ...headers
852
- }
853
- });
854
- }
855
857
  async function hashSecret(secret) {
856
858
  return generateTokenId(secret);
857
859
  }
@@ -972,11 +974,7 @@ async function deriveKeyFromToken(tokenStr) {
972
974
  false,
973
975
  ["sign"]
974
976
  );
975
- const hmacResult = await crypto.subtle.sign(
976
- "HMAC",
977
- hmacKey,
978
- encoder.encode(tokenStr)
979
- );
977
+ const hmacResult = await crypto.subtle.sign("HMAC", hmacKey, encoder.encode(tokenStr));
980
978
  return await crypto.subtle.importKey(
981
979
  "raw",
982
980
  hmacResult,
@@ -988,12 +986,7 @@ async function deriveKeyFromToken(tokenStr) {
988
986
  }
989
987
  async function wrapKeyWithToken(tokenStr, keyToWrap) {
990
988
  const wrappingKey = await deriveKeyFromToken(tokenStr);
991
- const wrappedKeyBuffer = await crypto.subtle.wrapKey(
992
- "raw",
993
- keyToWrap,
994
- wrappingKey,
995
- { name: "AES-KW" }
996
- );
989
+ const wrappedKeyBuffer = await crypto.subtle.wrapKey("raw", keyToWrap, wrappingKey, { name: "AES-KW" });
997
990
  return arrayBufferToBase64(wrappedKeyBuffer);
998
991
  }
999
992
  async function unwrapKeyWithToken(tokenStr, wrappedKeyBase64) {
@@ -1319,9 +1312,11 @@ var OAuthHelpersImpl = class {
1319
1312
  }
1320
1313
  const result = await this.env.OAUTH_KV.list(listOptions);
1321
1314
  if (result.keys.length > 0) {
1322
- await Promise.all(result.keys.map((key) => {
1323
- return this.env.OAUTH_KV.delete(key.name);
1324
- }));
1315
+ await Promise.all(
1316
+ result.keys.map((key) => {
1317
+ return this.env.OAUTH_KV.delete(key.name);
1318
+ })
1319
+ );
1325
1320
  }
1326
1321
  if (result.list_complete) {
1327
1322
  allTokensDeleted = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.0.1",
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",
@@ -11,19 +11,23 @@
11
11
  ],
12
12
  "type": "module",
13
13
  "publishConfig": {
14
- "access": "restricted"
14
+ "access": "public"
15
15
  },
16
16
  "scripts": {
17
17
  "build": "tsup",
18
- "test": "vitest",
19
- "prepublishOnly": "npm run build"
18
+ "build:watch": "tsup --watch",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest",
21
+ "prepublishOnly": "npm run build",
22
+ "prettier": "prettier -w ."
20
23
  },
21
24
  "dependencies": {
22
25
  "@cloudflare/workers-types": "^4.20250311.0"
23
26
  },
24
27
  "devDependencies": {
28
+ "prettier": "^3.5.3",
25
29
  "tsup": "^8.4.0",
26
30
  "typescript": "^5.8.2",
27
- "vitest": "^1.2.2"
31
+ "vitest": "^3.0.8"
28
32
  }
29
33
  }