Docs: rewrite MultiSite.md from the webadmin perspective; refresh oidc.md
MultiSite.md now describes the shipped feature for administrators — managing domains in the admin panel, sign-in sites, the auth host, related origins, cross-domain sign-in, OIDC discovery, CLI and 1.x upgrade notes — instead of the internal design narrative. oidc.md: realms -> domains, instance-global provider, current auth-code shapes.
This commit is contained in:
+175
-543
@@ -1,543 +1,175 @@
|
||||
# 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: `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 <domain>" — 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
|
||||
|
||||
```python
|
||||
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.
|
||||
# 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.
|
||||
|
||||
## 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:
|
||||
|
||||
- **Empty list (the default):** the domain and all its subdomains may
|
||||
sign in, on any scheme. This suits most deployments; the domain dialog
|
||||
shows the default as a `*` placeholder row.
|
||||
- **Once you add entries, the list becomes an allow-list** — only listed
|
||||
sites may sign in:
|
||||
- `app.company.com` — exactly this host, https only.
|
||||
- `*.company.com` — the base domain and all its subdomains, https only.
|
||||
Under `localhost`, wildcards match any scheme and any port, as a
|
||||
development convenience.
|
||||
- `http://localhost:8080` — a full origin with scheme, for non-https
|
||||
exceptions.
|
||||
- `*` — shorthand for `*.{domain}`: the whole domain over https.
|
||||
|
||||
Entries must be inside the domain. An entry on a different domain name
|
||||
automatically becomes a related origin (🔗) instead — see below.
|
||||
|
||||
## 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`).
|
||||
- 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.
|
||||
|
||||
Reference in New Issue
Block a user