auth0-server-python 1.0.0b13__tar.gz → 1.0.0b14__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/PKG-INFO +6 -1
  2. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/README.md +5 -0
  3. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/pyproject.toml +1 -1
  4. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/auth_server/server_client.py +214 -0
  5. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/auth_types/__init__.py +18 -0
  6. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/error/__init__.py +3 -0
  7. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/tests/test_server_client.py +384 -3
  8. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/utils/helpers.py +27 -1
  9. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/LICENSE +0 -0
  10. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/auth_schemes/__init__.py +0 -0
  11. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/auth_schemes/bearer_auth.py +0 -0
  12. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/auth_schemes/dpop_auth.py +0 -0
  13. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/auth_server/__init__.py +0 -0
  14. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/auth_server/mfa_client.py +0 -0
  15. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/auth_server/my_account_client.py +0 -0
  16. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/encryption/__init__.py +0 -0
  17. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/encryption/encrypt.py +0 -0
  18. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/store/__init__.py +0 -0
  19. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/store/abstract.py +0 -0
  20. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/telemetry.py +0 -0
  21. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/tests/test_dpop_auth.py +0 -0
  22. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/tests/test_mfa_client.py +0 -0
  23. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/tests/test_my_account_client.py +0 -0
  24. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/tests/test_telemetry.py +0 -0
  25. {auth0_server_python-1.0.0b13 → auth0_server_python-1.0.0b14}/src/auth0_server_python/utils/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth0-server-python
