@cloudflare/workers-oauth-provider 0.0.0-4d02346 → 0.0.0-5a59d78

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
@@ -38,7 +38,7 @@ export default new OAuthProvider({
38
38
  // You can provide either an object with a fetch method (ExportedHandler)
39
39
  // or a class extending WorkerEntrypoint.
40
40
  apiHandler: ApiHandler, // Using a WorkerEntrypoint class
41
-
41
+
42
42
  // For multi-handler setups, you can use apiHandlers instead of apiRoute+apiHandler.
43
43
  // This allows you to use different handlers for different API routes.
44
44
  // Note: You must use either apiRoute+apiHandler (single-handler) OR apiHandlers (multi-handler), not both.
@@ -87,7 +87,13 @@ export default new OAuthProvider({
87
87
  // Note: Creating public clients via the OAuthHelpers.createClient() method
88
88
  // is always allowed regardless of this setting.
89
89
  // Defaults to false.
90
- disallowPublicClientRegistration: false
90
+ disallowPublicClientRegistration: false,
91
+
92
+ // Optional: Time-to-live for refresh tokens in seconds.
93
+ // If not specified, refresh tokens do not expire.
94
+ // Set to 0 to disable refresh tokens (only access tokens will be issued).
95
+ // For example: 3600 = 1 hour, 86400 = 1 day, 2592000 = 30 days
96
+ refreshTokenTTL: 2592000 // 30 days
91
97
  });
92
98
 
93
99
  // The default handler object - the OAuthProvider will pass through HTTP requests to this object's fetch method
@@ -261,6 +267,7 @@ The callback can:
261
267
  - Return only `accessTokenProps` to update just the current access token
262
268
  - Return only `newProps` to update both the grant and access token (the access token inherits these props)
263
269
  - Return `accessTokenTTL` to override the default TTL for this specific access token
270
+ - Return `refreshTokenTTL` to override the default TTL for this specific refresh token
264
271
  - Return nothing to keep the original props unchanged
265
272
 
266
273
  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.
@@ -33,6 +33,13 @@ interface TokenExchangeCallbackResult {
33
33
  * Value should be in seconds.
34
34
  */
35
35
  accessTokenTTL?: number;
36
+ /**
37
+ * Override the default refresh token TTL (time-to-live) for this specific grant.
38
+ * Value should be in seconds.
39
+ * Note: This is only honored during authorization code exchange. If returned during
40
+ * refresh token exchange, it will be ignored.
41
+ */
42
+ refreshTokenTTL?: number;
36
43
  }
37
44
  /**
38
45
  * Options for token exchange callback functions
@@ -61,6 +68,33 @@ interface TokenExchangeCallbackOptions {
61
68
  */
62
69
  props: any;
63
70
  }
71
+ /**
72
+ * Input parameters for the resolveExternalToken callback function
73
+ */
74
+ interface ResolveExternalTokenInput {
75
+ /**
76
+ * The token string that was provided in the Authorization header
77
+ */
78
+ token: string;
79
+ /**
80
+ * The original HTTP request
81
+ */
82
+ request: Request;
83
+ /**
84
+ * Cloudflare Worker environment variables
85
+ */
86
+ env: any;
87
+ }
88
+ /**
89
+ * Result returned from the resolveExternalToken callback function
90
+ */
91
+ interface ResolveExternalTokenResult {
92
+ /**
93
+ * Application-specific properties that will be passed to the API handlers
94
+ * These properties are set in the execution context (ctx.props) when the external token is validated
95
+ */
96
+ props: any;
97
+ }
64
98
  interface OAuthProviderOptions {
65
99
  /**
66
100
  * URL(s) for API routes. Requests with URLs starting with any of these prefixes
@@ -116,6 +150,12 @@ interface OAuthProviderOptions {
116
150
  * Defaults to 1 hour (3600 seconds) if not specified.
117
151
  */
118
152
  accessTokenTTL?: number;
153
+ /**
154
+ * Time-to-live for refresh tokens in seconds.
155
+ * If not specified, refresh tokens do not expire.
156
+ * For example: 3600 = 1 hour, 2592000 = 30 days
157
+ */
158
+ refreshTokenTTL?: number;
119
159
  /**
120
160
  * List of scopes supported by this OAuth provider.
121
161
  * If not provided, the 'scopes_supported' field will be omitted from the OAuth metadata.
@@ -144,6 +184,16 @@ interface OAuthProviderOptions {
144
184
  * If the callback returns nothing or undefined for a props field, the original props will be used.
145
185
  */
146
186
  tokenExchangeCallback?: (options: TokenExchangeCallbackOptions) => Promise<TokenExchangeCallbackResult | void> | TokenExchangeCallbackResult | void;
187
+ /**
188
+ * Optional callback function that is called when a provided token was not found in the internal KV.
189
+ * This allows authentication through external OAuth servers.
190
+ * For example, if a request includes an authenticated token from a different OAuth authentication server,
191
+ * the callback can be used to authenticate it and set the context props through it.
192
+ *
193
+ * The callback can optionally return props values that will passed-through to the apiHandlers.
194
+ * The callback can return `null` to signal resolution failure.
195
+ */
196
+ resolveExternalToken?: (input: ResolveExternalTokenInput) => Promise<ResolveExternalTokenResult | null>;
147
197
  /**
148
198
  * Optional callback function that is called whenever the OAuthProvider returns an error response
149
199
  * This allows the client to emit notifications or perform other actions when an error occurs.
@@ -381,6 +431,10 @@ interface Grant {
381
431
  * Unix timestamp when the grant was created
382
432
  */
383
433
  createdAt: number;
434
+ /**
435
+ * Unix timestamp when the grant expires (if TTL is configured)
436
+ */
437
+ expiresAt?: number;
384
438
  /**
385
439
  * The hash of the current refresh token associated with this grant
386
440
  */
@@ -523,6 +577,10 @@ interface GrantSummary {
523
577
  * Unix timestamp when the grant was created
524
578
  */
525
579
  createdAt: number;
580
+ /**
581
+ * Unix timestamp when the grant expires (if TTL is configured)
582
+ */
583
+ expiresAt?: number;
526
584
  }
527
585
  /**
528
586
  * OAuth 2.0 Provider implementation for Cloudflare Workers
@@ -547,4 +605,4 @@ declare class OAuthProvider {
547
605
  fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response>;
548
606
  }
549
607
 
550
- 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 };
608
+ export { type AuthRequest, type ClientInfo, type CompleteAuthorizationOptions, type Grant, type GrantSummary, type ListOptions, type ListResult, type OAuthHelpers, OAuthProvider, type OAuthProviderOptions, type ResolveExternalTokenInput, type ResolveExternalTokenResult, type Token, type TokenExchangeCallbackOptions, type TokenExchangeCallbackResult, OAuthProvider as default };
@@ -142,7 +142,16 @@ var OAuthProviderImpl = class {
142
142
  return this.addCorsHeaders(response, request);
143
143
  }
144
144
  if (this.isTokenEndpoint(url)) {
145
- const response = await this.handleTokenRequest(request, env);
145
+ const parsed = await this.parseTokenEndpointRequest(request, env);
146
+ if (parsed instanceof Response) {
147
+ return this.addCorsHeaders(parsed, request);
148
+ }
149
+ let response;
150
+ if (parsed.isRevocationRequest) {
151
+ response = await this.handleRevocationRequest(parsed.body, env);
152
+ } else {
153
+ response = await this.handleTokenRequest(parsed.body, parsed.clientInfo, env);
154
+ }
146
155
  return this.addCorsHeaders(response, request);
147
156
  }
148
157
  if (this.options.clientRegistrationEndpoint && this.isClientRegistrationEndpoint(url)) {
@@ -206,6 +215,67 @@ var OAuthProviderImpl = class {
206
215
  if (!this.options.clientRegistrationEndpoint) return false;
207
216
  return this.matchEndpoint(url, this.options.clientRegistrationEndpoint);
208
217
  }
218
+ /**
219
+ * Parses and validates a token endpoint request (used for both token exchange and revocation)
220
+ * @param request - The HTTP request to parse
221
+ * @returns Promise with parsed body and client info, or error response
222
+ */
223
+ async parseTokenEndpointRequest(request, env) {
224
+ if (request.method !== "POST") {
225
+ return this.createErrorResponse("invalid_request", "Method not allowed", 405);
226
+ }
227
+ let contentType = request.headers.get("Content-Type") || "";
228
+ let body = {};
229
+ if (!contentType.includes("application/x-www-form-urlencoded")) {
230
+ return this.createErrorResponse("invalid_request", "Content-Type must be application/x-www-form-urlencoded", 400);
231
+ }
232
+ const formData = await request.formData();
233
+ for (const [key, value] of formData.entries()) {
234
+ body[key] = value;
235
+ }
236
+ const authHeader = request.headers.get("Authorization");
237
+ let clientId = "";
238
+ let clientSecret = "";
239
+ if (authHeader && authHeader.startsWith("Basic ")) {
240
+ const credentials = atob(authHeader.substring(6));
241
+ const [id, secret] = credentials.split(":", 2);
242
+ clientId = decodeURIComponent(id);
243
+ clientSecret = decodeURIComponent(secret || "");
244
+ } else {
245
+ clientId = body.client_id;
246
+ clientSecret = body.client_secret || "";
247
+ }
248
+ if (!clientId) {
249
+ return this.createErrorResponse("invalid_client", "Client ID is required", 401);
250
+ }
251
+ const clientInfo = await this.getClient(env, clientId);
252
+ if (!clientInfo) {
253
+ return this.createErrorResponse("invalid_client", "Client not found", 401);
254
+ }
255
+ const isPublicClient = clientInfo.tokenEndpointAuthMethod === "none";
256
+ if (!isPublicClient) {
257
+ if (!clientSecret) {
258
+ return this.createErrorResponse("invalid_client", "Client authentication failed: missing client_secret", 401);
259
+ }
260
+ if (!clientInfo.clientSecret) {
261
+ return this.createErrorResponse(
262
+ "invalid_client",
263
+ "Client authentication failed: client has no registered secret",
264
+ 401
265
+ );
266
+ }
267
+ const providedSecretHash = await hashSecret(clientSecret);
268
+ if (providedSecretHash !== clientInfo.clientSecret) {
269
+ return this.createErrorResponse("invalid_client", "Client authentication failed: invalid client_secret", 401);
270
+ }
271
+ }
272
+ const isRevocationRequest = !body.grant_type && !!body.token;
273
+ return {
274
+ body,
275
+ clientInfo,
276
+ isRevocationRequest
277
+ };
278
+ }
209
279
  /**
210
280
  * Checks if a URL matches a specific API route
211
281
  * @param url - The URL to check
@@ -329,59 +399,12 @@ var OAuthProviderImpl = class {
329
399
  /**
330
400
  * Handles client authentication and token issuance via the token endpoint
331
401
  * Supports authorization_code and refresh_token grant types
332
- * @param request - The HTTP request
402
+ * @param body - The parsed request body
403
+ * @param clientInfo - The authenticated client information
333
404
  * @param env - Cloudflare Worker environment variables
334
405
  * @returns Response with token data or error
335
406
  */
336
- async handleTokenRequest(request, env) {
337
- if (request.method !== "POST") {
338
- return this.createErrorResponse("invalid_request", "Method not allowed", 405);
339
- }
340
- let contentType = request.headers.get("Content-Type") || "";
341
- let body = {};
342
- if (!contentType.includes("application/x-www-form-urlencoded")) {
343
- return this.createErrorResponse("invalid_request", "Content-Type must be application/x-www-form-urlencoded", 400);
344
- }
345
- const formData = await request.formData();
346
- for (const [key, value] of formData.entries()) {
347
- body[key] = value;
348
- }
349
- const authHeader = request.headers.get("Authorization");
350
- let clientId = "";
351
- let clientSecret = "";
352
- if (authHeader && authHeader.startsWith("Basic ")) {
353
- const credentials = atob(authHeader.substring(6));
354
- const [id, secret] = credentials.split(":", 2);
355
- clientId = decodeURIComponent(id);
356
- clientSecret = decodeURIComponent(secret || "");
357
- } else {
358
- clientId = body.client_id;
359
- clientSecret = body.client_secret || "";
360
- }
361
- if (!clientId) {
362
- return this.createErrorResponse("invalid_client", "Client ID is required", 401);
363
- }
364
- const clientInfo = await this.getClient(env, clientId);
365
- if (!clientInfo) {
366
- return this.createErrorResponse("invalid_client", "Client not found", 401);
367
- }
368
- const isPublicClient = clientInfo.tokenEndpointAuthMethod === "none";
369
- if (!isPublicClient) {
370
- if (!clientSecret) {
371
- return this.createErrorResponse("invalid_client", "Client authentication failed: missing client_secret", 401);
372
- }
373
- if (!clientInfo.clientSecret) {
374
- return this.createErrorResponse(
375
- "invalid_client",
376
- "Client authentication failed: client has no registered secret",
377
- 401
378
- );
379
- }
380
- const providedSecretHash = await hashSecret(clientSecret);
381
- if (providedSecretHash !== clientInfo.clientSecret) {
382
- return this.createErrorResponse("invalid_client", "Client authentication failed: invalid client_secret", 401);
383
- }
384
- }
407
+ async handleTokenRequest(body, clientInfo, env) {
385
408
  const grantType = body.grant_type;
386
409
  if (grantType === "authorization_code") {
387
410
  return this.handleAuthorizationCodeGrant(body, clientInfo, env);
@@ -455,12 +478,10 @@ var OAuthProviderImpl = class {
455
478
  }
456
479
  }
457
480
  const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
458
- const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
459
481
  const accessToken = `${userId}:${grantId}:${accessTokenSecret}`;
460
- const refreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
461
482
  const accessTokenId = await generateTokenId(accessToken);
462
- const refreshTokenId = await generateTokenId(refreshToken);
463
483
  let accessTokenTTL = this.options.accessTokenTTL;
484
+ let refreshTokenTTL = this.options.refreshTokenTTL;
464
485
  const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
465
486
  let grantEncryptionKey = encryptionKey;
466
487
  let accessTokenEncryptionKey = encryptionKey;
@@ -490,6 +511,9 @@ var OAuthProviderImpl = class {
490
511
  if (callbackResult.accessTokenTTL !== void 0) {
491
512
  accessTokenTTL = callbackResult.accessTokenTTL;
492
513
  }
514
+ if ("refreshTokenTTL" in callbackResult) {
515
+ refreshTokenTTL = callbackResult.refreshTokenTTL;
516
+ }
493
517
  }
494
518
  const grantResult = await encryptProps(grantProps);
495
519
  grantData.encryptedProps = grantResult.encryptedData;
@@ -505,17 +529,26 @@ var OAuthProviderImpl = class {
505
529
  }
506
530
  const now = Math.floor(Date.now() / 1e3);
507
531
  const accessTokenExpiresAt = now + accessTokenTTL;
532
+ const useRefreshToken = refreshTokenTTL !== 0;
508
533
  const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, accessTokenEncryptionKey);
509
- const refreshTokenWrappedKey = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
510
534
  delete grantData.authCodeId;
511
535
  delete grantData.codeChallenge;
512
536
  delete grantData.codeChallengeMethod;
513
537
  delete grantData.authCodeWrappedKey;
514
- grantData.refreshTokenId = refreshTokenId;
515
- grantData.refreshTokenWrappedKey = refreshTokenWrappedKey;
516
- grantData.previousRefreshTokenId = void 0;
517
- grantData.previousRefreshTokenWrappedKey = void 0;
518
- await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData));
538
+ let refreshToken;
539
+ if (useRefreshToken) {
540
+ const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
541
+ refreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
542
+ const refreshTokenId = await generateTokenId(refreshToken);
543
+ const refreshTokenWrappedKey = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
544
+ const expiresAt = refreshTokenTTL !== void 0 ? now + refreshTokenTTL : void 0;
545
+ grantData.refreshTokenId = refreshTokenId;
546
+ grantData.refreshTokenWrappedKey = refreshTokenWrappedKey;
547
+ grantData.previousRefreshTokenId = void 0;
548
+ grantData.previousRefreshTokenWrappedKey = void 0;
549
+ grantData.expiresAt = expiresAt;
550
+ }
551
+ await this.saveGrantWithTTL(env, grantKey, grantData, now);
519
552
  const accessTokenData = {
520
553
  id: accessTokenId,
521
554
  grantId,
@@ -532,18 +565,18 @@ var OAuthProviderImpl = class {
532
565
  await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), {
533
566
  expirationTtl: accessTokenTTL
534
567
  });
535
- return new Response(
536
- JSON.stringify({
537
- access_token: accessToken,
538
- token_type: "bearer",
539
- expires_in: accessTokenTTL,
540
- refresh_token: refreshToken,
541
- scope: grantData.scope.join(" ")
542
- }),
543
- {
544
- headers: { "Content-Type": "application/json" }
545
- }
546
- );
568
+ const tokenResponse = {
569
+ access_token: accessToken,
570
+ token_type: "bearer",
571
+ expires_in: accessTokenTTL,
572
+ scope: grantData.scope.join(" ")
573
+ };
574
+ if (refreshToken) {
575
+ tokenResponse.refresh_token = refreshToken;
576
+ }
577
+ return new Response(JSON.stringify(tokenResponse), {
578
+ headers: { "Content-Type": "application/json" }
579
+ });
547
580
  }
