@cloudflare/workers-oauth-provider 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/oauth-provider.d.ts +82 -4
- package/dist/oauth-provider.js +263 -35
- package/package.json +1 -2
package/dist/oauth-provider.d.ts
CHANGED
|
@@ -2,6 +2,14 @@ import { WorkerEntrypoint } from "cloudflare:workers";
|
|
|
2
2
|
|
|
3
3
|
//#region src/oauth-provider.d.ts
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Enum representing OAuth grant types
|
|
7
|
+
*/
|
|
8
|
+
declare enum GrantType {
|
|
9
|
+
AUTHORIZATION_CODE = "authorization_code",
|
|
10
|
+
REFRESH_TOKEN = "refresh_token",
|
|
11
|
+
TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange",
|
|
12
|
+
}
|
|
5
13
|
/**
|
|
6
14
|
* Aliases for either type of Handler that makes .fetch required
|
|
7
15
|
*/
|
|
@@ -42,6 +50,11 @@ interface TokenExchangeCallbackResult {
|
|
|
42
50
|
* refresh token exchange, it will be ignored.
|
|
43
51
|
*/
|
|
44
52
|
refreshTokenTTL?: number;
|
|
53
|
+
/**
|
|
54
|
+
* List of scopes authorized for the new access token
|
|
55
|
+
* (If undefined, the granted scopes will be used)
|
|
56
|
+
*/
|
|
57
|
+
accessTokenScope?: string[];
|
|
45
58
|
}
|
|
46
59
|
/**
|
|
47
60
|
* Options for token exchange callback functions
|
|
@@ -49,10 +62,8 @@ interface TokenExchangeCallbackResult {
|
|
|
49
62
|
interface TokenExchangeCallbackOptions {
|
|
50
63
|
/**
|
|
51
64
|
* The type of grant being processed.
|
|
52
|
-
* 'authorization_code' for initial code exchange,
|
|
53
|
-
* 'refresh_token' for refresh token exchange.
|
|
54
65
|
*/
|
|
55
|
-
grantType:
|
|
66
|
+
grantType: GrantType;
|
|
56
67
|
/**
|
|
57
68
|
* Client that received this grant
|
|
58
69
|
*/
|
|
@@ -65,6 +76,11 @@ interface TokenExchangeCallbackOptions {
|
|
|
65
76
|
* List of scopes that were granted
|
|
66
77
|
*/
|
|
67
78
|
scope: string[];
|
|
79
|
+
/**
|
|
80
|
+
* List of scopes that were requested for this token by the client
|
|
81
|
+
* (Will be the same as granted scopes unless client specifically requested a downscoping)
|
|
82
|
+
*/
|
|
83
|
+
requestedScope: string[];
|
|
68
84
|
/**
|
|
69
85
|
* Application-specific properties currently associated with this grant
|
|
70
86
|
*/
|
|
@@ -175,6 +191,13 @@ interface OAuthProviderOptions {
|
|
|
175
191
|
* Defaults to false.
|
|
176
192
|
*/
|
|
177
193
|
allowImplicitFlow?: boolean;
|
|
194
|
+
/**
|
|
195
|
+
* Controls whether OAuth 2.0 Token Exchange (RFC 8693) is allowed.
|
|
196
|
+
* When false, the token exchange grant type will not be advertised in metadata
|
|
197
|
+
* and token exchange requests will be rejected.
|
|
198
|
+
* Defaults to false.
|
|
199
|
+
*/
|
|
200
|
+
allowTokenExchangeGrant?: boolean;
|
|
178
201
|
/**
|
|
179
202
|
* Controls whether public clients (clients without a secret, like SPAs) can register via the
|
|
180
203
|
* dynamic client registration endpoint. When true, only confidential clients can register.
|
|
@@ -285,6 +308,34 @@ interface OAuthHelpers {
|
|
|
285
308
|
* @returns Promise resolving to token data with decrypted props, or null if token is invalid
|
|
286
309
|
*/
|
|
287
310
|
unwrapToken<T = any>(token: string): Promise<TokenSummary<T> | null>;
|
|
311
|
+
/**
|
|
312
|
+
* Exchanges an existing access token for a new one with modified characteristics
|
|
313
|
+
* Implements OAuth 2.0 Token Exchange (RFC 8693)
|
|
314
|
+
* @param options - Options for token exchange including subject token and optional modifications
|
|
315
|
+
* @returns Promise resolving to token response with new access token
|
|
316
|
+
*/
|
|
317
|
+
exchangeToken(options: ExchangeTokenOptions): Promise<TokenResponse>;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Options for token exchange operations (RFC 8693)
|
|
321
|
+
*/
|
|
322
|
+
interface ExchangeTokenOptions {
|
|
323
|
+
/**
|
|
324
|
+
* The subject token to exchange (existing access token)
|
|
325
|
+
*/
|
|
326
|
+
subjectToken: string;
|
|
327
|
+
/**
|
|
328
|
+
* Optional narrowed set of scopes for the new token (must be subset of original grant scopes)
|
|
329
|
+
*/
|
|
330
|
+
scope?: string[];
|
|
331
|
+
/**
|
|
332
|
+
* Optional target audience/resource for the new token (maps to resource parameter per RFC 8707)
|
|
333
|
+
*/
|
|
334
|
+
aud?: string | string[];
|
|
335
|
+
/**
|
|
336
|
+
* Optional TTL override for the new token in seconds (must not exceed subject token's remaining lifetime)
|
|
337
|
+
*/
|
|
338
|
+
expiresIn?: number;
|
|
288
339
|
}
|
|
289
340
|
/**
|
|
290
341
|
* Parsed OAuth authorization request parameters
|
|
@@ -496,6 +547,22 @@ interface Grant {
|
|
|
496
547
|
*/
|
|
497
548
|
resource?: string | string[];
|
|
498
549
|
}
|
|
550
|
+
/**
|
|
551
|
+
* OAuth 2.0 Token Response
|
|
552
|
+
* The response returned when exchanging authorization codes or refresh tokens
|
|
553
|
+
*/
|
|
554
|
+
interface TokenResponse {
|
|
555
|
+
access_token: string;
|
|
556
|
+
token_type: 'bearer';
|
|
557
|
+
expires_in: number;
|
|
558
|
+
refresh_token?: string;
|
|
559
|
+
scope: string;
|
|
560
|
+
/**
|
|
561
|
+
* Resource indicator(s) for the issued access token (RFC 8707 Section 2.2)
|
|
562
|
+
* SHOULD be included to indicate the resource server(s) for which the token is valid
|
|
563
|
+
*/
|
|
564
|
+
resource?: string | string[];
|
|
565
|
+
}
|
|
499
566
|
/**
|
|
500
567
|
* Shared fields for Token and TokenSummary
|
|
501
568
|
*/
|
|
@@ -525,6 +592,10 @@ interface TokenBase {
|
|
|
525
592
|
* Can be a single string or array of strings
|
|
526
593
|
*/
|
|
527
594
|
audience?: string | string[];
|
|
595
|
+
/**
|
|
596
|
+
* List of scopes on this token
|
|
597
|
+
*/
|
|
598
|
+
scope: string[];
|
|
528
599
|
}
|
|
529
600
|
/**
|
|
530
601
|
* Token record stored in KV
|
|
@@ -660,5 +731,12 @@ declare class OAuthProvider {
|
|
|
660
731
|
*/
|
|
661
732
|
fetch(request: Request, env: any, ctx: ExecutionContext): Promise<Response>;
|
|
662
733
|
}
|
|
734
|
+
/**
|
|
735
|
+
* Gets OAuthHelpers for the given environment
|
|
736
|
+
* @param options - Configuration options for the OAuth provider
|
|
737
|
+
* @param env - Cloudflare Worker environment variables
|
|
738
|
+
* @returns An instance of OAuthHelpers
|
|
739
|
+
*/
|
|
740
|
+
declare function getOAuthApi(options: OAuthProviderOptions, env: any): OAuthHelpers;
|
|
663
741
|
//#endregion
|
|
664
|
-
export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, Grant, GrantSummary, ListOptions, ListResult, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary };
|
|
742
|
+
export { AuthRequest, ClientInfo, CompleteAuthorizationOptions, ExchangeTokenOptions, Grant, GrantSummary, GrantType, ListOptions, ListResult, OAuthHelpers, OAuthProvider, OAuthProvider as default, OAuthProviderOptions, ResolveExternalTokenInput, ResolveExternalTokenResult, Token, TokenBase, TokenExchangeCallbackOptions, TokenExchangeCallbackResult, TokenSummary, getOAuthApi };
|
package/dist/oauth-provider.js
CHANGED
|
@@ -11,6 +11,15 @@ var HandlerType = /* @__PURE__ */ function(HandlerType$1) {
|
|
|
11
11
|
return HandlerType$1;
|
|
12
12
|
}(HandlerType || {});
|
|
13
13
|
/**
|
|
14
|
+
* Enum representing OAuth grant types
|
|
15
|
+
*/
|
|
16
|
+
let GrantType = /* @__PURE__ */ function(GrantType$1) {
|
|
17
|
+
GrantType$1["AUTHORIZATION_CODE"] = "authorization_code";
|
|
18
|
+
GrantType$1["REFRESH_TOKEN"] = "refresh_token";
|
|
19
|
+
GrantType$1["TOKEN_EXCHANGE"] = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
20
|
+
return GrantType$1;
|
|
21
|
+
}({});
|
|
22
|
+
/**
|
|
14
23
|
* OAuth 2.0 Provider implementation for Cloudflare Workers
|
|
15
24
|
* Implements authorization code flow with support for refresh tokens
|
|
16
25
|
* and dynamic client registration.
|
|
@@ -37,6 +46,15 @@ var OAuthProvider = class {
|
|
|
37
46
|
}
|
|
38
47
|
};
|
|
39
48
|
/**
|
|
49
|
+
* Gets OAuthHelpers for the given environment
|
|
50
|
+
* @param options - Configuration options for the OAuth provider
|
|
51
|
+
* @param env - Cloudflare Worker environment variables
|
|
52
|
+
* @returns An instance of OAuthHelpers
|
|
53
|
+
*/
|
|
54
|
+
function getOAuthApi(options, env) {
|
|
55
|
+
return new OAuthProviderImpl(options).createOAuthHelpers(env);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
40
58
|
* Implementation class backing OAuthProvider.
|
|
41
59
|
*
|
|
42
60
|
* We use a PImpl pattern in `OAuthProvider` to make sure we don't inadvertently export any private
|
|
@@ -176,6 +194,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
176
194
|
createdAt: tokenData.createdAt,
|
|
177
195
|
expiresAt: tokenData.expiresAt,
|
|
178
196
|
audience: tokenData.audience,
|
|
197
|
+
scope: tokenData.scope || grant.scope,
|
|
179
198
|
grant: {
|
|
180
199
|
clientId: grant.clientId,
|
|
181
200
|
scope: grant.scope,
|
|
@@ -331,6 +350,8 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
331
350
|
if (this.options.clientRegistrationEndpoint) registrationEndpoint = this.getFullEndpointUrl(this.options.clientRegistrationEndpoint, requestUrl);
|
|
332
351
|
const responseTypesSupported = ["code"];
|
|
333
352
|
if (this.options.allowImplicitFlow) responseTypesSupported.push("token");
|
|
353
|
+
const grantTypesSupported = [GrantType.AUTHORIZATION_CODE, GrantType.REFRESH_TOKEN];
|
|
354
|
+
if (this.options.allowTokenExchangeGrant) grantTypesSupported.push(GrantType.TOKEN_EXCHANGE);
|
|
334
355
|
const metadata = {
|
|
335
356
|
issuer: new URL(tokenEndpoint).origin,
|
|
336
357
|
authorization_endpoint: authorizeEndpoint,
|
|
@@ -339,7 +360,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
339
360
|
scopes_supported: this.options.scopesSupported,
|
|
340
361
|
response_types_supported: responseTypesSupported,
|
|
341
362
|
response_modes_supported: ["query"],
|
|
342
|
-
grant_types_supported:
|
|
363
|
+
grant_types_supported: grantTypesSupported,
|
|
343
364
|
token_endpoint_auth_methods_supported: [
|
|
344
365
|
"client_secret_basic",
|
|
345
366
|
"client_secret_post",
|
|
@@ -361,8 +382,9 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
361
382
|
*/
|
|
362
383
|
async handleTokenRequest(body, clientInfo, env) {
|
|
363
384
|
const grantType = body.grant_type;
|
|
364
|
-
if (grantType ===
|
|
365
|
-
else if (grantType ===
|
|
385
|
+
if (grantType === GrantType.AUTHORIZATION_CODE) return this.handleAuthorizationCodeGrant(body, clientInfo, env);
|
|
386
|
+
else if (grantType === GrantType.REFRESH_TOKEN) return this.handleRefreshTokenGrant(body, clientInfo, env);
|
|
387
|
+
else if (grantType === GrantType.TOKEN_EXCHANGE && this.options.allowTokenExchangeGrant) return this.handleTokenExchangeGrant(body, clientInfo, env);
|
|
366
388
|
else return this.createErrorResponse("unsupported_grant_type", "Grant type not supported");
|
|
367
389
|
}
|
|
368
390
|
/**
|
|
@@ -402,23 +424,23 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
402
424
|
} else calculatedChallenge = codeVerifier;
|
|
403
425
|
if (calculatedChallenge !== grantData.codeChallenge) return this.createErrorResponse("invalid_grant", "Invalid PKCE code_verifier");
|
|
404
426
|
}
|
|
405
|
-
const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
406
|
-
const accessTokenId = await generateTokenId(accessToken);
|
|
407
427
|
let accessTokenTTL = this.options.accessTokenTTL;
|
|
408
428
|
let refreshTokenTTL = this.options.refreshTokenTTL;
|
|
409
429
|
const encryptionKey = await unwrapKeyWithToken(code, grantData.authCodeWrappedKey);
|
|
410
430
|
let grantEncryptionKey = encryptionKey;
|
|
411
431
|
let accessTokenEncryptionKey = encryptionKey;
|
|
412
432
|
let encryptedAccessTokenProps = grantData.encryptedProps;
|
|
433
|
+
let tokenScopes = this.downscope(body.scope, grantData.scope);
|
|
413
434
|
if (this.options.tokenExchangeCallback) {
|
|
414
435
|
const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
|
|
415
436
|
let grantProps = decryptedProps;
|
|
416
437
|
let accessTokenProps = decryptedProps;
|
|
417
438
|
const callbackOptions = {
|
|
418
|
-
grantType:
|
|
439
|
+
grantType: GrantType.AUTHORIZATION_CODE,
|
|
419
440
|
clientId: clientInfo.clientId,
|
|
420
441
|
userId,
|
|
421
442
|
scope: grantData.scope,
|
|
443
|
+
requestedScope: tokenScopes,
|
|
422
444
|
props: decryptedProps
|
|
423
445
|
};
|
|
424
446
|
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
@@ -430,6 +452,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
430
452
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
431
453
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
432
454
|
if ("refreshTokenTTL" in callbackResult) refreshTokenTTL = callbackResult.refreshTokenTTL;
|
|
455
|
+
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
433
456
|
}
|
|
434
457
|
const grantResult = await encryptProps(grantProps);
|
|
435
458
|
grantData.encryptedProps = grantResult.encryptedData;
|
|
@@ -444,9 +467,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
444
467
|
}
|
|
445
468
|
}
|
|
446
469
|
const now = Math.floor(Date.now() / 1e3);
|
|
447
|
-
const accessTokenExpiresAt = now + accessTokenTTL;
|
|
448
470
|
const useRefreshToken = refreshTokenTTL !== 0;
|
|
449
|
-
const accessTokenWrappedKey = await wrapKeyWithToken(accessToken, accessTokenEncryptionKey);
|
|
450
471
|
delete grantData.authCodeId;
|
|
451
472
|
delete grantData.codeChallenge;
|
|
452
473
|
delete grantData.codeChallengeMethod;
|
|
@@ -471,26 +492,21 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
471
492
|
}
|
|
472
493
|
const audience = parseResourceParameter(body.resource || grantData.resource);
|
|
473
494
|
if ((body.resource || grantData.resource) && !audience) return this.createErrorResponse("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
474
|
-
const accessTokenData = {
|
|
475
|
-
id: accessTokenId,
|
|
476
|
-
grantId,
|
|
477
|
-
userId,
|
|
478
|
-
createdAt: now,
|
|
479
|
-
expiresAt: accessTokenExpiresAt,
|
|
480
|
-
audience,
|
|
481
|
-
wrappedEncryptionKey: accessTokenWrappedKey,
|
|
482
|
-
grant: {
|
|
483
|
-
clientId: grantData.clientId,
|
|
484
|
-
scope: grantData.scope,
|
|
485
|
-
encryptedProps: encryptedAccessTokenProps
|
|
486
|
-
}
|
|
487
|
-
};
|
|
488
|
-
await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: accessTokenTTL });
|
|
489
495
|
const tokenResponse = {
|
|
490
|
-
access_token:
|
|
496
|
+
access_token: await this.createAccessToken({
|
|
497
|
+
userId,
|
|
498
|
+
grantId,
|
|
499
|
+
clientId: grantData.clientId,
|
|
500
|
+
scope: tokenScopes,
|
|
501
|
+
encryptedProps: encryptedAccessTokenProps,
|
|
502
|
+
encryptionKey: accessTokenEncryptionKey,
|
|
503
|
+
expiresIn: accessTokenTTL,
|
|
504
|
+
audience,
|
|
505
|
+
env
|
|
506
|
+
}),
|
|
491
507
|
token_type: "bearer",
|
|
492
508
|
expires_in: accessTokenTTL,
|
|
493
|
-
scope:
|
|
509
|
+
scope: tokenScopes.join(" ")
|
|
494
510
|
};
|
|
495
511
|
if (refreshToken) tokenResponse.refresh_token = refreshToken;
|
|
496
512
|
if (audience) tokenResponse.resource = audience;
|
|
@@ -531,16 +547,18 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
531
547
|
let grantEncryptionKey = encryptionKey;
|
|
532
548
|
let accessTokenEncryptionKey = encryptionKey;
|
|
533
549
|
let encryptedAccessTokenProps = grantData.encryptedProps;
|
|
550
|
+
let tokenScopes = this.downscope(body.scope, grantData.scope);
|
|
534
551
|
let grantPropsChanged = false;
|
|
535
552
|
if (this.options.tokenExchangeCallback) {
|
|
536
553
|
const decryptedProps = await decryptProps(encryptionKey, grantData.encryptedProps);
|
|
537
554
|
let grantProps = decryptedProps;
|
|
538
555
|
let accessTokenProps = decryptedProps;
|
|
539
556
|
const callbackOptions = {
|
|
540
|
-
grantType:
|
|
557
|
+
grantType: GrantType.REFRESH_TOKEN,
|
|
541
558
|
clientId: clientInfo.clientId,
|
|
542
559
|
userId,
|
|
543
560
|
scope: grantData.scope,
|
|
561
|
+
requestedScope: tokenScopes,
|
|
544
562
|
props: decryptedProps
|
|
545
563
|
};
|
|
546
564
|
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
@@ -553,6 +571,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
553
571
|
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
554
572
|
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = callbackResult.accessTokenTTL;
|
|
555
573
|
if ("refreshTokenTTL" in callbackResult) return this.createErrorResponse("invalid_request", "refreshTokenTTL cannot be changed during refresh token exchange");
|
|
574
|
+
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
556
575
|
}
|
|
557
576
|
if (grantPropsChanged) {
|
|
558
577
|
const grantResult = await encryptProps(grantProps);
|
|
@@ -600,6 +619,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
600
619
|
createdAt: now,
|
|
601
620
|
expiresAt: accessTokenExpiresAt,
|
|
602
621
|
audience,
|
|
622
|
+
scope: tokenScopes,
|
|
603
623
|
wrappedEncryptionKey: accessTokenWrappedKey,
|
|
604
624
|
grant: {
|
|
605
625
|
clientId: grantData.clientId,
|
|
@@ -613,12 +633,139 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
613
633
|
token_type: "bearer",
|
|
614
634
|
expires_in: accessTokenTTL,
|
|
615
635
|
refresh_token: newRefreshToken,
|
|
616
|
-
scope:
|
|
636
|
+
scope: tokenScopes.join(" ")
|
|
617
637
|
};
|
|
618
638
|
if (audience) tokenResponse.resource = audience;
|
|
619
639
|
return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
|
|
620
640
|
}
|
|
621
641
|
/**
|
|
642
|
+
* Core token exchange logic (RFC 8693)
|
|
643
|
+
* Performs the actual token exchange operation
|
|
644
|
+
* This method is not private because `OAuthHelpers` needs to call it. Note that since
|
|
645
|
+
* `OAuthProviderImpl` is not exposed outside this module, this is still effectively
|
|
646
|
+
* module-private.
|
|
647
|
+
* @param subjectToken - The subject token to exchange
|
|
648
|
+
* @param requestedScopes - Optional narrowed scopes (must be subset of original)
|
|
649
|
+
* @param requestedResource - Optional resource/audience (must be subset of original if original had resource)
|
|
650
|
+
* @param expiresIn - Optional TTL override in seconds
|
|
651
|
+
* @param clientInfo - The client making the exchange request
|
|
652
|
+
* @param env - Cloudflare Worker environment variables
|
|
653
|
+
* @returns Promise resolving to token response
|
|
654
|
+
* @throws OAuthError with OAuth error code and description
|
|
655
|
+
*/
|
|
656
|
+
async exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env) {
|
|
657
|
+
const tokenSummary = await this.unwrapToken(subjectToken, env);
|
|
658
|
+
if (!tokenSummary) throw new OAuthError("invalid_grant", "Invalid or expired subject token");
|
|
659
|
+
const grantKey = `grant:${tokenSummary.userId}:${tokenSummary.grantId}`;
|
|
660
|
+
const grantData = await env.OAUTH_KV.get(grantKey, { type: "json" });
|
|
661
|
+
if (!grantData) throw new OAuthError("invalid_grant", "Grant not found");
|
|
662
|
+
let tokenScopes = this.downscope(requestedScopes, grantData.scope);
|
|
663
|
+
let newAudience = tokenSummary.audience;
|
|
664
|
+
if (requestedResource) {
|
|
665
|
+
if (grantData.resource) {
|
|
666
|
+
const requestedResources = Array.isArray(requestedResource) ? requestedResource : [requestedResource];
|
|
667
|
+
const grantedResources = Array.isArray(grantData.resource) ? grantData.resource : [grantData.resource];
|
|
668
|
+
for (const requested of requestedResources) if (!grantedResources.includes(requested)) throw new OAuthError("invalid_target", "Requested resource was not included in the authorization request");
|
|
669
|
+
}
|
|
670
|
+
const parsedResource = parseResourceParameter(requestedResource);
|
|
671
|
+
if (!parsedResource) throw new OAuthError("invalid_target", "The resource parameter must be a valid absolute URI without a fragment");
|
|
672
|
+
newAudience = parsedResource;
|
|
673
|
+
}
|
|
674
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
675
|
+
const subjectTokenRemainingLifetime = tokenSummary.expiresAt - now;
|
|
676
|
+
let accessTokenTTL = this.options.accessTokenTTL ?? DEFAULT_ACCESS_TOKEN_TTL;
|
|
677
|
+
if (expiresIn !== void 0) {
|
|
678
|
+
if (expiresIn <= 0) throw new OAuthError("invalid_request", "Invalid expires_in parameter");
|
|
679
|
+
accessTokenTTL = Math.min(expiresIn, subjectTokenRemainingLifetime);
|
|
680
|
+
} else accessTokenTTL = Math.min(accessTokenTTL, subjectTokenRemainingLifetime);
|
|
681
|
+
const subjectTokenData = await env.OAUTH_KV.get(`token:${tokenSummary.userId}:${tokenSummary.grantId}:${tokenSummary.id}`, { type: "json" });
|
|
682
|
+
if (!subjectTokenData) throw new OAuthError("invalid_grant", "Subject token data not found");
|
|
683
|
+
const encryptionKey = await unwrapKeyWithToken(subjectToken, subjectTokenData.wrappedEncryptionKey);
|
|
684
|
+
let accessTokenEncryptionKey = encryptionKey;
|
|
685
|
+
let encryptedAccessTokenProps = subjectTokenData.grant.encryptedProps;
|
|
686
|
+
if (this.options.tokenExchangeCallback) {
|
|
687
|
+
const decryptedProps = await decryptProps(encryptionKey, subjectTokenData.grant.encryptedProps);
|
|
688
|
+
const callbackOptions = {
|
|
689
|
+
grantType: GrantType.TOKEN_EXCHANGE,
|
|
690
|
+
clientId: clientInfo.clientId,
|
|
691
|
+
userId: tokenSummary.userId,
|
|
692
|
+
scope: tokenSummary.grant.scope,
|
|
693
|
+
requestedScope: tokenScopes,
|
|
694
|
+
props: decryptedProps
|
|
695
|
+
};
|
|
696
|
+
const callbackResult = await Promise.resolve(this.options.tokenExchangeCallback(callbackOptions));
|
|
697
|
+
if (callbackResult) {
|
|
698
|
+
let accessTokenProps = decryptedProps;
|
|
699
|
+
if (callbackResult.newProps) {
|
|
700
|
+
if (!callbackResult.accessTokenProps) accessTokenProps = callbackResult.newProps;
|
|
701
|
+
}
|
|
702
|
+
if (callbackResult.accessTokenProps) accessTokenProps = callbackResult.accessTokenProps;
|
|
703
|
+
if (callbackResult.accessTokenTTL !== void 0) accessTokenTTL = Math.min(callbackResult.accessTokenTTL, subjectTokenRemainingLifetime);
|
|
704
|
+
if (accessTokenProps !== decryptedProps) {
|
|
705
|
+
const tokenResult = await encryptProps(accessTokenProps);
|
|
706
|
+
encryptedAccessTokenProps = tokenResult.encryptedData;
|
|
707
|
+
accessTokenEncryptionKey = tokenResult.key;
|
|
708
|
+
}
|
|
709
|
+
if (callbackResult.accessTokenScope) tokenScopes = this.downscope(callbackResult.accessTokenScope, grantData.scope);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
const tokenResponse = {
|
|
713
|
+
access_token: await this.createAccessToken({
|
|
714
|
+
userId: tokenSummary.userId,
|
|
715
|
+
grantId: tokenSummary.grantId,
|
|
716
|
+
clientId: tokenSummary.grant.clientId,
|
|
717
|
+
scope: tokenScopes,
|
|
718
|
+
encryptedProps: encryptedAccessTokenProps,
|
|
719
|
+
encryptionKey: accessTokenEncryptionKey,
|
|
720
|
+
expiresIn: accessTokenTTL,
|
|
721
|
+
audience: newAudience,
|
|
722
|
+
env
|
|
723
|
+
}),
|
|
724
|
+
issued_token_type: "urn:ietf:params:oauth:token-type:access_token",
|
|
725
|
+
token_type: "bearer",
|
|
726
|
+
expires_in: accessTokenTTL,
|
|
727
|
+
scope: tokenScopes.join(" ")
|
|
728
|
+
};
|
|
729
|
+
if (newAudience) tokenResponse.resource = newAudience;
|
|
730
|
+
return tokenResponse;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Handles OAuth 2.0 token exchange requests (RFC 8693)
|
|
734
|
+
* Exchanges an existing access token for a new one with modified characteristics
|
|
735
|
+
* @param body - The parsed request body
|
|
736
|
+
* @param clientInfo - The authenticated client information
|
|
737
|
+
* @param env - Cloudflare Worker environment variables
|
|
738
|
+
* @returns Response with new token data or error
|
|
739
|
+
*/
|
|
740
|
+
async handleTokenExchangeGrant(body, clientInfo, env) {
|
|
741
|
+
const subjectToken = body.subject_token;
|
|
742
|
+
const subjectTokenType = body.subject_token_type;
|
|
743
|
+
const requestedTokenType = body.requested_token_type || "urn:ietf:params:oauth:token-type:access_token";
|
|
744
|
+
const requestedScope = body.scope;
|
|
745
|
+
const requestedResource = body.resource;
|
|
746
|
+
if (!subjectToken) return this.createErrorResponse("invalid_request", "subject_token is required");
|
|
747
|
+
if (!subjectTokenType) return this.createErrorResponse("invalid_request", "subject_token_type is required");
|
|
748
|
+
if (subjectTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token subject_token_type is supported");
|
|
749
|
+
if (requestedTokenType !== "urn:ietf:params:oauth:token-type:access_token") return this.createErrorResponse("invalid_request", "Only access_token requested_token_type is supported");
|
|
750
|
+
let requestedScopes;
|
|
751
|
+
if (requestedScope) if (typeof requestedScope === "string") requestedScopes = requestedScope.split(" ").filter(Boolean);
|
|
752
|
+
else if (Array.isArray(requestedScope)) requestedScopes = requestedScope;
|
|
753
|
+
else return this.createErrorResponse("invalid_request", "Invalid scope parameter format");
|
|
754
|
+
let expiresIn;
|
|
755
|
+
if (body.expires_in !== void 0) {
|
|
756
|
+
const requestedTTL = parseInt(body.expires_in, 10);
|
|
757
|
+
if (isNaN(requestedTTL) || requestedTTL <= 0) return this.createErrorResponse("invalid_request", "Invalid expires_in parameter");
|
|
758
|
+
expiresIn = requestedTTL;
|
|
759
|
+
}
|
|
760
|
+
try {
|
|
761
|
+
const tokenResponse = await this.exchangeToken(subjectToken, requestedScopes, requestedResource, expiresIn, clientInfo, env);
|
|
762
|
+
return new Response(JSON.stringify(tokenResponse), { headers: { "Content-Type": "application/json" } });
|
|
763
|
+
} catch (error) {
|
|
764
|
+
if (error instanceof OAuthError) return this.createErrorResponse(error.code, error.message);
|
|
765
|
+
throw error;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
622
769
|
* Handles OAuth 2.0 token revocation requests (RFC 7009)
|
|
623
770
|
* @param body - The parsed request body containing revocation parameters
|
|
624
771
|
* @param env - Cloudflare Worker environment variables
|
|
@@ -730,7 +877,11 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
730
877
|
tosUri: OAuthProviderImpl.validateStringField(clientMetadata.tos_uri),
|
|
731
878
|
jwksUri: OAuthProviderImpl.validateStringField(clientMetadata.jwks_uri),
|
|
732
879
|
contacts: OAuthProviderImpl.validateStringArray(clientMetadata.contacts),
|
|
733
|
-
grantTypes: OAuthProviderImpl.validateStringArray(clientMetadata.grant_types) || [
|
|
880
|
+
grantTypes: OAuthProviderImpl.validateStringArray(clientMetadata.grant_types) || [
|
|
881
|
+
GrantType.AUTHORIZATION_CODE,
|
|
882
|
+
GrantType.REFRESH_TOKEN,
|
|
883
|
+
...this.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
|
|
884
|
+
],
|
|
734
885
|
responseTypes: OAuthProviderImpl.validateStringArray(clientMetadata.response_types) || ["code"],
|
|
735
886
|
registrationDate: Math.floor(Date.now() / 1e3),
|
|
736
887
|
tokenEndpointAuthMethod: authMethod
|
|
@@ -789,7 +940,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
789
940
|
if (tokenData.expiresAt < now) return this.createErrorResponse("invalid_token", "Access token expired", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\"" });
|
|
790
941
|
if (tokenData.audience) {
|
|
791
942
|
const requestUrl = new URL(request.url);
|
|
792
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`;
|
|
943
|
+
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
793
944
|
if (!(Array.isArray(tokenData.audience) ? tokenData.audience : [tokenData.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\", error_description=\"Invalid audience\"" });
|
|
794
945
|
}
|
|
795
946
|
ctx.props = await decryptProps(await unwrapKeyWithToken(accessToken, tokenData.wrappedEncryptionKey), tokenData.grant.encryptedProps);
|
|
@@ -802,7 +953,7 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
802
953
|
if (!ext) return this.createErrorResponse("invalid_token", "Invalid access token", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\"" });
|
|
803
954
|
if (ext.audience) {
|
|
804
955
|
const requestUrl = new URL(request.url);
|
|
805
|
-
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}`;
|
|
956
|
+
const resourceServer = `${requestUrl.protocol}//${requestUrl.host}${requestUrl.pathname}`;
|
|
806
957
|
if (!(Array.isArray(ext.audience) ? ext.audience : [ext.audience]).some((aud) => audienceMatches(resourceServer, aud))) return this.createErrorResponse("invalid_token", "Token audience does not match resource server", 401, { "WWW-Authenticate": "Bearer realm=\"OAuth\", error=\"invalid_token\", error_description=\"Invalid audience\"" });
|
|
807
958
|
}
|
|
808
959
|
ctx.props = ext.props;
|
|
@@ -856,6 +1007,45 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
856
1007
|
return env.OAUTH_KV.get(clientKey, { type: "json" });
|
|
857
1008
|
}
|
|
858
1009
|
/**
|
|
1010
|
+
* Creates and stores an access token
|
|
1011
|
+
* @param params - Options for creating the access token
|
|
1012
|
+
* @returns The access token string
|
|
1013
|
+
*/
|
|
1014
|
+
async createAccessToken(params) {
|
|
1015
|
+
const { userId, grantId, clientId, scope, encryptedProps, encryptionKey, expiresIn, audience, env } = params;
|
|
1016
|
+
const accessToken = `${userId}:${grantId}:${generateRandomString(TOKEN_LENGTH)}`;
|
|
1017
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
1018
|
+
const accessTokenId = await generateTokenId(accessToken);
|
|
1019
|
+
const accessTokenData = {
|
|
1020
|
+
id: accessTokenId,
|
|
1021
|
+
grantId,
|
|
1022
|
+
userId,
|
|
1023
|
+
createdAt: now,
|
|
1024
|
+
expiresAt: now + expiresIn,
|
|
1025
|
+
audience,
|
|
1026
|
+
scope,
|
|
1027
|
+
wrappedEncryptionKey: await wrapKeyWithToken(accessToken, encryptionKey),
|
|
1028
|
+
grant: {
|
|
1029
|
+
clientId,
|
|
1030
|
+
scope,
|
|
1031
|
+
encryptedProps
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
await env.OAUTH_KV.put(`token:${userId}:${grantId}:${accessTokenId}`, JSON.stringify(accessTokenData), { expirationTtl: expiresIn });
|
|
1035
|
+
return accessToken;
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Downscopes requested scopes to only include those that are in the grant
|
|
1039
|
+
* Filters out any requested scopes that are not in the granted scopes
|
|
1040
|
+
* @param requestedScope - The scope parameter from the request (string or array)
|
|
1041
|
+
* @param grantedScopes - The scopes that were granted in the authorization
|
|
1042
|
+
* @returns The filtered scopes that are a subset of the granted scopes
|
|
1043
|
+
*/
|
|
1044
|
+
downscope(requestedScope, grantedScopes) {
|
|
1045
|
+
if (!requestedScope) return grantedScopes;
|
|
1046
|
+
return (typeof requestedScope === "string" ? requestedScope.split(" ").filter(Boolean) : requestedScope).filter((scope) => grantedScopes.includes(scope));
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
859
1049
|
* Checks if the global_fetch_strictly_public compatibility flag is enabled.
|
|
860
1050
|
* This flag is required for CIMD to prevent SSRF attacks.
|
|
861
1051
|
* See: https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public
|
|
@@ -1022,6 +1212,17 @@ var OAuthProviderImpl = class OAuthProviderImpl {
|
|
|
1022
1212
|
}
|
|
1023
1213
|
};
|
|
1024
1214
|
/**
|
|
1215
|
+
* Error class for OAuth operations
|
|
1216
|
+
* Carries OAuth error code and description for proper error responses
|
|
1217
|
+
*/
|
|
1218
|
+
var OAuthError = class extends Error {
|
|
1219
|
+
constructor(code, message) {
|
|
1220
|
+
super(message);
|
|
1221
|
+
this.code = code;
|
|
1222
|
+
this.name = "OAuthError";
|
|
1223
|
+
}
|
|
1224
|
+
};
|
|
1225
|
+
/**
|
|
1025
1226
|
* Default expiration time for access tokens (1 hour in seconds)
|
|
1026
1227
|
*/
|
|
1027
1228
|
const DEFAULT_ACCESS_TOKEN_TTL = 3600;
|
|
@@ -1047,14 +1248,23 @@ function validateResourceUri(uri) {
|
|
|
1047
1248
|
}
|
|
1048
1249
|
}
|
|
1049
1250
|
/**
|
|
1050
|
-
* Checks if a resource server matches an audience claim
|
|
1051
|
-
*
|
|
1251
|
+
* Checks if a resource server matches an audience claim.
|
|
1252
|
+
* Uses origin comparison (case-insensitive hostname via URL normalization)
|
|
1253
|
+
* and path-prefix matching on path boundaries for RFC 8707 resource indicators.
|
|
1052
1254
|
* @param resourceServerUrl - The resource server URL (from request)
|
|
1053
1255
|
* @param audienceValue - The audience value from token
|
|
1054
1256
|
* @returns true if they match, false otherwise
|
|
1055
1257
|
*/
|
|
1056
1258
|
function audienceMatches(resourceServerUrl, audienceValue) {
|
|
1057
|
-
|
|
1259
|
+
try {
|
|
1260
|
+
const resource = new URL(resourceServerUrl);
|
|
1261
|
+
const audience = new URL(audienceValue);
|
|
1262
|
+
if (resource.origin !== audience.origin) return false;
|
|
1263
|
+
if (audience.pathname === "/" || audience.pathname === "") return true;
|
|
1264
|
+
return resource.pathname === audience.pathname || resource.pathname.startsWith(audience.pathname + "/");
|
|
1265
|
+
} catch {
|
|
1266
|
+
return false;
|
|
1267
|
+
}
|
|
1058
1268
|
}
|
|
1059
1269
|
/**
|
|
1060
1270
|
* Parses and validates the resource parameter from a token request (RFC 8707)
|
|
@@ -1365,6 +1575,7 @@ var OAuthHelpersImpl = class {
|
|
|
1365
1575
|
createdAt: now,
|
|
1366
1576
|
expiresAt: accessTokenExpiresAt,
|
|
1367
1577
|
audience,
|
|
1578
|
+
scope: options.scope,
|
|
1368
1579
|
wrappedEncryptionKey: accessTokenWrappedKey,
|
|
1369
1580
|
grant: {
|
|
1370
1581
|
clientId: options.request.clientId,
|
|
@@ -1428,7 +1639,11 @@ var OAuthHelpersImpl = class {
|
|
|
1428
1639
|
tosUri: clientInfo.tosUri,
|
|
1429
1640
|
jwksUri: clientInfo.jwksUri,
|
|
1430
1641
|
contacts: clientInfo.contacts,
|
|
1431
|
-
grantTypes: clientInfo.grantTypes || [
|
|
1642
|
+
grantTypes: clientInfo.grantTypes || [
|
|
1643
|
+
GrantType.AUTHORIZATION_CODE,
|
|
1644
|
+
GrantType.REFRESH_TOKEN,
|
|
1645
|
+
...this.provider.options.allowTokenExchangeGrant ? [GrantType.TOKEN_EXCHANGE] : []
|
|
1646
|
+
],
|
|
1432
1647
|
responseTypes: clientInfo.responseTypes || ["code"],
|
|
1433
1648
|
registrationDate: Math.floor(Date.now() / 1e3),
|
|
1434
1649
|
tokenEndpointAuthMethod
|
|
@@ -1570,6 +1785,19 @@ var OAuthHelpersImpl = class {
|
|
|
1570
1785
|
async unwrapToken(token) {
|
|
1571
1786
|
return await this.provider.unwrapToken(token, this.env);
|
|
1572
1787
|
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Exchanges an existing access token for a new one with modified characteristics
|
|
1790
|
+
* Implements OAuth 2.0 Token Exchange (RFC 8693)
|
|
1791
|
+
* @param options - Options for token exchange including subject token and optional modifications
|
|
1792
|
+
* @returns Promise resolving to token response with new access token
|
|
1793
|
+
*/
|
|
1794
|
+
async exchangeToken(options) {
|
|
1795
|
+
const tokenSummary = await this.unwrapToken(options.subjectToken);
|
|
1796
|
+
if (!tokenSummary) throw new Error("Invalid or expired subject token");
|
|
1797
|
+
const clientInfo = await this.lookupClient(tokenSummary.grant.clientId);
|
|
1798
|
+
if (!clientInfo) throw new Error("Client not found");
|
|
1799
|
+
return await this.provider.exchangeToken(options.subjectToken, options.scope, options.aud, options.expiresIn, clientInfo, this.env);
|
|
1800
|
+
}
|
|
1573
1801
|
};
|
|
1574
1802
|
/**
|
|
1575
1803
|
* Default export of the OAuth provider
|
|
@@ -1578,4 +1806,4 @@ var OAuthHelpersImpl = class {
|
|
|
1578
1806
|
var oauth_provider_default = OAuthProvider;
|
|
1579
1807
|
|
|
1580
1808
|
//#endregion
|
|
1581
|
-
export { OAuthProvider, oauth_provider_default as default };
|
|
1809
|
+
export { GrantType, OAuthProvider, oauth_provider_default as default, getOAuthApi };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloudflare/workers-oauth-provider",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "OAuth provider for Cloudflare Workers",
|
|
5
5
|
"main": "dist/oauth-provider.js",
|
|
6
6
|
"types": "dist/oauth-provider.d.ts",
|
|
@@ -31,7 +31,6 @@
|
|
|
31
31
|
"pkg-pr-new": "^0.0.62",
|
|
32
32
|
"prettier": "^3.7.4",
|
|
33
33
|
"tsdown": "^0.18.1",
|
|
34
|
-
"tsx": "^4.21.0",
|
|
35
34
|
"typescript": "^5.9.3",
|
|
36
35
|
"vitest": "^3.2.4"
|
|
37
36
|
},
|