Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
83 changes: 83 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,5 +224,88 @@ if (organization && invitation) {
}
});
}
```

## Custom Token Exchange (CTE)

Enable secure token exchange between external identity providers and Auth0 using RFC 8693 standards.

### Basic Implementation

```js
// Initialize client with custom token exchange configuration
const auth0 = await createAuth0Client({
domain: '<AUTH0_DOMAIN>',
clientId: '<AUTH0_CLIENT_ID>',
authorizationParams: {
audience: 'https://your-api.example.com'
}
});

// Exchange external token for Auth0 tokens
async function performTokenExchange() {
try {
const tokenResponse = await auth0.exchangeToken({
subject_token: 'EXTERNAL_PROVIDER_TOKEN',
subject_token_type: 'urn:example:external-token',
scope: 'openid profile email'
});

console.log('Received tokens:', tokenResponse);
} catch (error) {
console.error('Exchange failed:', error);
}
}
```

### Required Auth0 Configuration

1. **Create Token Exchange Profile** in Auth0 Dashboard:

```typescript
await managementClient.tokenExchangeProfiles.create({
action_id: 'custom-auth-action',
name: 'External System Exchange',
subject_token_type: 'urn:example:external-token',
type: 'custom_authentication'
});
```

2. **Add Required Scopes** to your API in Auth0:

```
urn:auth0:oauth2:grant-type:token-exchange
```

### Security Considerations

- Validate external tokens in Auth0 Actions using cryptographic verification
- Implement anti-replay mechanisms for subject tokens
- Store refresh tokens securely when using `offline_access` scope

### Error Handling

```js
async function safeTokenExchange() {
try {
return await auth0.exchangeToken(/* ... */);
} catch (error) {
if (error.error === 'invalid_token') {
// Handle token validation errors
await auth0.logout();
window.location.reload();
}
if (error.error === 'insufficient_scope') {
// Request additional scopes
await auth0.loginWithPopup({
authorizationParams: {
scope: 'additional_scope_required'
}
});
}
}
}
```

[Token Exchange Documentation](https://auth0.com/docs/authenticate/login/token-exchange)
[RFC 8693 Spec](https://tools.ietf.org/html/rfc8693)
138 changes: 138 additions & 0 deletions __tests__/Auth0Client/exchangeToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { verify } from '../../src/jwt';
import { MessageChannel } from 'worker_threads';
import * as utils from '../../src/utils';
import * as scope from '../../src/scope';

// @ts-ignore

import { fetchResponse, setupFn, setupMessageEventLister } from './helpers';

import {
TEST_ACCESS_TOKEN,
TEST_CODE_CHALLENGE,
TEST_ID_TOKEN,
TEST_REFRESH_TOKEN,
TEST_STATE
} from '../constants';

import { Auth0ClientOptions } from '../../src';
import { expect } from '@jest/globals';
import { CustomTokenExchangeOptions } from '../../src/TokenExchange';

jest.mock('es-cookie');
jest.mock('../../src/jwt');
jest.mock('../../src/worker/token.worker');

const mockWindow = <any>global;
const mockFetch = <jest.Mock>mockWindow.fetch;
const mockVerify = <jest.Mock>verify;

jest
.spyOn(utils, 'bufferToBase64UrlEncoded')
.mockReturnValue(TEST_CODE_CHALLENGE);

jest.spyOn(utils, 'runPopup');

const setup = setupFn(mockVerify);

describe('Auth0Client', () => {
const oldWindowLocation = window.location;

beforeEach(() => {
mockWindow.open = jest.fn();
mockWindow.addEventListener = jest.fn();
mockWindow.crypto = {
subtle: {
digest: () => 'foo'
},
getRandomValues() {
return '123';
}
};
mockWindow.MessageChannel = MessageChannel;
mockWindow.Worker = {};
jest.spyOn(scope, 'getUniqueScopes');
sessionStorage.clear();
});

afterEach(() => {
mockFetch.mockReset();
jest.clearAllMocks();
window.location = oldWindowLocation;
});

describe('getTokenWithPopup()', () => {
const localSetup = async (clientOptions?: Partial<Auth0ClientOptions>) => {
const auth0 = setup(clientOptions);

setupMessageEventLister(mockWindow, { state: TEST_STATE });

mockFetch.mockResolvedValueOnce(
fetchResponse(true, {
id_token: TEST_ID_TOKEN,
refresh_token: TEST_REFRESH_TOKEN,
access_token: TEST_ACCESS_TOKEN,
expires_in: 86400
})
);

auth0['_requestToken'] = async function (requestOptions: any) {
return {
decodedToken: {
encoded: {
header: 'fake_header',
payload: 'fake_payload',
signature: 'fake_signature'
},
header: {},
claims: { __raw: 'fake_raw' },
user: {}
},
id_token: 'fake_id_token',
access_token: 'fake_access_token',
expires_in: 3600,
scope: requestOptions.scope
};
};

return auth0;
};

it('calls `loginWithPopup` with the correct default options', async () => {
const auth0 = await localSetup();
const cteOptions: CustomTokenExchangeOptions = {
subject_token: 'external_token_value',
subject_token_type: 'urn:acme:legacy-system-token', // valid token type (not reserved)
scope: 'openid profile email',
audience: 'https://api.test.com'
};
const result = await auth0.exchangeToken(cteOptions);
console.log(result);
expect(result.id_token).toEqual('fake_id_token');
expect(result.access_token).toEqual('fake_access_token');
expect(result.expires_in).toEqual(3600);
expect(typeof result.scope).toBe('string');
});

it('should throw an error for invalid subject_token_type from reserved namespaces', async () => {
// List of reserved token types that must be rejected.
const invalidTokenTypes = [
'urn:ietf:params:oauth:foo',
'https://auth0.com/token',
'urn:auth0:token'
];

const auth0 = await localSetup();

// Each invalid token type should cause exchangeToken to reject with an Error.
for (const tokenType of invalidTokenTypes) {
const cteOptions: CustomTokenExchangeOptions = {
subject_token: 'external_token_value',
subject_token_type: tokenType,
audience: 'https://api.test.com'
};
await expect(auth0.exchangeToken(cteOptions)).rejects.toThrow(Error);
}
});
});
});
78 changes: 77 additions & 1 deletion src/Auth0Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import {
OLD_IS_AUTHENTICATED_COOKIE_NAME,
patchOpenUrlWithOnRedirect
} from './Auth0Client.utils';
import { CustomTokenExchangeOptions, validateTokenType } from './TokenExchange';

/**
* @ignore
Expand Down Expand Up @@ -1097,7 +1098,10 @@ export class Auth0Client {
};

private async _requestToken(
options: PKCERequestTokenOptions | RefreshTokenRequestTokenOptions,
options:
| PKCERequestTokenOptions
| RefreshTokenRequestTokenOptions
| TokenExchangeRequestOptions,
additionalParameters?: RequestTokenAdditionalParameters
) {
const { nonceIn, organization } = additionalParameters || {};
Expand Down Expand Up @@ -1137,6 +1141,70 @@ export class Auth0Client {

return { ...authResult, decodedToken };
}

/*
Custom Token Exchange
* **Implementation Notes:**
* - Ensure that the `subject_token` provided has been securely obtained and is valid according
* to your external identity provider's policies before invoking this function.
* - The function leverages internal helper methods:
* - `validateTokenType` confirms that the `subject_token_type` is supported.
* - `getUniqueScopes` merges and de-duplicates scopes between the provided options and
* the instance's default scopes.
* - `_requestToken` performs the actual HTTP request to the token endpoint.
*/

/**
* Exchanges an external subject token for an Auth0 token via a token exchange request.
*
* @param {CustomTokenExchangeOptions} options - The options required to perform the token exchange.
*
* @returns {Promise<TokenEndpointResponse>} A promise that resolves to the token endpoint response,
* which contains the issued Auth0 tokens.
*
* This method implements the token exchange grant as specified in RFC 8693 by first validating
* the provided subject token type and then constructing a token request to the /oauth/token endpoint.
* The request includes the following parameters:
*
* - `grant_type`: Hard-coded to "urn:ietf:params:oauth:grant-type:token-exchange".
* - `subject_token`: The external token provided via the options.
* - `subject_token_type`: The type of the external token (validated by this function).
* - `scope`: A unique set of scopes, generated by merging the scopes supplied in the options
* with the SDK’s default scopes.
* - `audience`: The target audience, as determined by the SDK's authorization configuration.
*
* **Example Usage:**
*
* ```
* // Define the token exchange options
* const options: CustomTokenExchangeOptions = {
* subject_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6Ikp...',
* subject_token_type: 'urn:acme:legacy-system-token',
* scope: ['openid', 'profile']
* };
*
* // Exchange the external token for Auth0 tokens
* try {
* const tokenResponse = await instance.exchangeToken(options);
* console.log('Token response:', tokenResponse);
* } catch (error) {
* console.error('Token exchange failed:', error);
* }
* ```
*/
async exchangeToken(
options: CustomTokenExchangeOptions
): Promise<TokenEndpointResponse> {
validateTokenType(options.subject_token_type);

return this._requestToken({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: options.subject_token,
subject_token_type: options.subject_token_type,
scope: getUniqueScopes(options.scope, this.scope),
audience: this.options.authorizationParams.audience
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

options contains audience, but it is never used.

This should be:

Suggested change
audience: this.options.authorizationParams.audience
audience: options.audience || this.options.authorizationParams.audience

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, options can contain any custom property, but this is never passed through.

});
}
}

interface BaseRequestTokenOptions {
Expand All @@ -1157,6 +1225,14 @@ interface RefreshTokenRequestTokenOptions extends BaseRequestTokenOptions {
refresh_token?: string;
}

interface TokenExchangeRequestOptions extends BaseRequestTokenOptions {
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange';
subject_token: string;
subject_token_type: string;
actor_token?: string;
actor_token_type?: string;
}

interface RequestTokenAdditionalParameters {
nonceIn?: string;
organization?: string;
Expand Down
Loading