Skip to content

Commit f6a7780

Browse files
feat: add custom token exchange support (#928)
## 📝 Summary This PR adds support for the `exchangeToken` method from **auth0-spa-js** to **auth0-react**, enabling token exchange flows based on [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693.html) This allows apps to exchange external or legacy tokens for Auth0 tokens directly in React. ### 🔧 Changes * Added `exchangeToken` to the `Auth0ContextInterface` with full TypeScript and JSDoc support. * Implemented `exchangeToken` using a `useCallback` with proper error handling. * Updated the auth state after successful exchanges. * Updated `useAuth0` docs to include the new method. * Added examples and best practices to `EXAMPLES.md`. ### 🧪 Testing * Added 4 new tests for method behavior, error handling, auth state updates, and memoization. * Updated Jest mocks for exchangeToken, with all tests passing and full TypeScript coverage. ### 💥 Impact * Enables secure token exchange following [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693.html). * Helps migration from legacy auth systems. * Fully documented, type-safe, and consistent with existing SDK patterns. --------- Co-authored-by: Yogesh Chaudhary <[email protected]>
1 parent 921c2da commit f6a7780

File tree

9 files changed

+224
-8
lines changed

9 files changed

+224
-8
lines changed

EXAMPLES.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,61 @@ const Posts = () => {
9999
export default Posts;
100100
```
101101

102+
## Custom token exchange
103+
104+
Exchange an external subject token for Auth0 tokens using the token exchange flow (RFC 8693):
105+
106+
```jsx
107+
import React, { useState } from 'react';
108+
import { useAuth0 } from '@auth0/auth0-react';
109+
110+
const TokenExchange = () => {
111+
const { exchangeToken } = useAuth0();
112+
const [tokens, setTokens] = useState(null);
113+
const [error, setError] = useState(null);
114+
115+
const handleExchange = async (externalToken) => {
116+
try {
117+
const tokenResponse = await exchangeToken({
118+
subject_token: externalToken,
119+
subject_token_type: 'urn:your-company:legacy-system-token',
120+
audience: 'https://api.example.com/',
121+
scope: 'openid profile email',
122+
});
123+
124+
setTokens(tokenResponse);
125+
setError(null);
126+
127+
// Use the returned tokens
128+
console.log('Access Token:', tokenResponse.access_token);
129+
console.log('ID Token:', tokenResponse.id_token);
130+
} catch (e) {
131+
console.error('Token exchange failed:', e);
132+
setError(e.message);
133+
}
134+
};
135+
136+
return (
137+
<div>
138+
<button onClick={() => handleExchange('your-external-token')}>
139+
Exchange Token
140+
</button>
141+
{tokens && <div>Token exchange successful!</div>}
142+
{error && <div>Error: {error}</div>}
143+
</div>
144+
);
145+
};
146+
147+
export default TokenExchange;
148+
```
149+
150+
**Important Notes:**
151+
- The `subject_token_type` must be a namespaced URI under your organization's control
152+
- The external token must be validated in Auth0 Actions using strong cryptographic verification
153+
- This method implements RFC 8693 token exchange grant type
154+
- The audience and scope can be provided directly in the options or will fall back to SDK defaults
155+
- **State Management:** This method triggers the `GET_ACCESS_TOKEN_COMPLETE` action internally upon completion. This ensures that the SDK's `isLoading` and `isAuthenticated` states behave identically to the standard `getAccessTokenSilently` flow.
156+
102157
## Protecting a route in a `react-router-dom v6` app
103158

104159
We need to access the `useNavigate` hook so we can use `navigate` in `onRedirectCallback` to return us to our `returnUrl`.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const getTokenSilently = jest.fn();
88
const getTokenWithPopup = jest.fn();
99
const getUser = jest.fn();
1010
const getIdTokenClaims = jest.fn();
11+
const exchangeToken = jest.fn();
1112
const isAuthenticated = jest.fn(() => false);
1213
const loginWithPopup = jest.fn();
1314
const loginWithRedirect = jest.fn();
@@ -28,6 +29,7 @@ export const Auth0Client = jest.fn(() => {
2829
getTokenWithPopup,
2930
getUser,
3031
getIdTokenClaims,
32+
exchangeToken,
3133
isAuthenticated,
3234
loginWithPopup,
3335
loginWithRedirect,

__tests__/auth-provider.test.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,101 @@ describe('Auth0Provider', () => {
880880
});
881881
});
882882

883+
it('should provide an exchangeToken method', async () => {
884+
const tokenResponse = {
885+
access_token: '__test_access_token__',
886+
id_token: '__test_id_token__',
887+
token_type: 'Bearer',
888+
expires_in: 86400,
889+
scope: 'openid profile email',
890+
};
891+
clientMock.exchangeToken.mockResolvedValue(tokenResponse);
892+
const wrapper = createWrapper();
893+
const { result } = renderHook(
894+
() => useContext(Auth0Context),
895+
{ wrapper }
896+
);
897+
await waitFor(() => {
898+
expect(result.current.exchangeToken).toBeInstanceOf(Function);
899+
});
900+
let response;
901+
await act(async () => {
902+
response = await result.current.exchangeToken({
903+
subject_token: '__test_token__',
904+
subject_token_type: 'urn:test:token-type',
905+
scope: 'openid profile email',
906+
});
907+
});
908+
expect(clientMock.exchangeToken).toHaveBeenCalledWith({
909+
subject_token: '__test_token__',
910+
subject_token_type: 'urn:test:token-type',
911+
scope: 'openid profile email',
912+
});
913+
expect(response).toStrictEqual(tokenResponse);
914+
});
915+
916+
it('should handle errors when exchanging tokens', async () => {
917+
clientMock.exchangeToken.mockRejectedValue(new Error('__test_error__'));
918+
const wrapper = createWrapper();
919+
const { result } = renderHook(
920+
() => useContext(Auth0Context),
921+
{ wrapper }
922+
);
923+
await waitFor(() => {
924+
expect(result.current.exchangeToken).toBeInstanceOf(Function);
925+
});
926+
await act(async () => {
927+
await expect(
928+
result.current.exchangeToken({
929+
subject_token: '__test_token__',
930+
subject_token_type: 'urn:test:token-type',
931+
})
932+
).rejects.toThrow('__test_error__');
933+
});
934+
expect(clientMock.exchangeToken).toHaveBeenCalled();
935+
});
936+
937+
it('should update auth state after successful token exchange', async () => {
938+
const user = { name: '__test_user__' };
939+
const tokenResponse = {
940+
access_token: '__test_access_token__',
941+
id_token: '__test_id_token__',
942+
token_type: 'Bearer',
943+
expires_in: 86400,
944+
};
945+
clientMock.exchangeToken.mockResolvedValue(tokenResponse);
946+
clientMock.getUser.mockResolvedValue(user);
947+
const wrapper = createWrapper();
948+
const { result } = renderHook(
949+
() => useContext(Auth0Context),
950+
{ wrapper }
951+
);
952+
await waitFor(() => {
953+
expect(result.current.exchangeToken).toBeInstanceOf(Function);
954+
});
955+
await act(async () => {
956+
await result.current.exchangeToken({
957+
subject_token: '__test_token__',
958+
subject_token_type: 'urn:test:token-type',
959+
});
960+
});
961+
expect(clientMock.getUser).toHaveBeenCalled();
962+
expect(result.current.user).toStrictEqual(user);
963+
});
964+
965+
it('should memoize the exchangeToken method', async () => {
966+
const wrapper = createWrapper();
967+
const { result, rerender } = renderHook(
968+
() => useContext(Auth0Context),
969+
{ wrapper }
970+
);
971+
await waitFor(() => {
972+
const memoized = result.current.exchangeToken;
973+
rerender();
974+
expect(result.current.exchangeToken).toBe(memoized);
975+
});
976+
});
977+
883978
it('should provide a handleRedirectCallback method', async () => {
884979
clientMock.handleRedirectCallback.mockResolvedValue({
885980
appState: { redirectUri: '/' },

package-lock.json

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,6 @@
9595
"react-dom": "^16.11.0 || ^17 || ^18 || ^19"
9696
},
9797
"dependencies": {
98-
"@auth0/auth0-spa-js": "^2.9.0"
98+
"@auth0/auth0-spa-js": "^2.9.1"
9999
}
100100
}

src/auth0-context.tsx

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import {
1111
RedirectLoginOptions as SPARedirectLoginOptions,
1212
type Auth0Client,
1313
RedirectConnectAccountOptions,
14-
ConnectAccountRedirectResult
14+
ConnectAccountRedirectResult,
15+
CustomTokenExchangeOptions,
16+
TokenEndpointResponse
1517
} from '@auth0/auth0-spa-js';
1618
import { createContext } from 'react';
1719
import { AuthState, initialAuthState } from './auth-state';
@@ -90,6 +92,35 @@ export interface Auth0ContextInterface<TUser extends User = User>
9092
*/
9193
getIdTokenClaims: () => Promise<IdToken | undefined>;
9294

95+
/**
96+
* ```js
97+
* const tokenResponse = await exchangeToken({
98+
* subject_token: 'external_token_value',
99+
* subject_token_type: 'urn:acme:legacy-system-token',
100+
* scope: 'openid profile email'
101+
* });
102+
* ```
103+
*
104+
* Exchanges an external subject token for Auth0 tokens via a token exchange request.
105+
*
106+
* This method implements the token exchange grant as specified in RFC 8693.
107+
* It performs a token exchange by sending a request to the `/oauth/token` endpoint
108+
* with the external token and returns Auth0 tokens (access token, ID token, etc.).
109+
*
110+
* The request includes the following parameters:
111+
* - `grant_type`: Hard-coded to "urn:ietf:params:oauth:grant-type:token-exchange"
112+
* - `subject_token`: The external token to be exchanged
113+
* - `subject_token_type`: A namespaced URI identifying the token type (must be under your organization's control)
114+
* - `audience`: The target audience (falls back to the SDK's default audience if not provided)
115+
* - `scope`: Space-separated list of scopes (merged with the SDK's default scopes)
116+
*
117+
* @param options - The options required to perform the token exchange
118+
* @returns A promise that resolves to the token endpoint response containing Auth0 tokens
119+
*/
120+
exchangeToken: (
121+
options: CustomTokenExchangeOptions
122+
) => Promise<TokenEndpointResponse>;
123+
93124
/**
94125
* ```js
95126
* await loginWithRedirect(options);
@@ -229,6 +260,7 @@ export const initialContext = {
229260
getAccessTokenSilently: stub,
230261
getAccessTokenWithPopup: stub,
231262
getIdTokenClaims: stub,
263+
exchangeToken: stub,
232264
loginWithRedirect: stub,
233265
loginWithPopup: stub,
234266
connectAccountWithRedirect: stub,

src/auth0-provider.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ import {
1717
User,
1818
RedirectConnectAccountOptions,
1919
ConnectAccountRedirectResult,
20-
ResponseType
20+
ResponseType,
21+
CustomTokenExchangeOptions,
22+
TokenEndpointResponse
2123
} from '@auth0/auth0-spa-js';
2224
import Auth0Context, {
2325
Auth0ContextInterface,
@@ -277,6 +279,30 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
277279
[client]
278280
);
279281

282+
const exchangeToken = useCallback(
283+
async (
284+
options: CustomTokenExchangeOptions
285+
): Promise<TokenEndpointResponse> => {
286+
let tokenResponse;
287+
try {
288+
tokenResponse = await client.exchangeToken(options);
289+
} catch (error) {
290+
throw tokenError(error);
291+
} finally {
292+
// We dispatch the standard GET_ACCESS_TOKEN_COMPLETE action here to maintain
293+
// backward compatibility and consistency with the getAccessTokenSilently flow.
294+
// This ensures the SDK's internal state lifecycle (loading/user updates) remains
295+
// identical regardless of whether the token was retrieved via silent auth or CTE.
296+
dispatch({
297+
type: 'GET_ACCESS_TOKEN_COMPLETE',
298+
user: await client.getUser(),
299+
});
300+
}
301+
return tokenResponse;
302+
},
303+
[client]
304+
);
305+
280306
const handleRedirectCallback = useCallback(
281307
async (
282308
url?: string
@@ -321,6 +347,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
321347
getAccessTokenSilently,
322348
getAccessTokenWithPopup,
323349
getIdTokenClaims,
350+
exchangeToken,
324351
loginWithRedirect,
325352
loginWithPopup,
326353
connectAccountWithRedirect,
@@ -336,6 +363,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
336363
getAccessTokenSilently,
337364
getAccessTokenWithPopup,
338365
getIdTokenClaims,
366+
exchangeToken,
339367
loginWithRedirect,
340368
loginWithPopup,
341369
connectAccountWithRedirect,

src/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ export {
4343
RedirectConnectAccountOptions,
4444
ConnectAccountRedirectResult,
4545
ResponseType,
46-
ConnectError
46+
ConnectError,
47+
CustomTokenExchangeOptions,
48+
TokenEndpointResponse
4749
} from '@auth0/auth0-spa-js';
4850
export { OAuthError } from './errors';

src/use-auth0.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Auth0Context, { Auth0ContextInterface } from './auth0-context';
1414
* getAccessTokenSilently,
1515
* getAccessTokenWithPopup,
1616
* getIdTokenClaims,
17+
* exchangeToken,
1718
* loginWithRedirect,
1819
* loginWithPopup,
1920
* logout,

0 commit comments

Comments
 (0)