MultiSite: one instance serves authentication across many domains #4

Merged
LeoVasanko merged 48 commits from multihost into main 2026-09-07 22:02:06 +00:00
33 changed files with 627 additions and 651 deletions
Showing only changes of commit f2e6f5784e - Show all commits
+10 -10
View File
@@ -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).
+206 -198
View File
@@ -3,19 +3,19 @@
One paskia process on one port serves multiple sites with **one combined
database**. The _administrative instance_ is separated from the _WebAuthn
RP_: organizations and users are global across rp-ids; rp-id is a
first-class per-realm object; passkeys remain tied to their rp-id
first-class per-domain object; passkeys remain tied to their rp-id
(WebAuthn-enforced); sessions remain host-bound. Motivating case:
`app1.company.com` and `app2.com` cannot share an rp-id, but user
management must be under single common controls.
Terminology: a **realm** is one rp-id with its associated hosts and
Terminology: a **domain** is one rp-id with its associated hosts and
origins. A **site** is any host served by the instance; each host belongs
to exactly one realm. The _administrative instance_ is the whole process:
global users/orgs, N realms.
to exactly one domain. The _administrative instance_ is the whole process:
global users/orgs, N domains.
## 1. What is global vs. per-realm
## 1. What is global vs. per-domain
**Global (single instance, shared across realms):**
**Global (single instance, shared across domains):**
| Data | Notes |
| ------------------------------- | -------------------------------------------------------- |
@@ -27,17 +27,17 @@ global users/orgs, N realms.
| Avatars | `paskia.data/users/<uuid>/profile.webp` |
| Auth codes, remote-auth manager | in-memory; carry rp-id fields |
**Per-realm (registry, keyed by rp-id):**
**Per-domain (registry, keyed by rp-id):**
| Data | Notes |
| -------------------------------------- | --------------------------------------------------------------- |
| `rp_id`, `rp_name`, origins, auth_host | stored combined `Config` |
| `Passkey` instance | per rp-id; ceremonies verify against the _origin realm's_ rp-id |
| `site_url`/`site_path` | runtime derivation, per realm |
| OIDC provider (keys, clients) | per rp-id — each realm is an independent issuer |
| Data | Notes |
| -------------------------------- | ---------------------------------------------------------------- |
| `rp_name`, origins, related | stored combined `Config` |
| `Passkey` instance | per rp-id; ceremonies verify against the _origin domain's_ rp-id |
| `site_url`/`site_path` | runtime derivation, per domain |
| OIDC provider (keys, clients) | per rp-id — each domain is an independent issuer |
`db.data()` is a plain global singleton. A contextvar is needed only for
the **current realm** (passkey, config, OIDC view) — not for database
the **current domain** (passkey, config, OIDC view) — not for database
access.
The architectural rule: **authentication establishes identity, not
@@ -47,7 +47,7 @@ session host binding).
## 2. Login architecture: three composable mechanisms
The realm infrastructure is shared by three mechanisms, alternatives
The domain infrastructure is shared by three mechanisms, alternatives
_per deployment_ and composable within one instance.
### 2.A WebAuthn Related Origin Requests (trusted domain families)
@@ -58,10 +58,10 @@ and those origins may then run ceremonies with the common rp-id locally —
no redirects, no cross-domain cookies. Browser support is universal
(Firefox included).
Model: realm `company.com` with related origin `https://app2.com`. A page
Model: domain `company.com` with related origin `https://app2.com`. A page
on `app2.com` calls WebAuthn with `rpId: "company.com"`; the passkey is
scoped to `company.com`; `clientDataJSON.origin` is `https://app2.com`,
which the backend validates against the realm's related origins.
which the backend validates against the domain's related origins.
Server side: paskia's `Passkey` passes `expected_origin=<the
pre-validated origin>` and `expected_rp_id=self.rp_id`; the webauthn
@@ -69,50 +69,50 @@ library string-compares origin and rp-id separately. The frontend never
chooses `rpId` client-side — ceremony options arrive from the server over
the WS. On top of that:
- Origin rule: `origins` and `related_origins` are separate fields.
An in-domain origin (rp-id or subdomain) is valid unless the realm's
- Origin rule: `origins` and `related` are separate fields.
An in-domain origin (rp-id or subdomain) is valid unless the domain's
`origins` allow-list is set, in which case it must be listed there. An
origin on another domain is valid only when listed in the realm's
`related_origins` — explicit related listing is the trust boundary.
origin on another domain is valid only when listed in the domain's
`related` — explicit related listing is the trust boundary.
- `GET /.well-known/webauthn` on the canonical rp-id host serves
`{"origins": [...]}` from the realm's related origins (404 when there
`{"origins": [...]}` from the domain's related origins (404 when there
are none).
- Dispatch resolution treats a Host matching a configured related-origin
hostname as belonging to that origin's realm (exact match only —
hostname as belonging to that origin's domain (exact match only —
`www.app2.com` does not follow `app2.com`).
Deployment constraint: the **browser** fetches
`https://<rp-id>/.well-known/webauthn` from the canonical apex directly —
if paskia does not host the apex, publish the JSON there statically (the
admin realm dialog links to the document for copying).
admin domain dialog shows the document for copying).
Constraints (from the WebAuthn WG): implementations must support at least
**5 registrable origin labels** — this is for a small family of
same-trust domains, not hundreds of customer domains. Sharing an rp-id
merges the security boundary: a weakly protected marketing domain should
not share the realm of the admin application. Config validation enforces
a cap (default 5) on related origins per realm.
not share the domain of the admin application. Config validation enforces
a cap (default 5) on related origins per domain.
**Re-enrollment note**: passkeys never move between rp-ids
(WebAuthn-enforced). A host family that first deploys separate realms
(WebAuthn-enforced). A host family that first deploys separate domains
(2.B) and later consolidates to Related Origins re-enrolls: authenticate
against the old realm (or via 2.C), register a new credential under the
against the old domain (or via 2.C), register a new credential under the
common rp-id, retire the old one. The per-credential rp-id badge (§9)
makes this visible. There is no automated credential migration.
### 2.B Multiple rp-id realms under one administrative instance
### 2.B Multiple rp-id domains under one administrative instance
For domains that should _not_ share an rp-id: rp-id is a first-class
object (realm), not an instance attribute. Users are global identities;
object (domain), not an instance attribute. Users are global identities;
credentials carry `rp_id`:
```
Instance
├── Orgs / Roles / Users (global)
└── Realms
├── company.com (origins [...], credentials scoped by rp_id)
├── app2.com (origins [...], credentials scoped by rp_id)
└── customer.net (origins [...], credentials scoped by rp_id)
└── Domains
├── company.com (origins {...}, credentials scoped by rp_id)
├── app2.com (origins {...}, credentials scoped by rp_id)
└── customer.net (origins {...}, credentials scoped by rp_id)
```
Alice can hold both a `company.com` and an `app2.com` passkey; sessions
@@ -120,96 +120,103 @@ stay host-only.
### 2.C Remote authorization + opportunistic local enrollment
For a realm where the user has no credential, the remote-login mechanism
For a domain where the user has no credential, the remote-login mechanism
provides a federation-style flow: unauthenticated device requests,
authenticated device permits, a short-lived **single-use opaque exchange
code** (60s `CookieCode`) is redeemed by the requesting host, which sets
its own host-only cookie. No shared cookies, no reusable tokens in URLs.
- Cross-realm permits are **allowed**: a device authenticated at
- Cross-domain permits are **allowed**: a device authenticated at
`company.com` may authorize a session for `app2.com`; the request's
realm is recorded and shown to the approver; the target host is
domain is recorded and shown to the approver; the target host is
registry-validated.
- **Opportunistic local enrollment**: after a cross-realm remote login,
the profile view offers "Add a passkey for <realm>" — registration runs
locally under the new realm's rp-id, stamping `Credential.rp_id`. This
- **Opportunistic local enrollment**: after a cross-domain remote login,
the profile view offers "Add a passkey for <domain>" — registration runs
locally under the new domain's rp-id, stamping `Credential.rp_id`. This
makes remote login primarily bootstrap/recovery, while everyday
authentication stays local.
### Policy summary (how deployments choose)
| Situation | Mechanism |
| -------------------------------------------------- | --------------------------------------------- |
| Few closely related, equally trusted brand domains | 2.A Related Origins — one passkey |
| Independent / customer / lower-trust domains | 2.B separate realms — passkey per realm |
| User lacks a credential for the current realm | 2.C remote authorization, then enroll locally |
| Situation | Mechanism |
| --------------------------------------------------- | ---------------------------------------------- |
| Few closely related, equally trusted brand domains | 2.A Related Origins — one passkey |
| Independent / customer / lower-trust domains | 2.B separate domains — passkey per domain |
| User lacks a credential for the current domain | 2.C remote authorization, then enroll locally |
## 3. Configuration model
### 3.1 Stored config
```python
class RealmConfig(msgspec.Struct, omit_defaults=True):
rp_id: str
class OriginEntry(msgspec.Struct, omit_defaults=True):
auth_host: bool = False # this site hosts the account/admin interface
class DomainConfig(msgspec.Struct, omit_defaults=True):
rp_name: str | None = None
auth_host: str | None = None # this realm's dedicated auth host
origins: list[str] | None = None # allow-list of in-domain sign-in sites
related_origins: list[str] | None = None # cross-domain ROR origins (§2.A)
origins: dict[str, bool | OriginEntry] = {} # in-domain sign-in sites
related: dict[str, bool] = {} # cross-domain ROR origins (§2.A)
class Config(msgspec.Struct, omit_defaults=True):
realms: list[RealmConfig] # at least one; first entry is the default realm
domains: dict[str, DomainConfig] # keyed by rp-id; at least one
listen: list[str] | None = None # process-global
```
- "At least one realm" is enforced by validation (not expressible in
msgspec).
- The first entry is the default realm, used only where a default is
genuinely needed (bootstrap reset-link URL, startup box ordering,
master-admin entry point) — never for dispatch.
- Domains are keyed by rp-id; there is no "default" or "primary" domain.
Where a domain is needed without request context (bootstrap reset-link
URL, background jobs), the single configured domain is used, and with
several domains the first one sorted by rp-id — never for dispatch.
- Origin keys are bare hosts (`app.example.com`), wildcard patterns
(`*.example.com`), or full origins when not https
(`http://localhost:8080`) — `https://` is omitted as the common case.
A dict value of `true` means presence only; an object carries extra
properties (currently just `auth_host`). Ordering carries no meaning —
display order is a UI affair.
- An empty `origins` dict means the rp-id and all its subdomains may
sign in (the default). A non-empty dict is an allow-list of in-domain
sign-in sites; one entry may be marked `auth_host` (never a wildcard).
- **Origin validation** — two separate concerns: `origins` entries must
be within the rp-id domain (an allow-list; unset = the rp-id and all
subdomains may authenticate). `related_origins` entries must be
outside it, are capped (default 5), and must not collide with another
realm's rp-id/auth-host/related origins nor fall inside another
realm's domain. Misfiled entries (cross-domain in `origins`, in-domain
in `related_origins`) are rejected. These rules are enforced at admin
be within the rp-id domain. `related` entries must be outside it, are
capped (default 5), must not be wildcards, and must not collide with
another domain's rp-id/auth-host/related origins nor fall inside
another domain's rp-id. Misfiled entries (cross-domain in `origins`,
in-domain in `related`) are rejected. These rules are enforced at admin
write time; at startup the stored config is sanitized best-effort
instead (§3.2). Origins are never _implicitly_ cross-domain.
### 3.2 CLI: bootstrap (`paskia init`) vs. serve (`paskia`)
The CLI is split so that realm options exist only at bootstrap time —
The CLI is split so that domain options exist only at bootstrap time —
they can never mix with runtime configuration of an already-configured
instance:
- **`paskia init`** — creates `paskia.kantadb` in CWD and seeds it:
- `--rp-id`: repeatable/comma-separated, default `["localhost"]`.
Multiple values create multiple realms at once (useful for
devserver/e2e); the **first is the default realm**.
- `--rp-name`: applies to the default realm. Its purpose is that the
very first admin registration ceremony already shows the correct RP
name; afterwards rp-names are edited via the admin interface.
- `--auth-host`, `--origin`: apply to the default realm. Further
realms' hosts are configured via the admin interface.
Multiple values create multiple domains at once (useful for
devserver/e2e).
- `--rp-name`, `--auth-host`, `--origin`: apply to the **first**
rp-id only. Their purpose is that the very first admin registration
ceremony already shows the correct RP name; everything is editable
via the admin interface afterwards.
- `--listen`: stored into `Config.listen` (process-global).
- Seeds the admin user + registration reset link (link URL from the
default realm) and prints the link. Refuses to run if
`paskia.kantadb` already exists, or if an unconverted legacy
`*.paskiadb` is present (`paskia migrate` converts it first).
- Seeds the admin user + registration reset link and prints the link.
Refuses to run if `paskia.kantadb` already exists, or if an
unconverted legacy `*.paskiadb` is present (`paskia migrate` converts
it first).
- **`paskia migrate`** — converts a legacy `<rp-id>.paskiadb` database
(§10) to `paskia.kantadb`. With several legacy candidates, `--rp-id`
selects `<rp-id>.paskiadb` by name; the others are left in place.
- **`paskia`** — serve. Takes **no realm options**; only `--listen`
- **`paskia`** — serve. Takes **no domain options**; only `--listen`
(per-run override of stored `Config.listen`, never persisted). Startup:
open `paskia.kantadb` → sanitize the stored realm set best-effort →
build the realm registry → serve. Sanitization never refuses to start:
open `paskia.kantadb` → sanitize the stored domain set best-effort →
build the domain registry → serve. Sanitization never refuses to start:
misfiled origin entries are reclassified (a cross-domain `origins`
entry is served as a related origin) or dropped, colliding
auth-hosts/related origins resolve first-come-wins, over-cap related
lists truncate, and unsalvageable realms are skipped — each producing
lists truncate, and unsalvageable domains are skipped — each producing
a startup warning, because fixing the stored config is the admin
interface's job and it must stay reachable to do so. Only a config
with no servable realm at all is fatal. The serve command never
with no servable domain at all is fatal. The serve command never
converts databases: with no `paskia.kantadb`, the startup error points
at `paskia init`, or at `paskia migrate` when legacy `*.paskiadb`
candidates are present.
@@ -220,41 +227,41 @@ stamp the child rp-id.
### 3.3 Runtime accessors
- The realm registry is built in the FastAPI lifespan **after
`kanta.open()`**, from `db.data().config.realms` — realm data does not
travel through `PASKIA_CONFIG`. Per-realm `site_url`/`site_path` are
computed at registry-build time (priority: auth_host > origins[0] >
`PASKIA_VITE_URL` for the localhost realm > `http://localhost:port` >
`https://rp-id`), using the effective listen endpoints for the
localhost fallback.
- The domain registry is built in the FastAPI lifespan **after
`kanta.open()`**, from `db.data().config.domains` — domain data does
not travel through `PASKIA_CONFIG`. Per-domain `site_url`/`site_path`
are computed at registry-build time (priority: auth host > exact rp-id
origin key > first concrete origin key > `PASKIA_VITE_URL` for the
localhost domain > `http://localhost:port` > `https://rp-id`), using
the effective listen endpoints for the localhost fallback.
- `PASKIA_CONFIG` carries only process-global serve parameters (the
effective listen endpoints) so the derivation inside the server
process can resolve the localhost-port fallback.
- Admin realm writes persist the combined `Config` and rebuild the
registry in place, so dispatch sees auth_host and related-origin
- Admin domain writes persist the combined `Config` and rebuild the
registry in place, so dispatch sees auth host and related-origin
changes immediately.
## 4. Credentials carry an rp-id
- `Credential.rp_id: str` is stamped at registration from the ceremony's
rp-id. With Related Origins the stamp is always the _realm's canonical_
rp-id regardless of which origin the ceremony ran on — the credential
genuinely is a `company.com` passkey.
rp-id. With Related Origins the stamp is always the _domain's
canonical_ rp-id regardless of which origin the ceremony ran on — the
credential genuinely is a `company.com` passkey.
- `authenticate_chat` filters the raw_id scan by `c.rp_id == ceremony
rp-id` — prevents wrong error semantics and a cross-realm oracle ("no
rp-id` — prevents wrong error semantics and a cross-domain oracle ("no
credential" vs "verification failed" would leak which rp-id a
credential belongs to).
- `exclude_credentials` (registration) and reauth `allow_credentials` are
filtered by the ceremony's rp-id (`User.credential_ids_for(rp_id)`) —
users are global, so their credential id sets are cross-realm.
users are global, so their credential id sets are cross-domain.
- Cascades are uuid-keyed; deleting a user removes their passkeys across
all realms (correct: users are global).
all domains (correct: users are global).
## 5. Dispatch and realm context
## 5. Dispatch and domain context
- `paskia/realms.py`: `Realm { config, passkey, ... }` and a registry
- `paskia/domains.py`: `Domain { config, passkey, ... }` and a registry
keyed by rp-id, built in the lifespan from the stored combined `Config`
and rebuilt on admin realm writes. No per-realm Kanta/DB.
and rebuilt on admin domain writes. No per-domain Kanta/DB.
- Host resolution (`resolve(host)`): normalize (lowercase, strip port and
trailing dot), then exact rp-id → exact auth host → **exact
related-origin hostname** → longest-suffix rp-id. Unknown → `None`.
@@ -262,52 +269,53 @@ stamp the child rp-id.
collisions between these sets.)
- A pure ASGI dispatch middleware, outermost, handles `"http"` and
`"websocket"` scopes. Unknown Host → 421 Misdirected Request (WS:
pre-accept close). Sets the `current_realm` contextvar +
`scope["state"]["realm"]`.
pre-accept close). Sets the `current_domain` contextvar +
`scope["state"]["domain"]`.
- **WebSocket resolution follows the `Origin`, not the connection
`Host`**: in auth-host mode the login page is on the app host, the WS
connects to the auth host, and Origin names the host being logged into.
So:
1. the middleware resolves the origin realm from the Origin hostname —
1. the middleware resolves the origin domain from the Origin hostname —
including related-origin hostnames;
2. the connection `Host` must be a valid WS endpoint for that realm:
the realm's _effective auth host_ (§6), or the origin host itself
when the realm has no auth host at all — else pre-accept reject;
3. `validate_origin` runs endpoint-side against the origin realm's
2. the connection `Host` must be a valid WS endpoint for that domain:
the domain's _effective auth host_ (§6), or the origin host itself
when the domain has no auth host at all — else pre-accept reject;
3. `validate_origin` runs endpoint-side against the origin domain's
`Passkey` (post-accept JSON errors preserved);
4. `current_realm` = origin realm for the WS handler's duration.
The ceremony rp-id is always the origin realm's rp-id — exactly what
4. `current_domain` = origin domain for the WS handler's duration.
The ceremony rp-id is always the origin domain's rp-id — exactly what
the browser enforces for the page's origin under both classic and
related-origin rules.
## 6. Auth host: per-realm values with global fallback
## 6. Auth host: per-domain values with global fallback
A realm without its own auth host falls back to the first configured auth
host (realm-list order):
A domain's auth host is one of its origins entries marked
`auth_host: true`. A domain without its own auth host falls back to the
first configured auth host found in the registry:
```
effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or None
effective_auth_host(domain) = domain's own auth host or first configured one or None
```
**Own vs. effective auth host are distinguished everywhere:**
- **Follow the realm's OWN auth host**: UI mode detection (minimal-
- **Follow the domain's OWN auth host**: UI mode detection (minimal-
profile decision in `App.vue`, via `own_auth_host` in settings), the
redirect middleware, `ui_base_path`, and `reset_link_url`. A realm with
no own auth host keeps its full UI on its own hosts — otherwise
redirect middleware, `ui_base_path`, and `reset_link_url`. A domain
with no own auth host keeps its full UI on its own hosts — otherwise
reset/registration pages on `app2.com` would redirect to
`auth.company.com`, where the ceremony's Origin resolves the _owner_
realm and stamps the wrong `Credential.rp_id`, breaking 2.B onboarding
domain and stamps the wrong `Credential.rp_id`, breaking 2.B onboarding
and 2.C local enrollment.
- **Follow the EFFECTIVE auth host**: WS endpoint selection only
(`passkey.js` builds the WS URL from settings). The fallback auth host
serves WS + restricted APIs for foreign realms.
serves WS + restricted APIs for foreign domains.
- Settings (`ApiSettings`) exposes both fields (`own_auth_host` alongside
the effective `auth_host`) so the frontend makes the mode decision
correctly.
- Root mode (`site_path == "/"`) applies only on a realm's _own_ auth
host, so it is never ambiguous: a realm's UI lives on its own hosts;
the fallback auth host serves the _owner_ realm's UI plus WS for the
- Root mode (`site_path == "/"`) applies only on a domain's _own_ auth
host, so it is never ambiguous: a domain's UI lives on its own hosts;
the fallback auth host serves the _owner_ domain's UI plus WS for the
rest.
## 7. Login flows and in-memory stores
@@ -316,104 +324,104 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
- `OIDCCode` and `CookieCode` carry `rp_id`, verified at redemption —
defense in depth.
- **Stamping source matters**: codes are stamped with the realm of the
session they will redeem — not naively with the current realm at
- **Stamping source matters**: codes are stamped with the domain of the
session they will redeem — not naively with the current domain at
issuance. Remote-completion codes are minted inside the _permit_
handler (permitting realm's context) but redeemed by the _requesting_
handler (permitting domain's context) but redeemed by the _requesting_
device on its own host, so they are stamped with
`RemoteAuthRequest.rp_id` — stamping them with the permitter's realm
would break every cross-realm remote login. Registration-flow and OIDC
codes stamp from the current realm (issue and redeem sides always
`RemoteAuthRequest.rp_id` — stamping them with the permitter's domain
would break every cross-domain remote login. Registration-flow and OIDC
codes stamp from the current domain (issue and redeem sides always
match). The host re-check at set-session independently binds
`CookieCode` to the host; the rp_id check complements it.
### 7.2 Remote authentication — cross-realm permits allowed
### 7.2 Remote authentication — cross-domain permits allowed
- `RemoteAuthRequest.rp_id` records the requesting device's origin realm.
- `RemoteAuthRequest.rp_id` records the requesting device's origin
domain.
- **Permit side may differ from the request side** (mechanism 2.C): the
permitting device authenticates with _its_ realm's passkey, and the
permitting device authenticates with _its_ domain's passkey, and the
`session_host=request.host` override creates the session for the
requesting host. The override must resolve to a **configured realm**
requesting host. The override must resolve to a **configured domain**
(registry check) — arbitrary-host session binding is refused. The
request's rp-id is shown to the permitting user ("device at app2.com
requests login").
- No policy flag: cross-realm remote login is how the product works
- No policy flag: cross-domain remote login is how the product works
(users are global).
- Exchange codes stay single-use, 60s, host-bound at redemption.
## 8. OIDC: per-realm providers in one DB
## 8. OIDC: per-domain providers in one DB
- `DB.oidc` is `dict[str, OIDC]` keyed by rp-id. Each realm is an
- `DB.oidc` is `dict[str, OIDC]` keyed by rp-id. Each domain is an
independent provider: own signing key (`oidc.<rp-id>.key` in the
transaction log shape), own clients.
- The `util/oidjwt.py` key cache is keyed by rp-id.
- Issuer stays per-request-Host — each realm host is an issuer alias
sharing the realm's key. **`Session` carries two fields**: `issuer`
- Issuer stays per-request-Host — each domain host is an issuer alias
sharing the domain's key. **`Session` carries two fields**: `issuer`
(stamped from the WS **Origin**, scheme included, at OIDC-session
creation and re-stamped at refresh — stamping from the WS _connection_
Host would be wrong, that is the effective auth host, not the
authorize/discovery host the RP validates against); and `rp_id` — the
owning realm, needed by every path that runs **without request
owning domain, needed by every path that runs **without request
context**:
- backchannel logout uses `session.rp_id` to select the realm's key
- backchannel logout uses `session.rp_id` to select the domain's key
and `session.issuer` as `iss`;
- session listings resolve the client under the session's own realm,
- session listings resolve the client under the session's own domain,
not the request's.
- Admin OIDC-client CRUD operates on the current realm's `OIDC` entry.
- Admin OIDC-client CRUD operates on the current domain's `OIDC` entry.
- Permission `domain` validation accepts a subdomain of **any**
configured rp-id, any related-origin hostname, or any realm's client
configured rp-id, any related-origin hostname, or any domain's client
UUID.
## 9. Admin API and UI
- `GET /auth/api/settings`: per-request-Host realm values (rp_id,
- `GET /auth/api/settings`: per-request-Host domain values (rp_id,
rp_name, effective `auth_host`, `own_auth_host`, site URLs).
- **Realm management (master admin only, `auth:admin`)** — how rp-ids are
managed after bootstrap:
- `GET/POST /auth/api/admin/realms/` and
`PATCH/DELETE /auth/api/admin/realms/{rp_id}`. Writes require recent
- **Domain management (master admin only, `auth:admin`)** — how rp-ids
are managed after bootstrap:
- `GET/POST /auth/api/admin/domains/` and
`PATCH/DELETE /auth/api/admin/domains/{rp_id}`. Writes require recent
authentication (5 minutes).
- Create: `rp_id` + optional `rp_name` (defaults to the rp-id),
`auth_host`, `origins`, `related_origins`; full §3.1 validation (cap,
cross-realm collisions); registry rebuilt immediately, including the
realm's `Passkey` instance and OIDC provider entry.
`origins` and `related` objects mirroring the stored shape (§3.1);
full §3.1 validation (cap, cross-domain collisions); registry rebuilt
immediately, including the domain's `Passkey` instance and OIDC
provider entry. The auth host is marked inside `origins`
(`{"auth.example.com": {"auth_host": true}}`).
- Update: same validation against the would-be combined config.
Changing a realm's rp-id itself is **not supported** (it would orphan
every credential stamped with the old rp-id) — delete and recreate
instead.
- Delete: refused for the last remaining realm, while any credential
carries the realm's rp-id (re-enroll or delete those credentials
first), and for the realm the admin is currently using; cascades
Changing a domain's rp-id itself is **not supported** (it would
orphan every credential stamped with the old rp-id) — delete and
recreate instead.
- Delete: refused for the last remaining domain, while any credential
carries the domain's rp-id (re-enroll or delete those credentials
first), and for the domain the admin is currently using; cascades
nothing else (users/orgs are global).
- **Lockout guard**: an update that would make the admin's current
host unable to run passkey ceremonies for the realm they are on is
host unable to run passkey ceremonies for the domain they are on is
refused (unless an auth host takes over ceremonies — it is always
allowed). Fixing a broken stored config is always possible: serve
sanitizes it best-effort (§3.2) so the admin interface stays
reachable on a working realm.
- The admin UI has a Realms section with a table (rp-id, name,
effective auth host, sign-in site and related domain counts),
per-row edit/delete and an add-realm dialog. The dialog edits the
in-domain allow-list and the related domains as two separate lists
with their own explanations. Its connectivity probe fetches
`<origin>/auth/api/settings` and compares the returned rp-id against
the edited realm — a related origin served by this instance answers
with the realm's rp_id. Connectivity/mismatch results are warnings;
malformed entries, misfiled entries (cross-domain in the allow-list,
in-domain in related domains), and an auth host outside the rp-id
domain block saving.
reachable on a working domain.
- The admin UI has a Domains section with a table (rp-id + rp-name,
allowed origins with a 🔑 on the auth host, actions), per-row
edit/delete and an add-domain dialog. In the dialog the in-domain
allow-list and the related domains are edited as one list — entries
are classified by whether they fall within the rp-id domain; related
domains additionally show the `/.well-known/webauthn` document the
canonical rp-id must publish and a warning when there are more than
5. Marking `*.example.com` as the auth host creates a concrete
`auth.example.com` entry instead.
- Credential listings: `Credential.rp_id` serializes automatically into
user-info and admin user detail responses; the frontend shows an rp-id
badge **only when `credential.rp_id !== settings.rp_id`** — single-
realm installs never show a badge.
domain installs never show a badge.
- **Enrollment prompt (2.C)**: when the user has no passkey for the
current realm, the profile view offers to add one (a fresh remote-auth
current domain, the profile view offers to add one (a fresh remote-auth
session satisfies the recent-auth requirement of registration).
- Bootstrap/reset links use the default realm's URL.
- **Bootstrap check**: the "admin has no credentials" startup check tests
for an admin credential **under the default realm's rp-id** — with
global users an admin may have passkeys only under another realm, and
the printed link must still be usable for the default realm.
- **Bootstrap check**: the "admin has no credentials" startup check
passes if the admin holds a credential under **any** configured domain;
otherwise a registration link is printed for the first domain (sorted
by rp-id).
## 10. Storage
@@ -436,7 +444,7 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
trigger conversion or writes.
- The legacy database's structs live in a separate module
(`paskia/db/legacy.py`). There is no multi-database merging.
- The startup box prints per-realm lines.
- The startup box prints per-domain lines.
## 11. Lifespan and background tasks
@@ -445,21 +453,21 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
- The kanta bootstrap hook only ever fires for a database created by
`paskia init` or `paskia migrate`; the serve command never bootstraps.
- The registry is built from the stored `Config` after open, sanitized
best-effort (§3.2) so startup never fails on config content; per-realm
best-effort (§3.2) so startup never fails on config content; per-domain
`Passkey` instances are constructed from the sanitized config.
- The admin-credential check runs at serve startup and reprints a usable
registration link when the admin lacks a credential under the default
realm (§9).
- `oidc_notify` fire-and-forget tasks need no realm context for DB access
(global DB); issuer comes from the session (§8).
- The dispatch middleware is the only place `current_realm` is set for
requests; admin realm writes rebuild the registry.
registration link when the admin lacks a credential under any
configured domain (§9).
- `oidc_notify` fire-and-forget tasks need no domain context for DB
access (global DB); issuer comes from the session (§8).
- The dispatch middleware is the only place `current_domain` is set for
requests; admin domain writes rebuild the registry.
## 12. Development
- `scripts/devserver.py`: bootstraps via one-shot `paskia init` when no
database exists (multi `--rp-id`, and `--rp-name`/`--auth-host`/
`--origin` for the default realm), then runs plain `paskia` serve.
`--origin` for the first domain), then runs plain `paskia` serve.
Caddy dev origins iterate all bootstrap rp-ids plus the auth host and
explicit origins.
- `PASKIA_AUTH_HOST` (consumed by `frontend/vite.config.js`) is a
@@ -470,14 +478,14 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
to paskia so a static `/.well-known/*` handler does not shadow them.
- E2E: `e2e/tests/global-setup.ts` runs `paskia init --rp-id
localhost,test.localhost` in the test-data dir (which doubles as the
server CWD) and serves; `e2e/tests/50-multirealm.spec.ts` exercises
host dispatch, the well-known endpoint via the admin realm API, and a
cross-realm remote login (request at test.localhost, permit at
server CWD) and serves; `e2e/tests/50-multidomain.spec.ts` exercises
host dispatch, the well-known endpoint via the admin domain API, and a
cross-domain remote login (request at test.localhost, permit at
localhost, session valid on test.localhost) including the enrollment
prompt UI. Related Origins have **no browser e2e**: a genuine related
origin needs a non-subdomain host over HTTPS, and the browser fetches
the well-known document itself — server-side coverage is in pytest
(`tests/test_realms.py`).
(`tests/test_domains.py`).
## 13. Security model
@@ -485,28 +493,28 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
and unconfigured-name access does not work; trailing dots normalized).
- **Related Origins boundary**: cross-domain origins are valid only when
explicitly configured and capped; the well-known document is served
only for the canonical realm and only lists configured origins. All
only for the canonical domain and only lists configured origins. All
origins sharing an rp-id share one security boundary — do not mix trust
levels within a realm.
- **Realm administration**: realm create/update/delete is gated on
levels within a domain.
- **Domain administration**: domain create/update/delete is gated on
`auth:admin` — deployment-wide by design. Writes are strictly
validated (cross-realm rules plus self-lockout guards); startup never
validated (cross-domain rules plus self-lockout guards); startup never
refuses a stored config — it sanitizes with warnings so the admin
interface stays reachable to fix problems.
- **Passkeys**: rp-id binding browser-enforced and server-recorded;
ceremonies, credential scans, exclude/allow lists all scoped to the
origin realm's rp-id. No cross-realm oracle in the scan.
- **Sessions**: host-bound, exact match. Cross-realm sessions arise only
via (a) a ceremony at the origin realm (incl. related origins), or (b)
a remote permit by a device holding a valid session at its own realm,
origin domain's rp-id. No cross-domain oracle in the scan.
- **Sessions**: host-bound, exact match. Cross-domain sessions arise only
via (a) a ceremony at the origin domain (incl. related origins), or (b)
a remote permit by a device holding a valid session at its own domain,
with registry-validated target host.
- **Users/orgs global**: deleting a user/org cascades across all realms —
- **Users/orgs global**: deleting a user/org cascades across all domains —
intended. `auth:admin` is deployment-wide. Permission `domain`
host-scopes effectiveness per host.
- **Cross-realm permit transparency**: requesting realm/host shown to the
approver; both sides logged.
- **Cross-domain permit transparency**: requesting domain/host shown to
the approver; both sides logged.
- **Secret hygiene in logs**: the OIDC signing-key censoring matches the
`oidc.<rp-id>.key` path shape, so realm keys never print in plaintext
`oidc.<rp-id>.key` path shape, so domain keys never print in plaintext
in the JSONL transaction log.
- **OIDC**: per-realm keys/issuers; logout tokens carry the stored
- **OIDC**: per-domain keys/issuers; logout tokens carry the stored
issuer.
+1 -1
View File
@@ -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/`.
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
@@ -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()
})
+3 -3
View File
@@ -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<any> {
return page.evaluate(async ({ code, powSrc, b64src }) => {
+3 -3
View File
@@ -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))
+3 -3
View File
@@ -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;
}
+4 -4
View File
@@ -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,
+1 -1
View File
@@ -28,10 +28,10 @@ from paskia.db.structs import (
OIDC,
Config,
Credential,
DomainConfig,
Org,
OriginEntry,
Permission,
DomainConfig,
ResetToken,
Role,
Session,
+1 -1
View File
@@ -21,10 +21,10 @@ from paskia.db.structs import (
Client,
Config,
Credential,
DomainConfig,
Org,
OriginEntry,
Permission,
DomainConfig,
ResetToken,
Role,
Session,
+2 -6
View File
@@ -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":
+1 -1
View File
@@ -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,
+4 -2
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+4 -2
View File
@@ -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
+1 -1
View File
@@ -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
+4 -2
View File
@@ -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:
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
-21
View File
@@ -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:
+7 -5
View File
@@ -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):
+9 -9
View File
@@ -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)
+99 -109
View File
@@ -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
+11 -5
View File
@@ -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"
+16 -16
View File
@@ -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
``<rp-id>.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()
+189 -200
View File
@@ -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,