From cf6d0488841a7ae304ea6d38214b53e945eaddbb Mon Sep 17 00:00:00 2001 From: Gyanesh Gouraw Date: Fri, 7 Nov 2025 18:05:22 +0530 Subject: [PATCH 1/5] feat: add custom token exchange support --- EXAMPLES.md | 56 ++++++++++++++++++ __mocks__/@auth0/auth0-spa-js.tsx | 2 + __tests__/auth-provider.test.tsx | 95 +++++++++++++++++++++++++++++++ src/auth0-context.tsx | 34 ++++++++++- src/auth0-provider.tsx | 26 ++++++++- src/index.tsx | 4 +- src/use-auth0.tsx | 1 + 7 files changed, 215 insertions(+), 3 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 007fe900..4a4c8bae 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -99,6 +99,62 @@ 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', + authorizationParams: { + 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 through `authorizationParams` or will fall back to SDK defaults + ## 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/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..a3311217 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,26 @@ const Auth0Provider = (opts: Auth0ProviderOptions => { + let tokenResponse; + try { + tokenResponse = await client.exchangeToken(options); + } catch (error) { + throw tokenError(error); + } finally { + dispatch({ + type: 'GET_ACCESS_TOKEN_COMPLETE', + user: await client.getUser(), + }); + } + return tokenResponse; + }, + [client] + ); + const handleRedirectCallback = useCallback( async ( url?: string @@ -321,6 +343,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions(opts: Auth0ProviderOptions Date: Fri, 21 Nov 2025 16:17:43 +0530 Subject: [PATCH 2/5] chore(deps): bump @auth0/auth0-spa-js to 2.9.1 --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) 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" } } From ccfa7d873897f896f8ba17600c81a0480c3f7357 Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Wed, 26 Nov 2025 11:14:11 +0530 Subject: [PATCH 3/5] docs: fix exchangeToken params nesting in EXAMPLES.md --- EXAMPLES.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 4a4c8bae..b78c62ef 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -117,10 +117,8 @@ const TokenExchange = () => { const tokenResponse = await exchangeToken({ subject_token: externalToken, subject_token_type: 'urn:your-company:legacy-system-token', - authorizationParams: { - audience: 'https://api.example.com/', - scope: 'openid profile email', - }, + audience: 'https://api.example.com/', + scope: 'openid profile email', }); setTokens(tokenResponse); From d8cdac6f03cca974bf276012acba49c88bc94d1e Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Wed, 26 Nov 2025 11:16:55 +0530 Subject: [PATCH 4/5] docs: correct note about audience/scope params --- EXAMPLES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index b78c62ef..577d47d9 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -151,7 +151,7 @@ export default TokenExchange; - 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 through `authorizationParams` or will fall back to SDK defaults +- The audience and scope can be provided directly in the options or will fall back to SDK defaults ## Protecting a route in a `react-router-dom v6` app From 931dd688f7ab7ebbc5fd8ec39a4f9bd8098b4e65 Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Wed, 26 Nov 2025 12:36:20 +0530 Subject: [PATCH 5/5] docs: document CTE state parity and backward compatibility --- EXAMPLES.md | 1 + src/auth0-provider.tsx | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index 577d47d9..fcefdc28 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -152,6 +152,7 @@ export default TokenExchange; - 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 diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index a3311217..54eb5451 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -289,6 +289,10 @@ const Auth0Provider = (opts: Auth0ProviderOptions