@cloudflare/workers-oauth-provider 0.0.6 → 0.0.8

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
@@ -116,6 +123,12 @@ interface OAuthProviderOptions {
116
123
  * Defaults to 1 hour (3600 seconds) if not specified.
117
124
  */
118
125
  accessTokenTTL?: number;
126
+ /**
127
+ * Time-to-live for refresh tokens in seconds.
128
+ * If not specified, refresh tokens do not expire.
129
+ * For example: 3600 = 1 hour, 2592000 = 30 days
130
+ */
131
+ refreshTokenTTL?: number;
119
132
  /**
120
133
  * List of scopes supported by this OAuth provider.
121
134
  * If not provided, the 'scopes_supported' field will be omitted from the OAuth metadata.
@@ -381,6 +394,10 @@ interface Grant {
381
394
  * Unix timestamp when the grant was created
382
395
  */
383
396
  createdAt: number;
397
+ /**
398
+ * Unix timestamp when the grant expires (if TTL is configured)
399
+ */
400
+ expiresAt?: number;
384
401
  /**
385
402
  * The hash of the current refresh token associated with this grant
386
403
  */
@@ -523,6 +540,10 @@ interface GrantSummary {
523
540
  * Unix timestamp when the grant was created
524
541
  */
525
542
  createdAt: number;
543
+ /**
544
+ * Unix timestamp when the grant expires (if TTL is configured)
545
+ */
546
+ expiresAt?: number;
526
547
  }
527
548
  /**
528
549
  * OAuth 2.0 Provider implementation for Cloudflare Workers
@@ -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)
@@ -859,6 +988,17 @@ var OAuthProviderImpl = class {
859
988
  createOAuthHelpers(env) {
860
989
  return new OAuthHelpersImpl(env, this);
861
990
  }
991
+ /**
992
+ * Saves a grant to KV with appropriate TTL based on expiration
993
+ * @param env - The environment bindings
994
+ * @param grantKey - The KV key for the grant
995
+ * @param grantData - The grant data to save
996
+ * @param now - Current timestamp in seconds
997
+ */
998
+ async saveGrantWithTTL(env, grantKey, grantData, now) {
999
+ const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
1000
+ await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
1001
+ }
862
1002
  /**
863
1003
  * Fetches client information from KV storage
864
1004
  * This method is not private because `OAuthHelpers` needs to call it. Note that since
@@ -1071,15 +1211,16 @@ var OAuthHelpersImpl = class {
1071
1211
  const state = url.searchParams.get("state") || "";
1072
1212
  const codeChallenge = url.searchParams.get("code_challenge") || void 0;
1073
1213
  const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "plain";
1214
+ if (!redirectUri.startsWith("http://") && !redirectUri.startsWith("https://")) {
1215
+ throw new Error("Invalid redirect URI");
1216
+ }
1074
1217
  if (responseType === "token" && !this.provider.options.allowImplicitFlow) {
1075
1218
  throw new Error("The implicit grant flow is not enabled for this provider");
1076
1219
  }
1077
1220
  if (clientId) {
1078
1221
  const clientInfo = await this.lookupClient(clientId);
1079
1222
  if (!clientInfo) {
1080
- throw new Error(
1081
- `Invalid client. The clientId provided does not match to this client.`
1082
- );
1223
+ throw new Error(`Invalid client. The clientId provided does not match to this client.`);
1083
1224
  }
1084
1225
  if (clientInfo && redirectUri) {
1085
1226
  if (!clientInfo.redirectUris.includes(redirectUri)) {
@@ -1340,7 +1481,8 @@ var OAuthHelpersImpl = class {
1340
1481
  userId: grantData.userId,
1341
1482
  scope: grantData.scope,
1342
1483
  metadata: grantData.metadata,
1343
- createdAt: grantData.createdAt
1484
+ createdAt: grantData.createdAt,
1485
+ expiresAt: grantData.expiresAt
1344
1486
  };
1345
1487
  grantSummaries.push(summary);
1346
1488
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
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
  },