RealmConfig.origins is again purely an allow-list of sign-in sites within the realm's domain (unset = rp-id and all subdomains), restoring the restriction semantics the realm rework had silently turned into an always-open subtree. Cross-domain ROR origins move to their own RealmConfig.related_origins field — always additive, capped, validated to be outside the rp-id domain, and the sole source of the /.well-known/webauthn document. Admin API POST/PATCH accept related_origins; misfiled entries are rejected (cross-domain in origins, in-domain in related_origins). Admin UI: the realm dialog edits the two lists separately with end-user-oriented explanations (allowed sign-in sites vs. related domains + the well-known note); the Realms section intro explains the multi-domain model, and the table shows sign-in site and related domain counts.
498 lines
26 KiB
Markdown
498 lines
26 KiB
Markdown
# Multi-Site Support
|
|
|
|
One paskia process on one port serves multiple sites with **one combined
|
|
database**. The _administrative instance_ is separated from the _WebAuthn
|
|
RP_: organizations and users are global across rp-ids; rp-id is a
|
|
first-class per-realm object; passkeys remain tied to their rp-id
|
|
(WebAuthn-enforced); sessions remain host-bound. Motivating case:
|
|
`app1.company.com` and `app2.com` cannot share an rp-id, but user
|
|
management must be under single common controls.
|
|
|
|
Terminology: a **realm** is one rp-id with its associated hosts and
|
|
origins. A **site** is any host served by the instance; each host belongs
|
|
to exactly one realm. The _administrative instance_ is the whole process:
|
|
global users/orgs, N realms.
|
|
|
|
## 1. What is global vs. per-realm
|
|
|
|
**Global (single instance, shared across realms):**
|
|
|
|
| Data | Notes |
|
|
| ------------------------------- | -------------------------------------------------------- |
|
|
| Organizations, Roles, Users | one global collection |
|
|
| Permissions | `domain` field host-scopes effectiveness |
|
|
| Sessions | host-bound (`Session.host`, exact match) |
|
|
| Credentials/passkeys | global collection, each stamped with its `rp_id` |
|
|
| Reset tokens | user-bound; global |
|
|
| Avatars | `paskia.data/users/<uuid>/profile.webp` |
|
|
| Auth codes, remote-auth manager | in-memory; carry rp-id fields |
|
|
|
|
**Per-realm (registry, keyed by rp-id):**
|
|
|
|
| Data | Notes |
|
|
| -------------------------------------- | --------------------------------------------------------------- |
|
|
| `rp_id`, `rp_name`, origins, auth_host | stored combined `Config` |
|
|
| `Passkey` instance | per rp-id; ceremonies verify against the _origin realm's_ rp-id |
|
|
| `site_url`/`site_path` | runtime derivation, per realm |
|
|
| OIDC provider (keys, clients) | per rp-id — each realm is an independent issuer |
|
|
|
|
`db.data()` is a plain global singleton. A contextvar is needed only for
|
|
the **current realm** (passkey, config, OIDC view) — not for database
|
|
access.
|
|
|
|
The architectural rule: **authentication establishes identity, not
|
|
organization** — the requested hostname selects the org/permission
|
|
context after authentication (via `Permission.domain` host-scoping and
|
|
session host binding).
|
|
|
|
## 2. Login architecture: three composable mechanisms
|
|
|
|
The realm infrastructure is shared by three mechanisms, alternatives
|
|
_per deployment_ and composable within one instance.
|
|
|
|
### 2.A WebAuthn Related Origin Requests (trusted domain families)
|
|
|
|
WebAuthn Level 3 lets otherwise-unrelated domains share one rp-id: the
|
|
canonical RP publishes `/.well-known/webauthn` listing permitted origins,
|
|
and those origins may then run ceremonies with the common rp-id locally —
|
|
no redirects, no cross-domain cookies. Browser support is universal
|
|
(Firefox included).
|
|
|
|
Model: realm `company.com` with related origin `https://app2.com`. A page
|
|
on `app2.com` calls WebAuthn with `rpId: "company.com"`; the passkey is
|
|
scoped to `company.com`; `clientDataJSON.origin` is `https://app2.com`,
|
|
which the backend validates against the realm's 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_origins` are separate fields.
|
|
An in-domain origin (rp-id or subdomain) is valid unless the realm'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 realm's
|
|
`related_origins` — explicit related listing is the trust boundary.
|
|
- `GET /.well-known/webauthn` on the canonical rp-id host serves
|
|
`{"origins": [...]}` from the realm'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 realm (exact match only —
|
|
`www.app2.com` does not follow `app2.com`).
|
|
|
|
Deployment constraint: the **browser** fetches
|
|
`https://<rp-id>/.well-known/webauthn` from the canonical apex directly —
|
|
if paskia does not host the apex, publish the JSON there statically (the
|
|
admin realm dialog links to the document for copying).
|
|
|
|
Constraints (from the WebAuthn WG): implementations must support at least
|
|
**5 registrable origin labels** — this is for a small family of
|
|
same-trust domains, not hundreds of customer domains. Sharing an rp-id
|
|
merges the security boundary: a weakly protected marketing domain should
|
|
not share the realm of the admin application. Config validation enforces
|
|
a cap (default 5) on related origins per realm.
|
|
|
|
**Re-enrollment note**: passkeys never move between rp-ids
|
|
(WebAuthn-enforced). A host family that first deploys separate realms
|
|
(2.B) and later consolidates to Related Origins re-enrolls: authenticate
|
|
against the old realm (or via 2.C), register a new credential under the
|
|
common rp-id, retire the old one. The per-credential rp-id badge (§9)
|
|
makes this visible. There is no automated credential migration.
|
|
|
|
### 2.B Multiple rp-id realms under one administrative instance
|
|
|
|
For domains that should _not_ share an rp-id: rp-id is a first-class
|
|
object (realm), not an instance attribute. Users are global identities;
|
|
credentials carry `rp_id`:
|
|
|
|
```
|
|
Instance
|
|
├── Orgs / Roles / Users (global)
|
|
└── Realms
|
|
├── company.com (origins [...], credentials scoped by rp_id)
|
|
├── app2.com (origins [...], credentials scoped by rp_id)
|
|
└── customer.net (origins [...], credentials scoped by rp_id)
|
|
```
|
|
|
|
Alice can hold both a `company.com` and an `app2.com` passkey; sessions
|
|
stay host-only.
|
|
|
|
### 2.C Remote authorization + opportunistic local enrollment
|
|
|
|
For a realm where the user has no credential, the remote-login mechanism
|
|
provides a federation-style flow: unauthenticated device requests,
|
|
authenticated device permits, a short-lived **single-use opaque exchange
|
|
code** (60s `CookieCode`) is redeemed by the requesting host, which sets
|
|
its own host-only cookie. No shared cookies, no reusable tokens in URLs.
|
|
|
|
- Cross-realm permits are **allowed**: a device authenticated at
|
|
`company.com` may authorize a session for `app2.com`; the request's
|
|
realm is recorded and shown to the approver; the target host is
|
|
registry-validated.
|
|
- **Opportunistic local enrollment**: after a cross-realm remote login,
|
|
the profile view offers "Add a passkey for <realm>" — registration runs
|
|
locally under the new realm's rp-id, stamping `Credential.rp_id`. This
|
|
makes remote login primarily bootstrap/recovery, while everyday
|
|
authentication stays local.
|
|
|
|
### Policy summary (how deployments choose)
|
|
|
|
| Situation | Mechanism |
|
|
| -------------------------------------------------- | --------------------------------------------- |
|
|
| Few closely related, equally trusted brand domains | 2.A Related Origins — one passkey |
|
|
| Independent / customer / lower-trust domains | 2.B separate realms — passkey per realm |
|
|
| User lacks a credential for the current realm | 2.C remote authorization, then enroll locally |
|
|
|
|
## 3. Configuration model
|
|
|
|
### 3.1 Stored config
|
|
|
|
```python
|
|
class RealmConfig(msgspec.Struct, omit_defaults=True):
|
|
rp_id: str
|
|
rp_name: str | None = None
|
|
auth_host: str | None = None # this realm's dedicated auth host
|
|
origins: list[str] | None = None # allow-list of in-domain sign-in sites
|
|
related_origins: list[str] | None = None # cross-domain ROR origins (§2.A)
|
|
|
|
class Config(msgspec.Struct, omit_defaults=True):
|
|
realms: list[RealmConfig] # at least one; first entry is the default realm
|
|
listen: list[str] | None = None # process-global
|
|
```
|
|
|
|
- "At least one realm" is enforced by validation (not expressible in
|
|
msgspec).
|
|
- The first entry is the default realm, used only where a default is
|
|
genuinely needed (bootstrap reset-link URL, startup box ordering,
|
|
master-admin entry point) — never for dispatch.
|
|
- **Origin validation** — two separate concerns: `origins` entries must
|
|
be within the rp-id domain (an allow-list; unset = the rp-id and all
|
|
subdomains may authenticate). `related_origins` entries must be
|
|
outside it, are capped (default 5), and must not collide with another
|
|
realm's rp-id/auth-host/related origins nor fall inside another
|
|
realm's domain. Misfiled entries (cross-domain in `origins`, in-domain
|
|
in `related_origins`) are rejected. These rules are enforced both at
|
|
startup and at admin write time. Origins are never _implicitly_
|
|
cross-domain.
|
|
|
|
### 3.2 CLI: bootstrap (`paskia init`) vs. serve (`paskia`)
|
|
|
|
The CLI is split so that realm options exist only at bootstrap time —
|
|
they can never mix with runtime configuration of an already-configured
|
|
instance:
|
|
|
|
- **`paskia init`** — creates `paskia.kantadb` in CWD and seeds it:
|
|
- `--rp-id`: repeatable/comma-separated, default `["localhost"]`.
|
|
Multiple values create multiple realms at once (useful for
|
|
devserver/e2e); the **first is the default realm**.
|
|
- `--rp-name`: applies to the default realm. Its purpose is that the
|
|
very first admin registration ceremony already shows the correct RP
|
|
name; afterwards rp-names are edited via the admin interface.
|
|
- `--auth-host`, `--origin`: apply to the default realm. Further
|
|
realms' hosts are configured via the admin interface.
|
|
- `--listen`: stored into `Config.listen` (process-global).
|
|
- Seeds the admin user + registration reset link (link URL from the
|
|
default realm) and prints the link. Refuses to run if
|
|
`paskia.kantadb` already exists, or if an unconverted legacy
|
|
`*.paskiadb` is present (`paskia migrate` converts it first).
|
|
- **`paskia migrate`** — converts a legacy `<rp-id>.paskiadb` database
|
|
(§10) to `paskia.kantadb`. With several legacy candidates, `--rp-id`
|
|
selects `<rp-id>.paskiadb` by name; the others are left in place.
|
|
- **`paskia`** — serve. Takes **no realm options**; only `--listen`
|
|
(per-run override of stored `Config.listen`, never persisted). Startup:
|
|
open `paskia.kantadb` → validate the stored realm set cross-realm
|
|
(rp-ids distinct; auth hosts distinct from each other and from every
|
|
rp-id; related origins capped and collision-free) → build the realm
|
|
registry → serve. The serve command never converts databases: with 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 realm registry is built in the FastAPI lifespan **after
|
|
`kanta.open()`**, from `db.data().config.realms` — realm data does not
|
|
travel through `PASKIA_CONFIG`. Per-realm `site_url`/`site_path` are
|
|
computed at registry-build time (priority: auth_host > origins[0] >
|
|
`PASKIA_VITE_URL` for the localhost realm > `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 realm 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 _realm'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-realm 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-realm.
|
|
- Cascades are uuid-keyed; deleting a user removes their passkeys across
|
|
all realms (correct: users are global).
|
|
|
|
## 5. Dispatch and realm context
|
|
|
|
- `paskia/realms.py`: `Realm { config, passkey, ... }` and a registry
|
|
keyed by rp-id, built in the lifespan from the stored combined `Config`
|
|
and rebuilt on admin realm writes. No per-realm Kanta/DB.
|
|
- Host resolution (`resolve(host)`): normalize (lowercase, strip port and
|
|
trailing dot), then exact rp-id → exact auth host → **exact
|
|
related-origin hostname** → longest-suffix rp-id. Unknown → `None`.
|
|
(Order safe because startup and admin-write validation forbids
|
|
collisions between these sets.)
|
|
- A pure ASGI dispatch middleware, outermost, handles `"http"` and
|
|
`"websocket"` scopes. Unknown Host → 421 Misdirected Request (WS:
|
|
pre-accept close). Sets the `current_realm` contextvar +
|
|
`scope["state"]["realm"]`.
|
|
- **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 realm from the Origin hostname —
|
|
including related-origin hostnames;
|
|
2. the connection `Host` must be a valid WS endpoint for that realm:
|
|
the realm's _effective auth host_ (§6), or the origin host itself
|
|
when the realm has no auth host at all — else pre-accept reject;
|
|
3. `validate_origin` runs endpoint-side against the origin realm's
|
|
`Passkey` (post-accept JSON errors preserved);
|
|
4. `current_realm` = origin realm for the WS handler's duration.
|
|
The ceremony rp-id is always the origin realm's rp-id — exactly what
|
|
the browser enforces for the page's origin under both classic and
|
|
related-origin rules.
|
|
|
|
## 6. Auth host: per-realm values with global fallback
|
|
|
|
A realm without its own auth host falls back to the first configured auth
|
|
host (realm-list order):
|
|
|
|
```
|
|
effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or None
|
|
```
|
|
|
|
**Own vs. effective auth host are distinguished everywhere:**
|
|
|
|
- **Follow the realm's OWN auth host**: UI mode detection (minimal-
|
|
profile decision in `App.vue`, via `own_auth_host` in settings), the
|
|
redirect middleware, `ui_base_path`, and `reset_link_url`. A realm with
|
|
no own auth host keeps its full UI on its own hosts — otherwise
|
|
reset/registration pages on `app2.com` would redirect to
|
|
`auth.company.com`, where the ceremony's Origin resolves the _owner_
|
|
realm and stamps the wrong `Credential.rp_id`, breaking 2.B onboarding
|
|
and 2.C local enrollment.
|
|
- **Follow the EFFECTIVE auth host**: WS endpoint selection only
|
|
(`passkey.js` builds the WS URL from settings). The fallback auth host
|
|
serves WS + restricted APIs for foreign realms.
|
|
- Settings (`ApiSettings`) exposes both fields (`own_auth_host` alongside
|
|
the effective `auth_host`) so the frontend makes the mode decision
|
|
correctly.
|
|
- Root mode (`site_path == "/"`) applies only on a realm's _own_ auth
|
|
host, so it is never ambiguous: a realm's UI lives on its own hosts;
|
|
the fallback auth host serves the _owner_ realm's UI plus WS for the
|
|
rest.
|
|
|
|
## 7. Login flows and in-memory stores
|
|
|
|
### 7.1 Auth codes
|
|
|
|
- `OIDCCode` and `CookieCode` carry `rp_id`, verified at redemption —
|
|
defense in depth.
|
|
- **Stamping source matters**: codes are stamped with the realm of the
|
|
session they will redeem — not naively with the current realm at
|
|
issuance. Remote-completion codes are minted inside the _permit_
|
|
handler (permitting realm's context) but redeemed by the _requesting_
|
|
device on its own host, so they are stamped with
|
|
`RemoteAuthRequest.rp_id` — stamping them with the permitter's realm
|
|
would break every cross-realm remote login. Registration-flow and OIDC
|
|
codes stamp from the current realm (issue and redeem sides always
|
|
match). The host re-check at set-session independently binds
|
|
`CookieCode` to the host; the rp_id check complements it.
|
|
|
|
### 7.2 Remote authentication — cross-realm permits allowed
|
|
|
|
- `RemoteAuthRequest.rp_id` records the requesting device's origin realm.
|
|
- **Permit side may differ from the request side** (mechanism 2.C): the
|
|
permitting device authenticates with _its_ realm's passkey, and the
|
|
`session_host=request.host` override creates the session for the
|
|
requesting host. The override must resolve to a **configured realm**
|
|
(registry check) — arbitrary-host session binding is refused. The
|
|
request's rp-id is shown to the permitting user ("device at app2.com
|
|
requests login").
|
|
- No policy flag: cross-realm remote login is how the product works
|
|
(users are global).
|
|
- Exchange codes stay single-use, 60s, host-bound at redemption.
|
|
|
|
## 8. OIDC: per-realm providers in one DB
|
|
|
|
- `DB.oidc` is `dict[str, OIDC]` keyed by rp-id. Each realm is an
|
|
independent provider: own signing key (`oidc.<rp-id>.key` in the
|
|
transaction log shape), own clients.
|
|
- The `util/oidjwt.py` key cache is keyed by rp-id.
|
|
- Issuer stays per-request-Host — each realm host is an issuer alias
|
|
sharing the realm's key. **`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 is the effective auth host, not the
|
|
authorize/discovery host the RP validates against); and `rp_id` — the
|
|
owning realm, needed by every path that runs **without request
|
|
context**:
|
|
- backchannel logout uses `session.rp_id` to select the realm's key
|
|
and `session.issuer` as `iss`;
|
|
- session listings resolve the client under the session's own realm,
|
|
not the request's.
|
|
- Admin OIDC-client CRUD operates on the current realm's `OIDC` entry.
|
|
- Permission `domain` validation accepts a subdomain of **any**
|
|
configured rp-id, any related-origin hostname, or any realm's client
|
|
UUID.
|
|
|
|
## 9. Admin API and UI
|
|
|
|
- `GET /auth/api/settings`: per-request-Host realm values (rp_id,
|
|
rp_name, effective `auth_host`, `own_auth_host`, site URLs).
|
|
- **Realm management (master admin only, `auth:admin`)** — how rp-ids are
|
|
managed after bootstrap:
|
|
- `GET/POST /auth/api/admin/realms/` and
|
|
`PATCH/DELETE /auth/api/admin/realms/{rp_id}`. Writes require recent
|
|
authentication (5 minutes).
|
|
- Create: `rp_id` + optional `rp_name` (defaults to the rp-id),
|
|
`auth_host`, `origins`, `related_origins`; full §3.1 validation (cap,
|
|
cross-realm collisions); registry rebuilt immediately, including the
|
|
realm's `Passkey` instance and OIDC provider entry.
|
|
- Update: same validation against the would-be combined config.
|
|
Changing a realm's rp-id itself is **not supported** (it would orphan
|
|
every credential stamped with the old rp-id) — delete and recreate
|
|
instead.
|
|
- Delete: refused for the last remaining realm and while any credential
|
|
carries the realm's rp-id (re-enroll or delete those credentials
|
|
first); cascades nothing else (users/orgs are global).
|
|
- The admin UI has a Realms section with a table (rp-id, name,
|
|
effective auth host, sign-in site and related domain counts),
|
|
per-row edit/delete and an add-realm dialog. The dialog edits the
|
|
in-domain allow-list and the related domains as two separate lists
|
|
with their own explanations. Its connectivity probe fetches
|
|
`<origin>/auth/api/settings` and compares the returned rp-id against
|
|
the edited realm — a related origin served by this instance answers
|
|
with the realm's rp_id. Connectivity/mismatch results are warnings;
|
|
malformed entries, misfiled entries (cross-domain in the allow-list,
|
|
in-domain in related domains), and an auth host outside the rp-id
|
|
domain block saving.
|
|
- 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-
|
|
realm installs never show a badge.
|
|
- **Enrollment prompt (2.C)**: when the user has no passkey for the
|
|
current realm, the profile view offers to add one (a fresh remote-auth
|
|
session satisfies the recent-auth requirement of registration).
|
|
- Bootstrap/reset links use the default realm's URL.
|
|
- **Bootstrap check**: the "admin has no credentials" startup check tests
|
|
for an admin credential **under the default realm's rp-id** — with
|
|
global users an admin may have passkeys only under another realm, and
|
|
the printed link must still be usable for the default realm.
|
|
|
|
## 10. Storage
|
|
|
|
- Fixed CWD-relative path: **`paskia.kantadb`** — a single kanta JSONL
|
|
file. Kanta rotation siblings (`paskia@<timestamp>.kantadb`) are
|
|
unaffected. There is no environment override; CWD selects the
|
|
deployment.
|
|
- **User files** (avatars) live in the fixed sibling directory
|
|
**`paskia.data/users/`**.
|
|
- **Legacy conversion**: `paskia 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
|
|
`--rp-id <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-realm 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; per-realm
|
|
`Passkey` instances constructed (each realm's origins validated at
|
|
startup — fail-fast, including related-origin cap checks).
|
|
- The admin-credential check runs at serve startup and reprints a usable
|
|
registration link when the admin lacks a credential under the default
|
|
realm (§9).
|
|
- `oidc_notify` fire-and-forget tasks need no realm context for DB access
|
|
(global DB); issuer comes from the session (§8).
|
|
- The dispatch middleware is the only place `current_realm` is set for
|
|
requests; admin realm writes rebuild the registry.
|
|
|
|
## 12. Development
|
|
|
|
- `scripts/devserver.py`: bootstraps via one-shot `paskia init` when no
|
|
database exists (multi `--rp-id`, and `--rp-name`/`--auth-host`/
|
|
`--origin` for the default realm), then runs plain `paskia` serve.
|
|
Caddy dev origins iterate all bootstrap rp-ids plus the auth host and
|
|
explicit origins.
|
|
- `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 --rp-id
|
|
localhost,test.localhost` in the test-data dir (which doubles as the
|
|
server CWD) and serves; `e2e/tests/50-multirealm.spec.ts` exercises
|
|
host dispatch, the well-known endpoint via the admin realm API, and a
|
|
cross-realm remote login (request at test.localhost, permit at
|
|
localhost, session valid on test.localhost) including the enrollment
|
|
prompt UI. Related Origins have **no browser e2e**: a genuine related
|
|
origin needs a non-subdomain host over HTTPS, and the browser fetches
|
|
the well-known document itself — server-side coverage is in pytest
|
|
(`tests/test_realms.py`).
|
|
|
|
## 13. Security model
|
|
|
|
- **Dispatch**: unknown Host → 421 before any router/DB access (direct-IP
|
|
and unconfigured-name access does not work; trailing dots normalized).
|
|
- **Related Origins boundary**: cross-domain origins are valid only when
|
|
explicitly configured and capped; the well-known document is served
|
|
only for the canonical realm and only lists configured origins. All
|
|
origins sharing an rp-id share one security boundary — do not mix trust
|
|
levels within a realm.
|
|
- **Realm administration**: realm create/update/delete is gated on
|
|
`auth:admin` — deployment-wide by design; validation runs on every
|
|
write, not just at startup.
|
|
- **Passkeys**: rp-id binding browser-enforced and server-recorded;
|
|
ceremonies, credential scans, exclude/allow lists all scoped to the
|
|
origin realm's rp-id. No cross-realm oracle in the scan.
|
|
- **Sessions**: host-bound, exact match. Cross-realm sessions arise only
|
|
via (a) a ceremony at the origin realm (incl. related origins), or (b)
|
|
a remote permit by a device holding a valid session at its own realm,
|
|
with registry-validated target host.
|
|
- **Users/orgs global**: deleting a user/org cascades across all realms —
|
|
intended. `auth:admin` is deployment-wide. Permission `domain`
|
|
host-scopes effectiveness per host.
|
|
- **Cross-realm permit transparency**: requesting realm/host shown to the
|
|
approver; both sides logged.
|
|
- **Secret hygiene in logs**: the OIDC signing-key censoring matches the
|
|
`oidc.<rp-id>.key` path shape, so realm keys never print in plaintext
|
|
in the JSONL transaction log.
|
|
- **OIDC**: per-realm keys/issuers; logout tokens carry the stored
|
|
issuer.
|