# 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; carry rp-id fields | **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 | | OIDC provider (keys, clients) | per rp-id — each domain is an independent issuer | `db.data()` is a plain global singleton. A contextvar is needed only for the **current domain** (passkey, config, OIDC view) — 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`). 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`), or full origins when not https (`http://localhost:8080`) — `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; one entry may be marked `auth_host` (never a wildcard). - **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 rp-id/auth-host/related origins nor fall inside another domain's rp-id. 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`** — creates `paskia.kantadb` in CWD and seeds it: - `--rp-id`: repeatable/comma-separated, default `["localhost"]`. Multiple values create multiple domains at once (useful for devserver/e2e). - `--rp-name`, `--auth-host`, `--origin`: apply to the **first** rp-id only. Their purpose is that the very first admin registration ceremony already shows the correct RP name; everything is editable via the admin interface afterwards. - `--listen`: stored into `Config.listen` (process-global). - Seeds the admin user + registration reset link and prints the link. Refuses to run if `paskia.kantadb` already exists, or if an unconverted legacy `*.paskiadb` is present (`paskia migrate` converts it first). - **`paskia migrate`** — converts a legacy `.paskiadb` database (§10) to `paskia.kantadb`. With several legacy candidates, `--rp-id` selects `.paskiadb` by name; the others are left in place. - **`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, colliding auth-hosts/related origins 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 → exact auth host → **exact related-origin hostname** → longest-suffix rp-id. Unknown → `None`. (Order safe because startup and admin-write validation forbids collisions between these sets.) - 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 _effective 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 values with global fallback A domain's auth host is one of its origins entries marked `auth_host: true`. A domain without its own auth host falls back to the first configured auth host found in the registry: ``` effective_auth_host(domain) = domain's own auth host or first configured one or None ``` **Own vs. effective auth host are distinguished everywhere:** - **Follow the domain's OWN auth host**: UI mode detection (minimal- profile decision in `App.vue`, via `own_auth_host` in settings), the redirect middleware, `ui_base_path`, and `reset_link_url`. A domain with no own auth host keeps its full UI on its own hosts — otherwise reset/registration pages on `app2.com` would redirect to `auth.company.com`, where the ceremony's Origin resolves the _owner_ domain and stamps the wrong `Credential.rp_id`, breaking 2.B onboarding and 2.C local enrollment. - **Follow the EFFECTIVE auth host**: WS endpoint selection only (`passkey.js` builds the WS URL from settings). The fallback auth host serves WS + restricted APIs for foreign domains. - Settings (`ApiSettings`) exposes both fields (`own_auth_host` alongside the effective `auth_host`) so the frontend makes the mode decision correctly. - Root mode (`site_path == "/"`) applies only on a domain's _own_ auth host, so it is never ambiguous: a domain's UI lives on its own hosts; the fallback auth host serves the _owner_ domain's UI plus WS for the rest. ## 7. Login flows and in-memory stores ### 7.1 Auth codes - `OIDCCode` and `CookieCode` carry `rp_id`, verified at redemption — defense in depth. - **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 and OIDC 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: per-domain providers in one DB - `DB.oidc` is `dict[str, OIDC]` keyed by rp-id. Each domain is an independent provider: own signing key (`oidc..key` in the transaction log shape), own clients. - The `util/oidjwt.py` key cache is keyed by rp-id. - Issuer stays per-request-Host — each domain host is an issuer alias sharing the domain's key. **`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 is the effective auth host, not the authorize/discovery host the RP validates against); and `rp_id` — the owning domain, needed by every path that runs **without request context**: - backchannel logout uses `session.rp_id` to select the domain's key and `session.issuer` as `iss`; - session listings resolve the client under the session's own domain, not the request's. - Admin OIDC-client CRUD operates on the current domain's `OIDC` entry. - Permission `domain` validation accepts a subdomain of **any** configured rp-id, any related-origin hostname, or any domain's client UUID. ## 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 and OIDC provider entry. 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 `--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` when no database exists (multi `--rp-id`, and `--rp-name`/`--auth-host`/ `--origin` for the first domain), then runs plain `paskia` serve. Caddy dev origins iterate all bootstrap rp-ids plus the auth host and explicit origins. - `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 --rp-id localhost,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 domain keys never print in plaintext in the JSONL transaction log. - **OIDC**: per-domain keys/issuers; logout tokens carry the stored issuer.