Skip to content
Merged
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
15 changes: 11 additions & 4 deletions examples/CustomTokenExchange.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ from auth0_server_python.error import CustomTokenExchangeError
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
organization=None, # optional, sent on the mint request (separate from the redirect)
store_options={"request": request, "response": None},
)

Expand All @@ -216,7 +216,14 @@ return RedirectResponse(redirect_url) # your framework performs the redire

> **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.
> **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. When `actor_token_type` is the ID token URN (the default), Auth0 validates the token, so it must be:
>
> - Signed with RS256 or PS256 (HS256 is rejected, it uses a shared secret).
> - Unexpired, and carrying `sub`, `iss`, `exp`, and `iat`.
> - Issued to the same client making the exchange (its `aud` must be that client's ID).
> - Belonging to a user who still exists and is not blocked.
>
> An Auth0 ID token from the agent's own session on this client satisfies all of these. A token that fails any of them is rejected by the server.
>
> ```python
> result = await auth0.request_session_transfer_token(
Expand All @@ -229,15 +236,15 @@ return RedirectResponse(redirect_url) # your framework performs the redire

### 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:
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 you want the target login org-scoped) 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
# "organization": org, # when you want the target login org-scoped
}),
store_options={"request": request, "response": None},
)
Expand Down
6 changes: 5 additions & 1 deletion src/auth0_server_python/auth_server/server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2763,6 +2763,7 @@ async def request_session_transfer_token(

Raises:
CustomTokenExchangeError: If no actor can be resolved or the exchange fails
InvalidArgumentError: If organization is provided but blank
"""
try:
# Validate the subject up front - before any session read/refresh/network.
Expand All @@ -2777,6 +2778,9 @@ async def request_session_transfer_token(
"subject_token_type cannot be empty or whitespace-only"
)

if organization is not None and not organization.strip():
raise InvalidArgumentError("organization", "organization must not be blank")

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

Expand Down Expand Up @@ -2805,7 +2809,7 @@ async def request_session_transfer_token(
token_type=response.token_type,
scope=response.scope,
)
except (CustomTokenExchangeError, ApiError):
except (CustomTokenExchangeError, InvalidArgumentError, ApiError):
raise
except Exception as e:
raise CustomTokenExchangeError(
Expand Down
45 changes: 45 additions & 0 deletions src/auth0_server_python/tests/test_server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4227,6 +4227,51 @@ def test_build_session_transfer_redirect_rejects_blank_organization():
"https://app.example.com/auth/login", _stt_result(), organization=" ")


@pytest.mark.asyncio
async def test_request_session_transfer_token_forwards_organization_on_mint(mocker):
"""A provided organization is forwarded onto the mint request."""
client, post_mock = _stt_client(mocker)

await client.request_session_transfer_token(
subject_token="subj", subject_token_type="urn:acme:sub", actor_token="a",
organization="org_abc123",
)

assert post_mock.post.call_args[1]["data"]["organization"] == "org_abc123"


@pytest.mark.asyncio
async def test_request_session_transfer_token_omits_organization_when_absent(mocker):
"""No organization passed → the parameter is absent from the mint request, not empty."""
client, post_mock = _stt_client(mocker)

await client.request_session_transfer_token(
subject_token="subj", subject_token_type="urn:acme:sub", actor_token="a",
)

assert "organization" not in post_mock.post.call_args[1]["data"]


@pytest.mark.asyncio
async def test_request_session_transfer_token_rejects_blank_organization_before_refresh(mocker):
"""A blank organization is rejected before the expired session is refreshed or persisted."""
client, post_mock = _stt_client(mocker)
client._state_store.get.return_value = {"id_token": "stale", "refresh_token": "rt"}
usable = mocker.patch.object(client, "_is_id_token_usable")
refresh = mocker.patch.object(client, "get_token_by_refresh_token")

with pytest.raises(InvalidArgumentError):
await client.request_session_transfer_token(
subject_token="subj", subject_token_type="urn:acme:sub",
organization=" ",
)

refresh.assert_not_called()
usable.assert_not_called()
client._state_store.set.assert_not_called()
post_mock.post.assert_not_called()


@pytest.mark.asyncio
async def test_request_session_transfer_token_surfaces_server_issued_token_type(mocker):
"""A non-STT issued_token_type is surfaced verbatim, never fabricated as the STT URN."""
Expand Down
Loading