Rename realms to domains; object-keyed origins/related config format

Finish the realm→domain terminology removal across source, tests, e2e
and docs. The stored config drops all lists: Config.domains is keyed by
rp-id, DomainConfig.origins/related are objects keyed by host (https://
omitted), values True or OriginEntry(auth_host=True). The default/primary
domain concept is gone; ordering is display-time. Tests and e2e updated
to the new API shapes (not run). Database re-migrated from the legacy
backup into the new format.
This commit is contained in:
2026-09-07 02:05:12 +00:00
parent 80d55679fb
commit f2e6f5784e
33 changed files with 627 additions and 651 deletions
+206 -198
View File
@@ -3,19 +3,19 @@
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
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 **realm** is one rp-id with its associated hosts and
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 realm. The _administrative instance_ is the whole process:
global users/orgs, N realms.
to exactly one domain. The _administrative instance_ is the whole process:
global users/orgs, N domains.
## 1. What is global vs. per-realm
## 1. What is global vs. per-domain
**Global (single instance, shared across realms):**
**Global (single instance, shared across domains):**
| Data | Notes |
| ------------------------------- | -------------------------------------------------------- |
@@ -27,17 +27,17 @@ global users/orgs, N realms.
| 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):**
**Per-domain (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 |
| 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 |
| OIDC provider (keys, clients) | per rp-id — each domain 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
the **current domain** (passkey, config, OIDC view) — not for database
access.
The architectural rule: **authentication establishes identity, not
@@ -47,7 +47,7 @@ session host binding).
## 2. Login architecture: three composable mechanisms
The realm infrastructure is shared by three mechanisms, alternatives
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)
@@ -58,10 +58,10 @@ 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
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 realm's related origins.
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
@@ -69,50 +69,50 @@ 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
- 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 realm's
`related_origins` — explicit related listing is the trust boundary.
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 realm's related origins (404 when there
`{"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 realm (exact match only —
hostname as belonging to that origin's domain (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).
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 realm of the admin application. Config validation enforces
a cap (default 5) on related origins per realm.
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 realms
(WebAuthn-enforced). A host family that first deploys separate domains
(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
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 realms under one administrative instance
### 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 (realm), not an instance attribute. Users are global identities;
object (domain), 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)
└── 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
@@ -120,96 +120,103 @@ stay host-only.
### 2.C Remote authorization + opportunistic local enrollment
For a realm where the user has no credential, the remote-login mechanism
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-realm permits are **allowed**: a device authenticated at
- Cross-domain 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
domain 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
- **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 realms — passkey per realm |
| User lacks a credential for the current realm | 2.C remote authorization, then enroll locally |
| 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 RealmConfig(msgspec.Struct, omit_defaults=True):
rp_id: str
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
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)
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):
realms: list[RealmConfig] # at least one; first entry is the default realm
domains: dict[str, DomainConfig] # keyed by rp-id; at least one
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.
- 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`), or full origins when not https
(`http://localhost:8080`) — `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; one entry may be marked `auth_host` (never a wildcard).
- **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
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 rp-id/auth-host/related origins nor fall inside
another domain's rp-id. 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 realm options exist only at bootstrap time —
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`** — 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.
Multiple values create multiple domains at once (useful for
devserver/e2e).
- `--rp-name`, `--auth-host`, `--origin`: apply to the **first**
rp-id only. Their purpose is that the very first admin registration
ceremony already shows the correct RP name; everything is editable
via the admin interface afterwards.
- `--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).
- Seeds the admin user + registration reset link 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`
- **`paskia`** — serve. Takes **no domain 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:
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, colliding
auth-hosts/related origins resolve first-come-wins, over-cap related
lists truncate, and unsalvageable realms are skipped — each producing
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 realm at all is fatal. The serve command never
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.
@@ -220,41 +227,41 @@ 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.
- 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 realm writes persist the combined `Config` and rebuild the
registry in place, so dispatch sees auth_host and related-origin
- 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 _realm's canonical_
rp-id regardless of which origin the ceremony ran on — the credential
genuinely is a `company.com` passkey.
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-realm oracle ("no
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-realm.
users are global, so their credential id sets are cross-domain.
- Cascades are uuid-keyed; deleting a user removes their passkeys across
all realms (correct: users are global).
all domains (correct: users are global).
## 5. Dispatch and realm context
## 5. Dispatch and domain context
- `paskia/realms.py`: `Realm { config, passkey, ... }` and a registry
- `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 realm writes. No per-realm Kanta/DB.
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 → exact auth host → **exact
related-origin hostname** → longest-suffix rp-id. Unknown → `None`.
@@ -262,52 +269,53 @@ stamp the child rp-id.
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"]`.
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 realm from the Origin hostname —
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 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
2. the connection `Host` must be a valid WS endpoint for that domain:
the domain's _effective 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_realm` = origin realm for the WS handler's duration.
The ceremony rp-id is always the origin realm's rp-id — exactly what
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-realm values with global fallback
## 6. Auth host: per-domain values with global fallback
A realm without its own auth host falls back to the first configured auth
host (realm-list order):
A domain's auth host is one of its origins entries marked
`auth_host: true`. A domain without its own auth host falls back to the
first configured auth host found in the registry:
```
effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or None
effective_auth_host(domain) = domain's own auth host or first configured one or None
```
**Own vs. effective auth host are distinguished everywhere:**
- **Follow the realm's OWN auth host**: UI mode detection (minimal-
- **Follow the domain'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
redirect middleware, `ui_base_path`, and `reset_link_url`. A domain
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
domain 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.
serves WS + restricted APIs for foreign domains.
- 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
- Root mode (`site_path == "/"`) applies only on a domain's _own_ auth
host, so it is never ambiguous: a domain's UI lives on its own hosts;
the fallback auth host serves the _owner_ domain's UI plus WS for the
rest.
## 7. Login flows and in-memory stores
@@ -316,104 +324,104 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
- `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
- **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 realm's context) but redeemed by the _requesting_
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 realm
would break every cross-realm remote login. Registration-flow and OIDC
codes stamp from the current realm (issue and redeem sides always
`RemoteAuthRequest.rp_id` — stamping them with the permitter's domain
would break every cross-domain remote login. Registration-flow and OIDC
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-realm permits allowed
### 7.2 Remote authentication — cross-domain permits allowed
- `RemoteAuthRequest.rp_id` records the requesting device's origin realm.
- `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_ realm's passkey, and 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 realm**
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-realm remote login is how the product works
- 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: per-realm providers in one DB
## 8. OIDC: per-domain providers in one DB
- `DB.oidc` is `dict[str, OIDC]` keyed by rp-id. Each realm is an
- `DB.oidc` is `dict[str, OIDC]` keyed by rp-id. Each domain 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`
- Issuer stays per-request-Host — each domain host is an issuer alias
sharing the domain'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
owning domain, needed by every path that runs **without request
context**:
- backchannel logout uses `session.rp_id` to select the realm's key
- backchannel logout uses `session.rp_id` to select the domain's key
and `session.issuer` as `iss`;
- session listings resolve the client under the session's own realm,
- session listings resolve the client under the session's own domain,
not the request's.
- Admin OIDC-client CRUD operates on the current realm's `OIDC` entry.
- Admin OIDC-client CRUD operates on the current domain's `OIDC` entry.
- Permission `domain` validation accepts a subdomain of **any**
configured rp-id, any related-origin hostname, or any realm's client
configured rp-id, any related-origin hostname, or any domain's client
UUID.
## 9. Admin API and UI
- `GET /auth/api/settings`: per-request-Host realm values (rp_id,
- `GET /auth/api/settings`: per-request-Host domain 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
- **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),
`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.
`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 and OIDC
provider entry. The auth host is marked inside `origins`
(`{"auth.example.com": {"auth_host": true}}`).
- 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
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 realm they are on is
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 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.
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-
realm installs never show a badge.
domain 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
current domain, 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.
- **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
@@ -436,7 +444,7 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
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.
- The startup box prints per-domain lines.
## 11. Lifespan and background tasks
@@ -445,21 +453,21 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
- 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
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 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.
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` when no
database exists (multi `--rp-id`, and `--rp-name`/`--auth-host`/
`--origin` for the default realm), then runs plain `paskia` serve.
`--origin` for the first domain), 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
@@ -470,14 +478,14 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
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
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_realms.py`).
(`tests/test_domains.py`).
## 13. Security model
@@ -485,28 +493,28 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
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
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 realm.
- **Realm administration**: realm create/update/delete is gated on
levels within a domain.
- **Domain administration**: domain 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
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 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,
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 realms —
- **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-realm permit transparency**: requesting realm/host shown to the
approver; both sides logged.
- **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.<rp-id>.key` path shape, so realm keys never print in plaintext
`oidc.<rp-id>.key` path shape, so domain keys never print in plaintext
in the JSONL transaction log.
- **OIDC**: per-realm keys/issuers; logout tokens carry the stored
- **OIDC**: per-domain keys/issuers; logout tokens carry the stored
issuer.