Files
kaya/packages/kaya-oidc/tests/test_backchannel_logout.py
T
woggioni 074e48a372
CI / Build and publish packages (push) Failing after 14s
Add OIDC back-channel logout with session indexes
2026-11-02 12:15:41 +08:00

428 lines
17 KiB
Python

import unittest
from time import time
from typing import Any, Optional
from urllib.parse import parse_qs, urlparse
import httpx
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from pwo import async_test
from test_oidc import MockOIDCProvider, MockTransport
from kaya.core import HttpContext, KayaApp
from kaya.oidc import OIDCConfig, OIDCMixin, OIDCProviders
from kaya.session import InMemorySessionStore, SessionMixin
_EVENT = 'http://schemas.openid.net/event/backchannel-logout'
class BackchannelProvider(MockOIDCProvider):
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.sid: Optional[str] = None
def issue_id_token(self, *args: Any, **kwargs: Any) -> str:
token = super().issue_id_token(*args, **kwargs)
if self.sid is None:
return token
payload = jwt.decode(token, options={'verify_signature': False})
payload['sid'] = self.sid
return jwt.encode(
payload,
self.private_key,
algorithm='RS256',
headers={'kid': self.kid},
)
class BackchannelLogoutTest(unittest.TestCase):
def setUp(self) -> None:
self.provider = BackchannelProvider()
self.http_client = httpx.AsyncClient(transport=MockTransport(self.provider))
self.store = InMemorySessionStore()
self.session = SessionMixin(self.store)
self.oidc = OIDCMixin(
OIDCConfig(
issuer=self.provider.issuer,
client_id='client',
client_secret='secret',
redirect_uri='http://localhost:8000/auth/callback',
backchannel_logout_path='/auth/backchannel-logout',
http_client=self.http_client,
),
session=self.session,
)
self.app = KayaApp(mixins=[self.session, self.oidc])
self.transport = httpx.ASGITransport(app=self.app)
@self.app.GET('/profile')
@self.oidc.require_auth
async def profile(ctx: HttpContext) -> None:
user = self.oidc.get_user(ctx)
assert user is not None
await ctx.send_str(200, user.sub)
@self.app.GET('/refresh')
@self.oidc.require_auth
async def refresh(ctx: HttpContext) -> None:
token = await self.oidc.refresh_access_token(ctx.session)
await ctx.send_str(200, token or 'no-token')
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(transport=self.transport, base_url='http://127.0.0.1:80')
async def _login(self, client: httpx.AsyncClient, sid: str) -> None:
self.provider.sid = sid
response = await client.get('/auth/login', follow_redirects=False)
self.assertEqual(302, response.status_code)
query = parse_qs(urlparse(response.headers['Location']).query)
self.provider.nonce = query['nonce'][0]
response = await client.get(
'/auth/callback',
params={'code': 'mock-code', 'state': query['state'][0]},
follow_redirects=False,
)
self.assertEqual(302, response.status_code)
def _claims(self, **overrides: Any) -> dict[str, Any]:
claims: dict[str, Any] = {
'iss': self.provider.issuer,
'aud': 'client',
'iat': time(),
'jti': 'logout-token-id',
'events': {_EVENT: {}},
'sub': self.provider.sub,
}
claims.update(overrides)
return claims
def _token(
self,
claims: dict[str, Any],
*,
typ: str = 'logout+jwt',
key: Any = None,
algorithm: str = 'RS256',
) -> str:
return jwt.encode(
claims,
self.provider.private_key if key is None else key,
algorithm=algorithm,
headers={'kid': self.provider.kid, 'typ': typ},
)
async def _post_logout(self, token: str) -> httpx.Response:
async with self._client() as client:
return await client.post(
'/auth/backchannel-logout',
data={'logout_token': token},
)
async def _profile_status(self, client: httpx.AsyncClient) -> int:
response = await client.get('/profile', follow_redirects=False)
return response.status_code
@async_test
async def test_logout_by_sid_only_invalidates_matching_session(self) -> None:
async with self._client() as first, self._client() as second:
await self._login(first, 'sid-a')
await self._login(second, 'sid-b')
response = await self._post_logout(self._token(self._claims(sid='sid-a')))
self.assertEqual(200, response.status_code)
self.assertEqual('no-store', response.headers['Cache-Control'])
self.assertNotIn('Set-Cookie', response.headers)
self.assertEqual(302, await self._profile_status(first))
self.assertEqual(200, await self._profile_status(second))
self.assertEqual([], await self.store.find_sessions('oidc:default:sid', 'sid-a'))
self.assertEqual(
[second.cookies['session_id']],
await self.store.find_sessions('oidc:default:sid', 'sid-b'),
)
@async_test
async def test_logout_by_sub_invalidates_all_user_sessions(self) -> None:
async with self._client() as first, self._client() as second, self._client() as other:
await self._login(first, 'sid-a')
await self._login(second, 'sid-b')
self.provider.sub = 'bob'
await self._login(other, 'sid-c')
response = await self._post_logout(self._token(self._claims(sub='user123')))
self.assertEqual(200, response.status_code)
self.assertEqual(302, await self._profile_status(first))
self.assertEqual(302, await self._profile_status(second))
self.assertEqual(200, await self._profile_status(other))
@async_test
async def test_sid_logout_verifies_sub_when_both_are_present(self) -> None:
async with self._client() as client:
await self._login(client, 'sid-a')
response = await self._post_logout(
self._token(self._claims(sub='someone-else', sid='sid-a'))
)
self.assertEqual(200, response.status_code)
self.assertEqual(200, await self._profile_status(client))
@async_test
async def test_unknown_session_is_idempotent(self) -> None:
response = await self._post_logout(self._token(self._claims(sid='unknown')))
self.assertEqual(200, response.status_code)
@async_test
async def test_invalid_logout_tokens_are_rejected(self) -> None:
other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
missing_iat = self._claims(sid='sid-a')
del missing_iat['iat']
missing_jti = self._claims(sid='sid-a')
del missing_jti['jti']
missing_events = self._claims(sid='sid-a')
del missing_events['events']
missing_subject = self._claims()
del missing_subject['sub']
invalid_tokens = [
self._token(self._claims(sid='sid-a'), typ='JWT'),
jwt.encode(
self._claims(sid='sid-a'),
None,
algorithm='none',
headers={'typ': 'logout+jwt'},
),
self._token(self._claims(sid='sid-a'), key=other_key),
self._token(self._claims(iss='https://evil.local', sid='sid-a')),
self._token(self._claims(aud='other-client', sid='sid-a')),
self._token(missing_iat),
self._token(missing_jti),
self._token(missing_events),
self._token(self._claims(events={'wrong-event': {}}, sid='sid-a')),
self._token(self._claims(events={_EVENT: None}, sid='sid-a')),
self._token(self._claims(nonce='forbidden', sid='sid-a')),
self._token(missing_subject),
self._token(self._claims(exp=time() - 3600, sid='sid-a')),
self._token(self._claims(sid='sid-a'), key='x' * 32, algorithm='HS256'),
]
async with self._client() as client:
await self._login(client, 'sid-a')
for token in invalid_tokens:
response = await self._post_logout(token)
self.assertEqual(400, response.status_code)
self.assertEqual(200, await self._profile_status(client))
@async_test
async def test_invalid_requests_are_rejected(self) -> None:
async with self._client() as client:
response = await client.post('/auth/backchannel-logout', data={})
self.assertEqual(400, response.status_code)
response = await client.post(
'/auth/backchannel-logout',
content='logout_token=abc',
headers={'Content-Type': 'text/plain'},
)
self.assertEqual(400, response.status_code)
response = await client.post(
'/auth/backchannel-logout',
content='x' * (64 * 1024 + 1),
headers={'Content-Type': 'application/x-www-form-urlencoded'},
)
self.assertEqual(400, response.status_code)
@async_test
async def test_local_logout_removes_indexes(self) -> None:
async with self._client() as client:
await self._login(client, 'sid-a')
self.assertEqual(
[client.cookies['session_id']],
await self.store.find_sessions('oidc:default:sub', 'user123'),
)
await client.get('/auth/logout', follow_redirects=False)
self.assertEqual([], await self.store.find_sessions('oidc:default:sub', 'user123'))
self.assertEqual([], await self.store.find_sessions('oidc:default:sid', 'sid-a'))
@async_test
async def test_relogin_replaces_old_sid_index(self) -> None:
async with self._client() as client:
await self._login(client, 'sid-a')
await self._login(client, 'sid-b')
self.assertEqual([], await self.store.find_sessions('oidc:default:sid', 'sid-a'))
self.assertEqual(
[client.cookies['session_id']],
await self.store.find_sessions('oidc:default:sid', 'sid-b'),
)
@async_test
async def test_refresh_updates_changed_sid_index(self) -> None:
async with self._client() as client:
await self._login(client, 'sid-a')
self.provider.sid = 'sid-b'
response = await client.get('/refresh')
self.assertEqual(200, response.status_code)
self.assertNotEqual('no-token', response.text)
self.assertEqual([], await self.store.find_sessions('oidc:default:sid', 'sid-a'))
self.assertEqual(
[client.cookies['session_id']],
await self.store.find_sessions('oidc:default:sid', 'sid-b'),
)
@async_test
async def test_existing_session_is_backfilled(self) -> None:
async with self._client() as client:
await self._login(client, 'sid-a')
session_id = client.cookies['session_id']
self.store._data[session_id].pop('oidc:default:indexed')
self.store._session_indexes.pop(session_id, None)
for session_ids in self.store._index_sessions.values():
session_ids.discard(session_id)
self.assertEqual([], await self.store.find_sessions('oidc:default:sid', 'sid-a'))
self.assertEqual(200, await self._profile_status(client))
self.assertEqual(
[session_id],
await self.store.find_sessions('oidc:default:sid', 'sid-a'),
)
@async_test
async def test_index_capable_store_is_required(self) -> None:
class LegacyStore(InMemorySessionStore):
supports_indexes = False
with self.assertRaises(ValueError):
OIDCMixin(
OIDCConfig(
issuer=self.provider.issuer,
client_id='client',
redirect_uri='http://localhost:8000/auth/callback',
backchannel_logout_path='/auth/backchannel-logout',
),
session=SessionMixin(LegacyStore()),
)
class MultiProviderBackchannelLogoutTest(unittest.TestCase):
def setUp(self) -> None:
self.google = BackchannelProvider('google', sub='shared-sub', email='g@example.com')
self.github = BackchannelProvider('github', sub='shared-sub', email='h@example.com')
self.store = InMemorySessionStore()
self.session = SessionMixin(self.store)
self.oidc = OIDCProviders(
[
self._config(self.google, 'google'),
self._config(self.github, 'github'),
],
session=self.session,
strategy='multi-identity',
)
self.app = KayaApp(mixins=[self.session, self.oidc])
self.transport = httpx.ASGITransport(app=self.app)
@self.app.GET('/google-profile')
@self.oidc.require_auth('google')
async def google_profile(ctx: HttpContext) -> None:
await ctx.send_str(200, 'google')
@self.app.GET('/github-profile')
@self.oidc.require_auth('github')
async def github_profile(ctx: HttpContext) -> None:
await ctx.send_str(200, 'github')
@staticmethod
def _config(provider: BackchannelProvider, name: str) -> OIDCConfig:
return OIDCConfig(
issuer=provider.issuer,
client_id='client',
client_secret='secret',
redirect_uri=f'http://localhost:8000/auth/{name}/callback',
provider=name,
backchannel_logout_path=f'/auth/{name}/backchannel-logout',
http_client=httpx.AsyncClient(transport=MockTransport(provider)),
)
async def _login(
self,
client: httpx.AsyncClient,
provider: BackchannelProvider,
name: str,
sid: str,
) -> None:
provider.sid = sid
response = await client.get(f'/auth/{name}/login', follow_redirects=False)
query = parse_qs(urlparse(response.headers['Location']).query)
provider.nonce = query['nonce'][0]
await client.get(
f'/auth/{name}/callback',
params={'code': 'mock-code', 'state': query['state'][0]},
follow_redirects=False,
)
@async_test
async def test_single_identity_eviction_removes_old_provider_indexes(self) -> None:
session = SessionMixin(self.store)
oidc = OIDCProviders(
[
self._config(self.google, 'google'),
self._config(self.github, 'github'),
],
session=session,
strategy='single-identity',
)
app = KayaApp(mixins=[session, oidc])
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url='http://127.0.0.1:80',
) as client:
await self._login(client, self.google, 'google', 'google-sid')
self.assertEqual(
[client.cookies['session_id']],
await self.store.find_sessions('oidc:google:sid', 'google-sid'),
)
await self._login(client, self.github, 'github', 'github-sid')
self.assertEqual(
[],
await self.store.find_sessions('oidc:google:sid', 'google-sid'),
)
self.assertEqual(
[client.cookies['session_id']],
await self.store.find_sessions('oidc:github:sid', 'github-sid'),
)
@async_test
async def test_provider_logout_is_isolated(self) -> None:
async with httpx.AsyncClient(
transport=self.transport,
base_url='http://127.0.0.1:80',
) as client:
await self._login(client, self.google, 'google', 'google-sid')
await self._login(client, self.github, 'github', 'github-sid')
token = jwt.encode(
{
'iss': self.google.issuer,
'aud': 'client',
'iat': time(),
'jti': 'google-logout',
'events': {_EVENT: {}},
'sub': 'shared-sub',
'sid': 'google-sid',
},
self.google.private_key,
algorithm='RS256',
headers={'kid': self.google.kid, 'typ': 'logout+jwt'},
)
response = await client.post(
'/auth/google/backchannel-logout',
data={'logout_token': token},
)
self.assertEqual(200, response.status_code)
response = await client.get('/google-profile', follow_redirects=False)
self.assertEqual(302, response.status_code)
response = await client.get('/github-profile', follow_redirects=False)
self.assertEqual(200, response.status_code)
if __name__ == '__main__':
unittest.main()