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:
+98
-83
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user