Skip to content

Commit a9debdd

Browse files
fix: scoped logout, backchannel logout
1 parent 1c5f2d8 commit a9debdd

File tree

3 files changed

+47
-13
lines changed

3 files changed

+47
-13
lines changed

src/auth0_server_python/auth_server/server_client.py

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -768,13 +768,22 @@ async def logout(
768768
) -> str:
769769
options = options or LogoutOptions()
770770

771-
# Delete the session from the state store
772-
await self._state_store.delete(self._state_identifier, store_options)
771+
if not self._domain_resolver:
772+
await self._state_store.delete(self._state_identifier, store_options)
773+
domain = self._domain
774+
else:
775+
# Resolver mode: delete session if domains match
776+
domain = await self._resolve_current_domain(store_options)
777+
state_data = await self._state_store.get(self._state_identifier, store_options)
773778

774-
# Resolve domain dynamically for MCD support
775-
domain = await self._resolve_current_domain(store_options)
779+
if state_data:
780+
if hasattr(state_data, "dict") and callable(state_data.dict):
781+
state_data = state_data.dict()
782+
session_domain = state_data.get("domain")
783+
if session_domain and self._normalize_issuer(session_domain) == self._normalize_issuer(domain):
784+
await self._state_store.delete(self._state_identifier, store_options)
776785

777-
# Use the URL helper to create the logout URL.
786+
# Return logout URL for the current resolved domain
778787
logout_url = URL.create_logout_url(
779788
domain, self._client_id, options.return_to)
780789

@@ -828,7 +837,7 @@ async def handle_backchannel_logout(
828837
jwks = await self._get_jwks_cached(domain)
829838

830839
try:
831-
claims = await self._verify_and_decode_jwt(logout_token, jwks)
840+
claims = await self._verify_and_decode_jwt(logout_token, jwks, audience=self._client_id)
832841

833842
# Normalized issuer validation
834843
token_issuer = claims.get("iss", "")
@@ -861,7 +870,12 @@ async def handle_backchannel_logout(
861870
sid=claims.get("sid")
862871
)
863872

864-
await self._state_store.delete_by_logout_token(logout_claims.dict(), store_options)
873+
# In resolver mode, include iss for issuer-scoped deletion
874+
claims_dict = logout_claims.dict()
875+
if self._domain_resolver:
876+
claims_dict["iss"] = claims.get("iss")
877+
878+
await self._state_store.delete_by_logout_token(claims_dict, store_options)
865879

866880
except (jwt.PyJWTError, ValidationError) as e:
867881
raise BackchannelLogoutError(
@@ -1473,7 +1487,9 @@ async def start_link_user(
14731487

14741488
# In resolver mode, reject sessions without domain or with mismatched domain
14751489
if self._domain_resolver:
1476-
session_domain = state_data.get('domain') if isinstance(state_data, dict) else getattr(state_data, 'domain', None)
1490+
if hasattr(state_data, "dict") and callable(state_data.dict):
1491+
state_data = state_data.dict()
1492+
session_domain = state_data.get('domain')
14771493
if not session_domain:
14781494
raise StartLinkUserError(
14791495
"Session is missing domain. User needs to re-authenticate."
@@ -1568,7 +1584,9 @@ async def start_unlink_user(
15681584

15691585
# In resolver mode, reject sessions without domain or with mismatched domain
15701586
if self._domain_resolver:
1571-
session_domain = state_data.get('domain') if isinstance(state_data, dict) else getattr(state_data, 'domain', None)
1587+
if hasattr(state_data, "dict") and callable(state_data.dict):
1588+
state_data = state_data.dict()
1589+
session_domain = state_data.get('domain')
15721590
if not session_domain:
15731591
raise StartLinkUserError(
15741592
"Session is missing domain. User needs to re-authenticate."

src/auth0_server_python/store/abstract.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,8 @@ async def delete_by_logout_token(self, claims: dict[str, Any], options: Optional
9696
Delete sessions based on logout token claims.
9797
9898
Args:
99-
claims: Claims from the logout token
99+
claims: Claims from the logout token (sub, sid, and optionally iss
100+
in MCD mode for issuer-scoped deletion)
100101
options: Additional operation-specific options
101102
102103
Note:

src/auth0_server_python/tests/test_server_client.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1445,14 +1445,20 @@ async def test_handle_backchannel_logout_ok(mocker):
14451445
mock_signing_key = mocker.MagicMock()
14461446
mock_signing_key.key = "mock_pem_key"
14471447
mocker.patch("jwt.PyJWK.from_dict", return_value=mock_signing_key)
1448-
mocker.patch("jwt.decode", return_value={
1448+
mock_jwt_decode = mocker.patch("jwt.decode", return_value={
14491449
"events": {"http://schemas.openid.net/event/backchannel-logout": {}},
14501450
"iss": "https://auth0.local",
14511451
"sub": "user_sub",
14521452
"sid": "session_id_123"
14531453
})
14541454

14551455
await client.handle_backchannel_logout("some_logout_token")
1456+
1457+
# Verify audience is passed to jwt.decode
1458+
call_kwargs = mock_jwt_decode.call_args[1]
1459+
assert call_kwargs["audience"] == "client_id"
1460+
1461+
# In static mode, iss should NOT be included
14561462
mock_state_store.delete_by_logout_token.assert_awaited_once_with(
14571463
{"sub": "user_sub", "sid": "session_id_123"},
14581464
None
@@ -1498,7 +1504,7 @@ async def domain_resolver(context):
14981504
return_value={"keys": [{"kty": "RSA", "kid": "test-key"}]}
14991505
)
15001506

1501-
mocker.patch.object(client, "_verify_and_decode_jwt", return_value={
1507+
mock_verify = mocker.patch.object(client, "_verify_and_decode_jwt", return_value={
15021508
"iss": "https://tenant1.auth0.com/",
15031509
"events": {"http://schemas.openid.net/event/backchannel-logout": {}},
15041510
"sub": "user123",
@@ -1507,8 +1513,14 @@ async def domain_resolver(context):
15071513

15081514
await client.handle_backchannel_logout("some_logout_token")
15091515

1516+
# Verify audience is passed to JWT verification
1517+
mock_verify.assert_awaited_once()
1518+
call_kwargs = mock_verify.call_args[1]
1519+
assert call_kwargs["audience"] == "test_client"
1520+
1521+
# In resolver mode, iss should be included for issuer-scoped deletion
15101522
mock_state_store.delete_by_logout_token.assert_awaited_once_with(
1511-
{"sub": "user123", "sid": "session123"},
1523+
{"sub": "user123", "sid": "session123", "iss": "https://tenant1.auth0.com/"},
15121524
None
15131525
)
15141526

@@ -4405,6 +4417,7 @@ async def domain_resolver(context):
44054417
return current_domain
44064418

44074419
mock_state_store = AsyncMock()
4420+
mock_state_store.get.return_value = {"domain": current_domain, "user": {"sub": "user1"}}
44084421

44094422
client = ServerClient(
44104423
domain=domain_resolver,
@@ -4420,6 +4433,8 @@ async def domain_resolver(context):
44204433
# Verify logout URL uses current domain
44214434
assert current_domain in logout_url
44224435
assert logout_url.startswith(f"https://{current_domain}")
4436+
# Verify session was deleted (domains match)
4437+
mock_state_store.delete.assert_called_once()
44234438

44244439

44254440
@pytest.mark.asyncio

0 commit comments

Comments
 (0)