From f8c213c7dca59cb02f15e46a3ac5a21842dca09a Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Tue, 17 Feb 2026 01:31:45 +0000 Subject: [PATCH] Implement backchannel logout to client. --- paskia/db/lifecycle.py | 6 +- paskia/db/operations.py | 21 +++++++ paskia/db/structs.py | 5 +- paskia/fastapi/admin.py | 20 +++++++ paskia/fastapi/mainapp.py | 1 - paskia/fastapi/oid.py | 15 ++++- paskia/oidc_notify.py | 120 ++++++++++++++++++++++++++++++++++++++ paskia/util/oidjwt.py | 46 ++++++++++++--- 8 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 paskia/oidc_notify.py diff --git a/paskia/db/lifecycle.py b/paskia/db/lifecycle.py index c67bb93..26ad70c 100644 --- a/paskia/db/lifecycle.py +++ b/paskia/db/lifecycle.py @@ -27,8 +27,12 @@ def cleanup_expired() -> int: """Remove expired sessions and reset tokens. Returns count removed.""" now = datetime.now(UTC) count = 0 + expired_sessions = [k for k, s in _ops._db.sessions.items() if s.expiry < now] + if expired_sessions: + from paskia import oidc_notify # noqa: PLC0415 + + oidc_notify.schedule_notifications(expired_sessions) with _ops._db.transaction("expiry"): - expired_sessions = [k for k, s in _ops._db.sessions.items() if s.expiry < now] for k in expired_sessions: del _ops._db.sessions[k] count += 1 diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 12328f3..8ae8cf9 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -34,6 +34,9 @@ from paskia.util.crypto import hash_secret _logger = logging.getLogger(__name__) +# Sentinel for distinguishing "not provided" from None +_UNSET = object() + # Global database instance (empty until init() loads data) _db = DB(config=Config(rp_id="uninitialized.invalid")) _store = JsonlStore(_db) @@ -395,6 +398,9 @@ def delete_session( """ if key not in _db.sessions: raise ValueError("Session not found") + from paskia import oidc_notify # noqa: PLC0415 + + oidc_notify.schedule_notifications([key]) with _db.transaction(action, ctx): _db.sessions[key].delete() @@ -411,6 +417,10 @@ def delete_sessions_for_user( user = _db.users.get(user_uuid) if not user: return + from paskia import oidc_notify # noqa: PLC0415 + + keys = [s.key for s in user.sessions] + oidc_notify.schedule_notifications(keys) with _db.transaction("admin:delete_sessions_for_user", ctx): for sess in user.sessions: sess.delete() @@ -615,6 +625,7 @@ def update_oid_client( name: str | None = None, redirect_uris: list[str] | None = None, secret_hash: bytes | None = None, + backchannel_logout_uri: str | None = _UNSET, *, ctx: SessionContext | None = None, ) -> None: @@ -631,10 +642,18 @@ def update_oid_client( changes["redirect_uris"] = redirect_uris if secret_hash is not None and secret_hash != client.client_secret_hash: changes["client_secret_hash"] = secret_hash + if backchannel_logout_uri is not _UNSET and backchannel_logout_uri != client.backchannel_logout_uri: + changes["backchannel_logout_uri"] = backchannel_logout_uri if not changes: return # No changes to make + new_logout_uri = ( + backchannel_logout_uri + if backchannel_logout_uri is not _UNSET + else client.backchannel_logout_uri + ) + with _db.transaction("admin:update_oid_client", ctx): # Create updated client with new values updated_client = OIDClient( @@ -645,6 +664,7 @@ def update_oid_client( redirect_uris=redirect_uris if redirect_uris is not None else client.redirect_uris, + backchannel_logout_uri=new_logout_uri, ) updated_client.uuid = client.uuid _db.oid_clients[client_uuid] = updated_client @@ -665,6 +685,7 @@ def reset_oid_client_secret( client_secret_hash=new_secret_hash, name=client.name, redirect_uris=client.redirect_uris, + backchannel_logout_uri=client.backchannel_logout_uri, ) updated.uuid = client.uuid _db.oid_clients[client_uuid] = updated diff --git a/paskia/db/structs.py b/paskia/db/structs.py index b76a03b..1fa4b92 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -536,7 +536,7 @@ class ResetToken(msgspec.Struct, dict=True): # ------------------------------------------------------------------------- -class OIDClient(msgspec.Struct, dict=True): +class OIDClient(msgspec.Struct, dict=True, omit_defaults=True): """OIDC client (relying party) registration. client_id is the dict key (UUID). @@ -545,6 +545,7 @@ class OIDClient(msgspec.Struct, dict=True): client_secret_hash: bytes name: str redirect_uris: list[str] + backchannel_logout_uri: str | None = None def __post_init__(self): if not hasattr(self, "uuid"): @@ -557,6 +558,7 @@ class OIDClient(msgspec.Struct, dict=True): redirect_uris: list[str], client_secret: str, created_at: datetime | None = None, + backchannel_logout_uri: str | None = None, ) -> tuple[OIDClient, str]: """Create a new OIDClient with hashed secret. @@ -568,6 +570,7 @@ class OIDClient(msgspec.Struct, dict=True): client_secret_hash=secret_hash, name=name, redirect_uris=redirect_uris, + backchannel_logout_uri=backchannel_logout_uri, ) client.uuid = uuid7.create(now) return client, client_secret diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index 8d389ef..f24db23 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -12,6 +12,7 @@ from paskia.db import Org as OrgDC from paskia.db import Permission as PermDC from paskia.db import Role as RoleDC from paskia.db import User as UserDC +from paskia.db.operations import _UNSET from paskia.db.structs import OIDClient from paskia.fastapi import authz from paskia.fastapi.response import MsgspecResponse @@ -966,6 +967,7 @@ async def admin_list_oidc_clients(request: Request, auth=AUTH_COOKIE): "uuid": str(client.uuid), "name": client.name, "redirect_uris": client.redirect_uris, + "backchannel_logout_uri": client.backchannel_logout_uri, "active_sessions": client_session_counts.get(client.uuid, 0), } for client in clients @@ -999,6 +1001,9 @@ async def admin_create_oidc_client( secret_hash_hex = payload.get("secret_hash", "").strip() name = payload.get("name", "").strip() redirect_uris = payload.get("redirect_uris", []) + backchannel_logout_uri = payload.get("backchannel_logout_uri") + if isinstance(backchannel_logout_uri, str): + backchannel_logout_uri = backchannel_logout_uri.strip() or None if not client_id or not secret_hash_hex: raise ValueError("client_id and secret_hash are required") @@ -1023,10 +1028,14 @@ async def admin_create_oidc_client( if not isinstance(uri, str) or not uri.startswith("http"): raise ValueError(f"Invalid redirect URI: {uri}") + if backchannel_logout_uri and not backchannel_logout_uri.startswith("http"): + raise ValueError("backchannel_logout_uri must be an HTTP(S) URL") + client = OIDClient( client_secret_hash=secret_hash, name=name, redirect_uris=redirect_uris, + backchannel_logout_uri=backchannel_logout_uri, ) client.uuid = client_uuid @@ -1062,6 +1071,13 @@ async def admin_update_oidc_client( secret_hash_hex = ( payload.get("secret_hash", "").strip() if "secret_hash" in payload else None ) + backchannel_logout_uri = ( + payload.get("backchannel_logout_uri") + if "backchannel_logout_uri" in payload + else _UNSET + ) + if isinstance(backchannel_logout_uri, str): + backchannel_logout_uri = backchannel_logout_uri.strip() or None if name is not None and not name: raise ValueError("Client name cannot be empty") @@ -1074,6 +1090,9 @@ async def admin_update_oidc_client( if not isinstance(uri, str) or not uri.startswith("http"): raise ValueError(f"Invalid redirect URI: {uri}") + if backchannel_logout_uri is not _UNSET and backchannel_logout_uri and not backchannel_logout_uri.startswith("http"): + raise ValueError("backchannel_logout_uri must be an HTTP(S) URL") + secret_hash = None if secret_hash_hex: try: @@ -1089,6 +1108,7 @@ async def admin_update_oidc_client( name=name, redirect_uris=redirect_uris, secret_hash=secret_hash, + backchannel_logout_uri=backchannel_logout_uri, ctx=ctx, ) except ValueError as e: diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 78615e6..0341f21 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -108,7 +108,6 @@ async def openid_configuration(request: Request): "jwks_uri": f"{issuer}/auth/oidc/keys", "backchannel_logout_supported": True, "backchannel_logout_session_supported": True, - "backchannel_logout_uri": f"{issuer}/auth/oidc/backchannel-logout", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "subject_types_supported": ["public"], diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py index ba1304d..d693c19 100644 --- a/paskia/fastapi/oid.py +++ b/paskia/fastapi/oid.py @@ -120,7 +120,10 @@ async def token( # RFC 6749: Token endpoint MUST NOT accept query parameters if request.url.query: return JSONResponse( - {"error": "invalid_request", "error_description": "Query parameters not allowed"}, + { + "error": "invalid_request", + "error_description": "Query parameters not allowed", + }, status_code=400, ) @@ -128,7 +131,10 @@ async def token( content_type = request.headers.get("content-type", "") if "application/x-www-form-urlencoded" not in content_type: return JSONResponse( - {"error": "invalid_request", "error_description": "Content-Type must be application/x-www-form-urlencoded"}, + { + "error": "invalid_request", + "error_description": "Content-Type must be application/x-www-form-urlencoded", + }, status_code=400, ) @@ -478,7 +484,10 @@ async def backchannel_logout( content_type = request.headers.get("content-type", "") if "application/x-www-form-urlencoded" not in content_type: return JSONResponse( - {"error": "invalid_request", "error_description": "Content-Type must be application/x-www-form-urlencoded"}, + { + "error": "invalid_request", + "error_description": "Content-Type must be application/x-www-form-urlencoded", + }, status_code=400, ) diff --git a/paskia/oidc_notify.py b/paskia/oidc_notify.py new file mode 100644 index 0000000..383d517 --- /dev/null +++ b/paskia/oidc_notify.py @@ -0,0 +1,120 @@ +""" +OIDC Back-Channel Logout notifications. + +When sessions are deleted (logout, admin, expiry), this module notifies +any OIDC clients that have a backchannel_logout_uri configured. +""" + +import asyncio +import logging +from uuid import UUID + +import base64url +import httpx + +from paskia import db +from paskia.util import oidjwt +from paskia.util.crypto import hash_secret +from paskia.util.hostutil import _load_config + +_logger = logging.getLogger(__name__) + +# Timeout for back-channel logout requests +_TIMEOUT = httpx.Timeout(10.0, connect=5.0) + + +def _issuer() -> str: + """Derive issuer URL from config (same base as discovery document).""" + cfg = _load_config() + return cfg.get("site_url", "https://localhost") + + +def _collect_oidc_sessions( + session_keys: list[bytes], +) -> list[tuple[str, str, UUID, UUID | None]]: + """Collect (backchannel_logout_uri, sid, client_uuid, user_uuid) for OIDC sessions. + + Must be called before the sessions are deleted from the database. + Returns only sessions whose client has a backchannel_logout_uri configured. + """ + notifications = [] + data = db.data() + for key in session_keys: + session = data.sessions.get(key) + if not session or session.client_uuid is None: + continue + client = data.oid_clients.get(session.client_uuid) + if not client or not client.backchannel_logout_uri: + continue + sid = base64url.enc(hash_secret("oidc", session.key)) + notifications.append( + (client.backchannel_logout_uri, sid, session.client_uuid, session.user_uuid) + ) + return notifications + + +async def _send_logout_token( + client: httpx.AsyncClient, + uri: str, + token: str, +) -> None: + """POST a logout_token to a single client endpoint.""" + try: + resp = await client.post( + uri, + data={"logout_token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + if resp.status_code == 200: + _logger.debug("Back-channel logout OK: %s", uri) + else: + _logger.warning( + "Back-channel logout %s returned %d: %s", + uri, + resp.status_code, + resp.text[:200], + ) + except Exception: + _logger.warning("Back-channel logout failed: %s", uri, exc_info=True) + + +async def notify( + notifications: list[tuple[str, str, UUID, UUID | None]], +) -> None: + """Send back-channel logout tokens to all collected endpoints. + + Args: + notifications: list of (backchannel_logout_uri, sid, client_uuid, user_uuid) + as returned by _collect_oidc_sessions. + """ + if not notifications: + return + + issuer = _issuer() + async with httpx.AsyncClient(timeout=_TIMEOUT) as client: + tasks = [] + for uri, sid, client_uuid, user_uuid in notifications: + token = oidjwt.create_logout_token( + issuer=issuer, + audience=str(client_uuid), + sid=sid, + sub=user_uuid, + ) + tasks.append(_send_logout_token(client, uri, token)) + await asyncio.gather(*tasks, return_exceptions=True) + + +def schedule_notifications(session_keys: list[bytes]) -> None: + """Collect OIDC info from sessions (before deletion) and schedule async notifications. + + Must be called BEFORE the sessions are deleted. The actual HTTP requests + are fire-and-forget via the running event loop. + """ + notifications = _collect_oidc_sessions(session_keys) + if not notifications: + return + try: + loop = asyncio.get_running_loop() + loop.create_task(notify(notifications)) + except RuntimeError: + _logger.debug("No event loop for back-channel logout notifications") diff --git a/paskia/util/oidjwt.py b/paskia/util/oidjwt.py index 72f0eb8..7d0bbc1 100644 --- a/paskia/util/oidjwt.py +++ b/paskia/util/oidjwt.py @@ -197,12 +197,44 @@ def decode_access_token( decode_kwargs["audience"] = audience else: options["verify_aud"] = False - - return jwt.decode( - token, - _public_key, - options=options, - **decode_kwargs - ) + + return jwt.decode(token, _public_key, options=options, **decode_kwargs) except jwt.PyJWTError: return None + + +def create_logout_token( + issuer: str, + audience: str, + sid: str | None = None, + sub: UUID | None = None, +) -> str: + """Create a signed logout token for back-channel logout notification. + + Per OIDC Back-Channel Logout 1.0, the logout token must contain + either sid (session) or sub (user), or both. + + Args: + issuer: Token issuer (site URL) + audience: Client ID (aud claim) + sid: Session ID (base64url-encoded) + sub: User UUID + + Returns: + Signed JWT string + """ + _ensure_key() + now = datetime.now(UTC) + payload = { + "iss": issuer, + "aud": audience, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(seconds=120)).timestamp()), + "events": {"http://schemas.openid.net/event/backchannel-logout": {}}, + "jti": hashlib.sha256(f"{now.timestamp()}{audience}{sid}{sub}".encode()).hexdigest()[:16], + } + if sid: + payload["sid"] = sid + if sub: + payload["sub"] = str(sub) + return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid})