Files
paskia/docs/MultiSite.md
T
LeoVasanko af6d7e3a3f Instance-global OIDC provider; per-domain auth hosts with shared-host resolution
- DB.oidc is a single OIDC (one key, one client set); hosts are issuer
  aliases. OIDCCode drops its rp_id field; client CRUD is not keyed by
  domain.
- No cross-domain auth-host fallback: a domain without its own auth host
  uses its own hosts; several domains may share one auth host (nested
  rp-ids) with deterministic best-suffix resolution.
- '*' origin shorthand expands to '*.{rp-id}'; legacy wildcards convert
  as-is; related origins may point at/inside another domain's rp-id.
- Admin UI and docs updated to match.
2026-09-07 06:10:08 +00:00

29 KiB

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/<uuid>/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.

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=<the pre-validated 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://<rp-id>/.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

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 <rp-id>.paskiadb database (§10) to paskia.kantadb. With several legacy candidates, the positional rp-id selects <rp-id>.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://<session.host>).
  • 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@<timestamp>.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 <name>.converted-bak. A lone candidate converts without options; with several candidates a positional rp-id selects <rp-id>.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.