Instance-global OIDC provider; per-domain auth hosts with shared-host resolution

- DB.oidc is a single OIDC (one key, one client set); hosts are issuer
  aliases. OIDCCode drops its rp_id field; client CRUD is not keyed by
  domain.
- No cross-domain auth-host fallback: a domain without its own auth host
  uses its own hosts; several domains may share one auth host (nested
  rp-ids) with deterministic best-suffix resolution.
- '*' origin shorthand expands to '*.{rp-id}'; legacy wildcards convert
  as-is; related origins may point at/inside another domain's rp-id.
- Admin UI and docs updated to match.
This commit is contained in:
2026-09-07 06:10:08 +00:00
parent 00560a05c1
commit af6d7e3a3f
29 changed files with 491 additions and 420 deletions
+98 -83
View File
@@ -25,7 +25,8 @@ global users/orgs, N domains.
| Credentials/passkeys | global collection, each stamped with its `rp_id` |
| Reset tokens | user-bound; global |
| Avatars | `paskia.data/users/<uuid>/profile.webp` |
| Auth codes, remote-auth manager | in-memory; carry rp-id fields |
| Auth codes, remote-auth manager | in-memory; `CookieCode` carries an rp-id field |
| OIDC provider (key, clients) | one instance-global provider; hosts are issuer aliases |
**Per-domain (registry, keyed by rp-id):**
@@ -34,11 +35,9 @@ global users/orgs, N domains.
| `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 domain** (passkey, config, OIDC view) — not for database
access.
the **current domain** (passkey, config) — not for database access.
The architectural rule: **authentication establishes identity, not
organization** — the requested hostname selects the org/permission
@@ -79,7 +78,9 @@ the WS. On top of that:
are none).
- Dispatch resolution treats a Host matching a configured related-origin
hostname as belonging to that origin's domain (exact match only —
`www.app2.com` does not follow `app2.com`).
`www.app2.com` does not follow `app2.com`) — unless the host is itself
a configured rp-id, which always wins: a host that is one domain's
rp-id and another's related origin serves its own domain.
Deployment constraint: the **browser** fetches
`https://<rp-id>/.well-known/webauthn` from the canonical apex directly —
@@ -167,26 +168,35 @@ class Config(msgspec.Struct, omit_defaults=True):
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`), full origins when not https (`http://localhost:8080`),
or the bare `*``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.
(`*.example.com`), full origins when not https
(`http://localhost:8080`), or the bare `*` (shorthand for a wildcard
over the rp-id itself) — `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; matching semantics per entry kind:
- `*`anything within the rp-id domain, any scheme and port;
- `*.example.com` — the base domain and its subdomains, **https only**;
- `*`the whole rp-id domain (shorthand for `*.{rp-id}`);
- `*.example.com` — the base domain and its subdomains, **https only**
— except under localhost (`*.localhost` or any wildcard below it),
which matches **any scheme and any port**;
- anything else — exact match on scheme, host and port.
One entry may be marked `auth_host` (never a wildcard or `*`).
- **Origin validation** — two separate concerns: `origins` entries must
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.
another domain's auth host; two domains may not list the same related
host **unless** that host is (or falls inside) a configured rp-id —
overlapping another domain's rp-id is permitted: the owning domain
always wins dispatch for that host, and each listing domain's
well-known document independently authorizes ROR logins. Several
domains may mark the same auth host when it lies under both rp-ids
(nested rp-ids); resolution among claimants is deterministic (§5).
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`)
@@ -207,26 +217,25 @@ instance:
Refuses to run if an unconverted legacy `*.paskiadb` is present
(`paskia migrate` converts it first).
- **With an existing `paskia.kantadb`**, init instead adds the given
rp-id as a new domain (seeding its OIDC provider), or updates the
rp-id as a new domain, or updates the
rp-name of an existing one — a convenience for what the admin
interface also does.
- **`paskia migrate [rp-id]`** — converts a legacy `<rp-id>.paskiadb`
database (§10) to `paskia.kantadb`. With several legacy candidates, the
positional rp-id selects `<rp-id>.paskiadb` by name; the others are
left in place. A legacy wildcard origin over the rp-id itself
(`*.example.com`) converts to the bare `*` entry, preserving its
any-scheme meaning.
left in place. Legacy wildcard origins (`*.example.com`) convert as-is
(https-only outside localhost, any scheme and port under localhost).
- **`paskia`** — serve. Takes **no domain options**; only `--listen`
(per-run override of stored `Config.listen`, never persisted). Startup:
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 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 domain at all is fatal. The serve command never
entry is served as a related origin) or dropped, related origins
claimed by two non-owner domains resolve first-come-wins, over-cap
related 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 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.
@@ -273,10 +282,13 @@ stamp the child rp-id.
keyed by rp-id, built in the lifespan from the stored combined `Config`
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
trailing dot), then exact rp-id → auth host → **exact
related-origin hostname** → longest-suffix rp-id. Unknown → `None`.
(Order safe because startup and admin-write validation forbids
collisions between these sets.)
Overlaps are deterministic: an owning rp-id always beats a related
listing of the same host; when several domains claim one auth host,
the claimant whose rp-id is the longest suffix of the host wins
(first configured as tiebreak); a related host claimed by two
non-owner domains resolves first-configured-wins.
- A pure ASGI dispatch middleware, outermost, handles `"http"` and
`"websocket"` scopes. Unknown Host → 421 Misdirected Request (WS:
pre-accept close). Sets the `current_domain` contextvar +
@@ -288,7 +300,7 @@ stamp the child rp-id.
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 domain:
the domain's _effective auth host_ (§6), or the origin host itself
the domain's _own 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);
@@ -297,51 +309,49 @@ stamp the child rp-id.
the browser enforces for the page's origin under both classic and
related-origin rules.
## 6. Auth host: per-domain values with global fallback
## 6. Auth host: per-domain, no fallback
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:
`auth_host: true` (always in-domain). There is **no cross-domain
fallback**: a domain without its own auth host uses its own hosts for WS
and all flows — one domain's auth host never reroutes another domain's
authentication. Consolidating logins on one host is explicit: the host
must be marked on every domain that uses it (possible when the host lies
under each domain's rp-id, i.e. nested rp-ids; see §5 for deterministic
resolution among claimants). Marking a _foreign_ host as a domain's own
auth host is rejected (origins entries are in-domain only): it would
redirect that domain's UI to the other domain, where the ceremony's
Origin resolves the _owner_ domain and stamps the wrong
`Credential.rp_id`.
```
effective_auth_host(domain) = domain's own auth host or first configured one or None
auth_host(domain) = domain's own auth host or None
```
**Own vs. effective auth host are distinguished everywhere:**
- **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 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_
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 domains.
- Settings (`ApiSettings`) exposes both fields (`own_auth_host` alongside
the effective `auth_host`) so the frontend makes the mode decision
correctly.
- **Own auth host governs everything**: UI mode detection (minimal-
profile decision in `App.vue`), the redirect middleware,
`ui_base_path`, `reset_link_url`, and WS endpoint selection
(`passkey.js` builds the WS URL from settings).
- Settings (`ApiSettings`) exposes `auth_host` and `own_auth_host` with
the same value (the latter remains for clients that switched to it).
- 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.
host: a domain's UI lives on its own hosts.
## 7. Login flows and in-memory stores
### 7.1 Auth codes
- `OIDCCode` and `CookieCode` carry `rp_id`, verified at redemption —
defense in depth.
- `CookieCode` carries `rp_id`, verified at redemption — defense in
depth. `OIDCCode` does not: the OIDC provider is instance-global, so
its codes are redeemable at any host.
- **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 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 domain
would break every cross-domain remote login. Registration-flow and OIDC
codes stamp from the current domain (issue and redeem sides always
would break every cross-domain remote login. Registration-flow 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.
@@ -360,28 +370,33 @@ effective_auth_host(domain) = domain's own auth host or first configured one or
(users are global).
- Exchange codes stay single-use, 60s, host-bound at redemption.
## 8. OIDC: per-domain providers in one DB
## 8. OIDC: one instance-global provider
- `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 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 domain, needed by every path that runs **without request
context**:
- 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 domain,
not the request's.
- Admin OIDC-client CRUD operates on the current domain's `OIDC` entry.
- `DB.oidc` is a single `OIDC` — one signing key (`oidc.key` in the
transaction log shape) and one client set for the whole instance.
Domains do **not** segment OIDC: a client registered once is usable
through every configured domain.
- The `util/oidjwt.py` key cache holds the single signing key.
- Issuer stays per-request-Host — every configured host is an issuer
alias sharing the one key, and the discovery document
(`/.well-known/openid-configuration`) is served on every host with
host-derived endpoints. An RP picks **one** discovery URL and uses it
consistently; tokens then validate against that issuer. The admin OIDC
client view lists the discovery URL of every configured domain.
- **`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
may be an auth host, not the authorize/discovery host the RP
validates against); and `rp_id` — the owning domain, kept for display
and diagnostics.
- backchannel logout runs without request context and uses
`session.issuer` as `iss` (falling back to `https://<session.host>`).
- Admin OIDC-client CRUD operates on the instance-global `OIDC` entry.
- Permission `domain` validation accepts a subdomain of **any**
configured rp-id, any related-origin hostname, or any domain's client
UUID.
configured rp-id, any related-origin hostname, or a client UUID.
- OIDC authorization always runs a fresh passkey ceremony; the session
cookie is never consulted in the OIDC branch of the WS authenticate
handler, so a stolen cookie cannot complete an OIDC login on any host.
## 9. Admin API and UI
@@ -395,8 +410,8 @@ effective_auth_host(domain) = domain's own auth host or first configured one or
- Create: `rp_id` + optional `rp_name` (defaults to the rp-id),
`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`
immediately, including the domain's `Passkey` instance. The OIDC
provider is instance-global and unaffected by domain writes. The auth host is marked inside `origins`
(`{"auth.example.com": {"auth_host": true}}`).
- Update: same validation against the would-be combined config.
Changing a domain's rp-id itself is **not supported** (it would
@@ -522,7 +537,7 @@ effective_auth_host(domain) = domain's own auth host or first configured one or
- **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 domain keys never print in plaintext
`oidc.key` path shape, so the key never prints in plaintext
in the JSONL transaction log.
- **OIDC**: per-domain keys/issuers; logout tokens carry the stored
issuer.
- **OIDC**: instance-global key and clients; issuers are per-request-Host
aliases; logout tokens carry the stored issuer.