548
581
  /**
549
582
  * Handles the refresh token grant type
@@ -577,12 +610,15 @@ var OAuthProviderImpl = class {
577
610
  if (grantData.clientId !== clientInfo.clientId) {
578
611
  return this.createErrorResponse("invalid_grant", "Client ID mismatch");
579
612
  }
613
+ if (grantData.expiresAt !== void 0) {
614
+ const now2 = Math.floor(Date.now() / 1e3);
615
+ if (now2 >= grantData.expiresAt) {
616
+ return this.createErrorResponse("invalid_grant", "Refresh token has expired");
617
+ }
618
+ }
580
619
  const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
581
620
  const newAccessToken = `${userId}:${grantId}:${accessTokenSecret}`;
582
621
  const accessTokenId = await generateTokenId(newAccessToken);
583
- const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
584
- const newRefreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
585
- const newRefreshTokenId = await generateTokenId(newRefreshToken);
586
622
  let accessTokenTTL = this.options.accessTokenTTL;
587
623
  let wrappedKeyToUse;
588
624
  if (isCurrentToken) {
@@ -594,6 +630,7 @@ var OAuthProviderImpl = class {
594
630
  let grantEncryptionKey = encryptionKey;
595
631
  let accessTokenEncryptionKey = encryptionKey;
596
632
  let encryptedAccessTokenProps = grantData.encryptedProps;
633
+ let grantPropsChanged = false;
597
634
  if (this.options.tokenExchangeCallback) {
598
635
  const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
599
636
  let grantProps = decryptedProps;
@@ -606,7 +643,6 @@ var OAuthProviderImpl = class {
606
643
  props: decryptedProps
607
644
  };
608
645
  const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
609
- let grantPropsChanged = false;
610
646
  if (callbackResult) {
611
647
  if (callbackResult.newProps) {
612
648
  grantProps = callbackResult.newProps;
@@ -621,6 +657,12 @@ var OAuthProviderImpl = class {
621
657
  if (callbackResult.accessTokenTTL !== void 0) {
622
658
  accessTokenTTL = callbackResult.accessTokenTTL;
623
659
  }
660
+ if ("refreshTokenTTL" in callbackResult) {
661
+ return this.createErrorResponse(
662
+ "invalid_request",
663
+ "refreshTokenTTL cannot be changed during refresh token exchange"
664
+ );
665
+ }
624
666
  }
625
667
  if (grantPropsChanged) {
626
668
  const grantResult = await encryptProps(grantProps);
@@ -642,14 +684,23 @@ var OAuthProviderImpl = class {
642
684
  }
643
685
  }
644
686
  const now = Math.floor(Date.now() / 1e3);
687
+ if (grantData.expiresAt !== void 0) {
688
+ const remainingRefreshTokenLifetime = grantData.expiresAt - now;
689
+ if (remainingRefreshTokenLifetime > 0) {
690
+ accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
691
+ }
692
+ }
645
693
  const accessTokenExpiresAt = now + accessTokenTTL;
646
694
  const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
695
+ const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
696
+ const newRefreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
697
+ const newRefreshTokenId = await generateTokenId(newRefreshToken);
647
698
  const newRefreshTokenWrappedKey = await wrapKeyWithToken(newRefreshToken, grantEncryptionKey);
648
699
  grantData.previousRefreshTokenId = providedTokenHash;
649
700
  grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
650
701
  grantData.refreshTokenId = newRefreshTokenId;
651
702
  grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
652
- await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData));
703
+ await this.saveGrantWithTTL(env, grantKey, grantData, now);
653
704
  const accessTokenData = {
654
705
  id: accessTokenId,
655
706
  grantId,
@@ -666,18 +717,96 @@ var OAuthProviderImpl = class {
666
717
  await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), {
667
718
  expirationTtl: accessTokenTTL
668
719
  });
669
- return new Response(
670
- JSON.stringify({
671
- access_token: newAccessToken,
672
- token_type: "bearer",
673
- expires_in: accessTokenTTL,
674
- refresh_token: newRefreshToken,
675
- scope: grantData.scope.join(" ")
676
- }),
677
- {
678
- headers: { "Content-Type": "application/json" }
679
- }
680
- );
720
+ const tokenResponse = {
721
+ access_token: newAccessToken,
722
+ token_type: "bearer",
723
+ expires_in: accessTokenTTL,
724
+ refresh_token: newRefreshToken,
725
+ scope: grantData.scope.join(" ")
726
+ };
727
+ return new Response(JSON.stringify(tokenResponse), {
728
+ headers: { "Content-Type": "application/json" }
729
+ });
730
+ }
731
+ /**
732
+ * Handles OAuth 2.0 token revocation requests (RFC 7009)
733
+ * @param body - The parsed request body containing revocation parameters
734
+ * @param env - Cloudflare Worker environment variables
735
+ * @returns Response confirming revocation or error
736
+ */
737
+ async handleRevocationRequest(body, env) {
738
+ return this.revokeToken(body, env);
739
+ }
740
+ /**
741
+ * - Access tokens: Revokes only the specific token
742
+ * - Refresh tokens: Revokes the entire grant (access + refresh tokens)
743
+ * @param body - The parsed request body containing token parameter
744
+ * @param env - Cloudflare Worker environment variables
745
+ * @returns Response confirming revocation or error
746
+ */
747
+ async revokeToken(body, env) {
748
+ const token = body.token;
749
+ if (!token) {
750
+ return this.createErrorResponse("invalid_request", "Token parameter is required");
751
+ }
752
+ const tokenParts = token.split(":");
753
+ if (tokenParts.length !== 3) {
754
+ return new Response("", { status: 200 });
755
+ }
756
+ const [userId, grantId, _] = tokenParts;
757
+ const tokenId = await generateTokenId(token);
758
+ const isAccessToken = await this.validateAccessToken(tokenId, userId, grantId, env);
759
+ const isRefreshToken = await this.validateRefreshToken(tokenId, userId, grantId, env);
760
+ if (isAccessToken) {
761
+ await this.revokeSpecificAccessToken(tokenId, userId, grantId, env);
762
+ } else if (isRefreshToken) {
763
+ await this.createOAuthHelpers(env).revokeGrant(grantId, userId);
764
+ }
765
+ return new Response("", { status: 200 });
766
+ }
767
+ /**
768
+ * Revokes a specific access token without affecting the refresh token
769
+ * @param tokenId - The hashed token ID
770
+ * @param userId - The user ID extracted from the token
771
+ * @param grantId - The grant ID extracted from the token
772
+ * @param env - Cloudflare Worker environment variables
773
+ */
774
+ async revokeSpecificAccessToken(tokenId, userId, grantId, env) {
775
+ const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
776
+ await env.OAUTH_KV.delete(tokenKey);
777
+ }
778
+ /**
779
+ * Validates if a token is a valid access token
780
+ * @param tokenId - The hashed token ID
781
+ * @param userId - The user ID extracted from the token
782
+ * @param grantId - The grant ID extracted from the token
783
+ * @param env - Cloudflare Worker environment variables
784
+ * @returns Promise<boolean> indicating if the token is valid
785
+ */
786
+ async validateAccessToken(tokenId, userId, grantId, env) {
787
+ const tokenKey = `token:${userId}:${grantId}:${tokenId}`;
788
+ const tokenData = await env.OAUTH_KV.get(tokenKey, { type: "json" });
789
+ if (!tokenData) {
790
+ return false;
791
+ }
792
+ const now = Math.floor(Date.now() / 1e3);
793
+ return tokenData.expiresAt >= now;
794
+ }
795
+ /**
796
+ * Validates if a token is a valid refresh token
797
+ * @param tokenId - The hashed token ID
798
+ * @param userId - The user ID extracted from the token
799
+ * @param grantId - The grant ID extracted from the token
800
+ * @param env - Cloudflare Worker environment variables
801
+ * @returns Promise<boolean> indicating if the token is valid
802
+ */
803
+ async validateRefreshToken(tokenId, userId, grantId, env) {
804
+ const grantKey = `grant:${userId}:${grantId}`;
805
+ const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
806
+ if (!grantData) {
807
+ return false;
808
+ }
809
+ return grantData.refreshTokenId === tokenId || grantData.previousRefreshTokenId === tokenId;
681
810
  }
