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

- DB.oidc is a single OIDC (one key, one client set); hosts are issuer
  aliases. OIDCCode drops its rp_id field; client CRUD is not keyed by
  domain.
- No cross-domain auth-host fallback: a domain without its own auth host
  uses its own hosts; several domains may share one auth host (nested
  rp-ids) with deterministic best-suffix resolution.
- '*' origin shorthand expands to '*.{rp-id}'; legacy wildcards convert
  as-is; related origins may point at/inside another domain's rp-id.
- Admin UI and docs updated to match.
This commit is contained in:
2026-09-07 06:10:08 +00:00
parent 00560a05c1
commit af6d7e3a3f
29 changed files with 491 additions and 420 deletions
+3 -3
View File
@@ -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
View File
@@ -25,7 +25,8 @@ global users/orgs, N domains.
| Credentials/passkeys | global collection, each stamped with its `rp_id` |
| Reset tokens | user-bound; global |
| Avatars | `paskia.data/users/<uuid>/profile.webp` |
| Auth codes, remote-auth manager | in-memory; carry rp-id fields |
| Auth codes, remote-auth manager | in-memory; `CookieCode` carries an rp-id field |
| OIDC provider (key, clients) | one instance-global provider; hosts are issuer aliases |
**Per-domain (registry, keyed by rp-id):**
@@ -34,11 +35,9 @@ global users/orgs, N domains.
| `rp_name`, origins, related | stored combined `Config` |
| `Passkey` instance | per rp-id; ceremonies verify against the _origin domain's_ rp-id |
| `site_url`/`site_path` | runtime derivation, per domain |
| OIDC provider (keys, clients) | per rp-id — each domain is an independent issuer |
`db.data()` is a plain global singleton. A contextvar is needed only for
the **current domain** (passkey, config, OIDC view) — not for database
access.
the **current domain** (passkey, config) — not for database access.
The architectural rule: **authentication establishes identity, not
organization** — the requested hostname selects the org/permission
@@ -79,7 +78,9 @@ the WS. On top of that:
are none).
- Dispatch resolution treats a Host matching a configured related-origin
hostname as belonging to that origin's domain (exact match only —
`www.app2.com` does not follow `app2.com`).
`www.app2.com` does not follow `app2.com`) — unless the host is itself
a configured rp-id, which always wins: a host that is one domain's
rp-id and another's related origin serves its own domain.
Deployment constraint: the **browser** fetches
`https://<rp-id>/.well-known/webauthn` from the canonical apex directly —
@@ -167,26 +168,35 @@ class Config(msgspec.Struct, omit_defaults=True):
URL, background jobs), the single configured domain is used, and with
several domains the first one sorted by rp-id — never for dispatch.
- Origin keys are bare hosts (`app.example.com`), wildcard patterns
(`*.example.com`), full origins when not https (`http://localhost:8080`),
or the bare `*``https://` is omitted as the common case. A dict value
of `true` means presence only; an object carries extra properties
(currently just `auth_host`). Ordering carries no meaning — display
order is a UI affair.
(`*.example.com`), full origins when not https
(`http://localhost:8080`), or the bare `*` (shorthand for a wildcard
over the rp-id itself) — `https://` is omitted as the common case. A
dict value of `true` means presence only; an object carries extra
properties (currently just `auth_host`). Ordering carries no meaning —
display order is a UI affair.
- An empty `origins` dict means the rp-id and all its subdomains may
sign in (the default). A non-empty dict is an allow-list of in-domain
sign-in sites; matching semantics per entry kind:
- `*`anything within the rp-id domain, any scheme and port;
- `*.example.com` — the base domain and its subdomains, **https only**;
- `*`the whole rp-id domain (shorthand for `*.{rp-id}`);
- `*.example.com` — the base domain and its subdomains, **https only**
— except under localhost (`*.localhost` or any wildcard below it),
which matches **any scheme and any port**;
- anything else — exact match on scheme, host and port.
One entry may be marked `auth_host` (never a wildcard or `*`).
- **Origin validation** — two separate concerns: `origins` entries must
be within the rp-id domain. `related` entries must be outside it, are
capped (default 5), must not be wildcards, and must not collide with
another domain's rp-id/auth-host/related origins nor fall inside
another domain's rp-id. Misfiled entries (cross-domain in `origins`,
in-domain in `related`) are rejected. These rules are enforced at admin
write time; at startup the stored config is sanitized best-effort
instead (§3.2). Origins are never _implicitly_ cross-domain.
another domain's auth host; two domains may not list the same related
host **unless** that host is (or falls inside) a configured rp-id —
overlapping another domain's rp-id is permitted: the owning domain
always wins dispatch for that host, and each listing domain's
well-known document independently authorizes ROR logins. Several
domains may mark the same auth host when it lies under both rp-ids
(nested rp-ids); resolution among claimants is deterministic (§5).
Misfiled entries (cross-domain in `origins`, in-domain in `related`)
are rejected. These rules are enforced at admin write time; at startup
the stored config is sanitized best-effort instead (§3.2). Origins are
never _implicitly_ cross-domain.
### 3.2 CLI: bootstrap (`paskia init`) vs. serve (`paskia`)
@@ -207,26 +217,25 @@ instance:
Refuses to run if an unconverted legacy `*.paskiadb` is present
(`paskia migrate` converts it first).
- **With an existing `paskia.kantadb`**, init instead adds the given
rp-id as a new domain (seeding its OIDC provider), or updates the
rp-id as a new domain, or updates the
rp-name of an existing one — a convenience for what the admin
interface also does.
- **`paskia migrate [rp-id]`** — converts a legacy `<rp-id>.paskiadb`
database (§10) to `paskia.kantadb`. With several legacy candidates, the
positional rp-id selects `<rp-id>.paskiadb` by name; the others are
left in place. A legacy wildcard origin over the rp-id itself
(`*.example.com`) converts to the bare `*` entry, preserving its
any-scheme meaning.
left in place. Legacy wildcard origins (`*.example.com`) convert as-is
(https-only outside localhost, any scheme and port under localhost).
- **`paskia`** — serve. Takes **no domain options**; only `--listen`
(per-run override of stored `Config.listen`, never persisted). Startup:
open `paskia.kantadb` → sanitize the stored domain set best-effort →
build the domain registry → serve. Sanitization never refuses to start:
misfiled origin entries are reclassified (a cross-domain `origins`
entry is served as a related origin) or dropped, colliding
auth-hosts/related origins resolve first-come-wins, over-cap related
lists truncate, and unsalvageable domains are skipped — each producing
a startup warning, because fixing the stored config is the admin
interface's job and it must stay reachable to do so. Only a config
with no servable domain at all is fatal. The serve command never
entry is served as a related origin) or dropped, related origins
claimed by two non-owner domains resolve first-come-wins, over-cap
related lists truncate, and unsalvageable domains are skipped — each
producing a startup warning, because fixing the stored config is the
admin interface's job and it must stay reachable to do so. Only a
config with no servable domain at all is fatal. The serve command never
converts databases: with no `paskia.kantadb`, the startup error points
at `paskia init`, or at `paskia migrate` when legacy `*.paskiadb`
candidates are present.
@@ -273,10 +282,13 @@ stamp the child rp-id.
keyed by rp-id, built in the lifespan from the stored combined `Config`
and rebuilt on admin domain writes. No per-domain Kanta/DB.
- Host resolution (`resolve(host)`): normalize (lowercase, strip port and
trailing dot), then exact rp-id → exact auth host → **exact
trailing dot), then exact rp-id → auth host → **exact
related-origin hostname** → longest-suffix rp-id. Unknown → `None`.
(Order safe because startup and admin-write validation forbids
collisions between these sets.)
Overlaps are deterministic: an owning rp-id always beats a related
listing of the same host; when several domains claim one auth host,
the claimant whose rp-id is the longest suffix of the host wins
(first configured as tiebreak); a related host claimed by two
non-owner domains resolves first-configured-wins.
- A pure ASGI dispatch middleware, outermost, handles `"http"` and
`"websocket"` scopes. Unknown Host → 421 Misdirected Request (WS:
pre-accept close). Sets the `current_domain` contextvar +
@@ -288,7 +300,7 @@ stamp the child rp-id.
1. the middleware resolves the origin domain from the Origin hostname —
including related-origin hostnames;
2. the connection `Host` must be a valid WS endpoint for that domain:
the domain's _effective auth host_ (§6), or the origin host itself
the domain's _own auth host_ (§6), or the origin host itself
when the domain has no auth host at all — else pre-accept reject;
3. `validate_origin` runs endpoint-side against the origin domain's
`Passkey` (post-accept JSON errors preserved);
@@ -297,51 +309,49 @@ stamp the child rp-id.
the browser enforces for the page's origin under both classic and
related-origin rules.
## 6. Auth host: per-domain values with global fallback
## 6. Auth host: per-domain, no fallback
A domain's auth host is one of its origins entries marked
`auth_host: true`. A domain without its own auth host falls back to the
first configured auth host found in the registry:
`auth_host: true` (always in-domain). There is **no cross-domain
fallback**: a domain without its own auth host uses its own hosts for WS
and all flows — one domain's auth host never reroutes another domain's
authentication. Consolidating logins on one host is explicit: the host
must be marked on every domain that uses it (possible when the host lies
under each domain's rp-id, i.e. nested rp-ids; see §5 for deterministic
resolution among claimants). Marking a _foreign_ host as a domain's own
auth host is rejected (origins entries are in-domain only): it would
redirect that domain's UI to the other domain, where the ceremony's
Origin resolves the _owner_ domain and stamps the wrong
`Credential.rp_id`.
```
effective_auth_host(domain) = domain's own auth host or first configured one or None
auth_host(domain) = domain's own auth host or None
```
**Own vs. effective auth host are distinguished everywhere:**
- **Follow the domain's OWN auth host**: UI mode detection (minimal-
profile decision in `App.vue`, via `own_auth_host` in settings), the
redirect middleware, `ui_base_path`, and `reset_link_url`. A domain
with no own auth host keeps its full UI on its own hosts — otherwise
reset/registration pages on `app2.com` would redirect to
`auth.company.com`, where the ceremony's Origin resolves the _owner_
domain and stamps the wrong `Credential.rp_id`, breaking 2.B onboarding
and 2.C local enrollment.
- **Follow the EFFECTIVE auth host**: WS endpoint selection only
(`passkey.js` builds the WS URL from settings). The fallback auth host
serves WS + restricted APIs for foreign domains.
- Settings (`ApiSettings`) exposes both fields (`own_auth_host` alongside
the effective `auth_host`) so the frontend makes the mode decision
correctly.
- **Own auth host governs everything**: UI mode detection (minimal-
profile decision in `App.vue`), the redirect middleware,
`ui_base_path`, `reset_link_url`, and WS endpoint selection
(`passkey.js` builds the WS URL from settings).
- Settings (`ApiSettings`) exposes `auth_host` and `own_auth_host` with
the same value (the latter remains for clients that switched to it).
- Root mode (`site_path == "/"`) applies only on a domain's _own_ auth
host, so it is never ambiguous: a domain's UI lives on its own hosts;
the fallback auth host serves the _owner_ domain's UI plus WS for the
rest.
host: a domain's UI lives on its own hosts.
## 7. Login flows and in-memory stores
### 7.1 Auth codes
- `OIDCCode` and `CookieCode` carry `rp_id`, verified at redemption —
defense in depth.
- `CookieCode` carries `rp_id`, verified at redemption — defense in
depth. `OIDCCode` does not: the OIDC provider is instance-global, so
its codes are redeemable at any host.
- **Stamping source matters**: codes are stamped with the domain of the
session they will redeem — not naively with the current domain at
issuance. Remote-completion codes are minted inside the _permit_
handler (permitting domain's context) but redeemed by the _requesting_
device on its own host, so they are stamped with
`RemoteAuthRequest.rp_id` — stamping them with the permitter's domain
would break every cross-domain remote login. Registration-flow and OIDC
codes stamp from the current domain (issue and redeem sides always
would break every cross-domain remote login. Registration-flow codes
stamp from the current domain (issue and redeem sides always
match). The host re-check at set-session independently binds
`CookieCode` to the host; the rp_id check complements it.
@@ -360,28 +370,33 @@ effective_auth_host(domain) = domain's own auth host or first configured one or
(users are global).
- Exchange codes stay single-use, 60s, host-bound at redemption.
## 8. OIDC: per-domain providers in one DB
## 8. OIDC: one instance-global provider
- `DB.oidc` is `dict[str, OIDC]` keyed by rp-id. Each domain is an
independent provider: own signing key (`oidc.<rp-id>.key` in the
transaction log shape), own clients.
- The `util/oidjwt.py` key cache is keyed by rp-id.
- Issuer stays per-request-Host — each domain host is an issuer alias
sharing the domain's key. **`Session` carries two fields**: `issuer`
(stamped from the WS **Origin**, scheme included, at OIDC-session
creation and re-stamped at refresh — stamping from the WS _connection_
Host would be wrong, that is the effective auth host, not the
authorize/discovery host the RP validates against); and `rp_id` — the
owning domain, needed by every path that runs **without request
context**:
- backchannel logout uses `session.rp_id` to select the domain's key
and `session.issuer` as `iss`;
- session listings resolve the client under the session's own domain,
not the request's.
- Admin OIDC-client CRUD operates on the current domain's `OIDC` entry.
- `DB.oidc` is a single `OIDC` — one signing key (`oidc.key` in the
transaction log shape) and one client set for the whole instance.
Domains do **not** segment OIDC: a client registered once is usable
through every configured domain.
- The `util/oidjwt.py` key cache holds the single signing key.
- Issuer stays per-request-Host — every configured host is an issuer
alias sharing the one key, and the discovery document
(`/.well-known/openid-configuration`) is served on every host with
host-derived endpoints. An RP picks **one** discovery URL and uses it
consistently; tokens then validate against that issuer. The admin OIDC
client view lists the discovery URL of every configured domain.
- **`Session` carries two fields**: `issuer` (stamped from the WS
**Origin**, scheme included, at OIDC-session creation and re-stamped at
refresh — stamping from the WS _connection_ Host would be wrong, that
may be an auth host, not the authorize/discovery host the RP
validates against); and `rp_id` — the owning domain, kept for display
and diagnostics.
- backchannel logout runs without request context and uses
`session.issuer` as `iss` (falling back to `https://<session.host>`).
- Admin OIDC-client CRUD operates on the instance-global `OIDC` entry.
- Permission `domain` validation accepts a subdomain of **any**
configured rp-id, any related-origin hostname, or any domain's client
UUID.
configured rp-id, any related-origin hostname, or a client UUID.
- OIDC authorization always runs a fresh passkey ceremony; the session
cookie is never consulted in the OIDC branch of the WS authenticate
handler, so a stolen cookie cannot complete an OIDC login on any host.
## 9. Admin API and UI
@@ -395,8 +410,8 @@ effective_auth_host(domain) = domain's own auth host or first configured one or
- Create: `rp_id` + optional `rp_name` (defaults to the rp-id),
`origins` and `related` objects mirroring the stored shape (§3.1);
full §3.1 validation (cap, cross-domain collisions); registry rebuilt
immediately, including the domain's `Passkey` instance and OIDC
provider entry. The auth host is marked inside `origins`
immediately, including the domain's `Passkey` instance. The OIDC
provider is instance-global and unaffected by domain writes. The auth host is marked inside `origins`
(`{"auth.example.com": {"auth_host": true}}`).
- Update: same validation against the would-be combined config.
Changing a domain's rp-id itself is **not supported** (it would
@@ -522,7 +537,7 @@ effective_auth_host(domain) = domain's own auth host or first configured one or
- **Cross-domain permit transparency**: requesting domain/host shown to
the approver; both sides logged.
- **Secret hygiene in logs**: the OIDC signing-key censoring matches the
`oidc.<rp-id>.key` path shape, so domain keys never print in plaintext
`oidc.key` path shape, so the key never prints in plaintext
in the JSONL transaction log.
- **OIDC**: per-domain keys/issuers; logout tokens carry the stored
issuer.
- **OIDC**: instance-global key and clients; issuers are per-request-Host
aliases; logout tokens carry the stored issuer.
-2
View File
@@ -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
+1
View File
@@ -1098,6 +1098,7 @@ async function submitDialog() {
ref="adminOidcDetailRef"
:client="editingOidcClient"
:permissions="permissions"
:domains="domains"
:is-new="editingOidcClient.isNew"
:navigation-disabled="hasActiveModal"
@save="handleOidcSave"
+81 -20
View File
@@ -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; }
+43 -3
View File
@@ -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;
+6 -3
View File
@@ -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>
+40 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+11 -13
View File
@@ -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,14 +704,9 @@ 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():
for uuid, client in self.oidc.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)
def session_ctx(
self, session_secret: str, host: str | None = None
) -> SessionContext | None:
+67 -70
View File
@@ -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/"
+2 -6
View File
@@ -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 = {}
+6 -11
View File
@@ -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"}
+3 -5
View File
@@ -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))
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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,
+8 -9
View File
@@ -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
View File
@@ -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:
+3 -4
View File
@@ -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
View File
@@ -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,
+33 -22
View File
@@ -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
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 hostutil.is_subdomain(hostname, rp_id):
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.
+4 -4
View File
@@ -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):
-5
View File
@@ -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
View File
@@ -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,