Session keys hardened (namespaced hashes of tokens). Various cleanup.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
+31
-73
@@ -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
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
+23
-29
@@ -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
|
||||
|
||||
+10
-4
@@ -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}
|
||||
|
||||
|
||||
|
||||
+37
-4
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
+82
-20
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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}
|
||||
|
||||
+50
-20
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Utility functions for session validation and checking."""
|
||||
"""Utility functions for session validation, derivation, and checking."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
+52
-10
@@ -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
|
||||
|
||||
+13
-15
@@ -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(
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user