- 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
100 lines
4.5 KiB
Markdown
100 lines
4.5 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.
|
|
|
|
## 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 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. Two separate stores keep the OIDC and cookie flows isolated:
|
|
|
|
```python
|
|
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)
|
|
|
|
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/domains.py](paskia/domains.py), [paskia/fastapi/mainapp.py](paskia/fastapi/mainapp.py)
|