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