From be39eb651737cc2142f7dae36392b398623fc8ae Mon Sep 17 00:00:00 2001 From: Yogesh Chaudhary Date: Fri, 22 May 2026 15:30:25 +0530 Subject: [PATCH] feat: add customTokenExchange method --- __mocks__/@auth0/auth0-spa-js.tsx | 2 + __tests__/auth-provider.test.tsx | 93 +++++++++++++++++++++++++++++++ package.json | 2 +- src/auth0-context.tsx | 28 ++++++++++ src/auth0-provider.tsx | 8 +++ src/index.tsx | 1 + 6 files changed, 133 insertions(+), 1 deletion(-) diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx index d3fa5ac6..97aaabb6 100644 --- a/__mocks__/@auth0/auth0-spa-js.tsx +++ b/__mocks__/@auth0/auth0-spa-js.tsx @@ -9,6 +9,7 @@ const getTokenWithPopup = jest.fn(); const getUser = jest.fn(); const getIdTokenClaims = jest.fn(); const loginWithCustomTokenExchange = jest.fn(); +const customTokenExchange = jest.fn(); const exchangeToken = jest.fn(); const isAuthenticated = jest.fn(() => false); const loginWithPopup = jest.fn(); @@ -37,6 +38,7 @@ export const Auth0Client = jest.fn(() => { getUser, getIdTokenClaims, loginWithCustomTokenExchange, + customTokenExchange, exchangeToken, isAuthenticated, loginWithPopup, diff --git a/__tests__/auth-provider.test.tsx b/__tests__/auth-provider.test.tsx index 3453f858..cbe45d2e 100644 --- a/__tests__/auth-provider.test.tsx +++ b/__tests__/auth-provider.test.tsx @@ -1109,6 +1109,99 @@ describe('Auth0Provider', () => { }); }); + it('should provide a customTokenExchange method', async () => { + const tokenResponse = { + access_token: '__test_access_token__', + id_token: '__test_id_token__', + token_type: 'Bearer', + expires_in: 86400, + }; + clientMock.customTokenExchange.mockResolvedValue(tokenResponse); + const wrapper = createWrapper(); + const { result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitFor(() => { + expect(result.current.customTokenExchange).toBeInstanceOf(Function); + }); + let response; + await act(async () => { + response = await result.current.customTokenExchange({ + subject_token: '__test_token__', + subject_token_type: 'urn:test:token-type', + actor_token: '__test_actor_token__', + actor_token_type: 'https://idp.example.com/token-type/agent', + }); + }); + expect(clientMock.customTokenExchange).toHaveBeenCalledWith({ + subject_token: '__test_token__', + subject_token_type: 'urn:test:token-type', + actor_token: '__test_actor_token__', + actor_token_type: 'https://idp.example.com/token-type/agent', + }); + expect(response).toStrictEqual(tokenResponse); + }); + + it('should not update auth state after customTokenExchange', async () => { + const tokenResponse = { + access_token: '__test_access_token__', + id_token: '__test_id_token__', + token_type: 'Bearer', + expires_in: 86400, + }; + clientMock.customTokenExchange.mockResolvedValue(tokenResponse); + clientMock.getUser.mockResolvedValue(undefined); + const wrapper = createWrapper(); + const { result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitFor(() => { + expect(result.current.customTokenExchange).toBeInstanceOf(Function); + }); + await act(async () => { + await result.current.customTokenExchange({ + subject_token: '__test_token__', + subject_token_type: 'urn:test:token-type', + }); + }); + expect(result.current.isAuthenticated).toBe(false); + }); + + it('should propagate errors from customTokenExchange', async () => { + clientMock.customTokenExchange.mockRejectedValue(new Error('__test_error__')); + const wrapper = createWrapper(); + const { result } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitFor(() => { + expect(result.current.customTokenExchange).toBeInstanceOf(Function); + }); + await act(async () => { + await expect( + result.current.customTokenExchange({ + subject_token: '__test_token__', + subject_token_type: 'urn:test:token-type', + }) + ).rejects.toThrow('__test_error__'); + }); + }); + + it('should memoize the customTokenExchange method', async () => { + const wrapper = createWrapper(); + const { result, rerender } = renderHook( + () => useContext(Auth0Context), + { wrapper } + ); + await waitFor(() => { + const memoized = result.current.customTokenExchange; + rerender(); + expect(result.current.customTokenExchange).toBe(memoized); + }); + }); + it('should provide a handleRedirectCallback method', async () => { clientMock.handleRedirectCallback.mockResolvedValue({ appState: { redirectUri: '/' }, diff --git a/package.json b/package.json index 1a1fd9da..48f06057 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,6 @@ "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" }, "dependencies": { - "@auth0/auth0-spa-js": "^2.19.2" + "@auth0/auth0-spa-js": "^2.20.0" } } diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index 53e994c4..47be599c 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -145,6 +145,33 @@ export interface Auth0ContextInterface options: CustomTokenExchangeOptions ) => Promise; + /** + * ```js + * const tokenResponse = await customTokenExchange({ + * subject_token: 'ey...', + * subject_token_type: 'urn:acme:legacy-system-token', + * actor_token: 'ey...', + * actor_token_type: 'https://idp.example.com/token-type/agent', + * }); + * ``` + * + * Exchanges an external subject token for Auth0 tokens without affecting the current session. + * + * Unlike `loginWithCustomTokenExchange`, this method has no side effects — it does not cache + * tokens, does not update the authenticated session, and does not affect `isAuthenticated` + * or `user`. Use this for delegation or impersonation scenarios where you need a downstream + * API token without changing who the current user is. + * + * When `actor_token` is present Auth0 suppresses refresh token issuance; a missing + * `refresh_token` in the response is expected and will not cause an error. + * + * @param options - The options required to perform the token exchange. + * @returns A promise that resolves to the token endpoint response. + */ + customTokenExchange: ( + options: CustomTokenExchangeOptions + ) => Promise; + /** * @deprecated Use `loginWithCustomTokenExchange()` instead. This method will be removed in the next major version. * @@ -383,6 +410,7 @@ export const initialContext = { getAccessTokenWithPopup: stub, getIdTokenClaims: stub, loginWithCustomTokenExchange: stub, + customTokenExchange: stub, exchangeToken: stub, loginWithRedirect: stub, loginWithPopup: stub, diff --git a/src/auth0-provider.tsx b/src/auth0-provider.tsx index c7077d55..9c391de8 100644 --- a/src/auth0-provider.tsx +++ b/src/auth0-provider.tsx @@ -331,6 +331,12 @@ const Auth0Provider = (opts: Auth0ProviderOptions => + client.customTokenExchange(options), + [client] + ); + const exchangeToken = useCallback( async ( options: CustomTokenExchangeOptions @@ -392,6 +398,7 @@ const Auth0Provider = (opts: Auth0ProviderOptions(opts: Auth0ProviderOptions