From eece6d4a219331bfe7f194f0fd064ef0c4afd7c0 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 16 Feb 2026 14:26:20 +0000 Subject: [PATCH] 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( {