@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.
@@ -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: 'authorization_code' | 'refresh_token';
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 };
@@ -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: ["authorization_code", "refresh_token"],
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 === "authorization_code") return this.handleAuthorizationCodeGrant(body, clientInfo, env);
365
- else if (grantType === "refresh_token") return this.handleRefreshTokenGrant(body, clientInfo, env);
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: "authorization_code",
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: accessToken,
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: grantData.scope.join(" ")
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: "refresh_token",
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: grantData.scope.join(" ")
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) || ["authorization_code", "refresh_token"],
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
- * RFC 7519 Section 4.1.3: audience values are case-sensitive strings
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
- return resourceServerUrl === audienceValue;
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 || ["authorization_code", "refresh_token"],
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.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
  },