3
- Version: 1.0.0b13
3
+ Version: 1.0.0b14
4
4
  Summary: Auth0 server-side Python SDK
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -172,6 +172,11 @@ response = await auth0.custom_token_exchange(
172
172
  print(response.access_token)
173
173
  ```
174
174
 
175
+ Building on token exchange, the SDK also supports:
176
+
177
+ - **[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).
178
+ - **[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()`.
179
+
175
180
  For more details and examples, see [examples/CustomTokenExchange.md](examples/CustomTokenExchange.md).
176
181
 
177
182
  ### 5. Multiple Custom Domains (MCD)
@@ -147,6 +147,11 @@ response = await auth0.custom_token_exchange(
147
147
  print(response.access_token)
148
148
  ```
149
149
 
150
+ Building on token exchange, the SDK also supports:
151
+
152
+ - **[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).
153
+ - **[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()`.
154
+
150
155
  For more details and examples, see [examples/CustomTokenExchange.md](examples/CustomTokenExchange.md).
151
156
 
152
157
  ### 5. Multiple Custom Domains (MCD)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "auth0-server-python"
3
- version = "1.0.0b13"
3
+ version = "1.0.0b14"
4
4
  description = "Auth0 server-side Python SDK"
5
5
  readme = "README.md"
6
6
  authors = ["Auth0 <support@okta.com>"]
@@ -41,6 +41,7 @@ from auth0_server_python.auth_types import (
41
41
  PasskeySignupChallengeResponse,
42
42
  PasskeyTokenResponse,
43
43
  PasskeyUserProfile,
44
+ SessionTransferTokenResult,
44
45
  StartInteractiveLoginOptions,
45
46
  StateData,
46
47
  TokenExchangeResponse,
@@ -86,6 +87,12 @@ TStoreOptions = TypeVar('TStoreOptions')
86
87
  INTERNAL_AUTHORIZE_PARAMS = ["client_id", "response_type",
87
88
  "code_challenge", "code_challenge_method", "state", "nonce", "scope"]
88
89
 
90
+ # issued_token_type URN for a Session Transfer Token (STT).
91
+ SESSION_TRANSFER_TOKEN_TYPE = "urn:auth0:params:oauth:token-type:session_transfer_token"
92
+
93
+ # actor_token_type URN when the actor is sourced from the agent session's ID token.
94
+ ID_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id_token"
95
+
89
96
 
90
97
  class ServerClient(Generic[TStoreOptions]):
91
98
  """
@@ -2635,6 +2642,213 @@ class ServerClient(Generic[TStoreOptions]):
2635
2642
  e
2636
2643
  )
2637
2644
 
2645
+ # ============================================================================
2646
+ # SESSION TRANSFER TOKEN (STT)
2647
+ # Impersonation via Session Transfer, built on Custom Token Exchange.
2648
+ # ============================================================================
2649
+
2650
+ async def _is_id_token_usable(self, token: str, store_options: Optional[dict[str, Any]]) -> bool:
2651
+ """
2652
+ Verifies the agent session's ID token (signature + expiry) before using it as an actor.
2653
+
2654
+ Full verification against JWKS - the same path the login callback uses - so an expired
2655
+ or tampered token is rejected client-side rather than sent to the server as a dud.
2656
+ """
2657
+ if not token:
2658
+ return False
2659
+ domain = await self._resolve_current_domain(store_options)
2660
+ metadata = await self._get_oidc_metadata_cached(domain)
2661
+ jwks = await self._get_jwks_cached(domain, metadata)
2662
+ try:
2663
+ # aud is the client_id for a standard Auth0 ID token.
2664
+ await self._verify_and_decode_jwt(token, jwks, audience=self._client_id)
2665
+ return True
2666
+ except (jwt.PyJWTError, ValueError):
2667
+ # Only token-level failures mean "unusable"; infra errors (JWKS fetch, domain
2668
+ # resolution) propagate above rather than being masked as ACTOR_UNAVAILABLE.
2669
+ return False
2670
+
2671
+ async def _resolve_actor_token(
2672
+ self,
2673
+ actor_token: Optional[str],
2674
+ actor_token_type: Optional[str],
2675
+ store_options: Optional[dict[str, Any]]
2676
+ ) -> tuple[str, str]:
2677
+ """
2678
+ Resolves the (actor_token, actor_token_type) pair for a session transfer request.
2679
+
2680
+ Raises:
2681
+ CustomTokenExchangeError(ACTOR_UNAVAILABLE): if no usable actor can be resolved.
2682
+ """
2683
+ # Explicit actor wins; a passed-but-blank value is a bug, not a fallback signal.
2684
+ if actor_token is not None:
2685
+ if not actor_token.strip():
2686
+ raise CustomTokenExchangeError(
2687
+ CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
2688
+ "actor_token cannot be empty or whitespace-only"
2689
+ )
2690
+ return actor_token, (actor_token_type or ID_TOKEN_TYPE)
2691
+
2692
+ # Otherwise source it from the agent's session ID token.
2693
+ state_data = await self._state_store.get(self._state_identifier, store_options)
2694
+ if state_data and hasattr(state_data, "dict") and callable(state_data.dict):
2695
+ state_data = state_data.dict()
2696
+ state_data = state_data or {}
2697
+
2698
+ # In resolver mode, don't source the actor from a session on a different domain.
2699
+ if self._domain_resolver:
2700
+ session_domain = self._get_session_domain(state_data)
2701
+ current_domain = await self._resolve_current_domain(store_options)
2702
+ if not session_domain or self._normalize_url(session_domain) != self._normalize_url(current_domain):
2703
+ raise CustomTokenExchangeError(
2704
+ CustomTokenExchangeErrorCode.ACTOR_UNAVAILABLE,
2705
+ "No usable actor token: the agent session is on a different domain."
2706
+ )
2707
+
2708
+ session_id_token = state_data.get("id_token")
2709
+
2710
+ # Refresh a stale (or missing) ID token when the agent session has a refresh token.
2711
+ if not await self._is_id_token_usable(session_id_token, store_options) and state_data.get("refresh_token"):
2712
+ refresh_domain = self._get_session_domain(state_data) or await self._resolve_current_domain(store_options)
2713
+ try:
2714
+ refreshed = await self.get_token_by_refresh_token({
2715
+ "refresh_token": state_data["refresh_token"],
2716
+ "domain": refresh_domain,
2717
+ })
2718
+ except (ApiError, AccessTokenError):
2719
+ # A genuine refresh failure means no usable actor; unexpected errors propagate.
2720
+ refreshed = None
2721
+ if refreshed:
2722
+ updated_state_data = State.update_state_data(
2723
+ self.DEFAULT_AUDIENCE_STATE_KEY, state_data, refreshed)
2724
+ await self._state_store.set(self._state_identifier, updated_state_data, options=store_options)
2725
+ session_id_token = refreshed.get("id_token") or session_id_token
2726
+
2727
+ if await self._is_id_token_usable(session_id_token, store_options):
2728
+ return session_id_token, ID_TOKEN_TYPE
2729
+
2730
+ raise CustomTokenExchangeError(
2731
+ CustomTokenExchangeErrorCode.ACTOR_UNAVAILABLE,
2732
+ "No usable actor token: pass actor_token or ensure the agent has a valid session."
2733
+ )
2734
+
2735
+ async def request_session_transfer_token(
2736
+ self,
2737
+ subject_token: str,
2738
+ subject_token_type: str,
2739
+ actor_token: Optional[str] = None,
2740
+ actor_token_type: Optional[str] = None,
2741
+ scope: Optional[str] = None,
2742
+ organization: Optional[str] = None,
2743
+ store_options: Optional[dict[str, Any]] = None
2744
+ ) -> SessionTransferTokenResult:
2745
+ """
2746
+ Requests a Session Transfer Token (STT) for impersonation via session transfer.
2747
+
2748
+ Performs a custom token exchange against the session_transfer audience. The returned
2749
+ STT is opaque and single-use; hand it to build_session_transfer_redirect and do not
2750
+ decode or store it. The act claim is not on this result.
2751
+
2752
+ Args:
2753
+ subject_token: Your proof of which customer to impersonate (validated by your Action)
2754
+ subject_token_type: The subject token type URI routing to your CTE Profile
2755
+ actor_token: The acting party's token; optional. Defaults to the agent session's ID token
2756
+ actor_token_type: Type URI of the actor token; defaults to the ID token URN
2757
+ scope: Space-delimited list of scopes (optional)
2758
+ organization: Organization identifier (optional)
2759
+ store_options: Optional options used to read the agent session and resolve the domain
2760
+
2761
+ Returns:
2762
+ SessionTransferTokenResult containing the STT and its metadata
2763
+
2764
+ Raises:
2765
+ CustomTokenExchangeError: If no actor can be resolved or the exchange fails
2766
+ """
2767
+ try:
2768
+ # Validate the subject up front - before any session read/refresh/network.
2769
+ if not subject_token or not subject_token.strip():
2770
+ raise CustomTokenExchangeError(
2771
+ CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
2772
+ "subject_token cannot be empty or whitespace-only"
2773
+ )
2774
+ if not subject_token_type or not subject_token_type.strip():
2775
+ raise CustomTokenExchangeError(
2776
+ CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
2777
+ "subject_token_type cannot be empty or whitespace-only"
2778
+ )
2779
+
2780
+ actor_token, actor_token_type = await self._resolve_actor_token(
2781
+ actor_token, actor_token_type, store_options)
2782
+
2783
+ # Build the session_transfer audience from the resolved request domain.
2784
+ domain = await self._resolve_current_domain(store_options)
2785
+ audience = f"urn:{domain}:session_transfer"
2786
+
2787
+ options = CustomTokenExchangeOptions(
2788
+ subject_token=subject_token,
2789
+ subject_token_type=subject_token_type,
2790
+ audience=audience,
2791
+ scope=scope,
2792
+ actor_token=actor_token,
2793
+ actor_token_type=actor_token_type,
2794
+ organization=organization,
2795
+ )
2796
+
2797
+ response = await self.custom_token_exchange(options, store_options)
2798
+
2799
+ return SessionTransferTokenResult(
2800
+ session_transfer_token=response.access_token,
2801
+ # Return the server's value as-is; don't default to the STT URN, or a non-STT
2802
+ # response would be mislabelled as an STT.
2803
+ issued_token_type=response.issued_token_type or "",
2804
+ expires_in=response.expires_in,
2805
+ token_type=response.token_type,
2806
+ scope=response.scope,
2807
+ )
2808
+ except (CustomTokenExchangeError, ApiError):
2809
+ raise
2810
+ except Exception as e:
2811
+ raise CustomTokenExchangeError(
2812
+ CustomTokenExchangeErrorCode.TOKEN_EXCHANGE_FAILED,
2813
+ f"Session transfer token request failed: {str(e)}",
2814
+ e
2815
+ )
2816
+
2817
+ def build_session_transfer_redirect(
2818
+ self,
2819
+ target_login_url: str,
2820
+ result: SessionTransferTokenResult,
2821
+ organization: Optional[str] = None
2822
+ ) -> str:
2823
+ """
2824
+ Builds the redirect URL that hands the STT to the target app's login URL.
2825
+
2826
+ target_login_url must be a trusted, app-controlled absolute https URL (http is allowed
2827
+ only for localhost/loopback) - the STT is a single-use credential and must not leak to an
2828
+ untrusted host.
2829
+
2830
+ Args:
2831
+ target_login_url: The target app's login URL (absolute, https)
2832
+ result: The SessionTransferTokenResult from request_session_transfer_token
2833
+ organization: Organization identifier to forward (optional)
2834
+
2835
+ Returns:
2836
+ A URL string with session_transfer_token (and organization) as query parameters
2837
+
2838
+ Raises:
2839
+ MissingRequiredArgumentError: If target_login_url is missing or blank
2840
+ InvalidArgumentError: If target_login_url is not an absolute https URL, or organization is blank
2841
+ """
2842
+ URL.validate_https_redirect_target(target_login_url, "target_login_url")
2843
+
2844
+ params = {"session_transfer_token": result.session_transfer_token}
2845
+ if organization is not None:
2846
+ if not organization.strip():
2847
+ raise InvalidArgumentError("organization", "organization must not be blank")
2848
+ params["organization"] = organization
2849
+
2850
+ return URL.build_url(target_login_url, params)
2851
+
2638
2852
  # ============================================================================
2639
2853
  # MFA (Multi-Factor Authentication)
2640
2854
  # ============================================================================
@@ -397,6 +397,24 @@ class LoginWithCustomTokenExchangeResult(BaseModel):
397
397
  authorization_details: Optional[list[AuthorizationDetails]] = None
398
398
 
399
399
 
400
+ class SessionTransferTokenResult(BaseModel):
401
+ """
402
+ Response from a session transfer token (STT) request.
403
+
404
+ Attributes:
405
+ session_transfer_token: The opaque, single-use session transfer token
406
+ issued_token_type: Format of issued token (the session-transfer URN)
407
+ expires_in: Token lifetime in seconds
408
+ token_type: Token type as returned by the server (typically "N_A")
409
+ scope: Granted scopes (if returned)
410
+ """
411
+ session_transfer_token: str
412
+ issued_token_type: str
413
+ expires_in: int
414
+ token_type: Optional[str] = None
415
+ scope: Optional[str] = None
416
+
417
+
400
418
  # =============================================================================
401
419
  # Connected Accounts Types
402
420
  # =============================================================================
@@ -254,6 +254,9 @@ class CustomTokenExchangeErrorCode:
254
254
  MISSING_ACTOR_TOKEN = "missing_actor_token"
255
255
  TOKEN_EXCHANGE_FAILED = "token_exchange_failed"
256
256
  INVALID_RESPONSE = "invalid_response"
257
+ ACTOR_UNAVAILABLE = "actor_unavailable"
258
+ SETACTOR_REQUIRED = "setactor_required"
259
+ SESSION_TRANSFER_DISABLED = "session_transfer_disabled"
257
260
 
258
261
 
259
262
  # =============================================================================
@@ -33,6 +33,7 @@ from auth0_server_python.auth_types import (
33
33
  PasskeyLoginResult,
34
34
  PasskeySignupChallengeResponse,
35
35
  PasskeyUserProfile,
36
+ SessionTransferTokenResult,
36
37
  StartInteractiveLoginOptions,
37
38
  StateData,
38
39
  TransactionData,
@@ -3953,6 +3954,384 @@ def test_state_merge_preserves_user_act_claim():
3953
3954
  assert updated["user"]["act"] == {"sub": "agent|abc"}
3954
3955
 
3955
3956
 
3957
+ # =============================================================================
3958
+ # Session Transfer Token (STT) Tests
3959
+ # =============================================================================
3960
+
3961
+ # Fixed wire-protocol URNs, pinned as literals so a bad edit to the SDK constants is caught.
3962
+ STT_URN = "urn:auth0:params:oauth:token-type:session_transfer_token"
3963
+ ID_TOKEN_URN = "urn:ietf:params:oauth:token-type:id_token"
3964
+
3965
+
3966
+ def _stt_base_client():
3967
+ """A bare ServerClient for STT tests (no network/session mocking)."""
3968
+ return ServerClient(
3969
+ domain="auth0.local",
3970
+ client_id="<client_id>",
3971
+ client_secret="<client_secret>",
3972
+ state_store=AsyncMock(),
3973
+ transaction_store=AsyncMock(),
3974
+ secret="some-secret",
3975
+ )
3976
+
3977
+
3978
+ def _stt_client(mocker, *, exchange_response=None):
3979
+ """A ServerClient with the token endpoint mocked, returning (client, post_mock)."""
3980
+ client = _stt_base_client()
3981
+ mocker.patch.object(
3982
+ client, "_fetch_oidc_metadata",
3983
+ return_value={"token_endpoint": "https://auth0.local/oauth/token"},
3984
+ )
3985
+ mock_response = MagicMock()
3986
+ mock_response.status_code = 200
3987
+ mock_response.json.return_value = exchange_response or {
3988
+ "access_token": "the-stt",
3989
+ "token_type": "N_A",
3990
+ "expires_in": 60,
3991
+ "scope": "openid profile",
3992
+ "issued_token_type": STT_URN,
3993
+ }
3994
+ mock_response.headers.get.return_value = "application/json"
3995
+
3996
+ post_mock = AsyncMock()
3997
+ post_mock.__aenter__.return_value = post_mock
3998
+ post_mock.__aexit__.return_value = None
3999
+ post_mock.post.return_value = mock_response
4000
+ mocker.patch("httpx.AsyncClient", return_value=post_mock)
4001
+ return client, post_mock
4002
+
4003
+
4004
+ @pytest.mark.asyncio
4005
+ async def test_request_session_transfer_token_success(mocker):
4006
+ """Returns a SessionTransferTokenResult with the STT and session_transfer audience."""
4007
+ client, post_mock = _stt_client(mocker)
4008
+
4009
+ result = await client.request_session_transfer_token(
4010
+ subject_token="subj",
4011
+ subject_token_type="urn:acme:customer-subject",
4012
+ actor_token="agent-id-token",
4013
+ )
4014
+
4015
+ assert isinstance(result, SessionTransferTokenResult)
4016
+ assert result.session_transfer_token == "the-stt"
4017
+ assert result.issued_token_type == STT_URN
4018
+ assert result.expires_in == 60
4019
+
4020
+ data = post_mock.post.call_args[1]["data"]
4021
+ assert data["audience"] == "urn:auth0.local:session_transfer"
4022
+ assert data["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange"
4023
+
4024
+
4025
+ @pytest.mark.asyncio
4026
+ async def test_request_session_transfer_token_sources_actor_from_session(mocker):
4027
+ """When actor_token is omitted, the agent session's ID token is used as the actor."""
4028
+ client, post_mock = _stt_client(mocker)
4029
+ client._state_store.get.return_value = {"id_token": "session-id-token"}
4030
+ mocker.patch.object(client, "_is_id_token_usable", return_value=True)
4031
+
4032
+ await client.request_session_transfer_token(
4033
+ subject_token="subj",
4034
+ subject_token_type="urn:acme:customer-subject",
4035
+ )
4036
+
4037
+ data = post_mock.post.call_args[1]["data"]
4038
+ assert data["actor_token"] == "session-id-token"
4039
+ assert data["actor_token_type"] == ID_TOKEN_URN
4040
+
4041
+
4042
+ @pytest.mark.asyncio
4043
+ async def test_request_session_transfer_token_explicit_actor_overrides_session(mocker):
4044
+ """An explicit actor_token wins and the session is never read."""
4045
+ client, post_mock = _stt_client(mocker)
4046
+
4047
+ await client.request_session_transfer_token(
4048
+ subject_token="subj",
4049
+ subject_token_type="urn:acme:customer-subject",
4050
+ actor_token="explicit-actor",
4051
+ )
4052
+
4053
+ client._state_store.get.assert_not_called()
4054
+ assert post_mock.post.call_args[1]["data"]["actor_token"] == "explicit-actor"
4055
+
4056
+
4057
+ @pytest.mark.asyncio
4058
+ async def test_request_session_transfer_token_refreshes_expired_session_token(mocker):
4059
+ """A stale session ID token is refreshed and the fresh token is used as the actor."""
4060
+ client, post_mock = _stt_client(mocker)
4061
+ client._state_store.get.return_value = {"id_token": "stale", "refresh_token": "rt"}
4062
+ # First check (stale) fails, second (refreshed) passes.
4063
+ mocker.patch.object(client, "_is_id_token_usable", side_effect=[False, True])
4064
+ mocker.patch.object(
4065
+ client, "get_token_by_refresh_token",
4066
+ return_value={"id_token": "fresh-id-token", "access_token": "a", "expires_in": 3600},
4067
+ )
4068
+
4069
+ await client.request_session_transfer_token(
4070
+ subject_token="subj",
4071
+ subject_token_type="urn:acme:customer-subject",
4072
+ )
4073
+
4074
+ assert post_mock.post.call_args[1]["data"]["actor_token"] == "fresh-id-token"
4075
+
4076
+
4077
+ @pytest.mark.asyncio
4078
+ async def test_request_session_transfer_token_never_persists_stt(mocker):
4079
+ """The mint is stateless: the one-shot STT must never be written to the session store."""
4080
+ client, _ = _stt_client(mocker)
4081
+
4082
+ await client.request_session_transfer_token(
4083
+ subject_token="subj",
4084
+ subject_token_type="urn:acme:customer-subject",
4085
+ actor_token="explicit-actor",
4086
+ )
4087
+
4088
+ client._state_store.set.assert_not_called()
4089
+
4090
+
4091
+ @pytest.mark.asyncio
4092
+ async def test_request_session_transfer_token_blank_actor_rejected(mocker):
4093
+ """A passed-but-blank actor_token is a bug, not a fallback signal."""
4094
+ client, _ = _stt_client(mocker)
4095
+
4096
+ with pytest.raises(CustomTokenExchangeError) as exc:
4097
+ await client.request_session_transfer_token(
4098
+ subject_token="subj",
4099
+ subject_token_type="urn:acme:customer-subject",
4100
+ actor_token=" ",
4101
+ )
4102
+ assert exc.value.code == CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT
4103
+
4104
+
4105
+ @pytest.mark.asyncio
4106
+ async def test_request_session_transfer_token_no_actor_raises_before_network(mocker):
4107
+ """No explicit actor and no usable session token fails client-side with ACTOR_UNAVAILABLE."""
4108
+ client, post_mock = _stt_client(mocker)
4109
+ client._state_store.get.return_value = None
4110
+
4111
+ with pytest.raises(CustomTokenExchangeError) as exc:
4112
+ await client.request_session_transfer_token(
4113
+ subject_token="subj",
4114
+ subject_token_type="urn:acme:customer-subject",
4115
+ )
4116
+ assert exc.value.code == CustomTokenExchangeErrorCode.ACTOR_UNAVAILABLE
4117
+ post_mock.post.assert_not_called()
4118
+
4119
+
4120
+ def test_build_session_transfer_redirect_encodes_and_includes_organization():
4121
+ """The redirect URL carries a URL-encoded STT and the organization when present."""
4122
+ client = ServerClient(
4123
+ domain="auth0.local",
4124
+ client_id="<client_id>",
4125
+ client_secret="<client_secret>",
4126
+ state_store=AsyncMock(),
4127
+ transaction_store=AsyncMock(),
4128
+ secret="some-secret",
4129
+ )
4130
+ result = SessionTransferTokenResult(
4131
+ session_transfer_token="a b/c+d",
4132
+ issued_token_type=STT_URN,
4133
+ expires_in=60,
4134
+ )
4135
+
4136
+ url = client.build_session_transfer_redirect(
4137
+ "https://app.example.com/auth/login", result, organization="org_globex"
4138
+ )
4139
+
4140
+ query = parse_qs(urlparse(url).query)
4141
+ assert query["session_transfer_token"] == ["a b/c+d"] # parse_qs decodes; encoding happened on the wire
4142
+ assert query["organization"] == ["org_globex"]
4143
+ assert "a+b" in url or "a%20b" in url # the raw URL is encoded
4144
+
4145
+
4146
+ def test_build_session_transfer_redirect_omits_organization_when_absent():
4147
+ """No organization is passed → the parameter is absent, not empty."""
4148
+ result = SessionTransferTokenResult(
4149
+ session_transfer_token="stt", issued_token_type=STT_URN, expires_in=60
4150
+ )
4151
+
4152
+ url = _stt_base_client().build_session_transfer_redirect(
4153
+ "https://app.example.com/auth/login", result
4154
+ )
4155
+
4156
+ query = parse_qs(urlparse(url).query)
4157
+ assert query["session_transfer_token"] == ["stt"]
4158
+ assert "organization" not in query
4159
+
4160
+
4161
+ def test_build_session_transfer_redirect_appends_to_existing_query():
4162
+ """A target login URL that already has a query string gets the STT appended with '&'."""
4163
+ result = SessionTransferTokenResult(
4164
+ session_transfer_token="stt", issued_token_type=STT_URN, expires_in=60
4165
+ )
4166
+
4167
+ url = _stt_base_client().build_session_transfer_redirect(
4168
+ "https://app.example.com/auth/login?returnTo=/home", result
4169
+ )
4170
+
4171
+ assert url.count("?") == 1
4172
+ query = parse_qs(urlparse(url).query)
4173
+ assert query["returnTo"] == ["/home"]
4174
+ assert query["session_transfer_token"] == ["stt"]
4175
+
4176
+
4177
+ def _stt_result():
4178
+ return SessionTransferTokenResult(
4179
+ session_transfer_token="stt", issued_token_type=STT_URN, expires_in=60
4180
+ )
4181
+
4182
+
4183
+ def test_build_session_transfer_redirect_allows_http_for_localhost():
4184
+ """http is allowed for localhost (local dev)."""
4185
+ url = _stt_base_client().build_session_transfer_redirect(
4186
+ "http://localhost:3000/auth/login", _stt_result())
4187
+ assert "session_transfer_token=stt" in url
4188
+
4189
+
4190
+ def test_build_session_transfer_redirect_allows_http_for_127_0_0_1():
4191
+ """http is allowed for the 127.0.0.1 loopback address."""
4192
+ url = _stt_base_client().build_session_transfer_redirect(
4193
+ "http://127.0.0.1:3000/auth/login", _stt_result())
4194
+ assert "session_transfer_token=stt" in url
4195
+
4196
+
4197
+ def test_build_session_transfer_redirect_rejects_non_https():
4198
+ """A non-loopback http target is rejected - the STT must not ride an insecure URL."""
4199
+ with pytest.raises(InvalidArgumentError):
4200
+ _stt_base_client().build_session_transfer_redirect(
4201
+ "http://app.example.com/auth/login", _stt_result())
4202
+
4203
+
4204
+ def test_build_session_transfer_redirect_rejects_non_absolute():
4205
+ """A non-absolute target URL is rejected."""
4206
+ with pytest.raises(InvalidArgumentError):
4207
+ _stt_base_client().build_session_transfer_redirect("app.example.com/auth/login", _stt_result())
4208
+
4209
+
4210
+ def test_build_session_transfer_redirect_rejects_blank_target():
4211
+ """A blank target URL is rejected."""
4212
+ with pytest.raises(MissingRequiredArgumentError):
4213
+ _stt_base_client().build_session_transfer_redirect(" ", _stt_result())
4214
+
4215
+
4216
+ def test_build_session_transfer_redirect_rejects_fragment():
4217
+ """A fragment is rejected - it would swallow the STT query param, dropping the token."""
4218
+ with pytest.raises(InvalidArgumentError):
4219
+ _stt_base_client().build_session_transfer_redirect(
4220
+ "https://app.example.com/auth/login#section", _stt_result())
4221
+
4222
+
4223
+ def test_build_session_transfer_redirect_rejects_blank_organization():
4224
+ """A blank organization fails fast rather than being forwarded as an empty param."""
4225
+ with pytest.raises(InvalidArgumentError):
4226
+ _stt_base_client().build_session_transfer_redirect(
4227
+ "https://app.example.com/auth/login", _stt_result(), organization=" ")
4228
+
4229
+
4230
+ @pytest.mark.asyncio
4231
+ async def test_request_session_transfer_token_surfaces_server_issued_token_type(mocker):
4232
+ """A non-STT issued_token_type is surfaced verbatim, never fabricated as the STT URN."""
4233
+ client, _ = _stt_client(mocker, exchange_response={
4234
+ "access_token": "tok", "token_type": "Bearer", "expires_in": 300,
4235
+ "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
4236
+ })
4237
+
4238
+ result = await client.request_session_transfer_token(
4239
+ subject_token="subj", subject_token_type="urn:acme:sub", actor_token="a",
4240
+ )
4241
+
4242
+ assert result.issued_token_type == "urn:ietf:params:oauth:token-type:access_token"
4243
+
4244
+
4245
+ @pytest.mark.asyncio
4246
+ async def test_request_session_transfer_token_empty_issued_token_type_when_absent(mocker):
4247
+ """When the server omits issued_token_type, the result carries an empty string, not the URN."""
4248
+ client, _ = _stt_client(mocker, exchange_response={
4249
+ "access_token": "tok", "token_type": "N_A", "expires_in": 60,
4250
+ })
4251
+
4252
+ result = await client.request_session_transfer_token(
4253
+ subject_token="subj", subject_token_type="urn:acme:sub", actor_token="a",
4254
+ )
4255
+
4256
+ assert result.issued_token_type == ""
4257
+
4258
+
4259
+ @pytest.mark.asyncio
4260
+ async def test_request_session_transfer_token_blank_subject_token_rejected_before_network(mocker):
4261
+ """A blank subject_token fails with INVALID_TOKEN_FORMAT before any network call."""
4262
+ client, post_mock = _stt_client(mocker)
4263
+ with pytest.raises(CustomTokenExchangeError) as exc:
4264
+ await client.request_session_transfer_token(
4265
+ subject_token=" ", subject_token_type="urn:acme:sub", actor_token="a")
4266
+ assert exc.value.code == CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT
4267
+ post_mock.post.assert_not_called()
4268
+
4269
+
4270
+ @pytest.mark.asyncio
4271
+ async def test_request_session_transfer_token_blank_subject_token_type_rejected(mocker):
4272
+ """A blank subject_token_type fails with INVALID_TOKEN_FORMAT before any network call."""
4273
+ client, post_mock = _stt_client(mocker)
4274
+ with pytest.raises(CustomTokenExchangeError) as exc:
4275
+ await client.request_session_transfer_token(
4276
+ subject_token="subj", subject_token_type=" ", actor_token="a")
4277
+ assert exc.value.code == CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT
4278
+ post_mock.post.assert_not_called()
4279
+
4280
+
4281
+ @pytest.mark.asyncio
4282
+ async def test_request_session_transfer_token_refresh_without_id_token_is_unavailable(mocker):
4283
+ """A refresh that returns no ID token leaves no usable actor -> ACTOR_UNAVAILABLE."""
4284
+ client, _ = _stt_client(mocker)
4285
+ client._state_store.get.return_value = {"id_token": "stale", "refresh_token": "rt"}
4286
+ mocker.patch.object(client, "_is_id_token_usable", return_value=False)
4287
+ mocker.patch.object(client, "get_token_by_refresh_token",
4288
+ return_value={"access_token": "a", "expires_in": 3600}) # no id_token
4289
+
4290
+ with pytest.raises(CustomTokenExchangeError) as exc:
4291
+ await client.request_session_transfer_token(
4292
+ subject_token="subj", subject_token_type="urn:acme:sub",
4293
+ )
4294
+ assert exc.value.code == CustomTokenExchangeErrorCode.ACTOR_UNAVAILABLE
4295
+
4296
+
4297
+ @pytest.mark.asyncio
4298
+ async def test_request_session_transfer_token_surfaces_server_error(mocker):
4299
+ """A server 400 surfaces as CustomTokenExchangeError carrying the server's error/description."""
4300
+ client, post_mock = _stt_client(mocker)
4301
+ post_mock.post.return_value.status_code = 400
4302
+ post_mock.post.return_value.json.return_value = {
4303
+ "error": "invalid_request",
4304
+ "error_description": "setActor is required when requesting a session transfer token via token exchange.",
4305
+ }
4306
+
4307
+ with pytest.raises(CustomTokenExchangeError) as exc:
4308
+ await client.request_session_transfer_token(
4309
+ subject_token="subj", subject_token_type="urn:acme:sub", actor_token="a",
4310
+ )
4311
+ assert exc.value.code == "invalid_request"
4312
+ assert "setActor is required" in str(exc.value)
4313
+
4314
+
4315
+ @pytest.mark.asyncio
4316
+ async def test_request_session_transfer_token_rejects_cross_domain_session_in_resolver_mode(mocker):
4317
+ """In resolver mode, an actor is not sourced from a session on a different domain."""
4318
+ async def resolver(context):
4319
+ return "tenant-a.auth0.com"
4320
+
4321
+ client = ServerClient(
4322
+ domain=resolver, client_id="<client_id>", client_secret="<client_secret>",
4323
+ state_store=AsyncMock(), transaction_store=AsyncMock(), secret="some-secret",
4324
+ )
4325
+ client._state_store.get.return_value = {"id_token": "t", "domain": "tenant-b.auth0.com"}
4326
+
4327
+ with pytest.raises(CustomTokenExchangeError) as exc:
4328
+ await client.request_session_transfer_token(
4329
+ subject_token="subj", subject_token_type="urn:acme:sub",
4330
+ store_options={"request": None, "response": None},
4331
+ )
4332
+ assert exc.value.code == CustomTokenExchangeErrorCode.ACTOR_UNAVAILABLE
4333
+
4334
+
3956
4335
  # =============================================================================
3957
4336
  # OIDC Metadata and JWKS Fetching Tests
3958
4337
  # =============================================================================
@@ -5009,8 +5388,9 @@ async def test_domain_migration_sessions_isolated():
5009
5388
  user = await client.get_user(store_options={"request": {}})
5010
5389
  assert user is None
5011
5390
 
5012
-
5013
- # ── MFA Integration Tests ────────────────────────────────────────────────────
5391
+ # =============================================================================
5392
+ # MFA Tests
5393
+ # =============================================================================
5014
5394
 
5015
5395
 
5016
5396
  @pytest.mark.asyncio
@@ -5231,9 +5611,10 @@ async def test_get_access_token_mfa_required_with_enroll_requirements(mocker):
5231
5611
 
5232
5612
 
5233
5613
  # =============================================================================
5234
- # Organization and Invitation Tests
5614
+ # ORGANIZATIONS AND INVITATION TESTS
5235
5615
  # =============================================================================
5236
5616
 
5617
+
5237
5618
  def _make_org_client(mocker, transaction_data: TransactionData, **extra):
5238
5619
  """Helper: build a ServerClient with mocked stores and standard JWT mocks."""
5239
5620
  mock_tx_store = AsyncMock()
@@ -8,7 +8,12 @@ from typing import Any, Optional
8
8
  from urllib.parse import parse_qs, urlencode, urlparse
9
9
 
10
10
  from auth0_server_python.auth_types import DomainResolverContext
11
- from auth0_server_python.error import DomainResolverError, OrganizationTokenValidationError
11
+ from auth0_server_python.error import (
12
+ DomainResolverError,
13
+ InvalidArgumentError,
14
+ MissingRequiredArgumentError,
15
+ OrganizationTokenValidationError,
16
+ )
12
17
 
13
18
 
14
19
  class PKCE:
@@ -218,6 +223,27 @@ class State:
218
223
 
219
224
 
220
225
  class URL:
226
+ @staticmethod
227
+ def validate_https_redirect_target(url: str, name: str) -> None:
228
+ """
229
+ Require url to be an absolute https URL (http allowed only for localhost/loopback).
230
+
231
+ Raises MissingRequiredArgumentError if blank, InvalidArgumentError otherwise.
232
+ """
233
+ if not url or not url.strip():
234
+ raise MissingRequiredArgumentError(name)
235
+ parsed = urlparse(url)
236
+ if not parsed.scheme or not parsed.netloc:
237
+ raise InvalidArgumentError(name, "must be an absolute URL")
238
+ is_loopback = parsed.hostname in ("localhost", "127.0.0.1", "::1")
239
+ if parsed.scheme != "https" and not (parsed.scheme == "http" and is_loopback):
240
+ raise InvalidArgumentError(
241
+ name, "must use https (http is allowed only for localhost/loopback)"
242
+ )
243
+ # A fragment would swallow the appended query params, dropping the token silently.
244
+ if parsed.fragment:
245
+ raise InvalidArgumentError(name, "must not contain a fragment")
246
+
221
247
  @staticmethod
222
248
  def build_url(base_url: str, params: dict[str, Any]) -> str:
223
249
  """