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