diff --git a/EXAMPLES.md b/EXAMPLES.md index f6ee95b60..be5b20737 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -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: '', + clientId: '', + 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) diff --git a/__tests__/Auth0Client/exchangeToken.test.ts b/__tests__/Auth0Client/exchangeToken.test.ts new file mode 100644 index 000000000..5504be674 --- /dev/null +++ b/__tests__/Auth0Client/exchangeToken.test.ts @@ -0,0 +1,117 @@ +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 = global; +const mockFetch = mockWindow.fetch; +const mockVerify = 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('exchangeToken()', () => { + const localSetup = async (clientOptions?: Partial) => { + 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 `exchangeToken` 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'); + }); + }); +}); diff --git a/src/Auth0Client.ts b/src/Auth0Client.ts index b338a7372..9fafa9755 100644 --- a/src/Auth0Client.ts +++ b/src/Auth0Client.ts @@ -92,6 +92,7 @@ import { OLD_IS_AUTHENTICATED_COOKIE_NAME, patchOpenUrlWithOnRedirect } from './Auth0Client.utils'; +import { CustomTokenExchangeOptions } from './TokenExchange'; /** * @ignore @@ -1097,7 +1098,10 @@ export class Auth0Client { }; private async _requestToken( - options: PKCERequestTokenOptions | RefreshTokenRequestTokenOptions, + options: + | PKCERequestTokenOptions + | RefreshTokenRequestTokenOptions + | TokenExchangeRequestOptions, additionalParameters?: RequestTokenAdditionalParameters ) { const { nonceIn, organization } = additionalParameters || {}; @@ -1137,6 +1141,68 @@ 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} 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 { + 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 + }); + } } interface BaseRequestTokenOptions { @@ -1157,6 +1223,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; diff --git a/src/TokenExchange.ts b/src/TokenExchange.ts new file mode 100644 index 000000000..0dbc2a97f --- /dev/null +++ b/src/TokenExchange.ts @@ -0,0 +1,74 @@ +/** + * Represents the configuration options required for initiating a Custom Token Exchange request + * following RFC 8693 specifications. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc8693 | RFC 8693: OAuth 2.0 Token Exchange} + */ +export type CustomTokenExchangeOptions = { + /** + * The type identifier for the subject token being exchanged + * + * @pattern + * - Must be a namespaced URI under your organization's control + * - Forbidden patterns: + * - `^urn:ietf:params:oauth:*` (IETF reserved) + * - `^https:\/\/auth0\.com/*` (Auth0 reserved) + * - `^urn:auth0:*` (Auth0 reserved) + * + * @example + * "urn:acme:legacy-system-token" + * "https://api.yourcompany.com/token-type/v1" + */ + subject_token_type: string; + + /** + * The opaque token value being exchanged for Auth0 tokens + * + * @security + * - Must be validated in Auth0 Actions using strong cryptographic verification + * - Implement replay attack protection + * - Recommended validation libraries: `jose`, `jsonwebtoken` + * + * @example + * "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c" + */ + subject_token: string; + + /** + * The target audience for the requested Auth0 token + * + * @remarks + * Must match exactly with an API identifier configured in your Auth0 tenant + * + * @example + * "https://api.your-service.com/v1" + */ + audience: string; + + /** + * Space-separated list of OAuth 2.0 scopes being requested + * + * @remarks + * Subject to API authorization policies configured in Auth0 + * + * @example + * "openid profile email read:data write:data" + */ + scope?: string; + + /** + * Additional custom parameters for Auth0 Action processing + * + * @remarks + * Accessible in Action code via `event.request.body` + * + * @example + * ```typescript + * { + * custom_parameter: "session_context", + * device_fingerprint: "a3d8f7...", + * } + * ``` + */ + [key: string]: unknown; +}; diff --git a/src/scope.ts b/src/scope.ts index da7d62f75..b91f064d1 100644 --- a/src/scope.ts +++ b/src/scope.ts @@ -6,6 +6,12 @@ const dedupe = (arr: string[]) => Array.from(new Set(arr)); /** * @ignore */ +/** + * Returns a string of unique scopes by removing duplicates and unnecessary whitespace. + * + * @param {...(string | undefined)[]} scopes - A list of scope strings or undefined values. + * @returns {string} A string containing unique scopes separated by a single space. + */ export const getUniqueScopes = (...scopes: (string | undefined)[]) => { return dedupe(scopes.filter(Boolean).join(' ').trim().split(/\s+/)).join(' '); };