From 18722f0e01e69990121344bd86e8cf92c1942e54 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 15 Feb 2026 03:47:56 +0000 Subject: [PATCH] Implement stateful OIDC as Session objects. Add refresh tokens and backchannel logout. --- oidc.md | 142 +++++++++++++++++++---- paskia/db/__init__.py | 10 +- paskia/db/operations.py | 86 ++++++++------ paskia/db/structs.py | 92 ++++++--------- paskia/fastapi/mainapp.py | 5 + paskia/fastapi/oid.py | 234 ++++++++++++++++++++++++++++++++++---- paskia/fastapi/ws.py | 33 ++++-- paskia/globals.py | 5 + paskia/oidauth.py | 152 +++++++++++++++++++++++++ paskia/util/oidjwt.py | 4 + 10 files changed, 609 insertions(+), 154 deletions(-) create mode 100644 paskia/oidauth.py diff --git a/oidc.md b/oidc.md index b237b5c..ea6e9a3 100644 --- a/oidc.md +++ b/oidc.md @@ -1,7 +1,7 @@ # OIDC Provider Implementation ## Overview -Minimal OpenID Connect 1.0 provider implementation for Paskia, enabling third-party apps to authenticate users via passkeys. +OpenID Connect 1.0 provider implementation for Paskia, enabling third-party apps to authenticate users via passkeys. Features stateful sessions with refresh tokens and back-channel logout support. ## Database Changes @@ -9,6 +9,23 @@ Minimal OpenID Connect 1.0 provider implementation for Paskia, enabling third-pa - `email: str | None` — omit if None - `preferred_username: str` — initially derived from full name, require unique and non-empty +### Session model additions (in `db/structs.py`) +Sessions now support both native (cookie-based) and OIDC authentication: +```python +class Session(msgspec.Struct, dict=True, omit_defaults=True): + user_uuid: UUID + credential_uuid: UUID # Always captured (passkey used for auth) + host: str + ip: str + user_agent: str + expiry: datetime + client_uuid: UUID | None = None # If set, this is an OIDC session +``` + +- Native sessions: `client_uuid` is None, validated via `session_ctx()` +- OIDC sessions: `client_uuid` set, looked up via `oidc_session_by_sid()` +- Session key serves as both cookie token (native) and `sid` claim (OIDC) + ### New OIDC-specific models ```python class OIDClient(msgspec.Struct): @@ -19,27 +36,29 @@ class OIDClient(msgspec.Struct): created_at: datetime def verify_secret(self, secret: str) -> bool: ... +``` -class OIDAuthCode(msgspec.Struct): - code: str # dict key, secure random - client: UUID - user: UUID +### In-memory auth codes (`paskia/oidauth.py`) +Authorization codes are stored in-memory only (not persisted), with 60-second lifetime: +```python +@dataclass +class AuthCode: + 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 # PKCE + code_challenge: str | None code_challenge_method: str | None created_at: datetime - expires_at: datetime # 10 minutes - - @classmethod - def create(cls, ...) -> OIDAuthCode: ... + expires_at: datetime # 60 seconds ``` ### DB additions ```python oid_clients: dict[UUID, OIDClient] = {} -oid_auth_codes: dict[str, OIDAuthCode] = {} ``` ## Endpoints @@ -49,8 +68,9 @@ oid_auth_codes: dict[str, OIDAuthCode] = {} - `GET /.well-known/jwks.json` — Public keys for token verification ### OIDC routes (`/auth/oidc/`) -- `POST /auth/oidc/token` — Token endpoint (code exchange) +- `POST /auth/oidc/token` — Token endpoint (code exchange & refresh) - `GET /auth/oidc/userinfo` — UserInfo endpoint (bearer token) +- `POST /auth/oidc/backchannel-logout` — Back-channel logout endpoint ### Authorization (via existing restricted app) - `GET /auth/restricted/oidc?client_id=...&redirect_uri=...&response_type=code&scope=openid...` @@ -74,6 +94,7 @@ The restricted app detects OIDC params from URL and handles authentication via W ```json { "sub": "user-uuid", + "sid": "session-id", "name": "Alice", "preferred_username": "alice", "email": "alice@example.com", @@ -83,24 +104,92 @@ The restricted app detects OIDC params from URL and handles authentication via W ## Authorization Flow -The `/auth/restricted/oidc` page handles OIDC authorization (same code as `/auth/restricted/iframe` for API auth): +The `/auth/restricted/oidc` page handles OIDC authorization: 1. Client redirects to `/auth/restricted/oidc?client_id=...&redirect_uri=...&response_type=code&scope=openid&state=...` 2. Frontend detects OIDC params from `window.location.search` 3. Frontend passes raw query string to `/auth/ws/authenticate?{query_string}` 4. User authenticates via passkey -5. WebSocket validates client/redirect_uri, authenticates user, creates auth code -6. WebSocket returns `{"redirect_url": "redirect_uri?code=...&state=..."}` -7. Frontend redirects to the URL -8. Client exchanges code at `/auth/oidc/token` → receives `id_token` + `access_token` -9. Optionally calls `/auth/oidc/userinfo` with bearer token +5. WebSocket validates client/redirect_uri, authenticates user +6. **Creates OIDC session** (persisted, with credential/IP/user_agent) +7. Creates auth code with session's `sid` +8. WebSocket returns `{"redirect_url": "redirect_uri?code=...&state=..."}` +9. Frontend redirects to the URL +10. Client exchanges code at `/auth/oidc/token` → receives tokens + +**Token Response:** +```json +{ + "access_token": "...", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "{sid}", + "id_token": "..." +} +``` **Key design points:** -- No session created during OIDC auth (stateless for the OIDC client) +- OIDC sessions are persisted (credential, IP, user_agent recorded) +- Session key becomes `sid` claim and `refresh_token` - Raw query string preserved throughout (no parsing/reconstruction of redirect_uri) - Redirect URI validated against client's registered URIs via exact string match - PKCE required (S256 only) +## Refresh Tokens + +The `refresh_token` returned is the OIDC session's `sid`. On refresh: + +**Request:** +``` +POST /auth/oidc/token +Content-Type: application/x-www-form-urlencoded + +grant_type=refresh_token&refresh_token={sid}&client_id=...&client_secret=... +``` + +**Behavior:** +1. Validates session exists and belongs to client +2. Checks session not expired +3. Extends session expiry to +24h (sliding window, matching native sessions) +4. Records current IP and user_agent +5. Issues new `access_token` and `id_token` (with same `sid`) +6. Returns same `refresh_token` (the sid is stable) + +**Session lifetime:** 24 hours with sliding window via refresh — same as native sessions. + +## Back-Channel Logout + +Supports OIDC Back-Channel Logout 1.0. + +**Discovery claims:** +```json +{ + "backchannel_logout_supported": true, + "backchannel_logout_session_supported": true +} +``` + +**Endpoint:** `POST /auth/oidc/backchannel-logout` + +**Request:** +``` +POST /auth/oidc/backchannel-logout +Content-Type: application/x-www-form-urlencoded + +logout_token={jwt} +``` + +The `logout_token` JWT must contain: +- `sid` — specific session to terminate, OR +- `sub` — terminate all sessions for user (optionally filtered by `aud` client) + +**Behavior:** +1. Decode and verify logout token signature +2. Look up session(s) by `sid` or `sub` +3. Verify client (`aud`) matches session's `client_uuid` +4. Delete session(s) +5. Return 200 OK (even if no sessions found, per spec) + ## WebSocket OIDC Mode The `/auth/ws/authenticate` WebSocket accepts optional OIDC query params: @@ -112,21 +201,25 @@ The `/auth/ws/authenticate` WebSocket accepts optional OIDC query params: When OIDC params present: - Validates client and redirect_uri before authentication -- Creates auth code after successful passkey auth +- Performs passkey authentication +- Creates OIDC session via `db.oidc_login()` (persists credential, IP, user_agent) +- Creates auth code with session's sid - Returns `{"redirect_url": "..."}` instead of session token When OIDC params absent: -- Normal authentication flow with session creation +- Normal authentication flow with native session creation ## Files ### Created -- `paskia/db/structs.py` — Added `OIDClient`, `OIDAuthCode` models; User fields `email`, `preferred_username` -- `paskia/db/operations.py` — CRUD for OIDC entities -- `paskia/fastapi/oid.py` — Token and userinfo endpoints +- `paskia/oidauth.py` — In-memory auth code storage (60-second lifetime, no persistence) +- `paskia/db/structs.py` — Added `OIDClient` model; Session `client_uuid` field; User fields `email`, `preferred_username` +- `paskia/db/operations.py` — CRUD for OIDC clients; `oidc_login()` function +- `paskia/fastapi/oid.py` — Token, userinfo, and backchannel-logout endpoints - `paskia/util/oidjwt.py` — Ed25519 key management, JWT creation, JWKS ### Modified +- `paskia/globals.py` — Initialize oidauth on startup - `paskia/fastapi/mainapp.py` — Mount OIDC app, well-known endpoints - `paskia/fastapi/ws.py` — OIDC params support in `/authenticate` - `frontend/src/utils/passkey.js` — Pass query string to authenticate @@ -137,4 +230,3 @@ When OIDC params absent: - Master admin UI for client management - User UI for editing preferred_username, email, profile picture - Token revocation endpoint (optional) -- Logout/session management spec (optional) diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index 1bdb479..d07fed8 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -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", ] diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 4eb94eb..21d6918 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -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) diff --git a/paskia/db/structs.py b/paskia/db/structs.py index 579cd22..5ff01c2 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -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 diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 1c82eea..4b4071b 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -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", ], } diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py index ab3e54e..936d0a1 100644 --- a/paskia/fastapi/oid.py +++ b/paskia/fastapi/oid.py @@ -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}) diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index a074923..65a09ce 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -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} diff --git a/paskia/globals.py b/paskia/globals.py index 137ad23..f0d7b18 100644 --- a/paskia/globals.py +++ b/paskia/globals.py @@ -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 diff --git a/paskia/oidauth.py b/paskia/oidauth.py new file mode 100644 index 0000000..3517ce1 --- /dev/null +++ b/paskia/oidauth.py @@ -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 diff --git a/paskia/util/oidjwt.py b/paskia/util/oidjwt.py index 8e08e63..f016e22 100644 --- a/paskia/util/oidjwt.py +++ b/paskia/util/oidjwt.py @@ -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: