@@ -3963,16 +3963,21 @@ def test_state_merge_preserves_user_act_claim():
39633963ID_TOKEN_URN = "urn:ietf:params:oauth:token-type:id_token"
39643964
39653965
3966- def _stt_client ( mocker , * , exchange_response = None ):
3967- """A ServerClient with the token endpoint mocked, returning (client, post_mock )."""
3968- client = ServerClient (
3966+ def _stt_base_client ( ):
3967+ """A bare ServerClient for STT tests (no network/session mocking )."""
3968+ return ServerClient (
39693969 domain = "auth0.local" ,
39703970 client_id = "<client_id>" ,
39713971 client_secret = "<client_secret>" ,
39723972 state_store = AsyncMock (),
39733973 transaction_store = AsyncMock (),
39743974 secret = "some-secret" ,
39753975 )
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 ()
39763981 mocker .patch .object (
39773982 client , "_fetch_oidc_metadata" ,
39783983 return_value = {"token_endpoint" : "https://auth0.local/oauth/token" },
@@ -4138,24 +4143,13 @@ def test_build_session_transfer_redirect_encodes_and_includes_organization():
41384143 assert "a+b" in url or "a%20b" in url # the raw URL is encoded
41394144
41404145
4141- def _redirect_client ():
4142- return ServerClient (
4143- domain = "auth0.local" ,
4144- client_id = "<client_id>" ,
4145- client_secret = "<client_secret>" ,
4146- state_store = AsyncMock (),
4147- transaction_store = AsyncMock (),
4148- secret = "some-secret" ,
4149- )
4150-
4151-
41524146def test_build_session_transfer_redirect_omits_organization_when_absent ():
41534147 """No organization is passed → the parameter is absent, not empty."""
41544148 result = SessionTransferTokenResult (
41554149 session_transfer_token = "stt" , issued_token_type = STT_URN , expires_in = 60
41564150 )
41574151
4158- url = _redirect_client ().build_session_transfer_redirect (
4152+ url = _stt_base_client ().build_session_transfer_redirect (
41594153 "https://app.example.com/auth/login" , result
41604154 )
41614155
@@ -4170,7 +4164,7 @@ def test_build_session_transfer_redirect_appends_to_existing_query():
41704164 session_transfer_token = "stt" , issued_token_type = STT_URN , expires_in = 60
41714165 )
41724166
4173- url = _redirect_client ().build_session_transfer_redirect (
4167+ url = _stt_base_client ().build_session_transfer_redirect (
41744168 "https://app.example.com/auth/login?returnTo=/home" , result
41754169 )
41764170
@@ -4180,6 +4174,139 @@ def test_build_session_transfer_redirect_appends_to_existing_query():
41804174 assert query ["session_transfer_token" ] == ["stt" ]
41814175
41824176
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_blank_organization ():
4217+ """A blank organization fails fast rather than being forwarded as an empty param."""
4218+ with pytest .raises (InvalidArgumentError ):
4219+ _stt_base_client ().build_session_transfer_redirect (
4220+ "https://app.example.com/auth/login" , _stt_result (), organization = " " )
4221+
4222+
4223+ @pytest .mark .asyncio
4224+ async def test_request_session_transfer_token_surfaces_server_issued_token_type (mocker ):
4225+ """A non-STT issued_token_type is surfaced verbatim, never fabricated as the STT URN."""
4226+ client , _ = _stt_client (mocker , exchange_response = {
4227+ "access_token" : "tok" , "token_type" : "Bearer" , "expires_in" : 300 ,
4228+ "issued_token_type" : "urn:ietf:params:oauth:token-type:access_token" ,
4229+ })
4230+
4231+ result = await client .request_session_transfer_token (
4232+ subject_token = "subj" , subject_token_type = "urn:acme:sub" , actor_token = "a" ,
4233+ )
4234+
4235+ assert result .issued_token_type == "urn:ietf:params:oauth:token-type:access_token"
4236+
4237+
4238+ @pytest .mark .asyncio
4239+ async def test_request_session_transfer_token_empty_issued_token_type_when_absent (mocker ):
4240+ """When the server omits issued_token_type, the result carries an empty string, not the URN."""
4241+ client , _ = _stt_client (mocker , exchange_response = {
4242+ "access_token" : "tok" , "token_type" : "N_A" , "expires_in" : 60 ,
4243+ })
4244+
4245+ result = await client .request_session_transfer_token (
4246+ subject_token = "subj" , subject_token_type = "urn:acme:sub" , actor_token = "a" ,
4247+ )
4248+
4249+ assert result .issued_token_type == ""
4250+
4251+
4252+ @pytest .mark .asyncio
4253+ async def test_request_session_transfer_token_blank_subject_token_rejected_before_network (mocker ):
4254+ """A blank subject_token fails with INVALID_TOKEN_FORMAT before any network call."""
4255+ client , post_mock = _stt_client (mocker )
4256+ with pytest .raises (CustomTokenExchangeError ) as exc :
4257+ await client .request_session_transfer_token (
4258+ subject_token = " " , subject_token_type = "urn:acme:sub" , actor_token = "a" )
4259+ assert exc .value .code == CustomTokenExchangeErrorCode .INVALID_TOKEN_FORMAT
4260+ post_mock .post .assert_not_called ()
4261+
4262+
4263+ @pytest .mark .asyncio
4264+ async def test_request_session_transfer_token_blank_subject_token_type_rejected (mocker ):
4265+ """A blank subject_token_type fails with INVALID_TOKEN_FORMAT before any network call."""
4266+ client , post_mock = _stt_client (mocker )
4267+ with pytest .raises (CustomTokenExchangeError ) as exc :
4268+ await client .request_session_transfer_token (
4269+ subject_token = "subj" , subject_token_type = " " , actor_token = "a" )
4270+ assert exc .value .code == CustomTokenExchangeErrorCode .INVALID_TOKEN_FORMAT
4271+ post_mock .post .assert_not_called ()
4272+
4273+
4274+ @pytest .mark .asyncio
4275+ async def test_request_session_transfer_token_refresh_without_id_token_is_unavailable (mocker ):
4276+ """A refresh that returns no ID token leaves no usable actor -> ACTOR_UNAVAILABLE."""
4277+ client , _ = _stt_client (mocker )
4278+ client ._state_store .get .return_value = {"id_token" : "stale" , "refresh_token" : "rt" }
4279+ mocker .patch .object (client , "_is_id_token_usable" , return_value = False )
4280+ mocker .patch .object (client , "get_token_by_refresh_token" ,
4281+ return_value = {"access_token" : "a" , "expires_in" : 3600 }) # no id_token
4282+
4283+ with pytest .raises (CustomTokenExchangeError ) as exc :
4284+ await client .request_session_transfer_token (
4285+ subject_token = "subj" , subject_token_type = "urn:acme:sub" ,
4286+ )
4287+ assert exc .value .code == CustomTokenExchangeErrorCode .ACTOR_UNAVAILABLE
4288+
4289+
4290+ @pytest .mark .asyncio
4291+ async def test_request_session_transfer_token_rejects_cross_domain_session_in_resolver_mode (mocker ):
4292+ """In resolver mode, an actor is not sourced from a session on a different domain."""
4293+ async def resolver (context ):
4294+ return "tenant-a.auth0.com"
4295+
4296+ client = ServerClient (
4297+ domain = resolver , client_id = "<client_id>" , client_secret = "<client_secret>" ,
4298+ state_store = AsyncMock (), transaction_store = AsyncMock (), secret = "some-secret" ,
4299+ )
4300+ client ._state_store .get .return_value = {"id_token" : "t" , "domain" : "tenant-b.auth0.com" }
4301+
4302+ with pytest .raises (CustomTokenExchangeError ) as exc :
4303+ await client .request_session_transfer_token (
4304+ subject_token = "subj" , subject_token_type = "urn:acme:sub" ,
4305+ store_options = {"request" : None , "response" : None },
4306+ )
4307+ assert exc .value .code == CustomTokenExchangeErrorCode .ACTOR_UNAVAILABLE
4308+
4309+
41834310# =============================================================================
41844311# OIDC Metadata and JWKS Fetching Tests
41854312# =============================================================================
0 commit comments