682
811
  /**
683
812
  * Handles the dynamic client registration endpoint (RFC 7591)
@@ -811,30 +940,40 @@ var OAuthProviderImpl = class {
811
940
  });
812
941
  }
813
942
  const accessToken = authHeader.substring(7);
814
- const tokenParts = accessToken.split(":");
815
- if (tokenParts.length !== 3) {
816
- return this.createErrorResponse("invalid_token", "Invalid token format", 401, {
817
- "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
818
- });
819
- }
820
- const [userId, grantId, _] = tokenParts;
821
- const accessTokenId = await generateTokenId(accessToken);
822
- const tokenKey = `token:${userId}:${grantId}:${accessTokenId}`;
823
- const tokenData = await env.OAUTH_KV.get(tokenKey, { type: "json" });
824
- if (!tokenData) {
943
+ const parts = accessToken.split(":");
944
+ const isPossiblyInternalFormat = parts.length === 3;
945
+ let tokenData = null;
946
+ let userId = "";
947
+ let grantId = "";
948
+ if (isPossiblyInternalFormat) {
949
+ [userId, grantId] = parts;
950
+ const id = await generateTokenId(accessToken);
951
+ tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
952
+ }
953
+ if (!tokenData && !this.options.resolveExternalToken) {
825
954
  return this.createErrorResponse("invalid_token", "Invalid access token", 401, {
826
955
  "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
827
956
  });
828
957
  }
829
- const now = Math.floor(Date.now() / 1e3);
830
- if (tokenData.expiresAt < now) {
831
- return this.createErrorResponse("invalid_token", "Access token expired", 401, {
832
- "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
833
- });
958
+ if (tokenData) {
959
+ const now = Math.floor(Date.now() / 1e3);
960
+ if (tokenData.expiresAt < now) {
961
+ return this.createErrorResponse("invalid_token", "Access token expired", 401, {
962
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
963
+ });
964
+ }
965
+ const encryptionKey = await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey);
966
+ const decryptedProps = await decryptProps(encryptionKey, tokenData.grant.encryptedProps);
967
+ ctx.props = decryptedProps;
968
+ } else if (this.options.resolveExternalToken) {
969
+ const ext = await this.options.resolveExternalToken({ token: accessToken, request, env });
970
+ if (!ext) {
971
+ return this.createErrorResponse("invalid_token", "Invalid access token", 401, {
972
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
973
+ });
974
+ }
975
+ ctx.props = ext.props;
834
976
  }
835
- const encryptionKey = await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey);
836
- const decryptedProps = await decryptProps(encryptionKey, tokenData.grant.encryptedProps);
837
- ctx.props = decryptedProps;
838
977
  if (!env.OAUTH_PROVIDER) {
839
978
  env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
840
979
  }
@@ -859,6 +998,17 @@ var OAuthProviderImpl = class {
859
998
  createOAuthHelpers(env) {
860
999
  return new OAuthHelpersImpl(env, this);
861
1000
  }
1001
+ /**
1002
+ * Saves a grant to KV with appropriate TTL based on expiration
1003
+ * @param env - The environment bindings
1004
+ * @param grantKey - The KV key for the grant
1005
+ * @param grantData - The grant data to save
1006
+ * @param now - Current timestamp in seconds
1007
+ */
1008
+ async saveGrantWithTTL(env, grantKey, grantData, now) {
1009
+ const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
1010
+ await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
1011
+ }
862
1012
  /**
863
1013
  * Fetches client information from KV storage
864
1014
  * This method is not private because `OAuthHelpers` needs to call it. Note that since
@@ -902,7 +1052,7 @@ async function hashSecret(secret) {
902
1052
  return generateTokenId(secret);
903
1053
  }
904
1054
  function generateRandomString(length) {
905
- const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
1055
+ const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
906
1056
  let result = "";
907
1057
  const values = new Uint8Array(length);
908
1058
  crypto.getRandomValues(values);
@@ -1071,15 +1221,16 @@ var OAuthHelpersImpl = class {
1071
1221
  const state = url.searchParams.get("state") || "";
1072
1222
  const codeChallenge = url.searchParams.get("code_challenge") || void 0;
1073
1223
  const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "plain";
1224
+ if (redirectUri.startsWith("javascript:") || redirectUri.startsWith("data:") || redirectUri.startsWith("vbscript:") || redirectUri.startsWith("file:") || redirectUri.startsWith("mailto:") || redirectUri.startsWith("blob:")) {
1225
+ throw new Error("Invalid redirect URI");
1226
+ }
1074
1227
  if (responseType === "token" && !this.provider.options.allowImplicitFlow) {
1075
1228
  throw new Error("The implicit grant flow is not enabled for this provider");
1076
1229
  }
1077
1230
  if (clientId) {
1078
1231
  const clientInfo = await this.lookupClient(clientId);
1079
1232
  if (!clientInfo) {
1080
- throw new Error(
1081
- `Invalid client. The clientId provided does not match to this client.`
1082
- );
1233
+ throw new Error(`Invalid client. The clientId provided does not match to this client.`);
1083
1234
  }
1084
1235
  if (clientInfo && redirectUri) {
1085
1236
  if (!clientInfo.redirectUris.includes(redirectUri)) {
@@ -1115,6 +1266,16 @@ var OAuthHelpersImpl = class {
1115
1266
  * @returns A Promise resolving to an object containing the redirect URL
1116
1267
  */
