From 29e85c50262927df3dcff7a93dbc92d6d0af60ab Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Thu, 5 Feb 2026 10:41:36 +0530 Subject: [PATCH] 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. --- EXAMPLES.md | 6 +- __mocks__/@auth0/auth0-spa-js.tsx | 2 + __tests__/auth-provider.test.tsx | 111 ++++++++++++++++++++++++++++-- package-lock.json | 8 +-- package.json | 2 +- src/auth0-context.tsx | 71 +++++++++++++++++-- src/auth0-provider.tsx | 21 ++++-- src/use-auth0.tsx | 3 +- 8 files changed, 197 insertions(+), 27 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index fe7764c8..9e336669 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -109,13 +109,13 @@ import React, { useState } from 'react'; import { useAuth0 } from '@auth0/auth0-react'; const TokenExchange = () => { - const { exchangeToken } = useAuth0(); + const { loginWithCustomTokenExchange } = useAuth0(); const [tokens, setTokens] = useState(null); const [error, setError] = useState(null); const handleExchange = async (externalToken) => { try { - const tokenResponse = await exchangeToken({ + const tokenResponse = await loginWithCustomTokenExchange({ subject_token: externalToken, subject_token_type: 'urn:your-company:legacy-system-token', audience: 'https://api.example.com/', @@ -148,6 +148,8 @@ const TokenExchange = () => { export default TokenExchange; ``` +> **Note:** The `exchangeToken` method is deprecated and will be removed in the next major version. Use `loginWithCustomTokenExchange` instead. + **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 diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx index 9012575e..0e101a2c 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 loginWithCustomTokenExchange = jest.fn(); const exchangeToken = jest.fn(); const isAuthenticated = jest.fn(() => false); const loginWithPopup = jest.fn(); @@ -30,6 +31,7 @@ export const Auth0Client = jest.fn(() => { getTokenWithPopup, getUser, getIdTokenClaims, + loginWithCustomTokenExchange, exchangeToken, isAuthenticated, loginWithPopup, diff --git a/__tests__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx index 38297635..ec6163c6 100644 --- a/__tests__/auth-provider.test.tsx +++ b/__tests__/auth-provider.test.tsx @@ -881,6 +881,103 @@ describe('Auth0Provider', () => { }); }); + it('should provide a loginWithCustomTokenExchange 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.loginWithCustomTokenExchange.mockResolvedValue(tokenResponse); + const wrapper = createWrapper(); + const { result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitFor(() => { + expect(result.current.loginWithCustomTokenExchange).toBeInstanceOf(Function); + }); + let response; + await act(async () => { + response = await result.current.loginWithCustomTokenExchange({ + subject_token: '__test_token__', + subject_token_type: 'urn:test:token-type', + scope: 'openid profile email', + organization: 'org_123', + }); + }); + expect(clientMock.loginWithCustomTokenExchange).toHaveBeenCalledWith({ + subject_token: '__test_token__', + subject_token_type: 'urn:test:token-type', + scope: 'openid profile email', + organization: 'org_123', + }); + expect(response).toStrictEqual(tokenResponse); + }); + + it('should handle errors when using loginWithCustomTokenExchange', async () => { + clientMock.loginWithCustomTokenExchange.mockRejectedValue(new Error('__test_error__')); + const wrapper = createWrapper(); + const { result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitFor(() => { + expect(result.current.loginWithCustomTokenExchange).toBeInstanceOf(Function); + }); + await act(async () => { + await expect( + result.current.loginWithCustomTokenExchange({ + subject_token: '__test_token__', + subject_token_type: 'urn:test:token-type', + }) + ).rejects.toThrow('__test_error__'); + }); + expect(clientMock.loginWithCustomTokenExchange).toHaveBeenCalled(); + }); + + it('should update auth state after successful loginWithCustomTokenExchange', 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.loginWithCustomTokenExchange.mockResolvedValue(tokenResponse); + clientMock.getUser.mockResolvedValue(user); + const wrapper = createWrapper(); + const { result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitFor(() => { + expect(result.current.loginWithCustomTokenExchange).toBeInstanceOf(Function); + }); + await act(async () => { + await result.current.loginWithCustomTokenExchange({ + 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 loginWithCustomTokenExchange method', async () => { + const wrapper = createWrapper(); + const { result, rerender } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitFor(() => { + const memoized = result.current.loginWithCustomTokenExchange; + rerender(); + expect(result.current.loginWithCustomTokenExchange).toBe(memoized); + }); + }); + it('should provide an exchangeToken method', async () => { const tokenResponse = { access_token: '__test_access_token__', @@ -889,7 +986,7 @@ describe('Auth0Provider', () => { expires_in: 86400, scope: 'openid profile email', }; - clientMock.exchangeToken.mockResolvedValue(tokenResponse); + clientMock.loginWithCustomTokenExchange.mockResolvedValue(tokenResponse); const wrapper = createWrapper(); const { result } = renderHook( () => useContext(Auth0Context), @@ -907,7 +1004,7 @@ describe('Auth0Provider', () => { organization: 'org_123', }); }); - expect(clientMock.exchangeToken).toHaveBeenCalledWith({ + expect(clientMock.loginWithCustomTokenExchange).toHaveBeenCalledWith({ subject_token: '__test_token__', subject_token_type: 'urn:test:token-type', scope: 'openid profile email', @@ -916,8 +1013,8 @@ describe('Auth0Provider', () => { expect(response).toStrictEqual(tokenResponse); }); - it('should handle errors when exchanging tokens', async () => { - clientMock.exchangeToken.mockRejectedValue(new Error('__test_error__')); + it('should handle errors when exchanging tokens (deprecated method)', async () => { + clientMock.loginWithCustomTokenExchange.mockRejectedValue(new Error('__test_error__')); const wrapper = createWrapper(); const { result } = renderHook( () => useContext(Auth0Context), @@ -934,10 +1031,10 @@ describe('Auth0Provider', () => { }) ).rejects.toThrow('__test_error__'); }); - expect(clientMock.exchangeToken).toHaveBeenCalled(); + expect(clientMock.loginWithCustomTokenExchange).toHaveBeenCalled(); }); - it('should update auth state after successful token exchange', async () => { + it('should update auth state after successful token exchange (deprecated method)', async () => { const user = { name: '__test_user__' }; const tokenResponse = { access_token: '__test_access_token__', @@ -945,7 +1042,7 @@ describe('Auth0Provider', () => { token_type: 'Bearer', expires_in: 86400, }; - clientMock.exchangeToken.mockResolvedValue(tokenResponse); + clientMock.loginWithCustomTokenExchange.mockResolvedValue(tokenResponse); clientMock.getUser.mockResolvedValue(user); const wrapper = createWrapper(); const { result } = renderHook( diff --git a/package-lock.json b/package-lock.json index a6c50973..4a87014c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.12.0", "license": "MIT", "dependencies": { - "@auth0/auth0-spa-js": "^2.12.0" + "@auth0/auth0-spa-js": "^2.14.0" }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.0.1", @@ -91,9 +91,9 @@ } }, "node_modules/@auth0/auth0-spa-js": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.13.1.tgz", - "integrity": "sha512-H9N4QjBO8Dxr9hWT9NsAn60pPDGJy4gW5GKdYLpn4M33GocmrxoZ5wfYh99mMObZj3Ww4HiTyauNT2HGr9mx/A==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.14.0.tgz", + "integrity": "sha512-2XGd3j7SCTMWBTCAU6Xk9ZtQxcgz9mjMs28t0BMv3y1GfoO7qA9VAgElYb52CyCeiTGlOYAVZFsioojFdRwxcA==", "license": "MIT", "dependencies": { "@auth0/auth0-auth-js": "^1.4.0", diff --git a/package.json b/package.json index 4165a4a3..e6176e8b 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,6 @@ "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" }, "dependencies": { - "@auth0/auth0-spa-js": "^2.12.0" + "@auth0/auth0-spa-js": "^2.14.0" } } diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index 146ec21f..07ed729f 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -93,6 +93,60 @@ export interface Auth0ContextInterface getIdTokenClaims: () => Promise; /** + * ```js + * await loginWithCustomTokenExchange(options); + * ``` + * + * Exchanges an external subject token for Auth0 tokens and logs the user in. + * This method implements the Custom Token Exchange grant as specified in RFC 8693. + * + * The exchanged tokens are automatically cached, establishing an authenticated session. + * After calling this method, you can use `getUser()`, `getIdTokenClaims()`, and + * `getTokenSilently()` to access the user's information and tokens. + * + * @param options - The options required to perform the token exchange. + * + * @returns A promise that resolves to the token endpoint response, + * which contains the issued Auth0 tokens (access_token, id_token, etc.). + * + * The request includes the following parameters: + * - `grant_type`: "urn:ietf:params:oauth:grant-type:token-exchange" + * - `subject_token`: The external token to exchange + * - `subject_token_type`: The type identifier of the external token + * - `scope`: Merged scopes from the request and SDK defaults + * - `audience`: Target audience (defaults to SDK configuration) + * - `organization`: Optional organization ID/name for org-scoped authentication + * + * **Example Usage:** + * + * ```js + * const options = { + * subject_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6Ikp...', + * subject_token_type: 'urn:acme:legacy-system-token', + * scope: 'openid profile email', + * audience: 'https://api.example.com', + * organization: 'org_12345' + * }; + * + * try { + * const tokenResponse = await loginWithCustomTokenExchange(options); + * console.log('Access token:', tokenResponse.access_token); + * + * // User is now logged in - access user info + * const user = await getUser(); + * console.log('Logged in user:', user); + * } catch (error) { + * console.error('Token exchange failed:', error); + * } + * ``` + */ + loginWithCustomTokenExchange: ( + options: CustomTokenExchangeOptions + ) => Promise; + + /** + * @deprecated Use `loginWithCustomTokenExchange()` instead. This method will be removed in the next major version. + * * ```js * const tokenResponse = await exchangeToken({ * subject_token: 'external_token_value', @@ -101,18 +155,20 @@ export interface Auth0ContextInterface * }); * ``` * - * Exchanges an external subject token for Auth0 tokens via a token exchange request. + * Exchanges an external subject token for Auth0 tokens and logs the user in. * * 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) + * **Example:** + * ```js + * // Instead of: + * const tokens = await exchangeToken(options); + * + * // Use: + * const tokens = await loginWithCustomTokenExchange(options); + * ``` * * @param options - The options required to perform the token exchange * @returns A promise that resolves to the token endpoint response containing Auth0 tokens @@ -271,6 +327,7 @@ export const initialContext = { getAccessTokenSilently: stub, getAccessTokenWithPopup: stub, getIdTokenClaims: stub, + loginWithCustomTokenExchange: stub, exchangeToken: stub, loginWithRedirect: stub, loginWithPopup: stub, diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index 2027468c..b10a20a6 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -279,19 +279,19 @@ const Auth0Provider = (opts: Auth0ProviderOptions => { let tokenResponse; try { - tokenResponse = await client.exchangeToken(options); + tokenResponse = await client.loginWithCustomTokenExchange(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 + // 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', @@ -303,6 +303,15 @@ const Auth0Provider = (opts: Auth0ProviderOptions => { + return loginWithCustomTokenExchange(options); + }, + [loginWithCustomTokenExchange] + ); + const handleRedirectCallback = useCallback( async ( url?: string @@ -352,6 +361,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions(opts: Auth0ProviderOptions