Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,61 @@ const Posts = () => {
export default Posts;
```

## Custom token exchange

Exchange an external subject token for Auth0 tokens using the token exchange flow (RFC 8693):

```jsx
import React, { useState } from 'react';
import { useAuth0 } from '@auth0/auth0-react';

const TokenExchange = () => {
const { exchangeToken } = useAuth0();
const [tokens, setTokens] = useState(null);
const [error, setError] = useState(null);

const handleExchange = async (externalToken) => {
try {
const tokenResponse = await exchangeToken({
subject_token: externalToken,
subject_token_type: 'urn:your-company:legacy-system-token',
audience: 'https://api.example.com/',
scope: 'openid profile email',
});

setTokens(tokenResponse);
setError(null);

// Use the returned tokens
console.log('Access Token:', tokenResponse.access_token);
console.log('ID Token:', tokenResponse.id_token);
} catch (e) {
console.error('Token exchange failed:', e);
setError(e.message);
}
};

return (
<div>
<button onClick={() => handleExchange('your-external-token')}>
Exchange Token
</button>
{tokens && <div>Token exchange successful!</div>}
{error && <div>Error: {error}</div>}
</div>
);
};

export default TokenExchange;
```

**Important Notes:**
- The `subject_token_type` must be a namespaced URI under your organization's control
- The external token must be validated in Auth0 Actions using strong cryptographic verification
- This method implements RFC 8693 token exchange grant type
- The audience and scope can be provided directly in the options or will fall back to SDK defaults
- **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.

## Protecting a route in a `react-router-dom v6` app

We need to access the `useNavigate` hook so we can use `navigate` in `onRedirectCallback` to return us to our `returnUrl`.
Expand Down
2 changes: 2 additions & 0 deletions __mocks__/@auth0/auth0-spa-js.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const getTokenSilently = jest.fn();
const getTokenWithPopup = jest.fn();
const getUser = jest.fn();
const getIdTokenClaims = jest.fn();
const exchangeToken = jest.fn();
const isAuthenticated = jest.fn(() => false);
const loginWithPopup = jest.fn();
const loginWithRedirect = jest.fn();
Expand All @@ -28,6 +29,7 @@ export const Auth0Client = jest.fn(() => {
getTokenWithPopup,
getUser,
getIdTokenClaims,
exchangeToken,
isAuthenticated,
loginWithPopup,
loginWithRedirect,
Expand Down
95 changes: 95 additions & 0 deletions __tests__/auth-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,101 @@ describe('Auth0Provider', () => {
});
});

it('should provide an exchangeToken method', async () => {
const tokenResponse = {
access_token: '__test_access_token__',
id_token: '__test_id_token__',
token_type: 'Bearer',
expires_in: 86400,
scope: 'openid profile email',
};
clientMock.exchangeToken.mockResolvedValue(tokenResponse);
const wrapper = createWrapper();
const { result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
await waitFor(() => {
expect(result.current.exchangeToken).toBeInstanceOf(Function);
});
let response;
await act(async () => {
response = await result.current.exchangeToken({
subject_token: '__test_token__',
subject_token_type: 'urn:test:token-type',
scope: 'openid profile email',
});
});
expect(clientMock.exchangeToken).toHaveBeenCalledWith({
subject_token: '__test_token__',
subject_token_type: 'urn:test:token-type',
scope: 'openid profile email',
});
expect(response).toStrictEqual(tokenResponse);
});

it('should handle errors when exchanging tokens', async () => {
clientMock.exchangeToken.mockRejectedValue(new Error('__test_error__'));
const wrapper = createWrapper();
const { result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
await waitFor(() => {
expect(result.current.exchangeToken).toBeInstanceOf(Function);
});
await act(async () => {
await expect(
result.current.exchangeToken({
subject_token: '__test_token__',
subject_token_type: 'urn:test:token-type',
})
).rejects.toThrow('__test_error__');
});
expect(clientMock.exchangeToken).toHaveBeenCalled();
});

it('should update auth state after successful token exchange', async () => {
const user = { name: '__test_user__' };
const tokenResponse = {
access_token: '__test_access_token__',
id_token: '__test_id_token__',
token_type: 'Bearer',
expires_in: 86400,
};
clientMock.exchangeToken.mockResolvedValue(tokenResponse);
clientMock.getUser.mockResolvedValue(user);
const wrapper = createWrapper();
const { result } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
await waitFor(() => {
expect(result.current.exchangeToken).toBeInstanceOf(Function);
});
await act(async () => {
await result.current.exchangeToken({
subject_token: '__test_token__',
subject_token_type: 'urn:test:token-type',
});
});
expect(clientMock.getUser).toHaveBeenCalled();
expect(result.current.user).toStrictEqual(user);
});

it('should memoize the exchangeToken method', async () => {
const wrapper = createWrapper();
const { result, rerender } = renderHook(
() => useContext(Auth0Context),
{ wrapper }
);
await waitFor(() => {
const memoized = result.current.exchangeToken;
rerender();
expect(result.current.exchangeToken).toBe(memoized);
});
});

it('should provide a handleRedirectCallback method', async () => {
clientMock.handleRedirectCallback.mockResolvedValue({
appState: { redirectUri: '/' },
Expand Down
9 changes: 5 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,6 @@
"react-dom": "^16.11.0 || ^17 || ^18 || ^19"
},
"dependencies": {
"@auth0/auth0-spa-js": "^2.9.0"
"@auth0/auth0-spa-js": "^2.9.1"
}
}
34 changes: 33 additions & 1 deletion src/auth0-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
RedirectLoginOptions as SPARedirectLoginOptions,
type Auth0Client,
RedirectConnectAccountOptions,
ConnectAccountRedirectResult
ConnectAccountRedirectResult,
CustomTokenExchangeOptions,
TokenEndpointResponse
} from '@auth0/auth0-spa-js';
import { createContext } from 'react';
import { AuthState, initialAuthState } from './auth-state';
Expand Down Expand Up @@ -90,6 +92,35 @@ export interface Auth0ContextInterface<TUser extends User = User>
*/
getIdTokenClaims: () => Promise<IdToken | undefined>;

/**
* ```js
* const tokenResponse = await exchangeToken({
* subject_token: 'external_token_value',
* subject_token_type: 'urn:acme:legacy-system-token',
* scope: 'openid profile email'
* });
* ```
*
* Exchanges an external subject token for Auth0 tokens via a token exchange request.
*
* This method implements the token exchange grant as specified in RFC 8693.
* It performs a token exchange by sending a request to the `/oauth/token` endpoint
* with the external token and returns Auth0 tokens (access token, ID token, etc.).
*
* The request includes the following parameters:
* - `grant_type`: Hard-coded to "urn:ietf:params:oauth:grant-type:token-exchange"
* - `subject_token`: The external token to be exchanged
* - `subject_token_type`: A namespaced URI identifying the token type (must be under your organization's control)
* - `audience`: The target audience (falls back to the SDK's default audience if not provided)
* - `scope`: Space-separated list of scopes (merged with the SDK's default scopes)
*
* @param options - The options required to perform the token exchange
* @returns A promise that resolves to the token endpoint response containing Auth0 tokens
*/
exchangeToken: (
options: CustomTokenExchangeOptions
) => Promise<TokenEndpointResponse>;

/**
* ```js
* await loginWithRedirect(options);
Expand Down Expand Up @@ -229,6 +260,7 @@ export const initialContext = {
getAccessTokenSilently: stub,
getAccessTokenWithPopup: stub,
getIdTokenClaims: stub,
exchangeToken: stub,
loginWithRedirect: stub,
loginWithPopup: stub,
connectAccountWithRedirect: stub,
Expand Down
30 changes: 29 additions & 1 deletion src/auth0-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import {
User,
RedirectConnectAccountOptions,
ConnectAccountRedirectResult,
ResponseType
ResponseType,
CustomTokenExchangeOptions,
TokenEndpointResponse
} from '@auth0/auth0-spa-js';
import Auth0Context, {
Auth0ContextInterface,
Expand Down Expand Up @@ -277,6 +279,30 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
[client]
);

const exchangeToken = useCallback(
async (
options: CustomTokenExchangeOptions
): Promise<TokenEndpointResponse> => {
let tokenResponse;
try {
tokenResponse = await client.exchangeToken(options);
} catch (error) {
throw tokenError(error);
} finally {
// We dispatch the standard GET_ACCESS_TOKEN_COMPLETE action here to maintain
// backward compatibility and consistency with the getAccessTokenSilently flow.
// This ensures the SDK's internal state lifecycle (loading/user updates) remains
// identical regardless of whether the token was retrieved via silent auth or CTE.
dispatch({
type: 'GET_ACCESS_TOKEN_COMPLETE',
Copy link
Contributor

@tusharpandey13 tusharpandey13 Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. This block will execute even if the tokenExchange fails. Do we want to send a GET_ACCESS_TOKEN_COMPLETE dispatch in the case of error ?
  2. Do we want to dispatch a TOKEN_EXCHANGE_COMPLETE or a similar event here? Just a suggestion, open to all perspectives on this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged. @gyaneshgouraw-okta, looping you in to address this comment regarding the implementation design.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tusharpandey13 , @gyaneshgouraw-okta and I discussed this. We are inclined to stick with GET_ACCESS_TOKEN_COMPLETE for now.

The reducer is currently designed around the outcome (obtaining a token) rather than the source (Silent Auth vs. CTE). Since the impact on the state is identical, adding a new action type would force us to duplicate the reconciliation logic (or alias the case) without changing the actual behavior. We feel it follows DRY principles better to keep a single source of truth for "token received" events.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block will execute even if the tokenExchange fails. Do we want to send a GET_ACCESS_TOKEN_COMPLETE dispatch in the case of error ?

Is this expected behaviour?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is. We are following the exact same pattern as getAccessTokenSilently here to ensure the CTE flow behaves consistently with the rest of the SDK, regardless of success or failure.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is to enable backwards compat, this behaviour should be documented (CTE + normal token flows)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to clarify, do you mean adding an inline code comment explaining why we reuse GET_ACCESS_TOKEN_COMPLETE, or are you asking for an update in the public docs?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in the latest commit. I documented the backward compatibility behavior both in the source code (inline) and in the EXAMPLES.md guide.

user: await client.getUser(),
});
}
return tokenResponse;
},
[client]
);

const handleRedirectCallback = useCallback(
async (
url?: string
Expand Down Expand Up @@ -321,6 +347,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
getAccessTokenSilently,
getAccessTokenWithPopup,
getIdTokenClaims,
exchangeToken,
loginWithRedirect,
loginWithPopup,
connectAccountWithRedirect,
Expand All @@ -336,6 +363,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
getAccessTokenSilently,
getAccessTokenWithPopup,
getIdTokenClaims,
exchangeToken,
loginWithRedirect,
loginWithPopup,
connectAccountWithRedirect,
Expand Down
4 changes: 3 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export {
RedirectConnectAccountOptions,
ConnectAccountRedirectResult,
ResponseType,
ConnectError
ConnectError,
CustomTokenExchangeOptions,
TokenEndpointResponse
} from '@auth0/auth0-spa-js';
export { OAuthError } from './errors';
1 change: 1 addition & 0 deletions src/use-auth0.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Auth0Context, { Auth0ContextInterface } from './auth0-context';
* getAccessTokenSilently,
* getAccessTokenWithPopup,
* getIdTokenClaims,
* exchangeToken,
* loginWithRedirect,
* loginWithPopup,
* logout,
Expand Down