Skip to content

Commit ee6bcdc

Browse files
fix(cte): harden act-claim extraction against decode and issuer failures
The act-claim enrichment in custom_token_exchange ran _verify_and_decode_jwt with no error handling, so a present-but-undecodable id_token turned an already-issued exchange into a generic TOKEN_EXCHANGE_FAILED. It also skipped the normalized issuer check the login path applies before trusting claims. Wrap the enrichment so any decode/verify failure leaves act=None instead of failing the exchange, and apply the issuer check before reading the claim. Rewrite the opaque-token test (it never exercised the decode path) and add coverage for the undecodable-id_token and issuer-mismatch cases.
1 parent 19adbcf commit ee6bcdc

2 files changed

Lines changed: 127 additions & 9 deletions

File tree

src/auth0_server_python/auth_server/server_client.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2336,13 +2336,21 @@ async def custom_token_exchange(
23362336

23372337
token_response = TokenExchangeResponse(**token_data)
23382338

2339-
# Surface the actor claim for delegation exchanges
2339+
# Surface the actor claim for delegation exchanges. Best-effort:
2340+
# a decode/verify hiccup must not fail an exchange the token
2341+
# endpoint already accepted, so act stays None on any failure.
23402342
if options.actor_token and token_response.id_token:
2341-
jwks = await self._get_jwks_cached(domain, metadata)
2342-
claims = await self._verify_and_decode_jwt(
2343-
token_response.id_token, jwks, audience=self._client_id
2344-
)
2345-
token_response.act = claims.get("act")
2343+
try:
2344+
jwks = await self._get_jwks_cached(domain, metadata)
2345+
claims = await self._verify_and_decode_jwt(
2346+
token_response.id_token, jwks, audience=self._client_id
2347+
)
2348+
# Apply the same normalized issuer check the login path uses
2349+
# before trusting any claim from the token.
2350+
if self._normalize_url(claims.get("iss", "")) == self._normalize_url(metadata.get("issuer")):
2351+
token_response.act = claims.get("act")
2352+
except Exception:
2353+
token_response.act = None
23462354

23472355
return token_response
23482356

src/auth0_server_python/tests/test_server_client.py

Lines changed: 113 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3397,11 +3397,15 @@ async def test_custom_token_exchange_surfaces_act_claim(mocker):
33973397

33983398
mocker.patch.object(
33993399
client, "_fetch_oidc_metadata",
3400-
return_value={"token_endpoint": "https://auth0.local/oauth/token"}
3400+
return_value={
3401+
"token_endpoint": "https://auth0.local/oauth/token",
3402+
"issuer": "https://auth0.local/",
3403+
}
34013404
)
34023405
mocker.patch.object(client, "_get_jwks_cached", return_value={"keys": []})
34033406
mocker.patch.object(client, "_verify_and_decode_jwt", return_value={
34043407
"sub": "user123",
3408+
"iss": "https://auth0.local/",
34053409
"act": {"sub": "agent|abc", "act": {"sub": "svc|xyz"}},
34063410
})
34073411

@@ -3433,8 +3437,8 @@ async def test_custom_token_exchange_surfaces_act_claim(mocker):
34333437

34343438

34353439
@pytest.mark.asyncio
3436-
async def test_custom_token_exchange_act_none_for_opaque_token(mocker):
3437-
"""An undecodable (opaque) token leaves act as None rather than raising."""
3440+
async def test_custom_token_exchange_act_none_when_no_id_token(mocker):
3441+
"""A response without an id_token leaves act as None (decode path skipped)."""
34383442
client = ServerClient(
34393443
domain="auth0.local",
34403444
client_id="<client_id>",
@@ -3474,6 +3478,112 @@ async def test_custom_token_exchange_act_none_for_opaque_token(mocker):
34743478
assert result.act is None
34753479

34763480

3481+
@pytest.mark.asyncio
3482+
async def test_custom_token_exchange_act_none_when_id_token_undecodable(mocker):
3483+
"""A present-but-undecodable id_token leaves act None without failing the exchange."""
3484+
client = ServerClient(
3485+
domain="auth0.local",
3486+
client_id="<client_id>",
3487+
client_secret="<client_secret>",
3488+
state_store=AsyncMock(),
3489+
transaction_store=AsyncMock(),
3490+
secret="some-secret"
3491+
)
3492+
3493+
mocker.patch.object(
3494+
client, "_fetch_oidc_metadata",
3495+
return_value={
3496+
"token_endpoint": "https://auth0.local/oauth/token",
3497+
"issuer": "https://auth0.local/",
3498+
}
3499+
)
3500+
mocker.patch.object(client, "_get_jwks_cached", return_value={"keys": []})
3501+
# No matching JWKS key -> _verify_and_decode_jwt raises; must not bubble up.
3502+
mocker.patch.object(
3503+
client, "_verify_and_decode_jwt",
3504+
side_effect=ValueError("No matching key found in JWKS for kid: abc")
3505+
)
3506+
3507+
mock_response = MagicMock()
3508+
mock_response.status_code = 200
3509+
mock_response.json.return_value = {
3510+
"access_token": "delegated_token",
3511+
"token_type": "Bearer",
3512+
"expires_in": 1800,
3513+
"id_token": "header.payload.sig",
3514+
}
3515+
mock_response.headers.get.return_value = "application/json"
3516+
3517+
mock_httpx_client = AsyncMock()
3518+
mock_httpx_client.__aenter__.return_value = mock_httpx_client
3519+
mock_httpx_client.__aexit__.return_value = None
3520+
mock_httpx_client.post.return_value = mock_response
3521+
mocker.patch("httpx.AsyncClient", return_value=mock_httpx_client)
3522+
3523+
result = await client.custom_token_exchange(CustomTokenExchangeOptions(
3524+
subject_token="user-token",
3525+
subject_token_type="urn:ietf:params:oauth:token-type:access_token",
3526+
actor_token="service-token",
3527+
actor_token_type="urn:ietf:params:oauth:token-type:access_token",
3528+
))
3529+
3530+
# Exchange still succeeds; only the act enrichment is skipped.
3531+
assert result.access_token == "delegated_token"
3532+
assert result.act is None
3533+
3534+
3535+
@pytest.mark.asyncio
3536+
async def test_custom_token_exchange_act_dropped_on_issuer_mismatch(mocker):
3537+
"""An id_token from an unexpected issuer does not surface its act claim."""
3538+
client = ServerClient(
3539+
domain="auth0.local",
3540+
client_id="<client_id>",
3541+
client_secret="<client_secret>",
3542+
state_store=AsyncMock(),
3543+
transaction_store=AsyncMock(),
3544+
secret="some-secret"
3545+
)
3546+
3547+
mocker.patch.object(
3548+
client, "_fetch_oidc_metadata",
3549+
return_value={
3550+
"token_endpoint": "https://auth0.local/oauth/token",
3551+
"issuer": "https://auth0.local/",
3552+
}
3553+
)
3554+
mocker.patch.object(client, "_get_jwks_cached", return_value={"keys": []})
3555+
mocker.patch.object(client, "_verify_and_decode_jwt", return_value={
3556+
"sub": "user123",
3557+
"iss": "https://evil.example.com/",
3558+
"act": {"sub": "agent|abc"},
3559+
})
3560+
3561+
mock_response = MagicMock()
3562+
mock_response.status_code = 200
3563+
mock_response.json.return_value = {
3564+
"access_token": "delegated_token",
3565+
"token_type": "Bearer",
3566+
"expires_in": 1800,
3567+
"id_token": "header.payload.sig",
3568+
}
3569+
mock_response.headers.get.return_value = "application/json"
3570+
3571+
mock_httpx_client = AsyncMock()
3572+
mock_httpx_client.__aenter__.return_value = mock_httpx_client
3573+
mock_httpx_client.__aexit__.return_value = None
3574+
mock_httpx_client.post.return_value = mock_response
3575+
mocker.patch("httpx.AsyncClient", return_value=mock_httpx_client)
3576+
3577+
result = await client.custom_token_exchange(CustomTokenExchangeOptions(
3578+
subject_token="user-token",
3579+
subject_token_type="urn:ietf:params:oauth:token-type:access_token",
3580+
actor_token="service-token",
3581+
actor_token_type="urn:ietf:params:oauth:token-type:access_token",
3582+
))
3583+
3584+
assert result.act is None
3585+
3586+
34773587
# =============================================================================
34783588
# Login with Custom Token Exchange Tests
34793589
# =============================================================================

0 commit comments

Comments
 (0)