@@ -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