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..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. @@ -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) @@ -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( diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 16ce89e..c1c012a 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -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."""