Implement backchannel logout to client.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"],
|
||||
|
||||
+12
-3
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
+38
-6
@@ -198,11 +198,43 @@ def decode_access_token(
|
||||
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})
|
||||
|
||||
Reference in New Issue
Block a user