README quick start and configuration updated for init/serve split and paskia.kantadb; API.md server-config endpoints replaced with admin realm CRUD and the well-known webauthn endpoint; proxy guides point at the realm auth-host setting; oidc.md documents per-realm issuers; MultiSite.md rewritten from the implementation plan into documentation of the shipped mechanics, policy model, and design rationale.
97 lines
4.1 KiB
Markdown
97 lines
4.1 KiB
Markdown
# OIDC Provider Implementation
|
|
|
|
OpenID Connect 1.0 provider enabling third-party apps to authenticate users via passkey. Also supports native cookie-based authentication.
|
|
|
|
## Realms (multi rp-id)
|
|
|
|
Each realm (rp-id) is an independent OIDC issuer with its own signing key and clients: `DB.oidc` is `dict[rp_id, OIDC]` and per-realm key files are `oidc.<rp-id>.key`. Discovery, keys, token and userinfo endpoints resolve the issuer from the request host (realm dispatch). `Session.issuer` records the realm that issued an OIDC session so refresh and back-channel logout select the right key, and auth codes (`OIDCCode`, `CookieCode`) are stamped with the realm's rp-id and verified against it at redemption.
|
|
|
|
## Data Models
|
|
|
|
**User** — Added: `email`, `preferred_username`
|
|
|
|
**Session** — Added: `client_uuid` (None = native, set = OIDC), `rp_id`/`issuer` (realm that authenticated / issued the session)
|
|
- `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`
|
|
|
|
## Auth Codes (In-Memory Only)
|
|
|
|
60-second lifetime, auto-cleaned:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
Usage: `code = authcode.store(AuthCode(...))` → later `codes.pop(code, None)`
|
|
|
|
## Authorization Flows
|
|
|
|
### OIDC (Authorization Code)
|
|
|
|
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)
|
|
|
|
**Token:** `access_token, id_token, refresh_token={secret}, expires_in=3600`
|
|
|
|
**ID token:** `sub, sid (base64url), name, preferred_username, email, groups`
|
|
|
|
### 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
|
|
|
|
- `GET /.well-known/openid-configuration` — Discovery
|
|
- `GET /auth/oidc/keys` — Keys (EdDSA)
|
|
- `POST /auth/oidc/token` — Exchange/refresh
|
|
- `GET /auth/oidc/userinfo` — User (bearer token, includes `picture` when `profile` scope is granted and avatar exists)
|
|
- `POST /auth/oidc/backchannel-logout` — Logout
|
|
- `POST /auth/api/exchange` — Native auth code → cookie
|
|
|
|
## Claims
|
|
|
|
- `profile` scope may include `name`, `preferred_username`, and `picture`
|
|
- `email` scope may include `email`
|
|
- `groups` is emitted from client-scoped permissions
|
|
|
|
## Files
|
|
|
|
**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/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/realms.py](paskia/realms.py), [paskia/fastapi/mainapp.py](paskia/fastapi/mainapp.py)
|