Files
paskia/docs/MultiSite.md
T
LeoVasanko 84985501f5 MultiSite: one instance serves authentication across many domains (#4)
- Serve multiple domains (RP IDs) from one instance: host-based dispatch,
  per-domain credentials and sessions, domains managed at runtime in the
  admin UI — previously one RP per instance
- Cross-domain sign-in via Related Origin Requests: per-domain related-origins
  list with a served .well-known/webauthn document
- Explicit per-domain origin lists with shell-glob wildcards (**. for apex +
  any subdomain depth, *. for one level), editable in the admin UI with
  validation and self-lockout guards
- Per-domain auth hosts: the account/admin UI can live on a different host
  per domain, no longer confined to subdomains of a single RP
- CLI: 'paskia init <rp-id [rp-name]' initializes or adds a domain to an
  existing database; 'paskia migrate' converts legacy databases

BREAKING CHANGES (v2.0):
- Database schema: config is now per-domain and credentials/sessions carry
  an rp_id — existing databases must be converted with 'paskia migrate'
- Origins are now explicit: main implicitly allowed every subdomain of the
  RP; configure '**.' origins to reproduce that behavior
- CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are
  replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
2026-09-07 22:14:42 +00:00

206 lines
9.4 KiB
Markdown

# Multiple Domains (Multi-Site)
One Paskia instance on one port serves several domains from a single
database. Typical uses:
- `app1.company.com` and `app2.com` cannot share passkeys, but user
management should stay under one roof.
- A few alternative brand names should accept the _same_ passkeys.
## Shared vs. per-domain
**Shared across all domains:** user accounts, organizations, roles,
permissions, and OIDC clients. A user account exists once and can sign in
on every domain.
**Per domain:** passkeys and sessions.
- A passkey is registered to one domain name (enforced by the browser): a
passkey created for `company.com` works on `company.com` and its
subdomains, never on an unrelated name — unless that name is configured
as a [related origin](#related-origins-sharing-passkeys-across-domain-names).
A user active on two domains simply holds one passkey per domain.
- A session is bound to the exact host that issued it.
## The simplest case: several sites on one domain
Multi-site does not require several domains. Sites under one name —
`app1.company.com`, `app2.company.com` and so on — share the domain
`company.com`: one passkey works on all of them (WebAuthn natively allows
the domain and its subdomains), and the default `**.company.com` origin
entry already lets every one of them sign in. Add explicit entries only
to restrict which sites may sign in, and mark an auth host (see below)
if you want sign-in centralized on one site.
## Managing domains
The admin panel's **Domains** section (master admins only) lists every
domain with its allowed origins. There you can add, edit and delete
domains; changes apply immediately without a restart and require a recent
sign-in (within 5 minutes). The 🔑 and 🔗 markers in the origins column
identify the auth host and related origins (below).
A domain consists of:
- **Domain (rp-id)** — the domain name passkeys belong to, e.g.
`company.com`. ("rp-id" is the WebAuthn term; read it as "domain name".)
It cannot be changed after creation, because existing passkeys are bound
to it — delete and re-create the domain instead.
- **Display name (rp-name)** — branding shown in sign-in dialogs and
registered with passkeys.
- **Allowed origins** — the sites where this domain's passkeys may sign
in, plus any related origins.
Deleting a domain is refused while any passkey is still registered to it,
when it is the last remaining domain, or when it is the domain you are
currently using. Users and organizations are never deleted with a domain
— they are shared. An edit that would lock you out (your current site
could no longer run passkey ceremonies for that domain) is refused as
well.
## Allowed origins (sign-in sites)
This list controls which sites may sign in with the domain's passkeys.
Everything is explicit: an empty list allows nothing of the domain itself
(related origins, below, still work — a domain can in principle run
entirely on related origins). A new domain starts with one entry,
`**.{domain}`, which suits most deployments.
Entry forms:
- `**.company.com` — the domain itself and its subdomains at any depth,
https only. The default entry of a new domain.
- `*.company.com` — exactly one subdomain level: `app.company.com` yes,
but neither the apex `company.com` nor `a.b.company.com`. Use this when
the apex or deeper subdomains should not serve sign-ins.
- `app.company.com` — exactly this host, https only.
- `http://localhost:8080` — a full origin with scheme, for non-https
exceptions.
Under `localhost`, both wildcard forms match any scheme and any port, as
a development convenience. An entry on a different domain name
automatically becomes a related origin (🔗) instead — see below.
### Wildcard syntax
Wildcards follow the shell-glob convention — the same one permission
scopes use (`*` within a segment, `**` across segments, see
[the perm argument](api/perm.md)): `**` spans any number of hostname
labels including none, `*` spans exactly one. Wildcards must stay within
the domain, and plain `*` is not accepted — it would suggest "anything
goes".
Conventions elsewhere differ: DNS, TLS and nginx take `*.example.com` to
mean subdomains only (TLS: exactly one level), while browser-extension
match patterns take it as apex plus any depth. The `*`/`**` split
sidesteps that ambiguity, and the less obvious forms `*example.com` and
`.example.com` remain unsupported on purpose.
## The auth host (🔑)
Marking one allowed origin as the **auth host** (row menu ⋮ → "Set as
auth host") centralizes the account and admin interface on that site,
e.g. `auth.company.com`:
- On the auth host the web UI is served at the site root (`/` instead of
`/auth/`), and all passkey operations for the domain happen there.
- The domain's other sites show only a minimal profile page at `/auth/`
with logout and a link to the full profile; their sign-in dialogs talk
to the auth host behind the scenes. Every sign-in site still needs to
be listed in (or covered by) the allowed origins.
The auth host is strictly per-domain — domains never borrow each other's
auth host. To consolidate several domains on one sign-in site, that site
must lie under each domain's name (nested domains, e.g. domains
`company.com` and `auth.company.com`) and be marked on each of them.
## Related origins: sharing passkeys across domain names
Sometimes a few different domain names should accept the _same_ passkeys
— for example after a rebrand, when `app2.com` should keep working with
existing `company.com` passkeys. Adding `app2.com` to `company.com`'s
allowed origins makes it a **related origin**: browsers then let
`app2.com` use `company.com` passkeys directly — no redirects, no
cross-domain cookies. (This uses the WebAuthn "Related Origin Requests"
mechanism, which is why the UI also says ROR.)
Rules:
- At most **5 related origins per domain** — a browser limit. This is for
a small family of equally trusted sites, not for hundreds of customer
domains; use separate domains for those.
- Exact hosts only — no wildcards — and always outside the domain's own
name.
- The browser verifies the setup against
`https://<domain>/.well-known/webauthn`. Paskia serves that document
automatically when it hosts the domain's main site; if the main site is
hosted elsewhere, copy the JSON document shown in the domain dialog and
publish it there. The dialog also checks the published document for
you.
- A related origin shares the domain's security boundary completely — do
not mix trust levels within one domain.
- When several domains could claim a host: a host that _is_ a configured
domain name always serves its own domain; otherwise an explicit related
origin listing wins over merely falling under another domain's name.
Passkeys never move between domains. If you later consolidate separate
domains onto one, users re-enroll: sign in once via remote authorization
(below), then register a new passkey for the common domain from the
profile page.
## Signing in across domains
Users exist once, but need a passkey per domain. Two mechanisms smooth
this over:
- **Remote authorization:** a user without a passkey for the current
domain can start a login request and approve it from any device already
signed in — on _any_ domain of the instance. The approval screen shows
which site is requesting access.
- **Enroll on the spot:** when the signed-in user has no passkey for the
current domain, the profile page offers "Add Passkey for {domain}", so
everyday sign-in stays local from then on.
## OIDC with multiple domains
OIDC clients are shared by the whole instance: register a client once and
it works through every domain. Each domain serves its own discovery URL
(`https://<host>/.well-known/openid-configuration`), listed in the admin
OIDC client view. Have each app pick **one** discovery URL and use it
consistently, so its tokens always validate against the same issuer.
## Command line
The admin panel covers all domain management after bootstrap. On the
command line:
- `paskia init [domain] [name]` — creates the database `paskia.kantadb`
with the first domain. Run again with an existing database to add
another domain (or update a display name).
- `paskia migrate [domain]` — converts a legacy 1.x `{domain}.paskiadb`
database to `paskia.kantadb`; see below.
- `paskia` — serves all configured domains; takes no domain options, only
`--listen` as a per-run override.
## Upgrading from 1.x
2.0 intentionally changes the on-disk layout and the domain configuration
model:
- The database is the single file **`paskia.kantadb`** in the working
directory; user files (avatars) live in **`paskia.data/users/`**.
`paskia migrate` performs the conversion and renames the old database
aside to `{domain}.paskiadb.converted-bak`. With several legacy
databases, the positional argument selects one by name. Legacy wildcard
origins convert as-is (https only, except any scheme and port under
`localhost`); a legacy database without configured origins — where that
meant the whole domain was allowed — gets an explicit `**.{domain}`
entry.
- Origins, auth hosts and related origins are no longer environment
settings — they live in the database and are managed in the admin
panel's Domains section. `PASKIA_AUTH_HOST` remains only as a
development-server (vite) setting.
- OIDC becomes instance-global: one signing key and one client set,
reachable through every domain's discovery URL (previously each rp-id
had its own). Existing clients keep working through any domain.