feat(auth0-server-js): add loginWithCustomTokenExchange and customTokenExchange methods#176
Conversation
bd5cc6f to
12cfbca
Compare
77b32f4 to
1ef94d5
Compare
ba61a9a to
97b20dc
Compare
af8e6d6 to
ed6e9c8
Compare
97b20dc to
e2d556d
Compare
|
Warning Review limit reached
More reviews will be available in 24 minutes and 2 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughThis PR introduces RFC 8693 custom token exchange support to the Auth0 ServerClient. It adds type definitions for exchange options and results, implements two new public methods for session-creating and delegation-only token exchanges, includes comprehensive test coverage for both methods with authorization request (RAR) and domain resolver support, and documents the feature with usage examples. ChangesCustom Token Exchange Support
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
d72d8f3 to
9141b4c
Compare
- Added ActClaim interface (sub: string + open index signature for extra claims per RFC 8693)
- Added actorToken? and actorTokenType? to ExchangeProfileOptions
- Added act?: ActClaim property to TokenResponse
- Populated act from parsed ID token claims in fromTokenEndpointResponse()
auth-client.ts
- Removed actor_token and actor_token_type from PARAM_DENYLIST (they now have explicit typed params)
- Updated the denylist comment block to remove the stale bullet
- Added validateTokenTypeUri() — syntactic-only new URL() check, throws TokenExchangeError with a clear message
- In #exchangeProfileToken(): calls validateTokenTypeUri for both subjectTokenType and actorTokenType, then appends actor_token/actor_token_type to the request when present
auth-client.spec.ts
- New describe('exchangeToken — actor token support') block with 5 tests covering: params wired + act claim populated, act claim with extra fields, params absent when not provided, invalid URI validation (both params in one test), valid URI acceptance
…uth-js changes in this branch
9141b4c to
7894519
Compare
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/auth0-server-js/EXAMPLES.md`:
- Around line 43-58: The table of contents contains repeated "Passing
`StoreOptions`" anchors whose numeric suffixes no longer match the actual
heading IDs after the "Custom Token Exchange" insertion; update each duplicated
anchor (the "Passing `StoreOptions`" links that follow "Custom Token Exchange",
and every subsequent occurrence) to use the correct unique fragment identifiers
that correspond to their target headings (e.g., adjust the
`#passing-storeoptions-3`, `#passing-storeoptions-4`, etc. suffixes so each TOC
entry points to the correct heading), ensuring the "Custom Token Exchange" entry
itself uses its proper anchor and all following anchors are shifted to match the
real headings like "Retrieving the logged-in User", "Retrieving the Session
Data", "Retrieving an Access Token", "Logout", and "Handle Backchannel Logout".
- Around line 944-960: The example incorrectly asserts that tokenResponse.act is
populated; update either the example or the parser: preferred fix—modify the
example in the delegation section to not reference tokenResponse.act?.sub
(remove or replace the console.log line) so it doesn't claim the ActClaim is
present, and add a note that act will be available only if the TokenResponse
parser (TokenResponse.fromTokenEndpointResponse) maps the 'act' claim into the
TokenResponse.act field; alternatively, if you choose to implement parsing
instead, update TokenResponse.fromTokenEndpointResponse to extract and assign
the 'act' claim into TokenResponse.act so tokenResponse.act?.sub is valid
(change the example only if you implement the parser change).
In `@packages/auth0-server-js/src/server-client.ts`:
- Around line 731-749: The loginWithCustomTokenExchange path can persist an
access-token-only exchange and merge it into an existing session because it
never guarantees OIDC claims; modify loginWithCustomTokenExchange (around the
authClient.exchangeToken call and before updateStateData / this.#stateStore.set)
to verify the exchange returned OIDC identity (e.g., require
tokenEndpointResponse.id_token or a claims payload) and if absent either
(preferred) reject the exchange by throwing a clear error instructing the caller
to request the openid scope, or (alternative) clear the existing session state
via this.#stateStore.set(this.#stateStoreIdentifier, undefined, true,
storeOptions) before calling updateStateData so you don't bind a previous
user/idToken to a new access token; reference loginWithCustomTokenExchange,
authClient.exchangeToken, updateStateData, and this.#stateStoreIdentifier when
implementing the check.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: b124d28a-9035-464d-b6ae-edbaa1808557
📒 Files selected for processing (4)
packages/auth0-server-js/EXAMPLES.mdpackages/auth0-server-js/src/server-client.spec.tspackages/auth0-server-js/src/server-client.tspackages/auth0-server-js/src/types.ts
| ### Using actor tokens for delegation | ||
|
|
||
| When performing a delegation exchange where an intermediate service acts on behalf of a user, provide `actorToken` and `actorTokenType` together. Both are required when using actor tokens: | ||
|
|
||
| ```ts | ||
| const tokenResponse = await serverClient.customTokenExchange({ | ||
| subjectToken: userToken, | ||
| subjectTokenType: 'urn:acme:user-token', | ||
| actorToken: serviceAccountToken, | ||
| actorTokenType: 'urn:acme:service-token', | ||
| audience: 'https://api.example.com', | ||
| }); | ||
|
|
||
| // tokenResponse.act contains the actor claim from the issued token: | ||
| // { sub: 'service-account-id', ... } | ||
| console.log(tokenResponse.act?.sub); | ||
| ``` |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n -C3 'act\?:|fromTokenEndpointResponse|authorization_details|issued_token_type' packages/auth0-auth-js/src/types.tsRepository: auth0/auth0-auth-js
Length of output: 1264
Don’t claim tokenResponse.act is populated in the delegation example
TokenResponse defines act?: ActClaim, but TokenResponse.fromTokenEndpointResponse() (packages/auth0-auth-js/src/types.ts) only assigns authorization_details, tokenType, and issuedTokenType—it doesn’t populate tokenResponse.act. The example should avoid documenting tokenResponse.act?.sub unless/until the parser sets it at runtime.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/auth0-server-js/EXAMPLES.md` around lines 944 - 960, The example
incorrectly asserts that tokenResponse.act is populated; update either the
example or the parser: preferred fix—modify the example in the delegation
section to not reference tokenResponse.act?.sub (remove or replace the
console.log line) so it doesn't claim the ActClaim is present, and add a note
that act will be available only if the TokenResponse parser
(TokenResponse.fromTokenEndpointResponse) maps the 'act' claim into the
TokenResponse.act field; alternatively, if you choose to implement parsing
instead, update TokenResponse.fromTokenEndpointResponse to extract and assign
the 'act' claim into TokenResponse.act so tokenResponse.act?.sub is valid
(change the example only if you implement the parser change).
| public async loginWithCustomTokenExchange( | ||
| options: LoginWithCustomTokenExchangeOptions, | ||
| storeOptions?: TStoreOptions | ||
| ): Promise<LoginWithCustomTokenExchangeResult> { | ||
| const domain = await this.#resolveDomain(storeOptions); | ||
| const authClient = this.#getAuthClient(domain); | ||
| const tokenEndpointResponse = await authClient.exchangeToken(options); | ||
|
|
||
| const existingStateData = await this.#stateStore.get(this.#stateStoreIdentifier, storeOptions); | ||
| const stateData = updateStateData( | ||
| options.audience ?? 'default', | ||
| existingStateData, | ||
| tokenEndpointResponse, | ||
| { domain } | ||
| ); | ||
|
|
||
| await this.#stateStore.set(this.#stateStoreIdentifier, stateData, true, storeOptions); | ||
|
|
||
| return { authorizationDetails: tokenEndpointResponse.authorizationDetails }; |
There was a problem hiding this comment.
Require identity claims before persisting a token-exchange login.
loginWithCustomTokenExchange() writes directly into the session store without first guaranteeing an ID token / claims payload. Unlike startInteractiveLogin() and loginBackchannel(), this path never adds openid, so an access-token-only exchange can reach updateStateData(). In that case updateStateData() cannot detect a subject change and will merge the new token set into the existing session, leaving the previous user / idToken bound to a different user's exchanged access token. Either force an OIDC-capable scope here and reject exchanges that return no claims, or clear existing session state before persisting when claims are absent.
Suggested direction
public async loginWithCustomTokenExchange(
options: LoginWithCustomTokenExchangeOptions,
storeOptions?: TStoreOptions
): Promise<LoginWithCustomTokenExchangeResult> {
const domain = await this.#resolveDomain(storeOptions);
const authClient = this.#getAuthClient(domain);
- const tokenEndpointResponse = await authClient.exchangeToken(options);
+ const tokenEndpointResponse = await authClient.exchangeToken({
+ ...options,
+ scope: ensureOpenIdScope(options.scope),
+ });
+
+ if (!tokenEndpointResponse.idToken || !tokenEndpointResponse.claims) {
+ throw new InvalidConfigurationError(
+ 'loginWithCustomTokenExchange requires an ID token response; include the openid scope in the exchange.'
+ );
+ }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/auth0-server-js/src/server-client.ts` around lines 731 - 749, The
loginWithCustomTokenExchange path can persist an access-token-only exchange and
merge it into an existing session because it never guarantees OIDC claims;
modify loginWithCustomTokenExchange (around the authClient.exchangeToken call
and before updateStateData / this.#stateStore.set) to verify the exchange
returned OIDC identity (e.g., require tokenEndpointResponse.id_token or a claims
payload) and if absent either (preferred) reject the exchange by throwing a
clear error instructing the caller to request the openid scope, or (alternative)
clear the existing session state via
this.#stateStore.set(this.#stateStoreIdentifier, undefined, true, storeOptions)
before calling updateStateData so you don't bind a previous user/idToken to a
new access token; reference loginWithCustomTokenExchange,
authClient.exchangeToken, updateStateData, and this.#stateStoreIdentifier when
implementing the check.
nandan-bhat
left a comment
There was a problem hiding this comment.
Reviewed the custom token exchange methods. The change is clean and the tests pass. I left a few inline notes. The main one is the audience used when we save the token set, since it may not match what getAccessToken looks for later. I also think we should add tests that pass an actor token and check the act claim, since that is the main goal of this feature.
|
|
||
| const existingStateData = await this.#stateStore.get(this.#stateStoreIdentifier, storeOptions); | ||
| const stateData = updateStateData( | ||
| options.audience ?? 'default', |
There was a problem hiding this comment.
Here we save the token set using the audience passed in the call (options.audience). But getAccessToken reads the token set using the audience from the client config (this.#options.authorizationParams?.audience). loginBackchannel and completeInteractiveLogin both save using the config audience, not a per call value.
So if someone calls this method with audience 'https://api.example.com' but the client is not configured with that same audience, getAccessToken will look under 'default', not find the saved token, and try to refresh. When an actor token is used there is no refresh token, so it will throw.
The EXAMPLES doc tells people to call getAccessToken after this, so this path can break. Can we either save under the config audience like the other login methods, or make the contract clear ? It would also help to add a test that logs in and then calls getAccessToken to prove it works end to end.
There was a problem hiding this comment.
yes, we should key the accessToken in TokenSets under primary config audience this.#options.authorizationParams?.audience or the default as we do in loginBackchannel and completeInteractiveLogin.
Update:
- The access token gets saved under
this.#options.authorizationParams?.audience ?? 'default' - unit test:
loginWithCustomTokenExchange - should allow getAccessToken to return the token after login- end-to-end test that proves loginWithCustomTokenExchange followed by getAccessToken works.
| ): Promise<LoginWithCustomTokenExchangeResult> { | ||
| const domain = await this.#resolveDomain(storeOptions); | ||
| const authClient = this.#getAuthClient(domain); | ||
| const tokenEndpointResponse = await authClient.exchangeToken(options); |
There was a problem hiding this comment.
The other login methods (loginBackchannel, startInteractiveLogin) call ensureOpenIdScope before the exchange. This one passes options straight through.
This is a login method and the session user is taken from the id token claims. The user is only set when an id token comes back. If the caller forgets openid in the scope, Auth0 may not return an id token, and we end up with a logged in session where user is undefined. The tests do not catch this because the mock always returns an id token.
Can we either add openid scope here like the sibling methods, or document that openid is required ? customTokenExchange can stay as is since it does not create a session.
There was a problem hiding this comment.
yes, I agree, this would create a subtle bug, a session would be silently created without any error, with id token claims undefined.
Update: Added
ensureOpenIdScopecheck inloginWithCustomTokenExchange- a note in
EXAMPLES.mdto clearly call out that SDK requiresopenidscope for this method, if not provided, SDK will inject appropriate defaults.
| spy.mockRestore(); | ||
| }); | ||
|
|
||
| test('loginWithCustomTokenExchange - should persist session after successful exchange', async () => { |
There was a problem hiding this comment.
None of the new tests pass actorToken or actorTokenType, and none check the act claim. Actor tokens are the main point of this feature.
The spec says the act claim must be saved in the session for loginWithCustomTokenExchange and must be readable on the response for customTokenExchange. The actor tests today only exist at the auth-client level, not for these new server client methods.
Can we add two tests:
loginWithCustomTokenExchangewith an actor token, then check the saved session user has act.customTokenExchangewith an actor token, then checkresult.act.sub.
There was a problem hiding this comment.
Added 2 proper unit tests
- loginWithCustomTokenExchange - should persist
actclaim on session user when actor token is used - customTokenExchange - should return
actclaim when actor token is used
|
|
||
| No StateStore reads or writes occur — `storeOptions` is only used for domain resolution in resolver mode. | ||
|
|
||
| ### Using actor tokens for delegation |
There was a problem hiding this comment.
The spec says we should document that no refresh token is given when an actor token is sent, even if offline_access is in the scope. This section does not mention it.
Without this note, someone using an actor token with loginWithCustomTokenExchange will call getAccessToken after the token expires and hit an error with no warning. Can we add a short line here that says refresh tokens are not issued for actor token exchanges, so the session cannot be refreshed silently ?
There was a problem hiding this comment.
Added a important note under this section to clearly call out this.
| - [Login using Custom Token Exchange](#login-using-custom-token-exchange) | ||
| - [Performing a delegation exchange without a session](#performing-a-delegation-exchange-without-a-session) | ||
| - [Using actor tokens for delegation](#using-actor-tokens-for-delegation) | ||
| - [Passing `StoreOptions`](#passing-storeoptions-3) |
There was a problem hiding this comment.
These StoreOptions anchors do not match the slugs GitHub builds. GitHub numbers repeated headings by their order in the page. This new section is the 6th 'Passing StoreOptions' heading, so the link should be passing-storeoptions-5, not -3. The backchannel one above also looks off.
This numbering was already messy before this PR, but the new section keeps the pattern. Can we fix the anchors so the links actually jump to the right place ?
There was a problem hiding this comment.
thanks for pointing this out.
Fixed the TOC.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/auth0-server-js/src/server-client.spec.ts`:
- Around line 2410-2429: The test cases have unguarded mockRestore() calls that
can leak spies if assertions fail before cleanup. In
packages/auth-server-js/src/server-client.spec.ts at lines 2410-2429 (anchor)
and lines 2436-2456 (sibling): wrap the test body (from the exchangeSpy setup
through all assertions) in a try/finally block, moving the
exchangeSpy.mockRestore() call into the finally block to ensure cleanup happens
regardless of whether assertions pass or fail.
- Around line 2420-2425: The test for customTokenExchange with actor token
parameters currently only validates the output/session handling but does not
verify that the actorToken and actorTokenType parameters are actually forwarded
to the underlying exchangeToken call. Add explicit call-argument assertions to
the mock or spy of exchangeToken to verify it receives the actor token and actor
token type parameters in the expected format (likely as actor_token and
actor_token_type). Apply this hardening to all relevant test cases including
those at both the anchor location (2420-2425) and sibling location (2446-2451)
so that regressions in RFC 8693 actor token forwarding are caught.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: af52129e-f370-472f-bf42-d743678ef958
📒 Files selected for processing (2)
packages/auth0-server-js/src/server-client.spec.tspackages/auth0-server-js/src/server-client.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/auth0-server-js/src/server-client.ts
|
LGTM. Thanks for addressing all the review items. |
Description
Adds first-class Custom Token Exchange (CTE) support to ServerClient in auth0-server-js, building on the actor token and act claim foundation added to auth0-auth-js.
loginWithCustomTokenExchange(options, storeOptions?): exchanges a custom external token (e.g. a Google ID token, a legacy system token) for Auth0 tokens and persists the result as a user session in the StateStore. Equivalent to completing an interactive login flow, but without a browser redirect. Calls stateStore.set with removeIfExists: true to prevent session fixation. ReturnsPromise<LoginWithCustomTokenExchangeResult>, surfacing authorizationDetails from the token response for callers using RAR (authorization_details in extra).customTokenExchange(options, storeOptions?): performs the same RFC 8693 token exchange but returns the raw TokenResponse without touching the StateStore. Designed for delegation and impersonation flows where downstream tokens are needed but no session should be created or modified.LoginWithCustomTokenExchangeOptionsandCustomTokenExchangeOptionstype aliases in types.ts, both derived fromExchangeProfileOptionsinauth0-auth-js. This keeps the types consistent across the monorepo and inheritsactorToken,actorTokenType, and all other exchange options automatically. Also addedLoginWithCustomTokenExchangeResultinterface to types.tsBoth methods follow the same domain resolution and AuthClient routing pattern used by loginBackchannel, supporting both static domain and resolver mode configurations.
The return of
Promise<LoginWithCustomTokenExchangeResult>containingauthorizationDetailsis kept in consistent withcompleteInteractiveLoginandloginBackchannelmethods. Though the case for returning it is narrow.Testing
Unit tests
Manual Testing
customTokenExchange(): stateless delegation flow
act.subpopulated correctly (set by api.authentication.setActor() in the Action)loginWithCustomTokenExchange(): session-persisting
Checklist
Summary by CodeRabbit
Release Notes
New Features
Documentation
storeOptionsusage.Tests