Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:`.
Expand Down
16 changes: 13 additions & 3 deletions android/src/main/java/com/auth0/react/A0Auth0Module.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
90 changes: 90 additions & 0 deletions example/src/App.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const HooksAuthContent = (): React.JSX.Element => {
createUser,
resetPassword,
loginWithPasswordRealm,
customTokenExchange,
mfa,
users,
} = useAuth0();
Expand All @@ -64,6 +65,30 @@ const HooksAuthContent = (): React.JSX.Element => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');

// Custom Token Exchange (RFC 8693) state
const [subjectToken, setSubjectToken] = useState('');
const [subjectTokenType, setSubjectTokenType] = useState(
'urn:acme:external-idp-token'
);
const [actorToken, setActorToken] = useState('');
const [actorTokenType, setActorTokenType] = useState(
'urn:ietf:params:oauth:token-type:id_token'
);

const fillActorTokenFromSession = async () => {
setApiError(null);
try {
const credentials = await getCredentials();
if (credentials?.idToken) {
setActorToken(credentials.idToken);
} else {
setApiError(new Error('No ID token available in the current session.'));
}
} catch (e) {
setApiError(e as Error);
}
};

// MFA wizard state
const [mfaToken, setMfaToken] = useState('');
const [mfaStep, setMfaStep] = useState<MfaStep>('idle');
Expand Down Expand Up @@ -288,6 +313,71 @@ const HooksAuthContent = (): React.JSX.Element => {
disabled={!result?.accessToken}
/>
<Button onPress={clearSession} title="Log Out" />
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Custom Token Exchange (RFC 8693)
</Text>
<LabeledInput
label="Subject Token"
value={subjectToken}
onChangeText={setSubjectToken}
placeholder="External IdP token to exchange"
autoCapitalize="none"
/>
<LabeledInput
label="Subject Token Type"
value={subjectTokenType}
onChangeText={setSubjectTokenType}
autoCapitalize="none"
/>
<Button
onPress={() =>
runDemo(() =>
customTokenExchange({ subjectToken, subjectTokenType })
)
}
title="customTokenExchange()"
disabled={!subjectToken || !subjectTokenType}
/>

<Text style={styles.hint}>Delegation & Impersonation</Text>
<LabeledInput
label="Actor Token"
value={actorToken}
onChangeText={setActorToken}
placeholder="Acting-party token"
autoCapitalize="none"
/>
<Button
onPress={fillActorTokenFromSession}
title="Use my ID token as actor"
/>
<LabeledInput
label="Actor Token Type"
value={actorTokenType}
onChangeText={setActorTokenType}
autoCapitalize="none"
/>
<Button
onPress={() =>
runDemo(() =>
customTokenExchange({
subjectToken,
subjectTokenType,
actorToken,
actorTokenType,
})
)
}
title="customTokenExchange() with actor"
disabled={
!subjectToken ||
!subjectTokenType ||
!actorToken ||
!actorTokenType
}
/>
</View>
</View>
) : (
<>
Expand Down
2 changes: 1 addition & 1 deletion example/src/navigation/ClassDemoNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClassDemoStackParamList>();
Expand Down
90 changes: 89 additions & 1 deletion example/src/screens/class-based/ClassApiTests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type Props = {
};

const ClassApiTestsScreen = ({ route }: Props) => {
const { accessToken } = route.params;
const { accessToken, idToken } = route.params;
const [result, setResult] = useState<object | null>(null);
const [error, setError] = useState<Error | null>(null);

Expand All @@ -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<any>, title: string) => {
setError(null);
setResult(null);
Expand Down Expand Up @@ -176,6 +186,77 @@ const ClassApiTestsScreen = ({ route }: Props) => {
disabled={!refreshToken}
/>
</Section>

<Section title="Custom Token Exchange (RFC 8693)">
<LabeledInput
label="Subject Token"
value={subjectToken}
onChangeText={setSubjectToken}
placeholder="External IdP token to exchange"
autoCapitalize="none"
/>
<LabeledInput
label="Subject Token Type"
value={subjectTokenType}
onChangeText={setSubjectTokenType}
autoCapitalize="none"
/>
<Button
onPress={() =>
runTest(
() =>
auth0.customTokenExchange({
subjectToken,
subjectTokenType,
}),
'Custom Token Exchange'
)
}
title="customTokenExchange()"
disabled={!subjectToken || !subjectTokenType}
/>

<Text style={styles.subheading}>Delegation & Impersonation</Text>
<LabeledInput
label="Actor Token"
value={actorToken}
onChangeText={setActorToken}
placeholder="Acting-party token"
autoCapitalize="none"
/>
<Button
onPress={() => idToken && setActorToken(idToken)}
title="Use my ID token as actor"
disabled={!idToken}
/>
<LabeledInput
label="Actor Token Type"
value={actorTokenType}
onChangeText={setActorTokenType}
autoCapitalize="none"
/>
<Button
onPress={() =>
runTest(
() =>
auth0.customTokenExchange({
subjectToken,
subjectTokenType,
actorToken,
actorTokenType,
}),
'Custom Token Exchange (with Actor)'
)
}
title="customTokenExchange() with actor"
disabled={
!subjectToken ||
!subjectTokenType ||
!actorToken ||
!actorTokenType
}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</Section>
</ScrollView>
</SafeAreaView>
);
Expand Down Expand Up @@ -215,6 +296,13 @@ const styles = StyleSheet.create({
fontWeight: 'bold',
marginBottom: 12,
},
subheading: {
fontSize: 14,
fontWeight: '600',
marginTop: 8,
marginBottom: 4,
color: '#555555',
},
buttonGroup: {
gap: 10,
},
Expand Down
7 changes: 5 additions & 2 deletions example/src/screens/class-based/ClassProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ class ClassProfileScreen extends Component<Props, State> {

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 (
<SafeAreaView style={styles.container}>
Expand Down Expand Up @@ -207,7 +207,10 @@ class ClassProfileScreen extends Component<Props, State> {
<Section title="Navigation & Logout">
<Button
onPress={() =>
this.props.navigation.navigate('ClassApiTests', { accessToken })
this.props.navigation.navigate('ClassApiTests', {
accessToken,
idToken,
})
}
title="Go to API Tests"
/>
Expand Down
4 changes: 3 additions & 1 deletion ios/A0Auth0.mm
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,11 @@ - (dispatch_queue_t)methodQueue
audience:(NSString * _Nullable)audience
scope:(NSString * _Nullable)scope
organization:(NSString * _Nullable)organization
actorToken:(NSString * _Nullable)actorToken
actorTokenType:(NSString * _Nullable)actorTokenType
resolve:(RCTPromiseResolveBlock)resolve
reject:(RCTPromiseRejectBlock)reject) {
[self.nativeBridge customTokenExchangeWithSubjectToken:subjectToken subjectTokenType:subjectTokenType audience:audience scope:scope organization:organization resolve:resolve reject:reject];
[self.nativeBridge customTokenExchangeWithSubjectToken:subjectToken subjectTokenType:subjectTokenType audience:audience scope:scope organization:organization actorToken:actorToken actorTokenType:actorTokenType resolve:resolve reject:reject];
}

RCT_EXPORT_METHOD(getMfaAuthenticators:(NSString *)mfaToken
Expand Down
14 changes: 10 additions & 4 deletions ios/NativeBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 actorTokenValue: 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 actorToken: ActorToken?
if let actorTokenValue = actorTokenValue, let actorTokenType = actorTokenType {
actorToken = ActorToken(token: actorTokenValue, tokenType: actorTokenType)
}

auth.customTokenExchange(
subjectToken: subjectToken,
subjectTokenType: subjectTokenType,
audience: audience,
scope: finalScope,
organization: organization
organization: organization,
actorToken: actorToken
).start { result in
switch result {
case .success(let credentials):
Expand Down
Loading
Loading