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:
+3
-3
@@ -79,7 +79,7 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access
|
||||
|
||||
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 object keyed by sign-in sites *within* the domain (bare hosts, `*.` wildcards matching the base domain and subdomains over https only, the bare `*` for anything in-domain on any scheme/port, or full origins when not https); an empty object means the rp-id and all subdomains may authenticate on any scheme. 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`.
|
||||
`origins` is an object keyed by sign-in sites *within* the domain (bare hosts, `*.` wildcards matching the base domain and subdomains over https only — any scheme and port under localhost — full origins when not https, or the bare `*` as shorthand for a wildcard over the rp-id itself); an empty object means the rp-id and all subdomains may authenticate on any scheme. 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/*
|
||||
|
||||
@@ -108,9 +108,9 @@ A domain may configure a dedicated authentication host (auth-host, a subdomain o
|
||||
|
||||
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 domains
|
||||
#### Auth hosts and other domains
|
||||
|
||||
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).
|
||||
Auth hosts are strictly per-domain: a domain without its own auth host uses its own hosts for the WebSocket flows, and `/auth/api/settings` reports `auth_host` (and the identical `own_auth_host`) as null. One domain's auth host never serves another domain implicitly. To consolidate logins on one host, mark that host as the auth host on each domain that should use it (possible when the host lies under each domain's rp-id, i.e. nested rp-ids); dispatch resolves a shared host to the best-matching (longest rp-id suffix) domain.
|
||||
|
||||
### Related Origin Requests: /.well-known/webauthn
|
||||
|
||||
|
||||
+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