428 lines
17 KiB
Python
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()
|