Skip to content

Commit 566ac47

Browse files
feat: CTE delegation and impersonation support (#1109)
## Summary Adds delegation and impersonation support for Custom Token Exchange per RFC 8693, building on [auth0-spa-js#1608](auth0/auth0-spa-js#1608). - Add `customTokenExchange()` to `Auth0ContextInterface` — stateless token exchange with no session side effects - Bump `@auth0/auth0-spa-js` to `^2.20.0` - Re-export `ActClaim` type ## Test plan - [x] All existing tests pass - [x] `customTokenExchange()`: returns raw `TokenEndpointResponse`, correct params forwarded including `actor_token`/`actor_token_type` - [x] `customTokenExchange()`: no auth state update, `isAuthenticated` unchanged - [x] `customTokenExchange()`: errors propagate raw (no try/catch wrapping) - [x] `customTokenExchange()`: method is memoized - [x] 100% coverage maintained
1 parent bdb79d2 commit 566ac47

6 files changed

Lines changed: 133 additions & 1 deletion

File tree

__mocks__/@auth0/auth0-spa-js.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const getTokenWithPopup = jest.fn();
99
const getUser = jest.fn();
1010
const getIdTokenClaims = jest.fn();
1111
const loginWithCustomTokenExchange = jest.fn();
12+
const customTokenExchange = jest.fn();
1213
const exchangeToken = jest.fn();
1314
const isAuthenticated = jest.fn(() => false);
1415
const loginWithPopup = jest.fn();
@@ -37,6 +38,7 @@ export const Auth0Client = jest.fn(() => {
3738
getUser,
3839
getIdTokenClaims,
3940
loginWithCustomTokenExchange,
41+
customTokenExchange,
4042
exchangeToken,
4143
isAuthenticated,
4244
loginWithPopup,

__tests__/auth-provider.test.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1109,6 +1109,99 @@ describe('Auth0Provider', () => {
11091109
});
11101110
});
11111111

1112+
it('should provide a customTokenExchange method', async () => {
1113+
const tokenResponse = {
1114+
access_token: '__test_access_token__',
1115+
id_token: '__test_id_token__',
1116+
token_type: 'Bearer',
1117+
expires_in: 86400,
1118+
};
1119+
clientMock.customTokenExchange.mockResolvedValue(tokenResponse);
1120+
const wrapper = createWrapper();
1121+
const { result } = renderHook(
1122+
() => useContext(Auth0Context),
1123+
{ wrapper }
1124+
);
1125+
await waitFor(() => {
1126+
expect(result.current.customTokenExchange).toBeInstanceOf(Function);
1127+
});
1128+
let response;
1129+
await act(async () => {
1130+
response = await result.current.customTokenExchange({
1131+
subject_token: '__test_token__',
1132+
subject_token_type: 'urn:test:token-type',
1133+
actor_token: '__test_actor_token__',
1134+
actor_token_type: 'https://idp.example.com/token-type/agent',
1135+
});
1136+
});
1137+
expect(clientMock.customTokenExchange).toHaveBeenCalledWith({
1138+
subject_token: '__test_token__',
1139+
subject_token_type: 'urn:test:token-type',
1140+
actor_token: '__test_actor_token__',
1141+
actor_token_type: 'https://idp.example.com/token-type/agent',
1142+
});
1143+
expect(response).toStrictEqual(tokenResponse);
1144+
});
1145+
1146+
it('should not update auth state after customTokenExchange', async () => {
1147+
const tokenResponse = {
1148+
access_token: '__test_access_token__',
1149+
id_token: '__test_id_token__',
1150+
token_type: 'Bearer',
1151+
expires_in: 86400,
1152+
};
1153+
clientMock.customTokenExchange.mockResolvedValue(tokenResponse);
1154+
clientMock.getUser.mockResolvedValue(undefined);
1155+
const wrapper = createWrapper();
1156+
const { result } = renderHook(
1157+
() => useContext(Auth0Context),
1158+
{ wrapper }
1159+
);
1160+
await waitFor(() => {
1161+
expect(result.current.customTokenExchange).toBeInstanceOf(Function);
1162+
});
1163+
await act(async () => {
1164+
await result.current.customTokenExchange({
1165+
subject_token: '__test_token__',
1166+
subject_token_type: 'urn:test:token-type',
1167+
});
1168+
});
1169+
expect(result.current.isAuthenticated).toBe(false);
1170+
});
1171+
1172+
it('should propagate errors from customTokenExchange', async () => {
1173+
clientMock.customTokenExchange.mockRejectedValue(new Error('__test_error__'));
1174+
const wrapper = createWrapper();
1175+
const { result } = renderHook(
1176+
() => useContext(Auth0Context),
1177+
{ wrapper }
1178+
);
1179+
await waitFor(() => {
1180+
expect(result.current.customTokenExchange).toBeInstanceOf(Function);
1181+
});
1182+
await act(async () => {
1183+
await expect(
1184+
result.current.customTokenExchange({
1185+
subject_token: '__test_token__',
1186+
subject_token_type: 'urn:test:token-type',
1187+
})
1188+
).rejects.toThrow('__test_error__');
1189+
});
1190+
});
1191+
1192+
it('should memoize the customTokenExchange method', async () => {
1193+
const wrapper = createWrapper();
1194+
const { result, rerender } = renderHook(
1195+
() => useContext(Auth0Context),
1196+
{ wrapper }
1197+
);
1198+
await waitFor(() => {
1199+
const memoized = result.current.customTokenExchange;
1200+
rerender();
1201+
expect(result.current.customTokenExchange).toBe(memoized);
1202+
});
1203+
});
1204+
11121205
it('should provide a handleRedirectCallback method', async () => {
11131206
clientMock.handleRedirectCallback.mockResolvedValue({
11141207
appState: { redirectUri: '/' },

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,6 @@
9393
"react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
9494
},
9595
"dependencies": {
96-
"@auth0/auth0-spa-js": "^2.19.2"
96+
"@auth0/auth0-spa-js": "^2.20.0"
9797
}
9898
}

src/auth0-context.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,33 @@ export interface Auth0ContextInterface<TUser extends User = User>
145145
options: CustomTokenExchangeOptions
146146
) => Promise<TokenEndpointResponse>;
147147

148+
/**
149+
* ```js
150+
* const tokenResponse = await customTokenExchange({
151+
* subject_token: 'ey...',
152+
* subject_token_type: 'urn:acme:legacy-system-token',
153+
* actor_token: 'ey...',
154+
* actor_token_type: 'https://idp.example.com/token-type/agent',
155+
* });
156+
* ```
157+
*
158+
* Exchanges an external subject token for Auth0 tokens without affecting the current session.
159+
*
160+
* Unlike `loginWithCustomTokenExchange`, this method has no side effects — it does not cache
161+
* tokens, does not update the authenticated session, and does not affect `isAuthenticated`
162+
* or `user`. Use this for delegation or impersonation scenarios where you need a downstream
163+
* API token without changing who the current user is.
164+
*
165+
* When `actor_token` is present Auth0 suppresses refresh token issuance; a missing
166+
* `refresh_token` in the response is expected and will not cause an error.
167+
*
168+
* @param options - The options required to perform the token exchange.
169+
* @returns A promise that resolves to the token endpoint response.
170+
*/
171+
customTokenExchange: (
172+
options: CustomTokenExchangeOptions
173+
) => Promise<TokenEndpointResponse>;
174+
148175
/**
149176
* @deprecated Use `loginWithCustomTokenExchange()` instead. This method will be removed in the next major version.
150177
*
@@ -383,6 +410,7 @@ export const initialContext = {
383410
getAccessTokenWithPopup: stub,
384411
getIdTokenClaims: stub,
385412
loginWithCustomTokenExchange: stub,
413+
customTokenExchange: stub,
386414
exchangeToken: stub,
387415
loginWithRedirect: stub,
388416
loginWithPopup: stub,

src/auth0-provider.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,12 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
331331
[client]
332332
);
333333

334+
const customTokenExchange = useCallback(
335+
(options: CustomTokenExchangeOptions): Promise<TokenEndpointResponse> =>
336+
client.customTokenExchange(options),
337+
[client]
338+
);
339+
334340
const exchangeToken = useCallback(
335341
async (
336342
options: CustomTokenExchangeOptions
@@ -392,6 +398,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
392398
getAccessTokenWithPopup,
393399
getIdTokenClaims,
394400
loginWithCustomTokenExchange,
401+
customTokenExchange,
395402
exchangeToken,
396403
loginWithRedirect,
397404
loginWithPopup,
@@ -411,6 +418,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
411418
getAccessTokenWithPopup,
412419
getIdTokenClaims,
413420
loginWithCustomTokenExchange,
421+
customTokenExchange,
414422
exchangeToken,
415423
loginWithRedirect,
416424
loginWithPopup,

src/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export {
4848
ConnectError,
4949
CustomTokenExchangeOptions,
5050
TokenEndpointResponse,
51+
ActClaim,
5152
ClientConfiguration,
5253
// MFA Errors
5354
MfaError,

0 commit comments

Comments
 (0)