Add OIDC back-channel logout with session indexes
CI / Build and publish packages (push) Failing after 14s

This commit is contained in:
woggioni committed 2026-11-02 12:15:41 +08:00
1 parent ec056a15b9
commit 074e48a372
20 files changed
+1594 -32

No files matched your search

+45
View File
@@ -128,6 +128,50 @@ app = KayaApp(mixins=[session, google, github])
> default provider is `default`). Existing logged-in users are signed out once
> and simply re-authenticate.
## Back-channel logout
Set `backchannel_logout_path` to accept OIDC Back-Channel Logout tokens from
the provider:
```python
OIDCConfig(
issuer=os.environ['OIDC_ISSUER'],
client_id=os.environ['OIDC_CLIENT_ID'],
client_secret=os.environ['OIDC_CLIENT_SECRET'],
redirect_uri='https://example.com/auth/callback',
backchannel_logout_path='/auth/backchannel-logout',
)
```
Register the corresponding absolute URL (for example
`https://example.com/auth/backchannel-logout`) as the client's back-channel
logout URI with the provider. This registration happens at the provider; it is
not part of OIDC discovery. With `OIDCProviders`, enabling the option derives
`{base_path}/{provider}/backchannel-logout` for that provider.
The endpoint accepts form-encoded `POST` requests containing `logout_token`.
It validates the JWT signature against the provider JWKS, the `logout+jwt`
type, issuer, audience, `iat`, `jti`, and the back-channel logout event, and
rejects tokens containing `nonce`. A token with `sid` logs out sessions for
that provider session; without `sid`, `sub` logs out all sessions for that
subject. Under `multi-identity`, only the named provider's identity is removed
from a shared session. Valid requests return `200` with
`Cache-Control: no-store`, including when the session is already gone. Invalid
requests return `400`.
Back-channel logout requires a session store with index support. All built-in
stores (`InMemorySessionStore`, `RedisSessionStore`, and
`MemcacheSessionStore`) support it. `OIDCMixin` raises immediately if the
configured store does not. Sessions created before upgrading are indexed on
their next request.
Logout tokens must be asymmetrically signed; unsigned tokens, HMAC signatures,
and encrypted logout tokens (JWE) are not supported. `jti` is required, but no
replay cache is maintained: repeating a valid logout request is idempotent.
`backchannel_logout_leeway` controls the allowed clock skew (60 seconds by
default). For a templated Microsoft Entra issuer, the logout token must carry
`tid`, just as for ID token validation.
## Pushed authorization requests (PAR)
`OIDCConfig.par` controls RFC 9126 Pushed Authorization Requests:
@@ -172,6 +216,7 @@ authentication methods (`private_key_jwt`, mTLS) is not supported.
- Optional userinfo endpoint fetch
- Refresh token support
- RP-initiated logout (when provider advertises `end_session_endpoint`)
- Back-channel logout (`sid` and `sub` session invalidation)
- Pushed Authorization Requests (RFC 9126) via `OIDCConfig.par`
- Multiple providers in one app via `OIDCProviders` (or several `OIDCMixin` instances)
- `single-identity` (default) or `multi-identity` session strategy
@@ -10,6 +10,8 @@ from ._config import PARPolicy, OIDCConfig
from ._utils import generate_nonce, generate_pkce, generate_state
_TENANT_ID_PLACEHOLDER = re.compile(r'\{tenantid\}', re.IGNORECASE)
_BACKCHANNEL_LOGOUT_EVENT = 'http://schemas.openid.net/event/backchannel-logout'
_LOGOUT_TOKEN_TYPES = frozenset({'logout+jwt', 'application/logout+jwt'})
class OIDCClient:
@@ -189,6 +191,60 @@ class OIDCClient:
raise ValueError('ID token nonce mismatch')
return payload
async def validate_logout_token(self, logout_token: str) -> Mapping[str, Any]:
"""Validate an OIDC Back-Channel Logout token and return its claims."""
header = jwt.get_unverified_header(logout_token)
typ = header.get('typ')
if not isinstance(typ, str) or typ.lower() not in _LOGOUT_TOKEN_TYPES:
raise ValueError('Logout token typ must be logout+jwt')
alg = header.get('alg')
if not isinstance(alg, str):
raise ValueError('Logout token header is missing alg')
if alg == 'none' or alg.startswith('HS'):
raise ValueError('Logout tokens must use an asymmetric signature')
if alg not in self._config.allowed_id_token_algorithms:
raise ValueError(f'Logout token algorithm {alg} is not allowed')
metadata_issuer = await self._get_metadata_issuer()
jwks = await self._ensure_jwks()
signing_key = self._get_signing_key(jwks, header.get('kid'), alg)
payload = jwt.decode(
logout_token,
signing_key.key,
algorithms=[alg],
audience=self._config.client_id,
leeway=self._config.backchannel_logout_leeway,
options={
'require': ['iss', 'aud', 'iat', 'jti', 'events'],
'verify_exp': True,
'verify_iat': True,
'verify_nbf': True,
},
)
if not isinstance(payload, dict):
raise ValueError('Logout token payload is not a JSON object')
self._validate_issuer(payload, metadata_issuer)
if not isinstance(payload.get('jti'), str):
raise ValueError('Logout token jti must be a string')
events = payload.get('events')
if (not isinstance(events, Mapping)
or not isinstance(events.get(_BACKCHANNEL_LOGOUT_EVENT), Mapping)):
raise ValueError('Logout token is missing the back-channel logout event')
if 'nonce' in payload:
raise ValueError('Logout token must not contain a nonce')
sub = payload.get('sub')
sid = payload.get('sid')
if sub is not None and not isinstance(sub, str):
raise ValueError('Logout token sub must be a string')
if sid is not None and not isinstance(sid, str):
raise ValueError('Logout token sid must be a string')
if sub is None and sid is None:
raise ValueError('Logout token must contain sub or sid')
return payload
async def _decode_id_token(self, id_token: str) -> Mapping[str, Any]:
header = jwt.get_unverified_header(id_token)
alg = header.get('alg')
@@ -26,6 +26,12 @@ class OIDCConfig:
A provider that sets ``require_pushed_authorization_requests`` is always
treated as requiring PAR.
``backchannel_logout_path`` enables OIDC Back-Channel Logout when set to
an application path. Register the corresponding absolute URL with the
provider. It requires a session store with index support.
``backchannel_logout_leeway`` is the clock-skew allowance, in seconds,
used when validating logout tokens.
``client_secret`` is optional; omit it for public clients.
``fetch_userinfo`` controls whether the userinfo endpoint is queried after
token exchange.
@@ -55,6 +61,8 @@ class OIDCConfig:
http_client: Optional[httpx.AsyncClient] = None
provider: str = 'default'
par: PARPolicy = 'never'
backchannel_logout_path: Optional[str] = None
backchannel_logout_leeway: int = 60
def __post_init__(self) -> None:
if not self.provider or _PROVIDER_PATTERN.match(self.provider) is None:
@@ -68,6 +76,11 @@ class OIDCConfig:
f'par must be one of {", ".join(map(repr, _PAR_POLICIES))} '
f'(got {self.par!r})'
)
if (self.backchannel_logout_path is not None
and not self.backchannel_logout_path.startswith('/')):
raise ValueError('backchannel_logout_path must start with "/"')
if self.backchannel_logout_leeway < 0:
raise ValueError('backchannel_logout_leeway must be non-negative')
scopes_list = list(self.scopes)
if 'openid' not in scopes_list:
self.scopes = ('openid', *scopes_list)
+180 -1
View File
@@ -15,8 +15,9 @@ type IdentityStrategy = Literal['single-identity', 'multi-identity']
_SESSION_NAMESPACE = 'oidc'
_USER_SUFFIX = 'user'
_IDENTITY_FIELDS = frozenset({'user', 'id_token', 'access_token', 'refresh_token'})
_IDENTITY_FIELDS = frozenset({'user', 'id_token', 'access_token', 'refresh_token', 'sid'})
_IDENTITY_STRATEGIES: tuple[IdentityStrategy, ...] = ('single-identity', 'multi-identity')
_BACKCHANNEL_BODY_LIMIT = 64 * 1024
class OIDCUser(Mapping[str, Any]):
@@ -64,6 +65,10 @@ class OIDCMixin(KayaMixin):
- ``callback_path`` (default ``/auth/callback``): handles the provider callback.
- ``logout_path`` (default ``/auth/logout``): logs the user out.
When ``backchannel_logout_path`` is configured, a POST route accepting
OIDC Back-Channel Logout tokens is registered as well. This requires an
index-capable session store.
Session state is namespaced by ``OIDCConfig.provider`` as
``oidc:{provider}:...`` so several providers can share one session. Use one
``OIDCMixin`` per provider (with distinct route paths), or
@@ -104,6 +109,11 @@ class OIDCMixin(KayaMixin):
self._client = OIDCClient(config)
self._strategy = strategy
self._prefix = f'{_SESSION_NAMESPACE}:{config.provider}:'
if (config.backchannel_logout_path is not None
and not session.store.supports_indexes):
raise ValueError(
'backchannel_logout_path requires a session store with index support'
)
@property
def provider(self) -> str:
@@ -116,11 +126,73 @@ class OIDCMixin(KayaMixin):
def _key(self, name: str) -> str:
return f'{self._prefix}{name}'
def _index_name(self, claim: str, provider: Optional[str] = None) -> str:
return f'{_SESSION_NAMESPACE}:{provider or self.provider}:{claim}'
def _apply_identity_indexes(
self,
session: Session,
sub: str,
sid: Optional[str],
) -> None:
session.add_index(self._index_name('sub'), sub)
if sid is not None:
session.add_index(self._index_name('sid'), sid)
session[self._key('indexed')] = True
def _remove_identity_indexes(
self,
session: Session,
provider: Optional[str] = None,
) -> None:
prefix = f'{_SESSION_NAMESPACE}:{provider or self.provider}:'
user = session.get(f'{prefix}user')
if isinstance(user, Mapping):
sub = user.get('sub')
if isinstance(sub, str):
session.remove_index(self._index_name('sub', provider), sub)
sid = session.get(f'{prefix}sid')
if isinstance(sid, str):
session.remove_index(self._index_name('sid', provider), sid)
def _backfill_identity_indexes(self, session: Session) -> None:
"""Index an authenticated session created before index support."""
user = session.get(self._key('user'))
if not isinstance(user, Mapping) or self._key('indexed') in session:
return
sub = user.get('sub')
if not isinstance(sub, str):
return
sid = session.get(self._key('sid'))
if not isinstance(sid, str):
id_token = session.get(self._key('id_token'))
if isinstance(id_token, str):
try:
claims = jwt.decode(
id_token,
options={'verify_signature': False},
)
candidate = claims.get('sid')
if isinstance(candidate, str):
sid = candidate
session[self._key('sid')] = candidate
except jwt.PyJWTError:
pass
self._apply_identity_indexes(
session,
sub,
sid if isinstance(sid, str) else None,
)
@property
def dependencies(self) -> Sequence[KayaMixin]:
return [self._session]
def apply(self, app: KayaApp) -> None:
if self._config.backchannel_logout_path is not None:
app.add_before_request_hook(self._before_request)
@app.GET(self._config.login_path)
async def login(ctx: HttpContext) -> None:
auth_url, state, nonce, code_verifier = await self._client.build_authorization_url(
@@ -168,6 +240,12 @@ class OIDCMixin(KayaMixin):
return
claims = await self._client.validate_id_token(id_token, nonce)
sub = claims.get('sub')
if not isinstance(sub, str):
raise ValueError('ID token is missing a valid sub claim')
sid = claims.get('sid')
if sid is not None and not isinstance(sid, str):
raise ValueError('ID token sid must be a string')
user: dict[str, Any] = dict(claims)
access_token = token_response.get('access_token')
@@ -180,8 +258,13 @@ class OIDCMixin(KayaMixin):
# Keep this provider's nonce so refreshed ID tokens can be
# checked for nonce continuity (OIDC Core 12.2).
self._remove_identity_indexes(session)
session[self._key('user')] = user
session[self._key('id_token')] = id_token
session.pop(self._key('sid'), None)
if sid is not None:
session[self._key('sid')] = sid
self._apply_identity_indexes(session, sub, sid)
if isinstance(access_token, str):
session[self._key('access_token')] = access_token
if 'refresh_token' in token_response:
@@ -204,6 +287,27 @@ class OIDCMixin(KayaMixin):
location = logout_url if logout_url is not None else self._config.post_logout_redirect
await ctx.send_empty(302, {'Location': location})
backchannel_path = self._config.backchannel_logout_path
if backchannel_path is not None:
@app.POST(backchannel_path)
async def backchannel_logout(ctx: HttpContext) -> None:
# This endpoint is server-to-server. Never persist a browser
# session that happens to accompany the request.
ctx.session = Session()
try:
logout_token = await self._read_logout_token(ctx)
claims = await self._client.validate_logout_token(logout_token)
except (ValueError, jwt.PyJWTError):
await ctx.send_str(
400,
'Invalid logout token',
{'Cache-Control': 'no-store'},
)
return
await self._invalidate_by_logout_token(claims)
await ctx.send_empty(200, {'Cache-Control': 'no-store'})
def _logout_session(self, session: Session) -> None:
"""Drop this provider's session state.
@@ -211,6 +315,7 @@ class OIDCMixin(KayaMixin):
logged in, so logging out of one provider does not sign the user out of
the others.
"""
self._remove_identity_indexes(session)
for key in [key for key in session if key.startswith(self._prefix)]:
del session[key]
if not any(_is_user_key(key) for key in session):
@@ -224,10 +329,75 @@ class OIDCMixin(KayaMixin):
can still complete. Under this strategy the last callback to finish
wins.
"""
other_providers = {
_provider_of_user_key(key)
for key in session
if _is_user_key(key) and not key.startswith(self._prefix)
}
for provider in other_providers:
self._remove_identity_indexes(session, provider)
for key in [key for key in session
if _is_identity_key(key) and not key.startswith(self._prefix)]:
del session[key]
async def _before_request(self, ctx: HttpContext) -> Optional[HttpContext]:
session = ctx.session
if isinstance(session, Session):
self._backfill_identity_indexes(session)
return None
async def _read_logout_token(self, ctx: HttpContext) -> str:
content_type_values = ctx.headers.get('content-type')
if isinstance(content_type_values, str):
content_type = content_type_values
elif content_type_values:
content_type = content_type_values[0]
else:
content_type = ''
if content_type.split(';', 1)[0].strip().lower() != 'application/x-www-form-urlencoded':
raise ValueError('Back-channel logout requires a form-encoded request')
body = bytearray()
async for chunk in ctx.request_body:
body.extend(chunk)
if len(body) > _BACKCHANNEL_BODY_LIMIT:
raise ValueError('Back-channel logout request is too large')
form = parse_qs(body.decode('utf-8'), keep_blank_values=True)
logout_token = self._first_value(form.get('logout_token'))
if logout_token is None:
raise ValueError('Missing logout_token')
return logout_token
async def _invalidate_by_logout_token(self, claims: Mapping[str, Any]) -> None:
store = self._session.store
sub = claims.get('sub')
sid = claims.get('sid')
if isinstance(sid, str):
session_ids = await store.find_sessions(self._index_name('sid'), sid)
elif isinstance(sub, str):
session_ids = await store.find_sessions(self._index_name('sub'), sub)
else:
return
for session_id in session_ids:
session = await store.load(session_id)
if session is None:
continue
if isinstance(sid, str) and isinstance(sub, str):
user = session.get(self._key('user'))
if not isinstance(user, Mapping) or user.get('sub') != sub:
continue
# A session can hold several provider identities. Remove only this
# provider's identity and delete the whole session when it was the
# last one, mirroring the local logout behavior.
self._logout_session(session)
if session.invalidated:
await store.delete(session_id)
else:
await store.save(session_id, session, self._session.max_age)
session.commit_indexes()
@staticmethod
def _session_of(ctx: HttpContext) -> Session:
session = ctx.session
@@ -293,6 +463,15 @@ class OIDCMixin(KayaMixin):
if isinstance(original_sub, str) and claims.get('sub') != original_sub:
raise ValueError('ID token subject mismatch')
refreshed_sid = claims.get('sid')
stored_sid = session.get(self._key('sid'))
if (isinstance(refreshed_sid, str)
and refreshed_sid != stored_sid):
if isinstance(stored_sid, str):
session.remove_index(self._index_name('sid'), stored_sid)
session[self._key('sid')] = refreshed_sid
session.add_index(self._index_name('sid'), refreshed_sid)
if isinstance(access_token, str):
session[self._key('access_token')] = access_token
if isinstance(id_token, str):
@@ -20,7 +20,9 @@ class OIDCProviders(KayaMixin):
Each config's ``provider`` becomes a route segment under ``base_path``:
``{base_path}/{provider}/login``, ``{base_path}/{provider}/callback`` and
``{base_path}/{provider}/logout``. All providers share the supplied
``{base_path}/{provider}/logout``. Providers that enable back-channel
logout also get ``{base_path}/{provider}/backchannel-logout``. All
providers share the supplied
:class:`~kaya.session.SessionMixin` and namespace their session state by
provider.
@@ -68,12 +70,17 @@ class OIDCProviders(KayaMixin):
name = config.provider
if name in self._providers:
raise ValueError(f'Duplicate OIDC provider {name!r}')
backchannel_path = (
f'{self._base_path}/{name}/backchannel-logout'
if config.backchannel_logout_path is not None else None
)
self._providers[name] = OIDCMixin(
replace(
config,
login_path=f'{self._base_path}/{name}/login',
callback_path=f'{self._base_path}/{name}/callback',
logout_path=f'{self._base_path}/{name}/logout',
backchannel_logout_path=backchannel_path,
),
session=session,
strategy=strategy,
@@ -0,0 +1,427 @@
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()