diff --git a/EXAMPLES.md b/EXAMPLES.md
index 007fe900..fcefdc28 100644
--- a/EXAMPLES.md
+++ b/EXAMPLES.md
@@ -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 (
+
+
+ {tokens &&
Token exchange successful!
}
+ {error &&
Error: {error}
}
+
+ );
+};
+
+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`.
diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx
index 9cc9da1b..4792c6ca 100644
--- a/__mocks__/@auth0/auth0-spa-js.tsx
+++ b/__mocks__/@auth0/auth0-spa-js.tsx
@@ -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();
@@ -28,6 +29,7 @@ export const Auth0Client = jest.fn(() => {
getTokenWithPopup,
getUser,
getIdTokenClaims,
+ exchangeToken,
isAuthenticated,
loginWithPopup,
loginWithRedirect,
diff --git a/__tests__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx
index 80a99214..53624f68 100644
--- a/__tests__/auth-provider.test.tsx
+++ b/__tests__/auth-provider.test.tsx
@@ -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: '/' },
diff --git a/package-lock.json b/package-lock.json
index 2bcec738..98147f5c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
"version": "2.9.0",
"license": "MIT",
"dependencies": {
- "@auth0/auth0-spa-js": "^2.9.0"
+ "@auth0/auth0-spa-js": "^2.9.1"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^15.0.1",
@@ -74,9 +74,10 @@
}
},
"node_modules/@auth0/auth0-spa-js": {
- "version": "2.9.0",
- "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.9.0.tgz",
- "integrity": "sha512-HmV9zaV3kpTHoVsdQCrHSisIUWxGDyVECcXg94Ky5uKU/Q03h68x0mkwSoz3JEmNIflg5xeZSn2m75BTkcFORw==",
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.9.1.tgz",
+ "integrity": "sha512-GNyypxb8ck32tUacYPHAEZ/L845kLDchqXtFZM3Gt/KcBr9C8/c1ncAhGY1UnkgUw2MctwVnBOEoqCD3oP3SPg==",
+ "license": "MIT",
"dependencies": {
"browser-tabs-lock": "^1.2.15",
"dpop": "^2.1.1",
diff --git a/package.json b/package.json
index 6726ab07..c1679fad 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx
index 5398ed28..ba3407cc 100644
--- a/src/auth0-context.tsx
+++ b/src/auth0-context.tsx
@@ -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';
@@ -90,6 +92,35 @@ export interface Auth0ContextInterface
*/
getIdTokenClaims: () => Promise;
+ /**
+ * ```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;
+
/**
* ```js
* await loginWithRedirect(options);
@@ -229,6 +260,7 @@ export const initialContext = {
getAccessTokenSilently: stub,
getAccessTokenWithPopup: stub,
getIdTokenClaims: stub,
+ exchangeToken: stub,
loginWithRedirect: stub,
loginWithPopup: stub,
connectAccountWithRedirect: stub,
diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx
index d3dc27cb..54eb5451 100644
--- a/src/auth0-provider.tsx
+++ b/src/auth0-provider.tsx
@@ -17,7 +17,9 @@ import {
User,
RedirectConnectAccountOptions,
ConnectAccountRedirectResult,
- ResponseType
+ ResponseType,
+ CustomTokenExchangeOptions,
+ TokenEndpointResponse
} from '@auth0/auth0-spa-js';
import Auth0Context, {
Auth0ContextInterface,
@@ -277,6 +279,30 @@ const Auth0Provider = (opts: Auth0ProviderOptions => {
+ 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',
+ user: await client.getUser(),
+ });
+ }
+ return tokenResponse;
+ },
+ [client]
+ );
+
const handleRedirectCallback = useCallback(
async (
url?: string
@@ -321,6 +347,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions(opts: Auth0ProviderOptions