@cloudflare/workers-oauth-provider 0.0.0-75d3fe2 → 0.0.0-818a557

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,39 @@ 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
+ /**
98
+ * Audience claim from the external token (RFC 7519 Section 4.1.3)
99
+ * If provided, will be validated against the resource server identity
100
+ *
101
+ */
102
+ audience?: string | string[];
103
+ }
64
104
  interface OAuthProviderOptions {
65
105
  /**
66
106
  * URL(s) for API routes. Requests with URLs starting with any of these prefixes
@@ -116,6 +156,12 @@ interface OAuthProviderOptions {
116
156
  * Defaults to 1 hour (3600 seconds) if not specified.
117
157
  */
118
158
  accessTokenTTL?: number;
159
+ /**
160
+ * Time-to-live for refresh tokens in seconds.
161
+ * If not specified, refresh tokens do not expire.
162
+ * For example: 3600 = 1 hour, 2592000 = 30 days
163
+ */
164
+ refreshTokenTTL?: number;
119
165
  /**
120
166
  * List of scopes supported by this OAuth provider.
121
167
  * If not provided, the 'scopes_supported' field will be omitted from the OAuth metadata.
@@ -144,6 +190,16 @@ interface OAuthProviderOptions {
144
190
  * If the callback returns nothing or undefined for a props field, the original props will be used.
145
191
  */
146
192
  tokenExchangeCallback?: (options: TokenExchangeCallbackOptions) => Promise<TokenExchangeCallbackResult | void> | TokenExchangeCallbackResult | void;
193
+ /**
194
+ * Optional callback function that is called when a provided token was not found in the internal KV.
195
+ * This allows authentication through external OAuth servers.
196
+ * For example, if a request includes an authenticated token from a different OAuth authentication server,
197
+ * the callback can be used to authenticate it and set the context props through it.
198
+ *
199
+ * The callback can optionally return props values that will passed-through to the apiHandlers.
200
+ * The callback can return `null` to signal resolution failure.
201
+ */
202
+ resolveExternalToken?: (input: ResolveExternalTokenInput) => Promise<ResolveExternalTokenResult | null>;
147
203
  /**
148
204
  * Optional callback function that is called whenever the OAuthProvider returns an error response
149
205
  * This allows the client to emit notifications or perform other actions when an error occurs.
@@ -254,6 +310,10 @@ interface AuthRequest {
254
310
  * PKCE code challenge method (plain or S256)
255
311
  */
256
312
  codeChallengeMethod?: string;
313
+ /**
314
+ * Resource parameter indicating target resource(s) (RFC 8707)
315
+ */
316
+ resource?: string | string[];
257
317
  }
258
318
  /**
259
319
  * OAuth client registration information
@@ -381,6 +441,10 @@ interface Grant {
381
441
  * Unix timestamp when the grant was created
382
442
  */
383
443
  createdAt: number;
444
+ /**
445
+ * Unix timestamp when the grant expires (if TTL is configured)
446
+ */
447
+ expiresAt?: number;
384
448
  /**
385
449
  * The hash of the current refresh token associated with this grant
386
450
  */
@@ -418,6 +482,11 @@ interface Grant {
418
482
  * Only present during the authorization code exchange process
419
483
  */
420
484
  codeChallengeMethod?: string;
485
+ /**
486
+ * Resource parameter from authorization request (RFC 8707 Section 2.1)
487
+ * Indicates the protected resource(s) for which access is requested
488
+ */
489
+ resource?: string | string[];
421
490
  }
422
491
  /**
423
492
  * Token record stored in KV
@@ -446,6 +515,11 @@ interface Token {
446
515
  * Unix timestamp when the token expires
447
516
  */
448
517
  expiresAt: number;
518
+ /**
519
+ * Intended audience for this token (RFC 7519 Section 4.1.3)
520
+ * Can be a single string or array of strings
521
+ */
522
+ audience?: string | string[];
449
523
  /**
450
524
  * The encryption key for props, wrapped with this token
451
525
  */
@@ -523,6 +597,10 @@ interface GrantSummary {
523
597
  * Unix timestamp when the grant was created
524
598
  */
525
599
  createdAt: number;
600
+ /**
601
+ * Unix timestamp when the grant expires (if TTL is configured)
602
+ */
603
+ expiresAt?: number;
526
604
  }
