From f2e6f5784ed467f0085e18811b46b88d1c0de6f5 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 7 Sep 2026 02:05:12 +0000 Subject: [PATCH] Rename realms to domains; object-keyed origins/related config format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/API.md | 20 +- docs/MultiSite.md | 404 +++++++++--------- 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 +- ...tirealm.spec.ts => 50-multidomain.spec.ts} | 76 ++-- e2e/tests/fixtures/remote-auth.ts | 6 +- e2e/tests/global-setup.ts | 6 +- frontend/src/assets/style.css | 6 +- paskia/db/__init__.py | 8 +- paskia/db/legacy.py | 2 +- paskia/db/operations.py | 2 +- paskia/domains.py | 8 +- paskia/fastapi/admin/adminapp.py | 2 +- paskia/fastapi/admin/domains.py | 6 +- paskia/fastapi/admin/oidc_clients.py | 2 +- paskia/fastapi/admin/permissions.py | 2 +- paskia/fastapi/admin/users.py | 2 +- paskia/fastapi/api.py | 2 +- paskia/fastapi/remote.py | 6 +- paskia/fastapi/user.py | 2 +- paskia/fastapi/ws.py | 6 +- paskia/fastapi/wschat.py | 2 +- paskia/fastapi/wsutil.py | 2 +- paskia/util/hostutil.py | 21 - scripts/devserver.py | 12 +- tests/conftest.py | 18 +- tests/test_admin.py | 208 +++++---- tests/test_api.py | 16 +- tests/test_cli.py | 32 +- tests/{test_realms.py => test_domains.py} | 389 ++++++++--------- 33 files changed, 627 insertions(+), 651 deletions(-) rename e2e/tests/{50-multirealm.spec.ts => 50-multidomain.spec.ts} (65%) rename tests/{test_realms.py => test_domains.py} (66%) diff --git a/docs/API.md b/docs/API.md index ce485a6..25db48c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -72,14 +72,14 @@ 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/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?, related_origins?}` | 200/400/401/403 | -| PATCH | /auth/api/admin/realms/{rp_id} | Update realm rp_name/auth_host/origins/related_origins | 200/400/401/403 | -| DELETE | /auth/api/admin/realms/{rp_id} | Delete realm (refused while credentials remain) | 200/400/401/403 | +| GET | /auth/api/admin/domains/ | List domains (rp-ids) with derived URLs | 200/401/403 | +| POST | /auth/api/admin/domains/ | Create domain `{rp_id, rp_name?, origins?, related?}` | 200/400/401/403 | +| PATCH | /auth/api/admin/domains/{rp_id} | Update domain rp_name/origins/related | 200/400/401/403 | +| DELETE | /auth/api/admin/domains/{rp_id} | Delete domain (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. +Domain endpoints require the `auth:admin` permission; writes additionally require recent authentication (5 minutes). Changes are validated cross-domain and apply immediately. -`origins` is an allow-list of sign-in sites *within* the realm's domain (empty = the rp-id and all subdomains may authenticate). `related_origins` lists *other* domains that may assert this realm's rp-id (WebAuthn Related Origin Requests, max 5); those are published at `/.well-known/webauthn` on the rp-id host. Entries filed under the wrong list are rejected: cross-domain entries in `origins`, in-domain entries in `related_origins`. +`origins` is an object keyed by sign-in sites *within* the domain (bare hosts, `*.` wildcards, or full origins when not https); an empty object means the rp-id and all subdomains may authenticate. A value of `true` marks presence; `{"auth_host": true}` additionally marks the entry as the domain's authentication host. `related` is an object keyed by *other* domains that may assert this domain's rp-id (WebAuthn Related Origin Requests, max 5); those are published at `/.well-known/webauthn` on the rp-id host. Entries filed under the wrong map are rejected: cross-domain entries in `origins`, in-domain entries in `related`. ### WebSockets: /auth/ws/* @@ -94,7 +94,7 @@ These are for internal use only, but are documented here because they are the co ### 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. +A domain may configure a dedicated authentication host (auth-host, a subdomain of the rp-id), either at bootstrap (`paskia init --auth-host`) or via the Domains admin panel. #### On the auth host: - The Web UI is served at site root instead of /auth/* (that redirects to root paths) @@ -108,10 +108,10 @@ A realm may configure a dedicated authentication host (auth-host, a subdomain of 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 +#### Shared auth host across domains -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). +When one domain has an auth host, other domains 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 domain's *own* auth host only). `/auth/api/settings` exposes both: `auth_host` (effective) and `own_auth_host` (this domain 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). +`GET /.well-known/webauthn` returns `{"origins": [...]}` listing the domain'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 domain's rp-id. Returns 404 when the domain 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 9787e6b..d1ee310 100644 --- a/docs/MultiSite.md +++ b/docs/MultiSite.md @@ -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//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=` 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:///.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 " — 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 " — 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 `.paskiadb` database (§10) to `paskia.kantadb`. With several legacy candidates, `--rp-id` selects `.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..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 - `/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..key` path shape, so realm keys never print in plaintext + `oidc..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. diff --git a/docs/proxy/apisix.md b/docs/proxy/apisix.md index 3d7716f..aa4b4d0 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 (the realm's auth-host setting), 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 domain'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 5e23d40..64aae65 100644 --- a/docs/proxy/caddy.md +++ b/docs/proxy/caddy.md @@ -81,7 +81,7 @@ auth.example.com { } ``` -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. +Remember to set the auth host for the domain — either `paskia init --auth-host auth.example.com` at bootstrap or in the admin panel's Domains 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 8e5194f..edb900a 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 (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. +If you use a dedicated authentication host (the domain'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 df22476..2b684c5 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 (the realm's auth-host setting), route `auth.example.com` to the Paskia backend instead of exposing `/auth/` on every site. +- If you use a dedicated authentication host (the domain'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 138e333..e2681ee 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 set the realm's auth host (`paskia init --auth-host auth.example.com` at bootstrap, or the admin panel's Realms section). +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 domain's auth host (`paskia init --auth-host auth.example.com` at bootstrap, or the admin panel's Domains section). ## Adjusting requirements diff --git a/e2e/tests/50-multirealm.spec.ts b/e2e/tests/50-multidomain.spec.ts similarity index 65% rename from e2e/tests/50-multirealm.spec.ts rename to e2e/tests/50-multidomain.spec.ts index ef277a8..d54c43f 100644 --- a/e2e/tests/50-multirealm.spec.ts +++ b/e2e/tests/50-multidomain.spec.ts @@ -11,33 +11,33 @@ import { } from './fixtures/remote-auth' /** - * Multi-realm E2E tests. + * Multi-domain E2E tests. * - * The server is bootstrapped with two realms: localhost (default) and + * The server is bootstrapped with two domains: localhost (default) and * test.localhost. Chrome resolves any *.localhost hostname to loopback, so - * both realms are reachable over real HTTP from the browser. + * both domains are reachable over real HTTP from the browser. * * Covers: - * - Host-based realm dispatch (settings, 421 for unknown hosts) - * - Related Origin Requests well-known endpoint + admin realm API - * - Cross-realm remote login: a passkey registered on localhost permits a + * - Host-based domain dispatch (settings, 421 for unknown hosts) + * - Related Origin Requests well-known endpoint + admin domain API + * - Cross-domain remote login: a passkey registered on localhost permits a * session on test.localhost via pairing code - * - The profile enrollment prompt on a realm where the user has no passkey + * - The profile enrollment prompt on a domain where the user has no passkey */ -test.describe('Multi-realm E2E', () => { +test.describe('Multi-domain E2E', () => { test.describe.configure({ mode: 'serial' }) const baseUrl = process.env.BASE_URL || 'http://localhost:4404' - const realmUrl = 'http://test.localhost:4404' + const domainUrl = 'http://test.localhost:4404' - test('dispatches realms by host header', async ({ page }) => { + test('dispatches domains by host header', async ({ page }) => { // Browser navigation: Chrome maps *.localhost to loopback - const realmResp = await page.goto(`${realmUrl}/auth/api/settings`) - expect(realmResp?.status()).toBe(200) - const realmSettings = await realmResp?.json() - expect(realmSettings.rp_id).toBe('test.localhost') - expect(realmSettings.own_auth_host).toBeNull() + const domainResp = await page.goto(`${domainUrl}/auth/api/settings`) + expect(domainResp?.status()).toBe(200) + const domainSettings = await domainResp?.json() + expect(domainSettings.rp_id).toBe('test.localhost') + expect(domainSettings.own_auth_host).toBeNull() const defaultResp = await page.goto(`${baseUrl}/auth/api/settings`) expect(defaultResp?.status()).toBe(200) @@ -58,8 +58,8 @@ test.describe('Multi-realm E2E', () => { expect(before.status()).toBe(404) }) - test('master admin manages realms and related origins via API', async ({ page, virtualAuthenticator }) => { - // Fresh session via device token (realm writes require recent auth) + test('master admin manages domains and related origins via API', async ({ page, virtualAuthenticator }) => { + // Fresh session via device token (domain writes require recent auth) const deviceToken = popDeviceToken() test.skip(!deviceToken, 'No device tokens available') await page.goto('/auth/') @@ -68,18 +68,18 @@ test.describe('Multi-realm E2E', () => { const headers = { Cookie: `${getSessionCookieName()}=${reg.session_token}` } - // List realms - const list = await page.request.get(`${baseUrl}/auth/api/admin/realms/`, { headers }) + // List domains + const list = await page.request.get(`${baseUrl}/auth/api/admin/domains/`, { headers }) expect(list.ok()).toBeTruthy() - const realms = await list.json() - expect(realms.map((r: any) => r.rp_id).sort()).toEqual(['localhost', 'test.localhost']) - const localhostRealm = realms.find((r: any) => r.rp_id === 'localhost') - expect(localhostRealm.is_default).toBe(true) + const domains = await list.json() + expect(domains.map((r: any) => r.rp_id).sort()).toEqual(['localhost', 'test.localhost']) + const localhostDomain = domains.find((r: any) => r.rp_id === 'localhost') + expect(localhostDomain.origins).toEqual({}) - // Add a related origin (unrelated domain) to the localhost realm - const patch = await page.request.patch(`${baseUrl}/auth/api/admin/realms/localhost`, { + // Add a related origin (unrelated domain) to the localhost domain + const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, { headers, - data: { rp_name: '', auth_host: '', origins: [], related_origins: ['https://app.example.com'] }, + data: { rp_name: '', origins: {}, related: { 'app.example.com': true } }, }) expect(patch.ok()).toBeTruthy() @@ -90,16 +90,16 @@ test.describe('Multi-realm E2E', () => { expect(wkJson.origins).toContain('https://app.example.com') // Restore: remove related origins again so later tests see the pristine state - const restore = await page.request.patch(`${baseUrl}/auth/api/admin/realms/localhost`, { + const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, { headers, - data: { rp_name: '', auth_host: '', origins: [], related_origins: [] }, + data: { rp_name: '', origins: {}, related: {} }, }) expect(restore.ok()).toBeTruthy() const after = await page.request.get(`${baseUrl}/.well-known/webauthn`) expect(after.status()).toBe(404) }) - test('cross-realm remote login via pairing code', async ({ page, virtualAuthenticator }) => { + test('cross-domain remote login via pairing code', async ({ page, virtualAuthenticator }) => { // Register a fresh passkey on localhost (this test's virtual authenticator) const deviceToken = popDeviceToken() test.skip(!deviceToken, 'No device tokens available') @@ -107,19 +107,19 @@ test.describe('Multi-realm E2E', () => { const reg = await registerPasskey(page, baseUrl, { resetToken: deviceToken }) expect(reg.session_token).toBeTruthy() - // Requester page on the other realm (no session there) + // Requester page on the other domain (no session there) const reqPage = await page.context().newPage() - await reqPage.goto(`${realmUrl}/auth/`) + await reqPage.goto(`${domainUrl}/auth/`) const pairingCode = await startRemoteAuthRequest(reqPage) expect(pairingCode.split('.')).toHaveLength(3) // Approver permits with the localhost passkey; the "found" message names - // the requesting realm + // the requesting domain const found = await permitRemoteAuth(page, pairingCode) expect(found.rp_id).toBe('test.localhost') - // The requester redeems the exchange code on its own realm and the + // The requester redeems the exchange code on its own domain and the // session validates there for the same user const validation = await awaitRemoteAuthSession(reqPage) expect(validation.ctx.user.uuid).toBe(reg.user) @@ -133,13 +133,13 @@ test.describe('Multi-realm E2E', () => { const current = Object.values(userInfo.sessions as any[]).find((s: any) => s.is_current) as any expect(current.host).toContain('test.localhost') - // The profile on test.localhost prompts adding a passkey for this realm, - // and the existing localhost passkey carries a realm badge - await reqPage.goto(`${realmUrl}/auth/`) - const notice = reqPage.locator('.realm-enroll-notice') + // The profile on test.localhost prompts adding a passkey for this domain, + // and the existing localhost passkey carries a domain badge + await reqPage.goto(`${domainUrl}/auth/`) + const notice = reqPage.locator('.domain-enroll-notice') await expect(notice).toBeVisible({ timeout: 15000 }) await expect(notice).toContainText('test.localhost') - await expect(reqPage.locator('.badge-realm').first()).toHaveText('localhost') + await expect(reqPage.locator('.badge-domain').first()).toHaveText('localhost') await reqPage.close() }) diff --git a/e2e/tests/fixtures/remote-auth.ts b/e2e/tests/fixtures/remote-auth.ts index b3f3957..dc83b9e 100644 --- a/e2e/tests/fixtures/remote-auth.ts +++ b/e2e/tests/fixtures/remote-auth.ts @@ -32,7 +32,7 @@ const b64helpersSource = ` /** * Start a remote auth request on the given page (the device wanting to log in). - * The page must already be navigated to the requesting realm's origin. + * The page must already be navigated to the requesting domain's origin. * Keeps the WebSocket open on window.__raWs and collects later messages into * window.__raMsgs; resolves with the pairing code. */ @@ -107,8 +107,8 @@ export async function awaitRemoteAuthSession(page: Page, timeoutMs = 90000): Pro /** * Permit a remote auth request from the given page (the authenticating device). * The page must be on the approver's origin with a valid session cookie and a - * virtual authenticator holding a credential for that realm. - * Resolves with the "found" message (includes the requesting realm's rp_id). + * virtual authenticator holding a credential for that domain. + * Resolves with the "found" message (includes the requesting domain's rp_id). */ export async function permitRemoteAuth(page: Page, code: string): Promise { return page.evaluate(async ({ code, powSrc, b64src }) => { diff --git a/e2e/tests/global-setup.ts b/e2e/tests/global-setup.ts index 0a140b3..a6cf33b 100644 --- a/e2e/tests/global-setup.ts +++ b/e2e/tests/global-setup.ts @@ -20,7 +20,7 @@ interface TestState { /** * Global setup for E2E tests. * - * Bootstraps a fresh combined database (paskia.kantadb) with two realms — + * Bootstraps a fresh combined database (paskia.kantadb) with two domains — * localhost (default) and test.localhost — then starts the server with the * test data directory as its working directory. Captures the bootstrap reset * token from 'paskia init' output for initial user registration. @@ -44,7 +44,7 @@ export default async function globalSetup() { const state: TestState = {} - // Bootstrap the database: two realms, localhost (default) and test.localhost + // Bootstrap the database: two domains, localhost (default) and test.localhost console.log(' Bootstrapping database with paskia init...') const initResult = spawnSync( 'uv', @@ -125,7 +125,7 @@ export default async function globalSetup() { } state.sessionCookie = settings.session_cookie console.log(` ✅ Session cookie name: ${state.sessionCookie}`) - console.log(` ✅ Realm: ${settings.rp_id} (${settings.rp_name})\n`) + console.log(` ✅ Domain: ${settings.rp_id} (${settings.rp_name})\n`) // Save state for tests writeFileSync(stateFile, JSON.stringify(state, null, 2)) diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 435760a..41c3658 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -804,12 +804,12 @@ th { border: 1px solid var(--color-border); } -.badge-realm { +.badge-domain { font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; font-size: 0.75rem; } -.realm-enroll-notice { +.domain-enroll-notice { display: flex; flex-wrap: wrap; align-items: center; @@ -822,7 +822,7 @@ th { background: var(--color-surface-subtle); } -.realm-enroll-notice p { +.domain-enroll-notice p { margin: 0; } diff --git a/paskia/db/__init__.py b/paskia/db/__init__.py index 657df58..c1c3967 100644 --- a/paskia/db/__init__.py +++ b/paskia/db/__init__.py @@ -25,18 +25,18 @@ from paskia.db.operations import ( add_permission_to_role, create_credential, create_credential_session, + create_domain, create_oid_client, create_org, create_permission, - create_domain, create_reset_token, create_role, create_user, delete_credential, + delete_domain, delete_oid_client, delete_org, delete_permission, - delete_domain, delete_reset_token, delete_role, delete_session, @@ -51,10 +51,10 @@ from paskia.db.operations import ( set_session_host, update_config, update_credential_sign_count, + update_domain, update_oid_client, update_org_name, update_permission, - update_domain, update_role_name, update_session, update_user_display_name, @@ -67,9 +67,9 @@ from paskia.db.structs import ( Client, Config, Credential, + DomainConfig, Org, Permission, - DomainConfig, ResetToken, Role, Session, diff --git a/paskia/db/legacy.py b/paskia/db/legacy.py index f190f38..c44514b 100644 --- a/paskia/db/legacy.py +++ b/paskia/db/legacy.py @@ -28,10 +28,10 @@ from paskia.db.structs import ( OIDC, Config, Credential, + DomainConfig, Org, OriginEntry, Permission, - DomainConfig, ResetToken, Role, Session, diff --git a/paskia/db/operations.py b/paskia/db/operations.py index 8b9dad9..ac327cf 100644 --- a/paskia/db/operations.py +++ b/paskia/db/operations.py @@ -21,10 +21,10 @@ from paskia.db.structs import ( Client, Config, Credential, + DomainConfig, Org, OriginEntry, Permission, - DomainConfig, ResetToken, Role, Session, diff --git a/paskia/domains.py b/paskia/domains.py index 045c5b6..f63dcaa 100644 --- a/paskia/domains.py +++ b/paskia/domains.py @@ -189,9 +189,7 @@ def validate_config( f"'{rp_id}' — configure it as a related origin instead" ) if is_auth: - raise ValueError( - f"Wildcard origin '{key}' cannot be the auth host" - ) + raise ValueError(f"Wildcard origin '{key}' cannot be the auth host") continue hn = hostutil.origin_hostname(origin_url(key)) if not hn: @@ -423,9 +421,7 @@ def _derive_site( return auth, "/" if rp_id in domain.origins: return origin_url(rp_id), "/auth/" - concrete = sorted( - k for k in domain.origins if not hostutil.is_wildcard_pattern(k) - ) + concrete = sorted(k for k in domain.origins if not hostutil.is_wildcard_pattern(k)) if concrete: return origin_url(concrete[0]), "/auth/" if rp_id == "localhost": diff --git a/paskia/fastapi/admin/adminapp.py b/paskia/fastapi/admin/adminapp.py index e4be429..364cc38 100644 --- a/paskia/fastapi/admin/adminapp.py +++ b/paskia/fastapi/admin/adminapp.py @@ -3,6 +3,7 @@ from uuid import UUID from fastapi import FastAPI, Request from paskia import db +from paskia.domains import current_domain from paskia.fastapi import authz from paskia.fastapi.admin import ( domains, @@ -16,7 +17,6 @@ from paskia.fastapi.admin.errors import install_error_handlers from paskia.fastapi.front import frontend from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.session import AUTH_COOKIE -from paskia.domains import current_domain from paskia.util import ( avatar, permutil, diff --git a/paskia/fastapi/admin/domains.py b/paskia/fastapi/admin/domains.py index a71e69c..1938eeb 100644 --- a/paskia/fastapi/admin/domains.py +++ b/paskia/fastapi/admin/domains.py @@ -25,7 +25,9 @@ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) install_error_handlers(app) -def _domain_to_api(domain: domains.Domain, registry: domains.DomainRegistry) -> ApiDomain: +def _domain_to_api( + domain: domains.Domain, registry: domains.DomainRegistry +) -> ApiDomain: return ApiDomain( rp_id=domain.rp_id, rp_name=domain.rp_name, @@ -58,7 +60,7 @@ def _normalize_origins_map(values: dict | None) -> dict[str, bool | OriginEntry] def _normalize_related_map(values: dict | None) -> dict[str, bool]: """Normalize a related-origins object from the admin UI.""" out: dict[str, bool] = {} - for raw_key in (values or {}): + for raw_key in values or {}: key = raw_key.strip() if not key: continue diff --git a/paskia/fastapi/admin/oidc_clients.py b/paskia/fastapi/admin/oidc_clients.py index d6090aa..6a2fde0 100644 --- a/paskia/fastapi/admin/oidc_clients.py +++ b/paskia/fastapi/admin/oidc_clients.py @@ -5,10 +5,10 @@ from fastapi import Body, FastAPI, HTTPException, Request from paskia import db from paskia.db.operations import _UNSET from paskia.db.structs import Client +from paskia.domains import current_domain from paskia.fastapi import authz from paskia.fastapi.admin.errors import install_error_handlers from paskia.fastapi.session import AUTH_COOKIE -from paskia.domains import current_domain from paskia.util import permutil app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) diff --git a/paskia/fastapi/admin/permissions.py b/paskia/fastapi/admin/permissions.py index 56c3132..e8b785e 100644 --- a/paskia/fastapi/admin/permissions.py +++ b/paskia/fastapi/admin/permissions.py @@ -4,10 +4,10 @@ from fastapi import Body, FastAPI, Query, Request from paskia import db from paskia.db import Permission as PermDC +from paskia.domains import registry from paskia.fastapi import authz from paskia.fastapi.admin.errors import install_error_handlers from paskia.fastapi.session import AUTH_COOKIE -from paskia.domains import registry from paskia.util import hostutil, permutil, querysafe app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) diff --git a/paskia/fastapi/admin/users.py b/paskia/fastapi/admin/users.py index ab1e894..818322a 100644 --- a/paskia/fastapi/admin/users.py +++ b/paskia/fastapi/admin/users.py @@ -5,11 +5,11 @@ from fastapi import Body, FastAPI, HTTPException, Request from paskia import aaguid as aaguid_mod from paskia import db from paskia.authsession import reset_expires +from paskia.domains import current_domain from paskia.fastapi import authz from paskia.fastapi.admin.errors import install_error_handlers from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.session import AUTH_COOKIE -from paskia.domains import current_domain from paskia.util import avatar, hostutil, permutil from paskia.util.apistructs import ( ApiAaguidInfo, diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index 594ec15..09f172b 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -17,10 +17,10 @@ from fastapi.security import HTTPBearer from paskia import authcode, db from paskia._version import __version__ from paskia.authsession import EXPIRES, get_reset, session_ctx +from paskia.domains import current_domain, registry from paskia.fastapi import authz, session, user from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip -from paskia.domains import current_domain, registry from paskia.util import hostutil, htmlutil, passphrase, permutil, userinfo from paskia.util.apistructs import ( ApiCheckUserResponse, diff --git a/paskia/fastapi/remote.py b/paskia/fastapi/remote.py index 411ea61..083fcf2 100644 --- a/paskia/fastapi/remote.py +++ b/paskia/fastapi/remote.py @@ -19,10 +19,10 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect from paskia import authcode, db, remoteauth from paskia.authcode import CookieCode from paskia.authsession import expires +from paskia.domains import current_domain, registry from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.wschat import authenticate_and_login from paskia.fastapi.wsutil import validate_origin, websocket_error_handler -from paskia.domains import current_domain, registry from paskia.util import pow, useragent # Create a FastAPI subapp for remote auth WebSocket endpoints @@ -454,7 +454,9 @@ async def websocket_remote_auth_permit(ws: WebSocket, auth=AUTH_COOKIE): "host": request.host, "rp_id": request.rp_id, "rp_name": ( - requesting_domain.rp_name if requesting_domain else request.rp_id + requesting_domain.rp_name + if requesting_domain + else request.rp_id ), "user_agent_pretty": useragent.compact_user_agent( request.user_agent diff --git a/paskia/fastapi/user.py b/paskia/fastapi/user.py index c2163ea..c775116 100644 --- a/paskia/fastapi/user.py +++ b/paskia/fastapi/user.py @@ -17,10 +17,10 @@ from paskia.authsession import ( expires, session_ctx, ) +from paskia.domains import current_domain from paskia.fastapi import authz, session from paskia.fastapi.response import MsgspecResponse from paskia.fastapi.session import AUTH_COOKIE -from paskia.domains import current_domain from paskia.util import avatar from paskia.util.apistructs import ApiCreateLinkResponse diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 1d64dcb..229488f 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -9,6 +9,7 @@ from paskia import authcode, db from paskia.authcode import CookieCode, OIDCCode from paskia.authsession import get_reset, session_ctx from paskia.db.structs import Session +from paskia.domains import current_domain from paskia.fastapi import authz, remote from paskia.fastapi.session import AUTH_COOKIE, infodict from paskia.fastapi.wschat import ( @@ -17,7 +18,6 @@ from paskia.fastapi.wschat import ( register_chat, ) from paskia.fastapi.wsutil import validate_origin, websocket_error_handler -from paskia.domains import current_domain from paskia.util import hostutil, passphrase from paskia.util.crypto import hash_secret @@ -148,7 +148,9 @@ async def websocket_authenticate( await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"}) return # Store as the only allowed redirect URI - db.update_oid_client(domain.rp_id, client_uuid, redirect_uris=[redirect_uri]) + db.update_oid_client( + domain.rp_id, client_uuid, redirect_uris=[redirect_uri] + ) # Reload client to get updated redirect_uris oidc_client = db.data().oidc_for(domain.rp_id).clients.get(client_uuid) elif redirect_uri not in oidc_client.redirect_uris: diff --git a/paskia/fastapi/wschat.py b/paskia/fastapi/wschat.py index 242cdf8..feb4aed 100644 --- a/paskia/fastapi/wschat.py +++ b/paskia/fastapi/wschat.py @@ -9,9 +9,9 @@ from fastapi import WebSocket from paskia import db from paskia.authsession import session_ctx from paskia.db import Credential, SessionContext +from paskia.domains import current_domain, registry from paskia.fastapi.session import infodict from paskia.fastapi.wsutil import validate_origin -from paskia.domains import current_domain, registry from paskia.util import hostutil diff --git a/paskia/fastapi/wsutil.py b/paskia/fastapi/wsutil.py index b8125b8..5515285 100644 --- a/paskia/fastapi/wsutil.py +++ b/paskia/fastapi/wsutil.py @@ -9,8 +9,8 @@ import base64url from fastapi import WebSocket, WebSocketDisconnect from webauthn.helpers.exceptions import InvalidAuthenticationResponse -from paskia.fastapi import authz from paskia.domains import current_domain +from paskia.fastapi import authz from paskia.util import pow diff --git a/paskia/util/hostutil.py b/paskia/util/hostutil.py index 19b7505..1dea746 100644 --- a/paskia/util/hostutil.py +++ b/paskia/util/hostutil.py @@ -76,27 +76,6 @@ def auth_host_netloc(auth_host: str) -> str | None: return parsed.netloc or parsed.path or None -def normalize_auth_host_and_origins( - auth_host: str | None, origins: list[str] | None -) -> tuple[str | None, list[str] | None]: - """Normalize auth_host and origins. - - - Adds https:// to auth_host if no scheme present, strips trailing slashes - - Validates auth_host is a well-formed subdomain (caller provides rp_id via validate_auth_host) - - Inserts auth_host as first origin if both are specified and not already present - - Deduplicates origins while preserving order - """ - if auth_host: - if "://" not in auth_host: - auth_host = f"https://{auth_host}" - auth_host = auth_host.rstrip("/") - if origins is not None and auth_host not in origins: - origins.insert(0, auth_host) - if origins: - origins = list(dict.fromkeys(origins)) - return auth_host, origins - - def normalize_host(raw_host: str | None) -> str | None: """Normalize a Host header, stripping port numbers and trailing dots.""" if not raw_host: diff --git a/scripts/devserver.py b/scripts/devserver.py index 0a175d0..78214d0 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -150,7 +150,7 @@ def _split_multi(values: list[str] | None) -> list[str]: def ensure_database(rp_ids: list[str], args: argparse.Namespace, listen: str) -> None: """Bootstrap paskia.kantadb via 'paskia init' when no database exists. - Realm options are init-only; 'paskia' (serve) reads all configuration + Domain options are init-only; 'paskia' (serve) reads all configuration from the database. A legacy *.paskiadb database must be converted with 'paskia migrate' first. """ @@ -192,7 +192,7 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None: rp_ids = _split_multi(args.rp_id) or ["localhost"] ensure_database(rp_ids, args, listen=backurl.removeprefix("http://")) - # Serve: no realm options — all configuration lives in the database + # Serve: no domain options — all configuration lives in the database paskia.extend(remaining) # Set environment for subprocesses @@ -251,10 +251,10 @@ def main(): "--rp-id", action="append", help="Relying Party ID(s) for first-run bootstrap (default: localhost). " - "Repeatable and comma-separated; the first is the default realm.", + "Repeatable and comma-separated; the bootstrap name/host options apply to the first.", ) parser.add_argument( - "--rp-name", help="Relying Party name of the default realm (bootstrap only)" + "--rp-name", help="Relying Party name of the first domain (bootstrap only)" ) parser.add_argument( "--origin", @@ -262,7 +262,9 @@ def main(): dest="origins", help="Allowed origin(s), bootstrap only", ) - parser.add_argument("--auth-host", help="Dedicated auth host for the default realm") + parser.add_argument( + "--auth-host", help="Dedicated auth host for the first domain (bootstrap only)" + ) args, remaining = parser.parse_known_args() with suppress(KeyboardInterrupt): diff --git a/tests/conftest.py b/tests/conftest.py index af2ab07..53edf2b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,7 +26,7 @@ import pytest_asyncio from kanta import Kanta import paskia.db.operations as ops_db -from paskia import realms +from paskia import domains from paskia.authsession import reset_expires from paskia.config import SESSION_LIFETIME from paskia.db import ( @@ -42,7 +42,7 @@ from paskia.db import ( ) from paskia.db.bootstrap import bootstrap from paskia.db.operations import DB -from paskia.db.structs import Config, RealmConfig, Session +from paskia.db.structs import Config, DomainConfig, Session from paskia.fastapi.mainapp import app from paskia.fastapi.session import AUTH_COOKIE_NAME from paskia.util import avatar @@ -81,7 +81,7 @@ async def test_db() -> AsyncGenerator[DB]: - auth:admin and auth:org:admin permissions - A default organization with Administration role - An admin user with the Administration role - - The localhost realm configuration (with its OIDC provider) + - The localhost domain configuration (with its OIDC provider) """ with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=True) as f: db = DB() @@ -94,7 +94,7 @@ async def test_db() -> AsyncGenerator[DB]: data, org_name="Test Organization", admin_name="Test Admin", - config=Config(realms=[RealmConfig(rp_id=TEST_RP_ID)]), + config=Config(domains={TEST_RP_ID: DomainConfig()}), ) await kanta.open() @@ -106,10 +106,10 @@ async def test_db() -> AsyncGenerator[DB]: @pytest_asyncio.fixture(scope="function") -async def realm_registry(test_db: DB) -> realms.RealmRegistry: - """Install the realm registry built from the test database config.""" - realms.configure(listen=TEST_LISTEN) - return realms.init_registry(test_db.config) +async def domain_registry(test_db: DB) -> domains.DomainRegistry: + """Install the domain registry built from the test database config.""" + domains.configure(listen=TEST_LISTEN) + return domains.init_registry(test_db.config) @pytest_asyncio.fixture(scope="function") @@ -233,7 +233,7 @@ async def reset_token(test_db: DB, test_user: User, test_credential: Credential) @pytest_asyncio.fixture(scope="function") async def client( - test_db: DB, realm_registry: realms.RealmRegistry + test_db: DB, domain_registry: domains.DomainRegistry ) -> AsyncGenerator[httpx.AsyncClient]: """Create an async test client for the FastAPI app.""" transport = httpx.ASGITransport(app=app) diff --git a/tests/test_admin.py b/tests/test_admin.py index 506793e..694f71e 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -22,7 +22,7 @@ import pytest import pytest_asyncio import uuid7 -from paskia import db, realms +from paskia import db, domains from paskia.db import ( Credential, Org, @@ -1789,26 +1789,28 @@ class TestOrgAdminAuthExceptions: assert response.status_code == 403 -class TestRealms: - """Tests for the realm management API (/auth/api/admin/realms/).""" +class TestDomains: + """Tests for the domain management API (/auth/api/admin/domains/).""" async def _set_auth_host(self, client, session_token, test_user, test_credential): - """Configure an auth host on the localhost realm, as the admin UI would.""" + """Configure an auth host on the localhost domain, as the admin UI would.""" r = await client.patch( - "/auth/api/admin/realms/localhost", + "/auth/api/admin/domains/localhost", json={ "rp_name": "", - "auth_host": "auth.localhost", - "origins": ["auth.localhost", "localhost"], + "origins": { + "auth.localhost": {"auth_host": True}, + "localhost": True, + }, }, headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert r.status_code == 200, r.text - realm_cfg = db.data().config.find_realm("localhost") - assert realm_cfg.auth_host == "https://auth.localhost" - realm = realms.registry().get("localhost") - assert realm.own_auth_host == "auth.localhost" - assert realm.auth_site_url == "https://auth.localhost/" + domain_cfg = db.data().config.domains["localhost"] + assert domains.auth_host_url(domain_cfg) == "https://auth.localhost" + domain = domains.registry().get("localhost") + assert domain.own_auth_host == "auth.localhost" + assert domain.auth_site_url == "https://auth.localhost/" # Session for requests coming from the auth host (sessions are host-bound) _, token = create_test_session( test_user.uuid, test_credential.uuid, host="auth.localhost" @@ -1816,26 +1818,27 @@ class TestRealms: return {**auth_headers(token), "Host": "auth.localhost"} @pytest.mark.asyncio - async def test_list_realms(self, client: httpx.AsyncClient, session_token: str): + async def test_list_domains(self, client: httpx.AsyncClient, session_token: str): r = await client.get( - "/auth/api/admin/realms/", + "/auth/api/admin/domains/", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) assert r.status_code == 200, r.text data = r.json() assert len(data) == 1 - realm = data[0] - assert realm["rp_id"] == "localhost" - assert realm["is_default"] is True - assert realm["auth_host"] is None - assert realm["site_url"] == "http://localhost:4401" + domain = data[0] + assert domain["rp_id"] == "localhost" + assert domain["origins"] == {} + assert domain["related"] == {} + assert domain["effective_auth_host"] is None + assert domain["site_url"] == "http://localhost:4401" @pytest.mark.asyncio - async def test_realms_require_master_admin( + async def test_domains_require_master_admin( self, client: httpx.AsyncClient, regular_session_token: str ): r = await client.get( - "/auth/api/admin/realms/", + "/auth/api/admin/domains/", headers={**auth_headers(regular_session_token), "Host": "localhost:4401"}, ) assert r.status_code in (401, 403) @@ -1848,37 +1851,37 @@ class TestRealms: test_user, test_credential, ): - """Removing auth_host must clear it from runtime realm config and URLs.""" + """Removing the auth host mark must clear it from runtime config and URLs.""" headers = await self._set_auth_host( client, session_token, test_user, test_credential ) # The dialog still lists the old auth host among origins, so it is sent back r = await client.patch( - "/auth/api/admin/realms/localhost", + "/auth/api/admin/domains/localhost", json={ "rp_name": "", - "auth_host": "", - "origins": ["auth.localhost", "localhost"], + "origins": {"auth.localhost": True, "localhost": True}, }, headers=headers, ) assert r.status_code == 200, r.text - assert db.data().config.find_realm("localhost").auth_host is None + domain_cfg = db.data().config.domains["localhost"] + assert domains.auth_host_url(domain_cfg) is None - realm = realms.registry().get("localhost") - assert realm.own_auth_host is None - assert realm.ui_base_path == "/auth/" - # Site URL derivation is stateless: with the auth host removed, the - # first remaining origin becomes the site URL. - assert realm.auth_site_url == "https://auth.localhost/auth/" + domain = domains.registry().get("localhost") + assert domain.own_auth_host is None + assert domain.ui_base_path == "/auth/" + # Site URL derivation is stateless: with the auth host mark removed, + # the exact rp-id origin becomes the site URL. + assert domain.auth_site_url == "https://localhost/auth/" # GET and settings reflect the cleared state r = await client.get( - "/auth/api/admin/realms/", + "/auth/api/admin/domains/", headers={**auth_headers(session_token), "Host": "localhost:4401"}, ) - assert r.json()[0]["auth_host"] is None + assert r.json()[0]["origins"] == {"auth.localhost": True, "localhost": True} r = await client.get("/auth/api/settings") assert r.json()["auth_host"] is None assert r.json()["own_auth_host"] is None @@ -1906,134 +1909,130 @@ class TestRealms: ) r = await client.patch( - "/auth/api/admin/realms/localhost", - json={"rp_name": "", "auth_host": "", "origins": []}, + "/auth/api/admin/domains/localhost", + json={"rp_name": "", "origins": {}}, headers=headers, ) assert r.status_code == 200, r.text - realm = realms.registry().get("localhost") - assert realm.own_auth_host is None - assert realm.ui_base_path == "/auth/" - assert "auth.localhost" not in realm.site_url - assert "auth.localhost" not in realm.auth_site_url + domain = domains.registry().get("localhost") + assert domain.own_auth_host is None + assert domain.ui_base_path == "/auth/" + assert "auth.localhost" not in domain.site_url + assert "auth.localhost" not in domain.auth_site_url @pytest.mark.asyncio - async def test_create_and_delete_realm( + async def test_create_and_delete_domain( self, client: httpx.AsyncClient, session_token: str ): headers = {**auth_headers(session_token), "Host": "localhost:4401"} r = await client.post( - "/auth/api/admin/realms/", + "/auth/api/admin/domains/", json={ "rp_id": "example.com", "rp_name": "Example", - "origins": ["https://app.example.com"], - "related_origins": ["https://unrelated-site.com"], + "origins": {"app.example.com": True}, + "related": {"unrelated-site.com": True}, }, headers=headers, ) assert r.status_code == 200, r.text - r = await client.get("/auth/api/admin/realms/", headers=headers) - realms_list = {realm["rp_id"]: realm for realm in r.json()} - assert set(realms_list) == {"localhost", "example.com"} - created = realms_list["example.com"] + r = await client.get("/auth/api/admin/domains/", headers=headers) + domains_list = {domain["rp_id"]: domain for domain in r.json()} + assert set(domains_list) == {"localhost", "example.com"} + created = domains_list["example.com"] assert created["rp_name"] == "Example" - assert created["is_default"] is False - assert created["related_origins"] == ["https://unrelated-site.com"] + assert created["related"] == {"unrelated-site.com": True} - # OIDC provider seeded for the new realm + # OIDC provider seeded for the new domain assert db.data().oidc_for("example.com") is not None - r = await client.delete("/auth/api/admin/realms/example.com", headers=headers) + r = await client.delete("/auth/api/admin/domains/example.com", headers=headers) assert r.status_code == 200, r.text - assert db.data().config.find_realm("example.com") is None - assert realms.registry().get("example.com") is None + assert "example.com" not in db.data().config.domains + assert domains.registry().get("example.com") is None @pytest.mark.asyncio - async def test_create_realm_validation( + async def test_create_domain_validation( self, client: httpx.AsyncClient, session_token: str ): headers = {**auth_headers(session_token), "Host": "localhost:4401"} # rp_id is required - r = await client.post("/auth/api/admin/realms/", json={}, headers=headers) + r = await client.post("/auth/api/admin/domains/", json={}, headers=headers) assert r.status_code == 400 # Duplicate rp-id r = await client.post( - "/auth/api/admin/realms/", json={"rp_id": "localhost"}, headers=headers + "/auth/api/admin/domains/", json={"rp_id": "localhost"}, headers=headers ) assert r.status_code == 400 # Invalid rp-id r = await client.post( - "/auth/api/admin/realms/", json={"rp_id": "not a domain!"}, headers=headers + "/auth/api/admin/domains/", json={"rp_id": "not a domain!"}, headers=headers ) assert r.status_code == 400 - # auth-host must be a subdomain of the rp-id + # An auth host must be within the rp-id domain r = await client.post( - "/auth/api/admin/realms/", - json={"rp_id": "example.com", "auth_host": "auth.other.com"}, + "/auth/api/admin/domains/", + json={ + "rp_id": "example.com", + "origins": {"auth.other.com": {"auth_host": True}}, + }, headers=headers, ) assert r.status_code == 400 - # Related origin host may not collide across realms + # Related origin host may not collide across domains r = await client.post( - "/auth/api/admin/realms/", - json={ - "rp_id": "example.com", - "related_origins": ["https://shared-app.com"], - }, + "/auth/api/admin/domains/", + json={"rp_id": "example.com", "related": {"shared-app.com": True}}, headers=headers, ) assert r.status_code == 200 r = await client.post( - "/auth/api/admin/realms/", - json={"rp_id": "other.com", "related_origins": ["https://shared-app.com"]}, + "/auth/api/admin/domains/", + json={"rp_id": "other.com", "related": {"shared-app.com": True}}, headers=headers, ) assert r.status_code == 400 # Cross-domain entries are rejected from the in-domain origins list r = await client.post( - "/auth/api/admin/realms/", - json={"rp_id": "another.com", "origins": ["https://elsewhere.com"]}, + "/auth/api/admin/domains/", + json={"rp_id": "another.com", "origins": {"elsewhere.com": True}}, headers=headers, ) assert r.status_code == 400 # In-domain entries are rejected from the related origins list r = await client.post( - "/auth/api/admin/realms/", - json={ - "rp_id": "another.com", - "related_origins": ["https://app.another.com"], - }, + "/auth/api/admin/domains/", + json={"rp_id": "another.com", "related": {"app.another.com": True}}, headers=headers, ) assert r.status_code == 400 @pytest.mark.asyncio - async def test_delete_realm_guards( + async def test_delete_domain_guards( self, client: httpx.AsyncClient, session_token: str, test_credential ): headers = {**auth_headers(session_token), "Host": "localhost:4401"} - # Cannot delete the last realm - r = await client.delete("/auth/api/admin/realms/localhost", headers=headers) + # Cannot delete the last domain + r = await client.delete("/auth/api/admin/domains/localhost", headers=headers) assert r.status_code == 400 - # Unknown realm - r = await client.delete("/auth/api/admin/realms/nope.com", headers=headers) + # Unknown domain + r = await client.delete("/auth/api/admin/domains/nope.com", headers=headers) assert r.status_code == 400 - # A realm with credentials still registered under it cannot be deleted + # A domain with credentials still registered under it cannot be deleted r = await client.post( - "/auth/api/admin/realms/", json={"rp_id": "example.com"}, headers=headers + "/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers ) assert r.status_code == 200 cred = Credential.create( @@ -2045,11 +2044,11 @@ class TestRealms: rp_id="example.com", ) create_credential(cred) - r = await client.delete("/auth/api/admin/realms/example.com", headers=headers) + r = await client.delete("/auth/api/admin/domains/example.com", headers=headers) assert r.status_code == 400 @pytest.mark.asyncio - async def test_update_realm_refuses_self_lockout( + async def test_update_domain_refuses_self_lockout( self, client: httpx.AsyncClient, session_token: str ): """An allow-list excluding the admin's current host is refused.""" @@ -2057,12 +2056,8 @@ class TestRealms: # Allow-list without the current host and no auth host → lockout r = await client.patch( - "/auth/api/admin/realms/localhost", - json={ - "rp_name": "", - "auth_host": "", - "origins": ["https://auth.localhost"], - }, + "/auth/api/admin/domains/localhost", + json={"rp_name": "", "origins": {"auth.localhost": True}}, headers=headers, ) assert r.status_code == 400 @@ -2070,12 +2065,8 @@ class TestRealms: # Allow-list including the current host is fine r = await client.patch( - "/auth/api/admin/realms/localhost", - json={ - "rp_name": "", - "auth_host": "", - "origins": ["https://localhost:4401"], - }, + "/auth/api/admin/domains/localhost", + json={"rp_name": "", "origins": {"localhost:4401": True}}, headers=headers, ) assert r.status_code == 200, r.text @@ -2084,31 +2075,30 @@ class TestRealms: # host is set: ceremonies move there (and it is always allowed). # Done last: with an auth host set, the API here routes differently. r = await client.patch( - "/auth/api/admin/realms/localhost", + "/auth/api/admin/domains/localhost", json={ "rp_name": "", - "auth_host": "auth.localhost", - "origins": ["https://auth.localhost"], + "origins": {"auth.localhost": {"auth_host": True}}, }, headers=headers, ) assert r.status_code == 200, r.text @pytest.mark.asyncio - async def test_delete_current_realm_refused( + async def test_delete_current_domain_refused( self, client: httpx.AsyncClient, session_token: str ): headers = {**auth_headers(session_token), "Host": "localhost:4401"} r = await client.post( - "/auth/api/admin/realms/", json={"rp_id": "example.com"}, headers=headers + "/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers ) assert r.status_code == 200 - # Deleting the realm in use is refused even if it has no credentials - r = await client.delete("/auth/api/admin/realms/localhost", headers=headers) + # Deleting the domain in use is refused even if it has no credentials + r = await client.delete("/auth/api/admin/domains/localhost", headers=headers) assert r.status_code == 400 assert "currently using" in r.text - # Deleting another realm while authenticated here is fine - r = await client.delete("/auth/api/admin/realms/example.com", headers=headers) + # Deleting another domain while authenticated here is fine + r = await client.delete("/auth/api/admin/domains/example.com", headers=headers) assert r.status_code == 200, r.text @pytest.mark.asyncio @@ -2119,12 +2109,12 @@ class TestRealms: test_user, test_credential, ): - """A realm without its own auth host uses the shared one in settings.""" + """A domain without its own auth host uses the shared one in settings.""" headers = await self._set_auth_host( client, session_token, test_user, test_credential ) r = await client.post( - "/auth/api/admin/realms/", json={"rp_id": "example.com"}, headers=headers + "/auth/api/admin/domains/", json={"rp_id": "example.com"}, headers=headers ) assert r.status_code == 200 diff --git a/tests/test_api.py b/tests/test_api.py index 51cfd79..45dbabc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,10 +18,10 @@ from uuid import UUID import httpx import pytest -from paskia import authcode, db, realms +from paskia import authcode, db, domains from paskia.authsession import EXPIRES from paskia.db import delete_session -from paskia.db.structs import Client, Config, RealmConfig +from paskia.db.structs import Client, Config, DomainConfig, OriginEntry from paskia.fastapi.api import _REFRESH_INTERVAL from paskia.util import avatar, oidjwt, permutil from paskia.util.crypto import hash_secret @@ -69,9 +69,15 @@ class TestAvatarUrls: self, tmp_path, monkeypatch ): """Absolute avatar URLs should preserve /auth/api even with an auth host.""" - realms.configure(listen=None) - realms.init_registry( - Config(realms=[RealmConfig(rp_id="zi.fi", auth_host="https://auth.zi.fi")]) + domains.configure(listen=None) + domains.init_registry( + Config( + domains={ + "zi.fi": DomainConfig( + origins={"auth.zi.fi": OriginEntry(auth_host=True)} + ) + } + ) ) # The autouse avatar fixture redirects storage to tmp_path / "users" diff --git a/tests/test_cli.py b/tests/test_cli.py index 2f1c853..74d19e8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,9 +1,9 @@ """Tests for the CLI entry point in paskia/__main__.py. The CLI is split into ``paskia init`` (create the combined paskia.kantadb -with the initial realm(s)), ``paskia migrate`` (convert a legacy +with the initial domain(s)), ``paskia migrate`` (convert a legacy ``.paskiadb`` database), and bare ``paskia`` (serve the stored -realms; never migrates). +domains; never migrates). """ from __future__ import annotations @@ -20,7 +20,7 @@ from kanta import Kanta from paskia.__main__ import _load_stored_config, main from paskia.db import legacy -from paskia.db.structs import Config +from paskia.db.structs import Config, OriginEntry from paskia.util.runtime import ServeConfig, clear_cache @@ -85,9 +85,9 @@ def test_init_defaults(run_cli, tmp_path): run_cli("init") config = stored_config(tmp_path) - assert [r.rp_id for r in config.realms] == ["localhost"] - assert config.realms[0].rp_name is None - assert config.realms[0].auth_host is None + assert list(config.domains) == ["localhost"] + assert config.domains["localhost"].rp_name is None + assert config.domains["localhost"].origins == {} assert config.listen is None @@ -107,11 +107,12 @@ def test_init_full_options(run_cli, tmp_path): ) config = stored_config(tmp_path) - realm = config.realms[0] - assert realm.rp_id == "example.com" - assert realm.rp_name == "Example Corp" - assert realm.auth_host == "https://auth.example.com" - assert realm.origins == ["https://auth.example.com", "https://app.example.com"] + domain = config.domains["example.com"] + assert domain.rp_name == "Example Corp" + assert domain.origins == { + "app.example.com": True, + "auth.example.com": OriginEntry(auth_host=True), + } assert config.listen == ["4402"] @@ -119,8 +120,7 @@ def test_init_multiple_rp_ids(run_cli, tmp_path): run_cli("init", "--rp-id", "company.com,app.com", "--rp-id", "pro.com") config = stored_config(tmp_path) - assert [r.rp_id for r in config.realms] == ["company.com", "app.com", "pro.com"] - assert config.default_realm.rp_id == "company.com" + assert list(config.domains) == ["company.com", "app.com", "pro.com"] def test_init_refuses_existing_database(run_cli): @@ -184,8 +184,8 @@ def test_migrate_converts_legacy_database(run_cli, tmp_path): run_cli("migrate") config = stored_config(tmp_path) - assert [r.rp_id for r in config.realms] == ["example.com"] - assert config.realms[0].rp_name == "Legacy Name" + assert list(config.domains) == ["example.com"] + assert config.domains["example.com"].rp_name == "Legacy Name" # Legacy directory renamed aside, user files moved over assert not src_dir.exists() assert (tmp_path / "example.com.paskiadb.converted-bak").is_dir() @@ -212,7 +212,7 @@ def test_migrate_explicit_rp_id_selects_candidate(run_cli, tmp_path): run_cli("migrate", "--rp-id", "two.com") config = stored_config(tmp_path) - assert [r.rp_id for r in config.realms] == ["two.com"] + assert list(config.domains) == ["two.com"] # The other candidate is left in place assert (tmp_path / "one.com.paskiadb").is_dir() assert (tmp_path / "two.com.paskiadb.converted-bak").is_dir() diff --git a/tests/test_realms.py b/tests/test_domains.py similarity index 66% rename from tests/test_realms.py rename to tests/test_domains.py index 2925820..ab14b50 100644 --- a/tests/test_realms.py +++ b/tests/test_domains.py @@ -1,5 +1,5 @@ -"""Tests for the multi-realm machinery: registry resolution, config -validation, ASGI dispatch, realm binding of auth codes, legacy database +"""Tests for the multi-domain machinery: registry resolution, config +validation, ASGI dispatch, domain binding of auth codes, legacy database conversion, log censoring and bootstrap caveats. """ @@ -14,7 +14,7 @@ import httpx import pytest from kanta import Kanta -from paskia import authcode, realms +from paskia import authcode, domains from paskia.bootstrap import check_admin_credentials from paskia.db import create_credential from paskia.db.legacy import ( @@ -26,7 +26,7 @@ from paskia.db.legacy import ( ) from paskia.db.lifecycle import format_log_uuid from paskia.db.operations import DB -from paskia.db.structs import Client, Config, Credential, RealmConfig +from paskia.db.structs import Client, Config, Credential, DomainConfig, OriginEntry from paskia.fastapi.dispatch import DispatchMiddleware from paskia.sansio import Passkey @@ -35,22 +35,21 @@ from paskia.sansio import Passkey # ------------------------------------------------------------------------- -def build_registry(*realm_configs: RealmConfig) -> realms.RealmRegistry: - """Build and install a registry from realm configs (listen unset).""" - realms.configure(listen=None) - return realms.init_registry(Config(realms=list(realm_configs))) +def build_registry(configs: dict[str, DomainConfig]) -> domains.DomainRegistry: + """Build and install a registry from domain configs (listen unset).""" + domains.configure(listen=None) + return domains.init_registry(Config(domains=configs)) ROR_CONFIG = Config( - realms=[ - RealmConfig( - rp_id="company.com", - auth_host="https://auth.company.com", - origins=["https://auth.company.com"], - related_origins=["https://app.com"], + domains={ + "company.com": DomainConfig( + rp_name="Company", + origins={"auth.company.com": OriginEntry(auth_host=True)}, + related={"app.com": True}, ), - RealmConfig(rp_id="pro.com"), - ] + "pro.com": DomainConfig(rp_name="Pro"), + } ) @@ -117,152 +116,151 @@ async def drive_http( class TestResolve: def test_exact_rp_id(self): - reg = build_registry(*ROR_CONFIG.realms) + reg = build_registry(ROR_CONFIG.domains) assert reg.resolve("pro.com").rp_id == "pro.com" assert reg.resolve("company.com").rp_id == "company.com" def test_auth_host_and_related_origin(self): - reg = build_registry(*ROR_CONFIG.realms) + reg = build_registry(ROR_CONFIG.domains) assert reg.resolve("auth.company.com").rp_id == "company.com" assert reg.resolve("app.com").rp_id == "company.com" def test_subdomain_suffix_longest_match(self): reg = build_registry( - RealmConfig(rp_id="example.com"), RealmConfig(rp_id="sub.example.com") + {"example.com": DomainConfig(), "sub.example.com": DomainConfig()} ) assert reg.resolve("www.example.com").rp_id == "example.com" assert reg.resolve("api.sub.example.com").rp_id == "sub.example.com" def test_port_and_trailing_dot_normalized(self): - reg = build_registry(*ROR_CONFIG.realms) + reg = build_registry(ROR_CONFIG.domains) assert reg.resolve("pro.com:8443").rp_id == "pro.com" assert reg.resolve("app.com.").rp_id == "company.com" def test_unknown_host(self): - reg = build_registry(*ROR_CONFIG.realms) + reg = build_registry(ROR_CONFIG.domains) assert reg.resolve("evil.com") is None assert reg.resolve("") is None assert reg.resolve(None) is None def test_effective_auth_host_fallback(self): - reg = build_registry(*ROR_CONFIG.realms) + reg = build_registry(ROR_CONFIG.domains) company = reg.get("company.com") pro = reg.get("pro.com") assert reg.effective_auth_host(company) == "auth.company.com" # pro.com has no own auth host: falls back to the first configured one assert reg.effective_auth_host(pro) == "auth.company.com" # No auth hosts at all: None - reg2 = build_registry(RealmConfig(rp_id="a.com"), RealmConfig(rp_id="b.com")) + reg2 = build_registry({"a.com": DomainConfig(), "b.com": DomainConfig()}) assert reg2.effective_auth_host(reg2.get("a.com")) is None # ------------------------------------------------------------------------- -# Cross-realm configuration validation +# Cross-domain configuration validation # ------------------------------------------------------------------------- class TestValidateConfig: def test_valid(self): - realms.validate_config(ROR_CONFIG) + domains.validate_config(ROR_CONFIG) def test_related_origin_cap(self): - realms.validate_config( + domains.validate_config( Config( - realms=[ - RealmConfig( - rp_id="company.com", - related_origins=[f"https://app{i}.com" for i in range(5)], + domains={ + "company.com": DomainConfig( + related={f"app{i}.com": True for i in range(5)} ) - ] + } ) ) with pytest.raises(ValueError, match="related origins"): - realms.validate_config( + domains.validate_config( Config( - realms=[ - RealmConfig( - rp_id="company.com", - related_origins=[f"https://app{i}.com" for i in range(6)], + domains={ + "company.com": DomainConfig( + related={f"app{i}.com": True for i in range(6)} ) - ] + } ) ) def test_origin_outside_rp_id_rejected(self): """In-domain origins are an allow-list; cross-domain needs related.""" with pytest.raises(ValueError, match="outside the rp-id domain"): - realms.validate_config( - Config( - realms=[ - RealmConfig(rp_id="a.com", origins=["https://elsewhere.com"]) - ] - ) + domains.validate_config( + Config(domains={"a.com": DomainConfig(origins={"elsewhere.com": True})}) ) - def test_related_origin_inside_own_realm_rejected(self): + def test_related_origin_inside_own_domain_rejected(self): """Subdomains of the rp-id are covered already; listing is an error.""" with pytest.raises(ValueError, match="within the rp-id domain"): - realms.validate_config( - Config( - realms=[ - RealmConfig( - rp_id="a.com", related_origins=["https://app.a.com"] - ) - ] - ) + domains.validate_config( + Config(domains={"a.com": DomainConfig(related={"app.a.com": True})}) ) def test_wildcard_related_origin_rejected(self): """ROR entries are always individual origins; wildcards are meaningless.""" with pytest.raises(ValueError, match="wildcard"): - realms.validate_config( - Config(realms=[RealmConfig(rp_id="a.com", related_origins=["*.b.com"])]) + domains.validate_config( + Config(domains={"a.com": DomainConfig(related={"*.b.com": True})}) ) def test_wildcard_origin_in_domain_accepted(self): - realms.validate_config( - Config(realms=[RealmConfig(rp_id="a.com", origins=["*.a.com"])]) + domains.validate_config( + Config(domains={"a.com": DomainConfig(origins={"*.a.com": True})}) ) with pytest.raises(ValueError, match="outside the rp-id domain"): - realms.validate_config( - Config(realms=[RealmConfig(rp_id="a.com", origins=["*.b.com"])]) + domains.validate_config( + Config(domains={"a.com": DomainConfig(origins={"*.b.com": True})}) + ) + + def test_wildcard_auth_host_rejected(self): + with pytest.raises(ValueError, match="cannot be the auth host"): + domains.validate_config( + Config( + domains={ + "a.com": DomainConfig( + origins={"*.a.com": OriginEntry(auth_host=True)} + ) + } + ) ) def test_auth_host_collision(self): with pytest.raises(ValueError, match="collides with a related origin"): - realms.validate_config( + domains.validate_config( Config( - realms=[ - RealmConfig(rp_id="a.com", auth_host="https://auth.a.com"), - RealmConfig( - rp_id="b.com", - related_origins=["https://auth.a.com"], + domains={ + "a.com": DomainConfig( + origins={"auth.a.com": OriginEntry(auth_host=True)} ), - ] + "b.com": DomainConfig(related={"auth.a.com": True}), + } ) ) - def test_related_origin_inside_other_realm(self): - with pytest.raises(ValueError, match="falls inside realm"): - realms.validate_config( + def test_related_origin_inside_other_domain(self): + with pytest.raises(ValueError, match="falls inside domain"): + domains.validate_config( Config( - realms=[ - RealmConfig( - rp_id="a.com", related_origins=["https://app.b.com"] - ), - RealmConfig(rp_id="b.com"), - ] + domains={ + "a.com": DomainConfig(related={"app.b.com": True}), + "b.com": DomainConfig(), + } ) ) def test_auth_host_must_not_collide_with_rp_id(self): with pytest.raises(ValueError, match="collides with an rp-id"): - realms.validate_config( + domains.validate_config( Config( - realms=[ - RealmConfig(rp_id="a.com", auth_host="https://b.a.com"), - RealmConfig(rp_id="b.a.com"), - ] + domains={ + "a.com": DomainConfig( + origins={"b.a.com": OriginEntry(auth_host=True)} + ), + "b.a.com": DomainConfig(), + } ) ) @@ -276,122 +274,110 @@ class TestSanitizeConfig: """Serving never fails on stored config problems; it degrades + warns.""" def test_cross_domain_origin_moved_to_related(self): - config, warnings = realms.sanitize_config( - Config( - realms=[RealmConfig(rp_id="localhost", origins=["https://example.com"])] - ) + config, warnings = domains.sanitize_config( + Config(domains={"localhost": DomainConfig(origins={"example.com": True})}) ) - realm = config.realms[0] - assert realm.origins is None - assert realm.related_origins == ["https://example.com"] + domain = config.domains["localhost"] + assert domain.origins == {} + assert domain.related == {"example.com": True} assert any("related origin" in w for w in warnings) - realms.validate_config(config) # sanitized config is strict-clean + domains.validate_config(config) # sanitized config is strict-clean def test_malformed_origin_dropped(self): - config, warnings = realms.sanitize_config( - Config(realms=[RealmConfig(rp_id="a.com", origins=["not a url"])]) + config, warnings = domains.sanitize_config( + Config(domains={"a.com": DomainConfig(origins={"https://": True})}) ) - assert config.realms[0].origins is None + assert config.domains["a.com"].origins == {} assert warnings - def test_invalid_rp_id_realm_dropped(self): - config, warnings = realms.sanitize_config( - Config( - realms=[RealmConfig(rp_id="not a domain!"), RealmConfig(rp_id="ok.com")] - ) + def test_invalid_rp_id_domain_dropped(self): + config, warnings = domains.sanitize_config( + Config(domains={"not a domain!": DomainConfig(), "ok.com": DomainConfig()}) ) - assert [r.rp_id for r in config.realms] == ["ok.com"] + assert list(config.domains) == ["ok.com"] assert any("dropped" in w for w in warnings) - def test_duplicate_rp_id_first_wins(self): - config, _warnings = realms.sanitize_config( - Config( - realms=[ - RealmConfig(rp_id="a.com", rp_name="First"), - RealmConfig(rp_id="a.com"), - ] - ) - ) - assert len(config.realms) == 1 - assert config.realms[0].rp_name == "First" - def test_related_inside_own_domain_dropped(self): - config, _ = realms.sanitize_config( - Config( - realms=[ - RealmConfig(rp_id="a.com", related_origins=["https://app.a.com"]) - ] - ) + config, _ = domains.sanitize_config( + Config(domains={"a.com": DomainConfig(related={"app.a.com": True})}) ) - assert config.realms[0].related_origins is None + assert config.domains["a.com"].related == {} def test_wildcard_related_origin_dropped(self): - config, warnings = realms.sanitize_config( - Config(realms=[RealmConfig(rp_id="a.com", related_origins=["*.b.com"])]) + config, warnings = domains.sanitize_config( + Config(domains={"a.com": DomainConfig(related={"*.b.com": True})}) ) - assert config.realms[0].related_origins is None + assert config.domains["a.com"].related == {} assert any("wildcard" in w for w in warnings) - realms.validate_config(config) # sanitized config is strict-clean + domains.validate_config(config) # sanitized config is strict-clean def test_cap_exceeded_truncated(self): - config, warnings = realms.sanitize_config( + config, warnings = domains.sanitize_config( Config( - realms=[ - RealmConfig( - rp_id="a.com", - related_origins=[f"https://app{i}.com" for i in range(6)], + domains={ + "a.com": DomainConfig( + related={f"app{i}.com": True for i in range(6)} ) - ] + } ) ) - assert len(config.realms[0].related_origins) == 5 + assert len(config.domains["a.com"].related) == 5 assert any("maximum" in w for w in warnings) - def test_auth_host_outside_domain_ignored(self): - config, warnings = realms.sanitize_config( - Config(realms=[RealmConfig(rp_id="a.com", auth_host="https://auth.b.com")]) - ) - assert config.realms[0].auth_host is None - assert any("auth host ignored" in w for w in warnings) - - def test_auth_host_colliding_with_rp_id_ignored(self): - config, warnings = realms.sanitize_config( + def test_auth_host_outside_domain_becomes_related(self): + """An auth-marked origin outside the rp-id degrades to a related origin.""" + config, warnings = domains.sanitize_config( Config( - realms=[ - RealmConfig(rp_id="a.com", auth_host="https://auth.a.com"), - RealmConfig(rp_id="auth.a.com"), - ] + domains={ + "a.com": DomainConfig( + origins={"auth.b.com": OriginEntry(auth_host=True)} + ) + } ) ) - assert config.realms[0].auth_host is None - assert any("collides with an rp-id" in w for w in warnings) + domain = config.domains["a.com"] + assert domain.origins == {} + assert domain.related == {"auth.b.com": True} + assert any("related origin" in w for w in warnings) - def test_related_colliding_with_other_realm_dropped(self): - config, _ = realms.sanitize_config( + def test_auth_host_colliding_with_rp_id_cleared(self): + config, warnings = domains.sanitize_config( Config( - realms=[ - RealmConfig(rp_id="a.com", related_origins=["https://app.b.com"]), - RealmConfig(rp_id="b.com"), - ] + domains={ + "a.com": DomainConfig( + origins={"auth.a.com": OriginEntry(auth_host=True)} + ), + "auth.a.com": DomainConfig(), + } ) ) - assert config.realms[0].related_origins is None + assert config.domains["a.com"].origins == {"auth.a.com": True} + assert any("collides" in w for w in warnings) - def test_no_realms_is_fatal(self): - with pytest.raises(ValueError, match="realm"): - realms.sanitize_config(Config(realms=[])) - with pytest.raises(ValueError, match="No servable realm"): - realms.sanitize_config(Config(realms=[RealmConfig(rp_id="not a domain!")])) + def test_related_colliding_with_other_domain_dropped(self): + config, _ = domains.sanitize_config( + Config( + domains={ + "a.com": DomainConfig(related={"app.b.com": True}), + "b.com": DomainConfig(), + } + ) + ) + assert config.domains["a.com"].related == {} + + def test_no_domains_is_fatal(self): + with pytest.raises(ValueError, match="No servable domain"): + domains.sanitize_config(Config(domains={})) + with pytest.raises(ValueError, match="No servable domain"): + domains.sanitize_config(Config(domains={"not a domain!": DomainConfig()})) def test_build_tolerates_and_serves(self): # Cross-domain entry stored in origins: served as a related origin - reg = build_registry( - RealmConfig(rp_id="localhost", origins=["https://example.com"]) - ) + reg = build_registry({"localhost": DomainConfig(origins={"example.com": True})}) assert reg.warnings - realm = reg.get("localhost") - assert realm.related_origins == ["https://example.com"] - realm.passkey.validate_origin("https://example.com") + domain = reg.get("localhost") + assert domain.related_origins == ["https://example.com"] + domain.passkey.validate_origin("https://example.com") # ------------------------------------------------------------------------- @@ -461,8 +447,8 @@ class TestOriginValidation: with pytest.raises(ValueError, match="within the rp-id domain"): Passkey(rp_id="example.com", related_origins=["https://app.example.com"]) - def test_realm_wires_both_lists(self): - reg = build_registry(*ROR_CONFIG.realms) + def test_domain_wires_both_lists(self): + reg = build_registry(ROR_CONFIG.domains) p = reg.get("company.com").passkey assert p.validate_origin("https://app.com") # related origin assert p.validate_origin("https://auth.company.com") # allow-listed @@ -478,7 +464,7 @@ class TestOriginValidation: class TestDispatchMiddleware: @pytest.mark.asyncio async def test_http_unknown_host_421(self): - build_registry(*ROR_CONFIG.realms) + build_registry(ROR_CONFIG.domains) stub, sent = await drive_http( DispatchMiddleware(StubApp()), [(b"host", b"evil.com")] ) @@ -487,30 +473,32 @@ class TestDispatchMiddleware: assert sent[0]["status"] == 421 @pytest.mark.asyncio - async def test_http_dispatches_realm(self): - build_registry(*ROR_CONFIG.realms) + async def test_http_dispatches_domain(self): + build_registry(ROR_CONFIG.domains) stub, _sent = await drive_http( DispatchMiddleware(StubApp()), [(b"host", b"app.com.")] ) assert stub.scope is not None - assert stub.scope["state"]["realm"].rp_id == "company.com" + assert stub.scope["state"]["domain"].rp_id == "company.com" @pytest.mark.asyncio - async def test_http_current_realm_set_inside_request(self): - reg = build_registry(*ROR_CONFIG.realms) + async def test_http_current_domain_set_inside_request(self): + build_registry(ROR_CONFIG.domains) seen = {} async def app(scope, receive, send): - seen["realm"] = realms.current_realm() + seen["domain"] = domains.current_domain() await drive_http(DispatchMiddleware(app), [(b"host", b"pro.com")]) - assert seen["realm"].rp_id == "pro.com" - # Contextvar is reset after the request - assert realms.current_realm() is reg.default + assert seen["domain"].rp_id == "pro.com" + # Contextvar is reset after the request; with several domains there + # is no implicit current domain outside a request context. + with pytest.raises(RuntimeError, match="request context"): + domains.current_domain() @pytest.mark.asyncio async def test_ws_unknown_host_closed(self): - build_registry(*ROR_CONFIG.realms) + build_registry(ROR_CONFIG.domains) stub, sent = await drive_ws( DispatchMiddleware(StubApp()), [(b"host", b"evil.com")] ) @@ -518,25 +506,25 @@ class TestDispatchMiddleware: assert sent == [{"type": "websocket.close", "code": 1008}] @pytest.mark.asyncio - async def test_ws_same_realm_origin(self): - build_registry(*ROR_CONFIG.realms) + async def test_ws_same_domain_origin(self): + build_registry(ROR_CONFIG.domains) stub, sent = await drive_ws( DispatchMiddleware(StubApp()), [(b"host", b"auth.company.com"), (b"origin", b"https://app.com")], ) assert sent == [] - assert stub.scope["state"]["realm"].rp_id == "company.com" + assert stub.scope["state"]["domain"].rp_id == "company.com" @pytest.mark.asyncio - async def test_ws_cross_realm_requires_effective_auth_host(self): - build_registry(*ROR_CONFIG.realms) - # pro.com page connecting to the shared auth host: allowed, pro realm + async def test_ws_cross_domain_requires_effective_auth_host(self): + build_registry(ROR_CONFIG.domains) + # pro.com page connecting to the shared auth host: allowed, pro domain stub, sent = await drive_ws( DispatchMiddleware(StubApp()), [(b"host", b"auth.company.com"), (b"origin", b"https://pro.com")], ) assert sent == [] - assert stub.scope["state"]["realm"].rp_id == "pro.com" + assert stub.scope["state"]["domain"].rp_id == "pro.com" # pro.com page connecting to some other host: closed pre-accept stub, sent = await drive_ws( @@ -547,27 +535,27 @@ class TestDispatchMiddleware: assert sent == [{"type": "websocket.close", "code": 1008}] @pytest.mark.asyncio - async def test_ws_unknown_origin_uses_host_realm(self): - build_registry(*ROR_CONFIG.realms) + async def test_ws_unknown_origin_uses_host_domain(self): + build_registry(ROR_CONFIG.domains) # Missing origin stub, _ = await drive_ws(DispatchMiddleware(StubApp()), [(b"host", b"pro.com")]) - assert stub.scope["state"]["realm"].rp_id == "pro.com" - # Unknown origin: host realm applies (endpoint-side validation decides) + assert stub.scope["state"]["domain"].rp_id == "pro.com" + # Unknown origin: host domain applies (endpoint-side validation decides) stub, _ = await drive_ws( DispatchMiddleware(StubApp()), [(b"host", b"pro.com"), (b"origin", b"https://evil.com")], ) - assert stub.scope["state"]["realm"].rp_id == "pro.com" + assert stub.scope["state"]["domain"].rp_id == "pro.com" # ------------------------------------------------------------------------- -# Realm binding of auth codes +# Domain binding of auth codes # ------------------------------------------------------------------------- -class TestAuthCodeRealmBinding: +class TestAuthCodeDomainBinding: @pytest.mark.asyncio - async def test_cookie_code_rejected_on_other_realm( + async def test_cookie_code_rejected_on_other_domain( self, client: httpx.AsyncClient, session_token: str ): code = authcode.store_cookie( @@ -587,7 +575,7 @@ class TestAuthCodeRealmBinding: assert response.status_code == 401 @pytest.mark.asyncio - async def test_oidc_code_rejected_on_other_realm( + async def test_oidc_code_rejected_on_other_domain( self, client: httpx.AsyncClient, test_db: DB ): oidc_client, secret = Client.create( @@ -621,7 +609,7 @@ class TestAuthCodeRealmBinding: ) assert response.status_code == 400 assert response.json()["error"] == "invalid_grant" - assert "realm" in response.json()["error_description"] + assert "domain" in response.json()["error_description"] # ------------------------------------------------------------------------- @@ -640,7 +628,7 @@ def _read_db(path) -> DB: class TestLegacyConversion: - def test_convert_stamps_realm_everywhere(self, tmp_path): + def test_convert_stamps_domain_everywhere(self, tmp_path): src = tmp_path / "example.com.paskiadb" src.mkdir() src_file = src / "main.db" @@ -678,8 +666,9 @@ class TestLegacyConversion: asyncio.run(_write()) config = convert_legacy_database(src_file, tmp_path / "paskia.kantadb") - assert config.default_realm.rp_id == "example.com" - assert config.default_realm.rp_name == "Example" + domain = config.domains["example.com"] + assert domain.rp_name == "Example" + assert domain.origins == {"app.example.com": True} converted = _read_db(tmp_path / "paskia.kantadb") assert converted.credentials[cred_uuid].rp_id == "example.com" @@ -703,32 +692,32 @@ class TestLogCensoring: def test_other_paths_unaffected(self): assert format_log_uuid("not-a-uuid", "oidc.localhost.clients") is None - assert format_log_uuid("not-a-uuid", "config.realms") is None + assert format_log_uuid("not-a-uuid", "config.domains") is None # ------------------------------------------------------------------------- -# Bootstrap caveat: admin credential is checked on the default realm +# Bootstrap caveat: admin credential is checked on the configured domains # ------------------------------------------------------------------------- class TestBootstrapCaveat: @pytest.mark.asyncio async def test_admin_without_credentials_gets_link( - self, test_db: DB, realm_registry + self, test_db: DB, domain_registry ): assert await check_admin_credentials() is True @pytest.mark.asyncio - async def test_admin_with_default_realm_credential_ok( - self, test_db: DB, realm_registry, test_user, test_credential + async def test_admin_with_domain_credential_ok( + self, test_db: DB, domain_registry, test_user, test_credential ): assert await check_admin_credentials() is False @pytest.mark.asyncio - async def test_admin_with_only_other_realm_credential_gets_link( - self, test_db: DB, realm_registry, test_user + async def test_admin_with_only_unconfigured_domain_credential_gets_link( + self, test_db: DB, domain_registry, test_user ): - """A passkey under a non-default realm does not satisfy the check.""" + """A passkey under an rp-id outside the config does not satisfy the check.""" cred = Credential.create( credential_id=os.urandom(32), user=test_user.uuid,