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