527
605
  /**
528
606
  * OAuth 2.0 Provider implementation for Cloudflare Workers
@@ -547,4 +625,4 @@ declare class OAuthProvider {
547
625
  fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response>;
548
626
  }
549
627
 
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 };
628
+ 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 };
@@ -231,7 +231,8 @@ var OAuthProviderImpl = class {
231
231
  }
232
232
  const formData = await request.formData();
233
233
  for (const [key, value] of formData.entries()) {
234
- body[key] = value;
234
+ const allValues = formData.getAll(key);
235
+ body[key] = allValues.length > 1 ? allValues : value;
235
236
  }
236
237
  const authHeader = request.headers.get("Authorization");
237
238
  let clientId = "";
@@ -478,12 +479,10 @@ var OAuthProviderImpl = class {
478
479
  }
479
480
  }
480
481
  const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
481
- const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
482
482
  const accessToken = `${userId}:${grantId}:${accessTokenSecret}`;
483
- const refreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
484
483
  const accessTokenId = await generateTokenId(accessToken);
485
- const refreshTokenId = await generateTokenId(refreshToken);
486
484
  let accessTokenTTL = this.options.accessTokenTTL;
485
+ let refreshTokenTTL = this.options.refreshTokenTTL;
487
486
  const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
488
487
  let grantEncryptionKey = encryptionKey;
489
488
  let accessTokenEncryptionKey = encryptionKey;
@@ -513,6 +512,9 @@ var OAuthProviderImpl = class {
513
512
  if (callbackResult.accessTokenTTL !== void 0) {
514
513
  accessTokenTTL = callbackResult.accessTokenTTL;
515
514
  }
515
+ if ("refreshTokenTTL" in callbackResult) {
516
+ refreshTokenTTL = callbackResult.refreshTokenTTL;
517
+ }
516
518
  }
517
519
  const grantResult = await encryptProps(grantProps);
518
520
  grantData.encryptedProps = grantResult.encryptedData;
@@ -528,23 +530,52 @@ var OAuthProviderImpl = class {
528
530
  }
529
531
  const now = Math.floor(Date.now() / 1e3);
530
532
  const accessTokenExpiresAt = now + accessTokenTTL;
533
+ const useRefreshToken = refreshTokenTTL !== 0;
531
534
  const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, accessTokenEncryptionKey);
532
- const refreshTokenWrappedKey = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
533
535
  delete grantData.authCodeId;
534
536
  delete grantData.codeChallenge;
535
537
  delete grantData.codeChallengeMethod;
536
538
  delete grantData.authCodeWrappedKey;
537
- grantData.refreshTokenId = refreshTokenId;
538
- grantData.refreshTokenWrappedKey = refreshTokenWrappedKey;
539
- grantData.previousRefreshTokenId = void 0;
540
- grantData.previousRefreshTokenWrappedKey = void 0;
541
- await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData));
539
+ let refreshToken;
540
+ if (useRefreshToken) {
541
+ const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
542
+ refreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
543
+ const refreshTokenId = await generateTokenId(refreshToken);
544
+ const refreshTokenWrappedKey = await wrapKeyWithToken(refreshToken, grantEncryptionKey);
545
+ const expiresAt = refreshTokenTTL !== void 0 ? now + refreshTokenTTL : void 0;
546
+ grantData.refreshTokenId = refreshTokenId;
547
+ grantData.refreshTokenWrappedKey = refreshTokenWrappedKey;
548
+ grantData.previousRefreshTokenId = void 0;
549
+ grantData.previousRefreshTokenWrappedKey = void 0;
550
+ grantData.expiresAt = expiresAt;
551
+ }
552
+ await this.saveGrantWithTTL(env, grantKey, grantData, now);
553
+ if (body.resource && grantData.resource) {
554
+ const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
555
+ const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
556
+ for (const requested of requestedResources) {
557
+ if (!grantedResources.includes(requested)) {
558
+ return this.createErrorResponse(
559
+ "invalid_target",
560
+ "Requested resource was not included in the authorization request"
561
+ );
562
+ }
563
+ }
564
+ }
565
+ const audience = parseResourceParameter(body.resource || grantData.resource);
566
+ if ((body.resource || grantData.resource) && !audience) {
567
+ return this.createErrorResponse(
568
+ "invalid_target",
569
+ "The resource parameter must be a valid absolute URI without a fragment"
570
+ );
571
+ }
542
572
  const accessTokenData = {
543
573
  id: accessTokenId,
544
574
  grantId,
545
575
  userId,
546
576
  createdAt: now,
547
577
  expiresAt: accessTokenExpiresAt,
578
+ audience,
548
579
  wrappedEncryptionKey: accessTokenWrappedKey,
549
580
  grant: {
550
581
  clientId: grantData.clientId,
@@ -555,18 +586,21 @@ var OAuthProviderImpl = class {
555
586
  await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), {
556
587
  expirationTtl: accessTokenTTL
557
588
  });
558
- return new Response(
559
- JSON.stringify({
560
- access_token: accessToken,
561
- token_type: "bearer",
562
- expires_in: accessTokenTTL,
563
- refresh_token: refreshToken,
564
- scope: grantData.scope.join(" ")
565
- }),
566
- {
567
- headers: { "Content-Type": "application/json" }
568
- }
569
- );
589
+ const tokenResponse = {
590
+ access_token: accessToken,
591
+ token_type: "bearer",
592
+ expires_in: accessTokenTTL,
593
+ scope: grantData.scope.join(" ")
594
+ };
595
+ if (refreshToken) {
596
+ tokenResponse.refresh_token = refreshToken;
597
+ }
598
+ if (audience) {
599
+ tokenResponse.resource = audience;
600
+ }
601
+ return new Response(JSON.stringify(tokenResponse), {
602
+ headers: { "Content-Type": "application/json" }
603
+ });
570
604
  }
571
605
  /**
572
606
  * Handles the refresh token grant type
@@ -600,12 +634,15 @@ var OAuthProviderImpl = class {
600
634
  if (grantData.clientId !== clientInfo.clientId) {
601
635
  return this.createErrorResponse("invalid_grant", "Client ID mismatch");
602
636
  }
637
+ if (grantData.expiresAt !== void 0) {
638
+ const now2 = Math.floor(Date.now() / 1e3);
639
+ if (now2 >= grantData.expiresAt) {
640
+ return this.createErrorResponse("invalid_grant", "Refresh token has expired");
641
+ }
642
+ }
603
643
  const accessTokenSecret = generateRandomString(TOKEN_LENGTH);
604
644
  const newAccessToken = `${userId}:${grantId}:${accessTokenSecret}`;
605
645
  const accessTokenId = await generateTokenId(newAccessToken);
606
- const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
607
- const newRefreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
608
- const newRefreshTokenId = await generateTokenId(newRefreshToken);
609
646
  let accessTokenTTL = this.options.accessTokenTTL;
610
647
  let wrappedKeyToUse;
611
648
  if (isCurrentToken) {
@@ -617,6 +654,7 @@ var OAuthProviderImpl = class {
617
654
  let grantEncryptionKey = encryptionKey;
618
655
  let accessTokenEncryptionKey = encryptionKey;
619
656
  let encryptedAccessTokenProps = grantData.encryptedProps;
657
+ let grantPropsChanged = false;
620
658
  if (this.options.tokenExchangeCallback) {
621
659
  const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
622
660
  let grantProps = decryptedProps;
@@ -629,7 +667,6 @@ var OAuthProviderImpl = class {
629
667
  props: decryptedProps
630
668
  };
631
669
  const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
632
- let grantPropsChanged = false;
633
670
  if (callbackResult) {
634
671
  if (callbackResult.newProps) {
635
672
  grantProps = callbackResult.newProps;
@@ -644,6 +681,12 @@ var OAuthProviderImpl = class {
644
681
  if (callbackResult.accessTokenTTL !== void 0) {
645
682
  accessTokenTTL = callbackResult.accessTokenTTL;
646
683
  }
684
+ if ("refreshTokenTTL" in callbackResult) {
685
+ return this.createErrorResponse(
686
+ "invalid_request",
687
+ "refreshTokenTTL cannot be changed during refresh token exchange"
688
+ );
689
+ }
647
690
  }
648
691
  if (grantPropsChanged) {
649
692
  const grantResult = await encryptProps(grantProps);
@@ -665,20 +708,49 @@ var OAuthProviderImpl = class {
665
708
  }
666
709
  }
667
710
  const now = Math.floor(Date.now() / 1e3);
711
+ if (grantData.expiresAt !== void 0) {
712
+ const remainingRefreshTokenLifetime = grantData.expiresAt - now;
713
+ if (remainingRefreshTokenLifetime > 0) {
714
+ accessTokenTTL = Math.min(accessTokenTTL, remainingRefreshTokenLifetime);
715
+ }
716
+ }
668
717
  const accessTokenExpiresAt = now + accessTokenTTL;
669
718
  const accessTokenWrappedKey = await wrapKeyWithToken(newAccessToken, accessTokenEncryptionKey);
719
+ const refreshTokenSecret = generateRandomString(TOKEN_LENGTH);
720
+ const newRefreshToken = `${userId}:${grantId}:${refreshTokenSecret}`;
721
+ const newRefreshTokenId = await generateTokenId(newRefreshToken);
670
722
  const newRefreshTokenWrappedKey = await wrapKeyWithToken(newRefreshToken, grantEncryptionKey);
671
723
  grantData.previousRefreshTokenId = providedTokenHash;
672
724
  grantData.previousRefreshTokenWrappedKey = wrappedKeyToUse;
673
725
  grantData.refreshTokenId = newRefreshTokenId;
674
726
  grantData.refreshTokenWrappedKey = newRefreshTokenWrappedKey;
675
- await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData));
727
+ await this.saveGrantWithTTL(env, grantKey, grantData, now);
728
+ if (body.resource && grantData.resource) {
729
+ const requestedResources = Array.isArray(body.resource) ? body.resource : [body.resource];
730
+ const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
731
+ for (const requested of requestedResources) {
732
+ if (!grantedResources.includes(requested)) {
733
+ return this.createErrorResponse(
734
+ "invalid_target",
735
+ "Requested resource was not included in the authorization request"
736
+ );
737
+ }
738
+ }
739
+ }
740
+ const audience = parseResourceParameter(body.resource || grantData.resource);
741
+ if ((body.resource || grantData.resource) && !audience) {
742
+ return this.createErrorResponse(
743
+ "invalid_target",
744
+ "The resource parameter must be a valid absolute URI without a fragment"
745
+ );
746
+ }
676
747
  const accessTokenData = {
677
748
  id: accessTokenId,
678
749
  grantId,
679
750
  userId,
680
751
  createdAt: now,
681
752
  expiresAt: accessTokenExpiresAt,
753
+ audience,
682
754
  wrappedEncryptionKey: accessTokenWrappedKey,
683
755
  grant: {
684
756
  clientId: grantData.clientId,
@@ -689,18 +761,19 @@ var OAuthProviderImpl = class {
689
761
  await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), {
690
762
  expirationTtl: accessTokenTTL
691
763
  });
692
- return new Response(
693
- JSON.stringify({
694
- access_token: newAccessToken,
695
- token_type: "bearer",
696
- expires_in: accessTokenTTL,
697
- refresh_token: newRefreshToken,
698
- scope: grantData.scope.join(" ")
699
- }),
700
- {
701
- headers: { "Content-Type": "application/json" }
702
- }
703
- );
764
+ const tokenResponse = {
765
+ access_token: newAccessToken,
766
+ token_type: "bearer",
767
+ expires_in: accessTokenTTL,
768
+ refresh_token: newRefreshToken,
769
+ scope: grantData.scope.join(" ")
770
+ };
771
+ if (audience) {
772
+ tokenResponse.resource = audience;
773
+ }
774
+ return new Response(JSON.stringify(tokenResponse), {
775
+ headers: { "Content-Type": "application/json" }
776
+ });
704
777
  }
705
778
  /**
706
779
  * Handles OAuth 2.0 token revocation requests (RFC 7009)
@@ -850,6 +923,9 @@ var OAuthProviderImpl = class {
850
923
  if (!redirectUris || redirectUris.length === 0) {
851
924
  throw new Error("At least one redirect URI is required");
852
925
  }
926
+ for (const uri of redirectUris) {
927
+ validateRedirectUriScheme(uri);
928
+ }
853
929
  clientInfo = {
854
930
  clientId,
855
931
  redirectUris,
@@ -914,30 +990,62 @@ var OAuthProviderImpl = class {
914
990
  });
915
991
  }
916
992
  const accessToken = authHeader.substring(7);
917
- const tokenParts = accessToken.split(":");
918
- if (tokenParts.length !== 3) {
919
- return this.createErrorResponse("invalid_token", "Invalid token format", 401, {
920
- "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
921
- });
922
- }
923
- const [userId, grantId, _] = tokenParts;
924
- const accessTokenId = await generateTokenId(accessToken);
925
- const tokenKey = `token:${userId}:${grantId}:${accessTokenId}`;
926
- const tokenData = await env.OAUTH_KV.get(tokenKey, { type: "json" });
927
- if (!tokenData) {
993
+ const parts = accessToken.split(":");
994
+ const isPossiblyInternalFormat = parts.length === 3;
995
+ let tokenData = null;
996
+ let userId = "";
997
+ let grantId = "";
998
+ if (isPossiblyInternalFormat) {
999
+ [userId, grantId] = parts;
1000
+ const id = await generateTokenId(accessToken);
1001
+ tokenData = await env.OAUTH_KV.get(`token:${userId}:${grantId}:${id}`, { type: "json" });
1002
+ }
1003
+ if (!tokenData && !this.options.resolveExternalToken) {
928
1004
  return this.createErrorResponse("invalid_token", "Invalid access token", 401, {
929
1005
  "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
930
1006
  });
931
1007
  }
932
- const now = Math.floor(Date.now() / 1e3);
933
- if (tokenData.expiresAt < now) {
934
- return this.createErrorResponse("invalid_token", "Access token expired", 401, {
935
- "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
936
- });
1008
+ if (tokenData) {
1009
+ const now = Math.floor(Date.now() / 1e3);
1010
+ if (tokenData.expiresAt < now) {
1011
+ return this.createErrorResponse("invalid_token", "Access token expired", 401, {
1012
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
1013
+ });
1014
+ }
1015
+ if (tokenData.audience) {
1016
+ const requestUrl = new URL(request.url);
1017
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`;
1018
+ const audiences = Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience];
1019
+ const matches = audiences.some((aud) => audienceMatches(resourceServer, aud));
1020
+ if (!matches) {
1021
+ return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, {
1022
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token", error_description="Invalid audience"'
1023
+ });
1024
+ }
1025
+ }
1026
+ const encryptionKey = await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey);
1027
+ const decryptedProps = await decryptProps(encryptionKey, tokenData.grant.encryptedProps);
1028
+ ctx.props = decryptedProps;
1029
+ } else if (this.options.resolveExternalToken) {
1030
+ const ext = await this.options.resolveExternalToken({ token: accessToken, request, env });
1031
+ if (!ext) {
1032
+ return this.createErrorResponse("invalid_token", "Invalid access token", 401, {
1033
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token"'
1034
+ });
1035
+ }
1036
+ if (ext.audience) {
1037
+ const requestUrl = new URL(request.url);
1038
+ const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`;
1039
+ const audiences = Array.isArray(ext.audience) ? ext.audience : [ext.audience];
1040
+ const matches = audiences.some((aud) => audienceMatches(resourceServer, aud));
1041
+ if (!matches) {
1042
+ return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, {
1043
+ "WWW-Authenticate": 'Bearer realm="OAuth", error="invalid_token", error_description="Invalid audience"'
1044
+ });
1045
+ }
1046
+ }
1047
+ ctx.props = ext.props;
937
1048
  }
938
- const encryptionKey = await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey);
939
- const decryptedProps = await decryptProps(encryptionKey, tokenData.grant.encryptedProps);
940
- ctx.props = decryptedProps;
941
1049
  if (!env.OAUTH_PROVIDER) {
942
1050
  env.OAUTH_PROVIDER = this.createOAuthHelpers(env);
943
1051
  }
@@ -962,6 +1070,17 @@ var OAuthProviderImpl = class {
962
1070
  createOAuthHelpers(env) {
963
1071
  return new OAuthHelpersImpl(env, this);
964
1072
  }
1073
+ /**
1074
+ * Saves a grant to KV with appropriate TTL based on expiration
1075
+ * @param env - The environment bindings
1076
+ * @param grantKey - The KV key for the grant
1077
+ * @param grantData - The grant data to save
1078
+ * @param now - Current timestamp in seconds
1079
+ */
1080
+ async saveGrantWithTTL(env, grantKey, grantData, now) {
1081
+ const kvOptions = grantData.expiresAt !== void 0 ? { expiration: grantData.expiresAt } : {};
1082
+ await env.OAUTH_KV.put(grantKey, JSON.stringify(grantData), kvOptions);
1083
+ }
965
1084
  /**
966
1085
  * Fetches client information from KV storage
967
1086
  * This method is not private because `OAuthHelpers` needs to call it. Note that since
@@ -1001,11 +1120,46 @@ var OAuthProviderImpl = class {
1001
1120
  };
1002
1121
  var DEFAULT_ACCESS_TOKEN_TTL = 60 * 60;
1003
1122
  var TOKEN_LENGTH = 32;
1123
+ function validateResourceUri(uri) {
1124
+ if (!uri || typeof uri !== "string") {
1125
+ return false;
1126
+ }
1127
+ try {
1128
+ const parsed = new URL(uri);
1129
+ if (!parsed.protocol) {
1130
+ return false;
1131
+ }
1132
+ if (parsed.hash) {
1133
+ return false;
1134
+ }
1135
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
1136
+ return false;
1137
+ }
1138
+ return true;
1139
+ } catch {
1140
+ return false;
1141
+ }
1142
+ }
1143
+ function audienceMatches(resourceServerUrl, audienceValue) {
1144
+ return resourceServerUrl === audienceValue;
1145
+ }
1146
+ function parseResourceParameter(value) {
1147
+ if (!value) {
1148
+ return void 0;
1149
+ }
1150
+ const uris = Array.isArray(value) ? value : [value];
1151
+ for (const uri of uris) {
1152
+ if (typeof uri !== "string" || !validateResourceUri(uri)) {
1153
+ return void 0;
1154
+ }
1155
+ }
1156
+ return value;
1157
+ }
1004
1158
  async function hashSecret(secret) {
1005
1159
  return generateTokenId(secret);
1006
1160
  }
1007
1161
  function generateRandomString(length) {
1008
- const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
1162
+ const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
1009
1163
  let result = "";
1010
1164
  const values = new Uint8Array(length);
1011
1165
  crypto.getRandomValues(values);
@@ -1022,6 +1176,26 @@ async function generateTokenId(token) {
1022
1176
  const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
1023
1177
  return hashHex;
1024
1178
  }
1179
+ function validateRedirectUriScheme(redirectUri) {
1180
+ const dangerousSchemes = ["javascript:", "data:", "vbscript:", "file:", "mailto:", "blob:"];
1181
+ const normalized = redirectUri.trim();
1182
+ for (let i = 0; i < normalized.length; i++) {
1183
+ const code = normalized.charCodeAt(i);
1184
+ if (code >= 0 && code <= 31 || code >= 127 && code <= 159) {
1185
+ throw new Error("Invalid redirect URI");
1186
+ }
1187
+ }
1188
+ const colonIndex = normalized.indexOf(":");
1189
+ if (colonIndex === -1) {
1190
+ throw new Error("Invalid redirect URI");
1191
+ }
1192
+ const scheme = normalized.substring(0, colonIndex + 1).toLowerCase();
1193
+ for (const dangerousScheme of dangerousSchemes) {
1194
+ if (scheme === dangerousScheme) {
1195
+ throw new Error("Invalid redirect URI");
1196
+ }
1197
+ }
1198
+ }
1025
1199
  function base64UrlEncode(str) {
1026
1200
  return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
1027
1201
  }
@@ -1174,8 +1348,12 @@ var OAuthHelpersImpl = class {
1174
1348
  const state = url.searchParams.get("state") || "";
1175
1349
  const codeChallenge = url.searchParams.get("code_challenge") || void 0;
1176
1350
  const codeChallengeMethod = url.searchParams.get("code_challenge_method") || "plain";
1177
- if (!redirectUri.startsWith("http://") && !redirectUri.startsWith("https://")) {
1178
- throw new Error("Invalid redirect URI");
1351
+ const resourceParams = url.searchParams.getAll("resource");
1352
+ const resourceParam = resourceParams.length > 0 ? resourceParams.length === 1 ? resourceParams[0] : resourceParams : void 0;
1353
+ validateRedirectUriScheme(redirectUri);
1354
+ const resource = parseResourceParameter(resourceParam);
1355
+ if (resourceParam && !resource) {
1356
+ throw new Error("The resource parameter must be a valid absolute URI without a fragment");
1179
1357
  }
1180
1358
  if (responseType === "token" && !this.provider.options.allowImplicitFlow) {
1181
1359
  throw new Error("The implicit grant flow is not enabled for this provider");
@@ -1200,7 +1378,8 @@ var OAuthHelpersImpl = class {
1200
1378
  scope,
1201
1379
  state,
1202
1380
  codeChallenge,
1203
- codeChallengeMethod
1381
+ codeChallengeMethod,
1382
+ resource
1204
1383
  };
1205
1384
  }
1206
1385
  /**
@@ -1219,6 +1398,16 @@ var OAuthHelpersImpl = class {
1219
1398
  * @returns A Promise resolving to an object containing the redirect URL
1220
1399
  */
