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
+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