Session keys hardened (namespaced hashes of tokens). Various cleanup.

This commit is contained in:
Leo Vasanko
2026-02-15 20:14:48 +00:00
parent 18722f0e01
commit e59852b44c
21 changed files with 495 additions and 565 deletions
+67 -213
View File
@@ -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)
+92
View File
@@ -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
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+2 -1
View File
@@ -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
View File
@@ -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
+1 -2
View File
@@ -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":
+9 -3
View File
@@ -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
View File
@@ -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,
}
)
+5 -5
View File
@@ -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
View File
@@ -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
-152
View File
@@ -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
+11
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
"""Utility functions for session validation and checking."""
"""Utility functions for session validation, derivation, and checking."""
from datetime import UTC, datetime
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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