Implement stateful OIDC as Session objects. Add refresh tokens and backchannel logout.
This commit is contained in:
+35
-57
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user