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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,7 @@ gen-external-apklibs
.TemporaryItems
.Trashes

version.txt
version.txt

# Internal planning docs
plans/
91 changes: 91 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
- [Sign Up with a database connection](#sign-up-with-a-database-connection)
- [Get user information](#get-user-information)
- [Custom Token Exchange](#custom-token-exchange)
- [Custom Token Exchange with Actor Token (Delegation/Impersonation)](#custom-token-exchange-with-actor-token-delegationimpersonation)
- [Native to Web SSO login](#native-to-web-sso-login)
- [Pushed Authorization Requests (PAR)](#pushed-authorization-requests-par)
- [DPoP](#dpop-1)
Expand Down Expand Up @@ -1637,6 +1638,96 @@ authentication

</details>

#### Custom Token Exchange with Actor Token (Delegation/Impersonation)

For delegation or impersonation scenarios where one principal acts on behalf of another (e.g., an AI agent acting on behalf of a user), pass `ActorToken` with the actor token details:

> **Note:** When `actor_token` is present in the request, Auth0 will not issue a refresh token regardless of whether `offline_access` is in the scope. The `Credentials.refreshToken` will be `null` in this flow.

```kotlin
import com.auth0.android.authentication.request.ActorToken

val actorToken = ActorToken(
token = "actor-token-value",
tokenType = "urn:my-org:actor-token-type"
)

authentication
.customTokenExchange(
subjectTokenType = "http://my-org/custom-token",
subjectToken = "subject-token-value",
organization = "org_12345",
actorToken = actorToken
)
.start(object : Callback<Credentials, AuthenticationException> {
override fun onSuccess(result: Credentials) {
// Access the actor claim from the ID token
val actor = result.user.actor
if (actor != null) {
println("Actor sub: ${actor.sub}")
println("Actor properties: ${actor.extraProperties}")
// Nested delegation chain (if present)
val nestedActor = actor.actor
}
}

override fun onFailure(exception: AuthenticationException) {
// Handle error
}
})
```

<details>
<summary>Using coroutines</summary>

```kotlin
try {
val actorToken = ActorToken(
token = "actor-token-value",
tokenType = "urn:my-org:actor-token-type"
)
val credentials = authentication
.customTokenExchange(
subjectTokenType = "http://my-org/custom-token",
subjectToken = "subject-token-value",
actorToken = actorToken
)
.await()
// Access the actor claim
val actor = credentials.user.actor
} catch (e: AuthenticationException) {
e.printStackTrace()
}
```
</details>

<details>
<summary>Using Java</summary>

```java
ActorToken actorToken = new ActorToken(
"actor-token-value",
"urn:my-org:actor-token-type"
);

authentication
.customTokenExchange("http://my-org/custom-token", "subject-token-value", null, actorToken)
.start(new Callback<Credentials, AuthenticationException>() {
@Override
public void onSuccess(@Nullable Credentials payload) {
ActorClaim actor = payload.getUser().getActor();
if (actor != null) {
Log.d("CTE", "Actor: " + actor.getSub());
}
}
@Override
public void onFailure(@NonNull AuthenticationException error) {
// Handle error
}
});
```
</details>


## Native to Web SSO login

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.auth0.android.Auth0
import com.auth0.android.Auth0Exception
import com.auth0.android.NetworkErrorException
import com.auth0.android.authentication.mfa.MfaApiClient
import com.auth0.android.authentication.request.ActorToken
import com.auth0.android.dpop.DPoP
import com.auth0.android.dpop.DPoPException
import com.auth0.android.dpop.SenderConstraining
Expand Down Expand Up @@ -815,17 +816,31 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
* })
* ```
*
* For delegation/impersonation scenarios, pass [ActorToken] with actor token details.
* When the server issues tokens with an `act` claim, it will be available via [Credentials.user] actor property.
*
* Note: When `actor_token` is present, Auth0 will not issue a refresh token regardless of
* whether `offline_access` is in the scope. The [Credentials.refreshToken] will be null.
*
* @param subjectTokenType the subject token type that is associated with the existing Identity Provider. e.g. 'http://acme.com/legacy-token'
* @param subjectToken the subject token, typically obtained through the Identity Provider's SDK
* @param organization id of the organization the user belongs to
* @param actorToken optional actor token details for delegation/impersonation flows.
* @return a request to configure and start that will yield [Credentials]
*/
@JvmOverloads
public fun customTokenExchange(
subjectTokenType: String,
subjectToken: String,
organization: String? = null
organization: String? = null,
actorToken: ActorToken? = null
): AuthenticationRequest {
return tokenExchange(subjectTokenType, subjectToken, organization)
return tokenExchange(
subjectTokenType,
Comment thread
kishore7snehil marked this conversation as resolved.
subjectToken,
organization,
actorToken
)
}

/**
Expand Down Expand Up @@ -1108,7 +1123,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
private fun tokenExchange(
subjectTokenType: String,
subjectToken: String,
organization: String? = null
organization: String? = null,
actorToken: ActorToken? = null
): AuthenticationRequest {
val parameters = ParameterBuilder.newAuthenticationBuilder().apply {
setGrantType(ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE)
Expand All @@ -1117,6 +1133,10 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
organization?.let {
set(ORGANIZATION_KEY, it)
}
actorToken?.let {
set(ACTOR_TOKEN_KEY, it.token)
set(ACTOR_TOKEN_TYPE_KEY, it.tokenType)
}
}.asDictionary()
Comment thread
kishore7snehil marked this conversation as resolved.
return loginWithToken(parameters)
}
Expand Down Expand Up @@ -1150,7 +1170,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
private const val AUTHENTICATOR_ID_KEY = "authenticator_id"
private const val RECOVERY_CODE_KEY = "recovery_code"
private const val SUBJECT_TOKEN_KEY = "subject_token"
private const val ACTOR_TOKEN_KEY = "actor_token"
private const val SUBJECT_TOKEN_TYPE_KEY = "subject_token_type"
private const val ACTOR_TOKEN_TYPE_KEY = "actor_token_type"
private const val ORGANIZATION_KEY = "organization"
private const val USER_METADATA_KEY = "user_metadata"
private const val AUTH_SESSION_KEY = "auth_session"
Expand All @@ -1172,7 +1194,6 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
private const val HEADER_AUTHORIZATION = "Authorization"
private const val WELL_KNOWN_PATH = ".well-known"
private const val JWKS_FILE_PATH = "jwks.json"
private const val TAG = "AuthenticationAPIClient"
private fun createErrorAdapter(): ErrorAdapter<AuthenticationException> {
val mapAdapter = forMap(GsonProvider.gson)
return object : ErrorAdapter<AuthenticationException> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.auth0.android.authentication.request

/**
* Represents the acting party in a token exchange delegation/impersonation flow.
*
* An `ActorToken` bundles the token and its type URI together, ensuring both are always provided as required by
* [RFC 8693](https://tools.ietf.org/html/rfc8693). Auth0 requires both `actor_token` and `actor_token_type` to be
* present when performing delegation.
*
* @param token The token representing the acting party (the entity performing actions on behalf of the subject).
* @param tokenType A URI indicating the type of the actor token (e.g., `urn:ietf:params:oauth:token-type:id_token`
* or a custom URI like `http://corporate-idp/id-token`).
*
* @see [RFC 8693: OAuth 2.0 Token Exchange](https://tools.ietf.org/html/rfc8693#section-2.1)
* @see [Custom Token Exchange Documentation](https://auth0.com/docs/authenticate/custom-token-exchange)
*/
public data class ActorToken(
val token: String,
val tokenType: String
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.auth0.android.request.internal;

import android.util.Log;

import com.auth0.android.result.ActorClaim;
import com.auth0.android.result.UserIdentity;
import com.auth0.android.result.UserProfile;
import com.google.gson.Gson;
Expand All @@ -11,12 +14,15 @@
import com.google.gson.reflect.TypeToken;

import java.lang.reflect.Type;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;

class UserProfileDeserializer implements JsonDeserializer<UserProfile> {

private static final String TAG = UserProfileDeserializer.class.getSimpleName();

private final Gson iso8601DateGson;

public UserProfileDeserializer() {
Expand All @@ -41,13 +47,41 @@ public UserProfile deserialize(JsonElement json, Type typeOfT, JsonDeserializati
final Boolean emailVerified = object.has("email_verified") ? context.<Boolean>deserialize(object.remove("email_verified"), Boolean.class) : false;
final Date createdAt = iso8601DateGson.fromJson(object.remove("created_at"), Date.class);

final Type identitiesType = new TypeToken<List<UserIdentity>>() {}.getType();
final Type identitiesType = new TypeToken<List<UserIdentity>>() {
}.getType();
final List<UserIdentity> identities = context.deserialize(object.remove("identities"), identitiesType);

final Type metadataType = new TypeToken<Map<String, Object>>() {}.getType();
final ActorClaim actor = deserializeActorClaim(object.remove("act"), context);

final Type metadataType = new TypeToken<Map<String, Object>>() {
}.getType();
Map<String, Object> userMetadata = context.deserialize(object.remove("user_metadata"), metadataType);
Map<String, Object> appMetadata = context.deserialize(object.remove("app_metadata"), metadataType);
Map<String, Object> extraInfo = context.deserialize(object, metadataType);
return new UserProfile(id, name, nickname, picture, email, emailVerified, familyName, createdAt, identities, extraInfo, userMetadata, appMetadata, givenName);
return new UserProfile(id, name, nickname, picture, email, emailVerified, familyName, createdAt, identities, extraInfo, userMetadata, appMetadata, givenName, actor);
}

private ActorClaim deserializeActorClaim(JsonElement actElement, JsonDeserializationContext context) {
if (actElement == null || actElement.isJsonNull() || !actElement.isJsonObject()) {
return null;
}

JsonObject actObject = actElement.getAsJsonObject();
String sub = context.deserialize(actObject.remove("sub"), String.class);
if (sub == null) {
Log.w(TAG, "act claim present but missing required 'sub' field, ignoring actor");
return null;
}

ActorClaim nestedActor = deserializeActorClaim(actObject.remove("act"), context);
Comment thread
kishore7snehil marked this conversation as resolved.

final Type mapType = new TypeToken<Map<String, Object>>() {
}.getType();
Map<String, Object> extraProperties = context.deserialize(actObject, mapType);
if (extraProperties == null) {
extraProperties = Collections.emptyMap();
}

return new ActorClaim(sub, nestedActor, extraProperties);
}
}
17 changes: 17 additions & 0 deletions auth0/src/main/java/com/auth0/android/result/ActorClaim.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.auth0.android.result

import java.io.Serializable

/**
* Represents the `act` (actor) claim in an ID token, used in delegation and impersonation scenarios.
* See RFC 8693 Section 4.4 for the specification of the `act` claim.
*
* @param sub The unique identifier of the actor (required).
Comment thread
kishore7snehil marked this conversation as resolved.
* @param actor A nested actor claim representing a delegation chain.
* @param extraProperties Additional custom properties set via the `setActor` Action command.
*/
public data class ActorClaim(
val sub: String,
val actor: ActorClaim? = null,
val extraProperties: Map<String, Any> = emptyMap()
) : Serializable
10 changes: 8 additions & 2 deletions auth0/src/main/java/com/auth0/android/result/UserProfile.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import java.util.*
* Class that holds the information of a user's profile in Auth0.
* Used both in [com.auth0.android.management.UsersAPIClient] and [com.auth0.android.authentication.AuthenticationAPIClient].
*/
public class UserProfile(
public class UserProfile @JvmOverloads constructor(
private val id: String?,
public val name: String?,
public val nickname: String?,
Expand All @@ -25,7 +25,13 @@ public class UserProfile(
private val extraInfo: Map<String, Any>?,
private val userMetadata: Map<String, Any>?,
private val appMetadata: Map<String, Any>?,
public val givenName: String?
public val givenName: String?,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since UserProfiles constructor is public, adding a new parameter is a binary-breaking change for any Java callers that construct this directly (Kotlin handles it via the default value, but Java doesn't see default params). I know it's mostly constructed internally by the deserializer, but would it be worth adding @jvmoverloads here to generate the overloaded constructors for Java consumers? Or alternatively, a secondary constructor without actor for backward compat

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done

/**
* The actor claim from the ID token, representing the acting party in delegation
* or impersonation scenarios (e.g., an AI agent acting on behalf of a user).
* Only present when the token was issued via Custom Token Exchange with an actor.
*/
public val actor: ActorClaim? = null
) : Serializable {

/**
Expand Down
Loading
Loading