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)
|
||||
|
||||
Reference in New Issue
Block a user