Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions __mocks__/@auth0/auth0-spa-js.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -37,6 +38,7 @@ export const Auth0Client = jest.fn(() => {
getUser,
getIdTokenClaims,
loginWithCustomTokenExchange,
customTokenExchange,
exchangeToken,
isAuthenticated,
loginWithPopup,
Expand Down
93 changes: 93 additions & 0 deletions __tests__/auth-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: '/' },
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
28 changes: 28 additions & 0 deletions src/auth0-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,33 @@ export interface Auth0ContextInterface<TUser extends User = User>
options: CustomTokenExchangeOptions
) => Promise<TokenEndpointResponse>;

/**
* ```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<TokenEndpointResponse>;

/**
* @deprecated Use `loginWithCustomTokenExchange()` instead. This method will be removed in the next major version.
*
Expand Down Expand Up @@ -383,6 +410,7 @@ export const initialContext = {
getAccessTokenWithPopup: stub,
getIdTokenClaims: stub,
loginWithCustomTokenExchange: stub,
customTokenExchange: stub,
exchangeToken: stub,
loginWithRedirect: stub,
loginWithPopup: stub,
Expand Down
8 changes: 8 additions & 0 deletions src/auth0-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,12 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
[client]
);

const customTokenExchange = useCallback(
(options: CustomTokenExchangeOptions): Promise<TokenEndpointResponse> =>
client.customTokenExchange(options),
[client]
);

const exchangeToken = useCallback(
async (
options: CustomTokenExchangeOptions
Expand Down Expand Up @@ -392,6 +398,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
getAccessTokenWithPopup,
getIdTokenClaims,
loginWithCustomTokenExchange,
customTokenExchange,
exchangeToken,
loginWithRedirect,
loginWithPopup,
Expand All @@ -411,6 +418,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
getAccessTokenWithPopup,
getIdTokenClaims,
loginWithCustomTokenExchange,
customTokenExchange,
exchangeToken,
loginWithRedirect,
loginWithPopup,
Expand Down
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export {
ConnectError,
CustomTokenExchangeOptions,
TokenEndpointResponse,
ActClaim,
ClientConfiguration,
// MFA Errors
MfaError,
Expand Down
Loading