OAuth2 OpenID Connect provider support etc. #3

Merged
LeoVasanko merged 65 commits from oidconnect into main 2026-02-18 02:40:27 +00:00
8 changed files with 221 additions and 13 deletions
Showing only changes of commit f8c213c7dc - Show all commits
+5 -1
View File
@@ -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
+21
View File
@@ -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
+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.
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
+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 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:
-1
View File
@@ -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
View File
@@ -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,
)
+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
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})