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: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,11 @@ response = await auth0.custom_token_exchange(
print(response.access_token)
```

Building on token exchange, the SDK also supports:

- **[Delegation and Impersonation](examples/CustomTokenExchange.md#3-actor-tokens-delegation)** - exchange with an `actor_token` so the issued tokens record who is acting on whose behalf (the `act` claim).
- **[Impersonation via Session Transfer (STT)](examples/CustomTokenExchange.md#8-impersonation-via-session-transfer-stt)** - mint a Session Transfer Token to log an agent into a target app as a customer, via `request_session_transfer_token()` and `build_session_transfer_redirect()`.

For more details and examples, see [examples/CustomTokenExchange.md](examples/CustomTokenExchange.md).

### 5. Multiple Custom Domains (MCD)
Expand Down
86 changes: 86 additions & 0 deletions examples/CustomTokenExchange.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,92 @@ Use standard URNs when possible:
"urn:company:legacy-token"
```

## 8. Impersonation via Session Transfer (STT)

Custom Token Exchange can also mint a **Session Transfer Token (STT)** instead of an API access token. An STT lets an initiator app (for example a support console) log an agent into a target web app **as** a customer, with the agent recorded in the `act` claim - so a support engineer can reproduce a customer's exact experience without their password.

This is a two-role, two-hop flow:

- **Initiator** (the agent's app) mints the STT and redirects with it. This is where the new SDK methods live.
- **Target** (the customer's app) forwards the STT to `/authorize` on a normal interactive login, which establishes the impersonated session.

The STT is opaque, single-use, and short-lived (~60s). The SDK requests it and helps build the redirect - it never decodes or stores it.

### Initiator: request an STT and build the redirect

```python
from auth0_server_python.auth_server.server_client import ServerClient
from auth0_server_python.error import CustomTokenExchangeError

# Mint the STT. The audience (urn:{domain}:session_transfer), grant type, and the actor are
# set by the SDK - the actor is sourced from the logged-in agent's session.
result = await auth0.request_session_transfer_token(
subject_token=subject_token, # your proof of which customer to impersonate
subject_token_type="urn:acme:customer-subject",
organization=None, # optional; forwarded to the redirect
store_options={"request": request, "response": None},
)

# result.session_transfer_token is the opaque, one-shot STT (~60s). Never store it.
redirect_url = auth0.build_session_transfer_redirect(
"https://customer-app.example.com/auth/login", result, organization=None
)
return RedirectResponse(redirect_url) # your framework performs the redirect
```

`SessionTransferTokenResult` carries `session_transfer_token`, `issued_token_type` (the session-transfer URN - the field to branch on), `expires_in`, and an informational `token_type` (`N_A`). There is no `act` on this result; `act` appears later, on the target session.

> **NOTE**: An actor is mandatory - an STT is only issued when the Action set one. By default the SDK sources the actor from the logged-in agent's session ID token, refreshing it when expired. If the agent is not logged in (no usable session ID token and none can be refreshed), the call fails client-side with `ACTOR_UNAVAILABLE` before any network request.

> **NOTE**: To use your own actor token instead of the session, pass `actor_token` (and optionally `actor_token_type`, which defaults to the ID token URN). An explicit `actor_token` takes precedence and the session is not read at all. It must be an **unexpired, asymmetrically-signed JWT** (RS256 or PS256) - an Auth0 session ID token satisfies this; an HS256 or expired token is rejected by the server.
>
> ```python
> result = await auth0.request_session_transfer_token(
> subject_token=subject_token,
> subject_token_type="urn:acme:customer-subject",
> actor_token=agent_id_token, # explicit override - session is not used
> store_options={"request": request, "response": None},
> )
> ```

### Target: forward the STT to `/authorize`

On the target, the STT rides through your normal login. `start_interactive_login` forwards arbitrary authorization parameters to `/authorize`, so your login route just passes `session_transfer_token` (and `organization`, when the STT was issued in an org context) straight through:

```python
from auth0_server_python.auth_types import StartInteractiveLoginOptions

url = await auth0.start_interactive_login(
StartInteractiveLoginOptions(authorization_params={
"session_transfer_token": request.query_params["session_transfer_token"],
# "organization": org, # when the STT was issued in an org context
}),
store_options={"request": request, "response": None},
)
return RedirectResponse(url)
```

After the callback completes, read the acting party off the session user - the same way as the [Actor Tokens (Delegation)](#3-actor-tokens-delegation) section above:

```python
session = await auth0.get_session(store_options={"request": request, "response": None})
act = (session or {}).get("user", {}).get("act")
if act:
print(f"Impersonated by: {act['sub']}") # drive an impersonation banner, etc.
```

> **NOTE**: Both clients need one-time configuration through the Auth0 Dashboard or Management API. The issuing (initiator) client must be allowed to create session transfer tokens. The redeeming (target) client must be allowed to accept delegated-access sessions and to receive the token as a query parameter. See the [Auth0 documentation](https://auth0.com/docs/authenticate/custom-token-exchange) for the exact client settings.

> **NOTE**: `build_session_transfer_redirect` attaches a single-use credential to `target_login_url`, so that URL must be a trusted, app-controlled value - never one derived from untrusted input (such as a user-supplied `returnTo`), which could leak the token to an attacker host.

> **NOTE**: The impersonation session is hard-capped at 2 hours and cannot mint a refresh token (`offline_access` is dropped when an actor is present). To continue past that, re-run the flow.

### STT error codes

- `ACTOR_UNAVAILABLE`: no usable actor token (client-side; raised before any network call)
- `SETACTOR_REQUIRED`: an STT was requested but the Action did not call `setActor` (server 400)
- `SESSION_TRANSFER_DISABLED`: the session-transfer feature is not enabled for the tenant/client (server 400)

## Additional Resources

- [Auth0 Custom Token Exchange Documentation](https://auth0.com/docs/authenticate/custom-token-exchange)
Expand Down
214 changes: 214 additions & 0 deletions src/auth0_server_python/auth_server/server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
PasskeySignupChallengeResponse,
PasskeyTokenResponse,
PasskeyUserProfile,
SessionTransferTokenResult,
StartInteractiveLoginOptions,
StateData,
TokenExchangeResponse,
Expand Down Expand Up @@ -86,6 +87,12 @@
INTERNAL_AUTHORIZE_PARAMS = ["client_id", "response_type",
"code_challenge", "code_challenge_method", "state", "nonce", "scope"]

# issued_token_type URN for a Session Transfer Token (STT).
SESSION_TRANSFER_TOKEN_TYPE = "urn:auth0:params:oauth:token-type:session_transfer_token"

# actor_token_type URN when the actor is sourced from the agent session's ID token.
ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"


class ServerClient(Generic[TStoreOptions]):
"""
Expand Down Expand Up @@ -2635,6 +2642,213 @@ async def login_with_custom_token_exchange(
e
)

# ============================================================================
# SESSION TRANSFER TOKEN (STT)
# Impersonation via Session Transfer, built on Custom Token Exchange.
# ============================================================================

async def _is_id_token_usable(self, token: str, store_options: Optional[dict[str, Any]]) -> bool:
"""
Verifies the agent session's ID token (signature + expiry) before using it as an actor.

Full verification against JWKS - the same path the login callback uses - so an expired
or tampered token is rejected client-side rather than sent to the server as a dud.
"""
if not token:
return False
domain = await self._resolve_current_domain(store_options)
metadata = await self._get_oidc_metadata_cached(domain)
jwks = await self._get_jwks_cached(domain, metadata)
try:
# aud is the client_id for a standard Auth0 ID token.
await self._verify_and_decode_jwt(token, jwks, audience=self._client_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

_is_id_token_usable verifies the ID token with audience=self._client_id. That is correct for a standard Auth0 ID token, just flagging it as an assumption: if a tenant ever issues the session ID token with a different aud, this verification would fail and the token would be treated as unusable (leading to a refresh or ACTOR_UNAVAILABLE). Probably fine, but worth a comment so it is a conscious choice.

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.

Fixed in a070d41

return True
except (jwt.PyJWTError, ValueError):
# Only token-level failures mean "unusable"; infra errors (JWKS fetch, domain
# resolution) propagate above rather than being masked as ACTOR_UNAVAILABLE.
return False

async def _resolve_actor_token(
self,
actor_token: Optional[str],
actor_token_type: Optional[str],
store_options: Optional[dict[str, Any]]
) -> tuple[str, str]:
"""
Resolves the (actor_token, actor_token_type) pair for a session transfer request.

Raises:
CustomTokenExchangeError(ACTOR_UNAVAILABLE): if no usable actor can be resolved.
"""
# Explicit actor wins; a passed-but-blank value is a bug, not a fallback signal.
if actor_token is not None:
if not actor_token.strip():
raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
"actor_token cannot be empty or whitespace-only"
)
return actor_token, (actor_token_type or ID_TOKEN_TYPE)

# Otherwise source it from the agent's session ID token.
state_data = await self._state_store.get(self._state_identifier, store_options)
if state_data and hasattr(state_data, "dict") and callable(state_data.dict):
state_data = state_data.dict()
state_data = state_data or {}

# In resolver mode, don't source the actor from a session on a different domain.
if self._domain_resolver:
session_domain = self._get_session_domain(state_data)
current_domain = await self._resolve_current_domain(store_options)
if not session_domain or self._normalize_url(session_domain) != self._normalize_url(current_domain):
raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.ACTOR_UNAVAILABLE,
"No usable actor token: the agent session is on a different domain."
)

session_id_token = state_data.get("id_token")

# Refresh a stale (or missing) ID token when the agent session has a refresh token.
if not await self._is_id_token_usable(session_id_token, store_options) and state_data.get("refresh_token"):
refresh_domain = self._get_session_domain(state_data) or await self._resolve_current_domain(store_options)
try:
refreshed = await self.get_token_by_refresh_token({
"refresh_token": state_data["refresh_token"],
"domain": refresh_domain,
})
except (ApiError, AccessTokenError):
# A genuine refresh failure means no usable actor; unexpected errors propagate.
refreshed = None
if refreshed:
updated_state_data = State.update_state_data(
self.DEFAULT_AUDIENCE_STATE_KEY, state_data, refreshed)
await self._state_store.set(self._state_identifier, updated_state_data, options=store_options)
session_id_token = refreshed.get("id_token") or session_id_token

if await self._is_id_token_usable(session_id_token, store_options):
return session_id_token, ID_TOKEN_TYPE

raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.ACTOR_UNAVAILABLE,
"No usable actor token: pass actor_token or ensure the agent has a valid session."
)

async def request_session_transfer_token(
self,
subject_token: str,
subject_token_type: str,
actor_token: Optional[str] = None,
actor_token_type: Optional[str] = None,
scope: Optional[str] = None,
organization: Optional[str] = None,
store_options: Optional[dict[str, Any]] = None
) -> SessionTransferTokenResult:
"""
Requests a Session Transfer Token (STT) for impersonation via session transfer.

Performs a custom token exchange against the session_transfer audience. The returned
STT is opaque and single-use; hand it to build_session_transfer_redirect and do not
decode or store it. The act claim is not on this result.

Args:
subject_token: Your proof of which customer to impersonate (validated by your Action)
subject_token_type: The subject token type URI routing to your CTE Profile
actor_token: The acting party's token; optional. Defaults to the agent session's ID token
actor_token_type: Type URI of the actor token; defaults to the ID token URN
scope: Space-delimited list of scopes (optional)
organization: Organization identifier (optional)
store_options: Optional options used to read the agent session and resolve the domain

Returns:
SessionTransferTokenResult containing the STT and its metadata

Raises:
CustomTokenExchangeError: If no actor can be resolved or the exchange fails
"""
try:
# Validate the subject up front - before any session read/refresh/network.
if not subject_token or not subject_token.strip():
raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
"subject_token cannot be empty or whitespace-only"
)
if not subject_token_type or not subject_token_type.strip():
raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
"subject_token_type cannot be empty or whitespace-only"
)

actor_token, actor_token_type = await self._resolve_actor_token(
actor_token, actor_token_type, store_options)

# Build the session_transfer audience from the resolved request domain.
domain = await self._resolve_current_domain(store_options)
audience = f"urn:{domain}:session_transfer"

options = CustomTokenExchangeOptions(
subject_token=subject_token,
subject_token_type=subject_token_type,
audience=audience,
scope=scope,
actor_token=actor_token,
actor_token_type=actor_token_type,
organization=organization,
)

response = await self.custom_token_exchange(options, store_options)

return SessionTransferTokenResult(
session_transfer_token=response.access_token,
# Return the server's value as-is; don't default to the STT URN, or a non-STT
# response would be mislabelled as an STT.
issued_token_type=response.issued_token_type or "",
expires_in=response.expires_in,
token_type=response.token_type,
scope=response.scope,
)
except (CustomTokenExchangeError, ApiError):
raise
except Exception as e:
raise CustomTokenExchangeError(
CustomTokenExchangeErrorCode.TOKEN_EXCHANGE_FAILED,
f"Session transfer token request failed: {str(e)}",
e
)

def build_session_transfer_redirect(
self,
target_login_url: str,
result: SessionTransferTokenResult,
organization: Optional[str] = None
) -> str:
"""
Builds the redirect URL that hands the STT to the target app's login URL.

target_login_url must be a trusted, app-controlled absolute https URL (http is allowed
only for localhost/loopback) - the STT is a single-use credential and must not leak to an
untrusted host.

Args:
target_login_url: The target app's login URL (absolute, https)
result: The SessionTransferTokenResult from request_session_transfer_token
organization: Organization identifier to forward (optional)

Returns:
A URL string with session_transfer_token (and organization) as query parameters

Raises:
MissingRequiredArgumentError: If target_login_url is missing or blank
InvalidArgumentError: If target_login_url is not an absolute https URL, or organization is blank
"""
URL.validate_https_redirect_target(target_login_url, "target_login_url")

params = {"session_transfer_token": result.session_transfer_token}
if organization is not None:
if not organization.strip():
raise InvalidArgumentError("organization", "organization must not be blank")
params["organization"] = organization

return URL.build_url(target_login_url, params)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

build_session_transfer_redirect builds the URL without validating target_login_url. The STT is a single use credential, so if this value ever comes from untrusted input it can leak the token to another host. Can we check that the URL is absolute and uses https here (allowing http only for localhost/127.0.0.1/[::1] for local dev)? The docstring says nothing about this, and I think we should enforce it in code rather than trust the caller. Right now anything that URL.build_url accepts will go through.

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.

Fixed in a070d41


# ============================================================================
# MFA (Multi-Factor Authentication)
# ============================================================================
Expand Down
18 changes: 18 additions & 0 deletions src/auth0_server_python/auth_types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,24 @@ class LoginWithCustomTokenExchangeResult(BaseModel):
authorization_details: Optional[list[AuthorizationDetails]] = None


class SessionTransferTokenResult(BaseModel):
"""
Response from a session transfer token (STT) request.

Attributes:
session_transfer_token: The opaque, single-use session transfer token
issued_token_type: Format of issued token (the session-transfer URN)
expires_in: Token lifetime in seconds
token_type: Token type as returned by the server (typically "N_A")
scope: Granted scopes (if returned)
"""
session_transfer_token: str
issued_token_type: str
expires_in: int
token_type: Optional[str] = None
scope: Optional[str] = None


# =============================================================================
# Connected Accounts Types
# =============================================================================
Expand Down
3 changes: 3 additions & 0 deletions src/auth0_server_python/error/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,9 @@ class CustomTokenExchangeErrorCode:
MISSING_ACTOR_TOKEN = "missing_actor_token"
TOKEN_EXCHANGE_FAILED = "token_exchange_failed"
INVALID_RESPONSE = "invalid_response"
ACTOR_UNAVAILABLE = "actor_unavailable"
SETACTOR_REQUIRED = "setactor_required"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SETACTOR_REQUIRED and SESSION_TRANSFER_DISABLED are defined here and mentioned in the docs, but I could not find anywhere they are actually raised. So today a real server 400 for these cases would surface as TOKEN_EXCHANGE_FAILED, not the typed code, and a developer catching on these constants would never hit them. Can we map the server error to these codes where the exchange fails (or if that is intentionally deferred, add a short comment saying they are placeholders for now)?

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.

These aren't raised by any SDK path on purpose. I checked the server (CustomTokenExchangeProfile.js and its custom_token_exchange.n2w.tests.js): all three STT rejections come back as error: "invalid_request" with the reason only in error_description ("setActor is required when requesting a session transfer token via token exchange.", "Feature is disabled for this client", etc.). The strings setactor_required/session_transfer_disabled don't exist anywhere in the server β€” it never emits them as codes.

The only way to set our typed code would be to string-match that error_description, which is brittle (a server reword silently breaks it), so we surface the raw server error/error_description as-is β€” the same as our existing CTE failures and the sibling auth0-auth-js STT PR. Keeping the two constants as named references for the conditions; happy to drop them if you'd rather not have unused constants.

SESSION_TRANSFER_DISABLED = "session_transfer_disabled"


# =============================================================================
Expand Down
Loading
Loading