From 083b7f5c7469908424e1d187b4ddcbdf09053208 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 25 May 2026 21:50:48 +0530 Subject: [PATCH 1/9] types.ts - Added ActClaim interface (sub: string + open index signature for extra claims per RFC 8693) - Added actorToken? and actorTokenType? to ExchangeProfileOptions - Added act?: ActClaim property to TokenResponse - Populated act from parsed ID token claims in fromTokenEndpointResponse() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth-client.ts - Removed actor_token and actor_token_type from PARAM_DENYLIST (they now have explicit typed params) - Updated the denylist comment block to remove the stale bullet - Added validateTokenTypeUri() — syntactic-only new URL() check, throws TokenExchangeError with a clear message - In #exchangeProfileToken(): calls validateTokenTypeUri for both subjectTokenType and actorTokenType, then appends actor_token/actor_token_type to the request when present auth-client.spec.ts - New describe('exchangeToken — actor token support') block with 5 tests covering: params wired + act claim populated, act claim with extra fields, params absent when not provided, invalid URI validation (both params in one test), valid URI acceptance --- .../auth0-auth-js/src/auth-client.spec.ts | 137 ++++++++++++++++++ packages/auth0-auth-js/src/auth-client.ts | 26 +++- packages/auth0-auth-js/src/types.ts | 54 +++++++ 3 files changed, 214 insertions(+), 3 deletions(-) diff --git a/packages/auth0-auth-js/src/auth-client.spec.ts b/packages/auth0-auth-js/src/auth-client.spec.ts index 93a92a9..2206e68 100644 --- a/packages/auth0-auth-js/src/auth-client.spec.ts +++ b/packages/auth0-auth-js/src/auth-client.spec.ts @@ -2910,6 +2910,143 @@ describe('exchangeToken with Token Exchange Profile', () => { }); }); +describe('exchangeToken — actor token support', () => { + const baseOptions = { + subjectToken: 'subject-token-123', + subjectTokenType: 'urn:acme:custom-token', + audience: 'https://api.example.com', + }; + + test('should send actor_token and actor_token_type when provided', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + let capturedActorToken: string | null = null; + let capturedActorTokenType: string | null = null; + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async ({ request }) => { + const info = await request.formData(); + capturedActorToken = info.get('actor_token') as string; + capturedActorTokenType = info.get('actor_token_type') as string; + return HttpResponse.json({ + access_token: accessToken, + id_token: await generateToken(domain, 'user_cte', '', undefined, undefined, undefined, { + act: { sub: 'service-account-123' }, + }), + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:default', + }); + }) + ); + + const result = await authClient.exchangeToken({ + ...baseOptions, + actorToken: 'actor-token-abc', + actorTokenType: 'urn:acme:actor-token', + }); + + expect(capturedActorToken).toBe('actor-token-abc'); + expect(capturedActorTokenType).toBe('urn:acme:actor-token'); + expect(result.act).toEqual({ sub: 'service-account-123' }); + }); + + test('should expose act claim from id_token on TokenResponse', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async () => { + return HttpResponse.json({ + access_token: accessToken, + id_token: await generateToken(domain, 'user_cte', '', undefined, undefined, undefined, { + act: { sub: 'actor-sub-456', iss: 'https://actor.example.com' }, + }), + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:default', + }); + }) + ); + + const result = await authClient.exchangeToken(baseOptions); + + expect(result.act).toBeDefined(); + expect(result.act?.sub).toBe('actor-sub-456'); + expect(result.act?.iss).toBe('https://actor.example.com'); + }); + + test('should not send actor_token or actor_token_type when omitted', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + let hasActorToken = false; + let hasActorTokenType = false; + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async ({ request }) => { + const info = await request.formData(); + hasActorToken = info.has('actor_token'); + hasActorTokenType = info.has('actor_token_type'); + return HttpResponse.json({ + access_token: accessToken, + id_token: await generateToken(domain, 'user_cte', ''), + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:default', + }); + }) + ); + + const result = await authClient.exchangeToken(baseOptions); + + expect(hasActorToken).toBe(false); + expect(hasActorTokenType).toBe(false); + expect(result.act).toBeUndefined(); + }); + + test('should throw TokenExchangeError for invalid token type URIs', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + await expect( + authClient.exchangeToken({ ...baseOptions, subjectTokenType: 'not a valid uri' }) + ).rejects.toMatchObject({ + name: 'TokenExchangeError', + code: 'token_exchange_error', + message: 'subjectTokenType must be a valid URI', + }); + + await expect( + authClient.exchangeToken({ ...baseOptions, actorToken: 'actor-token-abc', actorTokenType: 'not a valid uri' }) + ).rejects.toMatchObject({ + name: 'TokenExchangeError', + code: 'token_exchange_error', + message: 'actorTokenType must be a valid URI', + }); + }); + + test('should accept valid URI formats for subjectTokenType and actorTokenType', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async () => { + return HttpResponse.json({ + access_token: accessToken, + id_token: await generateToken(domain, 'user_cte', ''), + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:default', + }); + }) + ); + + await expect( + authClient.exchangeToken({ + subjectToken: 'subject-token-123', + subjectTokenType: 'http://acme.com/token-type', + actorToken: 'actor-token', + actorTokenType: 'http://acme.com/actor-type', + }) + ).resolves.toBeDefined(); + }); +}); + describe('Telemetry', () => { test('should include Auth0-Client header in discovery requests', async () => { let capturedHeader: string | null = null; diff --git a/packages/auth0-auth-js/src/auth-client.ts b/packages/auth0-auth-js/src/auth-client.ts index 336f98c..057a91b 100644 --- a/packages/auth0-auth-js/src/auth-client.ts +++ b/packages/auth0-auth-js/src/auth-client.ts @@ -78,7 +78,6 @@ const MAX_ARRAY_VALUES_PER_KEY = 20; * - subject_token, subject_token_type: Core token exchange parameters, overriding creates * ambiguity about which token is being exchanged * - requested_token_type: Determines the type of token returned, must be explicit - * - actor_token, actor_token_type: Delegation parameters that affect authorization context * - audience, aud, resource, resources, resource_indicator: Target API parameters must use * explicit API parameters to prevent confusion about precedence and ensure correct routing * - scope: Overriding via extras bypasses the explicit scope parameter and creates ambiguity @@ -101,8 +100,6 @@ const PARAM_DENYLIST = Object.freeze( 'subject_token', 'subject_token_type', 'requested_token_type', - 'actor_token', - 'actor_token_type', 'audience', 'aud', 'resource', @@ -161,6 +158,18 @@ function toOAuth2Error(e: unknown): OAuth2Error { return base; } +/** + * Validates that a token type value is a syntactically valid URI. + * Semantic validation (reserved namespaces) is enforced by the Auth0 server. + */ +function validateTokenTypeUri(value: string, paramName: string): void { + try { + new URL(value); + } catch { + throw new TokenExchangeError(`${paramName} must be a valid URI`); + } +} + /** * Appends extra parameters to URLSearchParams while enforcing security constraints. */ @@ -757,6 +766,11 @@ export class AuthClient { const { configuration } = await this.#discover(); validateSubjectToken(options.subjectToken); + validateTokenTypeUri(options.subjectTokenType, 'subjectTokenType'); + + if (options.actorTokenType !== undefined) { + validateTokenTypeUri(options.actorTokenType, 'actorTokenType'); + } const tokenRequestParams = new URLSearchParams({ subject_token_type: options.subjectTokenType, @@ -775,6 +789,12 @@ export class AuthClient { if (options.organization) { tokenRequestParams.append('organization', options.organization); } + if (options.actorToken) { + tokenRequestParams.append('actor_token', options.actorToken); + } + if (options.actorTokenType) { + tokenRequestParams.append('actor_token_type', options.actorTokenType); + } appendExtraParams(tokenRequestParams, options.extra); diff --git a/packages/auth0-auth-js/src/types.ts b/packages/auth0-auth-js/src/types.ts index 20ffa2b..4724664 100644 --- a/packages/auth0-auth-js/src/types.ts +++ b/packages/auth0-auth-js/src/types.ts @@ -388,6 +388,32 @@ export interface ExchangeProfileOptions { */ organization?: string; + /** + * The actor token to include in the delegation exchange (RFC 8693). + * + * When provided, identifies the acting party (the intermediate service or agent) + * on whose behalf the exchange is being performed. The resulting token will carry + * an `act` claim describing the actor. + * + * Must be used together with `actorTokenType`. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc8693#section-2.1 RFC 8693 Section 2.1} + */ + actorToken?: string; + + /** + * A URI that identifies the type of the actor token (RFC 8693). + * + * Must be a syntactically valid URI. Reserved namespaces are validated by the + * Auth0 platform, not the SDK. + * + * Must be used together with `actorToken`. + * + * @example "urn:acme:actor-token" + * @example "http://acme.com/service-token" + */ + actorTokenType?: string; + /** * Additional custom parameters accessible in Auth0 Actions via event.request.body. * @@ -534,6 +560,23 @@ export interface AuthorizationDetails { readonly [parameter: string]: unknown; } +/** + * Represents the `act` (actor) claim in a token response (RFC 8693). + * + * Present when a token was issued via a delegation exchange, identifying the + * acting party (e.g., an intermediate service) that performed the exchange on + * behalf of the subject. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc8693#section-4.1 RFC 8693 Section 4.1} + */ +export interface ActClaim { + /** + * The subject identifier of the actor. + */ + sub: string; + [key: string]: unknown; +} + /** * Represents a successful token response from Auth0. * @@ -589,6 +632,16 @@ export class TokenResponse { */ recoveryCode?: string; + /** + * The actor claim from the ID token (RFC 8693). + * + * Present in delegation exchanges when an `actorToken` was provided. Identifies + * the acting party on whose behalf the subject token was exchanged. + * + * @see {@link https://www.rfc-editor.org/rfc/rfc8693#section-4.1 RFC 8693 Section 4.1} + */ + act?: ActClaim; + constructor( accessToken: string, expiresAt: number, @@ -632,6 +685,7 @@ export class TokenResponse { tokenResponse.tokenType = response.token_type; tokenResponse.issuedTokenType = (response as typeof response & { issued_token_type?: string }).issued_token_type; + tokenResponse.act = claims?.act as ActClaim | undefined; return tokenResponse; } From 6987021725df7044a5f52a75c71a96d0d69cdd51 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 29 May 2026 13:28:46 +0530 Subject: [PATCH 2/9] parse act claim from access token if id_token is absent --- .../auth0-auth-js/src/auth-client.spec.ts | 25 +++++++++++++++++++ packages/auth0-auth-js/src/types.ts | 4 ++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/auth0-auth-js/src/auth-client.spec.ts b/packages/auth0-auth-js/src/auth-client.spec.ts index 2206e68..a4cfde4 100644 --- a/packages/auth0-auth-js/src/auth-client.spec.ts +++ b/packages/auth0-auth-js/src/auth-client.spec.ts @@ -3021,6 +3021,31 @@ describe('exchangeToken — actor token support', () => { }); }); + test('should expose act claim from access token when no id_token is returned (M2M delegation)', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + const accessTokenWithAct = await generateToken(domain, 'user_cte', 'https://api.example.com', undefined, undefined, undefined, { + act: { sub: 'service-account-123' }, + }); + + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async () => { + return HttpResponse.json({ + access_token: accessTokenWithAct, + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:data', + // no id_token — M2M flow, openid scope not requested + }); + }) + ); + + const result = await authClient.exchangeToken(baseOptions); + + expect(result.act).toBeDefined(); + expect(result.act?.sub).toBe('service-account-123'); + }); + test('should accept valid URI formats for subjectTokenType and actorTokenType', async () => { const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); diff --git a/packages/auth0-auth-js/src/types.ts b/packages/auth0-auth-js/src/types.ts index 4724664..8152ada 100644 --- a/packages/auth0-auth-js/src/types.ts +++ b/packages/auth0-auth-js/src/types.ts @@ -1,4 +1,5 @@ import { IDToken, TokenEndpointResponse, TokenEndpointResponseHelpers } from 'openid-client'; +import { decodeJwt } from 'jose'; import type { TelemetryConfig } from './telemetry.js'; export type { TelemetryConfig } from './telemetry.js'; @@ -685,7 +686,8 @@ export class TokenResponse { tokenResponse.tokenType = response.token_type; tokenResponse.issuedTokenType = (response as typeof response & { issued_token_type?: string }).issued_token_type; - tokenResponse.act = claims?.act as ActClaim | undefined; + const atClaims = decodeJwt(response.access_token); + tokenResponse.act = (claims?.act ?? atClaims.act) as ActClaim | undefined; return tokenResponse; } From e86ab58a5c37733467efffc554dab2088691e60c Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 29 May 2026 13:34:59 +0530 Subject: [PATCH 3/9] enforce actor_token_type when actor_token is provided --- packages/auth0-auth-js/src/auth-client.spec.ts | 12 ++++++++++++ packages/auth0-auth-js/src/auth-client.ts | 7 ++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/auth0-auth-js/src/auth-client.spec.ts b/packages/auth0-auth-js/src/auth-client.spec.ts index a4cfde4..39b9665 100644 --- a/packages/auth0-auth-js/src/auth-client.spec.ts +++ b/packages/auth0-auth-js/src/auth-client.spec.ts @@ -3001,6 +3001,18 @@ describe('exchangeToken — actor token support', () => { expect(result.act).toBeUndefined(); }); + test('should throw TokenExchangeError when actorToken is provided without actorTokenType', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + await expect( + authClient.exchangeToken({ ...baseOptions, actorToken: 'actor-token-abc' }) + ).rejects.toMatchObject({ + name: 'TokenExchangeError', + code: 'token_exchange_error', + message: 'actorTokenType is required when actorToken is provided', + }); + }); + test('should throw TokenExchangeError for invalid token type URIs', async () => { const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); diff --git a/packages/auth0-auth-js/src/auth-client.ts b/packages/auth0-auth-js/src/auth-client.ts index 057a91b..fc19e56 100644 --- a/packages/auth0-auth-js/src/auth-client.ts +++ b/packages/auth0-auth-js/src/auth-client.ts @@ -768,6 +768,9 @@ export class AuthClient { validateSubjectToken(options.subjectToken); validateTokenTypeUri(options.subjectTokenType, 'subjectTokenType'); + if (options.actorToken !== undefined && options.actorTokenType === undefined) { + throw new TokenExchangeError('actorTokenType is required when actorToken is provided'); + } if (options.actorTokenType !== undefined) { validateTokenTypeUri(options.actorTokenType, 'actorTokenType'); } @@ -791,9 +794,7 @@ export class AuthClient { } if (options.actorToken) { tokenRequestParams.append('actor_token', options.actorToken); - } - if (options.actorTokenType) { - tokenRequestParams.append('actor_token_type', options.actorTokenType); + tokenRequestParams.append('actor_token_type', options.actorTokenType!); } appendExtraParams(tokenRequestParams, options.extra); From 6f418b597b7667f7c5ed00ae8dbfac5b030a3f7d Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 29 May 2026 15:27:13 +0530 Subject: [PATCH 4/9] create valid JWT for api-js test suite --- packages/auth0-api-js/src/api-client.spec.ts | 43 ++++++++++++-------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/packages/auth0-api-js/src/api-client.spec.ts b/packages/auth0-api-js/src/api-client.spec.ts index 5015920..294ed3a 100644 --- a/packages/auth0-api-js/src/api-client.spec.ts +++ b/packages/auth0-api-js/src/api-client.spec.ts @@ -962,6 +962,8 @@ test('getAccessTokenForConnection - should return a token set when the exchange clientSecret: 'my-client-secret', }); + const newAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com'); + server.use( http.post(`https://${domain}/oauth/token`, async ({ request }) => { const body = await request.formData(); @@ -977,7 +979,7 @@ test('getAccessTokenForConnection - should return a token set when the exchange ) { return HttpResponse.json( { - access_token: 'new-access-token', + access_token: newAccessToken, expires_in: 86400, scope: 'openid profile email', token_type: 'Bearer', @@ -1000,7 +1002,7 @@ test('getAccessTokenForConnection - should return a token set when the exchange }); expect(tokenSet).toStrictEqual({ - accessToken: 'new-access-token', + accessToken: newAccessToken, expiresAt: expect.any(Number), scope: 'openid profile email', connection: 'my-connection', @@ -1045,6 +1047,8 @@ test('getTokenByExchangeProfile - should return tokens when exchange succeeds', clientSecret: 'my-client-secret', }); + const exchangedAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com'); + server.use( http.post(`https://${domain}/oauth/token`, async ({ request }) => { const body = await request.formData(); @@ -1059,7 +1063,7 @@ test('getTokenByExchangeProfile - should return tokens when exchange succeeds', ) { return HttpResponse.json( { - access_token: 'exchanged-access-token', + access_token: exchangedAccessToken, expires_in: 3600, scope: 'read:data write:data', token_type: 'Bearer', @@ -1086,7 +1090,7 @@ test('getTokenByExchangeProfile - should return tokens when exchange succeeds', ); expect(result).toMatchObject({ - accessToken: 'exchanged-access-token', + accessToken: exchangedAccessToken, expiresAt: expect.any(Number), scope: 'read:data write:data', }); @@ -1101,12 +1105,13 @@ test('getTokenByExchangeProfile - should include idToken and refreshToken when p clientSecret: 'my-client-secret', }); const idToken = await generateToken(domain, 'user_123', 'my-client-id'); + const exchangedAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com'); server.use( http.post(`https://${domain}/oauth/token`, async () => { return HttpResponse.json( { - access_token: 'exchanged-access-token', + access_token: exchangedAccessToken, expires_in: 3600, token_type: 'Bearer', issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', @@ -1178,11 +1183,13 @@ test('getTokenByExchangeProfile - should propagate issued_token_type from token clientSecret: 'my-client-secret', }); + const exchangedAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com'); + server.use( http.post(`https://${domain}/oauth/token`, async () => { return HttpResponse.json( { - access_token: 'exchanged-access-token', + access_token: exchangedAccessToken, expires_in: 3600, token_type: 'Bearer', issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', @@ -1212,12 +1219,13 @@ test('getTokenByExchangeProfile - should include organization parameter when pro clientSecret: 'my-client-secret', }); + const exchangedAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com'); let capturedOrganization: string | null = null; server.use( http.post(`https://${domain}/oauth/token`, async ({ request }) => { const body = await request.formData(); capturedOrganization = body.get('organization') as string; - + if ( body.get('grant_type') === 'urn:ietf:params:oauth:grant-type:token-exchange' && body.get('client_id') === 'my-client-id' && @@ -1229,7 +1237,7 @@ test('getTokenByExchangeProfile - should include organization parameter when pro ) { return HttpResponse.json( { - access_token: 'exchanged-access-token', + access_token: exchangedAccessToken, expires_in: 3600, scope: 'read:data write:data', token_type: 'Bearer', @@ -1258,7 +1266,7 @@ test('getTokenByExchangeProfile - should include organization parameter when pro expect(capturedOrganization).toBe('org_abc123'); expect(result).toMatchObject({ - accessToken: 'exchanged-access-token', + accessToken: exchangedAccessToken, expiresAt: expect.any(Number), scope: 'read:data write:data', }); @@ -1272,12 +1280,13 @@ test('getTokenByExchangeProfile - should work without organization parameter (ba clientSecret: 'my-client-secret', }); + const exchangedAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com'); let capturedOrganization: string | null = null; server.use( http.post(`https://${domain}/oauth/token`, async ({ request }) => { const body = await request.formData(); capturedOrganization = body.get('organization') as string; - + if ( body.get('grant_type') === 'urn:ietf:params:oauth:grant-type:token-exchange' && body.get('subject_token') === 'my-subject-token' && @@ -1285,7 +1294,7 @@ test('getTokenByExchangeProfile - should work without organization parameter (ba ) { return HttpResponse.json( { - access_token: 'exchanged-access-token', + access_token: exchangedAccessToken, expires_in: 3600, token_type: 'Bearer', }, @@ -1309,7 +1318,7 @@ test('getTokenByExchangeProfile - should work without organization parameter (ba ); expect(capturedOrganization).toBeNull(); - expect(result.accessToken).toBe('exchanged-access-token'); + expect(result.accessToken).toBe(exchangedAccessToken); }); test('getTokenOnBehalfOf - should throw when no clientId configured', async () => { @@ -1347,6 +1356,7 @@ test('getTokenOnBehalfOf - should exchange an access token using fixed OBO token clientSecret: 'my-client-secret', }); + const oboAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com'); let capturedOrganization: string | null = null; server.use( http.post(`https://${domain}/oauth/token`, async ({ request }) => { @@ -1365,7 +1375,7 @@ test('getTokenOnBehalfOf - should exchange an access token using fixed OBO token ) { return HttpResponse.json( { - access_token: 'obo-access-token', + access_token: oboAccessToken, expires_in: 3600, scope: 'read:data write:data', token_type: 'Bearer', @@ -1389,7 +1399,7 @@ test('getTokenOnBehalfOf - should exchange an access token using fixed OBO token expect(capturedOrganization).toBeNull(); expect(result).toMatchObject({ - accessToken: 'obo-access-token', + accessToken: oboAccessToken, expiresAt: expect.any(Number), scope: 'read:data write:data', issuedTokenType: 'urn:ietf:params:oauth:token-type:access_token', @@ -1405,12 +1415,13 @@ test('getTokenOnBehalfOf - should not expose idToken or refreshToken', async () clientSecret: 'my-client-secret', }); const idToken = await generateToken(domain, 'user_123', 'my-client-id'); + const oboAccessToken = await generateToken(domain, 'user_123', 'https://api.backend.com'); server.use( http.post(`https://${domain}/oauth/token`, async () => { return HttpResponse.json( { - access_token: 'obo-access-token', + access_token: oboAccessToken, expires_in: 3600, token_type: 'Bearer', issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', @@ -1428,7 +1439,7 @@ test('getTokenOnBehalfOf - should not expose idToken or refreshToken', async () expect(result).not.toHaveProperty('idToken'); expect(result).not.toHaveProperty('refreshToken'); - expect(result.accessToken).toBe('obo-access-token'); + expect(result.accessToken).toBe(oboAccessToken); }); test('getTokenOnBehalfOf - should handle exchange errors', async () => { From 1ef94d5d819047f4b71bdc2ac4359742e717f460 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Wed, 3 Jun 2026 17:14:19 +0530 Subject: [PATCH 5/9] fix actual access token generation for mfa specs --- .../auth0-auth-js/src/mfa/mfa-client.spec.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/auth0-auth-js/src/mfa/mfa-client.spec.ts b/packages/auth0-auth-js/src/mfa/mfa-client.spec.ts index 7ea05cf..55a3008 100644 --- a/packages/auth0-auth-js/src/mfa/mfa-client.spec.ts +++ b/packages/auth0-auth-js/src/mfa/mfa-client.spec.ts @@ -495,16 +495,24 @@ describe('MfaClient', () => { describe('verify', () => { let idToken: string; + let mfaAccessToken: string; + let oobAccessToken: string; + let recoveryAccessToken: string; + let genericAccessToken: string; beforeAll(async () => { idToken = await generateToken(domain, 'user|123', clientId); + mfaAccessToken = await generateToken(domain, 'user|123', clientId); + oobAccessToken = await generateToken(domain, 'user|123', clientId); + recoveryAccessToken = await generateToken(domain, 'user|123', clientId); + genericAccessToken = await generateToken(domain, 'user|123', clientId); }); test('should verify OTP and return TokenResponse', async () => { server.use( http.post(`https://${domain}/oauth/token`, async () => HttpResponse.json({ - access_token: 'mfa_access_token', + access_token: mfaAccessToken, id_token: idToken, refresh_token: 'mfa_refresh_token', token_type: 'Bearer', @@ -517,7 +525,7 @@ describe('MfaClient', () => { const client = new MfaClient({ domain, clientId, getConfiguration: makeGetConfiguration(domain, clientId) }); const result = await client.verify({ mfaToken, factorType: 'otp', otp: '123456' }); - expect(result.accessToken).toBe('mfa_access_token'); + expect(result.accessToken).toBe(mfaAccessToken); expect(result.idToken).toBe(idToken); expect(result.refreshToken).toBe('mfa_refresh_token'); expect(result.tokenType).toBe('bearer'); @@ -530,7 +538,7 @@ describe('MfaClient', () => { server.use( http.post(`https://${domain}/oauth/token`, async () => HttpResponse.json({ - access_token: 'oob_access_token', + access_token: oobAccessToken, id_token: idToken, token_type: 'Bearer', expires_in: 86400, @@ -541,14 +549,14 @@ describe('MfaClient', () => { const client = new MfaClient({ domain, clientId, getConfiguration: makeGetConfiguration(domain, clientId) }); const result = await client.verify({ mfaToken, factorType: 'oob', oobCode: 'oob_123' }); - expect(result.accessToken).toBe('oob_access_token'); + expect(result.accessToken).toBe(oobAccessToken); }); test('should verify recovery-code and set recoveryCode on TokenResponse', async () => { server.use( http.post(`https://${domain}/oauth/token`, async () => HttpResponse.json({ - access_token: 'recovery_access_token', + access_token: recoveryAccessToken, id_token: idToken, token_type: 'Bearer', expires_in: 86400, @@ -560,7 +568,7 @@ describe('MfaClient', () => { const client = new MfaClient({ domain, clientId, getConfiguration: makeGetConfiguration(domain, clientId) }); const result = await client.verify({ mfaToken, factorType: 'recovery-code', recoveryCode: 'OLD_CODE' }); - expect(result.accessToken).toBe('recovery_access_token'); + expect(result.accessToken).toBe(recoveryAccessToken); expect(result.recoveryCode).toBe('NEW_RECOVERY_CODE'); }); @@ -571,7 +579,7 @@ describe('MfaClient', () => { http.post(`https://${domain}/oauth/token`, async ({ request }) => { capturedBody = await request.formData(); return HttpResponse.json({ - access_token: 'token', + access_token: genericAccessToken, token_type: 'Bearer', expires_in: 86400, }); @@ -592,7 +600,7 @@ describe('MfaClient', () => { server.use( http.post(`https://${domain}/oauth/token`, async ({ request }) => { capturedBody = await request.formData(); - return HttpResponse.json({ access_token: 'token', token_type: 'Bearer', expires_in: 86400 }); + return HttpResponse.json({ access_token: genericAccessToken, token_type: 'Bearer', expires_in: 86400 }); }) ); @@ -610,7 +618,7 @@ describe('MfaClient', () => { server.use( http.post(`https://${domain}/oauth/token`, async ({ request }) => { capturedBody = await request.formData(); - return HttpResponse.json({ access_token: 'token', token_type: 'Bearer', expires_in: 86400 }); + return HttpResponse.json({ access_token: genericAccessToken, token_type: 'Bearer', expires_in: 86400 }); }) ); From 0959214d8b16505585d565496037dc24fd5ce1db Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 8 Jun 2026 17:06:34 +0530 Subject: [PATCH 6/9] fix: take opaque token into account and move the act claim populatiob to profileexchangetoken method --- packages/auth0-auth-js/src/auth-client.ts | 16 ++++++++++++++-- packages/auth0-auth-js/src/types.ts | 3 --- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/auth0-auth-js/src/auth-client.ts b/packages/auth0-auth-js/src/auth-client.ts index fc19e56..64e71dd 100644 --- a/packages/auth0-auth-js/src/auth-client.ts +++ b/packages/auth0-auth-js/src/auth-client.ts @@ -1,5 +1,5 @@ import * as client from 'openid-client'; -import { createRemoteJWKSet, importPKCS8, jwtVerify, customFetch, jwksCache } from 'jose'; +import { createRemoteJWKSet, importPKCS8, jwtVerify, customFetch, jwksCache, decodeJwt } from 'jose'; import type { JWKSCacheInput } from 'jose'; import { BackchannelAuthenticationError, @@ -40,6 +40,7 @@ import { TokenByRefreshTokenOptions, TokenForConnectionOptions, TokenResponse, + ActClaim, VerifyLogoutTokenOptions, VerifyLogoutTokenResult, } from './types.js'; @@ -806,7 +807,18 @@ export class AuthClient { tokenRequestParams ); - return TokenResponse.fromTokenEndpointResponse(tokenEndpointResponse); + const tokenResponse = TokenResponse.fromTokenEndpointResponse(tokenEndpointResponse); + const idTokenClaims = tokenEndpointResponse.id_token ? tokenEndpointResponse.claims() : undefined; + if (idTokenClaims?.act) { + tokenResponse.act = idTokenClaims.act as ActClaim; + } else { + try { + tokenResponse.act = decodeJwt(tokenEndpointResponse.access_token).act as ActClaim | undefined; + } catch { + // opaque access token — act claim not available + } + } + return tokenResponse; } catch (e) { throw new TokenExchangeError( `Failed to exchange token of type '${options.subjectTokenType}'${options.audience ? ` for audience '${options.audience}'` : ''}.`, diff --git a/packages/auth0-auth-js/src/types.ts b/packages/auth0-auth-js/src/types.ts index 8152ada..016c05d 100644 --- a/packages/auth0-auth-js/src/types.ts +++ b/packages/auth0-auth-js/src/types.ts @@ -1,5 +1,4 @@ import { IDToken, TokenEndpointResponse, TokenEndpointResponseHelpers } from 'openid-client'; -import { decodeJwt } from 'jose'; import type { TelemetryConfig } from './telemetry.js'; export type { TelemetryConfig } from './telemetry.js'; @@ -686,8 +685,6 @@ export class TokenResponse { tokenResponse.tokenType = response.token_type; tokenResponse.issuedTokenType = (response as typeof response & { issued_token_type?: string }).issued_token_type; - const atClaims = decodeJwt(response.access_token); - tokenResponse.act = (claims?.act ?? atClaims.act) as ActClaim | undefined; return tokenResponse; } From 2246cfd14fd3d56ecf607e172befcb666d28615e Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 8 Jun 2026 17:31:54 +0530 Subject: [PATCH 7/9] fix: remove URI syntax validation for tokentypes and re add the actor_toke and actor_token_type to param deny list --- .../auth0-auth-js/src/auth-client.spec.ts | 44 ------------------- packages/auth0-auth-js/src/auth-client.ts | 20 ++------- 2 files changed, 4 insertions(+), 60 deletions(-) diff --git a/packages/auth0-auth-js/src/auth-client.spec.ts b/packages/auth0-auth-js/src/auth-client.spec.ts index 39b9665..354e20e 100644 --- a/packages/auth0-auth-js/src/auth-client.spec.ts +++ b/packages/auth0-auth-js/src/auth-client.spec.ts @@ -3013,26 +3013,6 @@ describe('exchangeToken — actor token support', () => { }); }); - test('should throw TokenExchangeError for invalid token type URIs', async () => { - const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); - - await expect( - authClient.exchangeToken({ ...baseOptions, subjectTokenType: 'not a valid uri' }) - ).rejects.toMatchObject({ - name: 'TokenExchangeError', - code: 'token_exchange_error', - message: 'subjectTokenType must be a valid URI', - }); - - await expect( - authClient.exchangeToken({ ...baseOptions, actorToken: 'actor-token-abc', actorTokenType: 'not a valid uri' }) - ).rejects.toMatchObject({ - name: 'TokenExchangeError', - code: 'token_exchange_error', - message: 'actorTokenType must be a valid URI', - }); - }); - test('should expose act claim from access token when no id_token is returned (M2M delegation)', async () => { const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); @@ -3058,30 +3038,6 @@ describe('exchangeToken — actor token support', () => { expect(result.act?.sub).toBe('service-account-123'); }); - test('should accept valid URI formats for subjectTokenType and actorTokenType', async () => { - const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); - - server.use( - http.post(mockOpenIdConfiguration.token_endpoint, async () => { - return HttpResponse.json({ - access_token: accessToken, - id_token: await generateToken(domain, 'user_cte', ''), - expires_in: 3600, - token_type: 'Bearer', - scope: 'read:default', - }); - }) - ); - - await expect( - authClient.exchangeToken({ - subjectToken: 'subject-token-123', - subjectTokenType: 'http://acme.com/token-type', - actorToken: 'actor-token', - actorTokenType: 'http://acme.com/actor-type', - }) - ).resolves.toBeDefined(); - }); }); describe('Telemetry', () => { diff --git a/packages/auth0-auth-js/src/auth-client.ts b/packages/auth0-auth-js/src/auth-client.ts index 64e71dd..905f7fe 100644 --- a/packages/auth0-auth-js/src/auth-client.ts +++ b/packages/auth0-auth-js/src/auth-client.ts @@ -78,6 +78,8 @@ const MAX_ARRAY_VALUES_PER_KEY = 20; * credentials must be managed through configuration, not request parameters * - subject_token, subject_token_type: Core token exchange parameters, overriding creates * ambiguity about which token is being exchanged + * - actor_token, actor_token_type: Actor token parameters for delegation exchanges, must use + * explicit typed parameters to ensure correct delegation semantics * - requested_token_type: Determines the type of token returned, must be explicit * - audience, aud, resource, resources, resource_indicator: Target API parameters must use * explicit API parameters to prevent confusion about precedence and ensure correct routing @@ -101,6 +103,8 @@ const PARAM_DENYLIST = Object.freeze( 'subject_token', 'subject_token_type', 'requested_token_type', + 'actor_token', + 'actor_token_type', 'audience', 'aud', 'resource', @@ -159,18 +163,6 @@ function toOAuth2Error(e: unknown): OAuth2Error { return base; } -/** - * Validates that a token type value is a syntactically valid URI. - * Semantic validation (reserved namespaces) is enforced by the Auth0 server. - */ -function validateTokenTypeUri(value: string, paramName: string): void { - try { - new URL(value); - } catch { - throw new TokenExchangeError(`${paramName} must be a valid URI`); - } -} - /** * Appends extra parameters to URLSearchParams while enforcing security constraints. */ @@ -767,14 +759,10 @@ export class AuthClient { const { configuration } = await this.#discover(); validateSubjectToken(options.subjectToken); - validateTokenTypeUri(options.subjectTokenType, 'subjectTokenType'); if (options.actorToken !== undefined && options.actorTokenType === undefined) { throw new TokenExchangeError('actorTokenType is required when actorToken is provided'); } - if (options.actorTokenType !== undefined) { - validateTokenTypeUri(options.actorTokenType, 'actorTokenType'); - } const tokenRequestParams = new URLSearchParams({ subject_token_type: options.subjectTokenType, From a8c643a214585fcdfef339316d53df13560c279a Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 8 Jun 2026 17:35:37 +0530 Subject: [PATCH 8/9] sync types doc with runtime behavior for act claim --- packages/auth0-auth-js/src/types.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/auth0-auth-js/src/types.ts b/packages/auth0-auth-js/src/types.ts index 016c05d..6de257f 100644 --- a/packages/auth0-auth-js/src/types.ts +++ b/packages/auth0-auth-js/src/types.ts @@ -633,10 +633,12 @@ export class TokenResponse { recoveryCode?: string; /** - * The actor claim from the ID token (RFC 8693). + * The actor claim from a delegation token exchange (RFC 8693). * - * Present in delegation exchanges when an `actorToken` was provided. Identifies - * the acting party on whose behalf the subject token was exchanged. + * Present when an `actorToken` was provided. Sourced from the ID token when + * one is issued, or from the JWT access token in M2M flows where no ID token + * is returned. Identifies the acting party on whose behalf the subject token + * was exchanged. * * @see {@link https://www.rfc-editor.org/rfc/rfc8693#section-4.1 RFC 8693 Section 4.1} */ From c44ba1d661c81cf543c016ac1533179769150999 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Mon, 8 Jun 2026 17:49:05 +0530 Subject: [PATCH 9/9] add examples for Custom token exchange and unit tests for opaque access token --- packages/auth0-auth-js/EXAMPLES.md | 103 ++++++++++++++++++ .../auth0-auth-js/src/auth-client.spec.ts | 45 ++++++++ 2 files changed, 148 insertions(+) diff --git a/packages/auth0-auth-js/EXAMPLES.md b/packages/auth0-auth-js/EXAMPLES.md index 2e0608b..87174a1 100644 --- a/packages/auth0-auth-js/EXAMPLES.md +++ b/packages/auth0-auth-js/EXAMPLES.md @@ -38,6 +38,12 @@ - [Requesting a Login Challenge](#requesting-a-login-challenge) - [Exchanging a Credential for Tokens](#exchanging-a-credential-for-tokens) - [Error Handling](#error-handling) +- [Custom Token Exchange](#custom-token-exchange) + - [Basic Exchange](#basic-exchange) + - [Delegation Exchange with Actor Token](#delegation-exchange-with-actor-token) + - [Reading the act Claim](#reading-the-act-claim) + - [M2M Delegation (No ID Token)](#m2m-delegation-no-id-token) + - [Error Handling](#error-handling-1) ## Configuration @@ -1072,3 +1078,100 @@ try { } } ``` + +## Custom Token Exchange + +`exchangeToken` implements [RFC 8693 Token Exchange](https://www.rfc-editor.org/rfc/rfc8693) via an Auth0 Token Exchange Profile. It lets you swap a token issued by an external system (an MCP server, a legacy IdP, a partner service) for Auth0 tokens, preserving the user's identity. + +### Basic Exchange + +```ts +import { AuthClient } from '@auth0/auth0-auth-js'; + +const authClient = new AuthClient({ + domain: 'your-tenant.auth0.com', + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', +}); + +const tokens = await authClient.exchangeToken({ + subjectToken: externalToken, + subjectTokenType: 'urn:acme:legacy-token', + audience: 'https://api.example.com', + scope: 'openid profile read:data', +}); + +console.log(tokens.accessToken); +``` + +### Delegation Exchange with Actor Token + +When an intermediate service acts on behalf of a user, pass the service's own token as `actorToken`. Both `actorToken` and `actorTokenType` must be provided together. + +```ts +const tokens = await authClient.exchangeToken({ + subjectToken: userToken, + subjectTokenType: 'urn:ietf:params:oauth:token-type:access_token', + actorToken: serviceAccountToken, + actorTokenType: 'urn:ietf:params:oauth:token-type:access_token', + audience: 'https://api.example.com', +}); +``` + +### Reading the act Claim + +When a delegation exchange succeeds, the `act` claim on `TokenResponse` identifies the acting party. It is sourced from the ID token when one is issued, or from the JWT access token in M2M flows where no ID token is returned. + +```ts +const tokens = await authClient.exchangeToken({ + subjectToken: userToken, + subjectTokenType: 'urn:acme:user-token', + actorToken: serviceToken, + actorTokenType: 'urn:acme:service-token', + audience: 'https://api.example.com', + scope: 'openid', +}); + +if (tokens.act) { + console.log(tokens.act.sub); // Subject of the acting party + console.log(tokens.act.iss); // Optional issuer of the actor token +} +``` + +### M2M Delegation (No ID Token) + +In machine-to-machine flows the `openid` scope is not requested, so no ID token is issued. The SDK automatically falls back to reading the `act` claim from the JWT access token. If the access token is opaque, `act` will be `undefined`. + +```ts +const tokens = await authClient.exchangeToken({ + subjectToken: serviceAToken, + subjectTokenType: 'urn:acme:service-token', + actorToken: serviceBToken, + actorTokenType: 'urn:acme:service-token', + audience: 'https://api.example.com', + // no 'openid' in scope — no id_token will be returned +}); + +// act is populated from the JWT access token if it carries the claim +console.log(tokens.act?.sub); +``` + +### Error Handling + +```ts +import { AuthClient, TokenExchangeError } from '@auth0/auth0-auth-js'; + +try { + const tokens = await authClient.exchangeToken({ + subjectToken: externalToken, + subjectTokenType: 'urn:acme:legacy-token', + audience: 'https://api.example.com', + }); +} catch (error) { + if (error instanceof TokenExchangeError) { + console.error(error.message); // Human-readable error message + console.error(error.code); // 'token_exchange_error' + console.error(error.cause?.error); // e.g., 'invalid_grant', 'access_denied' + } +} +``` diff --git a/packages/auth0-auth-js/src/auth-client.spec.ts b/packages/auth0-auth-js/src/auth-client.spec.ts index 354e20e..17d55d5 100644 --- a/packages/auth0-auth-js/src/auth-client.spec.ts +++ b/packages/auth0-auth-js/src/auth-client.spec.ts @@ -3013,6 +3013,51 @@ describe('exchangeToken — actor token support', () => { }); }); + test('should not set act when access token is opaque and no id_token is returned', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async () => { + return HttpResponse.json({ + access_token: 'opaque-access-token-string', + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:data', + }); + }) + ); + + const result = await authClient.exchangeToken(baseOptions); + + expect(result.act).toBeUndefined(); + }); + + test('should prefer act from id_token over act from access token when both are present', async () => { + const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' }); + + const accessTokenWithAct = await generateToken(domain, 'user_cte', 'https://api.example.com', undefined, undefined, undefined, { + act: { sub: 'at-actor' }, + }); + + server.use( + http.post(mockOpenIdConfiguration.token_endpoint, async () => { + return HttpResponse.json({ + access_token: accessTokenWithAct, + id_token: await generateToken(domain, 'user_cte', '', undefined, undefined, undefined, { + act: { sub: 'id-token-actor' }, + }), + expires_in: 3600, + token_type: 'Bearer', + scope: 'read:default', + }); + }) + ); + + const result = await authClient.exchangeToken(baseOptions); + + expect(result.act?.sub).toBe('id-token-actor'); + }); + test('should expose act claim from access token when no id_token is returned (M2M delegation)', async () => { const authClient = new AuthClient({ domain, clientId: '', clientSecret: '' });