- Serve multiple domains (RP IDs) from one instance: host-based dispatch, per-domain credentials and sessions, domains managed at runtime in the admin UI — previously one RP per instance - Cross-domain sign-in via Related Origin Requests: per-domain related-origins list with a served .well-known/webauthn document - Explicit per-domain origin lists with shell-glob wildcards (**. for apex + any subdomain depth, *. for one level), editable in the admin UI with validation and self-lockout guards - Per-domain auth hosts: the account/admin UI can live on a different host per domain, no longer confined to subdomains of a single RP - CLI: 'paskia init <rp-id [rp-name]' initializes or adds a domain to an existing database; 'paskia migrate' converts legacy databases BREAKING CHANGES (v2.0): - Database schema: config is now per-domain and credentials/sessions carry an rp_id — existing databases must be converted with 'paskia migrate' - Origins are now explicit: main implicitly allowed every subdomain of the RP; configure '**.' origins to reproduce that behavior - CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
4.5 KiB
OIDC Provider Implementation
OpenID Connect 1.0 provider enabling third-party apps to authenticate users via passkey. Also supports native cookie-based authentication.
Domains (multi rp-id)
The OIDC provider is instance-global: one signing key (oidc.key in the transaction log) and one client set for the whole instance, usable through every configured domain. Discovery, keys, token and userinfo endpoints resolve the issuer from the request host (domain dispatch), so every configured host is an issuer alias sharing the one key. Session.issuer records the issuing origin (scheme included, stamped from the WS Origin) so refresh and back-channel logout produce the right iss; Session.rp_id records the owning domain for display. CookieCode is stamped with the session's rp-id and verified at redemption; OIDCCode is not, since the provider is instance-global.
Data Models
User — Added: email, preferred_username
Session — Added: client_uuid (None = native, set = OIDC), issuer (origin that issued the session), rp_id (owning domain, display only)
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. Two separate stores keep the OIDC and cookie flows isolated:
from paskia.authcode import CookieCode, OIDCCode, store_cookie, store_oidc
class OIDCCode(msgspec.Struct):
session_key: str # Session DB key
created: datetime
redirect_uri, scope: str
nonce, code_challenge: str | None # PKCE S256 when provided
class CookieCode(msgspec.Struct):
session_key: str
created: datetime
rp_id: str # domain the code was issued in; checked at redemption
Usage: code = store_oidc(OIDCCode(...)) → later popped from oidc_codes / cookie_codes.
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/domains.py, paskia/fastapi/mainapp.py