Skip to content

Commit a070d41

Browse files
fix: address STT review feedback on PR #139
- Validate the redirect target URL in build_session_transfer_redirect: require an absolute https URL (http allowed only for localhost/loopback), via a reusable URL.validate_https_redirect_target helper. Prevents leaking the single-use STT to an untrusted host. - Surface issued_token_type exactly as the server returned it instead of defaulting to the STT URN, so a non-STT response is not mislabelled. - Validate subject_token/subject_token_type up front, before any session read or network. - Narrow the swallowed exceptions in actor resolution: the refresh catches only ApiError/AccessTokenError and _is_id_token_usable only token-verification errors, so infrastructure failures propagate instead of being masked as ACTOR_UNAVAILABLE. - Source the refresh domain from the session (resolver-safe) and reject an actor sourced from a session on a different domain in resolver mode. - Note the aud==client_id assumption in _is_id_token_usable. - Add tests for redirect validation, issued_token_type passthrough, up-front subject validation, and the actor-resolution paths.
1 parent afa2097 commit a070d41

4 files changed

Lines changed: 220 additions & 28 deletions

File tree

examples/CustomTokenExchange.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ if act:
263263

264264
- `ACTOR_UNAVAILABLE`: no usable actor token (client-side; raised before any network call)
265265
- `SETACTOR_REQUIRED`: an STT was requested but the Action did not call `setActor` (server 400)
266-
- `SESSION_TRANSFER_DISABLED`: the session-transfer feature is off for the tenant/client (server 400)
266+
- `SESSION_TRANSFER_DISABLED`: the session-transfer feature is not enabled for the tenant/client (server 400)
267267

268268
## Additional Resources
269269

src/auth0_server_python/auth_server/server_client.py

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2656,13 +2656,16 @@ async def _is_id_token_usable(self, token: str, store_options: Optional[dict[str
26562656
"""
26572657
if not token:
26582658
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)
26592662
try:
2660-
domain = await self._resolve_current_domain(store_options)
2661-
metadata = await self._get_oidc_metadata_cached(domain)
2662-
jwks = await self._get_jwks_cached(domain, metadata)
2663+
# aud is the client_id for a standard Auth0 ID token.
26632664
await self._verify_and_decode_jwt(token, jwks, audience=self._client_id)
26642665
return True
2665-
except Exception:
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.
26662669
return False
26672670

26682671
async def _resolve_actor_token(
@@ -2692,21 +2695,34 @@ async def _resolve_actor_token(
26922695
state_data = state_data.dict()
26932696
state_data = state_data or {}
26942697

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+
26952708
session_id_token = state_data.get("id_token")
26962709

26972710
# Refresh a stale (or missing) ID token when the agent session has a refresh token.
26982711
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)
26992713
try:
27002714
refreshed = await self.get_token_by_refresh_token({
27012715
"refresh_token": state_data["refresh_token"],
2702-
"domain": state_data.get("domain") or self._domain,
2716+
"domain": refresh_domain,
27032717
})
2718+
except (ApiError, AccessTokenError):
2719+
# A genuine refresh failure means no usable actor; unexpected errors propagate.
2720+
refreshed = None
2721+
if refreshed:
27042722
updated_state_data = State.update_state_data(
27052723
self.DEFAULT_AUDIENCE_STATE_KEY, state_data, refreshed)
27062724
await self._state_store.set(self._state_identifier, updated_state_data, options=store_options)
27072725
session_id_token = refreshed.get("id_token") or session_id_token
2708-
except Exception:
2709-
session_id_token = None
27102726

27112727
if await self._is_id_token_usable(session_id_token, store_options):
27122728
return session_id_token, ID_TOKEN_TYPE
@@ -2749,6 +2765,18 @@ async def request_session_transfer_token(
27492765
CustomTokenExchangeError: If no actor can be resolved or the exchange fails
27502766
"""
27512767
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+
27522780
actor_token, actor_token_type = await self._resolve_actor_token(
27532781
actor_token, actor_token_type, store_options)
27542782

@@ -2770,7 +2798,9 @@ async def request_session_transfer_token(
27702798

27712799
return SessionTransferTokenResult(
27722800
session_transfer_token=response.access_token,
2773-
issued_token_type=response.issued_token_type or SESSION_TRANSFER_TOKEN_TYPE,
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 "",
27742804
expires_in=response.expires_in,
27752805
token_type=response.token_type,
27762806
scope=response.scope,
@@ -2793,16 +2823,28 @@ def build_session_transfer_redirect(
27932823
"""
27942824
Builds the redirect URL that hands the STT to the target app's login URL.
27952825
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+
27962830
Args:
2797-
target_login_url: The target app's login URL
2831+
target_login_url: The target app's login URL (absolute, https)
27982832
result: The SessionTransferTokenResult from request_session_transfer_token
27992833
organization: Organization identifier to forward (optional)
28002834
28012835
Returns:
28022836
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
28032841
"""
2842+
URL.validate_https_redirect_target(target_login_url, "target_login_url")
2843+
28042844
params = {"session_transfer_token": result.session_transfer_token}
2805-
if organization:
2845+
if organization is not None:
2846+
if not organization.strip():
2847+
raise InvalidArgumentError("organization", "organization must not be blank")
28062848
params["organization"] = organization
28072849

28082850
return URL.build_url(target_login_url, params)

src/auth0_server_python/tests/test_server_client.py

Lines changed: 143 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3963,16 +3963,21 @@ def test_state_merge_preserves_user_act_claim():
39633963
ID_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-
41524146
def 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
# =============================================================================

src/auth0_server_python/utils/helpers.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@
88
from urllib.parse import parse_qs, urlencode, urlparse
99

1010
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+
)
1217

1318

1419
class PKCE:
@@ -218,6 +223,24 @@ def is_session_ceiling_in_past(
218223

219224

220225
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+
221244
@staticmethod
222245
def build_url(base_url: str, params: dict[str, Any]) -> str:
223246
"""

0 commit comments

Comments
 (0)