From cc85b4ef56e6cd51067ad9a99ea49d0ad38ec8db Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Wed, 15 Jul 2026 12:47:15 +0530 Subject: [PATCH 1/5] feat: add actor token support for Custom Token Exchange impersonation & delegation --- EXAMPLES.md | 40 ++++++++++++++ .../java/com/auth0/react/A0Auth0Module.kt | 16 ++++-- ios/NativeBridge.swift | 14 +++-- src/core/models/__tests__/Auth0User.spec.ts | 28 ++++++++++ src/core/utils/__tests__/validation.spec.ts | 33 +++++++++++- src/core/utils/validation.ts | 28 ++++++++++ .../native/adapters/NativeAuth0Client.ts | 21 ++++++-- .../__tests__/NativeAuth0Client.spec.ts | 47 ++++++++++++++++- src/platforms/native/bridge/INativeBridge.ts | 6 ++- .../native/bridge/NativeBridgeManager.ts | 8 ++- .../__tests__/NativeBridgeManager.spec.ts | 32 +++++++++++- src/platforms/web/adapters/WebAuth0Client.ts | 18 +++++-- .../adapters/__tests__/WebAuth0Client.spec.ts | 52 +++++++++++++++++++ src/specs/NativeA0Auth0.ts | 4 +- src/types/common.ts | 6 +++ src/types/parameters.ts | 23 ++++++++ 16 files changed, 355 insertions(+), 21 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index feb0aefca..8d9478e78 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -45,6 +45,7 @@ - [Using Custom Token Exchange with Hooks](#using-custom-token-exchange-with-hooks) - [Using Custom Token Exchange with Auth0 Class](#using-custom-token-exchange-with-auth0-class) - [With Organization Context](#with-organization-context) + - [Delegation & Impersonation (Actor Token)](#delegation--impersonation-actor-token) - [Subject Token Type Requirements](#subject-token-type-requirements) - [Valid Token Type Patterns](#valid-token-type-patterns) - [Reserved Namespaces (Forbidden)](#reserved-namespaces-forbidden) @@ -1061,6 +1062,45 @@ const credentials = await customTokenExchange({ }); ``` +### Delegation & Impersonation (Actor Token) + +For delegation and impersonation scenarios — where an actor (such as an AI agent, support representative, or service) acts on behalf of the subject — pass an actor token alongside the subject token. This follows [RFC 8693 Section 2.1](https://datatracker.ietf.org/doc/html/rfc8693#section-2.1). + +```typescript +const credentials = await customTokenExchange({ + subjectToken: 'subject-provider-token', + subjectTokenType: 'urn:acme:legacy-system-token', + actorToken: 'actor-id-token', + actorTokenType: 'http://corporate-idp/id-token', +}); +``` + +`actorTokenType` follows the same URI validation rules as `subjectTokenType` and accepts any developer-defined URI. + +> ℹ️ **Prerequisites**: This flow requires the `cte_actor_token` feature flag enabled on your tenant and an [Auth0 Action](https://auth0.com/docs/customize/actions) that calls `api.authentication.setActor()` to set the `act` claim on the issued tokens. + +**Accessing the `act` claim** + +When an Action sets the actor, the issued ID token carries an `act` claim describing the acting party (which may be nested to represent a delegation chain). It is available on the parsed user profile: + +```typescript +import { parseIdToken } from 'react-native-auth0'; + +const credentials = await customTokenExchange({ + subjectToken: 'subject-provider-token', + subjectTokenType: 'urn:acme:legacy-system-token', + actorToken: 'actor-id-token', + actorTokenType: 'http://corporate-idp/id-token', +}); + +const user = parseIdToken(credentials.idToken); +console.log(user.act); // The acting party claim +``` + +> ⚠️ **Refresh token suppression**: When an actor token is present, Auth0 will **not** issue a refresh token, regardless of whether `offline_access` is in the requested scope. `credentials.refreshToken` will be `undefined` in this case. +> +> ⚠️ **Paired parameters**: `actorToken` and `actorTokenType` must be provided together. Supplying only one throws an `AuthError` with code `invalid_actor_token_parameters` before any network request is made. + ### Subject Token Type Requirements The `subjectTokenType` parameter must be a **unique profile token type URI** starting with `https://` or `urn:`. diff --git a/android/src/main/java/com/auth0/react/A0Auth0Module.kt b/android/src/main/java/com/auth0/react/A0Auth0Module.kt index e19a0d4c1..e034a2698 100644 --- a/android/src/main/java/com/auth0/react/A0Auth0Module.kt +++ b/android/src/main/java/com/auth0/react/A0Auth0Module.kt @@ -10,6 +10,7 @@ import androidx.lifecycle.LifecycleOwner import com.auth0.android.Auth0 import com.auth0.android.authentication.AuthenticationAPIClient import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.authentication.request.ActorToken import com.auth0.android.authentication.storage.CredentialsManagerException import com.auth0.android.authentication.storage.LocalAuthenticationOptions import com.auth0.android.authentication.storage.SecureCredentialsManager @@ -620,19 +621,28 @@ class A0Auth0Module(private val reactContext: ReactApplicationContext) : A0Auth0 audience: String?, scope: String?, organization: String?, + actorToken: String?, + actorTokenType: String?, promise: Promise ) { val authClient = AuthenticationAPIClient(auth0!!) if (useDPoP) { authClient.useDPoP(reactContext) } - + val finalScope = scope ?: "openid profile email" - + + val actor = if (actorToken != null && actorTokenType != null) { + ActorToken(token = actorToken, tokenType = actorTokenType) + } else { + null + } + val request = authClient.customTokenExchange( subjectTokenType = subjectTokenType, subjectToken = subjectToken, - organization = organization + organization = organization, + actorToken = actor ) // Set audience and scope using the request builder methods diff --git a/ios/NativeBridge.swift b/ios/NativeBridge.swift index d86d63934..6fe1d47a6 100644 --- a/ios/NativeBridge.swift +++ b/ios/NativeBridge.swift @@ -390,20 +390,26 @@ public class NativeBridge: NSObject { resolve(credentialsManager.clear(forAudience: audience, scope: scope)) } - @objc public func customTokenExchange(subjectToken: String, subjectTokenType: String, audience: String?, scope: String?, organization: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { + @objc public func customTokenExchange(subjectToken: String, subjectTokenType: String, audience: String?, scope: String?, organization: String?, actorToken: String?, actorTokenType: String?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) { var auth = Auth0.authentication(clientId: self.clientId, domain: self.domain) if self.useDPoP { auth = auth.useDPoP() } - + let finalScope = scope ?? "openid profile email" - + + var actor: ActorToken? + if let actorToken = actorToken, let actorTokenType = actorTokenType { + actor = ActorToken(token: actorToken, tokenType: actorTokenType) + } + auth.customTokenExchange( subjectToken: subjectToken, subjectTokenType: subjectTokenType, audience: audience, scope: finalScope, - organization: organization + organization: organization, + actorToken: actor ).start { result in switch result { case .success(let credentials): diff --git a/src/core/models/__tests__/Auth0User.spec.ts b/src/core/models/__tests__/Auth0User.spec.ts index fe7e033e1..d350c94b2 100644 --- a/src/core/models/__tests__/Auth0User.spec.ts +++ b/src/core/models/__tests__/Auth0User.spec.ts @@ -103,6 +103,34 @@ describe('Auth0User', () => { expect(user.name).toBe('John Doe'); }); + it('should preserve the nested "act" claim from a delegation/impersonation flow', () => { + const idTokenPayload = { + sub: 'auth0|impersonated-user', + name: 'Impersonated User', + // RFC 8693 acting-party claim, nested to represent a delegation chain. + act: { + sub: 'auth0|acting-party', + act: { + sub: 'auth0|original-actor', + }, + }, + }; + mockJwtDecode.mockReturnValue(idTokenPayload); + + const user = Auth0User.fromIdToken('a-mock-id-token'); + + expect(user.act).toEqual({ + sub: 'auth0|acting-party', + act: { + sub: 'auth0|original-actor', + }, + }); + // The claim must pass through untouched (no camel-casing, no stripping). + expect(user.act.sub).toBe('auth0|acting-party'); + expect(user.act.act.sub).toBe('auth0|original-actor'); + expect(user.sub).toBe('auth0|impersonated-user'); + }); + it('should throw an error if the ID token is missing the "sub" claim', () => { const invalidPayload = { name: 'John Doe', diff --git a/src/core/utils/__tests__/validation.spec.ts b/src/core/utils/__tests__/validation.spec.ts index e3515b028..37eb8133f 100644 --- a/src/core/utils/__tests__/validation.spec.ts +++ b/src/core/utils/__tests__/validation.spec.ts @@ -1,6 +1,37 @@ -import { validateAuth0Options } from '../validation'; +import { + validateAuth0Options, + validateActorTokenParameters, +} from '../validation'; import { AuthError } from '../../models'; +describe('validateActorTokenParameters', () => { + it('should not throw when neither parameter is provided', () => { + expect(() => validateActorTokenParameters()).not.toThrow(); + }); + + it('should not throw when both parameters are provided', () => { + expect(() => + validateActorTokenParameters('actor-token', 'urn:token-type') + ).not.toThrow(); + }); + + it('should throw when only actorToken is provided', () => { + expect(() => validateActorTokenParameters('actor-token')).toThrow( + AuthError + ); + }); + + it('should throw with the invalid_actor_token_parameters code', () => { + try { + validateActorTokenParameters(undefined, 'urn:token-type'); + throw new Error('Expected validateActorTokenParameters to throw'); + } catch (e) { + expect(e).toBeInstanceOf(AuthError); + expect((e as AuthError).code).toBe('invalid_actor_token_parameters'); + } + }); +}); + describe('validateAuth0Options', () => { const validOptions = { domain: 'my-tenant.auth0.com', diff --git a/src/core/utils/validation.ts b/src/core/utils/validation.ts index 75d6bfe1c..23939cb52 100644 --- a/src/core/utils/validation.ts +++ b/src/core/utils/validation.ts @@ -78,3 +78,31 @@ export function validateParameters( ); } } + +/** + * Validates that actor token parameters used in a Custom Token Exchange + * delegation/impersonation flow are provided as a pair. + * + * Auth0 requires both `actor_token` and `actor_token_type` to be present when + * performing delegation. This surfaces a clear client-side error before the + * network request is made rather than relying on the server response. + * + * @param actorToken The actor token, if provided. + * @param actorTokenType The actor token type URI, if provided. + * @throws {AuthError} If exactly one of the two parameters is provided. + */ +export function validateActorTokenParameters( + actorToken?: string, + actorTokenType?: string +): void { + const hasToken = actorToken !== undefined && actorToken !== null; + const hasType = actorTokenType !== undefined && actorTokenType !== null; + + if (hasToken !== hasType) { + throw new AuthError( + 'InvalidParameters', + 'actorToken and actorTokenType must both be provided together for a delegation token exchange.', + { code: 'invalid_actor_token_parameters' } + ); + } +} diff --git a/src/platforms/native/adapters/NativeAuth0Client.ts b/src/platforms/native/adapters/NativeAuth0Client.ts index b8152fc6e..b251001e1 100644 --- a/src/platforms/native/adapters/NativeAuth0Client.ts +++ b/src/platforms/native/adapters/NativeAuth0Client.ts @@ -29,7 +29,10 @@ import { import { HttpClient } from '../../../core/services/HttpClient'; import { TokenType } from '../../../types/common'; import { AuthError, DPoPError, PasskeyError } from '../../../core/models'; -import { getConfigSignature } from '../../../core/utils'; +import { + getConfigSignature, + validateActorTokenParameters, +} from '../../../core/utils'; export class NativeAuth0Client implements IAuth0Client { readonly webAuth: NativeWebAuthProvider; @@ -230,14 +233,24 @@ export class NativeAuth0Client implements IAuth0Client { async customTokenExchange( parameters: CustomTokenExchangeParameters ): Promise { - const { subjectToken, subjectTokenType, audience, scope, organization } = - parameters; + const { + subjectToken, + subjectTokenType, + audience, + scope, + organization, + actorToken, + actorTokenType, + } = parameters; + validateActorTokenParameters(actorToken, actorTokenType); return this.guardedBridge.customTokenExchange( subjectToken, subjectTokenType, audience, scope, - organization + organization, + actorToken, + actorTokenType ); } diff --git a/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts b/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts index 1378c5d52..59f380f31 100644 --- a/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts +++ b/src/platforms/native/adapters/__tests__/NativeAuth0Client.spec.ts @@ -237,7 +237,9 @@ describe('NativeAuth0Client', () => { 'urn:acme:legacy-token', 'https://api.example.com', 'openid profile email', - 'org_123' + 'org_123', + undefined, + undefined ); }); @@ -259,10 +261,53 @@ describe('NativeAuth0Client', () => { 'urn:acme:legacy-token', undefined, undefined, + undefined, + undefined, undefined ); }); + it('should forward actor token parameters to the bridge when provided', async () => { + const client = new NativeAuth0Client(options); + await new Promise(process.nextTick); + + await client.customTokenExchange({ + subjectToken: 'external-token', + subjectTokenType: 'urn:acme:legacy-token', + actorToken: 'actor-token', + actorTokenType: 'http://corporate-idp/id-token', + }); + + expect( + (mockBridgeInstance as any).customTokenExchange + ).toHaveBeenCalledWith( + 'external-token', + 'urn:acme:legacy-token', + undefined, + undefined, + undefined, + 'actor-token', + 'http://corporate-idp/id-token' + ); + }); + + it('should throw before calling the bridge when only actorToken is provided', async () => { + const client = new NativeAuth0Client(options); + await new Promise(process.nextTick); + + await expect( + client.customTokenExchange({ + subjectToken: 'external-token', + subjectTokenType: 'urn:acme:legacy-token', + actorToken: 'actor-token', + }) + ).rejects.toMatchObject({ code: 'invalid_actor_token_parameters' }); + + expect( + (mockBridgeInstance as any).customTokenExchange + ).not.toHaveBeenCalled(); + }); + it('should return credentials from customTokenExchange', async () => { const client = new NativeAuth0Client(options); await new Promise(process.nextTick); diff --git a/src/platforms/native/bridge/INativeBridge.ts b/src/platforms/native/bridge/INativeBridge.ts index 40236a626..bb4e7f35a 100644 --- a/src/platforms/native/bridge/INativeBridge.ts +++ b/src/platforms/native/bridge/INativeBridge.ts @@ -209,6 +209,8 @@ export interface INativeBridge { * @param audience Optional target API identifier. * @param scope Optional space-separated scopes. * @param organization Optional organization ID or name. + * @param actorToken Optional actor token for delegation/impersonation flows. + * @param actorTokenType Optional actor token type URI. Required when actorToken is provided. * @returns A promise that resolves with Auth0 credentials. */ customTokenExchange( @@ -216,7 +218,9 @@ export interface INativeBridge { subjectTokenType: string, audience?: string, scope?: string, - organization?: string + organization?: string, + actorToken?: string, + actorTokenType?: string ): Promise; /** diff --git a/src/platforms/native/bridge/NativeBridgeManager.ts b/src/platforms/native/bridge/NativeBridgeManager.ts index 25dcbbdfb..3e74079e6 100644 --- a/src/platforms/native/bridge/NativeBridgeManager.ts +++ b/src/platforms/native/bridge/NativeBridgeManager.ts @@ -247,7 +247,9 @@ export class NativeBridgeManager implements INativeBridge { subjectTokenType: string, audience?: string, scope?: string, - organization?: string + organization?: string, + actorToken?: string, + actorTokenType?: string ): Promise { try { const credential = await this.a0_call( @@ -256,7 +258,9 @@ export class NativeBridgeManager implements INativeBridge { subjectTokenType, audience, scope, - organization + organization, + actorToken, + actorTokenType ); return new CredentialsModel(credential); } catch (e) { diff --git a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts index 1de957eb7..2f508ea1c 100644 --- a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts +++ b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts @@ -432,7 +432,35 @@ describe('NativeBridgeManager', () => { 'urn:acme:legacy-token', 'https://api.example.com', 'openid profile email', - 'org_123' + 'org_123', + undefined, + undefined + ); + }); + + it('should forward actor token parameters to the native module', async () => { + MockedAuth0NativeModule.customTokenExchange.mockResolvedValueOnce( + mockExchangeResponse as any + ); + + await bridge.customTokenExchange( + 'external-token', + 'urn:acme:legacy-token', + undefined, + undefined, + undefined, + 'actor-token', + 'http://corporate-idp/id-token' + ); + + expect(MockedAuth0NativeModule.customTokenExchange).toHaveBeenCalledWith( + 'external-token', + 'urn:acme:legacy-token', + undefined, + undefined, + undefined, + 'actor-token', + 'http://corporate-idp/id-token' ); }); @@ -451,6 +479,8 @@ describe('NativeBridgeManager', () => { 'urn:acme:legacy-token', undefined, undefined, + undefined, + undefined, undefined ); }); diff --git a/src/platforms/web/adapters/WebAuth0Client.ts b/src/platforms/web/adapters/WebAuth0Client.ts index 10efa9796..538549bd0 100644 --- a/src/platforms/web/adapters/WebAuth0Client.ts +++ b/src/platforms/web/adapters/WebAuth0Client.ts @@ -34,6 +34,7 @@ import { import { HttpClient } from '../../../core/services/HttpClient'; import { TokenType } from '../../../types/common'; import { AuthError, DPoPError, PasskeyError } from '../../../core/models'; +import { validateActorTokenParameters } from '../../../core/utils'; export class WebAuth0Client implements IAuth0Client { readonly webAuth: WebWebAuthProvider; @@ -239,10 +240,19 @@ export class WebAuth0Client implements IAuth0Client { async customTokenExchange( parameters: CustomTokenExchangeParameters ): Promise { - try { - const { subjectToken, subjectTokenType, audience, scope, organization } = - parameters; + const { + subjectToken, + subjectTokenType, + audience, + scope, + organization, + actorToken, + actorTokenType, + } = parameters; + validateActorTokenParameters(actorToken, actorTokenType); + + try { // Apply default scope if not provided for consistency with native platforms const finalScope = scope ?? 'openid profile email'; @@ -252,6 +262,8 @@ export class WebAuth0Client implements IAuth0Client { audience, scope: finalScope, organization, + ...(actorToken && { actor_token: actorToken }), + ...(actorTokenType && { actor_token_type: actorTokenType }), }); // Convert expiresIn (seconds from now) to expiresAt (UNIX timestamp) diff --git a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts index e1783c261..0571188c2 100644 --- a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts @@ -484,6 +484,58 @@ describe('WebAuth0Client', () => { expect(e.name).toBe('AuthenticationException'); } }); + + it('should forward actor token parameters as snake_case when provided', async () => { + await client.customTokenExchange({ + subjectToken: 'external-token', + subjectTokenType: 'urn:acme:legacy-token', + actorToken: 'actor-token', + actorTokenType: 'http://corporate-idp/id-token', + }); + + expect(mockSpaClient.loginWithCustomTokenExchange).toHaveBeenCalledWith( + expect.objectContaining({ + actor_token: 'actor-token', + actor_token_type: 'http://corporate-idp/id-token', + }) + ); + }); + + it('should not include actor token keys when not provided', async () => { + await client.customTokenExchange({ + subjectToken: 'external-token', + subjectTokenType: 'urn:acme:legacy-token', + }); + + const callArg = + mockSpaClient.loginWithCustomTokenExchange.mock.calls[0][0]; + expect(callArg).not.toHaveProperty('actor_token'); + expect(callArg).not.toHaveProperty('actor_token_type'); + }); + + it('should throw before the network call when only actorToken is provided', async () => { + await expect( + client.customTokenExchange({ + subjectToken: 'external-token', + subjectTokenType: 'urn:acme:legacy-token', + actorToken: 'actor-token', + }) + ).rejects.toMatchObject({ code: 'invalid_actor_token_parameters' }); + + expect(mockSpaClient.loginWithCustomTokenExchange).not.toHaveBeenCalled(); + }); + + it('should throw before the network call when only actorTokenType is provided', async () => { + await expect( + client.customTokenExchange({ + subjectToken: 'external-token', + subjectTokenType: 'urn:acme:legacy-token', + actorTokenType: 'http://corporate-idp/id-token', + }) + ).rejects.toMatchObject({ code: 'invalid_actor_token_parameters' }); + + expect(mockSpaClient.loginWithCustomTokenExchange).not.toHaveBeenCalled(); + }); }); describe('ssoExchange', () => { diff --git a/src/specs/NativeA0Auth0.ts b/src/specs/NativeA0Auth0.ts index 9892d64fd..72b5f6611 100644 --- a/src/specs/NativeA0Auth0.ts +++ b/src/specs/NativeA0Auth0.ts @@ -167,7 +167,9 @@ export interface Spec extends TurboModule { subjectTokenType: string, audience: string | undefined, scope: string | undefined, - organization: string | undefined + organization: string | undefined, + actorToken: string | undefined, + actorTokenType: string | undefined ): Promise; /** diff --git a/src/types/common.ts b/src/types/common.ts index b94784930..99466e2cc 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -148,6 +148,12 @@ export type User = { address?: string; /** The timestamp when the user's profile was last updated. */ updatedAt?: string; + /** + * The acting party claim (RFC 8693 Section 4.1), present when tokens are + * issued through a Custom Token Exchange delegation/impersonation flow. + * May be nested to represent a delegation chain. + */ + act?: Record; /** Allows for additional, non-standard claims in the user profile. */ [key: string]: any; }; diff --git a/src/types/parameters.ts b/src/types/parameters.ts index 5991e322a..1faec2b90 100644 --- a/src/types/parameters.ts +++ b/src/types/parameters.ts @@ -181,6 +181,29 @@ export interface CustomTokenExchangeParameters { * When provided, the organization ID will be present in the access token. */ organization?: string; + + /** + * The token representing the acting party in a delegation/impersonation + * flow (RFC 8693 Section 2.1). When provided, the issued tokens may carry + * an `act` claim describing the actor, accessible via `user.act`. + * + * If `actorToken` is provided, `actorTokenType` must also be provided. + * + * Note: when an actor token is present, Auth0 will not issue a refresh + * token regardless of whether `offline_access` is in the requested scope. + */ + actorToken?: string; + + /** + * The type identifier for the actor token. Follows the same URI validation + * rules as `subjectTokenType` and may be any developer-defined URI. + * + * If `actorTokenType` is provided, `actorToken` must also be provided. + * + * @example "urn:ietf:params:oauth:token-type:id_token" + * @example "http://corporate-idp/id-token" + */ + actorTokenType?: string; } /** From 447a97e060a701a5ff35b23c0830200b4a2ff6ef Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 16 Jul 2026 10:39:51 +0530 Subject: [PATCH 2/5] updated example and address review comment --- example/src/navigation/ClassDemoNavigator.tsx | 2 +- .../src/screens/class-based/ClassApiTests.tsx | 85 ++++++++++++++++++- .../src/screens/class-based/ClassProfile.tsx | 7 +- ios/A0Auth0.mm | 4 +- ios/NativeBridge.swift | 10 +-- .../__tests__/NativeAuth0Client.spec.ts | 8 +- .../__tests__/NativeBridgeManager.spec.ts | 8 +- src/platforms/web/adapters/WebAuth0Client.ts | 6 +- src/types/common.ts | 2 +- 9 files changed, 109 insertions(+), 23 deletions(-) diff --git a/example/src/navigation/ClassDemoNavigator.tsx b/example/src/navigation/ClassDemoNavigator.tsx index d74ade6c9..475f7c0ec 100644 --- a/example/src/navigation/ClassDemoNavigator.tsx +++ b/example/src/navigation/ClassDemoNavigator.tsx @@ -12,7 +12,7 @@ import type { Credentials } from 'react-native-auth0'; export type ClassDemoStackParamList = { ClassLogin: undefined; ClassProfile: { credentials: Credentials }; // Expects credentials to be passed after login - ClassApiTests: { accessToken: string }; // Expects an access token for API calls + ClassApiTests: { accessToken: string; idToken?: string }; // Access token for API calls; idToken prefills the CTE actor token }; const Stack = createStackNavigator(); diff --git a/example/src/screens/class-based/ClassApiTests.tsx b/example/src/screens/class-based/ClassApiTests.tsx index 212471d6e..953b9ba38 100644 --- a/example/src/screens/class-based/ClassApiTests.tsx +++ b/example/src/screens/class-based/ClassApiTests.tsx @@ -15,7 +15,7 @@ type Props = { }; const ClassApiTestsScreen = ({ route }: Props) => { - const { accessToken } = route.params; + const { accessToken, idToken } = route.params; const [result, setResult] = useState(null); const [error, setError] = useState(null); @@ -26,6 +26,16 @@ const ClassApiTestsScreen = ({ route }: Props) => { const [otp, setOtp] = useState(''); const [refreshToken, setRefreshToken] = useState(''); + // State for Custom Token Exchange (RFC 8693) + const [subjectToken, setSubjectToken] = useState(''); + const [subjectTokenType, setSubjectTokenType] = useState( + 'urn:acme:external-idp-token' + ); + const [actorToken, setActorToken] = useState(idToken ?? ''); + const [actorTokenType, setActorTokenType] = useState( + 'urn:ietf:params:oauth:token-type:id_token' + ); + const runTest = async (testFn: () => Promise, title: string) => { setError(null); setResult(null); @@ -176,6 +186,72 @@ const ClassApiTestsScreen = ({ route }: Props) => { disabled={!refreshToken} /> + +
+ + +
); @@ -215,6 +291,13 @@ const styles = StyleSheet.create({ fontWeight: 'bold', marginBottom: 12, }, + subheading: { + fontSize: 14, + fontWeight: '600', + marginTop: 8, + marginBottom: 4, + color: '#555555', + }, buttonGroup: { gap: 10, }, diff --git a/example/src/screens/class-based/ClassProfile.tsx b/example/src/screens/class-based/ClassProfile.tsx index dd83bf1d6..b98d42717 100644 --- a/example/src/screens/class-based/ClassProfile.tsx +++ b/example/src/screens/class-based/ClassProfile.tsx @@ -77,7 +77,7 @@ class ClassProfileScreen extends Component { render() { const { user, result, error, audience, webAppUrl } = this.state; - const { accessToken } = this.props.route.params.credentials; + const { accessToken, idToken } = this.props.route.params.credentials; return ( @@ -207,7 +207,10 @@ class ClassProfileScreen extends Component {
diff --git a/src/core/utils/__tests__/validation.spec.ts b/src/core/utils/__tests__/validation.spec.ts index 37eb8133f..ac785e098 100644 --- a/src/core/utils/__tests__/validation.spec.ts +++ b/src/core/utils/__tests__/validation.spec.ts @@ -22,13 +22,11 @@ describe('validateActorTokenParameters', () => { }); it('should throw with the invalid_actor_token_parameters code', () => { - try { - validateActorTokenParameters(undefined, 'urn:token-type'); - throw new Error('Expected validateActorTokenParameters to throw'); - } catch (e) { - expect(e).toBeInstanceOf(AuthError); - expect((e as AuthError).code).toBe('invalid_actor_token_parameters'); - } + expect(() => + validateActorTokenParameters(undefined, 'urn:token-type') + ).toThrow( + expect.objectContaining({ code: 'invalid_actor_token_parameters' }) + ); }); }); From 094e18c7868b2e96b9a4202baa9b9bb40b057629 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 23 Jul 2026 16:00:31 +0530 Subject: [PATCH 4/5] addressed review comments --- src/core/utils/__tests__/validation.spec.ts | 58 +++++++++++++++++++ src/core/utils/validation.ts | 38 +++++++++++- .../native/adapters/NativeAuth0Client.ts | 2 + .../native/bridge/NativeBridgeManager.ts | 8 ++- .../__tests__/NativeBridgeManager.spec.ts | 23 +++++++- src/platforms/web/adapters/WebAuth0Client.ts | 12 ++-- .../adapters/__tests__/WebAuth0Client.spec.ts | 17 ++++++ 7 files changed, 147 insertions(+), 11 deletions(-) diff --git a/src/core/utils/__tests__/validation.spec.ts b/src/core/utils/__tests__/validation.spec.ts index ac785e098..6dce1260f 100644 --- a/src/core/utils/__tests__/validation.spec.ts +++ b/src/core/utils/__tests__/validation.spec.ts @@ -1,6 +1,7 @@ import { validateAuth0Options, validateActorTokenParameters, + validateTokenTypeUri, } from '../validation'; import { AuthError } from '../../models'; @@ -28,6 +29,63 @@ describe('validateActorTokenParameters', () => { expect.objectContaining({ code: 'invalid_actor_token_parameters' }) ); }); + + it('treats an empty actorToken as not provided and fails the pairing check', () => { + expect(() => validateActorTokenParameters('', 'urn:token-type')).toThrow( + expect.objectContaining({ code: 'invalid_actor_token_parameters' }) + ); + }); + + it('treats a whitespace-only actorToken as not provided', () => { + expect(() => validateActorTokenParameters(' ', 'urn:token-type')).toThrow( + expect.objectContaining({ code: 'invalid_actor_token_parameters' }) + ); + }); + + it('does not throw when both parameters are empty (delegation not requested)', () => { + expect(() => validateActorTokenParameters('', '')).not.toThrow(); + }); + + it('throws invalid_token_type when actorTokenType is not a valid URI', () => { + expect(() => + validateActorTokenParameters('actor-token', 'not-a-uri') + ).toThrow(expect.objectContaining({ code: 'invalid_token_type' })); + }); +}); + +describe('validateTokenTypeUri', () => { + it('accepts a urn-style token type', () => { + expect(() => + validateTokenTypeUri( + 'urn:ietf:params:oauth:token-type:id_token', + 'subjectTokenType' + ) + ).not.toThrow(); + }); + + it('accepts an http(s) token type', () => { + expect(() => + validateTokenTypeUri('http://corporate-idp/id-token', 'subjectTokenType') + ).not.toThrow(); + }); + + it('throws invalid_token_type when the value has no scheme', () => { + expect(() => + validateTokenTypeUri('legacy-token', 'subjectTokenType') + ).toThrow(expect.objectContaining({ code: 'invalid_token_type' })); + }); + + it('throws invalid_token_type when the value is empty', () => { + expect(() => validateTokenTypeUri('', 'subjectTokenType')).toThrow( + expect.objectContaining({ code: 'invalid_token_type' }) + ); + }); + + it('throws invalid_token_type when the value contains whitespace', () => { + expect(() => + validateTokenTypeUri('urn:token type', 'subjectTokenType') + ).toThrow(expect.objectContaining({ code: 'invalid_token_type' })); + }); }); describe('validateAuth0Options', () => { diff --git a/src/core/utils/validation.ts b/src/core/utils/validation.ts index 23939cb52..a15dcf64f 100644 --- a/src/core/utils/validation.ts +++ b/src/core/utils/validation.ts @@ -79,6 +79,33 @@ export function validateParameters( } } +// Empty and whitespace-only strings count as not-provided. +function isProvided(value?: string | null): value is string { + return typeof value === 'string' && value.trim() !== ''; +} + +/** + * Validates that a token type identifier is a syntactically valid URI, as + * required by RFC 8693 for `subject_token_type` and `actor_token_type`. + * + * @throws {AuthError} If the value is missing or not a valid URI. + */ +export function validateTokenTypeUri( + tokenType: string | undefined, + parameterName: string +): void { + if ( + !isProvided(tokenType) || + !/^[a-zA-Z][a-zA-Z0-9+.-]*:[^\s]+$/.test(tokenType) + ) { + throw new AuthError( + 'InvalidParameters', + `${parameterName} must be a valid URI (e.g. "urn:ietf:params:oauth:token-type:id_token").`, + { code: 'invalid_token_type' } + ); + } +} + /** * Validates that actor token parameters used in a Custom Token Exchange * delegation/impersonation flow are provided as a pair. @@ -89,14 +116,15 @@ export function validateParameters( * * @param actorToken The actor token, if provided. * @param actorTokenType The actor token type URI, if provided. - * @throws {AuthError} If exactly one of the two parameters is provided. + * @throws {AuthError} If exactly one of the two parameters is provided, or if + * `actorTokenType` is provided but is not a valid URI. */ export function validateActorTokenParameters( actorToken?: string, actorTokenType?: string ): void { - const hasToken = actorToken !== undefined && actorToken !== null; - const hasType = actorTokenType !== undefined && actorTokenType !== null; + const hasToken = isProvided(actorToken); + const hasType = isProvided(actorTokenType); if (hasToken !== hasType) { throw new AuthError( @@ -105,4 +133,8 @@ export function validateActorTokenParameters( { code: 'invalid_actor_token_parameters' } ); } + + if (hasType) { + validateTokenTypeUri(actorTokenType, 'actorTokenType'); + } } diff --git a/src/platforms/native/adapters/NativeAuth0Client.ts b/src/platforms/native/adapters/NativeAuth0Client.ts index b251001e1..c1ee1a1aa 100644 --- a/src/platforms/native/adapters/NativeAuth0Client.ts +++ b/src/platforms/native/adapters/NativeAuth0Client.ts @@ -32,6 +32,7 @@ import { AuthError, DPoPError, PasskeyError } from '../../../core/models'; import { getConfigSignature, validateActorTokenParameters, + validateTokenTypeUri, } from '../../../core/utils'; export class NativeAuth0Client implements IAuth0Client { @@ -242,6 +243,7 @@ export class NativeAuth0Client implements IAuth0Client { actorToken, actorTokenType, } = parameters; + validateTokenTypeUri(subjectTokenType, 'subjectTokenType'); validateActorTokenParameters(actorToken, actorTokenType); return this.guardedBridge.customTokenExchange( subjectToken, diff --git a/src/platforms/native/bridge/NativeBridgeManager.ts b/src/platforms/native/bridge/NativeBridgeManager.ts index 3e74079e6..fe7936ced 100644 --- a/src/platforms/native/bridge/NativeBridgeManager.ts +++ b/src/platforms/native/bridge/NativeBridgeManager.ts @@ -264,9 +264,13 @@ export class NativeBridgeManager implements INativeBridge { ); return new CredentialsModel(credential); } catch (e) { - // Convert to AuthError if needed, then throw directly + // Preserve the real Auth0 code when a0_call already produced an AuthError; + // only fall back to the generic code for truly unknown errors. + if (e instanceof AuthError) { + throw e; + } throw new AuthError( - e instanceof AuthError ? e.code : 'custom_token_exchange_failed', + 'custom_token_exchange_failed', e instanceof Error ? e.message : String(e), { code: 'custom_token_exchange_failed', json: e } ); diff --git a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts index 0a2edb5ce..6151ac8f7 100644 --- a/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts +++ b/src/platforms/native/bridge/__tests__/NativeBridgeManager.spec.ts @@ -519,7 +519,28 @@ describe('NativeBridgeManager', () => { } catch (e) { const tokenExchangeError = e as AuthError; expect(tokenExchangeError.message).toBe('Token exchange failed'); - expect(tokenExchangeError.code).toBe('custom_token_exchange_failed'); + expect(tokenExchangeError.code).toBe('a0.token_exchange_failed'); + expect(tokenExchangeError.name).toBe('a0.token_exchange_failed'); + } + }); + + it('preserves a specific Auth0 error code (e.g. invalid_request) instead of flattening it', async () => { + const nativeError = { + code: 'invalid_request', + message: + '"custom_authentication" Token Exchange Profile is not allowed for the client.', + }; + MockedAuth0NativeModule.customTokenExchange.mockRejectedValue( + nativeError + ); + + try { + await bridge.customTokenExchange('bad-token', 'urn:acme:legacy-token'); + throw new Error('Expected customTokenExchange to throw'); + } catch (e) { + const err = e as AuthError; + expect(err.code).toBe('invalid_request'); + expect(err.name).toBe('invalid_request'); } }); }); diff --git a/src/platforms/web/adapters/WebAuth0Client.ts b/src/platforms/web/adapters/WebAuth0Client.ts index 8f8454969..c0df7b3a0 100644 --- a/src/platforms/web/adapters/WebAuth0Client.ts +++ b/src/platforms/web/adapters/WebAuth0Client.ts @@ -34,7 +34,10 @@ import { import { HttpClient } from '../../../core/services/HttpClient'; import { TokenType } from '../../../types/common'; import { AuthError, DPoPError, PasskeyError } from '../../../core/models'; -import { validateActorTokenParameters } from '../../../core/utils'; +import { + validateActorTokenParameters, + validateTokenTypeUri, +} from '../../../core/utils'; export class WebAuth0Client implements IAuth0Client { readonly webAuth: WebWebAuthProvider; @@ -250,6 +253,7 @@ export class WebAuth0Client implements IAuth0Client { actorTokenType, } = parameters; + validateTokenTypeUri(subjectTokenType, 'subjectTokenType'); validateActorTokenParameters(actorToken, actorTokenType); try { @@ -262,10 +266,8 @@ export class WebAuth0Client implements IAuth0Client { audience, scope: finalScope, organization, - ...(actorToken !== undefined && { actor_token: actorToken }), - ...(actorTokenType !== undefined && { - actor_token_type: actorTokenType, - }), + ...(actorToken ? { actor_token: actorToken } : {}), + ...(actorTokenType ? { actor_token_type: actorTokenType } : {}), }); // Convert expiresIn (seconds from now) to expiresAt (UNIX timestamp) diff --git a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts index 0571188c2..1ffbc02fb 100644 --- a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts @@ -501,6 +501,23 @@ describe('WebAuth0Client', () => { ); }); + it('returns an undefined refreshToken when the actor exchange omits refresh_token', async () => { + mockSpaClient.loginWithCustomTokenExchange.mockResolvedValueOnce({ + ...mockExchangeResponse, + refresh_token: undefined, + }); + + const result = await client.customTokenExchange({ + subjectToken: 'external-token', + subjectTokenType: 'urn:acme:legacy-token', + actorToken: 'actor-token', + actorTokenType: 'http://corporate-idp/id-token', + }); + + expect(result.refreshToken).toBeUndefined(); + expect(result.accessToken).toBe('exchanged-access-token'); + }); + it('should not include actor token keys when not provided', async () => { await client.customTokenExchange({ subjectToken: 'external-token', From bc264c4acf3aff40b916ad77c3239f0ebda1efb7 Mon Sep 17 00:00:00 2001 From: Subhankar Maiti Date: Thu, 23 Jul 2026 17:24:42 +0530 Subject: [PATCH 5/5] normalize and validate actor/subject token parameters --- src/core/utils/__tests__/validation.spec.ts | 16 ++++++++++++++ src/core/utils/validation.ts | 10 ++++++++- .../native/adapters/NativeAuth0Client.ts | 16 +++++--------- src/platforms/web/adapters/WebAuth0Client.ts | 22 +++++++++---------- .../adapters/__tests__/WebAuth0Client.spec.ts | 14 ++++++++++++ 5 files changed, 55 insertions(+), 23 deletions(-) diff --git a/src/core/utils/__tests__/validation.spec.ts b/src/core/utils/__tests__/validation.spec.ts index 6dce1260f..d5fdae21f 100644 --- a/src/core/utils/__tests__/validation.spec.ts +++ b/src/core/utils/__tests__/validation.spec.ts @@ -16,6 +16,22 @@ describe('validateActorTokenParameters', () => { ).not.toThrow(); }); + it('returns the provided values unchanged when both are valid', () => { + expect( + validateActorTokenParameters('actor-token', 'urn:token-type') + ).toEqual({ + actorToken: 'actor-token', + actorTokenType: 'urn:token-type', + }); + }); + + it('normalizes empty/whitespace-only values to undefined', () => { + expect(validateActorTokenParameters(' ', ' ')).toEqual({ + actorToken: undefined, + actorTokenType: undefined, + }); + }); + it('should throw when only actorToken is provided', () => { expect(() => validateActorTokenParameters('actor-token')).toThrow( AuthError diff --git a/src/core/utils/validation.ts b/src/core/utils/validation.ts index a15dcf64f..117b8f494 100644 --- a/src/core/utils/validation.ts +++ b/src/core/utils/validation.ts @@ -114,6 +114,9 @@ export function validateTokenTypeUri( * performing delegation. This surfaces a clear client-side error before the * network request is made rather than relying on the server response. * + * Returns the normalized values (with empty/whitespace-only strings collapsed + * to `undefined`) so callers forward clean values rather than the raw input. + * * @param actorToken The actor token, if provided. * @param actorTokenType The actor token type URI, if provided. * @throws {AuthError} If exactly one of the two parameters is provided, or if @@ -122,7 +125,7 @@ export function validateTokenTypeUri( export function validateActorTokenParameters( actorToken?: string, actorTokenType?: string -): void { +): { actorToken?: string; actorTokenType?: string } { const hasToken = isProvided(actorToken); const hasType = isProvided(actorTokenType); @@ -137,4 +140,9 @@ export function validateActorTokenParameters( if (hasType) { validateTokenTypeUri(actorTokenType, 'actorTokenType'); } + + return { + actorToken: hasToken ? actorToken : undefined, + actorTokenType: hasType ? actorTokenType : undefined, + }; } diff --git a/src/platforms/native/adapters/NativeAuth0Client.ts b/src/platforms/native/adapters/NativeAuth0Client.ts index c1ee1a1aa..085893c49 100644 --- a/src/platforms/native/adapters/NativeAuth0Client.ts +++ b/src/platforms/native/adapters/NativeAuth0Client.ts @@ -234,17 +234,13 @@ export class NativeAuth0Client implements IAuth0Client { async customTokenExchange( parameters: CustomTokenExchangeParameters ): Promise { - const { - subjectToken, - subjectTokenType, - audience, - scope, - organization, - actorToken, - actorTokenType, - } = parameters; + const { subjectToken, subjectTokenType, audience, scope, organization } = + parameters; validateTokenTypeUri(subjectTokenType, 'subjectTokenType'); - validateActorTokenParameters(actorToken, actorTokenType); + const { actorToken, actorTokenType } = validateActorTokenParameters( + parameters.actorToken, + parameters.actorTokenType + ); return this.guardedBridge.customTokenExchange( subjectToken, subjectTokenType, diff --git a/src/platforms/web/adapters/WebAuth0Client.ts b/src/platforms/web/adapters/WebAuth0Client.ts index c0df7b3a0..5745c20b7 100644 --- a/src/platforms/web/adapters/WebAuth0Client.ts +++ b/src/platforms/web/adapters/WebAuth0Client.ts @@ -243,18 +243,14 @@ export class WebAuth0Client implements IAuth0Client { async customTokenExchange( parameters: CustomTokenExchangeParameters ): Promise { - const { - subjectToken, - subjectTokenType, - audience, - scope, - organization, - actorToken, - actorTokenType, - } = parameters; + const { subjectToken, subjectTokenType, audience, scope, organization } = + parameters; validateTokenTypeUri(subjectTokenType, 'subjectTokenType'); - validateActorTokenParameters(actorToken, actorTokenType); + const { actorToken, actorTokenType } = validateActorTokenParameters( + parameters.actorToken, + parameters.actorTokenType + ); try { // Apply default scope if not provided for consistency with native platforms @@ -266,8 +262,10 @@ export class WebAuth0Client implements IAuth0Client { audience, scope: finalScope, organization, - ...(actorToken ? { actor_token: actorToken } : {}), - ...(actorTokenType ? { actor_token_type: actorTokenType } : {}), + ...(actorToken !== undefined && { actor_token: actorToken }), + ...(actorTokenType !== undefined && { + actor_token_type: actorTokenType, + }), }); // Convert expiresIn (seconds from now) to expiresAt (UNIX timestamp) diff --git a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts index 1ffbc02fb..534f8d5cb 100644 --- a/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts +++ b/src/platforms/web/adapters/__tests__/WebAuth0Client.spec.ts @@ -530,6 +530,20 @@ describe('WebAuth0Client', () => { expect(callArg).not.toHaveProperty('actor_token_type'); }); + it('should not forward whitespace-only actor token values', async () => { + await client.customTokenExchange({ + subjectToken: 'external-token', + subjectTokenType: 'urn:acme:legacy-token', + actorToken: ' ', + actorTokenType: ' ', + }); + + const callArg = + mockSpaClient.loginWithCustomTokenExchange.mock.calls[0][0]; + expect(callArg).not.toHaveProperty('actor_token'); + expect(callArg).not.toHaveProperty('actor_token_type'); + }); + it('should throw before the network call when only actorToken is provided', async () => { await expect( client.customTokenExchange({