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.
4.1 KiB
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 rawsecret→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:
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)
GET /auth/restricted/oidc?client_id=UUID&redirect_uri=...&scope=openid&nonce=...&code_challenge=...- Frontend → WebSocket:
/auth/ws/authenticate?client_id=...&redirect_uri=...&... - Validate client/redirect_uri, authenticate via passkey
db.oidc_login()→(secret, session_key)- Create
AuthCode(session_key, oidc=OIDC(...))→ code - Return:
{"redirect_url": "{redirect_uri}?code={code}&state={state}"} - Client exchanges code at
/auth/oidc/tokenwithcode_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)
- WebSocket:
/auth/ws/authenticate(no OIDC params) - Authenticate via passkey
db.login()→ secret- Create
AuthCode(session_key=secret, oidc=None)→ exchange_code - Return:
{"user": "UUID", "exchange_code": "..."} POST /auth/api/exchangewith 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
sidorsub - Delete matched sessions
- Return 200 OK
Discovery: backchannel_logout_supported: true
Endpoints
GET /.well-known/openid-configuration— DiscoveryGET /auth/oidc/keys— Keys (EdDSA)POST /auth/oidc/token— Exchange/refreshGET /auth/oidc/userinfo— User (bearer token, includespicturewhenprofilescope is granted and avatar exists)POST /auth/oidc/backchannel-logout— LogoutPOST /auth/api/exchange— Native auth code → cookie
Claims
profilescope may includename,preferred_username, andpictureemailscope may includeemailgroupsis emitted from client-scoped permissions
Files
Created: paskia/authcode.py, paskia/util/crypto.py, paskia/fastapi/oid.py
Modified: paskia/db/structs.py, paskia/db/operations.py, paskia/fastapi/ws.py, paskia/fastapi/api.py, paskia/realms.py, paskia/fastapi/mainapp.py