Implement backchannel logout to client.

This commit is contained in:
Leo Vasanko
2026-02-17 01:31:45 +00:00
parent b5e308d5df
commit f8c213c7dc
8 changed files with 221 additions and 13 deletions
+5 -1
View File
@@ -27,8 +27,12 @@ def cleanup_expired() -> int:
"""Remove expired sessions and reset tokens. Returns count removed.""" """Remove expired sessions and reset tokens. Returns count removed."""
now = datetime.now(UTC) now = datetime.now(UTC)
count = 0 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"): 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: for k in expired_sessions:
del _ops._db.sessions[k] del _ops._db.sessions[k]
count += 1 count += 1
+21
View File
@@ -34,6 +34,9 @@ from paskia.util.crypto import hash_secret
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
# Sentinel for distinguishing "not provided" from None
_UNSET = object()
# Global database instance (empty until init() loads data) # Global database instance (empty until init() loads data)
_db = DB(config=Config(rp_id="uninitialized.invalid")) _db = DB(config=Config(rp_id="uninitialized.invalid"))
_store = JsonlStore(_db) _store = JsonlStore(_db)
@@ -395,6 +398,9 @@ def delete_session(
""" """
if key not in _db.sessions: if key not in _db.sessions:
raise ValueError("Session not found") raise ValueError("Session not found")
from paskia import oidc_notify # noqa: PLC0415
oidc_notify.schedule_notifications([key])
with _db.transaction(action, ctx): with _db.transaction(action, ctx):
_db.sessions[key].delete() _db.sessions[key].delete()
@@ -411,6 +417,10 @@ def delete_sessions_for_user(
user = _db.users.get(user_uuid) user = _db.users.get(user_uuid)
if not user: if not user:
return 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): with _db.transaction("admin:delete_sessions_for_user", ctx):
for sess in user.sessions: for sess in user.sessions:
sess.delete() sess.delete()
@@ -615,6 +625,7 @@ def update_oid_client(
name: str | None = None, name: str | None = None,
redirect_uris: list[str] | None = None, redirect_uris: list[str] | None = None,
secret_hash: bytes | None = None, secret_hash: bytes | None = None,
backchannel_logout_uri: str | None = _UNSET,
*, *,
ctx: SessionContext | None = None, ctx: SessionContext | None = None,
) -> None: ) -> None:
@@ -631,10 +642,18 @@ def update_oid_client(
changes["redirect_uris"] = redirect_uris changes["redirect_uris"] = redirect_uris
if secret_hash is not None and secret_hash != client.client_secret_hash: if secret_hash is not None and secret_hash != client.client_secret_hash:
changes["client_secret_hash"] = 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: if not changes:
return # No changes to make 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): with _db.transaction("admin:update_oid_client", ctx):
# Create updated client with new values # Create updated client with new values
updated_client = OIDClient( updated_client = OIDClient(
@@ -645,6 +664,7 @@ def update_oid_client(
redirect_uris=redirect_uris redirect_uris=redirect_uris
if redirect_uris is not None if redirect_uris is not None
else client.redirect_uris, else client.redirect_uris,
backchannel_logout_uri=new_logout_uri,
) )
updated_client.uuid = client.uuid updated_client.uuid = client.uuid
_db.oid_clients[client_uuid] = updated_client _db.oid_clients[client_uuid] = updated_client
@@ -665,6 +685,7 @@ def reset_oid_client_secret(
client_secret_hash=new_secret_hash, client_secret_hash=new_secret_hash,
name=client.name, name=client.name,
redirect_uris=client.redirect_uris, redirect_uris=client.redirect_uris,
backchannel_logout_uri=client.backchannel_logout_uri,
) )
updated.uuid = client.uuid updated.uuid = client.uuid
_db.oid_clients[client_uuid] = updated _db.oid_clients[client_uuid] = updated
+4 -1
View File
@@ -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. """OIDC client (relying party) registration.
client_id is the dict key (UUID). client_id is the dict key (UUID).
@@ -545,6 +545,7 @@ class OIDClient(msgspec.Struct, dict=True):
client_secret_hash: bytes client_secret_hash: bytes
name: str name: str
redirect_uris: list[str] redirect_uris: list[str]
backchannel_logout_uri: str | None = None
def __post_init__(self): def __post_init__(self):
if not hasattr(self, "uuid"): if not hasattr(self, "uuid"):
@@ -557,6 +558,7 @@ class OIDClient(msgspec.Struct, dict=True):
redirect_uris: list[str], redirect_uris: list[str],
client_secret: str, client_secret: str,
created_at: datetime | None = None, created_at: datetime | None = None,
backchannel_logout_uri: str | None = None,
) -> tuple[OIDClient, str]: ) -> tuple[OIDClient, str]:
"""Create a new OIDClient with hashed secret. """Create a new OIDClient with hashed secret.
@@ -568,6 +570,7 @@ class OIDClient(msgspec.Struct, dict=True):
client_secret_hash=secret_hash, client_secret_hash=secret_hash,
name=name, name=name,
redirect_uris=redirect_uris, redirect_uris=redirect_uris,
backchannel_logout_uri=backchannel_logout_uri,
) )
client.uuid = uuid7.create(now) client.uuid = uuid7.create(now)
return client, client_secret return client, client_secret
+20
View File
@@ -12,6 +12,7 @@ from paskia.db import Org as OrgDC
from paskia.db import Permission as PermDC from paskia.db import Permission as PermDC
from paskia.db import Role as RoleDC from paskia.db import Role as RoleDC
from paskia.db import User as UserDC from paskia.db import User as UserDC
from paskia.db.operations import _UNSET
from paskia.db.structs import OIDClient from paskia.db.structs import OIDClient
from paskia.fastapi import authz from paskia.fastapi import authz
from paskia.fastapi.response import MsgspecResponse 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), "uuid": str(client.uuid),
"name": client.name, "name": client.name,
"redirect_uris": client.redirect_uris, "redirect_uris": client.redirect_uris,
"backchannel_logout_uri": client.backchannel_logout_uri,
"active_sessions": client_session_counts.get(client.uuid, 0), "active_sessions": client_session_counts.get(client.uuid, 0),
} }
for client in clients for client in clients
@@ -999,6 +1001,9 @@ async def admin_create_oidc_client(
secret_hash_hex = payload.get("secret_hash", "").strip() secret_hash_hex = payload.get("secret_hash", "").strip()
name = payload.get("name", "").strip() name = payload.get("name", "").strip()
redirect_uris = payload.get("redirect_uris", []) 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: if not client_id or not secret_hash_hex:
raise ValueError("client_id and secret_hash are required") 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"): if not isinstance(uri, str) or not uri.startswith("http"):
raise ValueError(f"Invalid redirect URI: {uri}") 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 = OIDClient(
client_secret_hash=secret_hash, client_secret_hash=secret_hash,
name=name, name=name,
redirect_uris=redirect_uris, redirect_uris=redirect_uris,
backchannel_logout_uri=backchannel_logout_uri,
) )
client.uuid = client_uuid client.uuid = client_uuid
@@ -1062,6 +1071,13 @@ async def admin_update_oidc_client(
secret_hash_hex = ( secret_hash_hex = (
payload.get("secret_hash", "").strip() if "secret_hash" in payload else None 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: if name is not None and not name:
raise ValueError("Client name cannot be empty") 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"): if not isinstance(uri, str) or not uri.startswith("http"):
raise ValueError(f"Invalid redirect URI: {uri}") 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 secret_hash = None
if secret_hash_hex: if secret_hash_hex:
try: try:
@@ -1089,6 +1108,7 @@ async def admin_update_oidc_client(
name=name, name=name,
redirect_uris=redirect_uris, redirect_uris=redirect_uris,
secret_hash=secret_hash, secret_hash=secret_hash,
backchannel_logout_uri=backchannel_logout_uri,
ctx=ctx, ctx=ctx,
) )
except ValueError as e: except ValueError as e:
-1
View File
@@ -108,7 +108,6 @@ async def openid_configuration(request: Request):
"jwks_uri": f"{issuer}/auth/oidc/keys", "jwks_uri": f"{issuer}/auth/oidc/keys",
"backchannel_logout_supported": True, "backchannel_logout_supported": True,
"backchannel_logout_session_supported": True, "backchannel_logout_session_supported": True,
"backchannel_logout_uri": f"{issuer}/auth/oidc/backchannel-logout",
"response_types_supported": ["code"], "response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"], "grant_types_supported": ["authorization_code", "refresh_token"],
"subject_types_supported": ["public"], "subject_types_supported": ["public"],
+12 -3
View File
@@ -120,7 +120,10 @@ async def token(
# RFC 6749: Token endpoint MUST NOT accept query parameters # RFC 6749: Token endpoint MUST NOT accept query parameters
if request.url.query: if request.url.query:
return JSONResponse( return JSONResponse(
{"error": "invalid_request", "error_description": "Query parameters not allowed"}, {
"error": "invalid_request",
"error_description": "Query parameters not allowed",
},
status_code=400, status_code=400,
) )
@@ -128,7 +131,10 @@ async def token(
content_type = request.headers.get("content-type", "") content_type = request.headers.get("content-type", "")
if "application/x-www-form-urlencoded" not in content_type: if "application/x-www-form-urlencoded" not in content_type:
return JSONResponse( 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, status_code=400,
) )
@@ -478,7 +484,10 @@ async def backchannel_logout(
content_type = request.headers.get("content-type", "") content_type = request.headers.get("content-type", "")
if "application/x-www-form-urlencoded" not in content_type: if "application/x-www-form-urlencoded" not in content_type:
return JSONResponse( 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, status_code=400,
) )
+120
View File
@@ -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")
+39 -7
View File
@@ -197,12 +197,44 @@ def decode_access_token(
decode_kwargs["audience"] = audience decode_kwargs["audience"] = audience
else: else:
options["verify_aud"] = False options["verify_aud"] = False
return jwt.decode( return jwt.decode(token, _public_key, options=options, **decode_kwargs)
token,
_public_key,
options=options,
**decode_kwargs
)
except jwt.PyJWTError: except jwt.PyJWTError:
return None 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})