diff --git a/docs/MultiSite.md b/docs/MultiSite.md index 013c0df..29eb4ff 100644 --- a/docs/MultiSite.md +++ b/docs/MultiSite.md @@ -1,543 +1,175 @@ -# Multi-Site Support - -One paskia process on one port serves multiple sites with **one combined -database**. The _administrative instance_ is separated from the _WebAuthn -RP_: organizations and users are global across rp-ids; rp-id is a -first-class per-domain object; passkeys remain tied to their rp-id -(WebAuthn-enforced); sessions remain host-bound. Motivating case: -`app1.company.com` and `app2.com` cannot share an rp-id, but user -management must be under single common controls. - -Terminology: a **domain** is one rp-id with its associated hosts and -origins. A **site** is any host served by the instance; each host belongs -to exactly one domain. The _administrative instance_ is the whole process: -global users/orgs, N domains. - -## 1. What is global vs. per-domain - -**Global (single instance, shared across domains):** - -| Data | Notes | -| ------------------------------- | -------------------------------------------------------- | -| Organizations, Roles, Users | one global collection | -| Permissions | `domain` field host-scopes effectiveness | -| Sessions | host-bound (`Session.host`, exact match) | -| Credentials/passkeys | global collection, each stamped with its `rp_id` | -| Reset tokens | user-bound; global | -| Avatars | `paskia.data/users//profile.webp` | -| Auth codes, remote-auth manager | in-memory; `CookieCode` carries an rp-id field | -| OIDC provider (key, clients) | one instance-global provider; hosts are issuer aliases | - -**Per-domain (registry, keyed by rp-id):** - -| Data | Notes | -| -------------------------------- | ---------------------------------------------------------------- | -| `rp_name`, origins, related | stored combined `Config` | -| `Passkey` instance | per rp-id; ceremonies verify against the _origin domain's_ rp-id | -| `site_url`/`site_path` | runtime derivation, per domain | - -`db.data()` is a plain global singleton. A contextvar is needed only for -the **current domain** (passkey, config) — not for database access. - -The architectural rule: **authentication establishes identity, not -organization** — the requested hostname selects the org/permission -context after authentication (via `Permission.domain` host-scoping and -session host binding). - -## 2. Login architecture: three composable mechanisms - -The domain infrastructure is shared by three mechanisms, alternatives -_per deployment_ and composable within one instance. - -### 2.A WebAuthn Related Origin Requests (trusted domain families) - -WebAuthn Level 3 lets otherwise-unrelated domains share one rp-id: the -canonical RP publishes `/.well-known/webauthn` listing permitted origins, -and those origins may then run ceremonies with the common rp-id locally — -no redirects, no cross-domain cookies. Browser support is universal -(Firefox included). - -Model: domain `company.com` with related origin `https://app2.com`. A page -on `app2.com` calls WebAuthn with `rpId: "company.com"`; the passkey is -scoped to `company.com`; `clientDataJSON.origin` is `https://app2.com`, -which the backend validates against the domain's related origins. - -Server side: paskia's `Passkey` passes `expected_origin=` and `expected_rp_id=self.rp_id`; the webauthn -library string-compares origin and rp-id separately. The frontend never -chooses `rpId` client-side — ceremony options arrive from the server over -the WS. On top of that: - -- Origin rule: `origins` and `related` are separate fields. - An in-domain origin (rp-id or subdomain) is valid unless the domain's - `origins` allow-list is set, in which case it must be listed there. An - origin on another domain is valid only when listed in the domain's - `related` — explicit related listing is the trust boundary. -- `GET /.well-known/webauthn` on the canonical rp-id host serves - `{"origins": [...]}` from the domain's related origins (404 when there - are none). -- Dispatch resolution treats a Host matching a configured related-origin - hostname as belonging to that origin's domain (exact match only — - `www.app2.com` does not follow `app2.com`) — unless the host is itself - a configured rp-id, which always wins: a host that is one domain's - rp-id and another's related origin serves its own domain. - -Deployment constraint: the **browser** fetches -`https:///.well-known/webauthn` from the canonical apex directly — -if paskia does not host the apex, publish the JSON there statically (the -admin domain dialog shows the document for copying). - -Constraints (from the WebAuthn WG): implementations must support at least -**5 registrable origin labels** — this is for a small family of -same-trust domains, not hundreds of customer domains. Sharing an rp-id -merges the security boundary: a weakly protected marketing domain should -not share the domain of the admin application. Config validation enforces -a cap (default 5) on related origins per domain. - -**Re-enrollment note**: passkeys never move between rp-ids -(WebAuthn-enforced). A host family that first deploys separate domains -(2.B) and later consolidates to Related Origins re-enrolls: authenticate -against the old domain (or via 2.C), register a new credential under the -common rp-id, retire the old one. The per-credential rp-id badge (§9) -makes this visible. There is no automated credential migration. - -### 2.B Multiple rp-id domains under one administrative instance - -For domains that should _not_ share an rp-id: rp-id is a first-class -object (domain), not an instance attribute. Users are global identities; -credentials carry `rp_id`: - -``` -Instance -├── Orgs / Roles / Users (global) -└── Domains - ├── company.com (origins {...}, credentials scoped by rp_id) - ├── app2.com (origins {...}, credentials scoped by rp_id) - └── customer.net (origins {...}, credentials scoped by rp_id) -``` - -Alice can hold both a `company.com` and an `app2.com` passkey; sessions -stay host-only. - -### 2.C Remote authorization + opportunistic local enrollment - -For a domain where the user has no credential, the remote-login mechanism -provides a federation-style flow: unauthenticated device requests, -authenticated device permits, a short-lived **single-use opaque exchange -code** (60s `CookieCode`) is redeemed by the requesting host, which sets -its own host-only cookie. No shared cookies, no reusable tokens in URLs. - -- Cross-domain permits are **allowed**: a device authenticated at - `company.com` may authorize a session for `app2.com`; the request's - domain is recorded and shown to the approver; the target host is - registry-validated. -- **Opportunistic local enrollment**: after a cross-domain remote login, - the profile view offers "Add a passkey for " — registration runs - locally under the new domain's rp-id, stamping `Credential.rp_id`. This - makes remote login primarily bootstrap/recovery, while everyday - authentication stays local. - -### Policy summary (how deployments choose) - -| Situation | Mechanism | -| --------------------------------------------------- | ---------------------------------------------- | -| Few closely related, equally trusted brand domains | 2.A Related Origins — one passkey | -| Independent / customer / lower-trust domains | 2.B separate domains — passkey per domain | -| User lacks a credential for the current domain | 2.C remote authorization, then enroll locally | - -## 3. Configuration model - -### 3.1 Stored config - -```python -class OriginEntry(msgspec.Struct, omit_defaults=True): - auth_host: bool = False # this site hosts the account/admin interface - -class DomainConfig(msgspec.Struct, omit_defaults=True): - rp_name: str | None = None - origins: dict[str, bool | OriginEntry] = {} # in-domain sign-in sites - related: dict[str, bool] = {} # cross-domain ROR origins (§2.A) - -class Config(msgspec.Struct, omit_defaults=True): - domains: dict[str, DomainConfig] # keyed by rp-id; at least one - listen: list[str] | None = None # process-global -``` - -- Domains are keyed by rp-id; there is no "default" or "primary" domain. - Where a domain is needed without request context (bootstrap reset-link - URL, background jobs), the single configured domain is used, and with - several domains the first one sorted by rp-id — never for dispatch. -- Origin keys are bare hosts (`app.example.com`), wildcard patterns - (`*.example.com`), full origins when not https - (`http://localhost:8080`), or the bare `*` (shorthand for a wildcard - over the rp-id itself) — `https://` is omitted as the common case. A - dict value of `true` means presence only; an object carries extra - properties (currently just `auth_host`). Ordering carries no meaning — - display order is a UI affair. -- An empty `origins` dict means the rp-id and all its subdomains may - sign in (the default). A non-empty dict is an allow-list of in-domain - sign-in sites; matching semantics per entry kind: - - `*` — the whole rp-id domain (shorthand for `*.{rp-id}`); - - `*.example.com` — the base domain and its subdomains, **https only** - — except under localhost (`*.localhost` or any wildcard below it), - which matches **any scheme and any port**; - - anything else — exact match on scheme, host and port. - One entry may be marked `auth_host` (never a wildcard or `*`). -- **Origin validation** — two separate concerns: `origins` entries must - be within the rp-id domain. `related` entries must be outside it, are - capped (default 5), must not be wildcards, and must not collide with - another domain's auth host; two domains may not list the same related - host **unless** that host is (or falls inside) a configured rp-id — - overlapping another domain's rp-id is permitted: the owning domain - always wins dispatch for that host, and each listing domain's - well-known document independently authorizes ROR logins. Several - domains may mark the same auth host when it lies under both rp-ids - (nested rp-ids); resolution among claimants is deterministic (§5). - Misfiled entries (cross-domain in `origins`, in-domain in `related`) - are rejected. These rules are enforced at admin write time; at startup - the stored config is sanitized best-effort instead (§3.2). Origins are - never _implicitly_ cross-domain. - -### 3.2 CLI: bootstrap (`paskia init`) vs. serve (`paskia`) - -The CLI is split so that domain options exist only at bootstrap time — -they can never mix with runtime configuration of an already-configured -instance: - -- **`paskia init [rp-id] [rp-name]`** — creates `paskia.kantadb` in CWD - and seeds it: - - `rp-id` (positional, default `localhost`) and `rp-name` (positional, - default same as rp-id) are the only bootstrap-time domain - configuration; the rp-name exists so the very first admin - registration ceremony already shows the correct name. Everything else - (origins, auth hosts, related domains) is set up via the admin - interface. - - `--listen`: stored into `Config.listen` (process-global). - - Seeds the admin user + registration reset link and prints the link. - Refuses to run if an unconverted legacy `*.paskiadb` is present - (`paskia migrate` converts it first). - - **With an existing `paskia.kantadb`**, init instead adds the given - rp-id as a new domain, or updates the - rp-name of an existing one — a convenience for what the admin - interface also does. -- **`paskia migrate [rp-id]`** — converts a legacy `.paskiadb` - database (§10) to `paskia.kantadb`. With several legacy candidates, the - positional rp-id selects `.paskiadb` by name; the others are - left in place. Legacy wildcard origins (`*.example.com`) convert as-is - (https-only outside localhost, any scheme and port under localhost). -- **`paskia`** — serve. Takes **no domain options**; only `--listen` - (per-run override of stored `Config.listen`, never persisted). Startup: - open `paskia.kantadb` → sanitize the stored domain set best-effort → - build the domain registry → serve. Sanitization never refuses to start: - misfiled origin entries are reclassified (a cross-domain `origins` - entry is served as a related origin) or dropped, related origins - claimed by two non-owner domains resolve first-come-wins, over-cap - related lists truncate, and unsalvageable domains are skipped — each - producing a startup warning, because fixing the stored config is the - admin interface's job and it must stay reachable to do so. Only a - config with no servable domain at all is fatal. The serve command never - converts databases: with no `paskia.kantadb`, the startup error points - at `paskia init`, or at `paskia migrate` when legacy `*.paskiadb` - candidates are present. - -Nested rp-ids are allowed (longest-suffix dispatch determinism). Adding a -child rp-id moves **no data** — users are global; only new ceremonies -stamp the child rp-id. - -### 3.3 Runtime accessors - -- The domain registry is built in the FastAPI lifespan **after - `kanta.open()`**, from `db.data().config.domains` — domain data does - not travel through `PASKIA_CONFIG`. Per-domain `site_url`/`site_path` - are computed at registry-build time (priority: auth host > exact rp-id - origin key > first concrete origin key > `PASKIA_VITE_URL` for the - localhost domain > `http://localhost:port` > `https://rp-id`), using - the effective listen endpoints for the localhost fallback. -- `PASKIA_CONFIG` carries only process-global serve parameters (the - effective listen endpoints) so the derivation inside the server - process can resolve the localhost-port fallback. -- Admin domain writes persist the combined `Config` and rebuild the - registry in place, so dispatch sees auth host and related-origin - changes immediately. - -## 4. Credentials carry an rp-id - -- `Credential.rp_id: str` is stamped at registration from the ceremony's - rp-id. With Related Origins the stamp is always the _domain's - canonical_ rp-id regardless of which origin the ceremony ran on — the - credential genuinely is a `company.com` passkey. -- `authenticate_chat` filters the raw_id scan by `c.rp_id == ceremony - rp-id` — prevents wrong error semantics and a cross-domain oracle ("no - credential" vs "verification failed" would leak which rp-id a - credential belongs to). -- `exclude_credentials` (registration) and reauth `allow_credentials` are - filtered by the ceremony's rp-id (`User.credential_ids_for(rp_id)`) — - users are global, so their credential id sets are cross-domain. -- Cascades are uuid-keyed; deleting a user removes their passkeys across - all domains (correct: users are global). - -## 5. Dispatch and domain context - -- `paskia/domains.py`: `Domain { config, passkey, ... }` and a registry - keyed by rp-id, built in the lifespan from the stored combined `Config` - and rebuilt on admin domain writes. No per-domain Kanta/DB. -- Host resolution (`resolve(host)`): normalize (lowercase, strip port and - trailing dot), then exact rp-id → auth host → **exact - related-origin hostname** → longest-suffix rp-id. Unknown → `None`. - Overlaps are deterministic: an owning rp-id always beats a related - listing of the same host; when several domains claim one auth host, - the claimant whose rp-id is the longest suffix of the host wins - (first configured as tiebreak); a related host claimed by two - non-owner domains resolves first-configured-wins. -- A pure ASGI dispatch middleware, outermost, handles `"http"` and - `"websocket"` scopes. Unknown Host → 421 Misdirected Request (WS: - pre-accept close). Sets the `current_domain` contextvar + - `scope["state"]["domain"]`. -- **WebSocket resolution follows the `Origin`, not the connection - `Host`**: in auth-host mode the login page is on the app host, the WS - connects to the auth host, and Origin names the host being logged into. - So: - 1. the middleware resolves the origin domain from the Origin hostname — - including related-origin hostnames; - 2. the connection `Host` must be a valid WS endpoint for that domain: - the domain's _own auth host_ (§6), or the origin host itself - when the domain has no auth host at all — else pre-accept reject; - 3. `validate_origin` runs endpoint-side against the origin domain's - `Passkey` (post-accept JSON errors preserved); - 4. `current_domain` = origin domain for the WS handler's duration. - The ceremony rp-id is always the origin domain's rp-id — exactly what - the browser enforces for the page's origin under both classic and - related-origin rules. - -## 6. Auth host: per-domain, no fallback - -A domain's auth host is one of its origins entries marked -`auth_host: true` (always in-domain). There is **no cross-domain -fallback**: a domain without its own auth host uses its own hosts for WS -and all flows — one domain's auth host never reroutes another domain's -authentication. Consolidating logins on one host is explicit: the host -must be marked on every domain that uses it (possible when the host lies -under each domain's rp-id, i.e. nested rp-ids; see §5 for deterministic -resolution among claimants). Marking a _foreign_ host as a domain's own -auth host is rejected (origins entries are in-domain only): it would -redirect that domain's UI to the other domain, where the ceremony's -Origin resolves the _owner_ domain and stamps the wrong -`Credential.rp_id`. - -``` -auth_host(domain) = domain's own auth host or None -``` - -- **Own auth host governs everything**: UI mode detection (minimal- - profile decision in `App.vue`), the redirect middleware, - `ui_base_path`, `reset_link_url`, and WS endpoint selection - (`passkey.js` builds the WS URL from settings). -- Settings (`ApiSettings`) exposes `auth_host` and `own_auth_host` with - the same value (the latter remains for clients that switched to it). -- Root mode (`site_path == "/"`) applies only on a domain's _own_ auth - host: a domain's UI lives on its own hosts. - -## 7. Login flows and in-memory stores - -### 7.1 Auth codes - -- `CookieCode` carries `rp_id`, verified at redemption — defense in - depth. `OIDCCode` does not: the OIDC provider is instance-global, so - its codes are redeemable at any host. -- **Stamping source matters**: codes are stamped with the domain of the - session they will redeem — not naively with the current domain at - issuance. Remote-completion codes are minted inside the _permit_ - handler (permitting domain's context) but redeemed by the _requesting_ - device on its own host, so they are stamped with - `RemoteAuthRequest.rp_id` — stamping them with the permitter's domain - would break every cross-domain remote login. Registration-flow codes - stamp from the current domain (issue and redeem sides always - match). The host re-check at set-session independently binds - `CookieCode` to the host; the rp_id check complements it. - -### 7.2 Remote authentication — cross-domain permits allowed - -- `RemoteAuthRequest.rp_id` records the requesting device's origin - domain. -- **Permit side may differ from the request side** (mechanism 2.C): the - permitting device authenticates with _its_ domain's passkey, and the - `session_host=request.host` override creates the session for the - requesting host. The override must resolve to a **configured domain** - (registry check) — arbitrary-host session binding is refused. The - request's rp-id is shown to the permitting user ("device at app2.com - requests login"). -- No policy flag: cross-domain remote login is how the product works - (users are global). -- Exchange codes stay single-use, 60s, host-bound at redemption. - -## 8. OIDC: one instance-global provider - -- `DB.oidc` is a single `OIDC` — one signing key (`oidc.key` in the - transaction log shape) and one client set for the whole instance. - Domains do **not** segment OIDC: a client registered once is usable - through every configured domain. -- The `util/oidjwt.py` key cache holds the single signing key. -- Issuer stays per-request-Host — every configured host is an issuer - alias sharing the one key, and the discovery document - (`/.well-known/openid-configuration`) is served on every host with - host-derived endpoints. An RP picks **one** discovery URL and uses it - consistently; tokens then validate against that issuer. The admin OIDC - client view lists the discovery URL of every configured domain. -- **`Session` carries two fields**: `issuer` (stamped from the WS - **Origin**, scheme included, at OIDC-session creation and re-stamped at - refresh — stamping from the WS _connection_ Host would be wrong, that - may be an auth host, not the authorize/discovery host the RP - validates against); and `rp_id` — the owning domain, kept for display - and diagnostics. - - backchannel logout runs without request context and uses - `session.issuer` as `iss` (falling back to `https://`). -- Admin OIDC-client CRUD operates on the instance-global `OIDC` entry. -- Permission `domain` validation accepts a subdomain of **any** - configured rp-id, any related-origin hostname, or a client UUID. -- OIDC authorization always runs a fresh passkey ceremony; the session - cookie is never consulted in the OIDC branch of the WS authenticate - handler, so a stolen cookie cannot complete an OIDC login on any host. - -## 9. Admin API and UI - -- `GET /auth/api/settings`: per-request-Host domain values (rp_id, - rp_name, effective `auth_host`, `own_auth_host`, site URLs). -- **Domain management (master admin only, `auth:admin`)** — how rp-ids - are managed after bootstrap: - - `GET/POST /auth/api/admin/domains/` and - `PATCH/DELETE /auth/api/admin/domains/{rp_id}`. Writes require recent - authentication (5 minutes). - - Create: `rp_id` + optional `rp_name` (defaults to the rp-id), - `origins` and `related` objects mirroring the stored shape (§3.1); - full §3.1 validation (cap, cross-domain collisions); registry rebuilt - immediately, including the domain's `Passkey` instance. The OIDC - provider is instance-global and unaffected by domain writes. The auth host is marked inside `origins` - (`{"auth.example.com": {"auth_host": true}}`). - - Update: same validation against the would-be combined config. - Changing a domain's rp-id itself is **not supported** (it would - orphan every credential stamped with the old rp-id) — delete and - recreate instead. - - Delete: refused for the last remaining domain, while any credential - carries the domain's rp-id (re-enroll or delete those credentials - first), and for the domain the admin is currently using; cascades - nothing else (users/orgs are global). - - **Lockout guard**: an update that would make the admin's current - host unable to run passkey ceremonies for the domain they are on is - refused (unless an auth host takes over ceremonies — it is always - allowed). Fixing a broken stored config is always possible: serve - sanitizes it best-effort (§3.2) so the admin interface stays - reachable on a working domain. - - The admin UI has a Domains section with a table (rp-id + rp-name, - allowed origins with a 🔑 on the auth host, actions), per-row - edit/delete and an add-domain dialog. In the dialog the in-domain - allow-list and the related domains are edited as one list — entries - are classified by whether they fall within the rp-id domain; related - domains additionally show the `/.well-known/webauthn` document the - canonical rp-id must publish and a warning when there are more than - 5. Marking `*.example.com` as the auth host creates a concrete - `auth.example.com` entry instead. -- Credential listings: `Credential.rp_id` serializes automatically into - user-info and admin user detail responses; the frontend shows an rp-id - badge **only when `credential.rp_id !== settings.rp_id`** — single- - domain installs never show a badge. -- **Enrollment prompt (2.C)**: when the user has no passkey for the - current domain, the profile view offers to add one (a fresh remote-auth - session satisfies the recent-auth requirement of registration). -- **Bootstrap check**: the "admin has no credentials" startup check - passes if the admin holds a credential under **any** configured domain; - otherwise a registration link is printed for the first domain (sorted - by rp-id). - -## 10. Storage - -- Fixed CWD-relative path: **`paskia.kantadb`** — a single kanta JSONL - file. Kanta rotation siblings (`paskia@.kantadb`) are - unaffected. There is no environment override; CWD selects the - deployment. -- **User files** (avatars) live in the fixed sibling directory - **`paskia.data/users/`**. -- **Legacy conversion**: `paskia migrate` converts a legacy - `*.paskiadb` database — a directory containing `main.db`, or a legacy - single-file database — into `paskia.kantadb`: `main.db` (or the single - file) becomes `paskia.kantadb`, `users/` becomes `paskia.data/users/`, - and the old directory is renamed aside to `.converted-bak`. A - lone candidate converts without options; with several candidates - a positional rp-id selects `.paskiadb` by name and the rest are - left in place (e.g. a `*.bak.paskiadb` backup does not block - conversion). Empty directories are ignored. Conversion is an explicit - operator action, never a serve side effect — read-only opens never - trigger conversion or writes. -- The legacy database's structs live in a separate module - (`paskia/db/legacy.py`). There is no multi-database merging. -- The startup box prints per-domain lines. - -## 11. Lifespan and background tasks - -- One `Kanta` for `paskia.kantadb`, opened once in the lifespan; one - background cleanup task (DB is global). -- The kanta bootstrap hook only ever fires for a database created by - `paskia init` or `paskia migrate`; the serve command never bootstraps. -- The registry is built from the stored `Config` after open, sanitized - best-effort (§3.2) so startup never fails on config content; per-domain - `Passkey` instances are constructed from the sanitized config. -- The admin-credential check runs at serve startup and reprints a usable - registration link when the admin lacks a credential under any - configured domain (§9). -- `oidc_notify` fire-and-forget tasks need no domain context for DB - access (global DB); issuer comes from the session (§8). -- The dispatch middleware is the only place `current_domain` is set for - requests; admin domain writes rebuild the registry. - -## 12. Development - -- `scripts/devserver.py`: bootstraps via one-shot `paskia init` per rp-id - when no database exists (rp-name for the first domain), then runs - plain `paskia` serve. Caddy dev origins iterate all bootstrap rp-ids. -- `PASKIA_AUTH_HOST` (consumed by `frontend/vite.config.js`) is a - comma-separated list of bare hostnames; the vite dev proxy forwards - `/.well-known/openid-configuration` and `/.well-known/webauthn` to the - backend. -- The example `caddy/auth/setup` snippet forwards both well-known paths - to paskia so a static `/.well-known/*` handler does not shadow them. -- E2E: `e2e/tests/global-setup.ts` runs `paskia init localhost` and - `paskia init test.localhost` in the test-data dir (which doubles as the - server CWD) and serves; `e2e/tests/50-multidomain.spec.ts` exercises - host dispatch, the well-known endpoint via the admin domain API, and a - cross-domain remote login (request at test.localhost, permit at - localhost, session valid on test.localhost) including the enrollment - prompt UI. Related Origins have **no browser e2e**: a genuine related - origin needs a non-subdomain host over HTTPS, and the browser fetches - the well-known document itself — server-side coverage is in pytest - (`tests/test_domains.py`). - -## 13. Security model - -- **Dispatch**: unknown Host → 421 before any router/DB access (direct-IP - and unconfigured-name access does not work; trailing dots normalized). -- **Related Origins boundary**: cross-domain origins are valid only when - explicitly configured and capped; the well-known document is served - only for the canonical domain and only lists configured origins. All - origins sharing an rp-id share one security boundary — do not mix trust - levels within a domain. -- **Domain administration**: domain create/update/delete is gated on - `auth:admin` — deployment-wide by design. Writes are strictly - validated (cross-domain rules plus self-lockout guards); startup never - refuses a stored config — it sanitizes with warnings so the admin - interface stays reachable to fix problems. -- **Passkeys**: rp-id binding browser-enforced and server-recorded; - ceremonies, credential scans, exclude/allow lists all scoped to the - origin domain's rp-id. No cross-domain oracle in the scan. -- **Sessions**: host-bound, exact match. Cross-domain sessions arise only - via (a) a ceremony at the origin domain (incl. related origins), or (b) - a remote permit by a device holding a valid session at its own domain, - with registry-validated target host. -- **Users/orgs global**: deleting a user/org cascades across all domains — - intended. `auth:admin` is deployment-wide. Permission `domain` - host-scopes effectiveness per host. -- **Cross-domain permit transparency**: requesting domain/host shown to - the approver; both sides logged. -- **Secret hygiene in logs**: the OIDC signing-key censoring matches the - `oidc.key` path shape, so the key never prints in plaintext - in the JSONL transaction log. -- **OIDC**: instance-global key and clients; issuers are per-request-Host - aliases; logout tokens carry the stored issuer. +# Multiple Domains (Multi-Site) + +One Paskia instance on one port serves several domains from a single +database. Typical uses: + +- `app1.company.com` and `app2.com` cannot share passkeys, but user + management should stay under one roof. +- A few alternative brand names should accept the _same_ passkeys. + +## Shared vs. per-domain + +**Shared across all domains:** user accounts, organizations, roles, +permissions, and OIDC clients. A user account exists once and can sign in +on every domain. + +**Per domain:** passkeys and sessions. + +- A passkey is registered to one domain name (enforced by the browser): a + passkey created for `company.com` works on `company.com` and its + subdomains, never on an unrelated name — unless that name is configured + as a [related origin](#related-origins-sharing-passkeys-across-domain-names). + A user active on two domains simply holds one passkey per domain. +- A session is bound to the exact host that issued it. + +## Managing domains + +The admin panel's **Domains** section (master admins only) lists every +domain with its allowed origins. There you can add, edit and delete +domains; changes apply immediately without a restart and require a recent +sign-in (within 5 minutes). The 🔑 and 🔗 markers in the origins column +identify the auth host and related origins (below). + +A domain consists of: + +- **Domain (rp-id)** — the domain name passkeys belong to, e.g. + `company.com`. ("rp-id" is the WebAuthn term; read it as "domain name".) + It cannot be changed after creation, because existing passkeys are bound + to it — delete and re-create the domain instead. +- **Display name (rp-name)** — branding shown in sign-in dialogs and + registered with passkeys. +- **Allowed origins** — the sites where this domain's passkeys may sign + in, plus any related origins. + +Deleting a domain is refused while any passkey is still registered to it, +when it is the last remaining domain, or when it is the domain you are +currently using. Users and organizations are never deleted with a domain +— they are shared. An edit that would lock you out (your current site +could no longer run passkey ceremonies for that domain) is refused as +well. + +## Allowed origins (sign-in sites) + +This list controls which sites may sign in with the domain's passkeys: + +- **Empty list (the default):** the domain and all its subdomains may + sign in, on any scheme. This suits most deployments; the domain dialog + shows the default as a `*` placeholder row. +- **Once you add entries, the list becomes an allow-list** — only listed + sites may sign in: + - `app.company.com` — exactly this host, https only. + - `*.company.com` — the base domain and all its subdomains, https only. + Under `localhost`, wildcards match any scheme and any port, as a + development convenience. + - `http://localhost:8080` — a full origin with scheme, for non-https + exceptions. + - `*` — shorthand for `*.{domain}`: the whole domain over https. + +Entries must be inside the domain. An entry on a different domain name +automatically becomes a related origin (🔗) instead — see below. + +## The auth host (🔑) + +Marking one allowed origin as the **auth host** (row menu ⋮ → "Set as +auth host") centralizes the account and admin interface on that site, +e.g. `auth.company.com`: + +- On the auth host the web UI is served at the site root (`/` instead of + `/auth/`), and all passkey operations for the domain happen there. +- The domain's other sites show only a minimal profile page at `/auth/` + with logout and a link to the full profile; their sign-in dialogs talk + to the auth host behind the scenes. Every sign-in site still needs to + be listed in (or covered by) the allowed origins. + +The auth host is strictly per-domain — domains never borrow each other's +auth host. To consolidate several domains on one sign-in site, that site +must lie under each domain's name (nested domains, e.g. domains +`company.com` and `auth.company.com`) and be marked on each of them. + +## Related origins: sharing passkeys across domain names + +Sometimes a few different domain names should accept the _same_ passkeys +— for example after a rebrand, when `app2.com` should keep working with +existing `company.com` passkeys. Adding `app2.com` to `company.com`'s +allowed origins makes it a **related origin**: browsers then let +`app2.com` use `company.com` passkeys directly — no redirects, no +cross-domain cookies. (This uses the WebAuthn "Related Origin Requests" +mechanism, which is why the UI also says ROR.) + +Rules: + +- At most **5 related origins per domain** — a browser limit. This is for + a small family of equally trusted sites, not for hundreds of customer + domains; use separate domains for those. +- Exact hosts only — no wildcards — and always outside the domain's own + name. +- The browser verifies the setup against + `https:///.well-known/webauthn`. Paskia serves that document + automatically when it hosts the domain's main site; if the main site is + hosted elsewhere, copy the JSON document shown in the domain dialog and + publish it there. The dialog also checks the published document for + you. +- A related origin shares the domain's security boundary completely — do + not mix trust levels within one domain. +- When several domains could claim a host: a host that _is_ a configured + domain name always serves its own domain; otherwise an explicit related + origin listing wins over merely falling under another domain's name. + +Passkeys never move between domains. If you later consolidate separate +domains onto one, users re-enroll: sign in once via remote authorization +(below), then register a new passkey for the common domain from the +profile page. + +## Signing in across domains + +Users exist once, but need a passkey per domain. Two mechanisms smooth +this over: + +- **Remote authorization:** a user without a passkey for the current + domain can start a login request and approve it from any device already + signed in — on _any_ domain of the instance. The approval screen shows + which site is requesting access. +- **Enroll on the spot:** when the signed-in user has no passkey for the + current domain, the profile page offers "Add Passkey for {domain}", so + everyday sign-in stays local from then on. + +## OIDC with multiple domains + +OIDC clients are shared by the whole instance: register a client once and +it works through every domain. Each domain serves its own discovery URL +(`https:///.well-known/openid-configuration`), listed in the admin +OIDC client view. Have each app pick **one** discovery URL and use it +consistently, so its tokens always validate against the same issuer. + +## Command line + +The admin panel covers all domain management after bootstrap. On the +command line: + +- `paskia init [domain] [name]` — creates the database `paskia.kantadb` + with the first domain. Run again with an existing database to add + another domain (or update a display name). +- `paskia migrate [domain]` — converts a legacy 1.x `{domain}.paskiadb` + database to `paskia.kantadb`; see below. +- `paskia` — serves all configured domains; takes no domain options, only + `--listen` as a per-run override. + +## Upgrading from 1.x + +2.0 intentionally changes the on-disk layout and the domain configuration +model: + +- The database is the single file **`paskia.kantadb`** in the working + directory; user files (avatars) live in **`paskia.data/users/`**. + `paskia migrate` performs the conversion and renames the old database + aside to `{domain}.paskiadb.converted-bak`. With several legacy + databases, the positional argument selects one by name. Legacy wildcard + origins convert as-is (https only, except any scheme and port under + `localhost`). +- Origins, auth hosts and related origins are no longer environment + settings — they live in the database and are managed in the admin + panel's Domains section. `PASKIA_AUTH_HOST` remains only as a + development-server (vite) setting. +- OIDC becomes instance-global: one signing key and one client set, + reachable through every domain's discovery URL (previously each rp-id + had its own). Existing clients keep working through any domain. diff --git a/oidc.md b/oidc.md index b164f85..7ba9748 100644 --- a/oidc.md +++ b/oidc.md @@ -2,15 +2,15 @@ OpenID Connect 1.0 provider enabling third-party apps to authenticate users via passkey. Also supports native cookie-based authentication. -## Realms (multi rp-id) +## Domains (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..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. +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), `rp_id`/`issuer` (realm that authenticated / issued the session) +**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))` @@ -19,21 +19,24 @@ Each realm (rp-id) is an independent OIDC issuer with its own signing key and cl ## Auth Codes (In-Memory Only) -60-second lifetime, auto-cleaned: +60-second lifetime, auto-cleaned. Two separate stores keep the OIDC and cookie flows isolated: ```python -from paskia.authcode import AuthCode, OIDC, codes +from paskia.authcode import CookieCode, OIDCCode, store_cookie, store_oidc -class AuthCode(msgspec.Struct): +class OIDCCode(msgspec.Struct): session_key: str # Session DB key created: datetime - oidc: OIDC | None # Only for OIDC mode + redirect_uri, scope: str + nonce, code_challenge: str | None # PKCE S256 when provided -class OIDC(msgspec.Struct): - redirect_uri, scope, nonce, code_challenge, code_challenge_method: str +class CookieCode(msgspec.Struct): + session_key: str + created: datetime + rp_id: str # domain the code was issued in; checked at redemption ``` -Usage: `code = authcode.store(AuthCode(...))` → later `codes.pop(code, None)` +Usage: `code = store_oidc(OIDCCode(...))` → later popped from `oidc_codes` / `cookie_codes`. ## Authorization Flows @@ -93,4 +96,4 @@ Discovery: `backchannel_logout_supported: true` **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) +**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)