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
This commit is contained in:
+20
-4
@@ -72,8 +72,14 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access
|
||||
| PATCH | /auth/api/admin/oidc-clients/{uuid} | Update OIDC client | 200/401/403 |
|
||||
| PATCH | /auth/api/admin/oidc-clients/{uuid}/reset-secret | Reset client secret | 200/401/403 |
|
||||
| DELETE | /auth/api/admin/oidc-clients/{uuid} | Delete OIDC client | 200/401/403 |
|
||||
| GET | /auth/api/admin/server-config/ | Get server config | 200/401/403 |
|
||||
| PATCH | /auth/api/admin/server-config/ | Update server config | 200/401/403 |
|
||||
| GET | /auth/api/admin/domains/ | List domains (rp-ids) with derived URLs | 200/401/403 |
|
||||
| POST | /auth/api/admin/domains/ | Create domain `{rp_id, rp_name?, origins?, related?}` | 200/400/401/403 |
|
||||
| PATCH | /auth/api/admin/domains/{rp_id} | Update domain rp_name/origins/related | 200/400/401/403 |
|
||||
| DELETE | /auth/api/admin/domains/{rp_id} | Delete domain (refused while credentials remain) | 200/400/401/403 |
|
||||
|
||||
Domain endpoints require the `auth:admin` permission; writes additionally require recent authentication (5 minutes). Changes are validated cross-domain and apply immediately.
|
||||
|
||||
`origins` is a single object keyed by all of the domain's sign-in sites. Entries *within* the rp-id domain are in-domain sites: bare hosts, wildcards under the rp-id following the shell-glob convention (`**.example.com` covers the apex and subdomains at any depth, `*.example.com` exactly one subdomain level — https only, any scheme and port under localhost; plain `*` is not accepted), or full origins when not https. Entries *outside* the rp-id domain are related origins that may assert this domain's rp-id (WebAuthn Related Origin Requests, max 5, no wildcards); those are published at `/.well-known/webauthn` on the rp-id host. An empty object allows nothing of the domain itself. A value of `true` marks presence; `{"auth_host": true}` additionally marks an in-domain entry as the domain's authentication host.
|
||||
|
||||
### WebSockets: /auth/ws/*
|
||||
|
||||
@@ -86,7 +92,9 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access
|
||||
|
||||
These are for internal use only, but are documented here because they are the core piece in all passkey operations.
|
||||
|
||||
### Auth host mode (--auth-host)
|
||||
### Auth host mode (dedicated auth site)
|
||||
|
||||
A domain may configure a dedicated authentication host (auth-host, a subdomain of the rp-id) via the Domains admin panel.
|
||||
|
||||
#### On the auth host:
|
||||
- The Web UI is served at site root instead of /auth/* (that redirects to root paths)
|
||||
@@ -98,4 +106,12 @@ These are for internal use only, but are documented here because they are the co
|
||||
- /auth/api/* is served normally.
|
||||
- /auth/api/user/*, /auth/api/admin/*, and /auth/ws/* don't exist.
|
||||
|
||||
The WebSocket connections are directed to auth host, and must have an allowed origin corresponding to the host where the user is logging in, that the session is tied with.
|
||||
The WebSocket connections are directed to the auth host, and must have an allowed origin corresponding to the host where the user is logging in, that the session is tied with.
|
||||
|
||||
#### Auth hosts and other domains
|
||||
|
||||
Auth hosts are strictly per-domain: a domain without its own auth host uses its own hosts for the WebSocket flows, and `/auth/api/settings` reports `auth_host` (and the identical `own_auth_host`) as null. One domain's auth host never serves another domain implicitly. To consolidate logins on one host, mark that host as the auth host on each domain that should use it (possible when the host lies under each domain's rp-id, i.e. nested rp-ids); dispatch resolves a shared host to the best-matching (longest rp-id suffix) domain.
|
||||
|
||||
### Related Origin Requests: /.well-known/webauthn
|
||||
|
||||
`GET /.well-known/webauthn` returns `{"origins": [...]}` listing the domain's related origins (configured origins on domains unrelated to the rp-id), per WebAuthn Related Origin Requests. Browsers fetch this from the rp-id domain when an unrelated origin runs a ceremony with this domain's rp-id. Returns 404 when the domain has no related origins. If the rp-id's main site is hosted elsewhere, serve the JSON statically there (copy it from this instance).
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# 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.
|
||||
@@ -173,4 +173,4 @@ The auth check then always returns 204 (except reauth with `max_age`, which stil
|
||||
|
||||
- The auth request is `GET` by default. Since the `forward-auth` plugin does not forward the request body unless `request_method` is set to `POST`, the default `GET` is the right choice for Paskia.
|
||||
- Hop-by-hop headers are handled by APISIX when it builds the auth request, so no extra configuration is needed for `Connection`/`Upgrade`.
|
||||
- If Paskia is running on a different host, replace `localhost:4401` with the Paskia service address. For a dedicated authentication host (`--auth-host`), route `auth.example.com` to Paskia instead of `/auth/`.
|
||||
- If Paskia is running on a different host, replace `localhost:4401` with the Paskia service address. For a dedicated authentication host (the domain's auth-host setting), route `auth.example.com` to Paskia instead of `/auth/`.
|
||||
|
||||
+1
-1
@@ -81,7 +81,7 @@ auth.example.com {
|
||||
}
|
||||
```
|
||||
|
||||
Remember to specify `paskia serve --auth-host auth.example.com` to restrict the authentication services to this domain.
|
||||
Remember to set the auth host for the domain in the admin panel's Domains section to restrict the authentication services to this domain.
|
||||
|
||||
Note that we still reserve `/auth/` on each site for logout page and any APIs your application may require, while full user profile and global options are only available on the auth host.
|
||||
|
||||
|
||||
+1
-1
@@ -161,7 +161,7 @@ See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) fo
|
||||
|
||||
## WebSocket support for `/auth/`
|
||||
|
||||
If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia cluster and you do not need the `/auth/` bypass above. Otherwise, make sure the `/auth/` route keeps the `Upgrade` and `Connection` headers so passkey WebSocket endpoints work. The default Envoy router handles `Upgrade` headers when the client requests them.
|
||||
If you use a dedicated authentication host (the domain's auth-host setting), route `auth.example.com` to the Paskia cluster and you do not need the `/auth/` bypass above. Otherwise, make sure the `/auth/` route keeps the `Upgrade` and `Connection` headers so passkey WebSocket endpoints work. The default Envoy router handles `Upgrade` headers when the client requests them.
|
||||
|
||||
## Public access
|
||||
|
||||
|
||||
@@ -123,4 +123,4 @@ The `Remote-*` success-headers glob already copies the `Remote-Public` header th
|
||||
- The Lua script strips the request body from the auth subrequest, so Paskia's `/auth/api/forward` will only see the headers.
|
||||
- HAProxy variables are limited to alphanumeric characters, dots, and underscores, but the script already normalizes header names for you (e.g. `Remote-User` becomes `req.auth_response_header.remote_user`). The `Remote-*` glob pattern in the success-headers argument handles this automatically.
|
||||
- The auth backend must be reachable without TLS. If you need TLS to Paskia, run a local TCP forwarder or use HAProxy's Lua HTTP support directly (not covered by this script).
|
||||
- If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia backend and start Paskia with `--auth-host auth.example.com` instead of exposing `/auth/` on every site.
|
||||
- If you use a dedicated authentication host (the domain's auth-host setting), route `auth.example.com` to the Paskia backend instead of exposing `/auth/` on every site.
|
||||
|
||||
@@ -92,7 +92,7 @@ authResponseHeaders:
|
||||
|
||||
The `/auth/` router above forwards all authentication UI, API, and WebSocket traffic to Paskia. Because this router does **not** use the `paskia-auth` middleware, users can reach the login page and profile UI without being authenticated first. Traefik handles WebSocket upgrades automatically when the client requests them.
|
||||
|
||||
If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and start Paskia with `--auth-host auth.example.com`.
|
||||
If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and set the domain's auth host in the admin panel's Domains section.
|
||||
|
||||
## Adjusting requirements
|
||||
|
||||
|
||||
Reference in New Issue
Block a user