From 2baebc4072a2dd7b2cd84b63a1470f864c5adf01 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Sun, 6 Sep 2026 15:13:10 +0000 Subject: [PATCH] Docs: multi-realm current state README quick start and configuration updated for init/serve split and paskia.kantadb; API.md server-config endpoints replaced with admin realm CRUD and the well-known webauthn endpoint; proxy guides point at the realm auth-host setting; oidc.md documents per-realm issuers; MultiSite.md rewritten from the implementation plan into documentation of the shipped mechanics, policy model, and design rationale. --- README.md | 48 ++- docs/API.md | 22 +- docs/MultiSite.md | 860 +++++++++++++++--------------------------- docs/proxy/apisix.md | 2 +- docs/proxy/caddy.md | 2 +- docs/proxy/envoy.md | 2 +- docs/proxy/haproxy.md | 2 +- docs/proxy/traefik.md | 2 +- oidc.md | 8 +- 9 files changed, 365 insertions(+), 583 deletions(-) diff --git a/README.md b/README.md index c0f0667..7e8ee36 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,11 @@ Paskia includes set of login, reauthentication and forbidden dialogs that it can Install [UV](https://docs.astral.sh/uv/getting-started/installation/) and run: ```sh -uvx paskia --rp-id example.com +uvx paskia init --rp-id example.com +uvx paskia ``` -On the first run it downloads the software and prints a registration link for the Admin. The server starts on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--rp-id`. +The first command bootstraps the database and prints a registration link for the Admin. The second starts the server on [localhost:4401](http://localhost:4401), serving authentication for `*.example.com`. For local testing, leave out `--rp-id` (defaults to `localhost`). For production you need a web server such as [Caddy](https://caddyserver.com/) to serve HTTPS on your actual domain names and proxy requests to Paskia and your backend apps (see documentation below). @@ -51,22 +52,22 @@ uv tool install paskia ## Configuration -You will need to specify your main domain to which all passkeys will be tied as rp-id. Use your main domain even if Paskia is not running there. All other options are optional. +Bootstrapping is done once with `paskia init`; after that, `paskia` serves all configured realms from the database `paskia.kantadb` in the current directory. Realm configuration (rp-name, auth host, origins) is managed via the admin web interface, including adding further realms (rp-ids). ```text -paskia [options] +paskia init [options] # one-time bootstrap +paskia [-l endpoint] # serve ``` -| Option | Description | Default | +| init option | Description | Default | |--------|-------------|---------| -| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* | **localhost:4401** | -| --rp-id *domain* | Main/top domain for passkeys | **localhost** | -| --rp-name *"text"* | Branding name for the entire system (passkey auth, login dialog). | Same as rp-id | -| --origin *url* | Only sites listed can login (repeatable) | rp-id and all subdomains | +| -l, --listen *endpoint* | Listen address: *host*:*port*, :*port* (all interfaces), or */path.sock* (stored in the database) | **localhost:4401** | +| --rp-id *domain* | Main/top domain for passkeys. Repeatable and comma-separated for multiple realms; the first is the default realm | **localhost** | +| --rp-name *"text"* | Branding name of the default realm (passkey auth, login dialog) | Same as rp-id | +| --origin *url* | Only sites listed can login on the default realm (repeatable) | rp-id and all subdomains | | --auth-host *url* | Dedicated authentication site, e.g. **auth.example.com** | Use **/auth/** path on each site | -| --save | Save current options to database | (only --rp-id required on further invocations) | -To clear a stored setting, pass an empty value like `--auth-host=`. The database is stored in `{rp-id}.paskiadb` folder in current directory. This can be overridden by environment `PASKIA_DB` if needed. +The `paskia` serve command accepts only `--listen` (overriding the stored value). An existing legacy `{rp-id}.paskiadb` database is converted to `paskia.kantadb` automatically on first serve. ## Tutorial: From Local Testing to Production @@ -74,13 +75,14 @@ This section walks you through a complete example, from running Paskia locally t ### Step 1: Production Configuration -For a real deployment, configure Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains. +For a real deployment, bootstrap Paskia with your domain name (rp-id). This enables SSO setup for that domain and any subdomains. ```sh -uvx paskia --rp-id=example.com --rp-name="Example Corp" +uvx paskia init --rp-id=example.com --rp-name="Example Corp" +uvx paskia ``` -This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). On the first run, you'll see a registration link—use it to create your Admin account. You may enter your real name here for a more suitable account name. +This binds passkeys to the rp-id, allowing them to be used there or on any subdomain of it. The `--rp-name` is the branding shown in UI and registered with passkeys for everything on your domain (rp id). Init prints a registration link—use it to create your Admin account. You may enter your real name here for a more suitable account name. ### Step 2: Set Up Caddy @@ -177,20 +179,20 @@ curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR=/usr/local/ Create a systemd unit: ```sh -sudo systemctl edit --force --full paskia@.service +sudo systemctl edit --force --full paskia.service ``` Paste the following and save: ```ini [Unit] -Description=Paskia for %i +Description=Paskia [Service] Type=simple User=paskia WorkingDirectory=/srv/paskia -ExecStart=uvx paskia@latest --rp-id=%i +ExecStart=uvx paskia@latest [Install] WantedBy=multi-user.target @@ -199,7 +201,7 @@ WantedBy=multi-user.target Run the service and view log: ```sh -sudo systemctl enable --now paskia@example.com && sudo journalctl -n30 -ocat -fu paskia@example.com +sudo systemctl enable --now paskia && sudo journalctl -n30 -ocat -fu paskia ``` ### Optional: Dedicated Authentication Site @@ -214,7 +216,15 @@ auth.example.com { Now all authentication happens at `auth.example.com` instead of `/auth/` paths on your apps. Your existing protected sites continue to work as before but they just forward to the dedicated site for user profile and other such functionality. -Enter your auth site domain on Admin / Server Options panel or use `--auth-host=auth.example.com` when starting the server. +Set the auth host in the admin panel's Realms section, or pass `--auth-host=auth.example.com` to `paskia init` when bootstrapping. + +## Multiple Realms and Related Origins + +One Paskia instance can serve several realms (rp-ids) from the same database: users, orgs and permissions are shared, while passkeys are registered per realm. The master admin adds realms in the admin panel's Realms section; no restart is needed. + +A realm can also allow passkey use on unrelated domains via WebAuthn [Related Origin Requests](https://passkeys.dev/docs/advanced/related-origins/) — add the origin to the realm and paskia serves the required `/.well-known/webauthn` declaration. + +See [Multi-Site documentation](docs/MultiSite.md) for details. ## Further Documentation diff --git a/docs/API.md b/docs/API.md index cb3fa14..3c6aac0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -72,8 +72,12 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access | PATCH | /auth/api/admin/oidc-clients/{uuid} | Update OIDC client | 200/401/403 | | PATCH | /auth/api/admin/oidc-clients/{uuid}/reset-secret | Reset client secret | 200/401/403 | | DELETE | /auth/api/admin/oidc-clients/{uuid} | Delete OIDC client | 200/401/403 | -| GET | /auth/api/admin/server-config/ | Get server config | 200/401/403 | -| PATCH | /auth/api/admin/server-config/ | Update server config | 200/401/403 | +| GET | /auth/api/admin/realms/ | List realms (rp-ids) with derived URLs | 200/401/403 | +| POST | /auth/api/admin/realms/ | Create realm `{rp_id, rp_name?, auth_host?, origins?}` | 200/400/401/403 | +| PATCH | /auth/api/admin/realms/{rp_id} | Update realm rp_name/auth_host/origins | 200/400/401/403 | +| DELETE | /auth/api/admin/realms/{rp_id} | Delete realm (refused while credentials remain) | 200/400/401/403 | + +Realm endpoints require the `auth:admin` permission; writes additionally require recent authentication (5 minutes). Changes are validated cross-realm and apply immediately. ### WebSockets: /auth/ws/* @@ -86,7 +90,9 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access These are for internal use only, but are documented here because they are the core piece in all passkey operations. -### Auth host mode (--auth-host) +### Auth host mode (dedicated auth site) + +A realm may configure a dedicated authentication host (auth-host, a subdomain of the rp-id), either at bootstrap (`paskia init --auth-host`) or via the Realms admin panel. #### On the auth host: - The Web UI is served at site root instead of /auth/* (that redirects to root paths) @@ -98,4 +104,12 @@ These are for internal use only, but are documented here because they are the co - /auth/api/* is served normally. - /auth/api/user/*, /auth/api/admin/*, and /auth/ws/* don't exist. -The WebSocket connections are directed to auth host, and must have an allowed origin corresponding to the host where the user is logging in, that the session is tied with. +The WebSocket connections are directed to the auth host, and must have an allowed origin corresponding to the host where the user is logging in, that the session is tied with. + +#### Shared auth host across realms + +When one realm has an auth host, other realms without their own use it as their *effective* auth host: their WebSocket and cross-device flows are directed there, but their own `/auth/` still serves the full profile (host mode is keyed off the realm's *own* auth host only). `/auth/api/settings` exposes both: `auth_host` (effective) and `own_auth_host` (this realm only, null when unset). + +### Related Origin Requests: /.well-known/webauthn + +`GET /.well-known/webauthn` returns `{"origins": [...]}` listing the realm's related origins (configured origins on domains unrelated to the rp-id), per WebAuthn Related Origin Requests. Browsers fetch this from the rp-id domain when an unrelated origin runs a ceremony with this realm's rp-id. Returns 404 when the realm has no related origins. If the rp-id's main site is hosted elsewhere, serve the JSON statically there (copy it from this instance). diff --git a/docs/MultiSite.md b/docs/MultiSite.md index 1f8efb2..c69915a 100644 --- a/docs/MultiSite.md +++ b/docs/MultiSite.md @@ -1,144 +1,107 @@ -# Multi-Site Support: Combined-Database Plan +# Multi-Site Support -Status: **draft v4 for review** — no code changes made. v4 folds in a -simplification round: Related Origin Requests are now assumed to have -**universal browser support** (Firefox included); there are **no existing -multi-database deployments** to migrate — the only legacy path is adopting -a lone `.paskiadb` into the new combined `paskia.kantadb` file; and -realm configuration is **bootstrap-only on the CLI** — rp-ids, rp-names, -origins and auth hosts are managed at runtime through the master-admin web -interface, so the serve command takes no realm arguments at all. +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. -Goal: one paskia process on one port (4401) 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 becomes a first-class per-realm object; passkeys remain tied to -their rp-id (WebAuthn-enforced); sessions remain host-bound exactly as -today. Motivating case: `app1.company.com` and `app2.com` cannot share an -rp-id, but user management must be under single common controls. - -Decisions already made (from review rounds): - -- **One combined database** at a fixed CWD-relative path: - **`paskia.kantadb`** (a single kanta JSONL file). No `PASKIA_DB`, no - per-rp-id directories, no directory scanning. The `paskiadb`/`main.db` - names are retired (§10). -- **CLI is bootstrap-only**: `paskia init` seeds the database with the - initial realm(s); plain `paskia` opens `paskia.kantadb` and serves - whatever realms are stored. rp-id no longer selects which database to - open, which removes the whole class of CLI/runtime mixups. -- **Runtime realm management via the admin interface**: adding rp-ids, - changing rp-names, origins and auth hosts are master-admin operations - (§9), exactly like rp-name changes work today after first setup. The - admin interface is shared across the whole instance — as long as a - master admin can log in on some host, all further configuration happens - there. -- Cross-rp-id logins are **permitted** (§2 mechanisms); no separate - per-site user silos. - ---- +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 | already global structs; unchanged | -| Permissions | `domain` field already host-scopes effectiveness (`structs.py:704-706`) | -| Sessions | already host-bound (`Session.host`, exact match `structs.py:678-682`) | -| Credentials/passkeys | global collection, each stamped with its `rp_id` (§4) | -| Reset tokens | user-bound; global | -| Avatars | `users//profile.webp` under the one user-files root (§10) | -| Auth codes, remote-auth manager | in-memory; gain rp-id fields (§7) | +| Data | Notes | +| ------------------------------- | -------------------------------------------------------------- | +| Organizations, Roles, Users | unchanged structs | +| 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//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` (§3) | +| `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 (§8) | +| OIDC provider (keys, clients) | per rp-id — each realm is an independent issuer | -Terminology: a **realm** is one rp-id with its associated hosts and -origins (the feedback's "authentication realm"). 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. - -The key simplification: `db.data()` stays a plain global singleton. The +The key simplification: `db.data()` stays a plain global singleton. A contextvar is needed only for the **current realm** (passkey, config, OIDC view) — not for database access. +A deferred idea remains an Identity layer above the org-owned User +(`Identity → N org memberships + N credentials`). The architectural rule +is already in place: **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 plan implements the realm infrastructure (§3-§9) once, plus three -mechanisms that share it. They are alternatives _per deployment_, and -composable within one instance. +The realm infrastructure is shared by three mechanisms, alternatives +_per deployment_ and composable within one instance. -### 2.A WebAuthn Related Origin Requests (preferred for trusted domain families) +### 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 now universal -(Firefox included), so ROR needs no fallback mechanism for browser -reasons. +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 allow-list. -Server-side feasibility (verified against the installed `webauthn` 3.0.0): -paskia's `Passkey` passes `expected_origin=` -and `expected_rp_id=self.rp_id` (`sansio.py:188-193,255-263`); the +Server side: paskia's `Passkey` passes `expected_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 (`frontend/src/utils/passkey.js:40,68`). So the change set is: +the WS. On top of that: -- `Passkey._validate_origin` (`sansio.py:95-106`) currently requires - origin == rp-id or subdomain. New rule: an origin is valid if it is in - the rp-id subtree **or explicitly listed in the realm's configured - origins**. Explicit listing becomes the trust boundary — exactly the - right semantics, since `allowed_origins` is already an allow-list. - (Today's semantics are subtree-AND-listed when a list exists; the new - subtree-OR-listed is additive-only, so existing configs keep passing.) -- **Remove the redundant inline origin gate** in `authenticate_and_login` - (`wschat.py:93-95` re-implements `hostname == rp_id or endswith`) — it - would reject related origins after `validate_origin` accepted them. - Dispatch already resolved the realm from the Origin; the endpoint-side - `validate_origin` is the single origin rule. -- New endpoint: `GET /.well-known/webauthn` on the canonical rp-id host, - serving `{"origins": ["https://app2.com", ...]}` from the realm's - configured non-subdomain origins. -- Dispatch resolution gains a rule: a Host matching a configured - related-origin hostname resolves to that origin's realm (exact match - only — `www.app2.com` does not follow `app2.com`; document this). +- Origin rule: an origin is valid if it is in the rp-id subtree **or + explicitly listed in the realm's configured origins**. Explicit listing + is the trust boundary (`allowed_origins` was already an allow-list). +- `GET /.well-known/webauthn` on the canonical rp-id host serves + `{"origins": [...]}` from the realm's configured non-subdomain 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 (documented in §15): the **browser** fetches +Deployment constraint: the **browser** fetches `https:///.well-known/webauthn` from the canonical apex directly — -if paskia does not host the apex, the JSON must be published there -statically. +if paskia does not host the apex, publish the JSON there statically (the +admin realm dialog links to the document for copying). -Constraints and warnings (from the WebAuthn WG, to be documented): -implementations must support at least **5 registrable origin labels** and -may cap more aggressively — 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 -configurable cap (default 5) on related origins per realm. +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 UI's per-credential rp-id badge -(§9) makes this visible. No automated credential migration is provided or +common rp-id, retire the old one. The per-credential rp-id badge (§9) +makes this visible. No automated credential migration is provided or needed. -### 2.B Multiple rp-id realms under one administrative instance (the base refactor) +### 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; @@ -154,53 +117,25 @@ Instance ``` Alice can hold both a `company.com` and an `app2.com` passkey; sessions -stay host-only. This is the refactor described in §3-§9 and is worthwhile -**regardless of which login mechanism a deployment uses** — 2.A is -implemented as "a realm may declare extra origins", 2.C as "a realm may -be entered via remote authorization". +stay host-only. -**Deferred idea from the feedback — an Identity layer above the -org-owned User** (`Identity → N org memberships + N credentials`). Not -part of this plan: the current `User → Role → Org` ownership -(`structs.py:198-286`) is deeply embedded (bootstrap, admin API, -permissions), and multi-site works without it. We do adopt the feedback's -architectural rule now: **authentication establishes identity, not -organization** — the requested hostname selects the org/permission -context after authentication (already true via `Permission.domain` -host-scoping and session host binding). A future Identity split should -preserve that rule. +### 2.C Remote authorization + opportunistic local enrollment -### 2.C Remote authorization + opportunistic local enrollment (bootstrap/recovery path) +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. -For a realm where the user has no credential, the existing remote-login -mechanism already provides a federation-style flow: unauthenticated device -requests, authenticated device permits, a short-lived **single-use opaque -exchange code** (60s `CookieCode`, `authcode.py`) is redeemed by the -requesting host, which sets its own host-only cookie. No shared cookies, -no reusable tokens in URLs — matching the feedback's -authorization-code-shaped recommendation; the two channels (WS pairing -code vs redirect with `state`) are UX variants over the same code -redemption primitive. - -Changes under this plan: - -- Cross-realm permits are **allowed** (§7.2): a device authenticated at +- 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. -- **Same-device redirect variant** (optional, closes open question from - v1): "logged in at the auth host, bounce to the app host with a code" — - reuse the same `CookieCode` machinery with a `redirect_uri`+`state` - parameter set, PKCE not needed server-to-self but `state` protects the - redirect leg. This is a small addition over §7.1, kept as an optional - follow-up. - **Opportunistic local enrollment**: after a cross-realm remote login, - the UI offers "Add a passkey for faster login here". The mechanism - already exists — the remote flow's `register` action issues a - `device addition` reset token (`remote.py:325-333`) and registration - runs locally under the new realm's rp-id, stamping `Credential.rp_id` - (§4). This makes remote login primarily bootstrap/recovery, while - everyday authentication stays local. + the profile view offers "Add a passkey for " — 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) @@ -212,7 +147,7 @@ Changes under this plan: ## 3. Configuration model -### 3.1 Stored config (breaking change) +### 3.1 Stored config ```python class RealmConfig(msgspec.Struct, omit_defaults=True): @@ -226,22 +161,17 @@ class Config(msgspec.Struct, omit_defaults=True): listen: list[str] | None = None # process-global ``` -- Old top-level `rp_id/rp_name/auth_host/origins` fields removed; a kanta - migration converts existing databases (§10). Default constructors move - to the new shape everywhere: `structs.py:622` (DB.config factory), - `operations.py:40` (sentinel), `db/bootstrap.py:148`. -- "At least one realm" is not expressible in msgspec — enforce it in a - startup/validation check. -- 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. +- "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**: each configured origin is either in the rp-id - subtree (classic) or an explicit related origin (§2.A). Related origins - are counted and capped (default 5 registrable labels per realm) and - must not collide with another realm's rp-id/auth-host/related origins. - These rules are enforced **both at startup and at admin write time** - (§9) — startup-only checks are bypassable at runtime. Origins are never - _implicitly_ cross-domain. + subtree (classic) or an explicit related origin. Related origins are + counted and capped (default 5) and must not collide with another + realm's rp-id/auth-host/related origins. 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`) @@ -250,34 +180,26 @@ 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"]`; - normalized, deduped. Multiple values create multiple realms at once - (useful for devserver/e2e); the **first is the default realm**. - - `--rp-name`: single value, 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 (§9), as are any additional realms' names. - - `--auth-host`, `--origin`: apply to the default realm; existing - normalization (`validate_auth_host`, `normalize_origin`, - `normalize_auth_host_and_origins`) reused. Further realms' hosts are - configured via the admin interface. + - `--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). - - Runs the kanta bootstrap (admin user + registration reset link, link - URL from the default realm) and prints the link. Refuses to run if + - 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 un-adopted legacy - `*.paskiadb` is present (§10 — serve must adopt it first). + `*.paskiadb` is present (serve adopts it first). - **`paskia`** — serve. Takes **no realm options**; only `--listen` - (per-run override of stored `Config.listen`, never persisted) and - dev/debug flags. Startup flow: legacy-adoption pre-flight (§10) → 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 - (§5) → serve. Missing database → startup error pointing at - `paskia init`. -- `--save` is removed: init always persists, serve has nothing to save, - and runtime edits go through the admin API which persists directly. -- `PASKIA_VITE_URL` site_url fallback applies to the localhost realm - only (devserver). + (per-run override of stored `Config.listen`, never persisted). Startup: + legacy-adoption pre-flight → 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. Missing database → + startup error pointing at `paskia init`. Nested rp-ids are allowed (longest-suffix dispatch determinism). Adding a child rp-id moves **no data** — users are global; only new ceremonies @@ -286,89 +208,64 @@ 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 no longer - travels through `PASKIA_CONFIG` at all. Per-realm - `site_url`/`site_path` are computed at registry-build time by a shared - derivation function (same priority as today: auth_host > origins[0] > - PASKIA_VITE_URL > `http://localhost:port` > `https://rp-id`), using the - effective listen endpoints for the localhost fallback. -- `PASKIA_CONFIG` shrinks to process-global serve parameters (the + `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. A welcome side effect: - `db/lifecycle.py:28-37` no longer needs `PASKIA_CONFIG` at import time - to locate the database — the path is fixed (§10). -- `update_runtime_config` → `update_realm_runtime(rp_id, realm_config)`: - ports the site_url/site_path recomputation (`runtime.py:44-75`), - persists the combined `Config`, refreshes the registry entry in place - (dispatch must see auth_host and related-origin changes immediately). - Realm creation/deletion (§9) add/remove registry entries the same way. -- `util/hostutil.py` helpers take a realm parameter (`is_root_mode`, - `dedicated_auth_host`, `api_url`, `auth_site_url`, `ui_base_path`, - `reset_link_url`). `reset_link_url` has two context classes: the - bootstrap callback (`db/bootstrap.py:37-43`, no request context) uses - the **default** realm's URL; the request-context call sites - (`fastapi/user.py:294`, `admin/users.py:125`) must use the **current - request realm's** URL — otherwise device-addition links mint - credentials under the wrong realm's rp-id. -- Dead code removed: `util/frontend.py`, `hostutil.reload_config`. + 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 get an rp-id +## 4. Credentials carry an rp-id -- `Credential` (`structs.py:289-304`) gains `rp_id: str`, stamped at - registration from the ceremony's rp-id (`Passkey.reg_verify`, - `sansio.py:194-200`, and `Credential.create`, `structs.py:337-360`, - both gain the parameter). Field placement: `Credential` is not - `kw_only`, so the required field must precede the defaulted ones - (`structs.py:303-304`). 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. -- **Backfill migration** (`migrate_v6`): existing credentials get the old - stored `config.rp_id` (read from the DB's own config during replay). - `Credential` has no `omit_defaults`, so the field self-normalizes; the - migration writes the _correct_ value. -- `authenticate_chat` (`wschat.py:50-57`): the raw_id scan is filtered 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, `ws.py:78`) and reauth - `allow_credentials` (`wschat.py:99-103`) are filtered by the ceremony's - rp-id — `User.credential_ids` becomes cross-realm once users are global. -- Cascades are uuid-keyed and unchanged; deleting a user removes their - passkeys across all realms (correct: users are global). +- `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 -- New module `paskia/realms.py`: `Realm { runtime, passkey }` and a - registry keyed by rp-id, built in the lifespan from the stored combined - `Config` (§3.3) and refreshed on admin realm writes. No per-realm - Kanta/DB. -- Host resolution (`resolve(host)`): normalize - (`hostutil.normalize_host`, gaining trailing-dot stripping), 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.) -- **Pure ASGI dispatch middleware**, outermost (registered after - `redirect_middleware`), handling `"http"` and `"websocket"` scopes. - Unknown Host → 421 Misdirected Request (WS: pre-accept rejection). Sets - the `current_realm` contextvar + `request.state.realm`. +- `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 - (docs/API.md auth-host section). So: - 1. middleware resolves the origin realm from the Origin hostname — - including related-origin hostnames (a ceremony on `app2.com` for - rp-id `company.com` resolves to the `company.com` realm); + 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, `wsutil.py:23,34-35`); + `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. -- `paskia/globals.py` deleted; `from paskia.globals import passkey` → - `current_realm().passkey`; `db.data()` stays global. + 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 @@ -379,363 +276,220 @@ host (realm-list order): effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or None ``` -**Own vs. effective auth host must be distinguished everywhere** — this -was a review finding with real consequences. The split: +**Own vs. effective auth host are distinguished everywhere:** -- **Follow the realm's OWN auth host**: UI mode detection - (`App.vue:43-49` minimal-profile decision), the redirect middleware - (`auth_host.py:39-53`), `ui_base_path`, and `reset_link_url`. A realm - with no own auth host keeps its full UI on its own hosts — otherwise +- **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:8-12` builds the WS URL from settings). The fallback auth - host serves WS + restricted APIs for foreign realms. -- Settings (`ApiSettings`) exposes both fields (add `own_auth_host` - alongside the effective `auth_host`) so the frontend can make the mode - decision correctly. + (`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. -- Admin changes to auth_host re-validate cross-realm uniqueness against - the live registry (§9). ## 7. Login flows and in-memory stores -### 7.1 Auth codes (`authcode.py:45-113`) +### 7.1 Auth codes -- `OIDCCode` and `CookieCode` gain `rp_id`, verified at redemption - (`oid.py:184`, `api.py:407`) — defense in depth; cheap. -- **Stamping source matters** (review blocker): 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 (`remote.py:336-341`, permitting realm's context) - but redeemed by the _requesting_ device on its own host, so they are - stamped with `RemoteAuthRequest.rp_id` (§7.2) — stamping them with the - permitter's realm would break every cross-realm remote login. - Registration-flow codes (`ws.py:97`) and OIDC codes (`ws.py:233-241`) - stamp from the current realm (issue and redeem sides always match). - The host re-check at `api.py:414-416` already binds `CookieCode` +- `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 already binds `CookieCode` independently; the rp_id check is additive. -- Future stores (e.g. docs/AuthTickets.md's `AuthTicket`) inherit the - rp_id field. ### 7.2 Remote authentication — cross-realm permits allowed -- `RemoteAuthRequest` (`remoteauth.py:33-58`) gains `rp_id` — the - requesting device's origin realm (resolved at `remote.py:48-49,93-98`). -- **Permit side may differ from the request side** (this is mechanism - 2.C): the permitting device authenticates with _its_ realm's passkey, - and the existing `session_host=request.host` override - (`remote.py:315-321`) creates the session for the requesting host. - Changes required: - - the `session_host` override must resolve to a **configured realm** - (registry check) — today it is only non-empty-checked - (`wschat.py:108-114`); arbitrary-host session binding is refused; - - the request's rp-id is shown to the permitting user ("device at - app2.com requests login"); - - the login transaction logs both the session host and the permitting - host/credential. -- No policy flag for now: cross-realm remote login is how the product - works (users are global). A future per-realm policy field can add - isolation. -- Exchange codes stay single-use, 60s, host-bound at redemption - (`api.py:407-416` re-checks `session_ctx(secret, host)`). +- `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). A future per-realm policy field can add isolation. +- Exchange codes stay single-use, 60s, host-bound at redemption. -### 7.3 Same-device redirect variant (optional follow-up) - -"Logged in at auth host → bounce to app host with a code": reuse -`CookieCode` with `redirect_uri` + `state`; redeem at the target host's -`/auth/api/set-session` as today. Small addition; optional. +A possible follow-up is a same-device redirect variant ("logged in at the +auth host, bounce to the app host with a code"): reuse `CookieCode` with +`redirect_uri` + `state`; redeem at the target host's set-session as +today. ## 8. OIDC: per-realm providers in one DB -- `DB.oidc: OIDC` becomes `dict[str, OIDC]` keyed by rp-id (migration - wraps the existing struct under the old rp-id). Each realm is an - independent provider: own signing key, own clients. -- `util/oidjwt.py` key cache (`:22-24`) keyed by rp-id; keys remain - stored per realm in the DB (`structs.py:599-601`). -- Issuer stays per-request-Host (`oid.py:64-68`, discovery at - `mainapp.py:89-124`) — each realm host is an issuer alias sharing the - realm's key. **`Session` gains two fields** (both `omit_defaults`, - migration-free): `issuer: str | None` — stamped from the WS **Origin** - (scheme included, `ws.py:207-209`) at OIDC-session creation - (`ws.py:217-226`) and re-stamped at refresh (`oid.py:251-316`; - 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: str | None` — the owning realm, needed - by every path that runs **without request context**: - - backchannel logout (`oidc_notify.py:24-27` issuer, `:44` client - lookup, `:91-101` signing) uses `session.rp_id` to select the - realm's key and `session.issuer` as `iss`; - - `cleanup_expired` (`lifecycle.py:149-151`) drives the above with no - request; pre-upgrade sessions (`rp_id=None`) fall back to registry - issuer→realm resolution, then the default realm; - - the logfmt UUID→label lookup (`lifecycle.py:84-93`) iterates all - realms' client dicts; - - session listings (`apistructs.py:120` `client_name`) resolve the - client under the session's own realm, not the request's. -- **Log censoring must follow the new shape (security)**: the transaction - log censor (`lifecycle.py:108-109`) matches only `oidc.key` / - `.endswith(".oidc.key")`; the new path is `oidc..key` — without - a segment/regex-based rule, realm signing keys would print in plaintext - in the JSONL log and in the `migrate:v7` diff. Also harden - `_lookup_uuid_in_state` (`lifecycle.py:55`) for the nested clients. +- `DB.oidc` is `dict[str, OIDC]` keyed by rp-id. Each realm is an + independent provider: own signing key (`oidc..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. -- `_validate_permission_domain` (`admin/permissions.py:18-36`) accepts a - subdomain of **any** configured rp-id, any related-origin hostname, or - any realm's client UUID. -- `domain == client UUID` permission grouping (`oid.py:341,441`) looks up - the current realm's clients (OIDC sessions are always created under the - origin realm). +- Permission `domain` validation accepts a subdomain of **any** + configured rp-id, any related-origin hostname, or any realm's client + UUID. -## 9. API and frontend changes +## 9. Admin API and UI -- `GET /auth/api/settings` (`api.py:301-316`): per-request-Host realm - values (rp_id, rp_name, effective auth_host, site URLs). Schema - unchanged. -- **Realm management (master admin only)** — this is how new rp-ids are - added after bootstrap, mirroring how rp-name is already edited post - setup (`admin/server_config.py:18-78` becomes per-realm): - - New endpoints, e.g. `GET/POST /auth/api/admin/realms` and - `PATCH/DELETE /auth/api/admin/realms/{rp_id}`, gated on the - `auth:admin` scope. The admin UI gains a realm list/editor. +- `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 new rp-ids + are added after bootstrap, mirroring how rp-name was always edited post + setup: + - `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`; full §3.1 validation (cap, cross-realm - collisions); registry entry added immediately (§3.3), including its - `Passkey` instance and OIDC provider entry (§8). - - Update: same validation against the live registry; changing a - realm's rp-id itself is **not supported** (it would orphan every - credential stamped with the old rp-id) — delete and recreate + 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 client-side subdomain check in `AdminDialogs.vue:70-79` must - relax to accept configured related origins. - (`AdminDialogs.vue:96`'s rp-id connectivity probe keeps working: a - related origin answers with the realm's rp_id.) + - 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, origin count), per-row edit/delete and an + add-realm dialog. The dialog's connectivity probe fetches + `/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; + only malformed entries and an auth host outside the rp-id domain + block saving. - Credential listings: `Credential.rp_id` serializes automatically into - `ApiUserDetail.credentials` (both `GET /auth/api/user-info` and - `GET /auth/api/admin/users/{uuid}` return the raw struct). -- Frontend `CredentialList.vue` (shared by ProfileView and - AdminUserDetail): rp-id badge **only when - `credential.rp_id !== settings.rp_id`** — single-realm installs see no - change; even multi-realm installs only mark foreign passkeys. The - frontend already knows its rp-id (`stores/auth.js`) and already - compares rp-ids elsewhere (`AdminDialogs.vue:95-96`). -- **Enrollment prompt (2.C)**: after a cross-realm remote login, the - profile view offers "Add a passkey for faster login here" (mechanism - exists; UI wiring only). -- Bootstrap/reset links use the default realm's URL - (`db/bootstrap.py:37-43`, `paskia/bootstrap.py:40-89`). -- **Bootstrap caveat to handle**: `check_admin_credentials` - (`bootstrap.py:40-89`) prints a registration link when the first admin - "has no credentials" (`bootstrap.py:73` checks _any_ credential). With - global users, an admin may have passkeys only under _another_ realm's - rp-id — the check must test for an admin credential **under the default - realm's rp-id**, or the printed link is unusable. -- Cosmetic: `admin/users.py:115` picks "user registration" vs "account - recovery" token labels from _any_ credential existing; under global - users this can mislabel (e.g. "recovery" for a user who only lacks a - passkey in this realm). `token_type` is display-only (no gating: - `api.py:369`, `structs.py:469`) — adjust the wording logic, no - security impact. + 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 see no change. +- **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 caveat handled**: 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. Database path, adoption, and migrations +## 10. Storage - Fixed CWD-relative path: **`paskia.kantadb`** — a single kanta JSONL file. Kanta rotation siblings (`paskia@.kantadb`) are - unaffected. `PASKIA_DB` removed with no replacement; - `db/paths.py:8-47` drops the rp-id parameter and the root-directory - logic. CWD selects the deployment as needed. -- **User files** (avatars, `util/avatar.py:20`) move to a fixed sibling - directory **`paskia.data/users/`** (the old `users/` lived under the - per-rp-id directory; the name `paskia.data` is a proposal, see §16). -- **Legacy adoption** (the only supported migration — no multi-database - merging exists or is needed): if `paskia.kantadb` is absent and exactly - one `*.paskiadb` candidate exists in CWD — a directory containing - `main.db`, or a legacy single-file database (`db/paths.py:39-47`'s - `_migrate_legacy_db_file` case) — it is adopted: `main.db` (or the - single file) becomes `paskia.kantadb`, `users/` becomes - `paskia.data/users/`, and the old directory is removed. Multiple - candidates → startup error listing them, asking the operator to remove - or rename strays (e.g. a `*.bak.paskiadb` backup); empty directories - are ignored. Adoption runs as an explicit pre-flight step in the serve - command, **before** the read-only startup open — read-only opens never - trigger adoption or migration writes (verified against kanta: read-only - opens replay migrations in memory before decode and skip all writes, - and old Config shapes decode because `migrate_v7` runs pre-decode). -- Kanta migrations (`db/migrations.py`, name-scanned `migrate_vN`): - - `migrate_v6`: `Credential.rp_id` backfill from old `config.rp_id`. - - `migrate_v7`: `Config` restructure (old fields → `realms[0]`); wrap - `oidc` under the old rp-id key. -- `kanta.ctx.rp_id` is **kept** (set to the default realm) — `migrate_v2` - (`migrations.py:24`) still reads it when replaying v1-era databases; - only its role as "the" rp-id ends. Alternatively harden v2 to tolerate - a missing ctx; keeping the wiring is cheaper. -- The startup box prints per-realm lines (`util/startupbox.py`). + unaffected. There is no environment override; CWD selects the + deployment. +- **User files** (avatars) live in the fixed sibling directory + **`paskia.data/users/`**. +- **Legacy adoption**: if `paskia.kantadb` is absent and exactly one + `*.paskiadb` candidate exists in CWD — a directory containing + `main.db`, or a legacy single-file database — it is adopted: `main.db` + (or the single file) becomes `paskia.kantadb`, `users/` becomes + `paskia.data/users/`, and the old directory is renamed aside to + `.converted-bak`. Multiple candidates → startup error listing + them, asking the operator to remove or rename strays (e.g. a + `*.bak.paskiadb` backup); empty directories are ignored. Adoption runs + as an explicit pre-flight step in the serve command, before the + read-only startup open — read-only opens never trigger adoption or + writes. +- The legacy database's structs are kept in a separate module + (`paskia/db/legacy.py`) that will be deleted once adoption is no longer + supported. No multi-database merging exists or is needed. +- The startup box prints per-realm lines. ## 11. Lifespan and background tasks -- One `Kanta` for `paskia.kantadb`, constructed at import time from the - fixed path (no `PASKIA_CONFIG` dependency in `db/lifecycle.py`), opened - once in the lifespan; single bootstrap hook; one background cleanup - task (`db/background.py`) — unchanged in shape (DB is global). +- One `Kanta` for `paskia.kantadb`, opened once in the lifespan; one + background cleanup task — unchanged in shape (DB is global). - The kanta bootstrap hook only ever fires for a database created by - `paskia init` (which supplies the initial combined `Config`); the serve - command never bootstraps — a missing database is a startup error - pointing at `paskia init` (§3.2). -- Registry built from the stored `Config` after open; per-realm `Passkey` - instances constructed (each realm's origins validated at startup — - fail-fast preserved, now including related-origin cap checks). -- `bootstrap_if_needed` / `check_admin_credentials` still run at serve - startup (reprint a usable registration link when the admin lacks a - credential under the default realm, §9). + `paskia init`; 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; admin - realm writes refresh the registry (§3.3, §9). +- The dispatch middleware is the only place `current_realm` is set for + requests; admin realm writes rebuild the registry. -## 12. Devserver (`scripts/devserver.py`) +## 12. Development -- Extract init-argument parsing into an importable function (e.g. - `paskia/cliconfig.py`); `paskia init` and `devserver.py` share it — no - duplicated logic. -- devserver `--rp-id`/`--auth-host` become multi-value identically - (append + comma-split) and are passed to the init step; forwarding - (`devserver.py:146-156`) loops over the initialized realms. -- Caddy dev origins (`devserver.py:167-183`): iterate all rp-ids and all - effective auth hosts (`build_caddyfile` already takes a list); the dev - Caddyfile also forwards `/.well-known/webauthn`. -- `PASKIA_AUTH_HOST` (consumed by `frontend/vite.config.js:10`) becomes - comma-joined; vite config reads the first — dev-only, keep simple. +- `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 get **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 (breaking - change vs today: direct-IP and unconfigured-name access stop working; - trailing dots normalized). +- **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. - Document the WG's trust warning: all origins sharing an rp-id share one - security boundary — do not mix trust levels within a realm. + 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` (§9) — deployment-wide by design; validation runs on every + `auth:admin` — deployment-wide by design; validation runs on every write, not just at startup. -- **Passkeys**: rp-id binding browser-enforced and now server-recorded; +- **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**: unchanged — 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 (§7.2), with registry-validated target host. +- **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 (document prominently). - Permission `domain` host-scoping unchanged. + intended. `auth:admin` is deployment-wide. Permission `domain` + host-scoping unchanged. - **Cross-realm permit transparency**: requesting realm/host shown to the approver; both sides logged. -- **Secret hygiene in logs**: the OIDC signing-key censoring follows the - new `oidc..key` path shape (§8) — without it, realm keys leak - into the JSONL transaction log. +- **Secret hygiene in logs**: the OIDC signing-key censoring matches the + `oidc..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. - -## 14. Tests - -- `tests/conftest.py`: the import-time `PASKIA_CONFIG` seed - (`conftest.py:30-40`) goes away with the fixed DB path — the app-level - Kanta and the test fixtures chdir to / open a temp directory so - `paskia.kantadb` lands there. Realm config is seeded into the DB - fixture (a two-realm config: `localhost` + `test.example.com`); - `passkey_instance` becomes a registry/current-realm fixture. - `Credential.create` call sites (`conftest.py:189-195,203-210`, - `tests/test_admin.py:95,149`) gain the `rp_id` argument. -- New tests: - - CLI: `paskia init` seeds one/multiple realms; init refuses on an - existing database; serve without a database errors; legacy adoption - (single dir, single file, multiple candidates → error, empty dir - ignored); `--save` gone. - - Admin realm management: create/validate (collisions, related-origin - cap)/update/delete rules (last realm, credential-bearing realm); - registry refresh visible to dispatch without restart. - - dispatch: 421 unknown host; trailing-dot; related-origin hostname → - owning realm (exact only — `www.` variants 421); WS origin-realm - resolution; WS to auth host with app-host Origin accepted; arbitrary - Host/Origin combos rejected. - - credentials: rp_id stamping (incl. ceremony on a related origin → - canonical rp-id); backfill migration; scan/exclude/allow filtering; - no cross-realm oracle. - - **Related Origins server-side (pytest only)**: origin-validation - rules (subtree-OR-listed), `/.well-known/webauthn` contents and - absence-when-unconfigured. **Not e2e**: a genuine related origin - needs a non-subdomain host over HTTPS, and the browser fetches the - well-known document from the browser process itself — not - interceptable in the current plain-HTTP harness - (`playwright.config.js:27`). E2E for ROR requires deliberate TLS/DNS - infra; skip unless that is built. - - cross-realm remote login end-to-end (request at realm B, permit at - realm A, session valid only on B's host); exchange codes minted in - the permit path redeem on the requester's realm (§7.1); arbitrary - `session_host` refused. - - bootstrap caveat: admin with only foreign-realm credentials still - gets a usable registration link for the default realm. - - OIDC: per-realm keys/issuers; issuer stamped from WS Origin (not the - connection Host); refresh re-stamps; backchannel logout selects the - session realm's key with no request context; log censoring covers - `oidc..key`. -- E2E: `global-setup.ts` drops `PASKIA_DB`, spawns `paskia init --rp-id - localhost,test.localhost` with `cwd` in the tmp dir, then serves; add a - `http://test.localhost:4404` project exercising dispatch and a - cross-realm remote login (remote auth currently has no e2e coverage; - this feature needs it). - -## 15. Docs and compatibility - -- README: multi-realm model, `paskia init` bootstrap, combined DB at - `paskia.kantadb`, `PASKIA_DB` removal, Related Origins setup. -- docs/API.md: auth-host section rewritten for own-vs-effective fallback - semantics and cross-realm behavior; `/.well-known/webauthn` documented; - realm-management admin endpoints documented. -- **Related Origins deployment guidance** (the critical operational - bit): the browser fetches `https:///.well-known/webauthn` from - the canonical apex directly. If paskia hosts the apex, our endpoint - serves it; if the apex is hosted elsewhere (typical for marketing - domains), the JSON must be published there statically. Existing - examples serve `/.well-known/*` statically (`docs/proxy/caddy.md`, - `caddy/Caddyfile:10-14`) — they must not shadow paskia's endpoint when - paskia does host it. -- docs/proxy + `caddy/auth/setup`: forward - `/.well-known/openid-configuration` **and** `/.well-known/webauthn`; - the vite dev proxy allowlist (`frontend/vite.config.js:16-24`) gains - both paths; Host preservation requirement unchanged. -- `oidc.md` (root): updated for per-realm providers and `Session.issuer`. -- Breaking changes: DB moved to `paskia.kantadb` (auto-adopted from a - lone legacy `*.paskiadb`; `PASKIA_DB` removed), serve command drops all - realm options and `--save` (use `paskia init` / the admin interface), - `PASKIA_CONFIG` format reduced to serve parameters, - `Config`/`DB.oidc`/`Credential` schema migrations, `paskia/globals.py` - removed, unknown Host → 421, `paskia.util.frontend` removed. - -## 16. Open questions - -1. Same-device redirect flow (§7.3): include in this release or defer? -2. User-files directory name: `paskia.data/` (proposed) vs something - else; it holds only avatars today. -3. Bare `paskia` with no database: proposed behavior is a startup error - pointing at `paskia init`. Alternative: keep today's zero-config dev - experience by auto-initializing a `localhost` realm. Strictness avoids - bootstrap/runtime mixups; auto-init is friendlier for first contact. - -(Settled during review, for the record: fallback-auth-host UI semantics — -a realm's UI lives on its own hosts, the fallback auth host serves WS and -restricted APIs for foreign realms plus the owner realm's UI; settings -exposes own vs. effective auth host, §6. OIDC logout signing without -request context — `Session.rp_id` + `Session.issuer` fields, §8. Related -Origins e2e — pytest only, §14. Realm configuration after bootstrap — -admin interface only, serve takes no realm options, §3.2. Combined DB -name and location — `paskia.kantadb` in CWD, §10.) diff --git a/docs/proxy/apisix.md b/docs/proxy/apisix.md index 5361952..3d7716f 100644 --- a/docs/proxy/apisix.md +++ b/docs/proxy/apisix.md @@ -173,4 +173,4 @@ The auth check then always returns 204 (except reauth with `max_age`, which stil - The auth request is `GET` by default. Since the `forward-auth` plugin does not forward the request body unless `request_method` is set to `POST`, the default `GET` is the right choice for Paskia. - Hop-by-hop headers are handled by APISIX when it builds the auth request, so no extra configuration is needed for `Connection`/`Upgrade`. -- If Paskia is running on a different host, replace `localhost:4401` with the Paskia service address. For a dedicated authentication host (`--auth-host`), route `auth.example.com` to Paskia instead of `/auth/`. +- If Paskia is running on a different host, replace `localhost:4401` with the Paskia service address. For a dedicated authentication host (the realm's auth-host setting), route `auth.example.com` to Paskia instead of `/auth/`. diff --git a/docs/proxy/caddy.md b/docs/proxy/caddy.md index bab71c2..5e23d40 100644 --- a/docs/proxy/caddy.md +++ b/docs/proxy/caddy.md @@ -81,7 +81,7 @@ auth.example.com { } ``` -Remember to specify `paskia serve --auth-host auth.example.com` to restrict the authentication services to this domain. +Remember to set the auth host for the realm — either `paskia init --auth-host auth.example.com` at bootstrap or in the admin panel's Realms section — to restrict the authentication services to this domain. Note that we still reserve `/auth/` on each site for logout page and any APIs your application may require, while full user profile and global options are only available on the auth host. diff --git a/docs/proxy/envoy.md b/docs/proxy/envoy.md index e409103..8e5194f 100644 --- a/docs/proxy/envoy.md +++ b/docs/proxy/envoy.md @@ -161,7 +161,7 @@ See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) fo ## WebSocket support for `/auth/` -If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia cluster and you do not need the `/auth/` bypass above. Otherwise, make sure the `/auth/` route keeps the `Upgrade` and `Connection` headers so passkey WebSocket endpoints work. The default Envoy router handles `Upgrade` headers when the client requests them. +If you use a dedicated authentication host (the realm's auth-host setting), route `auth.example.com` to the Paskia cluster and you do not need the `/auth/` bypass above. Otherwise, make sure the `/auth/` route keeps the `Upgrade` and `Connection` headers so passkey WebSocket endpoints work. The default Envoy router handles `Upgrade` headers when the client requests them. ## Public access diff --git a/docs/proxy/haproxy.md b/docs/proxy/haproxy.md index 39f0156..df22476 100644 --- a/docs/proxy/haproxy.md +++ b/docs/proxy/haproxy.md @@ -123,4 +123,4 @@ The `Remote-*` success-headers glob already copies the `Remote-Public` header th - The Lua script strips the request body from the auth subrequest, so Paskia's `/auth/api/forward` will only see the headers. - HAProxy variables are limited to alphanumeric characters, dots, and underscores, but the script already normalizes header names for you (e.g. `Remote-User` becomes `req.auth_response_header.remote_user`). The `Remote-*` glob pattern in the success-headers argument handles this automatically. - The auth backend must be reachable without TLS. If you need TLS to Paskia, run a local TCP forwarder or use HAProxy's Lua HTTP support directly (not covered by this script). -- If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia backend and start Paskia with `--auth-host auth.example.com` instead of exposing `/auth/` on every site. +- If you use a dedicated authentication host (the realm's auth-host setting), route `auth.example.com` to the Paskia backend instead of exposing `/auth/` on every site. diff --git a/docs/proxy/traefik.md b/docs/proxy/traefik.md index 180c0d8..138e333 100644 --- a/docs/proxy/traefik.md +++ b/docs/proxy/traefik.md @@ -92,7 +92,7 @@ authResponseHeaders: The `/auth/` router above forwards all authentication UI, API, and WebSocket traffic to Paskia. Because this router does **not** use the `paskia-auth` middleware, users can reach the login page and profile UI without being authenticated first. Traefik handles WebSocket upgrades automatically when the client requests them. -If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and start Paskia with `--auth-host auth.example.com`. +If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and set the realm's auth host (`paskia init --auth-host auth.example.com` at bootstrap, or the admin panel's Realms section). ## Adjusting requirements diff --git a/oidc.md b/oidc.md index fb290ec..b164f85 100644 --- a/oidc.md +++ b/oidc.md @@ -2,11 +2,15 @@ OpenID Connect 1.0 provider enabling third-party apps to authenticate users via passkey. Also supports native cookie-based authentication. +## Realms (multi rp-id) + +Each realm (rp-id) is an independent OIDC issuer with its own signing key and clients: `DB.oidc` is `dict[rp_id, OIDC]` and per-realm key files are `oidc..key`. Discovery, keys, token and userinfo endpoints resolve the issuer from the request host (realm dispatch). `Session.issuer` records the realm that issued an OIDC session so refresh and back-channel logout select the right key, and auth codes (`OIDCCode`, `CookieCode`) are stamped with the realm's rp-id and verified against it at redemption. + ## Data Models **User** — Added: `email`, `preferred_username` -**Session** — Added: `client_uuid` (None = native, set = OIDC) +**Session** — Added: `client_uuid` (None = native, set = OIDC), `rp_id`/`issuer` (realm that authenticated / issued the session) - `key: bytes` — hashed DB key, never stored raw - `secret` → `hash_secret("session", secret)` → DB lookup - OIDC `sid` → `base64url.encode(hash_secret("oidc", session.key))` @@ -89,4 +93,4 @@ Discovery: `backchannel_logout_supported: true` **Created:** [paskia/authcode.py](paskia/authcode.py), [paskia/util/crypto.py](paskia/util/crypto.py), [paskia/fastapi/oid.py](paskia/fastapi/oid.py) -**Modified:** [paskia/db/structs.py](paskia/db/structs.py), [paskia/db/operations.py](paskia/db/operations.py), [paskia/fastapi/ws.py](paskia/fastapi/ws.py), [paskia/fastapi/api.py](paskia/fastapi/api.py), [paskia/globals.py](paskia/globals.py), [paskia/fastapi/mainapp.py](paskia/fastapi/mainapp.py) +**Modified:** [paskia/db/structs.py](paskia/db/structs.py), [paskia/db/operations.py](paskia/db/operations.py), [paskia/fastapi/ws.py](paskia/fastapi/ws.py), [paskia/fastapi/api.py](paskia/fastapi/api.py), [paskia/realms.py](paskia/realms.py), [paskia/fastapi/mainapp.py](paskia/fastapi/mainapp.py)