Skip to content

Commit 29e85c5

Browse files
feat: add loginWithCustomTokenExchange and deprecate exchangeToken
This change aligns with the auth0-spa-js v2.14.0 release and introduces the `loginWithCustomTokenExchange` method as the recommended way to perform custom token exchange, while deprecating the existing `exchangeToken` method. Changes: - Add loginWithCustomTokenExchange() method to Auth0Context and Auth0Provider - Deprecate exchangeToken() method with clear migration guidance - Update @auth0/auth0-spa-js dependency to ^2.14.0 - Update tests and mocks to support both methods - Update EXAMPLES.md with new method usage The exchangeToken method is maintained for backward compatibility and internally delegates to loginWithCustomTokenExchange. It will be removed in the next major version.
1 parent 99730fa commit 29e85c5

File tree

8 files changed

+197
-27
lines changed

8 files changed

+197
-27
lines changed

EXAMPLES.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,13 +109,13 @@ import React, { useState } from 'react';
109109
import { useAuth0 } from '@auth0/auth0-react';
110110

111111
const TokenExchange = () => {
112-
const { exchangeToken } = useAuth0();
112+
const { loginWithCustomTokenExchange } = useAuth0();
113113
const [tokens, setTokens] = useState(null);
114114
const [error, setError] = useState(null);
115115

116116
const handleExchange = async (externalToken) => {
117117
try {
118-
const tokenResponse = await exchangeToken({
118+
const tokenResponse = await loginWithCustomTokenExchange({
119119
subject_token: externalToken,
120120
subject_token_type: 'urn:your-company:legacy-system-token',
121121
audience: 'https://api.example.com/',
@@ -148,6 +148,8 @@ const TokenExchange = () => {
148148
export default TokenExchange;
149149
```
150150

151+
> **Note:** The `exchangeToken` method is deprecated and will be removed in the next major version. Use `loginWithCustomTokenExchange` instead.
152+
151153
**Important Notes:**
152154
- The `subject_token_type` must be a namespaced URI under your organization's control
153155
- The external token must be validated in Auth0 Actions using strong cryptographic verification

__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 loginWithCustomTokenExchange = jest.fn();
1112
const exchangeToken = jest.fn();
1213
const isAuthenticated = jest.fn(() => false);
1314
const loginWithPopup = jest.fn();
@@ -30,6 +31,7 @@ export const Auth0Client = jest.fn(() => {
3031
getTokenWithPopup,
3132
getUser,
3233
getIdTokenClaims,
34+
loginWithCustomTokenExchange,
3335
exchangeToken,
3436
isAuthenticated,
3537
loginWithPopup,

__tests__/auth-provider.test.tsx

Lines changed: 104 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,103 @@ describe('Auth0Provider', () => {
881881
});
882882
});
883883

884+
it('should provide a loginWithCustomTokenExchange method', async () => {
885+
const tokenResponse = {
886+
access_token: '__test_access_token__',
887+
id_token: '__test_id_token__',
888+
token_type: 'Bearer',
889+
expires_in: 86400,
890+
scope: 'openid profile email',
891+
};
892+
clientMock.loginWithCustomTokenExchange.mockResolvedValue(tokenResponse);
893+
const wrapper = createWrapper();
894+
const { result } = renderHook(
895+
() => useContext(Auth0Context),
896+
{ wrapper }
897+
);
898+
await waitFor(() => {
899+
expect(result.current.loginWithCustomTokenExchange).toBeInstanceOf(Function);
900+
});
901+
let response;
902+
await act(async () => {
903+
response = await result.current.loginWithCustomTokenExchange({
904+
subject_token: '__test_token__',
905+
subject_token_type: 'urn:test:token-type',
906+
scope: 'openid profile email',
907+
organization: 'org_123',
908+
});
909+
});
910+
expect(clientMock.loginWithCustomTokenExchange).toHaveBeenCalledWith({
911+
subject_token: '__test_token__',
912+
subject_token_type: 'urn:test:token-type',
913+
scope: 'openid profile email',
914+
organization: 'org_123',
915+
});
916+
expect(response).toStrictEqual(tokenResponse);
917+
});
918+
919+
it('should handle errors when using loginWithCustomTokenExchange', async () => {
920+
clientMock.loginWithCustomTokenExchange.mockRejectedValue(new Error('__test_error__'));
921+
const wrapper = createWrapper();
922+
const { result } = renderHook(
923+
() => useContext(Auth0Context),
924+
{ wrapper }
925+
);
926+
await waitFor(() => {
927+
expect(result.current.loginWithCustomTokenExchange).toBeInstanceOf(Function);
928+
});
929+
await act(async () => {
930+
await expect(
931+
result.current.loginWithCustomTokenExchange({
932+
subject_token: '__test_token__',
933+
subject_token_type: 'urn:test:token-type',
934+
})
935+
).rejects.toThrow('__test_error__');
936+
});
937+
expect(clientMock.loginWithCustomTokenExchange).toHaveBeenCalled();
938+
});
939+
940+
it('should update auth state after successful loginWithCustomTokenExchange', async () => {
941+
const user = { name: '__test_user__' };
942+
const tokenResponse = {
943+
access_token: '__test_access_token__',
944+
id_token: '__test_id_token__',
945+
token_type: 'Bearer',
946+
expires_in: 86400,
947+
};
948+
clientMock.loginWithCustomTokenExchange.mockResolvedValue(tokenResponse);
949+
clientMock.getUser.mockResolvedValue(user);
950+
const wrapper = createWrapper();
951+
const { result } = renderHook(
952+
() => useContext(Auth0Context),
953+
{ wrapper }
954+
);
955+
await waitFor(() => {
956+
expect(result.current.loginWithCustomTokenExchange).toBeInstanceOf(Function);
957+
});
958+
await act(async () => {
959+
await result.current.loginWithCustomTokenExchange({
960+
subject_token: '__test_token__',
961+
subject_token_type: 'urn:test:token-type',
962+
});
963+
});
964+
expect(clientMock.getUser).toHaveBeenCalled();
965+
expect(result.current.user).toStrictEqual(user);
966+
});
967+
968+
it('should memoize the loginWithCustomTokenExchange method', async () => {
969+
const wrapper = createWrapper();
970+
const { result, rerender } = renderHook(
971+
() => useContext(Auth0Context),
972+
{ wrapper }
973+
);
974+
await waitFor(() => {
975+
const memoized = result.current.loginWithCustomTokenExchange;
976+
rerender();
977+
expect(result.current.loginWithCustomTokenExchange).toBe(memoized);
978+
});
979+
});
980+
884981
it('should provide an exchangeToken method', async () => {
885982
const tokenResponse = {
886983
access_token: '__test_access_token__',
@@ -889,7 +986,7 @@ describe('Auth0Provider', () => {
889986
expires_in: 86400,
890987
scope: 'openid profile email',
891988
};
892-
clientMock.exchangeToken.mockResolvedValue(tokenResponse);
989+
clientMock.loginWithCustomTokenExchange.mockResolvedValue(tokenResponse);
893990
const wrapper = createWrapper();
894991
const { result } = renderHook(
895992
() => useContext(Auth0Context),
@@ -907,7 +1004,7 @@ describe('Auth0Provider', () => {
9071004
organization: 'org_123',
9081005
});
9091006
});
910-
expect(clientMock.exchangeToken).toHaveBeenCalledWith({
1007+
expect(clientMock.loginWithCustomTokenExchange).toHaveBeenCalledWith({
9111008
subject_token: '__test_token__',
9121009
subject_token_type: 'urn:test:token-type',
9131010
scope: 'openid profile email',
@@ -916,8 +1013,8 @@ describe('Auth0Provider', () => {
9161013
expect(response).toStrictEqual(tokenResponse);
9171014
});
9181015

919-
it('should handle errors when exchanging tokens', async () => {
920-
clientMock.exchangeToken.mockRejectedValue(new Error('__test_error__'));
1016+
it('should handle errors when exchanging tokens (deprecated method)', async () => {
1017+
clientMock.loginWithCustomTokenExchange.mockRejectedValue(new Error('__test_error__'));
9211018
const wrapper = createWrapper();
9221019
const { result } = renderHook(
9231020
() => useContext(Auth0Context),
@@ -934,18 +1031,18 @@ describe('Auth0Provider', () => {
9341031
})
9351032
).rejects.toThrow('__test_error__');
9361033
});
937-
expect(clientMock.exchangeToken).toHaveBeenCalled();
1034+
expect(clientMock.loginWithCustomTokenExchange).toHaveBeenCalled();
9381035
});
9391036

940-
it('should update auth state after successful token exchange', async () => {
1037+
it('should update auth state after successful token exchange (deprecated method)', async () => {
9411038
const user = { name: '__test_user__' };
9421039
const tokenResponse = {
9431040
access_token: '__test_access_token__',
9441041
id_token: '__test_id_token__',
9451042
token_type: 'Bearer',
9461043
expires_in: 86400,
9471044
};
948-
clientMock.exchangeToken.mockResolvedValue(tokenResponse);
1045+
clientMock.loginWithCustomTokenExchange.mockResolvedValue(tokenResponse);
9491046
clientMock.getUser.mockResolvedValue(user);
9501047
const wrapper = createWrapper();
9511048
const { result } = renderHook(

package-lock.json

Lines changed: 4 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.0.1 || ~19.1.2 || ^19.2.1"
9696
},
9797
"dependencies": {
98-
"@auth0/auth0-spa-js": "^2.12.0"
98+
"@auth0/auth0-spa-js": "^2.14.0"
9999
}
100100
}

src/auth0-context.tsx

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,60 @@ export interface Auth0ContextInterface<TUser extends User = User>
9393
getIdTokenClaims: () => Promise<IdToken | undefined>;
9494

9595
/**
96+
* ```js
97+
* await loginWithCustomTokenExchange(options);
98+
* ```
99+
*
100+
* Exchanges an external subject token for Auth0 tokens and logs the user in.
101+
* This method implements the Custom Token Exchange grant as specified in RFC 8693.
102+
*
103+
* The exchanged tokens are automatically cached, establishing an authenticated session.
104+
* After calling this method, you can use `getUser()`, `getIdTokenClaims()`, and
105+
* `getTokenSilently()` to access the user's information and tokens.
106+
*
107+
* @param options - The options required to perform the token exchange.
108+
*
109+
* @returns A promise that resolves to the token endpoint response,
110+
* which contains the issued Auth0 tokens (access_token, id_token, etc.).
111+
*
112+
* The request includes the following parameters:
113+
* - `grant_type`: "urn:ietf:params:oauth:grant-type:token-exchange"
114+
* - `subject_token`: The external token to exchange
115+
* - `subject_token_type`: The type identifier of the external token
116+
* - `scope`: Merged scopes from the request and SDK defaults
117+
* - `audience`: Target audience (defaults to SDK configuration)
118+
* - `organization`: Optional organization ID/name for org-scoped authentication
119+
*
120+
* **Example Usage:**
121+
*
122+
* ```js
123+
* const options = {
124+
* subject_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6Ikp...',
125+
* subject_token_type: 'urn:acme:legacy-system-token',
126+
* scope: 'openid profile email',
127+
* audience: 'https://api.example.com',
128+
* organization: 'org_12345'
129+
* };
130+
*
131+
* try {
132+
* const tokenResponse = await loginWithCustomTokenExchange(options);
133+
* console.log('Access token:', tokenResponse.access_token);
134+
*
135+
* // User is now logged in - access user info
136+
* const user = await getUser();
137+
* console.log('Logged in user:', user);
138+
* } catch (error) {
139+
* console.error('Token exchange failed:', error);
140+
* }
141+
* ```
142+
*/
143+
loginWithCustomTokenExchange: (
144+
options: CustomTokenExchangeOptions
145+
) => Promise<TokenEndpointResponse>;
146+
147+
/**
148+
* @deprecated Use `loginWithCustomTokenExchange()` instead. This method will be removed in the next major version.
149+
*
96150
* ```js
97151
* const tokenResponse = await exchangeToken({
98152
* subject_token: 'external_token_value',
@@ -101,18 +155,20 @@ export interface Auth0ContextInterface<TUser extends User = User>
101155
* });
102156
* ```
103157
*
104-
* Exchanges an external subject token for Auth0 tokens via a token exchange request.
158+
* Exchanges an external subject token for Auth0 tokens and logs the user in.
105159
*
106160
* This method implements the token exchange grant as specified in RFC 8693.
107161
* It performs a token exchange by sending a request to the `/oauth/token` endpoint
108162
* with the external token and returns Auth0 tokens (access token, ID token, etc.).
109163
*
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)
164+
* **Example:**
165+
* ```js
166+
* // Instead of:
167+
* const tokens = await exchangeToken(options);
168+
*
169+
* // Use:
170+
* const tokens = await loginWithCustomTokenExchange(options);
171+
* ```
116172
*
117173
* @param options - The options required to perform the token exchange
118174
* @returns A promise that resolves to the token endpoint response containing Auth0 tokens
@@ -271,6 +327,7 @@ export const initialContext = {
271327
getAccessTokenSilently: stub,
272328
getAccessTokenWithPopup: stub,
273329
getIdTokenClaims: stub,
330+
loginWithCustomTokenExchange: stub,
274331
exchangeToken: stub,
275332
loginWithRedirect: stub,
276333
loginWithPopup: stub,

src/auth0-provider.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -279,19 +279,19 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
279279
[client]
280280
);
281281

282-
const exchangeToken = useCallback(
282+
const loginWithCustomTokenExchange = useCallback(
283283
async (
284284
options: CustomTokenExchangeOptions
285285
): Promise<TokenEndpointResponse> => {
286286
let tokenResponse;
287287
try {
288-
tokenResponse = await client.exchangeToken(options);
288+
tokenResponse = await client.loginWithCustomTokenExchange(options);
289289
} catch (error) {
290290
throw tokenError(error);
291291
} 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
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
295295
// identical regardless of whether the token was retrieved via silent auth or CTE.
296296
dispatch({
297297
type: 'GET_ACCESS_TOKEN_COMPLETE',
@@ -303,6 +303,15 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
303303
[client]
304304
);
305305

306+
const exchangeToken = useCallback(
307+
async (
308+
options: CustomTokenExchangeOptions
309+
): Promise<TokenEndpointResponse> => {
310+
return loginWithCustomTokenExchange(options);
311+
},
312+
[loginWithCustomTokenExchange]
313+
);
314+
306315
const handleRedirectCallback = useCallback(
307316
async (
308317
url?: string
@@ -352,6 +361,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
352361
getAccessTokenSilently,
353362
getAccessTokenWithPopup,
354363
getIdTokenClaims,
364+
loginWithCustomTokenExchange,
355365
exchangeToken,
356366
loginWithRedirect,
357367
loginWithPopup,
@@ -369,6 +379,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
369379
getAccessTokenSilently,
370380
getAccessTokenWithPopup,
371381
getIdTokenClaims,
382+
loginWithCustomTokenExchange,
372383
exchangeToken,
373384
loginWithRedirect,
374385
loginWithPopup,

src/use-auth0.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import Auth0Context, { Auth0ContextInterface } from './auth0-context';
1414
* getAccessTokenSilently,
1515
* getAccessTokenWithPopup,
1616
* getIdTokenClaims,
17-
* exchangeToken,
17+
* loginWithCustomTokenExchange,
18+
* exchangeToken, // deprecated - use loginWithCustomTokenExchange
1819
* loginWithRedirect,
1920
* loginWithPopup,
2021
* logout,

0 commit comments

Comments
 (0)