Serve never converts databases: with no paskia.kantadb it points at 'paskia init', or at 'paskia migrate' when legacy *.paskiadb candidates exist. migrate converts a lone candidate, or the one named by --rp-id when several exist; the rest stay in place. devserver fails fast with the same hint. gitignore covers paskia.kantadb and *.converted-bak.
25 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-realm 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 realm 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 realm. The administrative instance is the whole process: global users/orgs, N realms.
1. What is global vs. per-realm
Global (single instance, shared across realms):
| 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; carry rp-id fields |
Per-realm (registry, keyed by rp-id):
| Data | Notes |
|---|---|
rp_id, rp_name, origins, auth_host |
stored combined Config |
Passkey instance |
per rp-id; ceremonies verify against the origin realm's rp-id |
site_url/site_path |
runtime derivation, per realm |
| OIDC provider (keys, clients) | per rp-id — each realm is an independent issuer |
db.data() is a plain global singleton. A contextvar is needed only for
the current realm (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 realm 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: realm 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 realm's allow-list.
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: an origin is valid if it is in the rp-id subtree or explicitly listed in the realm's configured origins. Explicit listing is the trust boundary.
GET /.well-known/webauthnon the canonical rp-id host serves{"origins": [...]}from the realm's configured non-subdomain origins (404 when there are none).- Dispatch resolution treats a Host matching a configured related-origin
hostname as belonging to that origin's realm (exact match only —
www.app2.comdoes not followapp2.com).
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 realm dialog links to 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 realm of the admin application. Config validation enforces a cap (default 5) on related origins per realm.
Re-enrollment note: passkeys never move between rp-ids (WebAuthn-enforced). A host family that first deploys separate realms (2.B) and later consolidates to Related Origins re-enrolls: authenticate against the old realm (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 realms under one administrative instance
For domains that should not share an rp-id: rp-id is a first-class
object (realm), not an instance attribute. Users are global identities;
credentials carry rp_id:
Instance
├── Orgs / Roles / Users (global)
└── Realms
├── 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 realm 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-realm permits are allowed: a device authenticated at
company.commay authorize a session forapp2.com; the request's realm is recorded and shown to the approver; the target host is registry-validated. - Opportunistic local enrollment: after a cross-realm remote login,
the profile view offers "Add a passkey for " — registration runs
locally under the new realm'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 realms — passkey per realm |
| User lacks a credential for the current realm | 2.C remote authorization, then enroll locally |
3. Configuration model
3.1 Stored config
class RealmConfig(msgspec.Struct, omit_defaults=True):
rp_id: str
rp_name: str | None = None
auth_host: str | None = None # this realm's dedicated auth host
origins: list[str] | None = None # subdomain origins AND related origins (§2.A)
class Config(msgspec.Struct, omit_defaults=True):
realms: list[RealmConfig] # at least one; first entry is the default realm
listen: list[str] | None = None # process-global
- "At least one realm" is enforced by validation (not expressible in msgspec).
- The first entry is the default realm, used only where a default is genuinely needed (bootstrap reset-link URL, startup box ordering, master-admin entry point) — never for dispatch.
- Origin validation: each configured origin is either in the rp-id subtree (classic) or an explicit related origin. Related origins are counted and capped (default 5) and must not collide with another realm's rp-id/auth-host/related origins. These rules are enforced both at startup and at admin write time. Origins are never implicitly cross-domain.
3.2 CLI: bootstrap (paskia init) vs. serve (paskia)
The CLI is split so that realm options exist only at bootstrap time — they can never mix with runtime configuration of an already-configured instance:
paskia init— createspaskia.kantadbin CWD and seeds it:--rp-id: repeatable/comma-separated, default["localhost"]. Multiple values create multiple realms at once (useful for devserver/e2e); the first is the default realm.--rp-name: applies to the default realm. Its purpose is that the very first admin registration ceremony already shows the correct RP name; afterwards rp-names are edited via the admin interface.--auth-host,--origin: apply to the default realm. Further realms' hosts are configured via the admin interface.--listen: stored intoConfig.listen(process-global).- Seeds the admin user + registration reset link (link URL from the
default realm) and prints the link. Refuses to run if
paskia.kantadbalready exists, or if an unconverted legacy*.paskiadbis present (paskia migrateconverts it first).
paskia migrate— converts a legacy<rp-id>.paskiadbdatabase (§10) topaskia.kantadb. With several legacy candidates,--rp-idselects<rp-id>.paskiadbby name; the others are left in place.paskia— serve. Takes no realm options; only--listen(per-run override of storedConfig.listen, never persisted). Startup: openpaskia.kantadb→ validate the stored realm set cross-realm (rp-ids distinct; auth hosts distinct from each other and from every rp-id; related origins capped and collision-free) → build the realm registry → serve. The serve command never converts databases: with nopaskia.kantadb, the startup error points atpaskia init, or atpaskia migratewhen legacy*.paskiadbcandidates 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 realm registry is built in the FastAPI lifespan after
kanta.open(), fromdb.data().config.realms— realm data does not travel throughPASKIA_CONFIG. Per-realmsite_url/site_pathare computed at registry-build time (priority: auth_host > origins[0] >PASKIA_VITE_URLfor the localhost realm >http://localhost:port>https://rp-id), using the effective listen endpoints for the localhost fallback. PASKIA_CONFIGcarries only process-global serve parameters (the effective listen endpoints) so the derivation inside the server process can resolve the localhost-port fallback.- Admin realm writes persist the combined
Configand rebuild the registry in place, so dispatch sees auth_host and related-origin changes immediately.
4. Credentials carry an rp-id
Credential.rp_id: stris stamped at registration from the ceremony's rp-id. With Related Origins the stamp is always the realm's canonical rp-id regardless of which origin the ceremony ran on — the credential genuinely is acompany.compasskey.authenticate_chatfilters the raw_id scan byc.rp_id == ceremony rp-id— prevents wrong error semantics and a cross-realm oracle ("no credential" vs "verification failed" would leak which rp-id a credential belongs to).exclude_credentials(registration) and reauthallow_credentialsare filtered by the ceremony's rp-id (User.credential_ids_for(rp_id)) — users are global, so their credential id sets are cross-realm.- Cascades are uuid-keyed; deleting a user removes their passkeys across all realms (correct: users are global).
5. Dispatch and realm context
paskia/realms.py:Realm { config, passkey, ... }and a registry keyed by rp-id, built in the lifespan from the stored combinedConfigand rebuilt on admin realm writes. No per-realm 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 thecurrent_realmcontextvar +scope["state"]["realm"]. - WebSocket resolution follows the
Origin, not the connectionHost: 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:- the middleware resolves the origin realm from the Origin hostname — including related-origin hostnames;
- the connection
Hostmust be a valid WS endpoint for that realm: the realm's effective auth host (§6), or the origin host itself when the realm has no auth host at all — else pre-accept reject; validate_originruns endpoint-side against the origin realm'sPasskey(post-accept JSON errors preserved);current_realm= origin realm for the WS handler's duration. The ceremony rp-id is always the origin realm's rp-id — exactly what the browser enforces for the page's origin under both classic and related-origin rules.
6. Auth host: per-realm values with global fallback
A realm without its own auth host falls back to the first configured auth host (realm-list order):
effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or None
Own vs. effective auth host are distinguished everywhere:
- Follow the realm's OWN auth host: UI mode detection (minimal-
profile decision in
App.vue, viaown_auth_hostin settings), the redirect middleware,ui_base_path, andreset_link_url. A realm with no own auth host keeps its full UI on its own hosts — otherwise reset/registration pages onapp2.comwould redirect toauth.company.com, where the ceremony's Origin resolves the owner realm and stamps the wrongCredential.rp_id, breaking 2.B onboarding and 2.C local enrollment. - Follow the EFFECTIVE auth host: WS endpoint selection only
(
passkey.jsbuilds the WS URL from settings). The fallback auth host serves WS + restricted APIs for foreign realms. - Settings (
ApiSettings) exposes both fields (own_auth_hostalongside the effectiveauth_host) so the frontend makes the mode decision correctly. - Root mode (
site_path == "/") applies only on a realm's own auth host, so it is never ambiguous: a realm's UI lives on its own hosts; the fallback auth host serves the owner realm's UI plus WS for the rest.
7. Login flows and in-memory stores
7.1 Auth codes
OIDCCodeandCookieCodecarryrp_id, verified at redemption — defense in depth.- Stamping source matters: codes are stamped with the realm of the
session they will redeem — not naively with the current realm at
issuance. Remote-completion codes are minted inside the permit
handler (permitting realm'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 realm would break every cross-realm remote login. Registration-flow and OIDC codes stamp from the current realm (issue and redeem sides always match). The host re-check at set-session independently bindsCookieCodeto the host; the rp_id check complements it.
7.2 Remote authentication — cross-realm permits allowed
RemoteAuthRequest.rp_idrecords the requesting device's origin realm.- Permit side may differ from the request side (mechanism 2.C): the
permitting device authenticates with its realm's passkey, and the
session_host=request.hostoverride creates the session for the requesting host. The override must resolve to a configured realm (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-realm remote login is how the product works (users are global).
- Exchange codes stay single-use, 60s, host-bound at redemption.
8. OIDC: per-realm providers in one DB
DB.oidcisdict[str, OIDC]keyed by rp-id. Each realm is an independent provider: own signing key (oidc.<rp-id>.keyin the transaction log shape), own clients.- The
util/oidjwt.pykey cache is keyed by rp-id. - Issuer stays per-request-Host — each realm host is an issuer alias
sharing the realm's key.
Sessioncarries 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); andrp_id— the owning realm, needed by every path that runs without request context:- backchannel logout uses
session.rp_idto select the realm's key andsession.issuerasiss; - session listings resolve the client under the session's own realm, not the request's.
- backchannel logout uses
- Admin OIDC-client CRUD operates on the current realm's
OIDCentry. - Permission
domainvalidation accepts a subdomain of any configured rp-id, any related-origin hostname, or any realm's client UUID.
9. Admin API and UI
GET /auth/api/settings: per-request-Host realm values (rp_id, rp_name, effectiveauth_host,own_auth_host, site URLs).- Realm management (master admin only,
auth:admin) — how rp-ids are managed after bootstrap:GET/POST /auth/api/admin/realms/andPATCH/DELETE /auth/api/admin/realms/{rp_id}. Writes require recent authentication (5 minutes).- Create:
rp_id+ optionalrp_name(defaults to the rp-id),auth_host,origins; full §3.1 validation (cap, cross-realm collisions); registry rebuilt immediately, including the realm'sPasskeyinstance and OIDC provider entry. - Update: same validation against the would-be combined config. Changing a realm'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 realm and while any credential carries the realm's rp-id (re-enroll or delete those credentials first); cascades nothing else (users/orgs are global).
- The admin UI has a Realms section with a table (rp-id, name,
effective auth host, origin count), per-row edit/delete and an
add-realm dialog. The dialog's connectivity probe fetches
<origin>/auth/api/settingsand compares the returned rp-id against the edited realm — a related origin served by this instance answers with the realm's rp_id. Connectivity/mismatch results are warnings; only malformed entries and an auth host outside the rp-id domain block saving.
- Credential listings:
Credential.rp_idserializes automatically into user-info and admin user detail responses; the frontend shows an rp-id badge only whencredential.rp_id !== settings.rp_id— single- realm installs never show a badge. - Enrollment prompt (2.C): when the user has no passkey for the current realm, the profile view offers to add one (a fresh remote-auth session satisfies the recent-auth requirement of registration).
- Bootstrap/reset links use the default realm's URL.
- Bootstrap check: the "admin has no credentials" startup check tests for an admin credential under the default realm's rp-id — with global users an admin may have passkeys only under another realm, and the printed link must still be usable for the default realm.
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 migrateconverts a legacy*.paskiadbdatabase — a directory containingmain.db, or a legacy single-file database — intopaskia.kantadb:main.db(or the single file) becomespaskia.kantadb,users/becomespaskia.data/users/, and the old directory is renamed aside to<name>.converted-bak. A lone candidate converts without options; with several candidates--rp-id <rp-id>selects<rp-id>.paskiadbby name and the rest are left in place (e.g. a*.bak.paskiadbbackup 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-realm lines.
11. Lifespan and background tasks
- One
Kantaforpaskia.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 initorpaskia migrate; the serve command never bootstraps. - The registry is built from the stored
Configafter open; per-realmPasskeyinstances constructed (each realm's origins validated at startup — fail-fast, including related-origin cap checks). - The admin-credential check runs at serve startup and reprints a usable registration link when the admin lacks a credential under the default realm (§9).
oidc_notifyfire-and-forget tasks need no realm context for DB access (global DB); issuer comes from the session (§8).- The dispatch middleware is the only place
current_realmis set for requests; admin realm writes rebuild the registry.
12. Development
scripts/devserver.py: bootstraps via one-shotpaskia initwhen no database exists (multi--rp-id, and--rp-name/--auth-host/--originfor the default realm), then runs plainpaskiaserve. Caddy dev origins iterate all bootstrap rp-ids plus the auth host and explicit origins.PASKIA_AUTH_HOST(consumed byfrontend/vite.config.js) is a comma-separated list of bare hostnames; the vite dev proxy forwards/.well-known/openid-configurationand/.well-known/webauthnto the backend.- The example
caddy/auth/setupsnippet forwards both well-known paths to paskia so a static/.well-known/*handler does not shadow them. - E2E:
e2e/tests/global-setup.tsrunspaskia init --rp-id localhost,test.localhostin the test-data dir (which doubles as the server CWD) and serves;e2e/tests/50-multirealm.spec.tsexercises host dispatch, the well-known endpoint via the admin realm API, and a cross-realm 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_realms.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 realm and only lists configured origins. All origins sharing an rp-id share one security boundary — do not mix trust levels within a realm.
- Realm administration: realm create/update/delete is gated on
auth:admin— deployment-wide by design; validation runs on every write, not just at startup. - Passkeys: rp-id binding browser-enforced and server-recorded; ceremonies, credential scans, exclude/allow lists all scoped to the origin realm's rp-id. No cross-realm oracle in the scan.
- Sessions: host-bound, exact match. Cross-realm sessions arise only via (a) a ceremony at the origin realm (incl. related origins), or (b) a remote permit by a device holding a valid session at its own realm, with registry-validated target host.
- Users/orgs global: deleting a user/org cascades across all realms —
intended.
auth:adminis deployment-wide. Permissiondomainhost-scopes effectiveness per host. - Cross-realm permit transparency: requesting realm/host shown to the approver; both sides logged.
- Secret hygiene in logs: the OIDC signing-key censoring matches the
oidc.<rp-id>.keypath shape, so realm keys never print in plaintext in the JSONL transaction log. - OIDC: per-realm keys/issuers; logout tokens carry the stored issuer.