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
6 changes: 4 additions & 2 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions __mocks__/@auth0/auth0-spa-js.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -30,6 +31,7 @@ export const Auth0Client = jest.fn(() => {
getTokenWithPopup,
getUser,
getIdTokenClaims,
loginWithCustomTokenExchange,
exchangeToken,
isAuthenticated,
loginWithPopup,
Expand Down
111 changes: 104 additions & 7 deletions __tests__/auth-provider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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__',
Expand All @@ -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),
Expand All @@ -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',
Expand All @@ -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),
Expand All @@ -934,18 +1031,18 @@ 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__',
id_token: '__test_id_token__',
token_type: 'Bearer',
expires_in: 86400,
};
clientMock.exchangeToken.mockResolvedValue(tokenResponse);
clientMock.loginWithCustomTokenExchange.mockResolvedValue(tokenResponse);
clientMock.getUser.mockResolvedValue(user);
const wrapper = createWrapper();
const { result } = renderHook(
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
71 changes: 64 additions & 7 deletions src/auth0-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,60 @@ export interface Auth0ContextInterface<TUser extends User = User>
getIdTokenClaims: () => Promise<IdToken | undefined>;

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

/**
* @deprecated Use `loginWithCustomTokenExchange()` instead. This method will be removed in the next major version.
*
* ```js
* const tokenResponse = await exchangeToken({
* subject_token: 'external_token_value',
Expand All @@ -101,18 +155,20 @@ export interface Auth0ContextInterface<TUser extends User = User>
* });
* ```
*
* 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
Expand Down Expand Up @@ -271,6 +327,7 @@ export const initialContext = {
getAccessTokenSilently: stub,
getAccessTokenWithPopup: stub,
getIdTokenClaims: stub,
loginWithCustomTokenExchange: stub,
exchangeToken: stub,
loginWithRedirect: stub,
loginWithPopup: stub,
Expand Down
21 changes: 16 additions & 5 deletions src/auth0-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,19 +279,19 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
[client]
);

const exchangeToken = useCallback(
const loginWithCustomTokenExchange = useCallback(
async (
options: CustomTokenExchangeOptions
): Promise<TokenEndpointResponse> => {
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',
Expand All @@ -303,6 +303,15 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
[client]
);

const exchangeToken = useCallback(
async (
options: CustomTokenExchangeOptions
): Promise<TokenEndpointResponse> => {
return loginWithCustomTokenExchange(options);
},
[loginWithCustomTokenExchange]
);

const handleRedirectCallback = useCallback(
async (
url?: string
Expand Down Expand Up @@ -352,6 +361,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
getAccessTokenSilently,
getAccessTokenWithPopup,
getIdTokenClaims,
loginWithCustomTokenExchange,
exchangeToken,
loginWithRedirect,
loginWithPopup,
Expand All @@ -369,6 +379,7 @@ const Auth0Provider = <TUser extends User = User>(opts: Auth0ProviderOptions<TUs
getAccessTokenSilently,
getAccessTokenWithPopup,
getIdTokenClaims,
loginWithCustomTokenExchange,
exchangeToken,
loginWithRedirect,
loginWithPopup,
Expand Down
3 changes: 2 additions & 1 deletion src/use-auth0.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import Auth0Context, { Auth0ContextInterface } from './auth0-context';
* getAccessTokenSilently,
* getAccessTokenWithPopup,
* getIdTokenClaims,
* exchangeToken,
* loginWithCustomTokenExchange,
* exchangeToken, // deprecated - use loginWithCustomTokenExchange
* loginWithRedirect,
* loginWithPopup,
* logout,
Expand Down