From b9d6ccd4bcedfa68324e783fe64e8c7357865d7c Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 5 Aug 2026 19:16:39 +0530 Subject: [PATCH 1/3] fix: reject blank organization before actor resolution in session transfer token request Move the blank-organization check in request_session_transfer_token ahead of actor resolution, so a request that cannot succeed no longer refreshes and persists the agent session first. Matches the up-front check already in build_session_transfer_redirect. Also correct the Session Transfer Token section of CustomTokenExchange.md: organization on the mint is sent on the exchange request (not forwarded to the redirect); the actor-token crypto requirement applies when actor_token_type is the ID token URN and now lists the aud and user-status checks; and the redirect organization is not conditional on how the STT was minted. --- examples/CustomTokenExchange.md | 15 +++++++++++---- .../auth_server/server_client.py | 5 ++++- .../tests/test_server_client.py | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index 86346db..a334237 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -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}, ) @@ -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( @@ -229,7 +236,7 @@ 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 @@ -237,7 +244,7 @@ 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}, ) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 4398804..d36238b 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -2777,6 +2777,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) @@ -2805,7 +2808,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( diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 16ce89e..800a779 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -4227,6 +4227,20 @@ 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_rejects_blank_organization(mocker): + """A blank organization is rejected before the actor is resolved or any request is sent.""" + client, post_mock = _stt_client(mocker) + + with pytest.raises(InvalidArgumentError): + await client.request_session_transfer_token( + subject_token="subj", subject_token_type="urn:acme:sub", actor_token="a", + organization=" ", + ) + + 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.""" From c0f3e5cc0a6da47586788664b42da6032661d22d Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 5 Aug 2026 19:21:37 +0530 Subject: [PATCH 2/3] test: cover organization forwarding and blank-org ordering for session transfer token Add tests that organization is forwarded onto the mint request and omitted when absent, and rewrite the blank-organization test to set up an expired session and assert the refresh, state-store write, and token request are all skipped - proving the check runs before actor resolution. --- .../tests/test_server_client.py | 37 +++++++++++++++++-- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 800a779..9eaa3af 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -4228,16 +4228,47 @@ def test_build_session_transfer_redirect_rejects_blank_organization(): @pytest.mark.asyncio -async def test_request_session_transfer_token_rejects_blank_organization(mocker): - """A blank organization is rejected before the actor is resolved or any request is sent.""" +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", side_effect=[False, True]) + 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", actor_token="a", + 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() From 1f7e6d4b4cff47f08fce17dff6694133028ef808 Mon Sep 17 00:00:00 2001 From: Snehil Kishore Date: Wed, 5 Aug 2026 19:37:06 +0530 Subject: [PATCH 3/3] docs: document InvalidArgumentError on request_session_transfer_token and simplify blank-org test Add the InvalidArgumentError case to the Raises docstring, matching build_session_transfer_redirect. Drop the dead side_effect on the _is_id_token_usable mock in the blank-org ordering test - the guard fires before it is ever consumed. --- src/auth0_server_python/auth_server/server_client.py | 1 + src/auth0_server_python/tests/test_server_client.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index d36238b..c8eb6b3 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -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. diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 9eaa3af..c1c012a 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -4257,7 +4257,7 @@ async def test_request_session_transfer_token_rejects_blank_organization_before_ """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", side_effect=[False, True]) + usable = mocker.patch.object(client, "_is_id_token_usable") refresh = mocker.patch.object(client, "get_token_by_refresh_token") with pytest.raises(InvalidArgumentError):