- 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.
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.
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=<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:
originsandrelatedare separate fields. An in-domain origin (rp-id or subdomain) is valid unless the domain'soriginsallow-list is set, in which case it must be listed there. An origin on another domain is valid only when listed in the domain'srelated— explicit related listing is the trust boundary. GET /.well-known/webauthnon 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.comdoes not followapp2.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.commay authorize a session forapp2.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 oftruemeans presence only; an object carries extra properties (currently justauth_host). Ordering carries no meaning — display order is a UI affair. - An empty
originsdict 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 (*.localhostor 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:
originsentries must be within the rp-id domain.relatedentries 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 inorigins, in-domain inrelated) 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]— createspaskia.kantadbin CWD and seeds it:rp-id(positional, defaultlocalhost) andrp-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 intoConfig.listen(process-global).- Seeds the admin user + registration reset link and prints the link.
Refuses to run if an unconverted legacy
*.paskiadbis present (paskia migrateconverts 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>.paskiadbdatabase (§10) topaskia.kantadb. With several legacy candidates, the positional rp-id selects<rp-id>.paskiadbby 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 storedConfig.listen, never persisted). Startup: openpaskia.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-domainoriginsentry 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 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 domain registry is built in the FastAPI lifespan after
kanta.open(), fromdb.data().config.domains— domain data does not travel throughPASKIA_CONFIG. Per-domainsite_url/site_pathare computed at registry-build time (priority: auth host > exact rp-id origin key > first concrete origin key >PASKIA_VITE_URLfor the localhost domain >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 domain 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 domain'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-domain 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-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 combinedConfigand 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 thecurrent_domaincontextvar +scope["state"]["domain"]. - 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 domain from the Origin hostname — including related-origin hostnames;
- the connection
Hostmust 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; validate_originruns endpoint-side against the origin domain'sPasskey(post-accept JSON errors preserved);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.jsbuilds the WS URL from settings). - Settings (
ApiSettings) exposesauth_hostandown_auth_hostwith 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
CookieCodecarriesrp_id, verified at redemption — defense in depth.OIDCCodedoes 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 bindsCookieCodeto the host; the rp_id check complements it.
7.2 Remote authentication — cross-domain permits allowed
RemoteAuthRequest.rp_idrecords 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.hostoverride 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.oidcis a singleOIDC— one signing key (oidc.keyin 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.pykey 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. 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 may be an auth host, not the authorize/discovery host the RP validates against); andrp_id— the owning domain, kept for display and diagnostics.- backchannel logout runs without request context and uses
session.issuerasiss(falling back tohttps://<session.host>).
- backchannel logout runs without request context and uses
- Admin OIDC-client CRUD operates on the instance-global
OIDCentry. - Permission
domainvalidation 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, effectiveauth_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/andPATCH/DELETE /auth/api/admin/domains/{rp_id}. Writes require recent authentication (5 minutes).- Create:
rp_id+ optionalrp_name(defaults to the rp-id),originsandrelatedobjects mirroring the stored shape (§3.1); full §3.1 validation (cap, cross-domain collisions); registry rebuilt immediately, including the domain'sPasskeyinstance. The OIDC provider is instance-global and unaffected by domain writes. The auth host is marked insideorigins({"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/webauthndocument the canonical rp-id must publish and a warning when there are more than 5. Marking*.example.comas the auth host creates a concreteauth.example.comentry instead.
- 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- 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 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 a positional 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-domain 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, sanitized best-effort (§3.2) so startup never fails on config content; per-domainPasskeyinstances 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_notifyfire-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_domainis set for requests; admin domain writes rebuild the registry.
12. Development
scripts/devserver.py: bootstraps via one-shotpaskia initper rp-id when no database exists (rp-name for the first domain), then runs plainpaskiaserve. Caddy dev origins iterate all bootstrap rp-ids.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 localhostandpaskia init test.localhostin the test-data dir (which doubles as the server CWD) and serves;e2e/tests/50-multidomain.spec.tsexercises 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:adminis deployment-wide. Permissiondomainhost-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.keypath 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.