From b5a5f2707aaceaf699dde842e7fe5ed6c41c17b9 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sat, 14 Feb 2026 23:01:13 +0000 Subject: [PATCH 01/64] Draft OpenID Connect support. --- frontend/auth/restricted/RestrictedApi.vue | 9 + frontend/src/components/RestrictedAuth.vue | 12 +- frontend/src/utils/passkey.js | 9 +- oidc.md | 140 +++++++++++ paskia/db/__init__.py | 15 ++ paskia/db/operations.py | 58 +++++ paskia/db/structs.py | 115 ++++++++- paskia/fastapi/mainapp.py | 43 +++- paskia/fastapi/oid.py | 272 +++++++++++++++++++++ paskia/fastapi/ws.py | 92 ++++++- paskia/util/oidjwt.py | 193 +++++++++++++++ pyproject.toml | 2 +- 12 files changed, 941 insertions(+), 19 deletions(-) create mode 100644 oidc.md create mode 100644 paskia/fastapi/oid.py create mode 100644 paskia/util/oidjwt.py diff --git a/frontend/auth/restricted/RestrictedApi.vue b/frontend/auth/restricted/RestrictedApi.vue index 791b445..5eae0a4 100644 --- a/frontend/auth/restricted/RestrictedApi.vue +++ b/frontend/auth/restricted/RestrictedApi.vue @@ -2,6 +2,7 @@ @@ -15,6 +16,9 @@ import RestrictedAuth from '@/components/RestrictedAuth.vue' // The token is a 5-word passphrase like "word1.word2.word3.word4.word5" const remoteAuthToken = ref(null) +// For OIDC flow, pass the raw query string to preserve exact param values +const oidcQueryString = window.location.search.includes('client_id=') ? window.location.search : null + function extractRemoteToken() { const path = window.location.pathname // Match /auth/{token} where token is a passphrase with dots @@ -41,6 +45,11 @@ function postToParent(message) { } function handleAuthenticated(result) { + if (result.redirect_url) { + // OIDC flow: redirect to client with auth code + window.location.href = result.redirect_url + return + } postToParent({ type: 'auth-success', authenticated: true, diff --git a/frontend/src/components/RestrictedAuth.vue b/frontend/src/components/RestrictedAuth.vue index 4ee1360..ed554ff 100644 --- a/frontend/src/components/RestrictedAuth.vue +++ b/frontend/src/components/RestrictedAuth.vue @@ -67,6 +67,10 @@ const props = defineProps({ type: String, default: 'login', validator: (value) => ['login', 'reauth', 'forbidden'].includes(value) + }, + oidcQueryString: { + type: String, + default: null } }) @@ -163,7 +167,7 @@ async function authenticateUser() { loading.value = true showMessage('Starting authentication…', 'info') let result - try { result = await passkey.authenticate() } catch (error) { + try { result = await passkey.authenticate(props.oidcQueryString) } catch (error) { loading.value = false const message = error?.message || 'Passkey authentication cancelled' const cancelled = message === 'Passkey authentication cancelled' @@ -171,6 +175,12 @@ async function authenticateUser() { emit('auth-error', { message, cancelled }) return } + // OIDC flow: no session cookie, just emit the redirect_url + if (result.redirect_url) { + loading.value = false + emit('authenticated', result) + return + } try { await setSessionCookie(result) } catch (error) { loading.value = false const message = error?.message || 'Failed to establish session' diff --git a/frontend/src/utils/passkey.js b/frontend/src/utils/passkey.js index 89c79f7..ca26267 100644 --- a/frontend/src/utils/passkey.js +++ b/frontend/src/utils/passkey.js @@ -54,8 +54,13 @@ export async function register(resetToken = null, displayName = null, onstartreg } } -export async function authenticate() { - const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate')) +export async function authenticate(queryString = null) { + // Build URL, optionally appending raw query string (e.g. for OIDC params) + let url = await makeUrl('/auth/ws/authenticate') + if (queryString) { + url += queryString.startsWith('?') ? queryString : `?${queryString}` + } + const ws = await aWebSocket(url) try { let res = await ws.receive_json() if (res.status >= 400) throw new Error(res.detail || `Authentication failed: ${res.status}`) diff --git a/oidc.md b/oidc.md new file mode 100644 index 0000000..ceb1a9e --- /dev/null +++ b/oidc.md @@ -0,0 +1,140 @@ +# OIDC Provider Implementation + +## Overview +Minimal OpenID Connect 1.0 provider implementation for Paskia, enabling third-party apps to authenticate users via passkeys. + +## Database Changes + +### User model additions (in `db/structs.py`) +- `email: str | None` — omit if None +- `preferred_username: str` — initially derived from full name, require unique and non-empty + +### New OIDC-specific models +```python +class OIDClient(msgspec.Struct): + uuid: UUID + client_secret_hash: bytes + name: str + redirect_uris: list[str] + created_at: datetime + + def verify_secret(self, secret: str) -> bool: ... + +class OIDAuthCode(msgspec.Struct): + code: str # dict key, secure random + client: UUID + user: UUID + redirect_uri: str + scope: str + nonce: str | None + code_challenge: str | None # PKCE + code_challenge_method: str | None + created_at: datetime + expires_at: datetime # 10 minutes + + @classmethod + def create(cls, ...) -> OIDAuthCode: ... +``` + +### DB additions +```python +oid_clients: dict[UUID, OIDClient] = {} +oid_auth_codes: dict[str, OIDAuthCode] = {} +``` + +## Endpoints + +### Well-known (root level) +- `GET /.well-known/openid-configuration` — Discovery document +- `GET /.well-known/jwks.json` — Public keys for token verification + +### OIDC routes (`/auth/oid/`) +- `POST /auth/oid/token` — Token endpoint (code exchange) +- `GET /auth/oid/userinfo` — UserInfo endpoint (bearer token) + +### Authorization (via existing restricted app) +- `GET /auth/restricted/?client_id=...&redirect_uri=...&response_type=code&scope=openid...` + +The restricted app detects OIDC params from URL and handles authentication via WebSocket. + +## JWT & Signing +- RSA keypair generated on first boot (stored in data directory) +- ID tokens signed with RS256 +- `kid` in JWKS for key rotation support +- Access tokens are signed JWTs (not opaque) + +## Scopes & Claims + +**Standard OIDC scopes** — implicit for authenticated users: +- `openid` — required, enables ID token +- `profile` — includes `name`, `preferred_username` +- `email` — includes `email` (if set) + +**Paskia permissions as claims** — user's effective permission scopes included automatically: +```json +{ + "sub": "user-uuid", + "name": "Alice", + "preferred_username": "alice", + "email": "alice@example.com", + "permissions": ["admin", "reports:view"] +} +``` + +## Authorization Flow + +The `/auth/restricted/` page handles OIDC authorization alongside normal iframe auth: + +1. Client redirects to `/auth/restricted/?client_id=...&redirect_uri=...&response_type=code&scope=openid&state=...` +2. Frontend detects OIDC params from `window.location.search` +3. User authenticates via passkey +4. Frontend passes raw query string to `/auth/ws/authenticate?{query_string}` +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/oid/token` → receives `id_token` + `access_token` +9. Optionally calls `/auth/oid/userinfo` with bearer token + +**Key design points:** +- No session created during OIDC auth (stateless for the OIDC client) +- Raw query string preserved throughout (no parsing/reconstruction of redirect_uri) +- Redirect URI validated against client's registered URIs via exact string match +- PKCE supported (S256 and plain methods) + +## WebSocket OIDC Mode + +The `/auth/ws/authenticate` WebSocket accepts optional OIDC query params: +- `client_id` — OIDC client UUID +- `redirect_uri` — exact registered redirect URI +- `scope` — must include `openid` +- `state`, `nonce` — passed through +- `code_challenge`, `code_challenge_method` — for PKCE + +When OIDC params present: +- Validates client and redirect_uri before authentication +- Creates auth code after successful passkey auth +- Returns `{"redirect_url": "..."}` instead of session token + +When OIDC params absent: +- Normal authentication flow with 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/util/oidjwt.py` — RSA key management, JWT creation, JWKS + +### Modified +- `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 +- `frontend/auth/restricted/RestrictedApi.vue` — Detect OIDC from URL, handle redirect +- `frontend/src/components/RestrictedAuth.vue` — Pass OIDC query string prop + +## TODO +- 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 09637b2..1bdb479 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -31,8 +31,12 @@ 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, create_reset_token, @@ -40,6 +44,7 @@ from paskia.db.operations import ( create_session, create_user, delete_credential, + delete_oid_client, delete_org, delete_permission, delete_reset_token, @@ -65,6 +70,8 @@ from paskia.db.structs import ( DB, Config, Credential, + OIDAuthCode, + OIDClient, Org, Permission, ResetToken, @@ -85,6 +92,8 @@ __all__ = [ "Config", "Credential", "DB", + "OIDAuthCode", + "OIDClient", "Org", "Permission", "ResetToken", @@ -142,4 +151,10 @@ __all__ = [ "update_user_display_name", "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 3a0f75b..4eb94eb 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -20,6 +20,8 @@ from paskia.db.structs import ( DB, Config, Credential, + OIDAuthCode, + OIDClient, Org, Permission, ResetToken, @@ -586,3 +588,59 @@ def create_credential_session( if token: token.delete() return session.key + + +# ------------------------------------------------------------------------- +# OIDC Provider operations +# ------------------------------------------------------------------------- + + +def create_oid_client(client: OIDClient, *, ctx: SessionContext | None = None) -> None: + """Create a new OIDC client.""" + if client.uuid in _db.oid_clients: + raise ValueError(f"OIDC client {client.uuid} already exists") + with _db.transaction("admin:create_oid_client", ctx): + _db.oid_clients[client.uuid] = client + + +def delete_oid_client(client_uuid: UUID, *, ctx: SessionContext | None = None) -> None: + """Delete an OIDC client.""" + if client_uuid not in _db.oid_clients: + 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 c1e981b..579cd22 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 +from datetime import UTC, datetime, timedelta from uuid import UUID import msgspec @@ -197,7 +197,7 @@ class Role(msgspec.Struct, dict=True, omit_defaults=True): class User(msgspec.Struct, dict=True, omit_defaults=True): """User data structure. - Mutable fields: display_name, role_uuid, last_seen, visits, theme + Mutable fields: display_name, role_uuid, last_seen, visits, theme, email, preferred_username Immutable fields: created_at (set at creation, never modified) uuid is derived from created_at using uuid7. """ @@ -208,6 +208,8 @@ class User(msgspec.Struct, dict=True, omit_defaults=True): last_seen: datetime | None = None visits: int = 0 theme: str = "" # "" or "auto" = OS default, "light", "dark" + email: str | None = None # OIDC email claim + preferred_username: str | None = None # OIDC preferred_username claim def __post_init__(self): if not hasattr(self, "uuid"): @@ -506,6 +508,107 @@ class ResetToken(msgspec.Struct, dict=True): return token, passphrase +# ------------------------------------------------------------------------- +# OIDC Provider structures +# ------------------------------------------------------------------------- + + +class OIDClient(msgspec.Struct, dict=True): + """OIDC client (relying party) registration. + + client_id is the dict key (UUID). + """ + + client_secret_hash: bytes + name: str + redirect_uris: list[str] + created_at: datetime + + def __post_init__(self): + if not hasattr(self, "uuid"): + self.uuid: UUID = _UUID_UNSET + + @classmethod + def create( + cls, + name: str, + redirect_uris: list[str], + client_secret: str, + created_at: datetime | None = None, + ) -> tuple[OIDClient, str]: + """Create a new OIDClient with hashed secret. + + Returns (client, client_secret) tuple. + """ + now = created_at or datetime.now(UTC) + secret_hash = hashlib.sha256(client_secret.encode()).digest() + client = cls( + client_secret_hash=secret_hash, + name=name, + redirect_uris=redirect_uris, + created_at=now, + ) + client.uuid = uuid7.create(now) + return client, client_secret + + def verify_secret(self, client_secret: str) -> bool: + """Verify a client secret against stored hash.""" + return secrets.compare_digest( + self.client_secret_hash, + hashlib.sha256(client_secret.encode()).digest(), + ) + + +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 @@ -541,6 +644,9 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False): credentials: dict[UUID, Credential] = {} sessions: dict[str, Session] = {} 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) @@ -560,6 +666,11 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False): session.key = key for key, token in self.reset_tokens.items(): token.key = key + # 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.""" diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 22eaa76..0a9db95 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -11,11 +11,12 @@ from fastapi_vue import Frontend from paskia import globals from paskia.db import start_background, stop_background from paskia.db.logging import configure_db_logging -from paskia.fastapi import admin, api, auth_host, ws +from paskia.fastapi import admin, api, auth_host, oid, ws from paskia.fastapi.__main__ import DEVMODE from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging from paskia.fastapi.session import AUTH_COOKIE from paskia.util import hostutil, passphrase, vitedev +from paskia.util.oidjwt import get_jwks # Configure custom logging configure_access_logging() @@ -87,6 +88,46 @@ app.middleware("http")(auth_host.redirect_middleware) app.mount("/auth/api/admin/", admin.app) app.mount("/auth/api/", api.app) app.mount("/auth/ws/", ws.app) +app.mount("/auth/oid/", oid.app) + + +# OIDC Well-Known endpoints (must be at site root) +@app.get("/.well-known/openid-configuration") +async def openid_configuration(request: Request): + """OpenID Connect Discovery document.""" + # Build issuer URL from request + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("host", request.url.netloc) + issuer = f"{scheme}://{host}" + + return { + "issuer": issuer, + "authorization_endpoint": f"{issuer}/auth/restricted/", + "token_endpoint": f"{issuer}/auth/oid/token", + "userinfo_endpoint": f"{issuer}/auth/oid/userinfo", + "jwks_uri": f"{issuer}/.well-known/jwks.json", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + "scopes_supported": ["openid", "profile", "email"], + "token_endpoint_auth_methods_supported": [ + "client_secret_post", + "client_secret_basic", + ], + "claims_supported": [ + "sub", + "name", + "preferred_username", + "email", + "permissions", + ], + } + + +@app.get("/.well-known/jwks.json") +async def jwks(): + """JSON Web Key Set for token verification.""" + return get_jwks() @app.get("/auth/restricted/") diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py new file mode 100644 index 0000000..fb1ea16 --- /dev/null +++ b/paskia/fastapi/oid.py @@ -0,0 +1,272 @@ +""" +OIDC Provider endpoints. + +Implements OpenID Connect 1.0 Authorization Code flow: +- POST /token - Token endpoint (code exchange) +- GET /userinfo - UserInfo endpoint (bearer token) + +Authorization is handled by /auth/restricted/ which passes OIDC params to +the /auth/ws/authenticate WebSocket. +""" + +import base64 +import hashlib +import logging +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.util import oidjwt + +_logger = logging.getLogger(__name__) + +app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) + + +def _get_issuer(request: Request) -> str: + """Build issuer URL from request.""" + scheme = request.headers.get("x-forwarded-proto", request.url.scheme) + host = request.headers.get("host", request.url.netloc) + return f"{scheme}://{host}" + + +def _verify_pkce(code_verifier: str, code_challenge: str, method: str) -> bool: + """Verify PKCE code_verifier against stored code_challenge.""" + if method == "plain": + return code_verifier == code_challenge + elif method == "S256": + # SHA256 hash, base64url encode + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() + computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return computed == code_challenge + return False + + +def _parse_client_credentials( + request: Request, + client_id: str | None, + client_secret: str | None, +) -> tuple[str, str]: + """Extract client credentials from request (Basic auth or body params).""" + auth_header = request.headers.get("authorization", "") + if auth_header.lower().startswith("basic "): + try: + decoded = base64.b64decode(auth_header[6:]).decode("utf-8") + client_id, client_secret = decoded.split(":", 1) + except Exception: + raise HTTPException(401, "Invalid Authorization header") + + if not client_id or not client_secret: + raise HTTPException(401, "Missing client credentials") + + return client_id, client_secret + + +@app.post("/token") +async def token( + request: Request, + grant_type: str = Body(..., embed=False), + code: str | None = Body(None, embed=False), + redirect_uri: str | None = Body(None, embed=False), + client_id: str | None = Body(None, embed=False), + client_secret: str | None = Body(None, embed=False), + code_verifier: str | None = Body(None, embed=False), +): + """OIDC Token endpoint. + + Exchanges authorization code for tokens. + Supports client_secret_post and client_secret_basic authentication. + """ + # Parse form data (OAuth uses application/x-www-form-urlencoded) + content_type = request.headers.get("content-type", "") + if "application/x-www-form-urlencoded" in content_type: + form = await request.form() + grant_type = form.get("grant_type", grant_type) + code = form.get("code", code) + redirect_uri = form.get("redirect_uri", redirect_uri) + client_id = form.get("client_id", client_id) + client_secret = form.get("client_secret", client_secret) + code_verifier = form.get("code_verifier", code_verifier) + + 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 + client_id, client_secret = _parse_client_credentials( + request, client_id, client_secret + ) + + # Validate client + try: + client_uuid = UUID(client_id) + except ValueError: + return JSONResponse({"error": "invalid_client"}, status_code=401) + + client = db.data().oid_clients.get(client_uuid) + if not client or not client.verify_secret(client_secret): + return JSONResponse({"error": "invalid_client"}, status_code=401) + + # Consume auth code (atomic delete + return) + auth_code = db.consume_oid_auth_code(code) + if not auth_code: + return JSONResponse( + {"error": "invalid_grant", "error_description": "Code expired or invalid"}, + status_code=400, + ) + + # Verify client matches + if auth_code.client_uuid != client.uuid: + return JSONResponse({"error": "invalid_grant"}, status_code=400) + + # Verify redirect_uri matches + if redirect_uri and redirect_uri != auth_code.redirect_uri: + return JSONResponse( + {"error": "invalid_grant", "error_description": "redirect_uri mismatch"}, + status_code=400, + ) + + # Verify PKCE if code_challenge was provided + if auth_code.code_challenge: + if not code_verifier: + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "Missing code_verifier", + }, + status_code=400, + ) + method = auth_code.code_challenge_method or "plain" + if not _verify_pkce(code_verifier, auth_code.code_challenge, method): + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "Invalid code_verifier", + }, + status_code=400, + ) + + # Get user + user = db.data().users.get(auth_code.user_uuid) + if not user: + return JSONResponse( + {"error": "invalid_grant", "error_description": "User not found"}, + status_code=400, + ) + + # Build issuer + issuer = _get_issuer(request) + + # Get user's permissions from role + role = user.role + org = role.org + org_perm_uuids = {p.uuid for p in org.permissions} + permissions = [] + for perm_uuid in role.permission_set: + if perm_uuid not in org_perm_uuids: + continue + p = db.data().permissions.get(perm_uuid) + if p: + permissions.append(p.scope) + + # Create ID token + id_token = oidjwt.create_id_token( + issuer=issuer, + subject=user.uuid, + audience=client_id, + nonce=auth_code.nonce, + name=user.display_name, + preferred_username=user.preferred_username, + email=user.email, + permissions=permissions if permissions else None, + ) + + # Create access token + access_token = oidjwt.create_access_token( + issuer=issuer, + subject=user.uuid, + audience=client_id, + scope=auth_code.scope, + ) + + return JSONResponse( + { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": 3600, + "id_token": id_token, + } + ) + + +bearer_auth = HTTPBearer(auto_error=False) + + +@app.get("/userinfo") +async def userinfo( + request: Request, + credentials=Depends(bearer_auth), +): + """OIDC UserInfo endpoint. + + Returns claims about the authenticated user. + Requires Bearer token from /token endpoint. + """ + if not credentials: + raise HTTPException(401, "Bearer token required") + + issuer = _get_issuer(request) + payload = oidjwt.decode_access_token(credentials.credentials, issuer) + if not payload: + raise HTTPException(401, "Invalid or expired token") + + # Get user + try: + user_uuid = UUID(payload["sub"]) + except (KeyError, ValueError): + raise HTTPException(401, "Invalid token") + + user = db.data().users.get(user_uuid) + if not user: + raise HTTPException(401, "User not found") + + # Get user's permissions + role = user.role + org = role.org + org_perm_uuids = {p.uuid for p in org.permissions} + permissions = [] + for perm_uuid in role.permission_set: + if perm_uuid not in org_perm_uuids: + continue + p = db.data().permissions.get(perm_uuid) + if p: + permissions.append(p.scope) + + # Build userinfo response based on scope + scope = payload.get("scope", "openid").split() + response = {"sub": str(user.uuid)} + + if "profile" in scope: + response["name"] = user.display_name + if user.preferred_username: + response["preferred_username"] = user.preferred_username + + if "email" in scope and user.email: + response["email"] = user.email + + # Always include permissions + if permissions: + response["permissions"] = permissions + + return response diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 30649d7..ac5fa59 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -1,10 +1,18 @@ +from urllib.parse import urlencode +from uuid import UUID + from fastapi import FastAPI, WebSocket from paskia import db 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 authenticate_and_login, register_chat +from paskia.fastapi.wschat import ( + authenticate_and_login, + authenticate_chat, + register_chat, +) from paskia.fastapi.wsutil import validate_origin, websocket_error_handler from paskia.globals import passkey from paskia.util import hostutil, passphrase @@ -83,10 +91,44 @@ async def websocket_register_add( @app.websocket("/authenticate") @websocket_error_handler -async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): +async def websocket_authenticate( + ws: WebSocket, + auth=AUTH_COOKIE, + # OIDC params (when present, creates auth code instead of session) + client_id: str | None = None, + redirect_uri: str | None = None, + scope: str = "openid", + state: str | None = None, + nonce: str | None = None, + code_challenge: str | None = None, + code_challenge_method: str | None = None, +): origin = validate_origin(ws) host = origin.split("://", 1)[1] + # OIDC mode: validate client before auth + oidc_client = None + if client_id and redirect_uri: + try: + client_uuid = UUID(client_id) + except ValueError: + await ws.send_json({"status": 400, "detail": "Invalid client_id"}) + return + + oidc_client = db.data().oid_clients.get(client_uuid) + if not oidc_client: + await ws.send_json({"status": 400, "detail": "Unknown client_id"}) + return + + if redirect_uri not in oidc_client.redirect_uris: + await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"}) + return + + scopes = scope.split() + if "openid" not in scopes: + await ws.send_json({"status": 400, "detail": "Scope must include openid"}) + return + # If there's an existing session, restrict to that user's credentials (reauth) session_user_uuid = None if auth: @@ -94,15 +136,41 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE): if existing_ctx: session_user_uuid = existing_ctx.user.uuid - ctx = await authenticate_and_login(ws, auth) + if oidc_client: + # OIDC mode: authenticate only, no session + cred, new_sign_count = await authenticate_chat(ws) + db.update_credential_sign_count(cred.uuid, new_sign_count) - # If reauth mode, verify the credential belongs to the session's user - if session_user_uuid and ctx.user.uuid != session_user_uuid: - raise ValueError("This passkey belongs to a different account") + # Create auth code + auth_code = OIDAuthCode.create( + client=oidc_client.uuid, + user=cred.user_uuid, + redirect_uri=redirect_uri, + scope=scope, + nonce=nonce, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method, + ) + db.create_oid_auth_code(auth_code) - await ws.send_json( - { - "user": str(ctx.user.uuid), - "session_token": ctx.session.key, - } - ) + # Build redirect URL + params = {"code": auth_code.code} + if state: + params["state"] = state + redirect_url = f"{redirect_uri}?{urlencode(params)}" + + await ws.send_json({"redirect_url": redirect_url}) + else: + # Normal mode: authenticate and create session + ctx = await authenticate_and_login(ws, auth) + + # If reauth mode, verify the credential belongs to the session's user + if session_user_uuid and ctx.user.uuid != session_user_uuid: + raise ValueError("This passkey belongs to a different account") + + await ws.send_json( + { + "user": str(ctx.user.uuid), + "session_token": ctx.session.key, + } + ) diff --git a/paskia/util/oidjwt.py b/paskia/util/oidjwt.py new file mode 100644 index 0000000..4f79b4f --- /dev/null +++ b/paskia/util/oidjwt.py @@ -0,0 +1,193 @@ +""" +OIDC JWT utilities for signing ID tokens and serving JWKS. +""" + +import hashlib +import logging +from base64 import urlsafe_b64encode +from datetime import UTC, datetime, timedelta +from pathlib import Path +from uuid import UUID + +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +_logger = logging.getLogger(__name__) + +# JWT signing key (loaded on first use) +_private_key = None +_public_key = None +_kid: str | None = None + +# Key file location (same directory as database) +_KEY_FILE = Path("oidc_key.pem") + + +def _load_or_generate_key() -> None: + """Load existing RSA key or generate a new one.""" + global _private_key, _public_key, _kid + + if _KEY_FILE.exists(): + _logger.info("Loading OIDC signing key from %s", _KEY_FILE) + pem_data = _KEY_FILE.read_bytes() + _private_key = serialization.load_pem_private_key(pem_data, password=None) + else: + _logger.info("Generating new OIDC signing key") + _private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + pem_data = _private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + _KEY_FILE.write_bytes(pem_data) + _logger.info("Saved OIDC signing key to %s", _KEY_FILE) + + _public_key = _private_key.public_key() + # Generate kid from public key fingerprint + pub_der = _public_key.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + _kid = hashlib.sha256(pub_der).hexdigest()[:16] + + +def _ensure_key() -> None: + """Ensure key is loaded.""" + if _private_key is None: + _load_or_generate_key() + + +def _b64url_uint(value: int) -> str: + """Encode an integer as base64url without padding (for JWK).""" + # Calculate minimum bytes needed + byte_length = (value.bit_length() + 7) // 8 + value_bytes = value.to_bytes(byte_length, byteorder="big") + return urlsafe_b64encode(value_bytes).rstrip(b"=").decode("ascii") + + +def get_jwks() -> dict: + """Get JWKS (JSON Web Key Set) for public key verification.""" + _ensure_key() + assert _public_key is not None + pub_numbers = _public_key.public_numbers() + return { + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": _kid, + "n": _b64url_uint(pub_numbers.n), + "e": _b64url_uint(pub_numbers.e), + } + ] + } + + +def create_id_token( + issuer: str, + subject: UUID, + audience: str, # client_id + nonce: str | None = None, + name: str | None = None, + preferred_username: str | None = None, + email: str | None = None, + permissions: list[str] | None = None, + expires_in: int = 3600, +) -> str: + """Create a signed ID token (JWT). + + Args: + issuer: Token issuer (site URL) + subject: User UUID (sub claim) + audience: Client ID (aud claim) + nonce: Nonce from authorization request + name: User's display name + preferred_username: User's preferred username + email: User's email address + permissions: List of permission scopes + expires_in: Token lifetime in seconds + + Returns: + Signed JWT string + """ + _ensure_key() + now = datetime.now(UTC) + payload = { + "iss": issuer, + "sub": str(subject), + "aud": audience, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(seconds=expires_in)).timestamp()), + } + if nonce: + payload["nonce"] = nonce + if name: + payload["name"] = name + if preferred_username: + payload["preferred_username"] = preferred_username + if email: + payload["email"] = email + if permissions: + payload["permissions"] = permissions + + return jwt.encode(payload, _private_key, algorithm="RS256", headers={"kid": _kid}) + + +def create_access_token( + issuer: str, + subject: UUID, + audience: str, + scope: str, + expires_in: int = 3600, +) -> str: + """Create a signed access token (JWT) for userinfo endpoint. + + Args: + issuer: Token issuer (site URL) + subject: User UUID + audience: Client ID + scope: Granted scopes + expires_in: Token lifetime in seconds + + Returns: + Signed JWT string + """ + _ensure_key() + now = datetime.now(UTC) + payload = { + "iss": issuer, + "sub": str(subject), + "aud": audience, + "scope": scope, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(seconds=expires_in)).timestamp()), + } + return jwt.encode(payload, _private_key, algorithm="RS256", headers={"kid": _kid}) + + +def decode_access_token(token: str, issuer: str) -> dict | None: + """Decode and verify an access token. + + Args: + token: JWT string + issuer: Expected issuer + + Returns: + Decoded payload or None if invalid + """ + _ensure_key() + try: + return jwt.decode( + token, + _public_key, + algorithms=["RS256"], + issuer=issuer, + options={"verify_aud": False}, # We'll verify audience separately if needed + ) + except jwt.PyJWTError: + return None diff --git a/pyproject.toml b/pyproject.toml index 3bb2d61..4e7193a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "webauthn>=1.11.1", "base64url>=1.0.0", "uuid7-standard>=1.0.0", - "pyjwt>=2.8.0", + "pyjwt[crypto]>=2.8.0", "jsondiff>=2.2.1", "msgspec>=0.20.0", "aiofiles>=25.1.0", -- 2.55.0 From 701b0810bdc6344c8fd5cdb1db6a556d4c902986 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 15 Feb 2026 02:57:23 +0000 Subject: [PATCH 02/64] Stricter security. Moved to /auth/oidc/ --- oidc.md | 22 +++++++++++----------- paskia/fastapi/mainapp.py | 9 +++++---- paskia/fastapi/oid.py | 27 +++++++++++++++------------ paskia/fastapi/ws.py | 15 +++++++++++++++ paskia/util/oidjwt.py | 37 +++++++++++++++---------------------- 5 files changed, 61 insertions(+), 49 deletions(-) diff --git a/oidc.md b/oidc.md index ceb1a9e..acb25e6 100644 --- a/oidc.md +++ b/oidc.md @@ -48,9 +48,9 @@ oid_auth_codes: dict[str, OIDAuthCode] = {} - `GET /.well-known/openid-configuration` — Discovery document - `GET /.well-known/jwks.json` — Public keys for token verification -### OIDC routes (`/auth/oid/`) -- `POST /auth/oid/token` — Token endpoint (code exchange) -- `GET /auth/oid/userinfo` — UserInfo endpoint (bearer token) +### OIDC routes (`/auth/oidc/`) +- `POST /auth/oidc/token` — Token endpoint (code exchange) +- `GET /auth/oidc/userinfo` — UserInfo endpoint (bearer token) ### Authorization (via existing restricted app) - `GET /auth/restricted/?client_id=...&redirect_uri=...&response_type=code&scope=openid...` @@ -58,8 +58,8 @@ oid_auth_codes: dict[str, OIDAuthCode] = {} The restricted app detects OIDC params from URL and handles authentication via WebSocket. ## JWT & Signing -- RSA keypair generated on first boot (stored in data directory) -- ID tokens signed with RS256 +- Ed25519 keypair generated on first boot (stored in data directory) +- ID tokens signed with EdDSA - `kid` in JWKS for key rotation support - Access tokens are signed JWTs (not opaque) @@ -87,19 +87,19 @@ The `/auth/restricted/` page handles OIDC authorization alongside normal iframe 1. Client redirects to `/auth/restricted/?client_id=...&redirect_uri=...&response_type=code&scope=openid&state=...` 2. Frontend detects OIDC params from `window.location.search` -3. User authenticates via passkey -4. Frontend passes raw query string to `/auth/ws/authenticate?{query_string}` +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/oid/token` → receives `id_token` + `access_token` -9. Optionally calls `/auth/oid/userinfo` with bearer token +8. Client exchanges code at `/auth/oidc/token` → receives `id_token` + `access_token` +9. Optionally calls `/auth/oidc/userinfo` with bearer token **Key design points:** - No session created during OIDC auth (stateless for the OIDC client) - Raw query string preserved throughout (no parsing/reconstruction of redirect_uri) - Redirect URI validated against client's registered URIs via exact string match -- PKCE supported (S256 and plain methods) +- PKCE required (S256 only) ## WebSocket OIDC Mode @@ -124,7 +124,7 @@ When OIDC params absent: - `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/util/oidjwt.py` — RSA key management, JWT creation, JWKS +- `paskia/util/oidjwt.py` — Ed25519 key management, JWT creation, JWKS ### Modified - `paskia/fastapi/mainapp.py` — Mount OIDC app, well-known endpoints diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 0a9db95..a8f04aa 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -88,7 +88,7 @@ app.middleware("http")(auth_host.redirect_middleware) app.mount("/auth/api/admin/", admin.app) app.mount("/auth/api/", api.app) app.mount("/auth/ws/", ws.app) -app.mount("/auth/oid/", oid.app) +app.mount("/auth/oidc/", oid.app) # OIDC Well-Known endpoints (must be at site root) @@ -103,17 +103,18 @@ async def openid_configuration(request: Request): return { "issuer": issuer, "authorization_endpoint": f"{issuer}/auth/restricted/", - "token_endpoint": f"{issuer}/auth/oid/token", - "userinfo_endpoint": f"{issuer}/auth/oid/userinfo", + "token_endpoint": f"{issuer}/auth/oidc/token", + "userinfo_endpoint": f"{issuer}/auth/oidc/userinfo", "jwks_uri": f"{issuer}/.well-known/jwks.json", "response_types_supported": ["code"], "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256"], + "id_token_signing_alg_values_supported": ["EdDSA"], "scopes_supported": ["openid", "profile", "email"], "token_endpoint_auth_methods_supported": [ "client_secret_post", "client_secret_basic", ], + "code_challenge_methods_supported": ["S256"], "claims_supported": [ "sub", "name", diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py index fb1ea16..79cc6ea 100644 --- a/paskia/fastapi/oid.py +++ b/paskia/fastapi/oid.py @@ -33,16 +33,11 @@ def _get_issuer(request: Request) -> str: return f"{scheme}://{host}" -def _verify_pkce(code_verifier: str, code_challenge: str, method: str) -> bool: - """Verify PKCE code_verifier against stored code_challenge.""" - if method == "plain": - return code_verifier == code_challenge - elif method == "S256": - # SHA256 hash, base64url encode - digest = hashlib.sha256(code_verifier.encode("ascii")).digest() - computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") - return computed == code_challenge - return False +def _verify_pkce(code_verifier: str, code_challenge: str) -> bool: + """Verify PKCE code_verifier against stored code_challenge (S256 only).""" + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() + computed = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return computed == code_challenge def _parse_client_credentials( @@ -147,8 +142,16 @@ async def token( }, status_code=400, ) - method = auth_code.code_challenge_method or "plain" - if not _verify_pkce(code_verifier, auth_code.code_challenge, method): + method = auth_code.code_challenge_method or "S256" + if method != "S256": + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "Only S256 code_challenge_method is supported", + }, + status_code=400, + ) + if not _verify_pkce(code_verifier, auth_code.code_challenge): return JSONResponse( { "error": "invalid_grant", diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index ac5fa59..a074923 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -129,6 +129,21 @@ async def websocket_authenticate( await ws.send_json({"status": 400, "detail": "Scope must include openid"}) return + # PKCE is required with S256 + if not code_challenge: + await ws.send_json( + {"status": 400, "detail": "PKCE code_challenge is required"} + ) + return + if code_challenge_method and code_challenge_method != "S256": + await ws.send_json( + { + "status": 400, + "detail": "Only S256 code_challenge_method is supported", + } + ) + return + # If there's an existing session, restrict to that user's credentials (reauth) session_user_uuid = None if auth: diff --git a/paskia/util/oidjwt.py b/paskia/util/oidjwt.py index 4f79b4f..8e08e63 100644 --- a/paskia/util/oidjwt.py +++ b/paskia/util/oidjwt.py @@ -11,7 +11,7 @@ from uuid import UUID import jwt from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey _logger = logging.getLogger(__name__) @@ -25,7 +25,7 @@ _KEY_FILE = Path("oidc_key.pem") def _load_or_generate_key() -> None: - """Load existing RSA key or generate a new one.""" + """Load existing Ed25519 key or generate a new one.""" global _private_key, _public_key, _kid if _KEY_FILE.exists(): @@ -34,10 +34,7 @@ def _load_or_generate_key() -> None: _private_key = serialization.load_pem_private_key(pem_data, password=None) else: _logger.info("Generating new OIDC signing key") - _private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - ) + _private_key = Ed25519PrivateKey.generate() pem_data = _private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, @@ -61,28 +58,24 @@ def _ensure_key() -> None: _load_or_generate_key() -def _b64url_uint(value: int) -> str: - """Encode an integer as base64url without padding (for JWK).""" - # Calculate minimum bytes needed - byte_length = (value.bit_length() + 7) // 8 - value_bytes = value.to_bytes(byte_length, byteorder="big") - return urlsafe_b64encode(value_bytes).rstrip(b"=").decode("ascii") - - def get_jwks() -> dict: """Get JWKS (JSON Web Key Set) for public key verification.""" _ensure_key() assert _public_key is not None - pub_numbers = _public_key.public_numbers() + # Ed25519 public key is 32 bytes raw + pub_bytes = _public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) return { "keys": [ { - "kty": "RSA", + "kty": "OKP", + "crv": "Ed25519", "use": "sig", - "alg": "RS256", + "alg": "EdDSA", "kid": _kid, - "n": _b64url_uint(pub_numbers.n), - "e": _b64url_uint(pub_numbers.e), + "x": urlsafe_b64encode(pub_bytes).rstrip(b"=").decode("ascii"), } ] } @@ -135,7 +128,7 @@ def create_id_token( if permissions: payload["permissions"] = permissions - return jwt.encode(payload, _private_key, algorithm="RS256", headers={"kid": _kid}) + return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid}) def create_access_token( @@ -167,7 +160,7 @@ def create_access_token( "iat": int(now.timestamp()), "exp": int((now + timedelta(seconds=expires_in)).timestamp()), } - return jwt.encode(payload, _private_key, algorithm="RS256", headers={"kid": _kid}) + return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid}) def decode_access_token(token: str, issuer: str) -> dict | None: @@ -185,7 +178,7 @@ def decode_access_token(token: str, issuer: str) -> dict | None: return jwt.decode( token, _public_key, - algorithms=["RS256"], + algorithms=["EdDSA"], issuer=issuer, options={"verify_aud": False}, # We'll verify audience separately if needed ) -- 2.55.0 From 8132189a04cee389fb60e5fc3074da1d304a6ef7 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 15 Feb 2026 03:05:11 +0000 Subject: [PATCH 03/64] Rename /auth/restricted/ to auth/restricted/{iframe,oidc} for clarity and separation. --- e2e/tests/20-api-auth.spec.ts | 2 +- oidc.md | 6 +++--- paskia-js/README.md | 2 +- paskia/fastapi/api.py | 2 +- paskia/fastapi/authz.py | 2 +- paskia/fastapi/mainapp.py | 7 ++++--- paskia/fastapi/oid.py | 2 +- 7 files changed, 12 insertions(+), 11 deletions(-) diff --git a/e2e/tests/20-api-auth.spec.ts b/e2e/tests/20-api-auth.spec.ts index 6f268b7..ce3292b 100644 --- a/e2e/tests/20-api-auth.spec.ts +++ b/e2e/tests/20-api-auth.spec.ts @@ -534,7 +534,7 @@ test.describe('API Mode - Direct API Response Format', () => { expect(data.auth).toBeDefined() expect(data.auth.iframe).toBeDefined() expect(data.auth.mode).toBe('login') - expect(data.auth.iframe).toContain('/auth/restricted/') + expect(data.auth.iframe).toContain('/auth/restricted/iframe') console.log(`✓ 401 response includes auth.iframe: ${data.auth.iframe}`) }) diff --git a/oidc.md b/oidc.md index acb25e6..b237b5c 100644 --- a/oidc.md +++ b/oidc.md @@ -53,7 +53,7 @@ oid_auth_codes: dict[str, OIDAuthCode] = {} - `GET /auth/oidc/userinfo` — UserInfo endpoint (bearer token) ### Authorization (via existing restricted app) -- `GET /auth/restricted/?client_id=...&redirect_uri=...&response_type=code&scope=openid...` +- `GET /auth/restricted/oidc?client_id=...&redirect_uri=...&response_type=code&scope=openid...` The restricted app detects OIDC params from URL and handles authentication via WebSocket. @@ -83,9 +83,9 @@ The restricted app detects OIDC params from URL and handles authentication via W ## Authorization Flow -The `/auth/restricted/` page handles OIDC authorization alongside normal iframe auth: +The `/auth/restricted/oidc` page handles OIDC authorization (same code as `/auth/restricted/iframe` for API auth): -1. Client redirects to `/auth/restricted/?client_id=...&redirect_uri=...&response_type=code&scope=openid&state=...` +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 diff --git a/paskia-js/README.md b/paskia-js/README.md index 2f1441b..5c6b3e5 100644 --- a/paskia-js/README.md +++ b/paskia-js/README.md @@ -68,7 +68,7 @@ The JSON variants set headers automatically, with body and response in JSON. Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request. -The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need. +The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/iframe#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need. ```js import { showAuthIframe, AuthCancelledError } from 'paskia' diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index 76f9d36..5e7b86c 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -128,7 +128,7 @@ async def forward_authentication( - If Accept header contains "text/html": HTML page for authentication with data attributes for mode and other metadata. - Otherwise: JSON response with error details and an `iframe` field - pointing to /auth/restricted/?mode=... for iframe-based authentication. + pointing to /auth/restricted/iframe#mode=... for iframe-based authentication. """ try: ctx = await authz.verify( diff --git a/paskia/fastapi/authz.py b/paskia/fastapi/authz.py index 3b192fb..c82b776 100644 --- a/paskia/fastapi/authz.py +++ b/paskia/fastapi/authz.py @@ -41,7 +41,7 @@ async def auth_error_content(exc: AuthException) -> dict: # Build hash fragment from mode and metadata params = {"mode": exc.mode, **exc.metadata} fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None) - iframe_url = f"/auth/restricted/#{fragment}" + iframe_url = f"/auth/restricted/iframe#{fragment}" return { "detail": exc.detail, "auth": { diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index a8f04aa..1c82eea 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -102,7 +102,7 @@ async def openid_configuration(request: Request): return { "issuer": issuer, - "authorization_endpoint": f"{issuer}/auth/restricted/", + "authorization_endpoint": f"{issuer}/auth/restricted/oidc", "token_endpoint": f"{issuer}/auth/oidc/token", "userinfo_endpoint": f"{issuer}/auth/oidc/userinfo", "jwks_uri": f"{issuer}/.well-known/jwks.json", @@ -131,9 +131,10 @@ async def jwks(): return get_jwks() -@app.get("/auth/restricted/") +@app.get("/auth/restricted/iframe") +@app.get("/auth/restricted/oidc") async def restricted_view(): - """Serve the restricted/authentication UI for iframe embedding.""" + """Serve the restricted/authentication UI for iframe or OpenID Connect.""" return Response(*await vitedev.read("/auth/restricted/index.html")) diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py index 79cc6ea..ab3e54e 100644 --- a/paskia/fastapi/oid.py +++ b/paskia/fastapi/oid.py @@ -5,7 +5,7 @@ Implements OpenID Connect 1.0 Authorization Code flow: - POST /token - Token endpoint (code exchange) - GET /userinfo - UserInfo endpoint (bearer token) -Authorization is handled by /auth/restricted/ which passes OIDC params to +Authorization is handled by /auth/restricted/oidc which passes OIDC params to the /auth/ws/authenticate WebSocket. """ -- 2.55.0 From 18722f0e01e69990121344bd86e8cf92c1942e54 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 15 Feb 2026 03:47:56 +0000 Subject: [PATCH 04/64] 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: -- 2.55.0 From e59852b44c1cfb582234a74cb9b005ab971edbc6 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 15 Feb 2026 20:14:48 +0000 Subject: [PATCH 05/64] Session keys hardened (namespaced hashes of tokens). Various cleanup. --- oidc.md | 280 +++++++++---------------------------- paskia/authcode.py | 92 ++++++++++++ paskia/db/__init__.py | 2 - paskia/db/operations.py | 104 ++++---------- paskia/db/structs.py | 52 +++---- paskia/fastapi/admin.py | 14 +- paskia/fastapi/api.py | 41 +++++- paskia/fastapi/mainapp.py | 3 +- paskia/fastapi/oid.py | 102 +++++++++++--- paskia/fastapi/remote.py | 3 +- paskia/fastapi/user.py | 12 +- paskia/fastapi/ws.py | 70 +++++++--- paskia/fastapi/wschat.py | 10 +- paskia/globals.py | 8 +- paskia/oidauth.py | 152 -------------------- paskia/util/crypto.py | 11 ++ paskia/util/sessionutil.py | 2 +- paskia/util/userinfo.py | 2 +- tests/conftest.py | 62 ++++++-- tests/test_admin.py | 28 ++-- tests/test_api.py | 10 +- 21 files changed, 495 insertions(+), 565 deletions(-) create mode 100644 paskia/authcode.py delete mode 100644 paskia/oidauth.py create mode 100644 paskia/util/crypto.py diff --git a/oidc.md b/oidc.md index ea6e9a3..9cdcfc8 100644 --- a/oidc.md +++ b/oidc.md @@ -1,232 +1,86 @@ # OIDC Provider Implementation -## Overview -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. +OpenID Connect 1.0 provider enabling third-party apps to authenticate users via passkey. Also supports native cookie-based authentication. -## Database Changes +## Data Models -### User model additions (in `db/structs.py`) -- `email: str | None` — omit if None -- `preferred_username: str` — initially derived from full name, require unique and non-empty +**User** — Added: `email`, `preferred_username` + +**Session** — Added: `client_uuid` (None = native, set = OIDC) +- `key: bytes` — hashed DB key, never stored raw +- `secret` → `hash_secret("session", secret)` → DB lookup +- OIDC `sid` → `base64url.encode(hash_secret("oidc", session.key))` + +**OIDClient** — `uuid, client_secret_hash, name, redirect_uris, created_at` + +## Auth Codes (In-Memory Only) + +60-second lifetime, auto-cleaned: -### 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 +from paskia.authcode import AuthCode, OIDC, codes + +class AuthCode(msgspec.Struct): + session_key: str # Session DB key + created: datetime + oidc: OIDC | None # Only for OIDC mode + +class OIDC(msgspec.Struct): + redirect_uri, scope, nonce, code_challenge, code_challenge_method: str ``` -- 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) +Usage: `code = authcode.store(AuthCode(...))` → later `codes.pop(code, None)` -### New OIDC-specific models -```python -class OIDClient(msgspec.Struct): - uuid: UUID - client_secret_hash: bytes - name: str - redirect_uris: list[str] - created_at: datetime +## Authorization Flows - def verify_secret(self, secret: str) -> bool: ... -``` +### OIDC (Authorization Code) -### 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 - code_challenge_method: str | None - created_at: datetime - expires_at: datetime # 60 seconds -``` +1. `GET /auth/restricted/oidc?client_id=UUID&redirect_uri=...&scope=openid&nonce=...&code_challenge=...` +2. Frontend → WebSocket: `/auth/ws/authenticate?client_id=...&redirect_uri=...&...` +3. Validate client/redirect_uri, authenticate via passkey +4. `db.oidc_login()` → `(secret, session_key)` +5. Create `AuthCode(session_key, oidc=OIDC(...))` → code +6. Return: `{"redirect_url": "{redirect_uri}?code={code}&state={state}"}` +7. Client exchanges code at `/auth/oidc/token` with `code_verifier` (PKCE S256) -### DB additions -```python -oid_clients: dict[UUID, OIDClient] = {} -``` +**Token:** `access_token, id_token, refresh_token={secret}, expires_in=3600` + +**ID token:** `sub, sid (base64url), name, preferred_username, email, permissions` + +### Native (Cookie) + +1. WebSocket: `/auth/ws/authenticate` (no OIDC params) +2. Authenticate via passkey +3. `db.login()` → secret +4. Create `AuthCode(session_key=secret, oidc=None)` → exchange_code +5. Return: `{"user": "UUID", "exchange_code": "..."}` +6. `POST /auth/api/exchange` with code → sets cookie + +## Refresh & Logout + +**Refresh:** `POST /auth/oidc/token` with `grant_type=refresh_token&refresh_token={secret}&client_id=...&client_secret=...` +- Looks up session, validates client match +- Extends expiry +24h (sliding window) +- Returns new tokens with same `sid` + +**Back-channel logout:** `POST /auth/oidc/backchannel-logout` with `logout_token={jwt}` +- Verify signature, extract `sid` or `sub` +- Delete matched sessions +- Return 200 OK + +Discovery: `backchannel_logout_supported: true` ## Endpoints -### Well-known (root level) -- `GET /.well-known/openid-configuration` — Discovery document -- `GET /.well-known/jwks.json` — Public keys for token verification - -### OIDC routes (`/auth/oidc/`) -- `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...` - -The restricted app detects OIDC params from URL and handles authentication via WebSocket. - -## JWT & Signing -- Ed25519 keypair generated on first boot (stored in data directory) -- ID tokens signed with EdDSA -- `kid` in JWKS for key rotation support -- Access tokens are signed JWTs (not opaque) - -## Scopes & Claims - -**Standard OIDC scopes** — implicit for authenticated users: -- `openid` — required, enables ID token -- `profile` — includes `name`, `preferred_username` -- `email` — includes `email` (if set) - -**Paskia permissions as claims** — user's effective permission scopes included automatically: -```json -{ - "sub": "user-uuid", - "sid": "session-id", - "name": "Alice", - "preferred_username": "alice", - "email": "alice@example.com", - "permissions": ["admin", "reports:view"] -} -``` - -## Authorization Flow - -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 -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:** -- 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: -- `client_id` — OIDC client UUID -- `redirect_uri` — exact registered redirect URI -- `scope` — must include `openid` -- `state`, `nonce` — passed through -- `code_challenge`, `code_challenge_method` — for PKCE - -When OIDC params present: -- Validates client and redirect_uri before authentication -- 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 native session creation +- `GET /.well-known/openid-configuration` — Discovery +- `GET /.well-known/jwks.json` — Keys (EdDSA) +- `POST /auth/oidc/token` — Exchange/refresh +- `GET /auth/oidc/userinfo` — User (bearer token) +- `POST /auth/oidc/backchannel-logout` — Logout +- `POST /auth/api/exchange` — Native auth code → cookie ## Files -### Created -- `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 +**Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py) -### 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 -- `frontend/auth/restricted/RestrictedApi.vue` — Detect OIDC from URL, handle redirect -- `frontend/src/components/RestrictedAuth.vue` — Pass OIDC query string prop - -## TODO -- Master admin UI for client management -- User UI for editing preferred_username, email, profile picture -- Token revocation endpoint (optional) +**Modified:** [paskia/db/structs.py](paskia/db/structs.py), [paskia/db/operations.py](paskia/db/operations.py), [paskia/fastapi/ws.py](paskia/fastapi/ws.py), [paskia/fastapi/api.py](paskia/fastapi/api.py), [paskia/globals.py](paskia/globals.py), [paskia/fastapi/mainapp.py](paskia/fastapi/mainapp.py) diff --git a/paskia/authcode.py b/paskia/authcode.py new file mode 100644 index 0000000..c88ed6d --- /dev/null +++ b/paskia/authcode.py @@ -0,0 +1,92 @@ +""" +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. +""" + +from __future__ import annotations + +import asyncio +import logging +import secrets +from datetime import UTC, datetime, timedelta + +import msgspec + +_logger = logging.getLogger(__name__) + +# Auth codes expire after this duration +AUTH_CODE_LIFETIME = timedelta(seconds=60) + + +class AuthCode(msgspec.Struct): + """A pending authorization code for OIDC or native auth.""" + + session_key: str + created: datetime + oidc: OIDC | None = None # OIDC-specific fields (None for native auth) + + +class OIDC(msgspec.Struct): + """OIDC verification data carried authenticate->token that is not stored in Session.""" + + redirect_uri: str + scope: str + nonce: str + code_challenge: str + code_challenge_method: str + + +# Public interface - auth codes in-memory store +codes: dict[str, AuthCode] = {} + +# Background cleanup task +_cleanup_task: asyncio.Task | None = None + + +async def start(): + """Start the cleanup background task.""" + global _cleanup_task + if _cleanup_task is None: + _cleanup_task = asyncio.create_task(_cleanup_loop()) + + +async def stop(): + """Stop the cleanup background task.""" + global _cleanup_task + if _cleanup_task: + _cleanup_task.cancel() + try: + await _cleanup_task + except asyncio.CancelledError: + pass + _cleanup_task = None + + +async def _cleanup_loop(): + while True: + try: + await asyncio.sleep(30) # Check every 30 seconds + _cleanup_expired() + except asyncio.CancelledError: + break + except Exception: + _logger.exception("Error in auth code cleanup loop") + + +def _cleanup_expired(): + oldest = datetime.now(UTC) - AUTH_CODE_LIFETIME + for code, auth_code in list(codes.items()): + if auth_code.created < oldest: + del codes[code] + + +def store(auth_code: AuthCode) -> str: + """Store an authorization code and return the code string. + + Caller must construct AuthCode with their own timestamp. + """ + code = secrets.token_urlsafe(12) + codes[code] = auth_code + return code diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index d07fed8..44b34be 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -38,7 +38,6 @@ from paskia.db.operations import ( create_permission, create_reset_token, create_role, - create_session, create_user, delete_credential, delete_oid_client, @@ -124,7 +123,6 @@ __all__ = [ "create_permission", "create_reset_token", "create_role", - "create_session", "create_user", "delete_credential", "delete_org", diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 21d6918..db4edf0 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -7,6 +7,7 @@ Write operations: Functions that validate and commit, or raise ValueError. """ import logging +import secrets from datetime import UTC, datetime, timedelta from uuid import UUID @@ -29,6 +30,7 @@ from paskia.db.structs import ( SessionContext, User, ) +from paskia.util.crypto import hash_secret _logger = logging.getLogger(__name__) @@ -351,39 +353,8 @@ def delete_credential( cred.delete() -def create_session( - user_uuid: UUID, - credential_uuid: UUID, - host: str, - ip: str, - user_agent: str, - duration: timedelta = SESSION_LIFETIME, - *, - ctx: SessionContext | None = None, -) -> str: - """Create a new session. Returns the session key.""" - 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") - now = datetime.now(UTC) - session = Session.create( - user=user_uuid, - credential=credential_uuid, - host=host, - ip=ip, - user_agent=user_agent, - expiry=now + duration, - ) - if session.key in _db.sessions: - raise ValueError("Session already exists") - with _db.transaction("create_session", ctx): - session.store(now) - return session.key - - def update_session( - key: str, + key: bytes, host: str | None = None, ip: str | None = None, user_agent: str | None = None, @@ -406,13 +377,15 @@ def update_session( s.expiry = expiry -def set_session_host(key: str, host: str, *, ctx: SessionContext | None = None) -> None: +def set_session_host( + key: bytes, host: str, *, ctx: SessionContext | None = None +) -> None: """Set the host for a session (first-time binding).""" update_session(key, host=host, ctx=ctx) def delete_session( - key: str, *, ctx: SessionContext | None = None, action: str = "delete_session" + key: bytes, *, ctx: SessionContext | None = None, action: str = "delete_session" ) -> None: """Delete a session. @@ -514,9 +487,13 @@ def login( if credential_uuid not in _db.credentials: raise ValueError(f"Credential {credential_uuid} not found") + # Generate token and derive key + token = secrets.token_urlsafe(12) + session = Session.create( user=user_uuid, credential=credential_uuid, + key=hash_secret("cookie", token), host=host, ip=ip, user_agent=user_agent, @@ -528,57 +505,33 @@ def login( # Update credential _db.credentials[credential_uuid].sign_count = sign_count _db.credentials[credential_uuid].last_used = now - return session.key + return token def oidc_login( - user_uuid: UUID, + session: Session, credential_uuid: UUID, sign_count: int, - client_uuid: UUID, - host: str, - ip: str, - user_agent: str, -) -> str: - """Create an OIDC session after passkey authentication. +) -> None: + """Store an OIDC session and update credential in a single transaction. - 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. + The caller is responsible for generating the token, deriving the key, + and creating the Session object. This function only handles the + database transaction. Updates: + - user.last_seen, user.visits - credential.sign_count, credential.last_used - Creates: - - new OIDC session - - Returns the session key (sid for OIDC tokens). + Stores: + - the provided session """ - 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) + user_str = str(session.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( @@ -606,9 +559,14 @@ def create_credential_session( if user_uuid not in _db.users: raise ValueError(f"User {user_uuid} not found") + # Generate token and derive key + token = secrets.token_urlsafe(12) + key = hash_secret("cookie", token) + session = Session.create( user=user_uuid, credential=credential.uuid, + key=key, host=host, ip=ip, user_agent=user_agent, @@ -633,10 +591,10 @@ def create_credential_session( # Delete reset token if provided if reset_key: - token = _db.reset_tokens.get(reset_key) - if token: - token.delete() - return session.key + reset_token = _db.reset_tokens.get(reset_key) + if reset_token: + reset_token.delete() + return token # ------------------------------------------------------------------------- diff --git a/paskia/db/structs.py b/paskia/db/structs.py index 5ff01c2..c382f68 100644 --- a/paskia/db/structs.py +++ b/paskia/db/structs.py @@ -11,6 +11,7 @@ import uuid7 from paskia import db from paskia.util import hostutil from paskia.util import passphrase as passphrase_util +from paskia.util.crypto import hash_secret # Sentinel for uuid fields before they are set by create() or DB post init _UUID_UNSET = UUID(int=0) @@ -360,9 +361,12 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True): Mutable fields: expiry (updated on session refresh) Immutable fields: user_uuid, credential_uuid, host, ip, user_agent, client_uuid - key is stored in the dict key, not in the struct. + key is the hashed db_key, stored in the dict key, not in the struct. - If client_uuid is set, this is an OIDC session (key is the sid claim). + If client_uuid is set, this is an OIDC session. + + Security: The database stores only derived keys, never the raw secret. + A database leak does not expose working session credentials. """ user_uuid: UUID = msgspec.field(name="user") @@ -375,7 +379,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True): def __post_init__(self): if not hasattr(self, "key"): - self.key: str = "" + self.key: bytes = b"" @property def user(self) -> User: @@ -415,20 +419,27 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True): cls, user: UUID | User, credential: UUID | Credential, + key: bytes, host: str, 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 the provided key. - If client is provided, creates an OIDC session (key becomes sid claim). + Args: + key: The hashed session key (derived from secret via hash_secret) + + Returns: + Session object with key set """ + user_uuid = user if isinstance(user, UUID) else user.uuid credential_uuid = ( credential if isinstance(credential, UUID) else credential.uuid ) + session = cls( user_uuid=user_uuid, credential_uuid=credential_uuid, @@ -438,7 +449,7 @@ class Session(msgspec.Struct, dict=True, omit_defaults=True): expiry=expiry, client_uuid=client, ) - session.key = secrets.token_urlsafe(12) + session.key = key return session @@ -600,7 +611,7 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False): roles: dict[UUID, Role] = {} users: dict[UUID, User] = {} credentials: dict[UUID, Credential] = {} - sessions: dict[str, Session] = {} + sessions: dict[bytes, Session] = {} reset_tokens: dict[bytes, ResetToken] = {} # OIDC provider data oid_clients: dict[UUID, OIDClient] = {} @@ -632,19 +643,21 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False): return self._store.transaction(action, ctx, user=user) def session_ctx( - self, session_key: str, host: str | None = None + self, session_secret: str, host: str | None = None ) -> SessionContext | None: """Get full session context with effective permissions. Args: - session_key: The session key string + session_secret: The session secret (cookie value) - will be hashed for lookup host: Optional host for binding/validation and domain-scoped permissions Returns: SessionContext if valid, None if session not found, expired, or host mismatch """ + + key = hash_secret("cookie", session_secret) try: - s = self.sessions[session_key] + s = self.sessions[key] except KeyError: return None @@ -693,22 +706,3 @@ 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/admin.py b/paskia/fastapi/admin.py index 1a2c5b1..e7f7a2b 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -1,6 +1,7 @@ import logging from uuid import UUID +import base64url from fastapi import Body, FastAPI, HTTPException, Query, Request, Response from fastapi.responses import JSONResponse @@ -591,7 +592,7 @@ async def admin_get_user_detail( "sessions": [ ApiSession.from_db( s, - current_key=auth, + current_key=ctx.session.key, normalized_host=normalized_host, expires_delta=EXPIRES, ) @@ -708,14 +709,19 @@ async def admin_delete_user_session( status_code=403, detail="Insufficient permissions", mode="forbidden" ) - target_session = db.data().sessions.get(session_id) + try: + session_key = base64url.dec(session_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid session ID format") + + target_session = db.data().sessions.get(session_key) if not target_session or target_session.user_uuid != user_uuid: raise HTTPException(status_code=404, detail="Session not found") - db.delete_session(session_id, ctx=ctx, action="admin:delete_session") + db.delete_session(session_key, ctx=ctx, action="admin:delete_session") # Check if admin terminated their own session - current_terminated = session_id == auth + current_terminated = session_key == ctx.session.key return {"status": "ok", "current_session_terminated": current_terminated} diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index 5e7b86c..822a557 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -3,6 +3,7 @@ from contextlib import suppress from datetime import UTC, datetime, timedelta from fastapi import ( + Body, Depends, FastAPI, HTTPException, @@ -13,7 +14,7 @@ from fastapi import ( from fastapi.responses import JSONResponse from fastapi.security import HTTPBearer -from paskia import db +from paskia import authcode, db from paskia._version import __version__ from paskia.authsession import EXPIRES, expires, get_reset from paskia.fastapi import authz, session, user @@ -22,7 +23,7 @@ from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip from paskia.globals import passkey as global_passkey from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev -bearer_auth = HTTPBearer(auto_error=True) +bearer_auth = HTTPBearer(auto_error=False) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) @@ -67,6 +68,34 @@ async def general_exception_handler( return JSONResponse(status_code=500, content={"detail": "Internal server error"}) +@app.post("/exchange") +async def exchange_code( + request: Request, + response: Response, + code: str = Body(..., embed=True), +): + """Exchange a session code for setting the session cookie. + + Called by frontend after WebSocket authentication. + The code is ephemeral (60s TTL) and can only be used once. + """ + auth_code = authcode.codes.pop(code, None) + if not auth_code: + raise HTTPException(status_code=400, detail="Invalid or expired code") + + secret = auth_code.session_key + + # Verify the session exists + host = hostutil.normalize_host(request.headers.get("host", "")) + ctx = db.data().session_ctx(secret, host) + if not ctx: + raise HTTPException(status_code=400, detail="Session not found") + + # Set the session cookie + session.set_session_cookie(response, secret) + return {"status": "ok", "user": str(ctx.user.uuid)} + + @app.post("/validate") async def validate_token( request: Request, @@ -91,7 +120,7 @@ async def validate_token( consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC)) if not timedelta(0) < consumed < _REFRESH_INTERVAL: db.update_session( - auth, + ctx.session.key, ip=get_client_ip(request), user_agent=request.headers.get("user-agent") or "", expiry=expires(), @@ -230,6 +259,8 @@ async def api_user_info( @app.get("/token-info") async def token_info(credentials=Depends(bearer_auth)): """Get reset/device-add token info. Pass token via Bearer header.""" + if not credentials or not credentials.credentials: + raise HTTPException(401, "Bearer token required") token = credentials.credentials if not passphrase.is_well_formed(token): raise HTTPException(400, "Invalid token format") @@ -254,7 +285,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE): if not ctx: return {"message": "Already logged out"} with suppress(Exception): - db.delete_session(auth, ctx=ctx, action="logout") + db.delete_session(ctx.session.key, ctx=ctx, action="logout") session.clear_session_cookie(response) return {"message": "Logged out successfully"} @@ -263,6 +294,8 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE): async def api_set_session( request: Request, response: Response, auth=Depends(bearer_auth) ): + if not auth or not auth.credentials: + raise HTTPException(401, "Bearer token required") ctx = db.data().session_ctx(auth.credentials, request.headers.get("host")) if not ctx: raise HTTPException(401, "Session expired") diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 4b4071b..4a10f7f 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -8,7 +8,7 @@ from fastapi import FastAPI, HTTPException, Request, Response from fastapi.responses import FileResponse, RedirectResponse from fastapi_vue import Frontend -from paskia import globals +from paskia import authcode, globals from paskia.db import start_background, stop_background from paskia.db.logging import configure_db_logging from paskia.fastapi import admin, api, auth_host, oid, ws @@ -68,6 +68,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path await start_background() yield await stop_background() + await authcode.stop() app = FastAPI( diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py index 936d0a1..7ae0289 100644 --- a/paskia/fastapi/oid.py +++ b/paskia/fastapi/oid.py @@ -15,19 +15,47 @@ import logging from datetime import UTC, datetime from uuid import UUID +import base64url from fastapi import Body, Depends, FastAPI, HTTPException, Request from fastapi.responses import JSONResponse from fastapi.security import HTTPBearer -from paskia import db, oidauth +from paskia import authcode, db from paskia.config import SESSION_LIFETIME +from paskia.db.structs import Session from paskia.util import oidjwt +from paskia.util.crypto import hash_secret _logger = logging.getLogger(__name__) app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) +def _oidc_session_by_token( + token: str, client_uuid: UUID | None = None +) -> Session | None: + """Look up an OIDC session by token (refresh token value).""" + key = hash_secret("oidc", token) + s = db.data().sessions.get(key) + 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 + + +def _oidc_session_by_sid(sid: bytes, client_uuid: UUID | None = None) -> Session | None: + """Look up an OIDC session by sid (for backchannel logout).""" + for s in db.data().sessions.values(): + if s.client_uuid is None: + continue + if client_uuid is not None and s.client_uuid != client_uuid: + continue + if hash_secret("oidc", s.key) == sid: + return s + return None + + def _get_issuer(request: Request) -> str: """Build issuer URL from request.""" scheme = request.headers.get("x-forwarded-proto", request.url.scheme) @@ -137,26 +165,33 @@ async def _handle_authorization_code( ) # Consume auth code (atomic delete + return) - auth_code = oidauth.instance.consume(code) + auth_code = authcode.codes.pop(code, None) if not auth_code: return JSONResponse( {"error": "invalid_grant", "error_description": "Code expired or invalid"}, status_code=400, ) - # Verify client matches - if auth_code.client_uuid != client.uuid: - return JSONResponse({"error": "invalid_grant"}, status_code=400) + # Look up the OIDC session by token + session = _oidc_session_by_token(auth_code.session_key, client.uuid) + if not session: + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "Session not found or not OIDC", + }, + status_code=400, + ) - # Verify redirect_uri matches - if redirect_uri and redirect_uri != auth_code.redirect_uri: + # Verify redirect_uri matches (OIDC only) + if auth_code.oidc and redirect_uri and redirect_uri != auth_code.oidc.redirect_uri: return JSONResponse( {"error": "invalid_grant", "error_description": "redirect_uri mismatch"}, status_code=400, ) - # Verify PKCE if code_challenge was provided - if auth_code.code_challenge: + # Verify PKCE (OIDC only) + if auth_code.oidc: if not code_verifier: return JSONResponse( { @@ -165,7 +200,7 @@ async def _handle_authorization_code( }, status_code=400, ) - method = auth_code.code_challenge_method or "S256" + method = auth_code.oidc.code_challenge_method if method != "S256": return JSONResponse( { @@ -174,7 +209,7 @@ async def _handle_authorization_code( }, status_code=400, ) - if not _verify_pkce(code_verifier, auth_code.code_challenge): + if not _verify_pkce(code_verifier, auth_code.oidc.code_challenge): return JSONResponse( { "error": "invalid_grant", @@ -183,16 +218,25 @@ async def _handle_authorization_code( status_code=400, ) - # Get user - user = db.data().users.get(auth_code.user_uuid) + # Get user from session + user = db.data().users.get(session.user_uuid) if not user: return JSONResponse( {"error": "invalid_grant", "error_description": "User not found"}, status_code=400, ) + # Derive sid from session key + sid = base64url.enc(hash_secret("oidc", session.key)) + return _build_token_response( - request, user, client_id, auth_code.sid, auth_code.nonce, auth_code.scope + request, + user, + client_id, + auth_code.session_key, + sid, + auth_code.oidc.nonce if auth_code.oidc else None, + auth_code.oidc.scope if auth_code.oidc else None, ) @@ -204,7 +248,7 @@ async def _handle_refresh_token( ): """Handle grant_type=refresh_token. - The refresh_token is the OIDC session sid. On refresh: + The refresh_token is the session secret. On refresh: - Validates session exists and belongs to client - Extends session expiry (24h sliding window) - Records current IP and user_agent @@ -216,8 +260,8 @@ async def _handle_refresh_token( status_code=400, ) - # Look up session by sid - session = db.data().oidc_session_by_sid(refresh_token_value, client.uuid) + # Look up session by refresh token + session = _oidc_session_by_token(refresh_token_value, client.uuid) if not session: return JSONResponse( { @@ -258,8 +302,17 @@ async def _handle_refresh_token( _logger.info("OIDC session refreshed: %s", session.key) + # Base64url encode session's derived sid for JWT claim + sid_str = base64url.enc(hash_secret("oidc", session.key)) + return _build_token_response( - request, user, client_id, session.key, nonce=None, scope="openid" + request, + user, + client_id, + refresh_token_value, + sid_str, + nonce=None, + scope="openid", ) @@ -267,6 +320,7 @@ def _build_token_response( request: Request, user, client_id: str, + secret: str, sid: str, nonce: str | None, scope: str, @@ -312,7 +366,7 @@ def _build_token_response( "access_token": access_token, "token_type": "Bearer", "expires_in": 3600, - "refresh_token": sid, + "refresh_token": secret, "id_token": id_token, } ) @@ -436,8 +490,16 @@ async def backchannel_logout( # Delete session(s) deleted = 0 if sid: + # Decode sid from base64url to bytes + try: + sid_bytes = base64url.dec(sid) + except Exception: + return JSONResponse( + {"error": "invalid_request", "error_description": "Invalid sid format"}, + status_code=400, + ) # Delete specific session by sid - session = db.data().oidc_session_by_sid(sid, client_uuid) + session = _oidc_session_by_sid(sid_bytes, client_uuid) if session: db.delete_session(session.key) deleted = 1 diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py index f8ee4fa..f6ab618 100644 --- a/paskia/fastapi/remote.py +++ b/paskia/fastapi/remote.py @@ -310,9 +310,8 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE): # Handle authenticate request (no PoW needed - already validated during lookup) if msg.get("authenticate") and request is not None: - ctx = await authenticate_and_login(ws, auth) + ctx, session_token = await authenticate_and_login(ws, auth) - session_token = ctx.session.key reset_token = None if request.action == "register": diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index 3b93505..67d9e9e 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -1,6 +1,7 @@ from datetime import UTC from uuid import UUID +import base64url from fastapi import ( Body, FastAPI, @@ -112,12 +113,17 @@ async def api_delete_session( status_code=401, detail="Session expired", mode="login" ) - target_session = db.data().sessions.get(session_id) + try: + session_key = base64url.dec(session_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid session ID format") + + target_session = db.data().sessions.get(session_key) if not target_session or target_session.user_uuid != ctx.user.uuid: raise HTTPException(status_code=404, detail="Session not found") - db.delete_session(session_id, ctx=ctx) - current_terminated = session_id == auth + db.delete_session(session_key, ctx=ctx) + current_terminated = session_key == ctx.session.key if current_terminated: session.clear_session_cookie(response) # explicit because 200 return {"status": "ok", "current_session_terminated": current_terminated} diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 65a09ce..18da687 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -1,10 +1,15 @@ +import secrets +from datetime import UTC, datetime from urllib.parse import urlencode from uuid import UUID from fastapi import FastAPI, WebSocket -from paskia import db, oidauth +from paskia import authcode, db +from paskia.authcode import OIDC, AuthCode from paskia.authsession import get_reset +from paskia.config import SESSION_LIFETIME +from paskia.db.structs import Session from paskia.fastapi import authz, remote from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.wschat import ( @@ -15,6 +20,7 @@ from paskia.fastapi.wschat import ( from paskia.fastapi.wsutil import validate_origin, websocket_error_handler from paskia.globals import passkey from paskia.util import hostutil, passphrase +from paskia.util.crypto import hash_secret # Create a FastAPI subapp for WebSocket endpoints app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) @@ -143,6 +149,11 @@ async def websocket_authenticate( ) return + # Nonce is required for OIDC + if not nonce: + await ws.send_json({"status": 400, "detail": "nonce is required for OIDC"}) + return + # If there's an existing session, restrict to that user's credentials (reauth) session_user_uuid = None if auth: @@ -160,31 +171,42 @@ async def websocket_authenticate( 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, + # Use same timestamp for session and auth code + now = datetime.now(UTC) + + # Generate token and create OIDC session + token = secrets.token_urlsafe(12) + session = Session.create( + user=cred.user_uuid, + credential=cred.uuid, + key=hash_secret("oidc", token), host=normalized_host, ip=metadata["ip"], user_agent=metadata["user_agent"], + expiry=now + SESSION_LIFETIME, + client=oidc_client.uuid, + ) + db.oidc_login( + session=session, + credential_uuid=cred.uuid, + sign_count=new_sign_count, ) - # 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, + auth_code = AuthCode( + session_key=token, + created=now, + oidc=OIDC( + redirect_uri=redirect_uri, + scope=scope, + nonce=nonce, + code_challenge=code_challenge, + code_challenge_method=code_challenge_method or "S256", + ), ) + code = authcode.store(auth_code) # Build redirect URL - params = {"code": auth_code.code} + params = {"code": code} if state: params["state"] = state redirect_url = f"{redirect_uri}?{urlencode(params)}" @@ -192,15 +214,23 @@ async def websocket_authenticate( await ws.send_json({"redirect_url": redirect_url}) else: # Normal mode: authenticate and create session - ctx = await authenticate_and_login(ws, auth) + ctx, secret = await authenticate_and_login(ws, auth) # If reauth mode, verify the credential belongs to the session's user if session_user_uuid and ctx.user.uuid != session_user_uuid: raise ValueError("This passkey belongs to a different account") + # Create exchange code (ephemeral, 60s TTL) + now = datetime.now(UTC) + auth_code = AuthCode( + session_key=secret, + created=now, + ) + exchange_code = authcode.store(auth_code) + await ws.send_json( { "user": str(ctx.user.uuid), - "session_token": ctx.session.key, + "exchange_code": exchange_code, } ) diff --git a/paskia/fastapi/wschat.py b/paskia/fastapi/wschat.py index 9fd0cb9..9388990 100644 --- a/paskia/fastapi/wschat.py +++ b/paskia/fastapi/wschat.py @@ -68,13 +68,13 @@ async def authenticate_chat( async def authenticate_and_login( ws: WebSocket, auth: str | None = None, -) -> SessionContext: +) -> tuple[SessionContext, str]: """Run WebAuthn authentication flow, create session, and return the session context. If auth is provided, restrict authentication to credentials of that session's user. Returns: - SessionContext for the authenticated session + Tuple of (SessionContext for the authenticated session, session secret) """ origin = validate_origin(ws) host = origin.split("://", 1)[1] @@ -97,7 +97,7 @@ async def authenticate_and_login( cred, new_sign_count = await authenticate_chat(ws, credential_ids) # Create session and update user/credential - token = db.login( + secret = db.login( user_uuid=cred.user_uuid, credential_uuid=cred.uuid, sign_count=new_sign_count, @@ -107,7 +107,7 @@ async def authenticate_and_login( ) # Fetch and return the full session context - ctx = db.data().session_ctx(token, normalized_host) + ctx = db.data().session_ctx(secret, normalized_host) if not ctx: raise ValueError("Failed to create session context") - return ctx + return ctx, secret diff --git a/paskia/globals.py b/paskia/globals.py index f0d7b18..d4ce0cb 100644 --- a/paskia/globals.py +++ b/paskia/globals.py @@ -1,6 +1,6 @@ from typing import Generic, TypeVar -from paskia import db, remoteauth +from paskia import authcode, db, remoteauth from paskia.bootstrap import bootstrap_if_needed from paskia.sansio import Passkey @@ -58,10 +58,8 @@ async def init( # Initialize remote auth manager await remoteauth.init() - # Initialize OIDC auth code manager - from paskia import oidauth - - await oidauth.init() + # Initialize auth code manager + await authcode.start() if bootstrap: # Bootstrap system if needed diff --git a/paskia/oidauth.py b/paskia/oidauth.py deleted file mode 100644 index 3517ce1..0000000 --- a/paskia/oidauth.py +++ /dev/null @@ -1,152 +0,0 @@ -""" -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/crypto.py b/paskia/util/crypto.py new file mode 100644 index 0000000..d920532 --- /dev/null +++ b/paskia/util/crypto.py @@ -0,0 +1,11 @@ +import hashlib + + +def hash_secret(*data) -> bytes: + """A custom HMAC that securily combines and hashes the given data (context, secrets). The first argument should be a namespacing string.""" + inner = bytearray(len(data).to_bytes(8, "big")) + for d in data: + if isinstance(d, str): + d = d.encode() + inner += hashlib.sha256(d).digest() + return hashlib.sha256(inner).digest()[:12] diff --git a/paskia/util/sessionutil.py b/paskia/util/sessionutil.py index afeda44..6ccd386 100644 --- a/paskia/util/sessionutil.py +++ b/paskia/util/sessionutil.py @@ -1,4 +1,4 @@ -"""Utility functions for session validation and checking.""" +"""Utility functions for session validation, derivation, and checking.""" from datetime import UTC, datetime diff --git a/paskia/util/userinfo.py b/paskia/util/userinfo.py index 39fe2db..9434747 100644 --- a/paskia/util/userinfo.py +++ b/paskia/util/userinfo.py @@ -54,7 +54,7 @@ async def build_user_info( "sessions": [ ApiSession.from_db( s, - current_key=auth, + current_key=ctx.session.key, normalized_host=normalized_host, expires_delta=EXPIRES, ) diff --git a/tests/conftest.py b/tests/conftest.py index 2d881a5..9cbdd73 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,10 +9,14 @@ Since we can't emulate WebAuthn passkeys, we create sessions directly in the database to test authenticated endpoints. """ +from __future__ import annotations + import asyncio import os +import secrets import tempfile from collections.abc import AsyncGenerator +from datetime import UTC, datetime, timedelta from uuid import UUID import httpx @@ -22,6 +26,7 @@ import pytest_asyncio import paskia.db.operations as ops_db from paskia import globals as paskia_globals from paskia.authsession import reset_expires +from paskia.config import SESSION_LIFETIME from paskia.db import ( Config, Credential, @@ -33,14 +38,15 @@ from paskia.db import ( create_credential, create_reset_token, create_role, - create_session, create_user, ) from paskia.db.jsonl import JsonlStore from paskia.db.operations import DB +from paskia.db.structs import Session from paskia.fastapi.mainapp import app from paskia.fastapi.session import AUTH_COOKIE_NAME from paskia.sansio import Passkey +from paskia.util.crypto import hash_secret @pytest.fixture(scope="session") @@ -60,7 +66,6 @@ async def test_db() -> AsyncGenerator[DB, None]: - A default organization with Administration role - An admin user with the Administration role """ - with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f: db = DB(config=Config(rp_id="test.example.com")) store = JsonlStore(db, f.name) @@ -179,13 +184,11 @@ async def session_token( test_db: DB, test_user: User, test_credential: Credential ) -> str: """Create a session for the admin user and return the token.""" - return create_session( + _db_key, secret = create_test_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, - host="localhost", - ip="127.0.0.1", - user_agent="pytest", ) + return secret @pytest_asyncio.fixture(scope="function") @@ -193,13 +196,11 @@ async def regular_session_token( test_db: DB, regular_user: User, regular_credential: Credential ) -> str: """Create a session for a regular user and return the token.""" - return create_session( + _db_key, secret = create_test_session( user_uuid=regular_user.uuid, credential_uuid=regular_credential.uuid, - host="localhost", - ip="127.0.0.1", - user_agent="pytest", ) + return secret @pytest_asyncio.fixture(scope="function") @@ -241,3 +242,44 @@ def auth_cookie(token: str) -> httpx.Cookies: cookies = httpx.Cookies() cookies.set(AUTH_COOKIE_NAME, token, domain="localhost") return cookies + + +def create_test_session( + user_uuid: UUID, + credential_uuid: UUID, + host: str = "localhost", + ip: str = "127.0.0.1", + user_agent: str = "pytest", + duration: timedelta | None = None, +) -> tuple[bytes, str]: + """Create a test session. Returns (key, token) tuple. + + - key: bytes used for session lookup (base64url encode for URLs) + - token: stored in cookie/sent to client + """ + if duration is None: + duration = SESSION_LIFETIME + if user_uuid not in ops_db._db.users: + raise ValueError(f"User {user_uuid} not found") + if credential_uuid not in ops_db._db.credentials: + raise ValueError(f"Credential {credential_uuid} not found") + now = datetime.now(UTC) + + # Generate token and derive key + token = secrets.token_urlsafe(12) + key = hash_secret("cookie", token) + + session = Session.create( + user=user_uuid, + credential=credential_uuid, + key=key, + host=host, + ip=ip, + user_agent=user_agent, + expiry=now + duration, + ) + if session.key in ops_db._db.sessions: + raise ValueError("Session already exists") + with ops_db._db.transaction("create_test_session"): + session.store(now) + return session.key, token diff --git a/tests/test_admin.py b/tests/test_admin.py index b9143fc..4b673dd 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -16,6 +16,7 @@ import secrets from datetime import UTC, datetime from uuid import UUID +import base64url import httpx import pytest import pytest_asyncio @@ -33,11 +34,11 @@ from paskia.db import ( create_org, create_permission, create_role, - create_session, create_user, ) from paskia.db.operations import DB -from tests.conftest import auth_headers +from paskia.util.crypto import hash_secret +from tests.conftest import auth_headers, create_test_session # -------------------- Additional Fixtures -------------------- @@ -97,13 +98,11 @@ async def second_org_session_token( test_db: DB, second_org_user: User, second_org_credential: Credential ) -> str: """Create a session for the second org admin user.""" - return create_session( + _db_key, secret = create_test_session( user_uuid=second_org_user.uuid, credential_uuid=second_org_credential.uuid, - host="localhost", - ip="127.0.0.1", - user_agent="pytest", ) + return secret @pytest_asyncio.fixture(scope="function") @@ -153,13 +152,11 @@ async def org_admin_session_token( test_db: DB, org_admin_user: User, org_admin_credential: Credential ) -> str: """Create a session for the org admin user.""" - return create_session( + _db_key, secret = create_test_session( user_uuid=org_admin_user.uuid, credential_uuid=org_admin_credential.uuid, - host="localhost", - ip="127.0.0.1", - user_agent="pytest", ) + return secret @pytest_asyncio.fixture(scope="function") @@ -1290,7 +1287,7 @@ class TestAdminSessions: ): """Admin should be able to delete a user's session.""" # Create an additional session to delete - extra_token = create_session( + extra_db_key, _extra_secret = create_test_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, host="other.host:4401", @@ -1299,7 +1296,7 @@ class TestAdminSessions: ) response = await client.delete( - f"/auth/api/admin/users/{test_user.uuid}/sessions/{extra_token}", + f"/auth/api/admin/users/{test_user.uuid}/sessions/{base64url.enc(extra_db_key)}", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 @@ -1316,8 +1313,9 @@ class TestAdminSessions: test_user, ): """Admin can delete their own current session.""" + session_db_key = base64url.enc(hash_secret("cookie", session_token)) response = await client.delete( - f"/auth/api/admin/users/{test_user.uuid}/sessions/{session_token}", + f"/auth/api/admin/users/{test_user.uuid}/sessions/{session_db_key}", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert response.status_code == 200 @@ -1362,9 +1360,9 @@ class TestAdminSessions: f"/auth/api/admin/users/{test_user.uuid}/sessions/invalid!!id", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) - assert response.status_code == 404 + assert response.status_code == 400 data = response.json() - assert "Session not found" in data["detail"] + assert "Invalid session ID format" in data["detail"] @pytest.mark.asyncio async def test_delete_session_not_found( diff --git a/tests/test_api.py b/tests/test_api.py index 579c07e..68574d3 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -17,9 +17,9 @@ import httpx import pytest from paskia.authsession import EXPIRES -from paskia.db import create_session, delete_session +from paskia.db import delete_session from paskia.util.passphrase import generate -from tests.conftest import auth_headers +from tests.conftest import auth_headers, create_test_session class TestSettingsEndpoint: @@ -522,7 +522,7 @@ class TestValidateSessionRefresh: """Validate should return 401 if session disappears during refresh.""" # Create a session with a short remaining duration to trigger refresh - token = create_session( + db_key, secret = create_test_session( user_uuid=test_user.uuid, credential_uuid=test_credential.uuid, host="localhost", @@ -532,11 +532,11 @@ class TestValidateSessionRefresh: ) # Delete the session right before validate tries to refresh - delete_session(token) + delete_session(db_key) response = await client.post( "/auth/api/validate", - headers={**auth_headers(token), "Host": "localhost:4401"}, + headers={**auth_headers(secret), "Host": "localhost:4401"}, ) # Session was found initially but disappeared during refresh assert response.status_code == 401 -- 2.55.0 From feeea30cb6e50b05fe7451da27944ae5a8606b75 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 15 Feb 2026 20:49:13 +0000 Subject: [PATCH 06/64] Hardening OIDC verification. --- paskia/fastapi/oid.py | 59 ++++++++++++++++++++++++++++++++++++------- paskia/fastapi/ws.py | 21 +++++++++++++++ paskia/util/oidjwt.py | 11 ++++++-- 3 files changed, 80 insertions(+), 11 deletions(-) diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py index 7ae0289..08bf3fe 100644 --- a/paskia/fastapi/oid.py +++ b/paskia/fastapi/oid.py @@ -237,6 +237,7 @@ async def _handle_authorization_code( sid, auth_code.oidc.nonce if auth_code.oidc else None, auth_code.oidc.scope if auth_code.oidc else None, + credential_uuid=session.credential_uuid, ) @@ -313,6 +314,7 @@ async def _handle_refresh_token( sid_str, nonce=None, scope="openid", + credential_uuid=session.credential_uuid, ) @@ -324,6 +326,7 @@ def _build_token_response( sid: str, nonce: str | None, scope: str, + credential_uuid: UUID | None = None, ): """Build the token response with access_token, id_token, and refresh_token.""" issuer = _get_issuer(request) @@ -340,6 +343,16 @@ def _build_token_response( if p: permissions.append(p.scope) + # Get credential's last_used as auth_time + auth_time = None + if credential_uuid: + try: + credential = db.data().credentials[credential_uuid] + if credential.last_used: + auth_time = credential.last_used + except KeyError: + pass + # Create ID token id_token = oidjwt.create_id_token( issuer=issuer, @@ -351,6 +364,7 @@ def _build_token_response( preferred_username=user.preferred_username, email=user.email, permissions=permissions if permissions else None, + auth_time=auth_time, ) # Create access token @@ -393,6 +407,19 @@ async def userinfo( if not payload: raise HTTPException(401, "Invalid or expired token") + # Verify audience is a valid client + aud = payload.get("aud") + if not aud: + raise HTTPException(401, "Invalid token (missing aud claim)") + + try: + client_uuid = UUID(aud) + except ValueError: + raise HTTPException(401, "Invalid token (invalid aud format)") + + if not db.data().oid_clients.get(client_uuid): + raise HTTPException(401, "Invalid token (unknown client)") + # Get user try: user_uuid = UUID(payload["sub"]) @@ -469,6 +496,29 @@ async def backchannel_logout( sid = payload.get("sid") sub = payload.get("sub") + # Verify audience is a valid client (if present) + aud = payload.get("aud") + client_uuid = None + if aud: + try: + client_uuid = UUID(aud) + if not db.data().oid_clients.get(client_uuid): + return JSONResponse( + { + "error": "invalid_request", + "error_description": "Unknown client in logout_token", + }, + status_code=400, + ) + except ValueError: + return JSONResponse( + { + "error": "invalid_request", + "error_description": "Invalid client format in logout_token", + }, + status_code=400, + ) + if not sid and not sub: return JSONResponse( { @@ -478,15 +528,6 @@ async def backchannel_logout( 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: diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 18da687..3e62e10 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -154,6 +154,27 @@ async def websocket_authenticate( await ws.send_json({"status": 400, "detail": "nonce is required for OIDC"}) return + # Validate state parameter if provided (defensive against injection) + if state: + # State should be short-lived and contain only safe characters + # Per OAuth 2.0 spec: unreserved characters - alphanumerics and -._~ + if len(state) > 500: + await ws.send_json( + { + "status": 400, + "detail": "state parameter is too long (max 500 chars)", + } + ) + return + if not all(c.isalnum() or c in "-._~" for c in state): + await ws.send_json( + { + "status": 400, + "detail": "state parameter contains invalid characters", + } + ) + return + # If there's an existing session, restrict to that user's credentials (reauth) session_user_uuid = None if auth: diff --git a/paskia/util/oidjwt.py b/paskia/util/oidjwt.py index f016e22..b89228b 100644 --- a/paskia/util/oidjwt.py +++ b/paskia/util/oidjwt.py @@ -91,6 +91,7 @@ def create_id_token( preferred_username: str | None = None, email: str | None = None, permissions: list[str] | None = None, + auth_time: datetime | None = None, expires_in: int = 3600, ) -> str: """Create a signed ID token (JWT). @@ -105,6 +106,7 @@ def create_id_token( preferred_username: User's preferred username email: User's email address permissions: List of permission scopes + auth_time: When the user authenticated (last credential use time) expires_in: Token lifetime in seconds Returns: @@ -131,6 +133,8 @@ def create_id_token( payload["email"] = email if permissions: payload["permissions"] = permissions + if auth_time: + payload["auth_time"] = int(auth_time.timestamp()) return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid}) @@ -167,12 +171,15 @@ def create_access_token( return jwt.encode(payload, _private_key, algorithm="EdDSA", headers={"kid": _kid}) -def decode_access_token(token: str, issuer: str) -> dict | None: +def decode_access_token( + token: str, issuer: str, audience: str | None = None +) -> dict | None: """Decode and verify an access token. Args: token: JWT string issuer: Expected issuer + audience: Optional expected audience (client_id). If provided, aud claim must match. Returns: Decoded payload or None if invalid @@ -184,7 +191,7 @@ def decode_access_token(token: str, issuer: str) -> dict | None: _public_key, algorithms=["EdDSA"], issuer=issuer, - options={"verify_aud": False}, # We'll verify audience separately if needed + audience=audience, # PyJWT handles None by skipping verification ) except jwt.PyJWTError: return None -- 2.55.0 From d653a1db355f7259f80c47b6ae6affc739091987 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 15 Feb 2026 21:10:10 +0000 Subject: [PATCH 07/64] Database migration to add OIDC and convert to hardened sessions. --- paskia/db/migrations.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/paskia/db/migrations.py b/paskia/db/migrations.py index 570e380..ddba29b 100644 --- a/paskia/db/migrations.py +++ b/paskia/db/migrations.py @@ -5,8 +5,11 @@ Migrations are applied during database load based on the version field. Each migration should be idempotent and only run when needed. """ +import base64 from collections.abc import Awaitable, Callable +from paskia.util.crypto import hash_secret + def migrate_v1(d: dict, **kwargs) -> None: """Remove Org.created_at fields.""" @@ -20,6 +23,15 @@ def migrate_v2(d: dict, *, rp_id: str = "localhost") -> None: d["config"] = {"rp_id": rp_id} +def migrate_v3(d: dict, **kwargs) -> None: + """OpenID Connect support and hardened session keys.""" + d["oid_clients"] = {} + d["sessions"] = { + base64.standard_b64encode(hash_secret("cookie", k)).decode(): v + for k, v in d["sessions"].items() + } + + migrations = sorted( [f for n, f in globals().items() if n.startswith("migrate_v")], key=lambda f: int(f.__name__.removeprefix("migrate_v")), -- 2.55.0 From ebf5f6db2c09423469c78cd68a977361212d35bd Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 16 Feb 2026 14:23:07 +0000 Subject: [PATCH 08/64] Route our new restricted endpoints correctly on vite dev. --- frontend/vite.config.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 4966aca..16e0919 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -46,6 +46,18 @@ export default defineConfig(({ command }) => ({ }) } }, + { + name: 'restricted-endpoints-rewrite', + configureServer(server) { + server.middlewares.use((req, _res, next) => { + // Rewrite /auth/restricted/iframe and /auth/restricted/oidc to /auth/restricted/ + if (req.url === '/auth/restricted/iframe' || req.url === '/auth/restricted/oidc') { + req.url = '/auth/restricted/' + } + next() + }) + } + }, { name: 'serve-examples', configureServer(server) { -- 2.55.0 From eece6d4a219331bfe7f194f0fd064ef0c4afd7c0 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 16 Feb 2026 14:26:20 +0000 Subject: [PATCH 09/64] Remove some confusion between exchange and set-session endpoints, all using set-session now with a bearer code. Using codes in remote auth as well. Full separation of cookie and OIDC codes. --- frontend/auth/restricted/RestrictedApi.vue | 2 +- frontend/int/reset/ResetApp.vue | 12 +- frontend/src/components/RemoteAuthRequest.vue | 2 +- frontend/src/components/RestrictedAuth.vue | 14 +- frontend/src/stores/auth.js | 14 +- paskia/authcode.py | 67 ++++++---- paskia/fastapi/admin.py | 120 ++++++++++++++++++ paskia/fastapi/api.py | 61 ++++----- paskia/fastapi/oid.py | 60 ++++----- paskia/fastapi/remote.py | 18 ++- paskia/fastapi/ws.py | 21 ++- 11 files changed, 257 insertions(+), 134 deletions(-) diff --git a/frontend/auth/restricted/RestrictedApi.vue b/frontend/auth/restricted/RestrictedApi.vue index 5eae0a4..a9d8fb7 100644 --- a/frontend/auth/restricted/RestrictedApi.vue +++ b/frontend/auth/restricted/RestrictedApi.vue @@ -53,7 +53,7 @@ function handleAuthenticated(result) { postToParent({ type: 'auth-success', authenticated: true, - sessionToken: result.session_token + exchangeCode: result.exchange_code }) } diff --git a/frontend/int/reset/ResetApp.vue b/frontend/int/reset/ResetApp.vue index b647ceb..745fd66 100644 --- a/frontend/int/reset/ResetApp.vue +++ b/frontend/int/reset/ResetApp.vue @@ -144,7 +144,7 @@ async function registerPasskey() { } try { - await setSessionCookie(result) + await exchangeCode(result) } catch (error) { loading.value = false const message = error?.message || 'Failed to establish session' @@ -156,15 +156,13 @@ async function registerPasskey() { setTimeout(() => { loading.value = false; goHome() }, 800) } -async function setSessionCookie(result) { - if (!result?.session_token) { - throw new Error('Registration response missing session_token') +async function exchangeCode(result) { + if (!result?.exchange_code) { + throw new Error('Registration response missing exchange_code') } return await apiJson('/auth/api/set-session', { method: 'POST', - headers: { - Authorization: `Bearer ${result.session_token}` - } + headers: { 'Authorization': `Bearer ${result.exchange_code}` } }) } diff --git a/frontend/src/components/RemoteAuthRequest.vue b/frontend/src/components/RemoteAuthRequest.vue index cca3bd6..d96d6f9 100644 --- a/frontend/src/components/RemoteAuthRequest.vue +++ b/frontend/src/components/RemoteAuthRequest.vue @@ -196,7 +196,7 @@ async function startRemoteAuth() { } else if (msg.status === 'authenticated') { // Success completed.value = true - emit('authenticated', { session_token: msg.session_token }) + emit('authenticated', { exchange_code: msg.exchange_code }) break } else if (msg.status === 'denied') { // Explicitly denied by the authenticating device diff --git a/frontend/src/components/RestrictedAuth.vue b/frontend/src/components/RestrictedAuth.vue index ed554ff..b5a2ca3 100644 --- a/frontend/src/components/RestrictedAuth.vue +++ b/frontend/src/components/RestrictedAuth.vue @@ -181,7 +181,7 @@ async function authenticateUser() { emit('authenticated', result) return } - try { await setSessionCookie(result) } catch (error) { + try { await exchangeCode(result) } catch (error) { loading.value = false const message = error?.message || 'Failed to establish session' showMessage(message, 'error', 4000) @@ -212,13 +212,13 @@ function openProfile() { if (profileWindow) profileWindow.focus() } -async function setSessionCookie(result) { - if (!result?.session_token) { - console.error('setSessionCookie called with missing session_token:', result) - throw new Error('Authentication response missing session_token') +async function exchangeCode(result) { + if (!result?.exchange_code) { + console.error('exchangeCode called with missing exchange_code:', result) + throw new Error('Authentication response missing exchange_code') } return await fetchJson('/auth/api/set-session', { - method: 'POST', headers: { Authorization: `Bearer ${result.session_token}` } + method: 'POST', headers: { 'Authorization': `Bearer ${result.exchange_code}` } }) } @@ -233,7 +233,7 @@ function switchToLocal() { async function handleRemoteAuthenticated(result) { showMessage('Authenticated from another device!', 'success', 2000) try { - await setSessionCookie(result) + await exchangeCode(result) } catch (error) { const message = error?.message || 'Failed to establish session' showMessage(message, 'error', 4000) diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 26ebeb3..2eb58dc 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -41,21 +41,21 @@ export const useAuthStore = defineStore('auth', { }, effectiveDuration) } }, - async setSessionCookie(result) { - if (!result?.session_token) { - console.error('setSessionCookie called with missing session_token:', result) - throw new Error('Authentication response missing session_token') + async exchangeCode(result) { + if (!result?.exchange_code) { + console.error('exchangeCode called with missing exchange_code:', result) + throw new Error('Authentication response missing exchange_code') } return await apiJson('/auth/api/set-session', { method: 'POST', - headers: {'Authorization': `Bearer ${result.session_token}`}, + headers: { 'Authorization': `Bearer ${result.exchange_code}` }, }) }, async register() { this.isLoading = true try { const result = await register() - await this.setSessionCookie(result) + await this.exchangeCode(result) await this.loadUserInfo() this.selectView() return result @@ -68,7 +68,7 @@ export const useAuthStore = defineStore('auth', { try { const result = await authenticate() - await this.setSessionCookie(result) + await this.exchangeCode(result) await this.loadUserInfo() this.selectView() diff --git a/paskia/authcode.py b/paskia/authcode.py index c88ed6d..a239aae 100644 --- a/paskia/authcode.py +++ b/paskia/authcode.py @@ -1,8 +1,8 @@ """ -OIDC authorization code management. +Authorization code management for OIDC and cookie exchange flows. -Authorization codes are short-lived (60 seconds) and stored in-memory only. -Similar to remote auth, these are not persisted to the database. +Codes are short-lived (60 seconds) and stored in-memory only. +Two separate stores maintain full isolation between OIDC and cookie flows. """ from __future__ import annotations @@ -20,26 +20,30 @@ _logger = logging.getLogger(__name__) AUTH_CODE_LIFETIME = timedelta(seconds=60) -class AuthCode(msgspec.Struct): - """A pending authorization code for OIDC or native auth.""" +class OIDCCode(msgspec.Struct): + """An OIDC authorization code pending token exchange. + + PKCE uses S256 only (verified at auth time). + """ session_key: str created: datetime - oidc: OIDC | None = None # OIDC-specific fields (None for native auth) - - -class OIDC(msgspec.Struct): - """OIDC verification data carried authenticate->token that is not stored in Session.""" - redirect_uri: str scope: str nonce: str code_challenge: str - code_challenge_method: str -# Public interface - auth codes in-memory store -codes: dict[str, AuthCode] = {} +class CookieCode(msgspec.Struct): + """A cookie exchange code for setting session cookie after WebSocket auth.""" + + session_key: str + created: datetime + + +# Separate stores for each code type +oidc_codes: dict[str, OIDCCode] = {} +cookie_codes: dict[str, CookieCode] = {} # Background cleanup task _cleanup_task: asyncio.Task | None = None @@ -77,16 +81,33 @@ async def _cleanup_loop(): def _cleanup_expired(): oldest = datetime.now(UTC) - AUTH_CODE_LIFETIME - for code, auth_code in list(codes.items()): + for code, auth_code in list(oidc_codes.items()): if auth_code.created < oldest: - del codes[code] + del oidc_codes[code] + for code, auth_code in list(cookie_codes.items()): + if auth_code.created < oldest: + del cookie_codes[code] -def store(auth_code: AuthCode) -> str: - """Store an authorization code and return the code string. +def store_oidc(code: OIDCCode) -> str: + """Store an OIDC authorization code and return the code string.""" + token = secrets.token_urlsafe(12) + oidc_codes[token] = code + return token - Caller must construct AuthCode with their own timestamp. - """ - code = secrets.token_urlsafe(12) - codes[code] = auth_code - return code + +def consume_oidc(token: str) -> OIDCCode | None: + """Consume an OIDC code, returning it if valid. Atomic removal.""" + return oidc_codes.pop(token, None) + + +def store_cookie(code: CookieCode) -> str: + """Store a cookie exchange code and return the code string.""" + token = secrets.token_urlsafe(12) + cookie_codes[token] = code + return token + + +def consume_cookie(token: str) -> CookieCode | None: + """Consume a cookie exchange code, returning it if valid. Atomic removal.""" + return cookie_codes.pop(token, None) diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index e7f7a2b..93b2d56 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -1,4 +1,5 @@ import logging +import secrets from uuid import UUID import base64url @@ -12,6 +13,7 @@ from paskia.db import Org as OrgDC from paskia.db import Permission as PermDC from paskia.db import Role as RoleDC from paskia.db import User as UserDC +from paskia.db.structs import OIDClient from paskia.fastapi import authz from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.session import AUTH_COOKIE @@ -919,3 +921,121 @@ async def admin_delete_permission( db.delete_permission(permission_uuid, ctx=ctx) return {"status": "ok"} + + +# -------------------- OIDC Clients -------------------- + + +@app.get("/oidc-clients") +async def admin_list_oidc_clients(request: Request, auth=AUTH_COOKIE): + """List all OIDC clients (master admin only).""" + ctx = await authz.verify( + auth, + ["auth:admin"], + host=request.headers.get("host"), + match=permutil.has_all, + ) + if not master_admin(ctx): + raise authz.AuthException( + status_code=403, + detail="Only master admin can manage OIDC clients", + mode="forbidden", + ) + + clients = db.data().oid_clients.values() + return MsgspecResponse( + [ + { + "uuid": str(client.uuid), + "name": client.name, + "redirect_uris": client.redirect_uris, + "created_at": format_datetime(client.created_at), + } + for client in clients + ] + ) + + +@app.post("/oidc-clients") +async def admin_create_oidc_client( + request: Request, + payload: dict = Body(...), + auth=AUTH_COOKIE, +): + """Create a new OIDC client (master admin only).""" + ctx = await authz.verify( + auth, + ["auth:admin"], + host=request.headers.get("host"), + match=permutil.has_all, + max_age="5m", + ) + if not master_admin(ctx): + raise authz.AuthException( + status_code=403, + detail="Only master admin can manage OIDC clients", + mode="forbidden", + ) + + name = payload.get("name", "").strip() + redirect_uris = payload.get("redirect_uris", []) + + if not name: + raise ValueError("Client name is required") + if not redirect_uris: + raise ValueError("At least one redirect URI is required") + if not isinstance(redirect_uris, list): + raise ValueError("redirect_uris must be a list") + + # Validate redirect URIs + for uri in redirect_uris: + if not isinstance(uri, str) or not uri.startswith("http"): + raise ValueError(f"Invalid redirect URI: {uri}") + + # Generate a secure client secret + client_secret = secrets.token_urlsafe(32) + + # Create the client + client, _ = OIDClient.create( + name=name, + redirect_uris=redirect_uris, + client_secret=client_secret, + ) + + db.create_oid_client(client, ctx=ctx) + + return { + "status": "ok", + "client_id": str(client.uuid), + "client_secret": client_secret, + "message": "Save the client_secret now - it cannot be retrieved later", + } + + +@app.delete("/oidc-clients/{client_uuid}") +async def admin_delete_oidc_client( + client_uuid: UUID, + request: Request, + auth=AUTH_COOKIE, +): + """Delete an OIDC client (master admin only).""" + ctx = await authz.verify( + auth, + ["auth:admin"], + host=request.headers.get("host"), + match=permutil.has_all, + max_age="5m", + ) + if not master_admin(ctx): + raise authz.AuthException( + status_code=403, + detail="Only master admin can manage OIDC clients", + mode="forbidden", + ) + + try: + db.delete_oid_client(client_uuid, ctx=ctx) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + return {"status": "ok"} diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index 822a557..7e73337 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -3,7 +3,6 @@ from contextlib import suppress from datetime import UTC, datetime, timedelta from fastapi import ( - Body, Depends, FastAPI, HTTPException, @@ -68,34 +67,6 @@ async def general_exception_handler( return JSONResponse(status_code=500, content={"detail": "Internal server error"}) -@app.post("/exchange") -async def exchange_code( - request: Request, - response: Response, - code: str = Body(..., embed=True), -): - """Exchange a session code for setting the session cookie. - - Called by frontend after WebSocket authentication. - The code is ephemeral (60s TTL) and can only be used once. - """ - auth_code = authcode.codes.pop(code, None) - if not auth_code: - raise HTTPException(status_code=400, detail="Invalid or expired code") - - secret = auth_code.session_key - - # Verify the session exists - host = hostutil.normalize_host(request.headers.get("host", "")) - ctx = db.data().session_ctx(secret, host) - if not ctx: - raise HTTPException(status_code=400, detail="Session not found") - - # Set the session cookie - session.set_session_cookie(response, secret) - return {"status": "ok", "user": str(ctx.user.uuid)} - - @app.post("/validate") async def validate_token( request: Request, @@ -294,13 +265,29 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE): async def api_set_session( request: Request, response: Response, auth=Depends(bearer_auth) ): + """Exchange an auth code for setting the session cookie. + + Called by frontend after WebSocket authentication. + The code is ephemeral (60s TTL) and can only be used once. + """ if not auth or not auth.credentials: - raise HTTPException(401, "Bearer token required") - ctx = db.data().session_ctx(auth.credentials, request.headers.get("host")) + raise HTTPException(400, "Bearer token required") + + # Verify host is provided + host = hostutil.normalize_host(request.headers.get("host", "")) + if not host: + raise HTTPException(400, "Host header required") + + a = authcode.consume_cookie(auth.credentials) + if not a: + raise HTTPException(401, "Code expired or already used") + + secret = a.session_key + + # Verify the session exists + ctx = db.data().session_ctx(secret, host) if not ctx: - raise HTTPException(401, "Session expired") - session.set_session_cookie(response, auth.credentials) - return { - "message": "Session cookie set successfully", - "user": str(ctx.user.uuid), - } + raise HTTPException(401, f"Session not found on {host}") + + session.set_session_cookie(response, secret) + return {"status": "ok", "user": str(ctx.user.uuid)} diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py index 08bf3fe..cc8ef28 100644 --- a/paskia/fastapi/oid.py +++ b/paskia/fastapi/oid.py @@ -165,15 +165,15 @@ async def _handle_authorization_code( ) # Consume auth code (atomic delete + return) - auth_code = authcode.codes.pop(code, None) - if not auth_code: + oidc_code = authcode.consume_oidc(code) + if not oidc_code: return JSONResponse( {"error": "invalid_grant", "error_description": "Code expired or invalid"}, status_code=400, ) # Look up the OIDC session by token - session = _oidc_session_by_token(auth_code.session_key, client.uuid) + session = _oidc_session_by_token(oidc_code.session_key, client.uuid) if not session: return JSONResponse( { @@ -183,40 +183,30 @@ async def _handle_authorization_code( status_code=400, ) - # Verify redirect_uri matches (OIDC only) - if auth_code.oidc and redirect_uri and redirect_uri != auth_code.oidc.redirect_uri: + # Verify redirect_uri matches + if redirect_uri and redirect_uri != oidc_code.redirect_uri: return JSONResponse( {"error": "invalid_grant", "error_description": "redirect_uri mismatch"}, status_code=400, ) - # Verify PKCE (OIDC only) - if auth_code.oidc: - if not code_verifier: - return JSONResponse( - { - "error": "invalid_grant", - "error_description": "Missing code_verifier", - }, - status_code=400, - ) - method = auth_code.oidc.code_challenge_method - if method != "S256": - return JSONResponse( - { - "error": "invalid_grant", - "error_description": "Only S256 code_challenge_method is supported", - }, - status_code=400, - ) - if not _verify_pkce(code_verifier, auth_code.oidc.code_challenge): - return JSONResponse( - { - "error": "invalid_grant", - "error_description": "Invalid code_verifier", - }, - status_code=400, - ) + # Verify PKCE (S256 only, enforced at auth time) + if not code_verifier: + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "Missing code_verifier", + }, + status_code=400, + ) + if not _verify_pkce(code_verifier, oidc_code.code_challenge): + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "Invalid code_verifier", + }, + status_code=400, + ) # Get user from session user = db.data().users.get(session.user_uuid) @@ -233,10 +223,10 @@ async def _handle_authorization_code( request, user, client_id, - auth_code.session_key, + oidc_code.session_key, sid, - auth_code.oidc.nonce if auth_code.oidc else None, - auth_code.oidc.scope if auth_code.oidc else None, + oidc_code.nonce, + oidc_code.scope, credential_uuid=session.credential_uuid, ) diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py index f6ab618..022e289 100644 --- a/paskia/fastapi/remote.py +++ b/paskia/fastapi/remote.py @@ -10,12 +10,14 @@ Endpoints: """ import asyncio +from datetime import UTC, datetime from uuid import UUID import base64url from fastapi import FastAPI, WebSocket, WebSocketDisconnect -from paskia import db, remoteauth +from paskia import authcode, db, remoteauth +from paskia.authcode import CookieCode from paskia.authsession import expires from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.wschat import authenticate_and_login @@ -183,7 +185,7 @@ async def websocket_remote_auth_request(ws: WebSocket): "user": str(result_data["user_uuid"]), } if result_data.get("session_token"): - response["session_token"] = result_data["session_token"] + response["exchange_code"] = result_data["session_token"] if result_data.get("reset_token"): response["reset_token"] = result_data["reset_token"] await ws.send_json(response) @@ -310,7 +312,7 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE): # Handle authenticate request (no PoW needed - already validated during lookup) if msg.get("authenticate") and request is not None: - ctx, session_token = await authenticate_and_login(ws, auth) + ctx, secret = await authenticate_and_login(ws, auth) reset_token = None @@ -324,11 +326,19 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE): user=str(ctx.user.uuid), ) + # Create exchange code for the session (don't expose raw secret) + exchange_code = authcode.store_cookie( + CookieCode( + session_key=secret, + created=datetime.now(UTC), + ) + ) + # Complete the remote auth request (notifies the waiting device) cred = db.data().credentials[ctx.session.credential_uuid] completed = await remoteauth.instance.complete_request( token=request.key, - session_token=session_token, + session_token=exchange_code, user_uuid=ctx.user.uuid, credential_uuid=cred.uuid, reset_token=reset_token, diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 3e62e10..e607fbc 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -6,7 +6,7 @@ from uuid import UUID from fastapi import FastAPI, WebSocket from paskia import authcode, db -from paskia.authcode import OIDC, AuthCode +from paskia.authcode import CookieCode, OIDCCode from paskia.authsession import get_reset from paskia.config import SESSION_LIFETIME from paskia.db.structs import Session @@ -213,18 +213,15 @@ async def websocket_authenticate( sign_count=new_sign_count, ) # Create auth code (in-memory only) - auth_code = AuthCode( + oidc_code = OIDCCode( session_key=token, created=now, - oidc=OIDC( - redirect_uri=redirect_uri, - scope=scope, - nonce=nonce, - code_challenge=code_challenge, - code_challenge_method=code_challenge_method or "S256", - ), + redirect_uri=redirect_uri, + scope=scope, + nonce=nonce, + code_challenge=code_challenge, ) - code = authcode.store(auth_code) + code = authcode.store_oidc(oidc_code) # Build redirect URL params = {"code": code} @@ -243,11 +240,11 @@ async def websocket_authenticate( # Create exchange code (ephemeral, 60s TTL) now = datetime.now(UTC) - auth_code = AuthCode( + cookie_code = CookieCode( session_key=secret, created=now, ) - exchange_code = authcode.store(auth_code) + exchange_code = authcode.store_cookie(cookie_code) await ws.send_json( { -- 2.55.0 From b73b2d6fe9d99330c08668e8b4e550d96dd7df93 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 16 Feb 2026 16:54:53 +0000 Subject: [PATCH 10/64] Renamed OIDC permissions claim to more commonly used groups. Move jwtk to a more convenient location. Draft admin app OIDC client configuratioon. --- frontend/auth/admin/AdminApp.vue | 107 +++++++++++++++++++++++++++ frontend/src/admin/AdminDialogs.vue | 38 +++++++++- frontend/src/admin/AdminOverview.vue | 53 ++++++++++++- oidc.md | 6 +- paskia/db/__init__.py | 1 + paskia/db/operations.py | 36 +++++++++ paskia/db/structs.py | 2 - paskia/fastapi/admin.py | 49 +++++++++++- paskia/fastapi/mainapp.py | 9 +-- paskia/fastapi/oid.py | 8 +- paskia/util/oidjwt.py | 8 +- 11 files changed, 295 insertions(+), 22 deletions(-) diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index 069ac18..ea53db5 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -24,6 +24,7 @@ const showBackMessage = ref(false) const error = ref(null) const orgs = ref([]) const permissions = ref([]) +const oidcClients = ref([]) const currentOrgId = ref(null) // UUID of selected org for detail view const currentUserId = ref(null) // UUID for user detail view const userDetail = ref(null) // cached user detail object @@ -143,6 +144,24 @@ async function loadPermissions() { permissions.value = await apiJson('/auth/api/admin/permissions') } +async function loadOidcClients() { + // Only master admins can view OIDC clients + if (!isMasterAdmin.value) { + oidcClients.value = [] + return + } + try { + oidcClients.value = await apiJson('/auth/api/admin/oidc-clients') + } catch (e) { + // If 403, user is not master admin - silently skip + if (e.message?.includes('403') || e.message?.includes('Forbidden')) { + oidcClients.value = [] + } else { + throw e + } + } +} + async function loadUserInfo() { const data = await apiJson('/auth/api/validate', { method: 'POST' }) info.value = data @@ -153,6 +172,7 @@ function clearSensitiveState() { info.value = null orgs.value = [] permissions.value = [] + oidcClients.value = [] userDetail.value = null authenticated.value = false } @@ -181,6 +201,8 @@ async function load() { await Promise.all([loadOrgs(), loadPermissions()]) // If we get here, user has admin access - now fetch user info for display await loadUserInfo() + // Load OIDC clients after authentication (master admin only) + await loadOidcClients() if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) { if (!window.location.hash || window.location.hash === '#overview') { @@ -383,6 +405,34 @@ function deletePermission(p) { } }) } +// OIDC Client actions +function createOidcClient() { + openDialog('oidc-create', { name: '', redirect_uris: '' }) +} + +function editOidcClient(client) { + openDialog('oidc-edit', { + client, + name: client.name, + redirect_uris: client.redirect_uris.join('\n') + }) +} + +function deleteOidcClient(client) { + openDialog('confirm', { + message: `Delete OIDC client "${client.name}"? This will break any applications using this client.`, + action: async () => { + await performOidcClientDeletion(client.uuid, client.name) + } + }) +} + +async function performOidcClientDeletion(clientUuid, clientName) { + await apiJson(`/auth/api/admin/oidc-clients/${clientUuid}`, { method: 'DELETE' }) + authStore.showMessage(`OIDC client "${clientName}" deleted.`, 'success', 2500) + await loadOidcClients() +} + const selectedOrg = computed(() => orgs.value.find(o => o.uuid === currentOrgId.value) || null) function openOrg(o) { @@ -721,6 +771,59 @@ async function submitDialog() { authStore.showMessage(e.message || 'Failed to create permission', 'error') }) return // Don't call closeDialog() again + } else if (t === 'oidc-create') { + const name = dialog.value.data.name?.trim() + const uris = dialog.value.data.redirect_uris?.trim() + if (!name) throw new Error('Client name required') + if (!uris) throw new Error('Redirect URIs required') + + const redirect_uris = uris.split('\n').map(u => u.trim()).filter(u => u) + if (redirect_uris.length === 0) throw new Error('At least one redirect URI required') + + // Close dialog immediately, then perform async operation + closeDialog() + apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { name, redirect_uris } }) + .then((result) => { + // Show success dialog with client credentials + openDialog('oidc-created', { + client_id: result.client_id, + client_secret: result.client_secret + }) + loadOidcClients() + }) + .catch(e => { + authStore.showMessage(e.message || 'Failed to create OIDC client', 'error') + }) + return // Don't call closeDialog() again + } else if (t === 'oidc-edit') { + const { client } = dialog.value.data + const name = dialog.value.data.name?.trim() + const uris = dialog.value.data.redirect_uris?.trim() + if (!name) throw new Error('Client name required') + if (!uris) throw new Error('Redirect URIs required') + + const redirect_uris = uris.split('\n').map(u => u.trim()).filter(u => u) + if (redirect_uris.length === 0) throw new Error('At least one redirect URI required') + + // Close dialog immediately, then perform async operation + closeDialog() + + // Check if anything changed + const oldUris = [...client.redirect_uris].sort().join('\n') + const newUris = [...redirect_uris].sort().join('\n') + if (name === client.name && oldUris === newUris) { + return // No changes + } + + apiJson(`/auth/api/admin/oidc-clients/${client.uuid}`, { method: 'PATCH', body: { name, redirect_uris } }) + .then(() => { + authStore.showMessage(`OIDC client "${name}" updated.`, 'success', 2500) + loadOidcClients() + }) + .catch(e => { + authStore.showMessage(e.message || 'Failed to update OIDC client', 'error') + }) + return // Don't call closeDialog() again } else if (t === 'confirm') { const action = dialog.value.data.action // Close dialog first, then perform action (errors shown via showMessage) @@ -773,6 +876,7 @@ async function submitDialog() { :info="info" :orgs="orgs" :permissions="permissions" + :oidc-clients="oidcClients" :navigation-disabled="hasActiveModal" :permission-summary="permissionSummary" @create-org="createOrg" @@ -783,6 +887,9 @@ async function submitDialog() { @open-dialog="openDialog" @delete-permission="deletePermission" @rename-permission-display="renamePermissionDisplay" + @create-oidc-client="createOidcClient" + @edit-oidc-client="editOidcClient" + @delete-oidc-client="deleteOidcClient" @navigate-out="handlePanelNavigateOut" /> diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index 92faaf9..438ebc9 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -12,6 +12,7 @@ const props = defineProps({ const emit = defineEmits(['submitDialog', 'closeDialog']) const NAME_EDIT_TYPES = new Set(['org-update', 'role-update', 'user-update-name']) +const NO_SUBMIT_TYPES = new Set(['oidc-created']) const rpId = computed(() => props.settings?.rp_id || 'the configured domain') @@ -25,6 +26,9 @@ const rpId = computed(() => props.settings?.rp_id || 'the configured domain') + + +