Skip to content

Commit c34a6bf

Browse files
feat: add custom token exchange support
1 parent 921c2da commit c34a6bf

File tree

7 files changed

+215
-3
lines changed

7 files changed

+215
-3
lines changed

EXAMPLES.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,62 @@ 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+
authorizationParams: {
121+
audience: 'https://api.example.com/',
122+
scope: 'openid profile email',
123+
},
124+
});
125+
126+
setTokens(tokenResponse);
127+
setError(null);
128+
129+
// Use the returned tokens
130+
console.log('Access Token:', tokenResponse.access_token);
131+
console.log('ID Token:', tokenResponse.id_token);
132+
} catch (e) {
133+
console.error('Token exchange failed:', e);
134+
setError(e.message);
135+
}
136+
};
137+
138+
return (
139+
<div>
140+
<button onClick={() => handleExchange('your-external-token')}>
141+
Exchange Token
142+
</button>
143+
{tokens && <div>Token exchange successful!</div>}
144+
{error && <div>Error: {error}</div>}
145+
</div>
146+
);
147+
};
148+
149+
export default TokenExchange;
150+
```
151+
152+
**Important Notes:**
153+
- The `subject_token_type` must be a namespaced URI under your organization's control
154+
- The external token must be validated in Auth0 Actions using strong cryptographic verification
155+
- This method implements RFC 8693 token exchange grant type
156+
- The audience and scope can be provided through `authorizationParams` or will fall back to SDK defaults
157+
102158
## Protecting a route in a `react-router-dom v6` app
103159

104160
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: '/' },

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: 25 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,26 @@ 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+
dispatch({
293+
type: 'GET_ACCESS_TOKEN_COMPLETE',
294+
user: await client.getUser(),
295+
});
296+
}
297+
return tokenResponse;
298+
},
299+
[client]
300+
);
301+
280302
const handleRedirectCallback = useCallback(
281303
async (
282304
url?: string
@@ -321,6 +343,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
321343
getAccessTokenSilently,
322344
getAccessTokenWithPopup,
323345
getIdTokenClaims,
346+
exchangeToken,
324347
loginWithRedirect,
325348
loginWithPopup,
326349
connectAccountWithRedirect,
@@ -336,6 +359,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
336359
getAccessTokenSilently,
337360
getAccessTokenWithPopup,
338361
getIdTokenClaims,
362+
exchangeToken,
339363
loginWithRedirect,
340364
loginWithPopup,
341365
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)