Serving never refuses to start because of stored realm config: the registry build sanitizes best-effort and warns — misfiled origin entries are reclassified (a cross-domain origins entry is served as a related origin) or dropped, collisions resolve first-come-wins, over-cap related lists truncate, unsalvageable realms are skipped. Fixing the stored config stays the admin interface's job, and it stays reachable on any working realm. Only a config with no servable realm at all is fatal. Admin realm writes stay strict and gain self-lockout guards: an update that would leave the admin's current host unable to run ceremonies for the realm they are on is refused (unless an auth host takes over ceremonies), and deleting the realm currently in use is refused.
513 lines
27 KiB
Markdown
513 lines
27 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 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 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` → sanitize the stored realm set best-effort →
|
|
build the realm 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, colliding
|
|
auth-hosts/related origins resolve first-come-wins, over-cap related
|
|
lists truncate, and unsalvageable realms 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 realm 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 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, while any credential
|
|
carries the realm's rp-id (re-enroll or delete those credentials
|
|
first), and for the realm 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 realm 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 realm.
|
|
- 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, sanitized
|
|
best-effort (§3.2) so startup never fails on config content; per-realm
|
|
`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 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. Writes are strictly
|
|
validated (cross-realm 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 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.
|