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.
|
||||
|
||||
@@ -39,8 +39,6 @@ function normalizeHost(raw) {
|
||||
/**
|
||||
* Host mode is active when an own_auth_host is configured AND the current host differs from it.
|
||||
* In host mode, we show a limited profile view with logout and link to full profile.
|
||||
* own_auth_host (not auth_host) is used so that domains sharing another domain's auth host
|
||||
* still serve the full profile on their own hosts.
|
||||
*/
|
||||
const isHostMode = computed(() => {
|
||||
const authHost = store.settings?.own_auth_host
|
||||
|
||||
@@ -1098,6 +1098,7 @@ async function submitDialog() {
|
||||
ref="adminOidcDetailRef"
|
||||
:client="editingOidcClient"
|
||||
:permissions="permissions"
|
||||
:domains="domains"
|
||||
:is-new="editingOidcClient.isNew"
|
||||
:navigation-disabled="hasActiveModal"
|
||||
@save="handleOidcSave"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
import NameEditForm from '@/components/NameEditForm.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { compareOrigins } from '@/utils/helpers'
|
||||
|
||||
const props = defineProps({
|
||||
dialog: Object,
|
||||
@@ -161,6 +162,23 @@ async function validateOriginConnectivity(i) {
|
||||
}
|
||||
}
|
||||
|
||||
// A sole '*' expands to '*.<rp-id>' immediately, keeping the cursor where
|
||||
// it was (before the inserted rp-id).
|
||||
function onOriginInput(i, e) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
const el = e.target
|
||||
let value = el.value
|
||||
if (value === '*' && dialogRpId.value) {
|
||||
const pos = el.selectionStart
|
||||
value = '*.' + dialogRpId.value
|
||||
el.value = value
|
||||
el.setSelectionRange(pos, pos)
|
||||
}
|
||||
d.origins[i] = value
|
||||
validateOrigin(i)
|
||||
}
|
||||
|
||||
function validateOrigin(i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
@@ -205,16 +223,32 @@ async function testWellKnown() {
|
||||
}
|
||||
watch(() => relatedEntries.value.map(asHttpsOrigin).join('|'), testWellKnown, { immediate: true })
|
||||
|
||||
// Seed the default '*' entry for a new domain once its rp-id is known,
|
||||
// so the list always shows what is allowed ('*' = the domain and all its
|
||||
// subdomains, any scheme/port). Removing the last in-domain entry is
|
||||
// blocked in the row menu, so the list never becomes empty afterwards.
|
||||
// Seed the default '*.<rp-id>' entry for a new domain once its rp-id is
|
||||
// known, so the list always shows what is allowed ('*.x' = the domain and
|
||||
// all its subdomains; https only, any scheme/port under localhost).
|
||||
// Removing the last in-domain entry is blocked in the row menu, so the
|
||||
// list never becomes empty afterwards.
|
||||
// Seeding waits for a complete-looking rp-id (letters after the final dot)
|
||||
// so mid-typing states like 'something.' don't seed a broken '*.something'.
|
||||
function looksCompleteDomain(value) {
|
||||
const host = (value || '').trim().replace(/\.$/, '')
|
||||
return host === 'localhost' || /\.[a-z]{2,}$/i.test(host)
|
||||
}
|
||||
// Tracks the auto-seeded entry so it can be corrected if it was seeded
|
||||
// from an incomplete rp-id and the admin keeps typing.
|
||||
let seededOrigin = null
|
||||
watch(dialogRpId, rp => {
|
||||
const d = props.dialog?.data
|
||||
if (props.dialog?.type !== 'domain-edit' || !d?.isNew) return
|
||||
if (!d.origins.length && isWellFormedDomain(rp)) {
|
||||
d.origins.push('*')
|
||||
if (!looksCompleteDomain(rp) || !isWellFormedDomain(rp)) return
|
||||
const seed = '*.' + rp.trim().replace(/\.$/, '')
|
||||
if (!d.origins.length) {
|
||||
d.origins.push(seed)
|
||||
d.originValidation.push(null)
|
||||
seededOrigin = seed
|
||||
} else if (d.origins.length === 1 && d.origins[0] === seededOrigin && seed !== seededOrigin) {
|
||||
d.origins[0] = seed
|
||||
seededOrigin = seed
|
||||
}
|
||||
})
|
||||
|
||||
@@ -222,6 +256,14 @@ watch(dialogRpId, rp => {
|
||||
|
||||
const openMenu = ref(null)
|
||||
|
||||
// Close the popup on any click outside it (the toggle button stops
|
||||
// propagation, so it never reaches this listener).
|
||||
function onDocumentClick(e) {
|
||||
if (openMenu.value !== null && !e.target.closest('.row-menu')) openMenu.value = null
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onDocumentClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
|
||||
|
||||
// Origins-dict key form of an entry (https:// omitted), also used for the
|
||||
// auth_host value.
|
||||
function entryKey(value) {
|
||||
@@ -238,6 +280,7 @@ function setAuthHost(i) {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
let key = entryKey(d.origins[i])
|
||||
let added = false
|
||||
if (key === '*' || key.startsWith('*.')) {
|
||||
// A wildcard cannot be the auth host — create a concrete auth.<base> entry
|
||||
const base = key === '*' ? dialogRpId.value : key.slice(2)
|
||||
@@ -245,17 +288,33 @@ function setAuthHost(i) {
|
||||
if (!d.origins.some(o => entryKey(o) === key)) {
|
||||
d.origins.push(key)
|
||||
d.originValidation.push(null)
|
||||
validateOrigin(d.origins.length - 1)
|
||||
added = true
|
||||
}
|
||||
}
|
||||
d.auth_host = key
|
||||
openMenu.value = null
|
||||
resortOrigins()
|
||||
if (added) validateOrigin(d.origins.findIndex(o => entryKey(o) === key))
|
||||
}
|
||||
|
||||
function clearAuthHost() {
|
||||
const d = props.dialog?.data
|
||||
if (d) d.auth_host = ''
|
||||
openMenu.value = null
|
||||
resortOrigins()
|
||||
}
|
||||
|
||||
// Display order, applied after row-menu actions (never while typing in an
|
||||
// input, to avoid focus loss): auth host first, then the rp-id, then
|
||||
// in-domain entries hierarchically, then related origins.
|
||||
function resortOrigins() {
|
||||
const d = props.dialog?.data
|
||||
if (!d) return
|
||||
const rank = o => isAuthHostEntry(o) ? 0 : o === dialogRpId.value ? 1 : isRelatedEntry(o) ? 3 : 2
|
||||
const pairs = d.origins.map((o, i) => [o, d.originValidation[i]])
|
||||
pairs.sort((a, b) => rank(a[0]) - rank(b[0]) || compareOrigins(a[0], b[0]))
|
||||
d.origins = pairs.map(p => p[0])
|
||||
d.originValidation = pairs.map(p => p[1])
|
||||
}
|
||||
|
||||
const inDomainCount = computed(() =>
|
||||
@@ -276,6 +335,7 @@ function onRemoveOrigin(i) {
|
||||
if (isAuthHostEntry(d.origins[i])) d.auth_host = ''
|
||||
removeOrigin(i)
|
||||
openMenu.value = null
|
||||
resortOrigins()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -290,7 +350,7 @@ function onRemoveOrigin(i) {
|
||||
<template v-else-if="dialog.type==='user-update-name'">Edit User Name</template>
|
||||
<template v-else-if="dialog.type==='perm-create' || dialog.type==='perm-display'">{{ dialog.type === 'perm-create' ? 'Create Permission' : 'Edit Permission' }}</template>
|
||||
<template v-else-if="dialog.type==='oidc-edit'">{{ dialog.data?.isNew ? 'New OIDC Client' : 'OIDC Client' }}</template>
|
||||
<template v-else-if="dialog.type==='domain-edit'">{{ dialog.data?.isNew ? 'Add Domain' : 'Edit Domain' }}</template>
|
||||
<template v-else-if="dialog.type==='domain-edit'">{{ dialog.data?.isNew ? 'Add Domain' : `Edit Domain: ${dialog.data?.rp_id}` }}</template>
|
||||
<template v-else-if="dialog.type==='confirm'">Confirm</template>
|
||||
</h3>
|
||||
<form @submit.prevent="$emit('submitDialog')" class="modal-form">
|
||||
@@ -355,9 +415,8 @@ function onRemoveOrigin(i) {
|
||||
<label>Domain (rp-id)
|
||||
<input v-model="dialog.data.rp_id" placeholder="example.com" data-form-type="other" required />
|
||||
</label>
|
||||
<p class="small muted">The domain name passkeys belong to — they work on this domain and its subdomains, and never on other domains. Cannot be changed later.</p>
|
||||
<p class="small muted">The domain name passkeys belong to — they work on this domain and its subdomains, and related domains. Cannot be changed later.</p>
|
||||
</template>
|
||||
<p v-else class="small muted">Domain: <strong>{{ dialog.data.rp_id }}</strong></p>
|
||||
<label>Display Name (rp-name)
|
||||
<input v-model="dialog.data.rp_name" :placeholder="dialog.data.rp_id" />
|
||||
</label>
|
||||
@@ -370,19 +429,19 @@ function onRemoveOrigin(i) {
|
||||
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
|
||||
<input
|
||||
:value="dialog.data.origins[i]"
|
||||
@input="e => { dialog.data.origins[i] = e.target.value; validateOrigin(i) }"
|
||||
@input="e => onOriginInput(i, e)"
|
||||
@focus="focusOriginStart"
|
||||
class="origin-input"
|
||||
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
|
||||
/>
|
||||
<span v-if="isAuthHostEntry(dialog.data.origins[i])" class="key-badge" title="Authentication site — the account and admin interface live here">🔑</span>
|
||||
<span v-else-if="isRelatedEntry(dialog.data.origins[i])" class="ror-tag" title="Related origin (WebAuthn ROR) — shares this domain's passkeys">related</span>
|
||||
<span v-else-if="isRelatedEntry(dialog.data.origins[i])" class="key-badge" title="Related origin (WebAuthn ROR) — shares this domain's passkeys">🔗</span>
|
||||
<div class="row-menu">
|
||||
<button type="button" class="icon-btn" @click.stop="openMenu = openMenu === i ? null : i" aria-label="Origin actions" title="Actions">⋮</button>
|
||||
<div v-if="openMenu === i" class="row-menu-popup">
|
||||
<button v-if="isAuthHostEntry(dialog.data.origins[i])" type="button" @click="clearAuthHost()">Remove auth host</button>
|
||||
<button v-else-if="!isRelatedEntry(dialog.data.origins[i]) && originHostname(dialog.data.origins[i])" type="button" @click="setAuthHost(i)">Set as auth host</button>
|
||||
<button type="button" @click="onRemoveOrigin(i)" :disabled="!canRemoveOrigin(i)">Remove entry</button>
|
||||
<button v-if="isAuthHostEntry(dialog.data.origins[i])" type="button" @click="clearAuthHost()"><span class="menu-icon">🔑</span>Remove auth host</button>
|
||||
<button v-else-if="!isRelatedEntry(dialog.data.origins[i]) && originHostname(dialog.data.origins[i])" type="button" @click="setAuthHost(i)"><span class="menu-icon">🔑</span>Set as auth host</button>
|
||||
<button type="button" @click="onRemoveOrigin(i)" :disabled="!canRemoveOrigin(i)"><span class="menu-icon delete-menu-icon">❌</span>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -390,8 +449,8 @@ function onRemoveOrigin(i) {
|
||||
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some sites are reachable but do not serve this domain.</p>
|
||||
</div>
|
||||
<p class="small muted">
|
||||
Only the listed sites may sign in with this domain's passkeys — <strong>*</strong> means the domain and all its subdomains on any scheme and port; <strong>*.{{ dialog.data.rp_id }}</strong> restricts that to https.
|
||||
Entries on other domain names become related origins (WebAuthn ROR). The 🔑 site hosts the account and admin interface (set via ⋮).
|
||||
Only the listed sites may sign in with this domain's passkeys — <strong>*</strong> is shorthand for <strong>*.{{ dialog.data.rp_id }}</strong>: the domain and all its subdomains over https (any scheme and port under localhost); list a full origin like <strong>http://localhost:8080</strong> for other exceptions.
|
||||
Entries on other domain names become related origins (WebAuthn ROR), marked 🔗. The 🔑 site hosts the account and admin interface (set via ⋮).
|
||||
</p>
|
||||
|
||||
<template v-if="relatedEntries.length">
|
||||
@@ -467,10 +526,12 @@ function onRemoveOrigin(i) {
|
||||
.key-badge { flex-shrink: 0; }
|
||||
.row-menu { position: relative; flex-shrink: 0; }
|
||||
.row-menu-popup { position: absolute; right: 0; top: 100%; z-index: 10; display: flex; flex-direction: column; min-width: 9rem; background: var(--color-bg, #fff); border: 1px solid var(--color-border, #ccc); border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
|
||||
.row-menu-popup button { text-align: left; padding: var(--space-xs) var(--space-sm); background: none; border: none; cursor: pointer; white-space: nowrap; }
|
||||
.row-menu-popup button { display: flex; align-items: center; justify-content: flex-start; gap: 0.45em; text-align: left; padding: var(--space-xs) var(--space-sm); background: none; border: none; cursor: pointer; white-space: nowrap; }
|
||||
.row-menu-popup button:hover:not(:disabled) { background: var(--color-bg-soft, rgba(127,127,127,0.12)); }
|
||||
.row-menu-popup button:disabled { opacity: 0.5; cursor: default; }
|
||||
.ror-tag { flex-shrink: 0; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--color-text-muted); border: 1px solid var(--color-border, currentColor); border-radius: 3px; padding: 0 0.3rem; }
|
||||
.row-menu-popup .menu-icon { flex-shrink: 0; width: 1.1em; text-align: center; }
|
||||
.row-menu-popup .delete-menu-icon { filter: saturate(1.4); }
|
||||
|
||||
.wellknown-doc { display: flex; align-items: flex-start; gap: var(--space-xs); }
|
||||
.wellknown-doc pre { flex: 1; margin: 0; padding: var(--space-xs) var(--space-sm); font-size: 0.8rem; background: var(--color-bg-soft, rgba(127,127,127,0.08)); border-radius: 4px; overflow-x: auto; }
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
const props = defineProps({
|
||||
client: Object,
|
||||
permissions: Array,
|
||||
domains: Array,
|
||||
isNew: { type: Boolean, default: false },
|
||||
navigationDisabled: { type: Boolean, default: false }
|
||||
})
|
||||
@@ -29,7 +30,17 @@ const clientSecret = ref(null)
|
||||
|
||||
// Computed
|
||||
const clientId = computed(() => props.client?.client_id || props.client?.uuid || '')
|
||||
const discoveryUrl = computed(() => authSitePath('/.well-known/openid-configuration'))
|
||||
// One discovery URL per domain origin (the OIDC provider is instance-global;
|
||||
// any configured host works — the RP must use its chosen one consistently)
|
||||
const discoveryUrls = computed(() => {
|
||||
const origins = new Set()
|
||||
for (const d of props.domains || []) {
|
||||
const url = d.site_url && new URL(d.site_url)
|
||||
if (url) origins.add(url.origin)
|
||||
}
|
||||
if (!origins.size) origins.add(new URL(authStore.settings.auth_site_url).origin)
|
||||
return [...origins].sort().map(o => `${o}/.well-known/openid-configuration`)
|
||||
})
|
||||
const iconUrl = computed(() => authSitePath('/favicon.ico'))
|
||||
|
||||
// Groups (permissions) scoped to this client
|
||||
@@ -147,8 +158,14 @@ defineExpose({ focusFirstElement })
|
||||
<span v-else class="small muted">(only stored in hashed form)</span>
|
||||
</dd>
|
||||
|
||||
<dt>Auto Discovery URL</dt>
|
||||
<dd><output @click="copyText(discoveryUrl, 'OpenID Connect Auto Discovery URL')" title="Click to copy">{{ discoveryUrl }}</output></dd>
|
||||
<dt class="discovery-dt">Auto Discovery URL
|
||||
<span v-if="discoveryUrls.length > 1" class="small muted">Any one — pick the site your users should log in on, and use it consistently.</span>
|
||||
</dt>
|
||||
<dd class="discovery-dd">
|
||||
<span class="discovery-urls">
|
||||
<output v-for="url in discoveryUrls" :key="url" @click="copyText(url, 'OpenID Connect Auto Discovery URL')" title="Click to copy">{{ url }}</output>
|
||||
</span>
|
||||
</dd>
|
||||
|
||||
<dt>Icon URL</dt>
|
||||
<dd>
|
||||
@@ -258,6 +275,29 @@ defineExpose({ focusFirstElement })
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.discovery-dt {
|
||||
white-space: normal;
|
||||
max-width: 20em;
|
||||
}
|
||||
|
||||
.discovery-dt .small {
|
||||
display: block;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.discovery-dd {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.discovery-urls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
|
||||
@@ -437,7 +437,10 @@ defineExpose({ focusFirstElement })
|
||||
<div class="section-header">
|
||||
<h2>Domains</h2>
|
||||
<p class="section-description">
|
||||
The domain names this instance serves. Each domain has its own passkeys: users sign in per domain, and a passkey registered on one domain never works on another. To let several <em>different</em> domain names share the same passkeys, open the domain and configure related domains (WebAuthn Related Origins). Changes apply immediately.
|
||||
The domain names (rp-ids) served, along with hosts belonging to them. Each domain has its own passkeys, and each host will only accept passkeys from its own domain. To let several <em>different</em> domain names share the same passkeys, open the domain and configure related domains (WebAuthn Related Origins). Alternatively create entirely separate domains, or combine the two modes. Only the main domain can have wildcards, and there can be only up to five related origins on the same domain.
|
||||
</p>
|
||||
<p class="section-description">
|
||||
Relative domains within the same domain are your choice when you wish to preserve existing credentials to a few alternative domains. Configure separate domains only when there is more separation, or a need for wildcard hosts. Note that users are shared and remote logins remain possible across domains.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -446,7 +449,7 @@ defineExpose({ focusFirstElement })
|
||||
<table class="org-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Domain</th>
|
||||
<th>Domain (rp-id)</th>
|
||||
<th>Allowed Origins</th>
|
||||
<th class="center"></th>
|
||||
</tr>
|
||||
@@ -464,7 +467,7 @@ defineExpose({ focusFirstElement })
|
||||
<span class="id-text">{{ domain.rp_id }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="domain-origins"><span v-for="(e, i) in originDisplayEntries(domain)" :key="e.key">{{ i ? ', ' : '' }}{{ e.key }}{{ e.auth ? ' 🔑' : '' }}</span></td>
|
||||
<td class="domain-origins"><span v-for="(e, i) in originDisplayEntries(domain)" :key="e.key">{{ i ? ', ' : '' }}{{ e.key }}{{ e.auth ? '🔑' : '' }}{{ e.related ? '🔗' : '' }}</span></td>
|
||||
<td class="center">
|
||||
<button v-if="domain.rp_id !== currentRpId" @click="$emit('deleteDomain', domain)" class="icon-btn delete-icon" aria-label="Delete domain" title="Delete domain">❌</button>
|
||||
</td>
|
||||
|
||||
@@ -44,9 +44,45 @@ export const hostIP = ip => {
|
||||
|
||||
// Display-time ordering of a domain's configured origins (the stored
|
||||
// objects are unordered): the auth host first (flagged), then in-domain
|
||||
// entries (exact rp-id, then alphabetical), then related domains
|
||||
// alphabetically. An empty origins object shows as the '*' default
|
||||
// entries (exact rp-id, then hierarchical), then related domains
|
||||
// hierarchically. An empty origins object shows as the '*' default
|
||||
// (anything within the rp-id domain, any scheme/port).
|
||||
|
||||
// Hierarchical origin comparison: split off scheme/port, compare hostnames
|
||||
// label by label from the TLD down, parents before their subdomains and a
|
||||
// wildcard label after all concrete labels at the same level. Entries on
|
||||
// the same host tie-break by scheme (https first) and port.
|
||||
function originParts(key) {
|
||||
let s = key.toLowerCase().replace(/\/+$/, '')
|
||||
let scheme = ''
|
||||
const sm = s.match(/^([a-z][a-z0-9+.-]*):\/\//)
|
||||
if (sm) { scheme = sm[1]; s = s.slice(sm[0].length) }
|
||||
let port = ''
|
||||
const pm = s.match(/:(\d+)$/)
|
||||
if (pm) { port = pm[1]; s = s.slice(0, -pm[0].length) }
|
||||
return { labels: s.split('.').reverse(), scheme, port }
|
||||
}
|
||||
|
||||
export function compareOrigins(a, b) {
|
||||
const A = originParts(a), B = originParts(b)
|
||||
for (let i = 0; i < Math.max(A.labels.length, B.labels.length); i++) {
|
||||
const la = A.labels[i], lb = B.labels[i]
|
||||
if (la === undefined) return -1
|
||||
if (lb === undefined) return 1
|
||||
if (la === lb) continue
|
||||
if (la === '*') return 1
|
||||
if (lb === '*') return -1
|
||||
const c = la.localeCompare(lb)
|
||||
if (c) return c
|
||||
}
|
||||
if (A.scheme !== B.scheme) {
|
||||
if (A.scheme === 'https') return -1
|
||||
if (B.scheme === 'https') return 1
|
||||
return A.scheme.localeCompare(B.scheme)
|
||||
}
|
||||
return A.port.localeCompare(B.port)
|
||||
}
|
||||
|
||||
export function originDisplayEntries(domain) {
|
||||
const origins = domain.origins || {}
|
||||
const keys = Object.keys(origins)
|
||||
@@ -54,13 +90,13 @@ export function originDisplayEntries(domain) {
|
||||
const inDomain = keys.filter(k => k !== authKey).sort((a, b) => {
|
||||
if (a === domain.rp_id) return -1
|
||||
if (b === domain.rp_id) return 1
|
||||
return a.localeCompare(b)
|
||||
return compareOrigins(a, b)
|
||||
})
|
||||
const rows = []
|
||||
if (authKey) rows.push({ key: authKey, auth: true })
|
||||
for (const k of inDomain) rows.push({ key: k, auth: false })
|
||||
if (!keys.length) rows.push({ key: '*', auth: false })
|
||||
for (const k of Object.keys(domain.related || {}).sort()) {
|
||||
for (const k of Object.keys(domain.related || {}).sort(compareOrigins)) {
|
||||
rows.push({ key: k, auth: false, related: true })
|
||||
}
|
||||
return rows
|
||||
|
||||
+3
-5
@@ -12,13 +12,12 @@ from kanta import Kanta
|
||||
from paskia.db import legacy
|
||||
from paskia.db.bootstrap import bootstrap, log_reset_link
|
||||
from paskia.db.paths import db_file_path
|
||||
from paskia.db.structs import DB, OIDC, Config, DomainConfig
|
||||
from paskia.db.structs import DB, Config, DomainConfig
|
||||
from paskia.domains import build as build_registry
|
||||
from paskia.domains import configure as configure_domains
|
||||
from paskia.domains import validate_config
|
||||
from paskia.util import hostutil, startupbox
|
||||
from paskia.util.constants import DEFAULT_PORT, DEVMODE
|
||||
from paskia.util.crypto import secret_key
|
||||
from paskia.util.runtime import ServeConfig
|
||||
|
||||
EPILOG = """\
|
||||
@@ -75,7 +74,7 @@ def _load_stored_config(db_path: Path) -> Config:
|
||||
|
||||
def _init_add_domain(db_path: Path, rp_id: str, rp_name: str | None, listen) -> None:
|
||||
"""Add a domain to an existing database, or update an existing one's
|
||||
rp-name. Seeds an OIDC provider entry for a new domain."""
|
||||
rp-name."""
|
||||
new_db = DB()
|
||||
kanta = Kanta(str(db_path), new_db)
|
||||
|
||||
@@ -104,7 +103,6 @@ def _init_add_domain(db_path: Path, rp_id: str, rp_name: str | None, listen) ->
|
||||
raise SystemExit(str(e)) from e
|
||||
with kanta.transaction("init:add_domain"):
|
||||
data.config.domains[rp_id] = new
|
||||
data.oidc[rp_id] = OIDC(key=secret_key())
|
||||
if listen is not None:
|
||||
data.config.listen = listen
|
||||
return f"Added domain {rp_id}"
|
||||
@@ -146,7 +144,7 @@ def cmd_init(args: argparse.Namespace) -> None:
|
||||
raise SystemExit(str(e)) from e
|
||||
|
||||
# Create the database; the kanta bootstrap callback seeds it (admin
|
||||
# user, org, permissions, reset token, per-domain OIDC keys).
|
||||
# user, org, permissions, reset token, the OIDC signing key).
|
||||
new_db = DB()
|
||||
kanta = Kanta(str(db_path), new_db)
|
||||
result = {}
|
||||
|
||||
+2
-3
@@ -24,15 +24,14 @@ class OIDCCode(msgspec.Struct):
|
||||
"""An OIDC authorization code pending token exchange.
|
||||
|
||||
PKCE uses S256 only when provided (verified at token exchange).
|
||||
rp_id binds the code to the domain it was issued in; the token
|
||||
endpoint (dispatched by Host) must match.
|
||||
Codes are redeemable at any host of the instance — the OIDC provider
|
||||
is instance-global.
|
||||
"""
|
||||
|
||||
session_key: str
|
||||
created: datetime
|
||||
redirect_uri: str
|
||||
scope: str
|
||||
rp_id: str
|
||||
nonce: str | None = None
|
||||
code_challenge: str | None = None
|
||||
|
||||
|
||||
@@ -145,8 +145,8 @@ def bootstrap(
|
||||
if config is not None:
|
||||
data.config = config
|
||||
|
||||
# Generate an OIDC signing key for each domain
|
||||
data.oidc = {rp_id: OIDC(key=secret_key()) for rp_id in data.config.domains}
|
||||
# Generate the instance-global OIDC signing key
|
||||
data.oidc = OIDC(key=secret_key())
|
||||
|
||||
# Store all bootstrapped objects in the live data object
|
||||
data.permissions[perm_admin_uuid] = perm_admin
|
||||
|
||||
+4
-8
@@ -106,7 +106,8 @@ def convert_legacy_database(src: Path, dst: Path) -> Config:
|
||||
|
||||
Reads the legacy database at ``src`` and writes a fresh database at
|
||||
``dst``. All credentials and sessions are stamped with the legacy
|
||||
database's rp-id; the OIDC provider is moved under that rp-id key.
|
||||
database's rp-id; the OIDC provider carries over as-is (it is
|
||||
instance-global).
|
||||
Returns the converted (new-format) configuration.
|
||||
"""
|
||||
old = _read_legacy(src)
|
||||
@@ -116,12 +117,7 @@ def convert_legacy_database(src: Path, dst: Path) -> Config:
|
||||
|
||||
origins: dict[str, bool | OriginEntry] = {}
|
||||
for origin in old.config.origins or []:
|
||||
key = origin_key(origin)
|
||||
# A legacy wildcard over the rp-id itself matched any scheme; the
|
||||
# bare '*' keeps that meaning ('*.x' is now https-only).
|
||||
if key == f"*.{rp_id}":
|
||||
key = "*"
|
||||
origins[key] = True
|
||||
origins[origin_key(origin)] = True
|
||||
if old.config.auth_host:
|
||||
origins[origin_key(old.config.auth_host)] = OriginEntry(auth_host=True)
|
||||
|
||||
@@ -167,7 +163,7 @@ def convert_legacy_database(src: Path, dst: Path) -> Config:
|
||||
credentials=credentials,
|
||||
sessions=sessions,
|
||||
reset_tokens=old.reset_tokens,
|
||||
oidc={rp_id: old.oidc},
|
||||
oidc=old.oidc,
|
||||
)
|
||||
|
||||
new_db = DB()
|
||||
|
||||
+9
-12
@@ -41,12 +41,10 @@ def _lookup_uuid_in_state(state: dict | None, uuid_str: str) -> str | None:
|
||||
if isinstance(display_name, str) and display_name:
|
||||
return display_name
|
||||
|
||||
# OIDC clients use "name" instead of "display_name"; providers are
|
||||
# nested per domain rp-id.
|
||||
for provider in state.get("oidc", {}).values():
|
||||
if not isinstance(provider, dict):
|
||||
continue
|
||||
client = provider.get("clients", {}).get(uuid_str)
|
||||
# OIDC clients use "name" instead of "display_name".
|
||||
oidc_state = state.get("oidc", {})
|
||||
if isinstance(oidc_state, dict):
|
||||
client = oidc_state.get("clients", {}).get(uuid_str)
|
||||
if isinstance(client, dict):
|
||||
name = client.get("name")
|
||||
if isinstance(name, str) and name:
|
||||
@@ -83,14 +81,13 @@ def _resolve_uuid_label(
|
||||
return _ops._db.roles[uid].display_name
|
||||
if uid in _ops._db.permissions:
|
||||
return _ops._db.permissions[uid].display_name
|
||||
for provider in _ops._db.oidc.values():
|
||||
if uid in provider.clients:
|
||||
return provider.clients[uid].name
|
||||
if uid in _ops._db.oidc.clients:
|
||||
return _ops._db.oidc.clients[uid].name
|
||||
return None
|
||||
|
||||
|
||||
# OIDC signing keys are stored at oidc.<rp-id>.key (rp-ids contain dots).
|
||||
_OIDC_KEY_PATH = re.compile(r"^oidc\..+\.key$")
|
||||
# The OIDC signing key is stored at oidc.key.
|
||||
_OIDC_KEY_PATH = re.compile(r"^oidc\.key$")
|
||||
|
||||
|
||||
@kanta.logfmt
|
||||
@@ -103,7 +100,7 @@ def format_log_uuid(
|
||||
"""Format UUID values/keys/actor labels and censor secrets in transaction logs."""
|
||||
# Censor sensitive OIDC key material regardless of value type, but only
|
||||
# when formatting the value: path components are passed with the component
|
||||
# itself as value and must stay visible ("oidc.<rp-id>.key = <hidden>").
|
||||
# itself as value and must stay visible ("oidc.key = <hidden>").
|
||||
if _OIDC_KEY_PATH.fullmatch(path) and value != "key":
|
||||
return "<hidden>"
|
||||
|
||||
|
||||
+14
-32
@@ -17,7 +17,6 @@ from paskia import oidc_notify
|
||||
from paskia.config import SESSION_LIFETIME
|
||||
from paskia.db.structs import (
|
||||
DB,
|
||||
OIDC,
|
||||
Client,
|
||||
Config,
|
||||
Credential,
|
||||
@@ -31,7 +30,7 @@ from paskia.db.structs import (
|
||||
SessionContext,
|
||||
User,
|
||||
)
|
||||
from paskia.util.crypto import hash_secret, secret_key
|
||||
from paskia.util.crypto import hash_secret
|
||||
from paskia.util.nameutil import slugify_name
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -717,27 +716,17 @@ def create_credential_session(
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _oidc_provider(rp_id: str) -> OIDC:
|
||||
"""Return the OIDC provider entry for a domain, raising if missing."""
|
||||
provider = _db.oidc.get(rp_id)
|
||||
if provider is None:
|
||||
raise ValueError(f"Domain {rp_id} not found")
|
||||
return provider
|
||||
|
||||
|
||||
def create_domain(
|
||||
rp_id: str, domain: DomainConfig, *, ctx: SessionContext | None = None
|
||||
) -> None:
|
||||
"""Add a new domain (rp-id) to the stored configuration.
|
||||
|
||||
Seeds an OIDC provider entry (with a fresh signing key) for the domain.
|
||||
The caller must validate the resulting combined configuration.
|
||||
"""
|
||||
if rp_id in _db.config.domains:
|
||||
raise ValueError(f"Domain {rp_id} already exists")
|
||||
with _transaction("admin:create_domain", ctx):
|
||||
_db.config.domains[rp_id] = domain
|
||||
_db.oidc[rp_id] = OIDC(key=secret_key())
|
||||
|
||||
|
||||
def update_domain(
|
||||
@@ -775,7 +764,6 @@ def delete_domain(rp_id: str, *, ctx: SessionContext | None = None) -> None:
|
||||
)
|
||||
with _transaction("admin:delete_domain", ctx):
|
||||
del _db.config.domains[rp_id]
|
||||
_db.oidc.pop(rp_id, None)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -784,18 +772,16 @@ def delete_domain(rp_id: str, *, ctx: SessionContext | None = None) -> None:
|
||||
|
||||
|
||||
def create_oid_client(
|
||||
rp_id: str, client: Client, *, ctx: SessionContext | None = None
|
||||
client: Client, *, ctx: SessionContext | None = None
|
||||
) -> None:
|
||||
"""Create a new OIDC client under a domain."""
|
||||
provider = _oidc_provider(rp_id)
|
||||
if client.uuid in provider.clients:
|
||||
"""Create a new OIDC client."""
|
||||
if client.uuid in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client.uuid} already exists")
|
||||
with _transaction("admin:create_oid_client", ctx):
|
||||
provider.clients[client.uuid] = client
|
||||
_db.oidc.clients[client.uuid] = client
|
||||
|
||||
|
||||
def update_oid_client(
|
||||
rp_id: str,
|
||||
client_uuid: UUID,
|
||||
name: str | None = None,
|
||||
redirect_uris: list[str] | None = None,
|
||||
@@ -805,11 +791,10 @@ def update_oid_client(
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Update an OIDC client's name, redirect URIs, and/or secret."""
|
||||
provider = _oidc_provider(rp_id)
|
||||
if client_uuid not in provider.clients:
|
||||
if client_uuid not in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||
|
||||
client = provider.clients[client_uuid]
|
||||
client = _db.oidc.clients[client_uuid]
|
||||
changes = {}
|
||||
|
||||
if name is not None and name != client.name:
|
||||
@@ -846,21 +831,19 @@ def update_oid_client(
|
||||
backchannel_logout_uri=new_logout_uri,
|
||||
)
|
||||
updated_client.uuid = client.uuid
|
||||
provider.clients[client_uuid] = updated_client
|
||||
_db.oidc.clients[client_uuid] = updated_client
|
||||
|
||||
|
||||
def reset_oid_client_secret(
|
||||
rp_id: str,
|
||||
client_uuid: UUID,
|
||||
new_secret_hash: bytes,
|
||||
*,
|
||||
ctx: SessionContext | None = None,
|
||||
) -> None:
|
||||
"""Reset an OIDC client's secret."""
|
||||
provider = _oidc_provider(rp_id)
|
||||
if client_uuid not in provider.clients:
|
||||
if client_uuid not in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||
client = provider.clients[client_uuid]
|
||||
client = _db.oidc.clients[client_uuid]
|
||||
with _transaction("admin:reset_oid_client_secret", ctx):
|
||||
updated = Client(
|
||||
client_secret_hash=new_secret_hash,
|
||||
@@ -869,15 +852,14 @@ def reset_oid_client_secret(
|
||||
backchannel_logout_uri=client.backchannel_logout_uri,
|
||||
)
|
||||
updated.uuid = client.uuid
|
||||
provider.clients[client_uuid] = updated
|
||||
_db.oidc.clients[client_uuid] = updated
|
||||
|
||||
|
||||
def delete_oid_client(
|
||||
rp_id: str, client_uuid: UUID, *, ctx: SessionContext | None = None
|
||||
client_uuid: UUID, *, ctx: SessionContext | None = None
|
||||
) -> None:
|
||||
"""Delete an OIDC client."""
|
||||
provider = _oidc_provider(rp_id)
|
||||
if client_uuid not in provider.clients:
|
||||
if client_uuid not in _db.oidc.clients:
|
||||
raise ValueError(f"OIDC client {client_uuid} not found")
|
||||
with _transaction("admin:delete_oid_client", ctx):
|
||||
del provider.clients[client_uuid]
|
||||
del _db.oidc.clients[client_uuid]
|
||||
|
||||
+12
-14
@@ -635,10 +635,13 @@ class DomainConfig(msgspec.Struct, omit_defaults=True):
|
||||
|
||||
``origins`` maps sign-in sites within the rp-id domain to their
|
||||
properties. Keys are hosts without the https:// scheme
|
||||
("app.example.com"), wildcard patterns ("*.example.com"), or full
|
||||
origins when not https ("http://localhost:8080"). An empty dict means
|
||||
the rp-id and all its subdomains may sign in (the default). Ordering
|
||||
carries no meaning — display order is decided by the UI.
|
||||
("app.example.com"), wildcard patterns ("*.example.com" — the base
|
||||
domain and its subdomains over https only, any scheme and port under
|
||||
localhost), the bare "*" (shorthand for a wildcard over the rp-id
|
||||
itself), or full origins when not https ("http://localhost:8080").
|
||||
An empty dict means the rp-id and all its subdomains may sign in (the
|
||||
default). Ordering carries no meaning — display order is decided by
|
||||
the UI.
|
||||
|
||||
``related`` lists other domain names that may assert this rp-id
|
||||
(WebAuthn Related Origin Requests), with the same key rule.
|
||||
@@ -678,9 +681,9 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
credentials: dict[UUID, Credential] = {}
|
||||
sessions: dict[str, Session] = {}
|
||||
reset_tokens: dict[str, ResetToken] = {}
|
||||
# OIDC provider data, keyed by rp-id: each domain is an independent
|
||||
# issuer with its own signing key and clients.
|
||||
oidc: dict[str, OIDC] = {}
|
||||
# OIDC provider data: one instance-global provider (single signing key
|
||||
# and client set); each request Host acts as an issuer alias.
|
||||
oidc: OIDC = msgspec.field(default_factory=OIDC)
|
||||
|
||||
def __post_init__(self):
|
||||
# Optional store reference for non-global DB instances (e.g. tests).
|
||||
@@ -701,13 +704,8 @@ class DB(msgspec.Struct, dict=True, omit_defaults=False):
|
||||
for key, token in self.reset_tokens.items():
|
||||
token.key = key
|
||||
# OIDC
|
||||
for provider in self.oidc.values():
|
||||
for uuid, client in provider.clients.items():
|
||||
client.uuid = uuid
|
||||
|
||||
def oidc_for(self, rp_id: str) -> OIDC | None:
|
||||
"""Get the OIDC provider data for a domain, if it exists."""
|
||||
return self.oidc.get(rp_id)
|
||||
for uuid, client in self.oidc.clients.items():
|
||||
client.uuid = uuid
|
||||
|
||||
def session_ctx(
|
||||
self, session_secret: str, host: str | None = None
|
||||
|
||||
+67
-70
@@ -4,7 +4,7 @@ A **domain** is one rp-id with its associated hosts and origins. The
|
||||
registry is built from the stored combined ``Config`` at startup and
|
||||
rebuilt on admin domain changes; request dispatch resolves hosts to
|
||||
domains through it. The database itself is global — only the *current
|
||||
domain* (passkey, site URLs, OIDC view) varies per request, tracked via a
|
||||
domain* (passkey, site URLs) varies per request, tracked via a
|
||||
contextvar set by the dispatch middleware.
|
||||
"""
|
||||
|
||||
@@ -29,11 +29,7 @@ DEFAULT_RELATED_ORIGIN_CAP = 5
|
||||
|
||||
def origin_url(key: str) -> str:
|
||||
"""Full origin URL for an origins-dict key (https:// is implied)."""
|
||||
if (
|
||||
hostutil.is_any_pattern(key)
|
||||
or hostutil.is_wildcard_pattern(key)
|
||||
or "://" in key
|
||||
):
|
||||
if hostutil.is_wildcard_pattern(key) or key == "*" or "://" in key:
|
||||
return key
|
||||
return f"https://{key}"
|
||||
|
||||
@@ -55,8 +51,8 @@ class Domain:
|
||||
"""Runtime view of one domain: stored config plus derived values."""
|
||||
|
||||
def __init__(self, rp_id: str, config: DomainConfig, site_url: str, site_path: str):
|
||||
# Lazy import: paskia.sansio depends on paskia.db, which (via
|
||||
# paskia.db.operations → paskia.oidc_notify) depends on this module.
|
||||
# Lazy import: paskia.sansio depends on paskia.db, which used to
|
||||
# depend on this module (via paskia.oidc_notify).
|
||||
from paskia.sansio import Passkey # noqa: PLC0415
|
||||
|
||||
self.rp_id = rp_id
|
||||
@@ -115,15 +111,19 @@ class DomainRegistry:
|
||||
|
||||
def __init__(self, domains: list[Domain]):
|
||||
self._by_rp_id = {d.rp_id: d for d in domains}
|
||||
self._auth_hosts: dict[str, Domain] = {}
|
||||
self._auth_hosts: dict[str, list[Domain]] = {}
|
||||
self._related_hosts: dict[str, Domain] = {}
|
||||
self.warnings: list[str] = []
|
||||
for domain in domains:
|
||||
if own := domain.own_auth_host:
|
||||
self._auth_hosts[hostutil.normalize_host(own) or own] = domain
|
||||
key = hostutil.normalize_host(own) or own
|
||||
self._auth_hosts.setdefault(key, []).append(domain)
|
||||
for origin in domain.related_origins:
|
||||
if hostname := hostutil.origin_hostname(origin):
|
||||
self._related_hosts[hostname] = domain
|
||||
# First claimant wins (config order); a related host that
|
||||
# is another domain's rp-id never reaches this map in
|
||||
# resolve() — the owning domain is matched first.
|
||||
self._related_hosts.setdefault(hostname, domain)
|
||||
|
||||
@property
|
||||
def domains(self) -> list[Domain]:
|
||||
@@ -133,32 +133,30 @@ class DomainRegistry:
|
||||
def get(self, rp_id: str) -> Domain | None:
|
||||
return self._by_rp_id.get(rp_id)
|
||||
|
||||
def effective_auth_host(self, domain: Domain) -> str | None:
|
||||
"""Auth host serving WS/restricted APIs for a domain: its own, or
|
||||
another domain's as a shared fallback.
|
||||
|
||||
Returns host[:port] suitable for URL building, or None.
|
||||
"""
|
||||
if domain.own_auth_host:
|
||||
return domain.own_auth_host
|
||||
for candidate in self._by_rp_id.values():
|
||||
if candidate.own_auth_host:
|
||||
return candidate.own_auth_host
|
||||
return None
|
||||
|
||||
def resolve(self, host: str | None) -> Domain | None:
|
||||
"""Resolve a request Host header to a domain.
|
||||
|
||||
Order: exact rp-id → exact auth host → exact related-origin
|
||||
hostname → longest-suffix rp-id. Unknown hosts return None.
|
||||
Order: exact rp-id → auth host → exact related-origin hostname →
|
||||
longest-suffix rp-id. Unknown hosts return None. When several
|
||||
domains share an auth host, the best suffix match (longest rp-id
|
||||
the host falls under) wins, first configured as tiebreak — so
|
||||
``auth.company.com`` shared by ``company.com`` and ``app2.com``
|
||||
serves ``company.com`` for plain HTTP; WebSocket logins still
|
||||
follow the Origin header to the right domain.
|
||||
"""
|
||||
h = hostutil.normalize_host(host)
|
||||
if not h:
|
||||
return None
|
||||
if domain := self._by_rp_id.get(h):
|
||||
return domain
|
||||
if domain := self._auth_hosts.get(h):
|
||||
return domain
|
||||
if claimants := self._auth_hosts.get(h):
|
||||
best = None
|
||||
for candidate in claimants:
|
||||
if h.endswith(f".{candidate.rp_id}") and (
|
||||
best is None or len(candidate.rp_id) > len(best.rp_id)
|
||||
):
|
||||
best = candidate
|
||||
return best or claimants[0]
|
||||
if domain := self._related_hosts.get(h):
|
||||
return domain
|
||||
best = None
|
||||
@@ -185,7 +183,8 @@ def validate_config(
|
||||
|
||||
for key, props in domain.origins.items():
|
||||
is_auth = isinstance(props, OriginEntry) and props.auth_host
|
||||
if hostutil.is_any_pattern(key):
|
||||
if key == "*":
|
||||
# Shorthand for '*.{rp_id}'
|
||||
if is_auth:
|
||||
raise ValueError("Origin '*' cannot be the auth host")
|
||||
continue
|
||||
@@ -211,12 +210,9 @@ def validate_config(
|
||||
ah = hostutil.normalize_host(
|
||||
hostutil.auth_host_netloc(origin_url(key)) or ""
|
||||
)
|
||||
if ah in auth_hosts:
|
||||
raise ValueError(
|
||||
f"auth-host '{ah}' is configured for both "
|
||||
f"'{auth_hosts[ah]}' and '{rp_id}'"
|
||||
)
|
||||
auth_hosts[ah] = rp_id
|
||||
# Several domains may share an auth host to consolidate
|
||||
# logins; resolution picks the best suffix match.
|
||||
auth_hosts.setdefault(ah, rp_id)
|
||||
|
||||
if len(domain.related) > related_origin_cap:
|
||||
raise ValueError(
|
||||
@@ -224,6 +220,11 @@ def validate_config(
|
||||
f"related origins (maximum {related_origin_cap})"
|
||||
)
|
||||
for key in domain.related:
|
||||
if key == "*":
|
||||
raise ValueError(
|
||||
"Related origin '*' is not allowed — related origins "
|
||||
"(ROR) must be listed individually"
|
||||
)
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
raise ValueError(
|
||||
f"Related origin '{key}' is a wildcard — related "
|
||||
@@ -237,7 +238,13 @@ def validate_config(
|
||||
f"Related origin '{key}' is within the rp-id domain "
|
||||
f"'{rp_id}' — subdomains need no related origin entry"
|
||||
)
|
||||
if hn in related_hosts:
|
||||
# A related host may be (or fall inside) another domain's
|
||||
# rp-id: the owning domain wins dispatch, and the listing
|
||||
# domain's well-known document still authorizes ROR logins.
|
||||
covered_by_rp_id = any(
|
||||
hostutil.is_subdomain(hn, other) for other in config.domains
|
||||
)
|
||||
if hn in related_hosts and not covered_by_rp_id:
|
||||
raise ValueError(
|
||||
f"Related origin host '{hn}' is configured for both "
|
||||
f"'{related_hosts[hn]}' and '{rp_id}'"
|
||||
@@ -248,22 +255,12 @@ def validate_config(
|
||||
for hn, owner in auth_hosts.items():
|
||||
if hn in rp_ids:
|
||||
raise ValueError(f"auth-host '{hn}' collides with an rp-id")
|
||||
if hn in related_hosts:
|
||||
if hn in related_hosts and related_hosts[hn] != owner:
|
||||
raise ValueError(
|
||||
f"auth-host '{hn}' collides with a related origin of "
|
||||
f"domain '{related_hosts[hn]}'"
|
||||
)
|
||||
|
||||
for hn, owner in related_hosts.items():
|
||||
if hn in rp_ids:
|
||||
raise ValueError(f"Related origin host '{hn}' collides with an rp-id")
|
||||
for other in rp_ids:
|
||||
if other != owner and hostutil.is_subdomain(hn, other):
|
||||
raise ValueError(
|
||||
f"Related origin host '{hn}' of domain '{owner}' "
|
||||
f"falls inside domain '{other}'"
|
||||
)
|
||||
|
||||
|
||||
def sanitize_config(
|
||||
config: Config, *, related_origin_cap: int = DEFAULT_RELATED_ORIGIN_CAP
|
||||
@@ -293,14 +290,14 @@ def sanitize_config(
|
||||
related: dict[str, bool] = dict(domain.related)
|
||||
for key, props in domain.origins.items():
|
||||
is_auth = isinstance(props, OriginEntry) and props.auth_host
|
||||
if hostutil.is_any_pattern(key):
|
||||
if key == "*":
|
||||
# Shorthand for '*.{rp_id}'; wildcards cannot be auth hosts
|
||||
if is_auth:
|
||||
warn(
|
||||
f"Domain '{rp_id}': origin '*' cannot be the "
|
||||
"auth host — mark cleared"
|
||||
)
|
||||
props = True
|
||||
origins[key] = props
|
||||
origins[f"*.{rp_id}"] = True
|
||||
continue
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
base = key[2:].rstrip(".")
|
||||
@@ -338,6 +335,12 @@ def sanitize_config(
|
||||
|
||||
related_ok: dict[str, bool] = {}
|
||||
for key in related:
|
||||
if key == "*":
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '*' is not allowed — "
|
||||
"dropped (ROR entries must be individual)"
|
||||
)
|
||||
continue
|
||||
if hostutil.is_wildcard_pattern(key):
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' is a "
|
||||
@@ -369,8 +372,12 @@ def sanitize_config(
|
||||
if not domains:
|
||||
raise ValueError("No servable domain in the stored configuration")
|
||||
|
||||
# Cross-domain collisions: keep the first configured claimant, drop the
|
||||
# rest with a warning so dispatch stays deterministic.
|
||||
# Cross-domain conflicts: an auth host equal to an rp-id is dead config
|
||||
# (the rp-id always wins dispatch) — clear the mark. Sharing one auth
|
||||
# host between domains is allowed (login consolidation); resolution
|
||||
# picks the best suffix match. Related origins may point at or inside
|
||||
# other domains' rp-ids (the owner wins dispatch; the listing domain's
|
||||
# well-known document still authorizes ROR logins).
|
||||
rp_ids = set(domains)
|
||||
seen_auth_hosts: dict[str, str] = {}
|
||||
for rp_id, domain in domains.items():
|
||||
@@ -380,33 +387,23 @@ def sanitize_config(
|
||||
hn = hostutil.normalize_host(
|
||||
hostutil.auth_host_netloc(origin_url(key)) or ""
|
||||
)
|
||||
if hn in rp_ids or hn in seen_auth_hosts:
|
||||
if hn in rp_ids:
|
||||
warn(
|
||||
f"Domain '{rp_id}': auth host '{hn}' collides with "
|
||||
"another domain — mark cleared"
|
||||
"an rp-id — mark cleared"
|
||||
)
|
||||
domain.origins[key] = True
|
||||
else:
|
||||
seen_auth_hosts[hn] = rp_id
|
||||
elif hn:
|
||||
seen_auth_hosts.setdefault(hn, rp_id)
|
||||
|
||||
seen_related: dict[str, str] = {}
|
||||
for rp_id, domain in domains.items():
|
||||
keep: dict[str, bool] = {}
|
||||
for key in domain.related:
|
||||
hn = hostutil.origin_hostname(origin_url(key))
|
||||
if hn in rp_ids:
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' collides "
|
||||
"with an rp-id — dropped"
|
||||
)
|
||||
elif other := next(
|
||||
(o for o in rp_ids if o != rp_id and hostutil.is_subdomain(hn, o)),
|
||||
None,
|
||||
):
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' falls "
|
||||
f"inside domain '{other}' — dropped"
|
||||
)
|
||||
covered_by_rp_id = any(hostutil.is_subdomain(hn, o) for o in rp_ids)
|
||||
if covered_by_rp_id:
|
||||
keep[key] = True
|
||||
elif hn in seen_auth_hosts:
|
||||
warn(
|
||||
f"Domain '{rp_id}': related origin '{key}' is the "
|
||||
@@ -441,7 +438,7 @@ def _derive_site(
|
||||
concrete = sorted(
|
||||
k
|
||||
for k in domain.origins
|
||||
if not hostutil.is_any_pattern(k) and not hostutil.is_wildcard_pattern(k)
|
||||
if k != "*" and not hostutil.is_wildcard_pattern(k)
|
||||
)
|
||||
if concrete:
|
||||
return origin_url(concrete[0]), "/auth/"
|
||||
|
||||
@@ -3,7 +3,6 @@ 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,
|
||||
@@ -95,13 +94,10 @@ async def admin_info(request: Request, auth=AUTH_COOKIE):
|
||||
perms = db.data().permissions.values() if master_admin(ctx) else ctx.org.permissions
|
||||
perms_dict = {p.uuid: ApiPermission.from_db(p) for p in perms}
|
||||
|
||||
# OIDC Clients (master admin only) — the current domain's provider
|
||||
# OIDC Clients (master admin only) — the instance-global provider
|
||||
oidc_clients_dict = {}
|
||||
if master_admin(ctx):
|
||||
provider = db.data().oidc_for(current_domain().rp_id)
|
||||
clients = (
|
||||
sorted(provider.clients.values(), key=lambda c: c.uuid) if provider else []
|
||||
)
|
||||
clients = sorted(db.data().oidc.clients.values(), key=lambda c: c.uuid)
|
||||
sessions = db.data().sessions
|
||||
# Count active sessions per client
|
||||
client_session_counts = {}
|
||||
|
||||
@@ -17,7 +17,7 @@ from paskia.fastapi.admin.errors import install_error_handlers
|
||||
from paskia.fastapi.response import MsgspecResponse
|
||||
from paskia.fastapi.session import AUTH_COOKIE
|
||||
from paskia.sansio import Passkey
|
||||
from paskia.util import hostutil, oidjwt
|
||||
from paskia.util import hostutil
|
||||
from paskia.util.apistructs import ApiDomain
|
||||
|
||||
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
@@ -25,9 +25,7 @@ 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) -> ApiDomain:
|
||||
return ApiDomain(
|
||||
rp_id=domain.rp_id,
|
||||
rp_name=domain.rp_name,
|
||||
@@ -35,7 +33,7 @@ def _domain_to_api(
|
||||
related=domain.config.related,
|
||||
site_url=domain.site_url,
|
||||
auth_site_url=domain.auth_site_url,
|
||||
effective_auth_host=registry.effective_auth_host(domain),
|
||||
auth_host=domain.own_auth_host,
|
||||
)
|
||||
|
||||
|
||||
@@ -50,7 +48,7 @@ def _normalize_origins_map(values: dict | None) -> dict[str, bool | OriginEntry]
|
||||
key = raw_key.strip()
|
||||
if not key:
|
||||
continue
|
||||
if not hostutil.is_any_pattern(key) and not hostutil.is_wildcard_pattern(key):
|
||||
if key != "*" and not hostutil.is_wildcard_pattern(key):
|
||||
key = domains.origin_key(hostutil.normalize_origin(key))
|
||||
is_auth = raw_props is not True and bool((raw_props or {}).get("auth_host"))
|
||||
out[key] = OriginEntry(auth_host=True) if is_auth else True
|
||||
@@ -64,7 +62,7 @@ def _normalize_related_map(values: dict | None) -> dict[str, bool]:
|
||||
key = raw_key.strip()
|
||||
if not key:
|
||||
continue
|
||||
if hostutil.is_any_pattern(key) or hostutil.is_wildcard_pattern(key):
|
||||
if key == "*" or hostutil.is_wildcard_pattern(key):
|
||||
raise ValueError(
|
||||
f"Related origin '{key}' is a wildcard — related origins "
|
||||
"(ROR) must be listed individually"
|
||||
@@ -119,9 +117,7 @@ async def admin_list_domains(request: Request, auth=AUTH_COOKIE):
|
||||
"""List all domains with derived URLs (master admin only)."""
|
||||
await authz.verify(auth, ["auth:admin"], host=request.headers.get("host"))
|
||||
registry = domains.registry()
|
||||
return MsgspecResponse(
|
||||
[_domain_to_api(domain, registry) for domain in registry.domains]
|
||||
)
|
||||
return MsgspecResponse([_domain_to_api(domain) for domain in registry.domains])
|
||||
|
||||
|
||||
@app.post("/")
|
||||
@@ -216,5 +212,4 @@ async def admin_delete_domain(
|
||||
)
|
||||
db.delete_domain(rp_id, ctx=ctx)
|
||||
_rebuild_registry()
|
||||
oidjwt.clear_key(rp_id)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -84,7 +83,7 @@ async def admin_create_oidc_client(
|
||||
)
|
||||
client.uuid = client_uuid
|
||||
|
||||
db.create_oid_client(current_domain().rp_id, client, ctx=ctx)
|
||||
db.create_oid_client(client, ctx=ctx)
|
||||
|
||||
return {"status": "ok", "client_id": str(client.uuid)}
|
||||
|
||||
@@ -153,7 +152,6 @@ async def admin_update_oidc_client(
|
||||
|
||||
try:
|
||||
db.update_oid_client(
|
||||
current_domain().rp_id,
|
||||
client_uuid,
|
||||
name=name,
|
||||
redirect_uris=redirect_uris,
|
||||
@@ -204,7 +202,7 @@ async def admin_reset_oidc_client_secret(
|
||||
|
||||
try:
|
||||
db.reset_oid_client_secret(
|
||||
current_domain().rp_id, client_uuid, secret_hash, ctx=ctx
|
||||
client_uuid, secret_hash, ctx=ctx
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
@@ -234,7 +232,7 @@ async def admin_delete_oidc_client(
|
||||
)
|
||||
|
||||
try:
|
||||
db.delete_oid_client(current_domain().rp_id, client_uuid, ctx=ctx)
|
||||
db.delete_oid_client(client_uuid, ctx=ctx)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ def _validate_permission_domain(domain: str | None) -> None:
|
||||
"""Validate that domain is a configured domain host or an OIDC client UUID.
|
||||
|
||||
Accepted: any domain's rp-id or its subdomain, a related-origin hostname
|
||||
of any domain, or the UUID of any domain's OIDC client (used for the
|
||||
of any domain, or the UUID of an OIDC client (used for the
|
||||
groups claim).
|
||||
"""
|
||||
if domain is None:
|
||||
@@ -28,7 +28,7 @@ def _validate_permission_domain(domain: str | None) -> None:
|
||||
# Allow OIDC client UUIDs (used for groups claim)
|
||||
try:
|
||||
client_uuid = UUID(domain)
|
||||
if any(client_uuid in provider.clients for provider in db.data().oidc.values()):
|
||||
if client_uuid in db.data().oidc.clients:
|
||||
return
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
@@ -17,7 +17,7 @@ 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.domains import current_domain
|
||||
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
|
||||
@@ -306,7 +306,7 @@ async def get_settings():
|
||||
rp_id=domain.rp_id,
|
||||
rp_name=domain.rp_name,
|
||||
ui_base_path=domain.ui_base_path,
|
||||
auth_host=registry().effective_auth_host(domain),
|
||||
auth_host=domain.own_auth_host,
|
||||
own_auth_host=domain.own_auth_host,
|
||||
auth_site_url=domain.auth_site_url,
|
||||
session_cookie=AUTH_COOKIE_NAME,
|
||||
|
||||
@@ -14,11 +14,10 @@ Unknown hosts are rejected before routing:
|
||||
|
||||
For WebSocket connections the Origin header selects the domain when it
|
||||
belongs to a different domain than the Host — a related-origin page using
|
||||
the domain's auth host, or a domain without its own auth host using the
|
||||
shared one. A cross-domain connection is only allowed when the Host is the
|
||||
origin domain's effective auth host; otherwise the connection is closed
|
||||
pre-accept. When the Origin is missing or unknown the Host domain applies
|
||||
and endpoint-side origin validation decides.
|
||||
the domain's auth host. A cross-domain connection is only allowed when
|
||||
the Host is the origin domain's own auth host; otherwise the connection
|
||||
is closed pre-accept. When the Origin is missing or unknown the Host
|
||||
domain applies and endpoint-side origin validation decides.
|
||||
"""
|
||||
|
||||
from fastapi.responses import PlainTextResponse
|
||||
@@ -73,11 +72,11 @@ class DispatchMiddleware:
|
||||
origin_host = hostutil.origin_hostname(origin) if origin else None
|
||||
origin_domain = registry.resolve(origin_host) if origin_host else None
|
||||
if origin_domain is not None and origin_domain is not host_domain:
|
||||
# Cross-domain connection: only via the origin domain's auth host.
|
||||
effective = registry.effective_auth_host(origin_domain)
|
||||
if not effective or hostutil.normalize_host(
|
||||
# Cross-domain connection: only via the origin domain's own auth host.
|
||||
own = origin_domain.own_auth_host
|
||||
if not own or hostutil.normalize_host(
|
||||
host
|
||||
) != hostutil.normalize_host(effective):
|
||||
) != hostutil.normalize_host(own):
|
||||
await send(
|
||||
{"type": "websocket.close", "code": _WS_CLOSE_POLICY_VIOLATION}
|
||||
)
|
||||
|
||||
+6
-26
@@ -22,7 +22,6 @@ from fastapi.security import HTTPBearer
|
||||
|
||||
from paskia import authcode, db
|
||||
from paskia.db.structs import OIDC, Session
|
||||
from paskia.domains import current_domain
|
||||
from paskia.util import avatar, oidjwt
|
||||
from paskia.util.crypto import hash_secret
|
||||
|
||||
@@ -32,17 +31,14 @@ app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
|
||||
def _provider() -> OIDC:
|
||||
"""Return the OIDC provider state of the current request's domain."""
|
||||
provider = db.data().oidc_for(current_domain().rp_id)
|
||||
if provider is None: # pragma: no cover - invariant: domains always seed OIDC
|
||||
raise RuntimeError(f"No OIDC provider for domain {current_domain().rp_id}")
|
||||
return provider
|
||||
"""Return the instance-global OIDC provider state."""
|
||||
return db.data().oidc
|
||||
|
||||
|
||||
@app.get("/keys")
|
||||
async def keys():
|
||||
"""JSON Web Key Set for token verification."""
|
||||
return oidjwt.get_jwks(current_domain().rp_id)
|
||||
return oidjwt.get_jwks()
|
||||
|
||||
|
||||
def _oidc_session_by_token(
|
||||
@@ -197,16 +193,6 @@ async def _handle_authorization_code(
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# The code is bound to the domain it was issued in (dispatched by Host)
|
||||
if oidc_code.rp_id != current_domain().rp_id:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "Code was issued for a different domain",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Look up the OIDC session by token
|
||||
session = _oidc_session_by_token(oidc_code.session_key, client.uuid)
|
||||
if not session:
|
||||
@@ -347,7 +333,6 @@ def _build_token_response(
|
||||
credential_uuid: UUID | None = None,
|
||||
):
|
||||
"""Build the token response with access_token, id_token, and refresh_token."""
|
||||
rp_id = current_domain().rp_id
|
||||
issuer = _get_issuer(request)
|
||||
|
||||
# Get user's permissions scoped to this OIDC client (domain == client UUID)
|
||||
@@ -374,7 +359,6 @@ def _build_token_response(
|
||||
|
||||
# Create ID token
|
||||
id_token = oidjwt.create_id_token(
|
||||
rp_id,
|
||||
issuer=issuer,
|
||||
subject=user.uuid,
|
||||
audience=client_id,
|
||||
@@ -390,7 +374,6 @@ def _build_token_response(
|
||||
|
||||
# Create access token
|
||||
access_token = oidjwt.create_access_token(
|
||||
rp_id,
|
||||
issuer=issuer,
|
||||
subject=user.uuid,
|
||||
audience=client_id,
|
||||
@@ -424,9 +407,8 @@ async def userinfo(
|
||||
if not credentials:
|
||||
raise HTTPException(401, "Bearer token required")
|
||||
|
||||
rp_id = current_domain().rp_id
|
||||
issuer = _get_issuer(request)
|
||||
payload = oidjwt.decode_access_token(rp_id, credentials.credentials, issuer)
|
||||
payload = oidjwt.decode_access_token(credentials.credentials, issuer)
|
||||
if not payload:
|
||||
raise HTTPException(401, "Invalid or expired token")
|
||||
|
||||
@@ -510,9 +492,8 @@ async def backchannel_logout(
|
||||
)
|
||||
|
||||
# Decode and verify the logout token
|
||||
rp_id = current_domain().rp_id
|
||||
issuer = _get_issuer(request)
|
||||
payload = oidjwt.decode_access_token(rp_id, logout_token, issuer)
|
||||
payload = oidjwt.decode_access_token(logout_token, issuer)
|
||||
if not payload:
|
||||
return JSONResponse(
|
||||
{"error": "invalid_request", "error_description": "Invalid logout_token"},
|
||||
@@ -581,13 +562,12 @@ async def backchannel_logout(
|
||||
{"error": "invalid_request", "error_description": "Invalid sub claim"},
|
||||
status_code=400,
|
||||
)
|
||||
# Find and delete matching sessions (this domain's OIDC sessions only)
|
||||
# Find and delete matching OIDC sessions for this user/client
|
||||
sessions_to_delete = [
|
||||
s
|
||||
for s in db.data().sessions.values()
|
||||
if s.user_uuid == user_uuid
|
||||
and s.client_uuid is not None
|
||||
and s.rp_id == rp_id
|
||||
and (client_uuid is None or s.client_uuid == client_uuid)
|
||||
]
|
||||
for session in sessions_to_delete:
|
||||
|
||||
@@ -136,7 +136,7 @@ async def websocket_authenticate(
|
||||
await ws.send_json({"status": 400, "detail": "Invalid client_id"})
|
||||
return
|
||||
|
||||
oidc_client = db.data().oidc_for(domain.rp_id).clients.get(client_uuid)
|
||||
oidc_client = db.data().oidc.clients.get(client_uuid)
|
||||
if not oidc_client:
|
||||
await ws.send_json({"status": 400, "detail": "Unknown client_id"})
|
||||
return
|
||||
@@ -149,10 +149,10 @@ async def websocket_authenticate(
|
||||
return
|
||||
# Store as the only allowed redirect URI
|
||||
db.update_oid_client(
|
||||
domain.rp_id, client_uuid, redirect_uris=[redirect_uri]
|
||||
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)
|
||||
oidc_client = db.data().oidc.clients.get(client_uuid)
|
||||
elif redirect_uri not in oidc_client.redirect_uris:
|
||||
await ws.send_json({"status": 400, "detail": "Invalid redirect_uri"})
|
||||
return
|
||||
@@ -240,7 +240,6 @@ async def websocket_authenticate(
|
||||
created=now,
|
||||
redirect_uri=redirect_uri,
|
||||
scope=scope,
|
||||
rp_id=domain.rp_id,
|
||||
nonce=nonce,
|
||||
code_challenge=code_challenge,
|
||||
)
|
||||
|
||||
+11
-27
@@ -4,9 +4,9 @@ OIDC Back-Channel Logout notifications.
|
||||
When sessions are deleted (logout, admin, expiry), this module notifies
|
||||
any OIDC clients that have a backchannel_logout_uri configured.
|
||||
|
||||
Notifications run without request context, so the domain and issuer come
|
||||
from the session itself: ``Session.rp_id`` selects the domain's signing key
|
||||
and ``Session.issuer`` (stamped at session creation/refresh) is the `iss`.
|
||||
Notifications run without request context, so the issuer comes from the
|
||||
session itself (``Session.issuer``, stamped at session creation/refresh);
|
||||
the signing key is instance-global.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -15,7 +15,7 @@ from uuid import UUID
|
||||
|
||||
import httpx
|
||||
|
||||
from paskia import db, domains
|
||||
from paskia import db
|
||||
from paskia.util import oidjwt
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -24,20 +24,10 @@ _logger = logging.getLogger(__name__)
|
||||
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
||||
|
||||
|
||||
def _session_domain(rp_id: str | None):
|
||||
"""Resolve a session's domain by its rp-id stamp (None if unknown)."""
|
||||
if not rp_id:
|
||||
return None
|
||||
try:
|
||||
return domains.registry().get(rp_id)
|
||||
except RuntimeError:
|
||||
return None
|
||||
|
||||
|
||||
def _collect_oidc_sessions(
|
||||
session_keys: list[str],
|
||||
) -> list[tuple[str, str, str, str, UUID, UUID | None]]:
|
||||
"""Collect (logout_uri, rp_id, issuer, sid, client_uuid, user_uuid).
|
||||
) -> list[tuple[str, str, str, UUID, UUID | None]]:
|
||||
"""Collect (logout_uri, issuer, sid, client_uuid, user_uuid).
|
||||
|
||||
Must be called before the sessions are deleted from the database.
|
||||
Returns only sessions whose client has a backchannel_logout_uri configured.
|
||||
@@ -48,18 +38,13 @@ def _collect_oidc_sessions(
|
||||
session = data.sessions.get(key)
|
||||
if not session or session.client_uuid is None:
|
||||
continue
|
||||
domain = _session_domain(session.rp_id)
|
||||
if domain is None:
|
||||
continue
|
||||
provider = data.oidc.get(domain.rp_id)
|
||||
client = provider.clients.get(session.client_uuid) if provider else None
|
||||
client = data.oidc.clients.get(session.client_uuid)
|
||||
if not client or not client.backchannel_logout_uri:
|
||||
continue
|
||||
issuer = session.issuer or domain.site_url
|
||||
issuer = session.issuer or f"https://{session.host}"
|
||||
notifications.append(
|
||||
(
|
||||
client.backchannel_logout_uri,
|
||||
domain.rp_id,
|
||||
issuer,
|
||||
session.key,
|
||||
session.client_uuid,
|
||||
@@ -95,12 +80,12 @@ async def _send_logout_token(
|
||||
|
||||
|
||||
async def notify(
|
||||
notifications: list[tuple[str, str, str, str, UUID, UUID | None]],
|
||||
notifications: list[tuple[str, str, str, UUID, UUID | None]],
|
||||
) -> None:
|
||||
"""Send back-channel logout tokens to all collected endpoints.
|
||||
|
||||
Args:
|
||||
notifications: list of (backchannel_logout_uri, rp_id, issuer, sid,
|
||||
notifications: list of (backchannel_logout_uri, issuer, sid,
|
||||
client_uuid, user_uuid) as returned by _collect_oidc_sessions.
|
||||
"""
|
||||
if not notifications:
|
||||
@@ -108,9 +93,8 @@ async def notify(
|
||||
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
tasks = []
|
||||
for uri, rp_id, issuer, sid, client_uuid, user_uuid in notifications:
|
||||
for uri, issuer, sid, client_uuid, user_uuid in notifications:
|
||||
token = oidjwt.create_logout_token(
|
||||
rp_id,
|
||||
issuer=issuer,
|
||||
audience=str(client_uuid),
|
||||
sid=sid,
|
||||
|
||||
+35
-24
@@ -56,13 +56,14 @@ class Passkey:
|
||||
rp_id: Your security domain (e.g. "example.com")
|
||||
rp_name: The relying party display name (e.g. "Example App"). May be shown in authenticators.
|
||||
origins: Allow-list of sign-in site origins within the rp-id domain
|
||||
(e.g. ["https://app.example.com"]); the bare entry "*"
|
||||
allows the whole rp-id domain on any scheme and port,
|
||||
while wildcard patterns like "*.example.com" match the
|
||||
base domain and its subdomains over https only. Exact
|
||||
entries match scheme, host and port. If not provided, the
|
||||
rp-id and any subdomain of it may authenticate (same as
|
||||
listing "*").
|
||||
(e.g. ["https://app.example.com"]); "*" is shorthand for
|
||||
a wildcard over the whole rp-id domain, and wildcard
|
||||
patterns like "*.example.com" match the base domain and
|
||||
its subdomains over https only — except under localhost
|
||||
("*.localhost"), which matches any scheme and any port.
|
||||
Exact entries match scheme, host and port. If not
|
||||
provided, the rp-id and any subdomain of it may
|
||||
authenticate (same as listing "*").
|
||||
related_origins: Origins on unrelated domains that may assert this
|
||||
rp-id (WebAuthn Related Origin Requests). Always additive.
|
||||
supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
|
||||
@@ -78,17 +79,22 @@ class Passkey:
|
||||
self.allowed_origins: set[str] | None = None
|
||||
if origins:
|
||||
# Validate and deduplicate origins into a set for O(1) lookups
|
||||
normalized = []
|
||||
for o in origins:
|
||||
if hostutil.is_any_pattern(o):
|
||||
continue # anything in-domain, any scheme/port
|
||||
self._validate_origin_url(o)
|
||||
hostname = hostutil.origin_hostname(o)
|
||||
if not hostutil.is_subdomain(hostname, rp_id):
|
||||
if o == "*":
|
||||
o = f"*.{rp_id}" # shorthand: the whole rp-id domain
|
||||
if hostutil.is_wildcard_pattern(o):
|
||||
hostname = hostutil.origin_hostname(o)
|
||||
else:
|
||||
self._validate_origin_url(o)
|
||||
hostname = hostutil.origin_hostname(o)
|
||||
if not hostname or not hostutil.is_subdomain(hostname, rp_id):
|
||||
raise ValueError(
|
||||
f"Origin '{o}' is outside the rp-id domain '{rp_id}' — "
|
||||
"configure it as a related origin instead"
|
||||
)
|
||||
self.allowed_origins = set(origins)
|
||||
normalized.append(o)
|
||||
self.allowed_origins = set(normalized)
|
||||
self.related_origins: set[str] = set()
|
||||
for o in related_origins or []:
|
||||
if hostutil.is_wildcard_pattern(o):
|
||||
@@ -124,20 +130,25 @@ class Passkey:
|
||||
def _allowlisted(self, origin: str) -> bool:
|
||||
"""Check an in-domain origin against the allow-list.
|
||||
|
||||
An entry matches exactly, '*' matches anything in-domain (any
|
||||
scheme/port), and a wildcard pattern ('*.example.com') matches the
|
||||
base domain and any subdomain of it over https only.
|
||||
An entry matches exactly; a wildcard pattern ('*.example.com')
|
||||
matches the base domain and any subdomain of it over https only,
|
||||
except under localhost ('*.localhost'), which matches any scheme
|
||||
and any port.
|
||||
"""
|
||||
if "*" in self.allowed_origins or origin in self.allowed_origins:
|
||||
if origin in self.allowed_origins:
|
||||
return True
|
||||
if not origin.startswith("https://"):
|
||||
return False # Wildcard patterns match https origins only
|
||||
hostname = hostutil.origin_hostname(origin)
|
||||
return any(
|
||||
hostutil.is_wildcard_pattern(entry)
|
||||
and hostutil.is_subdomain(hostname, entry[2:])
|
||||
for entry in self.allowed_origins
|
||||
)
|
||||
for entry in self.allowed_origins:
|
||||
if not hostutil.is_wildcard_pattern(entry):
|
||||
continue
|
||||
base = entry[2:]
|
||||
if not hostutil.is_subdomain(hostname, base):
|
||||
continue
|
||||
if hostutil.is_subdomain(base, "localhost"):
|
||||
return True # localhost: any scheme, any port
|
||||
if origin.startswith("https://"):
|
||||
return True # Wildcard patterns match https origins only
|
||||
return False
|
||||
|
||||
def validate_origin(self, origin: str) -> str:
|
||||
"""Validate that origin is allowed and return it.
|
||||
|
||||
@@ -163,9 +163,9 @@ class ApiOrgResponse(msgspec.Struct, kw_only=True):
|
||||
class ApiSettings(msgspec.Struct):
|
||||
"""Settings response struct (per the domain the request was dispatched to).
|
||||
|
||||
auth_host is the domain's effective auth host (its own, or the shared
|
||||
fallback of another domain); own_auth_host is set only when this domain
|
||||
has its own dedicated auth host.
|
||||
auth_host is the domain's own dedicated auth host (None when the
|
||||
domain has none); own_auth_host is the same value, kept as a separate
|
||||
field for clients that switched to it.
|
||||
"""
|
||||
|
||||
rp_id: str
|
||||
@@ -192,7 +192,7 @@ class ApiDomain(msgspec.Struct):
|
||||
related: dict[str, bool]
|
||||
site_url: str
|
||||
auth_site_url: str
|
||||
effective_auth_host: str | None
|
||||
auth_host: str | None
|
||||
|
||||
|
||||
class ApiTokenInfo(msgspec.Struct, omit_defaults=True):
|
||||
|
||||
@@ -23,11 +23,6 @@ def is_wildcard_pattern(value: str) -> bool:
|
||||
return value.startswith("*.")
|
||||
|
||||
|
||||
def is_any_pattern(value: str) -> bool:
|
||||
"""Check whether an origins entry is the bare '*' (anything in-domain)."""
|
||||
return value == "*"
|
||||
|
||||
|
||||
def normalize_origin(origin: str) -> str:
|
||||
"""Normalize an origin URL by adding https:// if no scheme is present, removing trailing slashes.
|
||||
|
||||
|
||||
+24
-31
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
OIDC JWT utilities for signing ID tokens and serving JWKS.
|
||||
|
||||
Each domain is an independent OIDC provider with its own signing key;
|
||||
keys are cached per rp-id.
|
||||
The OIDC provider is instance-global: a single signing key serves all
|
||||
domains, with each request Host acting as an issuer alias.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
@@ -21,16 +21,14 @@ from paskia.util.crypto import (
|
||||
secret_key,
|
||||
)
|
||||
|
||||
# JWT signing keys (loaded on first use), keyed by domain rp-id
|
||||
_keys: dict[str, tuple[object, object, str]] = {}
|
||||
# JWT signing key (loaded on first use): (private, public, kid)
|
||||
_key: tuple[object, object, str] | None = None
|
||||
|
||||
|
||||
def _load_or_generate_key(rp_id: str) -> tuple[object, object, str]:
|
||||
"""Load a domain's Ed25519 key or generate and store a new one."""
|
||||
def _load_or_generate_key() -> tuple[object, object, str]:
|
||||
"""Load the Ed25519 signing key or generate and store a new one."""
|
||||
data = db.data()
|
||||
provider = data.oidc.get(rp_id)
|
||||
if provider is None:
|
||||
raise RuntimeError(f"No OIDC provider for domain {rp_id}")
|
||||
provider = data.oidc
|
||||
store = data._store
|
||||
if store is None:
|
||||
raise RuntimeError("Kanta store is not initialized")
|
||||
@@ -48,21 +46,23 @@ def _load_or_generate_key(rp_id: str) -> tuple[object, object, str]:
|
||||
return private_key, public_key, kid
|
||||
|
||||
|
||||
def _ensure_key(rp_id: str) -> tuple[object, object, str]:
|
||||
"""Ensure a domain's key is loaded and return (private, public, kid)."""
|
||||
if rp_id not in _keys:
|
||||
_keys[rp_id] = _load_or_generate_key(rp_id)
|
||||
return _keys[rp_id]
|
||||
def _ensure_key() -> tuple[object, object, str]:
|
||||
"""Ensure the signing key is loaded and return (private, public, kid)."""
|
||||
global _key
|
||||
if _key is None:
|
||||
_key = _load_or_generate_key()
|
||||
return _key
|
||||
|
||||
|
||||
def clear_key(rp_id: str) -> None:
|
||||
"""Drop a domain's cached key (domain deleted or key rotated)."""
|
||||
_keys.pop(rp_id, None)
|
||||
def clear_key() -> None:
|
||||
"""Drop the cached signing key (key rotated)."""
|
||||
global _key
|
||||
_key = None
|
||||
|
||||
|
||||
def get_jwks(rp_id: str) -> dict:
|
||||
def get_jwks() -> dict:
|
||||
"""Get JWKS (JSON Web Key Set) for public key verification."""
|
||||
private_key, _, kid = _ensure_key(rp_id)
|
||||
private_key, _, kid = _ensure_key()
|
||||
# Ed25519 public key is 32 bytes raw
|
||||
pub_bytes = get_public_key_raw(private_key)
|
||||
return {
|
||||
@@ -80,7 +80,6 @@ def get_jwks(rp_id: str) -> dict:
|
||||
|
||||
|
||||
def create_id_token(
|
||||
rp_id: str,
|
||||
issuer: str,
|
||||
subject: UUID,
|
||||
audience: str, # client_id
|
||||
@@ -97,7 +96,6 @@ def create_id_token(
|
||||
"""Create a signed ID token (JWT).
|
||||
|
||||
Args:
|
||||
rp_id: Domain whose signing key to use
|
||||
issuer: Token issuer (site URL)
|
||||
subject: User UUID (sub claim)
|
||||
audience: Client ID (aud claim)
|
||||
@@ -114,7 +112,7 @@ def create_id_token(
|
||||
Returns:
|
||||
Signed JWT string
|
||||
"""
|
||||
private_key, _, kid = _ensure_key(rp_id)
|
||||
private_key, _, kid = _ensure_key()
|
||||
now = datetime.now(UTC)
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
@@ -144,7 +142,6 @@ def create_id_token(
|
||||
|
||||
|
||||
def create_access_token(
|
||||
rp_id: str,
|
||||
issuer: str,
|
||||
subject: UUID,
|
||||
audience: str,
|
||||
@@ -154,7 +151,6 @@ def create_access_token(
|
||||
"""Create a signed access token (JWT) for userinfo endpoint.
|
||||
|
||||
Args:
|
||||
rp_id: Domain whose signing key to use
|
||||
issuer: Token issuer (site URL)
|
||||
subject: User UUID
|
||||
audience: Client ID
|
||||
@@ -164,7 +160,7 @@ def create_access_token(
|
||||
Returns:
|
||||
Signed JWT string
|
||||
"""
|
||||
private_key, _, kid = _ensure_key(rp_id)
|
||||
private_key, _, kid = _ensure_key()
|
||||
now = datetime.now(UTC)
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
@@ -178,12 +174,11 @@ def create_access_token(
|
||||
|
||||
|
||||
def decode_access_token(
|
||||
rp_id: str, token: str, issuer: str, audience: str | None = None
|
||||
token: str, issuer: str, audience: str | None = None
|
||||
) -> dict | None:
|
||||
"""Decode and verify an access token.
|
||||
|
||||
Args:
|
||||
rp_id: Domain whose key to verify with
|
||||
token: JWT string
|
||||
issuer: Expected issuer
|
||||
audience: Optional expected audience (client_id). If provided, aud claim must match.
|
||||
@@ -191,7 +186,7 @@ def decode_access_token(
|
||||
Returns:
|
||||
Decoded payload or None if invalid
|
||||
"""
|
||||
_, public_key, _ = _ensure_key(rp_id)
|
||||
_, public_key, _ = _ensure_key()
|
||||
try:
|
||||
if audience is not None:
|
||||
return jwt.decode(
|
||||
@@ -214,7 +209,6 @@ def decode_access_token(
|
||||
|
||||
|
||||
def create_logout_token(
|
||||
rp_id: str,
|
||||
issuer: str,
|
||||
audience: str,
|
||||
sid: str | None = None,
|
||||
@@ -226,7 +220,6 @@ def create_logout_token(
|
||||
either sid (session) or sub (user), or both.
|
||||
|
||||
Args:
|
||||
rp_id: Domain whose signing key to use
|
||||
issuer: Token issuer (site URL)
|
||||
audience: Client ID (aud claim)
|
||||
sid: Session ID (base64url-encoded)
|
||||
@@ -235,7 +228,7 @@ def create_logout_token(
|
||||
Returns:
|
||||
Signed JWT string
|
||||
"""
|
||||
private_key, _, kid = _ensure_key(rp_id)
|
||||
private_key, _, kid = _ensure_key()
|
||||
now = datetime.now(UTC)
|
||||
payload: dict[str, object] = {
|
||||
"iss": issuer,
|
||||
|
||||
Reference in New Issue
Block a user