1221
1400
  async completeAuthorization(options) {
1401
+ const { clientId, redirectUri } = options.request;
1402
+ if (!clientId || !redirectUri) {
1403
+ throw new Error("Client ID and Redirect URI are required in the authorization request.");
1404
+ }
1405
+ const clientInfo = await this.lookupClient(clientId);
1406
+ if (!clientInfo || !clientInfo.redirectUris.includes(redirectUri)) {
1407
+ throw new Error(
1408
+ "Invalid redirect URI. The redirect URI provided does not match any registered URI for this client."
1409
+ );
1410
+ }
1222
1411
  const grantId = generateRandomString(16);
1223
1412
  const { encryptedData, key: encryptionKey } = await encryptProps(options.props);
1224
1413
  const now = Math.floor(Date.now() / 1e3);
@@ -1229,6 +1418,10 @@ var OAuthHelpersImpl = class {
1229
1418
  const accessTokenTTL = this.provider.options.accessTokenTTL || DEFAULT_ACCESS_TOKEN_TTL;
1230
1419
  const accessTokenExpiresAt = now + accessTokenTTL;
1231
1420
  const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, encryptionKey);
1421
+ const audience = parseResourceParameter(options.request.resource);
1422
+ if (options.request.resource && !audience) {
1423
+ throw new Error("The resource parameter must be a valid absolute URI without a fragment");
1424
+ }
1232
1425
  const grant = {
1233
1426
  id: grantId,
1234
1427
  clientId: options.request.clientId,
@@ -1236,7 +1429,8 @@ var OAuthHelpersImpl = class {
1236
1429
  scope: options.scope,
1237
1430
  metadata: options.metadata,
1238
1431
  encryptedProps: encryptedData,
1239
- createdAt: now
1432
+ createdAt: now,
1433
+ resource: options.request.resource
1240
1434
  };
1241
1435
  const grantKey = `grant:${options.userId}:${grantId}`;
1242
1436
  await this.env.OAUTH_KV.put(grantKey, JSON.stringify(grant));
@@ -1246,6 +1440,7 @@ var OAuthHelpersImpl = class {
1246
1440
  userId: options.userId,
1247
1441
  createdAt: now,
1248
1442
  expiresAt: accessTokenExpiresAt,
1443
+ audience,
1249
1444
  wrappedEncryptionKey: accessTokenWrappedKey,
1250
1445
  grant: {
1251
1446
  clientId: options.request.clientId,
@@ -1288,7 +1483,8 @@ var OAuthHelpersImpl = class {
1288
1483
  // Store the wrapped key
1289
1484
  // Store PKCE parameters if provided
1290
1485
  codeChallenge: options.request.codeChallenge,
1291
- codeChallengeMethod: options.request.codeChallengeMethod
1486
+ codeChallengeMethod: options.request.codeChallengeMethod,
1487
+ resource: options.request.resource
1292
1488
  };
1293
1489
  const grantKey = `grant:${options.userId}:${grantId}`;
1294
1490
  const codeExpiresIn = 600;
@@ -1325,6 +1521,9 @@ var OAuthHelpersImpl = class {
1325
1521
  registrationDate: Math.floor(Date.now() / 1e3),
1326
1522
  tokenEndpointAuthMethod
1327
1523
  };
1524
+ for (const uri of newClient.redirectUris) {
1525
+ validateRedirectUriScheme(uri);
1526
+ }
1328
1527
  let clientSecret;
1329
1528
  if (!isPublicClient) {
1330
1529
  clientSecret = generateRandomString(32);
@@ -1444,7 +1643,8 @@ var OAuthHelpersImpl = class {
1444
1643
  userId: grantData.userId,
1445
1644
  scope: grantData.scope,
1446
1645
  metadata: grantData.metadata,
1447
- createdAt: grantData.createdAt
1646
+ createdAt: grantData.createdAt,
1647
+ expiresAt: grantData.expiresAt
1448
1648
  };
1449
1649
  grantSummaries.push(summary);
1450
1650
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/workers-oauth-provider",
3
- "version": "0.0.0-75d3fe2",
3
+ "version": "0.0.0-818a557",
4
4
  "description": "OAuth provider for Cloudflare Workers",
5
5
  "main": "dist/oauth-provider.js",
6
6
  "types": "dist/oauth-provider.d.ts",