Implement stateful OIDC as Session objects. Add refresh tokens and backchannel logout.

This commit is contained in:
Leo Vasanko
2026-02-15 03:48:10 +00:00
parent 8132189a04
commit 18722f0e01
10 changed files with 609 additions and 154 deletions
+2 -8
View File
@@ -31,11 +31,8 @@ from paskia.db.lifecycle import cleanup_expired, init
from paskia.db.operations import (
add_permission_to_org,
add_permission_to_role,
cleanup_expired_oid_auth_codes,
consume_oid_auth_code,
create_credential,
create_credential_session,
create_oid_auth_code,
create_oid_client,
create_org,
create_permission,
@@ -53,6 +50,7 @@ from paskia.db.operations import (
delete_sessions_for_user,
delete_user,
login,
oidc_login,
remove_permission_from_org,
remove_permission_from_role,
set_session_host,
@@ -70,7 +68,6 @@ from paskia.db.structs import (
DB,
Config,
Credential,
OIDAuthCode,
OIDClient,
Org,
Permission,
@@ -92,7 +89,6 @@ __all__ = [
"Config",
"Credential",
"DB",
"OIDAuthCode",
"OIDClient",
"Org",
"Permission",
@@ -139,6 +135,7 @@ __all__ = [
"delete_sessions_for_user",
"delete_user",
"login",
"oidc_login",
"remove_permission_from_org",
"remove_permission_from_role",
"set_session_host",
@@ -152,9 +149,6 @@ __all__ = [
"update_user_role",
"update_user_theme",
# OIDC
"cleanup_expired_oid_auth_codes",
"consume_oid_auth_code",
"create_oid_auth_code",
"create_oid_client",
"delete_oid_client",
]
+50 -36
View File
@@ -20,7 +20,6 @@ from paskia.db.structs import (
DB,
Config,
Credential,
OIDAuthCode,
OIDClient,
Org,
Permission,
@@ -532,6 +531,56 @@ def login(
return session.key
def oidc_login(
user_uuid: UUID,
credential_uuid: UUID,
sign_count: int,
client_uuid: UUID,
host: str,
ip: str,
user_agent: str,
) -> str:
"""Create an OIDC session after passkey authentication.
Similar to login() but creates an OIDC session (with client_uuid set).
The returned session key becomes the 'sid' claim in the id_token.
Session uses standard SESSION_LIFETIME (24h) with refresh token support.
Updates:
- credential.sign_count, credential.last_used
Creates:
- new OIDC session
Returns the session key (sid for OIDC tokens).
"""
if isinstance(user_uuid, str):
user_uuid = UUID(user_uuid)
now = datetime.now(UTC)
if user_uuid not in _db.users:
raise ValueError(f"User {user_uuid} not found")
if credential_uuid not in _db.credentials:
raise ValueError(f"Credential {credential_uuid} not found")
if client_uuid not in _db.oid_clients:
raise ValueError(f"OIDC client {client_uuid} not found")
session = Session.create(
user=user_uuid,
credential=credential_uuid,
host=host,
ip=ip,
user_agent=user_agent,
expiry=now + SESSION_LIFETIME,
client=client_uuid,
)
user_str = str(user_uuid)
with _db.transaction("oidc_login", user=user_str):
session.store(now)
# Update credential
_db.credentials[credential_uuid].sign_count = sign_count
_db.credentials[credential_uuid].last_used = now
return session.key
def create_credential_session(
user_uuid: UUID,
credential: Credential,
@@ -609,38 +658,3 @@ def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -
raise ValueError(f"OIDC client {client_uuid} not found")
with _db.transaction("admin:delete_oid_client", ctx):
del _db.oid_clients[client_uuid]
def create_oid_auth_code(auth_code: OIDAuthCode) -> None:
"""Create a new OIDC authorization code."""
with _db.transaction("oid:create_auth_code"):
_db.oid_auth_codes[auth_code.code] = auth_code
def consume_oid_auth_code(code: str) -> OIDAuthCode | None:
"""Consume (delete and return) an OIDC authorization code.
Returns None if code not found or expired.
"""
auth_code = _db.oid_auth_codes.get(code)
if not auth_code:
return None
if auth_code.expires_at < datetime.now(UTC):
# Expired - delete it
with _db.transaction("oid:expire_auth_code"):
del _db.oid_auth_codes[code]
return None
with _db.transaction("oid:consume_auth_code"):
del _db.oid_auth_codes[code]
return auth_code
def cleanup_expired_oid_auth_codes() -> int:
"""Delete expired OIDC authorization codes. Returns count deleted."""
now = datetime.now(UTC)
expired = [k for k, v in _db.oid_auth_codes.items() if v.expires_at < now]
if expired:
with _db.transaction("oid:cleanup_auth_codes"):
for code in expired:
del _db.oid_auth_codes[code]
return len(expired)
+35 -57
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import hashlib
import secrets
from datetime import UTC, datetime, timedelta
from datetime import UTC, datetime
from uuid import UUID
import msgspec
@@ -355,12 +355,14 @@ class Credential(msgspec.Struct, dict=True):
return cred
class Session(msgspec.Struct, dict=True):
class Session(msgspec.Struct, dict=True, omit_defaults=True):
"""Session data structure.
Mutable fields: expiry (updated on session refresh)
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent
Immutable fields: user_uuid, credential_uuid, host, ip, user_agent, client_uuid
key is stored in the dict key, not in the struct.
If client_uuid is set, this is an OIDC session (key is the sid claim).
"""
user_uuid: UUID = msgspec.field(name="user")
@@ -369,6 +371,7 @@ class Session(msgspec.Struct, dict=True):
ip: str
user_agent: str
expiry: datetime
client_uuid: UUID | None = msgspec.field(name="client", default=None)
def __post_init__(self):
if not hasattr(self, "key"):
@@ -416,8 +419,12 @@ class Session(msgspec.Struct, dict=True):
ip: str,
user_agent: str,
expiry: datetime,
client: UUID | None = None,
) -> Session:
"""Create a new Session with auto-generated key."""
"""Create a new Session with auto-generated key.
If client is provided, creates an OIDC session (key becomes sid claim).
"""
user_uuid = user if isinstance(user, UUID) else user.uuid
credential_uuid = (
credential if isinstance(credential, UUID) else credential.uuid
@@ -429,6 +436,7 @@ class Session(msgspec.Struct, dict=True):
ip=ip,
user_agent=user_agent,
expiry=expiry,
client_uuid=client,
)
session.key = secrets.token_urlsafe(12)
return session
@@ -559,56 +567,6 @@ class OIDClient(msgspec.Struct, dict=True):
)
class OIDAuthCode(msgspec.Struct, dict=True):
"""OIDC authorization code (short-lived, single-use).
code is the dict key (random string).
"""
client_uuid: UUID = msgspec.field(name="client")
user_uuid: UUID = msgspec.field(name="user")
redirect_uri: str
scope: str
nonce: str | None = None
code_challenge: str | None = None
code_challenge_method: str | None = None
created_at: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
expires_at: datetime = msgspec.field(default_factory=lambda: datetime.now(UTC))
def __post_init__(self):
if not hasattr(self, "code"):
self.code: str = ""
@classmethod
def create(
cls,
client: UUID | OIDClient,
user: UUID,
redirect_uri: str,
scope: str,
nonce: str | None = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
lifetime_seconds: int = 600,
) -> OIDAuthCode:
"""Create a new auth code with 10-minute default lifetime."""
now = datetime.now(UTC)
client_uuid = client if isinstance(client, UUID) else client.uuid
auth_code = cls(
client_uuid=client_uuid,
user_uuid=user,
redirect_uri=redirect_uri,
scope=scope,
nonce=nonce,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
created_at=now,
expires_at=now + timedelta(seconds=lifetime_seconds),
)
auth_code.code = secrets.token_urlsafe(32)
return auth_code
class SessionContext(msgspec.Struct):
session: Session
user: User
@@ -646,7 +604,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
reset_tokens: dict[bytes, ResetToken] = {}
# OIDC provider data
oid_clients: dict[UUID, OIDClient] = {}
oid_auth_codes: dict[str, OIDAuthCode] = {}
def __post_init__(self):
# Store reference for persistence (not serialized)
@@ -669,8 +626,6 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
# OIDC
for uuid, client in self.oid_clients.items():
client.uuid = uuid
for code, auth_code in self.oid_auth_codes.items():
auth_code.code = code
def transaction(self, action, ctx=None, *, user=None):
"""Wrap writes in transaction. Delegates to JsonlStore."""
@@ -693,6 +648,10 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
except KeyError:
return None
# OIDC sessions (client_uuid set) are not valid for cookie-based auth
if s.client_uuid is not None:
return None
# Normalize host for comparison (stored hosts are already normalized)
normalized_input = hostutil.normalize_host(host)
@@ -734,3 +693,22 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
credential=credential,
permissions=effective_perms,
)
def oidc_session_by_sid(
self, sid: str, client_uuid: UUID | None = None
) -> Session | None:
"""Look up an OIDC session by sid (session key).
Args:
sid: The session ID (same as session key)
client_uuid: If provided, verify the session belongs to this client
Returns:
Session if found and valid OIDC session, None otherwise
"""
s = self.sessions.get(sid)
if not s or s.client_uuid is None:
return None
if client_uuid is not None and s.client_uuid != client_uuid:
return None
return s
+5
View File
@@ -106,7 +106,11 @@ async def openid_configuration(request: Request):
"token_endpoint": f"{issuer}/auth/oidc/token",
"userinfo_endpoint": f"{issuer}/auth/oidc/userinfo",
"jwks_uri": f"{issuer}/.well-known/jwks.json",
"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"],
"id_token_signing_alg_values_supported": ["EdDSA"],
"scopes_supported": ["openid", "profile", "email"],
@@ -121,6 +125,7 @@ async def openid_configuration(request: Request):
"preferred_username",
"email",
"permissions",
"sid",
],
}
+215 -19
View File
@@ -12,13 +12,15 @@ the /auth/ws/authenticate WebSocket.
import base64
import hashlib
import logging
from datetime import UTC, datetime
from uuid import UUID
from fastapi import Body, Depends, FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from paskia import db
from paskia import db, oidauth
from paskia.config import SESSION_LIFETIME
from paskia.util import oidjwt
_logger = logging.getLogger(__name__)
@@ -69,10 +71,14 @@ async def token(
client_id: str | None = Body(None, embed=False),
client_secret: str | None = Body(None, embed=False),
code_verifier: str | None = Body(None, embed=False),
refresh_token: str | None = Body(None, embed=False),
):
"""OIDC Token endpoint.
Exchanges authorization code for tokens.
Supports:
- grant_type=authorization_code: Exchange code for tokens
- grant_type=refresh_token: Refresh access token using sid
Supports client_secret_post and client_secret_basic authentication.
"""
# Parse form data (OAuth uses application/x-www-form-urlencoded)
@@ -85,20 +91,9 @@ async def token(
client_id = form.get("client_id", client_id)
client_secret = form.get("client_secret", client_secret)
code_verifier = form.get("code_verifier", code_verifier)
refresh_token = form.get("refresh_token", refresh_token)
if grant_type != "authorization_code":
return JSONResponse(
{"error": "unsupported_grant_type"},
status_code=400,
)
if not code:
return JSONResponse(
{"error": "invalid_request", "error_description": "Missing code"},
status_code=400,
)
# Get client credentials
# Get client credentials (required for all grant types)
client_id, client_secret = _parse_client_credentials(
request, client_id, client_secret
)
@@ -113,8 +108,36 @@ async def token(
if not client or not client.verify_secret(client_secret):
return JSONResponse({"error": "invalid_client"}, status_code=401)
if grant_type == "authorization_code":
return await _handle_authorization_code(
request, client, client_id, code, redirect_uri, code_verifier
)
elif grant_type == "refresh_token":
return await _handle_refresh_token(request, client, client_id, refresh_token)
else:
return JSONResponse(
{"error": "unsupported_grant_type"},
status_code=400,
)
async def _handle_authorization_code(
request: Request,
client,
client_id: str,
code: str | None,
redirect_uri: str | None,
code_verifier: str | None,
):
"""Handle grant_type=authorization_code."""
if not code:
return JSONResponse(
{"error": "invalid_request", "error_description": "Missing code"},
status_code=400,
)
# Consume auth code (atomic delete + return)
auth_code = db.consume_oid_auth_code(code)
auth_code = oidauth.instance.consume(code)
if not auth_code:
return JSONResponse(
{"error": "invalid_grant", "error_description": "Code expired or invalid"},
@@ -168,7 +191,87 @@ async def token(
status_code=400,
)
# Build issuer
return _build_token_response(
request, user, client_id, auth_code.sid, auth_code.nonce, auth_code.scope
)
async def _handle_refresh_token(
request: Request,
client,
client_id: str,
refresh_token_value: str | None,
):
"""Handle grant_type=refresh_token.
The refresh_token is the OIDC session sid. On refresh:
- Validates session exists and belongs to client
- Extends session expiry (24h sliding window)
- Records current IP and user_agent
- Issues new access_token and id_token
"""
if not refresh_token_value:
return JSONResponse(
{"error": "invalid_request", "error_description": "Missing refresh_token"},
status_code=400,
)
# Look up session by sid
session = db.data().oidc_session_by_sid(refresh_token_value, client.uuid)
if not session:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Invalid or expired refresh_token",
},
status_code=400,
)
# Check session not expired
now = datetime.now(UTC)
if session.expiry < now:
return JSONResponse(
{"error": "invalid_grant", "error_description": "Refresh token expired"},
status_code=400,
)
# Get user
user = db.data().users.get(session.user_uuid)
if not user:
return JSONResponse(
{"error": "invalid_grant", "error_description": "User not found"},
status_code=400,
)
# Refresh the session - extend expiry and record IP/user_agent
ip = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if not ip:
ip = request.client.host if request.client else ""
user_agent = request.headers.get("user-agent", "")
db.update_session(
session.key,
ip=ip,
user_agent=user_agent,
expiry=now + SESSION_LIFETIME,
)
_logger.info("OIDC session refreshed: %s", session.key)
return _build_token_response(
request, user, client_id, session.key, nonce=None, scope="openid"
)
def _build_token_response(
request: Request,
user,
client_id: str,
sid: str,
nonce: str | None,
scope: str,
):
"""Build the token response with access_token, id_token, and refresh_token."""
issuer = _get_issuer(request)
# Get user's permissions from role
@@ -188,7 +291,8 @@ async def token(
issuer=issuer,
subject=user.uuid,
audience=client_id,
nonce=auth_code.nonce,
nonce=nonce,
sid=sid,
name=user.display_name,
preferred_username=user.preferred_username,
email=user.email,
@@ -200,7 +304,7 @@ async def token(
issuer=issuer,
subject=user.uuid,
audience=client_id,
scope=auth_code.scope,
scope=scope,
)
return JSONResponse(
@@ -208,6 +312,7 @@ async def token(
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": sid,
"id_token": id_token,
}
)
@@ -273,3 +378,94 @@ async def userinfo(
response["permissions"] = permissions
return response
@app.post("/backchannel-logout")
async def backchannel_logout(
request: Request,
logout_token: str | None = Body(None, embed=False),
):
"""OIDC Back-Channel Logout endpoint.
Receives a logout_token JWT from the RP and invalidates the session.
The logout_token must contain either 'sid' (session ID) or 'sub' (user ID).
"""
# Parse form data
content_type = request.headers.get("content-type", "")
if "application/x-www-form-urlencoded" in content_type:
form = await request.form()
logout_token = form.get("logout_token", logout_token)
if not logout_token:
return JSONResponse(
{"error": "invalid_request", "error_description": "Missing logout_token"},
status_code=400,
)
# Decode and verify the logout token
issuer = _get_issuer(request)
payload = oidjwt.decode_access_token(logout_token, issuer)
if not payload:
return JSONResponse(
{"error": "invalid_request", "error_description": "Invalid logout_token"},
status_code=400,
)
# Validate required claims
sid = payload.get("sid")
sub = payload.get("sub")
if not sid and not sub:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "logout_token must contain sid or sub",
},
status_code=400,
)
# Get client from audience
aud = payload.get("aud")
client_uuid = None
if aud:
try:
client_uuid = UUID(aud)
except ValueError:
pass
# Delete session(s)
deleted = 0
if sid:
# Delete specific session by sid
session = db.data().oidc_session_by_sid(sid, client_uuid)
if session:
db.delete_session(session.key)
deleted = 1
_logger.info("Back-channel logout: deleted session %s", sid)
elif sub:
# Delete all OIDC sessions for this user/client
try:
user_uuid = UUID(sub)
except ValueError:
return JSONResponse(
{"error": "invalid_request", "error_description": "Invalid sub claim"},
status_code=400,
)
# Find and delete matching sessions
sessions_to_delete = [
s
for s in db.data().sessions.values()
if s.user_uuid == user_uuid
and s.client_uuid is not None
and (client_uuid is None or s.client_uuid == client_uuid)
]
for session in sessions_to_delete:
db.delete_session(session.key)
deleted += 1
if deleted:
_logger.info(
"Back-channel logout: deleted %d sessions for user %s", deleted, sub
)
# Return 200 OK even if no sessions were found (per spec)
return JSONResponse({"deleted": deleted})
+24 -9
View File
@@ -3,9 +3,8 @@ from uuid import UUID
from fastapi import FastAPI, WebSocket
from paskia import db
from paskia import db, oidauth
from paskia.authsession import get_reset
from paskia.db import OIDAuthCode
from paskia.fastapi import authz, remote
from paskia.fastapi.session import AUTH_COOKIE, infodict
from paskia.fastapi.wschat import (
@@ -152,21 +151,37 @@ async def websocket_authenticate(
session_user_uuid = existing_ctx.user.uuid
if oidc_client:
# OIDC mode: authenticate only, no session
# OIDC mode: authenticate and create OIDC session
cred, new_sign_count = await authenticate_chat(ws)
db.update_credential_sign_count(cred.uuid, new_sign_count)
# Create auth code
auth_code = OIDAuthCode.create(
client=oidc_client.uuid,
user=cred.user_uuid,
# Get metadata for session
origin = validate_origin(ws)
host = origin.split("://", 1)[1]
normalized_host = hostutil.normalize_host(host)
metadata = infodict(ws, "oidc_auth")
# Create OIDC session (returns sid)
sid = db.oidc_login(
user_uuid=cred.user_uuid,
credential_uuid=cred.uuid,
sign_count=new_sign_count,
client_uuid=oidc_client.uuid,
host=normalized_host,
ip=metadata["ip"],
user_agent=metadata["user_agent"],
)
# Create auth code (in-memory only)
auth_code = oidauth.instance.create(
client_uuid=oidc_client.uuid,
user_uuid=cred.user_uuid,
redirect_uri=redirect_uri,
scope=scope,
sid=sid,
nonce=nonce,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
)
db.create_oid_auth_code(auth_code)
# Build redirect URL
params = {"code": auth_code.code}
+5
View File
@@ -58,6 +58,11 @@ async def init(
# Initialize remote auth manager
await remoteauth.init()
# Initialize OIDC auth code manager
from paskia import oidauth
await oidauth.init()
if bootstrap:
# Bootstrap system if needed
+152
View File
@@ -0,0 +1,152 @@
"""
OIDC authorization code management.
Authorization codes are short-lived (60 seconds) and stored in-memory only.
Similar to remote auth, these are not persisted to the database.
"""
import asyncio
import logging
import secrets
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from uuid import UUID
_logger = logging.getLogger(__name__)
# Auth codes expire after this duration
AUTH_CODE_LIFETIME = timedelta(seconds=60)
@dataclass
class AuthCode:
"""A pending OIDC authorization code."""
code: str
client_uuid: UUID
user_uuid: UUID
redirect_uri: str
scope: str
sid: str # Session ID for backchannel logout
nonce: str | None
code_challenge: str | None
code_challenge_method: str | None
created_at: datetime
expires_at: datetime
class OIDAuthCodeManager:
"""Manages pending OIDC authorization codes in-memory."""
def __init__(self):
self._codes: dict[str, AuthCode] = {}
self._cleanup_task: asyncio.Task | None = None
self._lock = asyncio.Lock()
async def start(self):
"""Start the cleanup background task."""
if self._cleanup_task is None:
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
async def stop(self):
"""Stop the cleanup background task."""
if self._cleanup_task:
self._cleanup_task.cancel()
try:
await self._cleanup_task
except asyncio.CancelledError:
pass
self._cleanup_task = None
async def _cleanup_loop(self):
"""Periodically clean up expired codes."""
while True:
try:
await asyncio.sleep(30) # Check every 30 seconds
await self._cleanup_expired()
except asyncio.CancelledError:
break
except Exception:
_logger.exception("Error in OIDC auth code cleanup loop")
async def _cleanup_expired(self):
"""Remove expired authorization codes."""
now = datetime.now(UTC)
expired_codes = []
async with self._lock:
for code, auth_code in self._codes.items():
if now > auth_code.expires_at:
expired_codes.append(code)
for code in expired_codes:
del self._codes[code]
if expired_codes:
_logger.debug("Cleaned up %d expired OIDC auth codes", len(expired_codes))
def create(
self,
client_uuid: UUID,
user_uuid: UUID,
redirect_uri: str,
scope: str,
sid: str,
nonce: str | None = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
) -> AuthCode:
"""Create a new authorization code.
Returns the AuthCode with a unique code string.
"""
now = datetime.now(UTC)
code = secrets.token_urlsafe(32)
auth_code = AuthCode(
code=code,
client_uuid=client_uuid,
user_uuid=user_uuid,
redirect_uri=redirect_uri,
scope=scope,
sid=sid,
nonce=nonce,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
created_at=now,
expires_at=now + AUTH_CODE_LIFETIME,
)
self._codes[code] = auth_code
return auth_code
def consume(self, code: str) -> AuthCode | None:
"""Consume (delete and return) an authorization code.
Returns None if code not found or expired.
"""
auth_code = self._codes.get(code)
if not auth_code:
return None
if auth_code.expires_at < datetime.now(UTC):
# Expired - delete it
del self._codes[code]
return None
del self._codes[code]
return auth_code
# Global instance (initialized on startup)
instance: OIDAuthCodeManager | None = None
async def init():
"""Initialize the global OIDC auth code manager."""
global instance
instance = OIDAuthCodeManager()
await instance.start()
async def shutdown():
"""Shutdown the global OIDC auth code manager."""
global instance
if instance:
await instance.stop()
instance = None
+4
View File
@@ -86,6 +86,7 @@ def create_id_token(
subject: UUID,
audience: str, # client_id
nonce: str | None = None,
sid: str | None = None,
name: str | None = None,
preferred_username: str | None = None,
email: str | None = None,
@@ -99,6 +100,7 @@ def create_id_token(
subject: User UUID (sub claim)
audience: Client ID (aud claim)
nonce: Nonce from authorization request
sid: Session ID for backchannel logout
name: User's display name
preferred_username: User's preferred username
email: User's email address
@@ -119,6 +121,8 @@ def create_id_token(
}
if nonce:
payload["nonce"] = nonce
if sid:
payload["sid"] = sid
if name:
payload["name"] = name
if preferred_username: