@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 +9 -2
- package/dist/oauth-provider.d.ts +79 -1
- package/dist/oauth-provider.js +265 -65
- package/package.json +1 -1
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.
|
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -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 };
|
package/dist/oauth-provider.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
|
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
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
"
|
|
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
|
-
|
|
1178
|
-
|
|
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