1117
1268
  async completeAuthorization(options) {
1269
+ const { clientId, redirectUri } = options.request;
1270
+ if (!clientId || !redirectUri) {
1271
+ throw new Error("Client ID and Redirect URI are required in the authorization request.");
1272
+ }
1273
+ const clientInfo = await this.lookupClient(clientId);
1274
+ if (!clientInfo || !clientInfo.redirectUris.includes(redirectUri)) {
1275
+ throw new Error(
1276
+ "Invalid redirect URI. The redirect URI provided does not match any registered URI for this client."
1277
+ );
1278
+ }
1118
1279
  const grantId = generateRandomString(16);
1119
1280
  const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
1120
1281
  const now = Math.floor(Date.now() / 1e3);
@@ -1340,7 +1501,8 @@ var OAuthHelpersImpl = class {
1340
1501
  userId: grantData.userId,
1341
1502
  scope: grantData.scope,
1342
1503
  metadata: grantData.metadata,
1343
- createdAt: grantData.createdAt
1504
+ createdAt: grantData.createdAt,
1505
+ expiresAt: grantData.expiresAt
1344
1506
  };
1345
1507
  grantSummaries.push(summary);
1346
1508
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.0.0-4d02346",
3
+ "version": "0.0.0-5a59d78",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",
@@ -26,11 +26,12 @@
26
26
  },
27
27
  "devDependencies": {
28
28
  "@changesets/changelog-github": "^0.5.1",
29
- "@changesets/cli": "^2.29.5",
29
+ "@changesets/cli": "^2.29.7",
30
30
  "@cloudflare/workers-types": "^4.20250807.0",
31
+ "pkg-pr-new": "^0.0.59",
31
32
  "prettier": "^3.6.2",
32
33
  "tsup": "^8.5.0",
33
- "tsx": "^4.20.3",
34
+ "tsx": "^4.20.5",
34
35
  "typescript": "^5.9.2",
35
36
  "vitest": "^3.2.4"
36
37
  },