Separate related domains (ROR) from the in-domain sign-in allow-list

RealmConfig.origins is again purely an allow-list of sign-in sites
within the realm's domain (unset = rp-id and all subdomains), restoring
the restriction semantics the realm rework had silently turned into an
always-open subtree. Cross-domain ROR origins move to their own
RealmConfig.related_origins field — always additive, capped, validated
to be outside the rp-id domain, and the sole source of the
/.well-known/webauthn document.

Admin API POST/PATCH accept related_origins; misfiled entries are
rejected (cross-domain in origins, in-domain in related_origins).

Admin UI: the realm dialog edits the two lists separately with
end-user-oriented explanations (allowed sign-in sites vs. related
domains + the well-known note); the Realms section intro explains the
multi-domain model, and the table shows sign-in site and related domain
counts.
This commit is contained in:
2026-09-06 22:29:12 +00:00
parent fefd54f02a
commit b9e6f4bc27
16 changed files with 376 additions and 160 deletions
+1 -1
View File
@@ -223,7 +223,7 @@ Set the auth host in the admin panel's Realms section, or pass `--auth-host=auth
One Paskia instance can serve several realms (rp-ids) from the same database: users, orgs and permissions are shared, while passkeys are registered per realm. The master admin adds realms in the admin panel's Realms section; no restart is needed.
A realm can also allow passkey use on unrelated domains via WebAuthn [Related Origin Requests](https://passkeys.dev/docs/advanced/related-origins/) — add the origin to the realm and paskia serves the required `/.well-known/webauthn` declaration.
A realm can also let *other* domain names use its passkeys via WebAuthn [Related Origin Requests](https://passkeys.dev/docs/advanced/related-origins/) — add them as related domains in the realm settings, and paskia serves the required `/.well-known/webauthn` declaration on the realm's domain. This is separate from the allowed-sign-in-sites list, which only restricts which subdomains of the realm's own domain may authenticate (empty = the domain and all subdomains).
See [Multi-Site documentation](docs/MultiSite.md) for details.
+4 -2
View File
@@ -73,12 +73,14 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access
| PATCH | /auth/api/admin/oidc-clients/{uuid}/reset-secret | Reset client secret | 200/401/403 |
| DELETE | /auth/api/admin/oidc-clients/{uuid} | Delete OIDC client | 200/401/403 |
| GET | /auth/api/admin/realms/ | List realms (rp-ids) with derived URLs | 200/401/403 |
| POST | /auth/api/admin/realms/ | Create realm `{rp_id, rp_name?, auth_host?, origins?}` | 200/400/401/403 |
| PATCH | /auth/api/admin/realms/{rp_id} | Update realm rp_name/auth_host/origins | 200/400/401/403 |
| POST | /auth/api/admin/realms/ | Create realm `{rp_id, rp_name?, auth_host?, origins?, related_origins?}` | 200/400/401/403 |
| PATCH | /auth/api/admin/realms/{rp_id} | Update realm rp_name/auth_host/origins/related_origins | 200/400/401/403 |
| DELETE | /auth/api/admin/realms/{rp_id} | Delete realm (refused while credentials remain) | 200/400/401/403 |
Realm endpoints require the `auth:admin` permission; writes additionally require recent authentication (5 minutes). Changes are validated cross-realm and apply immediately.
`origins` is an allow-list of sign-in sites *within* the realm's domain (empty = the rp-id and all subdomains may authenticate). `related_origins` lists *other* domains that may assert this realm'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 list are rejected: cross-domain entries in `origins`, in-domain entries in `related_origins`.
### WebSockets: /auth/ws/*
| Path | Used for | Notes |
+28 -19
View File
@@ -61,7 +61,7 @@ no redirects, no cross-domain cookies. Browser support is universal
Model: realm `company.com` with related origin `https://app2.com`. A page
on `app2.com` calls WebAuthn with `rpId: "company.com"`; the passkey is
scoped to `company.com`; `clientDataJSON.origin` is `https://app2.com`,
which the backend validates against the realm's allow-list.
which the backend validates against the realm's related origins.
Server side: paskia's `Passkey` passes `expected_origin=<the
pre-validated origin>` and `expected_rp_id=self.rp_id`; the webauthn
@@ -69,12 +69,14 @@ library string-compares origin and rp-id separately. The frontend never
chooses `rpId` client-side — ceremony options arrive from the server over
the WS. On top of that:
- Origin rule: an origin is valid if it is in the rp-id subtree **or
explicitly listed in the realm's configured origins**. Explicit listing
is the trust boundary.
- Origin rule: `origins` and `related_origins` are separate fields.
An in-domain origin (rp-id or subdomain) is valid unless the realm's
`origins` allow-list is set, in which case it must be listed there. An
origin on another domain is valid only when listed in the realm's
`related_origins` — explicit related listing is the trust boundary.
- `GET /.well-known/webauthn` on the canonical rp-id host serves
`{"origins": [...]}` from the realm's configured non-subdomain origins
(404 when there are none).
`{"origins": [...]}` from the realm's related origins (404 when there
are none).
- Dispatch resolution treats a Host matching a configured related-origin
hostname as belonging to that origin's realm (exact match only —
`www.app2.com` does not follow `app2.com`).
@@ -151,7 +153,8 @@ class RealmConfig(msgspec.Struct, omit_defaults=True):
rp_id: str
rp_name: str | None = None
auth_host: str | None = None # this realm's dedicated auth host
origins: list[str] | None = None # subdomain origins AND related origins (§2.A)
origins: list[str] | None = None # allow-list of in-domain sign-in sites
related_origins: list[str] | None = None # cross-domain ROR origins (§2.A)
class Config(msgspec.Struct, omit_defaults=True):
realms: list[RealmConfig] # at least one; first entry is the default realm
@@ -163,11 +166,14 @@ class Config(msgspec.Struct, omit_defaults=True):
- The first entry is the default realm, used only where a default is
genuinely needed (bootstrap reset-link URL, startup box ordering,
master-admin entry point) — never for dispatch.
- **Origin validation**: each configured origin is either in the rp-id
subtree (classic) or an explicit related origin. Related origins are
counted and capped (default 5) and must not collide with another
realm's rp-id/auth-host/related origins. These rules are enforced both
at startup and at admin write time. Origins are never _implicitly_
- **Origin validation** — two separate concerns: `origins` entries must
be within the rp-id domain (an allow-list; unset = the rp-id and all
subdomains may authenticate). `related_origins` entries must be
outside it, are capped (default 5), and must not collide with another
realm's rp-id/auth-host/related origins nor fall inside another
realm's domain. Misfiled entries (cross-domain in `origins`, in-domain
in `related_origins`) are rejected. These rules are enforced both at
startup and at admin write time. Origins are never _implicitly_
cross-domain.
### 3.2 CLI: bootstrap (`paskia init`) vs. serve (`paskia`)
@@ -362,9 +368,9 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
`PATCH/DELETE /auth/api/admin/realms/{rp_id}`. Writes require recent
authentication (5 minutes).
- Create: `rp_id` + optional `rp_name` (defaults to the rp-id),
`auth_host`, `origins`; full §3.1 validation (cap, cross-realm
collisions); registry rebuilt immediately, including the realm's
`Passkey` instance and OIDC provider entry.
`auth_host`, `origins`, `related_origins`; full §3.1 validation (cap,
cross-realm collisions); registry rebuilt immediately, including the
realm's `Passkey` instance and OIDC provider entry.
- Update: same validation against the would-be combined config.
Changing a realm's rp-id itself is **not supported** (it would orphan
every credential stamped with the old rp-id) — delete and recreate
@@ -373,13 +379,16 @@ effective_auth_host(realm) = realm.auth_host or first_configured_auth_host or No
carries the realm's rp-id (re-enroll or delete those credentials
first); cascades nothing else (users/orgs are global).
- The admin UI has a Realms section with a table (rp-id, name,
effective auth host, origin count), per-row edit/delete and an
add-realm dialog. The dialog's connectivity probe fetches
effective auth host, sign-in site and related domain counts),
per-row edit/delete and an add-realm dialog. The dialog edits the
in-domain allow-list and the related domains as two separate lists
with their own explanations. Its connectivity probe fetches
`<origin>/auth/api/settings` and compares the returned rp-id against
the edited realm — a related origin served by this instance answers
with the realm's rp_id. Connectivity/mismatch results are warnings;
only malformed entries and an auth host outside the rp-id domain
block saving.
malformed entries, misfiled entries (cross-domain in the allow-list,
in-domain in related domains), and an auth host outside the rp-id
domain block saving.
- Credential listings: `Credential.rp_id` serializes automatically into
user-info and admin user detail responses; the frontend shows an rp-id
badge **only when `credential.rp_id !== settings.rp_id`** — single-
+3 -3
View File
@@ -79,7 +79,7 @@ test.describe('Multi-realm E2E', () => {
// Add a related origin (unrelated domain) to the localhost realm
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/realms/localhost`, {
headers,
data: { rp_name: '', auth_host: '', origins: ['https://app.example.com'] },
data: { rp_name: '', auth_host: '', origins: [], related_origins: ['https://app.example.com'] },
})
expect(patch.ok()).toBeTruthy()
@@ -89,10 +89,10 @@ test.describe('Multi-realm E2E', () => {
const wkJson = await wk.json()
expect(wkJson.origins).toContain('https://app.example.com')
// Restore: remove origins again so later tests see the pristine state
// Restore: remove related origins again so later tests see the pristine state
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/realms/localhost`, {
headers,
data: { rp_name: '', auth_host: '', origins: [] },
data: { rp_name: '', auth_host: '', origins: [], related_origins: [] },
})
expect(restore.ok()).toBeTruthy()
const after = await page.request.get(`${baseUrl}/.well-known/webauthn`)
+11 -3
View File
@@ -483,6 +483,8 @@ function createRealm() {
auth_host: '',
origins: [],
originValidation: [],
related_origins: [],
relatedValidation: [],
authHostValidation: null,
})
}
@@ -490,6 +492,7 @@ function createRealm() {
function openRealm(realm) {
// Strip https:// scheme from stored origins and auth_host for editing
const origins = (realm.origins || []).map(o => o.replace(/^https:\/\//, ''))
const related = (realm.related_origins || []).map(o => o.replace(/^https:\/\//, ''))
openDialog('realm-edit', {
isNew: false,
rp_id: realm.rp_id,
@@ -497,6 +500,8 @@ function openRealm(realm) {
auth_host: (realm.auth_host || '').replace(/^https:\/\//, ''),
origins,
originValidation: origins.map(() => null),
related_origins: related,
relatedValidation: related.map(() => null),
authHostValidation: null,
})
}
@@ -936,18 +941,21 @@ async function submitDialog() {
} else if (t === 'realm-edit') {
const d = dialog.value.data
const rp_id = d.rp_id?.trim().toLowerCase()
if (!rp_id) throw new Error('RP ID (domain) required')
if (!rp_id) throw new Error('Domain (rp-id) required')
const rp_name = d.rp_name?.trim() || ''
const auth_host = d.auth_host?.trim() || ''
// Origins are stored as-is (hostnames); backend normalizes with https://
const origins = (d.origins || [])
.map(o => o.trim())
.filter(o => o)
const related_origins = (d.related_origins || [])
.map(o => o.trim())
.filter(o => o)
closeDialog()
const req = d.isNew
? apiJson('/auth/api/admin/realms/', { method: 'POST', body: { rp_id, rp_name, auth_host, origins } })
: apiJson(`/auth/api/admin/realms/${rp_id}`, { method: 'PATCH', body: { rp_name, auth_host, origins } })
? apiJson('/auth/api/admin/realms/', { method: 'POST', body: { rp_id, rp_name, auth_host, origins, related_origins } })
: apiJson(`/auth/api/admin/realms/${rp_id}`, { method: 'PATCH', body: { rp_name, auth_host, origins, related_origins } })
req
.then(() => {
authStore.showMessage(`Realm "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500)
+97 -70
View File
@@ -26,39 +26,33 @@ if (props.dialog?.data && props.dialog.type === 'realm-edit') {
if (!('originValidation' in props.dialog.data)) {
props.dialog.data.originValidation = (props.dialog.data.origins || []).map(() => null)
}
if (!('relatedValidation' in props.dialog.data)) {
props.dialog.data.relatedValidation = (props.dialog.data.related_origins || []).map(() => null)
}
}
// Block submit on hard errors: malformed entries, auth-host outside the
// rp-id domain, or validation still in flight. Connectivity and rp-id
// mismatch results are warnings only (e.g. related origins hosted elsewhere,
// or a new realm whose DNS is not routed to this instance yet).
// Block submit on hard errors: malformed entries, entries filed under the
// wrong list, auth-host outside the rp-id domain, or validation still in
// flight. Connectivity and rp-id mismatch results are warnings only (e.g.
// related domains hosted elsewhere, or a new realm whose DNS is not routed
// to this instance yet).
const isValidationInvalid = computed(() => {
if (props.dialog?.type !== 'realm-edit') return false
const d = props.dialog.data
if (d.authHostValidation === 'invalid-domain' || d.authHostValidation === 'validating') return true
if (d.originValidation?.some(v => v === 'invalid' || v === 'validating')) return true
const bad = v => v === 'invalid' || v === 'invalid-domain' || v === 'validating'
if (d.originValidation?.some(bad) || d.relatedValidation?.some(bad)) return true
if (props.dialog.type === 'realm-edit' && d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
return false
})
// Well-known URL that must list any related (non-subdomain) origins.
// Well-known URL that must list any related (cross-domain) origins.
// Browsers always fetch it from the rp-id domain, never the auth host.
const wellKnownUrl = computed(() => {
const host = (props.dialog?.data?.rp_id || '').replace(/^https:\/\//, '').replace(/\/+$/, '')
return host ? `https://${host}/.well-known/webauthn` : ''
})
// Number of related (non-subdomain) origins in the realm dialog
const relatedOriginCount = computed(() => {
const d = props.dialog?.data
if (!d?.origins) return 0
const id = realmRpId.value
return d.origins.filter(o => {
const h = originHostname(o)
return h && id && h !== id && !h.endsWith('.' + id)
}).length
})
// Copy-to-clipboard helper
const authStore = useAuthStore()
function copyText(value, label) {
@@ -67,29 +61,26 @@ function copyText(value, label) {
})
}
function addOrigin() {
// The two origin lists are separate concerns: an in-domain allow-list of
// sign-in sites, and cross-domain related origins (WebAuthn ROR).
const LIST_VALIDATION = { origins: 'originValidation', related_origins: 'relatedValidation' }
function addEntry(field) {
const d = props.dialog?.data
if (!d) return
// Prefill the in-domain list with the rp-id; related domains start blank
d[field].push(field === 'origins' ? realmRpId.value : '')
d[LIST_VALIDATION[field]].push(null)
const i = d[field].length - 1
if (d[field][i]) validateEntry(field, i)
}
function removeEntry(field, i) {
const d = props.dialog?.data
if (d) {
d.origins.push(realmRpId.value)
d.originValidation.push(null)
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 1)
d[field].splice(i, 1)
d[LIST_VALIDATION[field]].splice(i, 1)
}
}
function removeOrigin(i) {
const d = props.dialog?.data
if (d) {
d.origins.splice(i, 1)
d.originValidation.splice(i, 1)
}
}
function stripScheme(val, i) {
const d = props.dialog?.data
if (d) d.origins[i] = val.replace(/^https:\/\//, '').replace(/\/+$/, '')
}
function stripSchemeAuthHost() {
const d = props.dialog?.data
if (d && d.auth_host) d.auth_host = d.auth_host.replace(/^https:\/\//, '').replace(/\/+$/, '')
}
function focusOriginStart(e) {
e.target.setSelectionRange(0, 0)
}
@@ -120,44 +111,57 @@ function isWithinDomain(origin, rpId) {
return hostname === rpId || hostname.endsWith('.' + rpId)
}
async function validateOriginConnectivity(origin, i) {
async function validateEntryConnectivity(field, i) {
const d = props.dialog?.data
if (!d) return
const value = d[field][i]
const vlist = d[LIST_VALIDATION[field]]
d.originValidation[i] = 'validating'
vlist[i] = 'validating'
try {
const cleanOrigin = origin.replace(/\/+$/, '')
const testUrl = cleanOrigin.startsWith('http') ? cleanOrigin : 'https://' + cleanOrigin
const cleanValue = value.replace(/\/+$/, '')
const testUrl = cleanValue.startsWith('http') ? cleanValue : 'https://' + cleanValue
const response = await fetch(testUrl + '/auth/api/settings', {
method: 'GET',
headers: { 'Accept': 'application/json' }
})
if (d.origins[i] !== origin) return // origin changed while validating
if (d[field][i] !== value) return // entry changed while validating
if (response.ok) {
const data = await response.json()
// Valid when the origin is served by this instance for the edited realm
d.originValidation[i] = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
// Valid when the entry is served by this instance for the edited realm
vlist[i] = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
} else {
d.originValidation[i] = 'unreachable'
vlist[i] = 'unreachable'
}
} catch (e) {
if (d.origins[i] === origin) {
d.originValidation[i] = 'unreachable'
if (d[field][i] === value) {
vlist[i] = 'unreachable'
}
}
}
function validateOrigin(origin, i) {
function validateEntry(field, i) {
const d = props.dialog?.data
if (!d) return
const value = d[field][i]
const vlist = d[LIST_VALIDATION[field]]
// Related origins on unrelated domains are allowed (WebAuthn ROR), so any
// well-formed origin passes; connectivity is checked as a hint only.
if (originHostname(origin)) {
validateOriginConnectivity(origin, i)
} else {
d.originValidation[i] = 'invalid'
if (!originHostname(value)) {
vlist[i] = 'invalid'
return
}
// Each entry must be filed under the right list: the in-domain allow-list
// only covers the rp-id domain; related domains must be outside it.
const within = isWithinDomain(value, realmRpId.value)
if (field === 'origins' && !within) {
vlist[i] = 'invalid-domain'
return
}
if (field === 'related_origins' && within) {
vlist[i] = 'invalid-domain'
return
}
validateEntryConnectivity(field, i)
}
async function validateAuthHostConnectivity(authHost) {
@@ -274,10 +278,10 @@ function validateAuthHost() {
</template>
<template v-else-if="dialog.type==='realm-edit'">
<template v-if="dialog.data.isNew">
<label>RP ID (domain)
<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 passkeys are registered for. Cannot be changed later.</p>
<p class="small muted">The domain name this realm's passkeys belong to they work on this domain and its subdomains, and never on other realms. Cannot be changed later.</p>
</template>
<p v-else class="small muted">Realm: <strong>{{ dialog.data.rp_id }}</strong></p>
<label>Display Name (rp-name)
@@ -292,33 +296,56 @@ function validateAuthHost() {
<p v-else-if="dialog.data.authHostValidation === 'unreachable'" class="small muted">Well-formed but unreachable make sure it is routed to this instance.</p>
<p v-else-if="dialog.data.authHostValidation === 'mismatch'" class="small muted">Reachable, but does not serve this realm.</p>
<p v-else class="small muted">Optional. Leave empty to serve authentication on {{ dialog.data.rp_id }} itself.</p>
<div class="origin-label">
Allowed Origins
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin"></button>
Allowed Sign-in Sites
<button type="button" class="icon-btn origin-add-btn" @click="addEntry('origins')" aria-label="Add site" title="Add site"></button>
</div>
<div v-if="dialog.data.origins.length" class="origin-list">
<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(e.target.value, i) }"
@input="e => { dialog.data.origins[i] = e.target.value; validateEntry('origins', i) }"
@focus="focusOriginStart"
class="origin-input"
:class="{ 'input-error': dialog.data.originValidation[i] === 'invalid' }"
:class="{ 'input-error': ['invalid', 'invalid-domain'].includes(dialog.data.originValidation[i]) }"
/>
<button type="button" class="icon-btn delete-icon" @click="removeOrigin(i)" aria-label="Remove origin" title="Remove origin">❌</button>
<button type="button" class="icon-btn delete-icon" @click="removeEntry('origins', i)" aria-label="Remove site" title="Remove site"></button>
</div>
<p v-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some origins are unreachable — make sure they are routed to this instance, or host them externally.</p>
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some origins are reachable but do not serve this realm.</p>
<p v-if="dialog.data.originValidation.some(v => v === 'invalid-domain')" class="small muted">Sites must be on {{ dialog.data.rp_id }} or a subdomain of it use Related Domains below for other domain names.</p>
<p v-else-if="dialog.data.originValidation.some(v => v === 'unreachable')" class="small muted">Some sites are unreachable make sure they are routed to this instance.</p>
<p v-else-if="dialog.data.originValidation.some(v => v === 'mismatch')" class="small muted">Some sites are reachable but do not serve this realm.</p>
</div>
<p v-if="!dialog.data.origins.length" class="small muted">{{ dialog.data.rp_id }} and all subdomains allowed.</p>
<p v-else class="small muted">Only the above sites are allowed to authenticate. Origins on unrelated domains count as related origins (max 5 per realm).</p>
<template v-if="relatedOriginCount > 0">
<p class="small muted">
Related origins require the rp-id domain to list them at
<p v-if="!dialog.data.origins.length" class="small muted">All of <strong>{{ dialog.data.rp_id }}</strong> and its subdomains may sign in (default). Add entries to restrict sign-in to specific sites on this domain.</p>
<p v-else class="small muted">Only the listed sites may sign in with this realm's passkeys.</p>
<div class="origin-label">
Related Domains
<button type="button" class="icon-btn origin-add-btn" @click="addEntry('related_origins')" aria-label="Add related domain" title="Add related domain"></button>
</div>
<div v-if="dialog.data.related_origins.length" class="origin-list">
<div v-for="(_, i) in dialog.data.related_origins" :key="i" class="origin-row">
<input
v-model="dialog.data.related_origins[i]"
@input="validateEntry('related_origins', i)"
placeholder="other-domain.com"
class="origin-input"
:class="{ 'input-error': ['invalid', 'invalid-domain'].includes(dialog.data.relatedValidation[i]) }"
/>
<button type="button" class="icon-btn delete-icon" @click="removeEntry('related_origins', i)" aria-label="Remove related domain" title="Remove related domain">❌</button>
</div>
<p v-if="dialog.data.relatedValidation.some(v => v === 'invalid-domain')" class="small muted">That entry is inside {{ dialog.data.rp_id }} — subdomains are already covered by the realm itself.</p>
<p v-else-if="dialog.data.relatedValidation.some(v => v === 'unreachable')" class="small muted">Some domains are unreachable — make sure they are routed to this instance.</p>
<p v-else-if="dialog.data.relatedValidation.some(v => v === 'mismatch')" class="small muted">Some domains are reachable but do not serve this realm.</p>
</div>
<p class="small muted">
Other domain names that may use this realm's passkeys (WebAuthn Related Origins, max 5). List only domains you trust as much as {{ dialog.data.rp_id }} itself.
<template v-if="dialog.data.related_origins.length">
Browsers verify the list at
<a :href="wellKnownUrl" target="_blank" rel="noopener noreferrer">{{ wellKnownUrl }}</a>
— this instance serves it automatically; copy it there if the main site is hosted elsewhere.
</p>
</template>
served automatically when this instance hosts {{ dialog.data.rp_id }}; otherwise copy the document there.
</template>
</p>
</template>
<template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p>
+6 -4
View File
@@ -430,7 +430,7 @@ defineExpose({ focusFirstElement })
<div class="section-header">
<h2>Realms</h2>
<p class="section-description">
Each realm is one passkey rp-id with its own display name, optional dedicated auth host, and allowed origins (including Related Origin Requests origins on unrelated domains). Changes apply immediately.
Realms are the domain names this instance serves. Each realm has its own passkeys: users sign in per domain, and a passkey registered on one realm never works on another. Add a realm for every domain you operate. To let several <em>different</em> domain names share the same passkeys, open the realm and configure related domains (WebAuthn Related Origins). Changes apply immediately.
</p>
</div>
<div class="section-actions">
@@ -441,13 +441,14 @@ defineExpose({ focusFirstElement })
<tr>
<th>Realm</th>
<th>Auth Host</th>
<th class="center">Origins</th>
<th class="center">Sign-in Sites</th>
<th class="center">Related Domains</th>
<th class="center"></th>
</tr>
</thead>
<tbody>
<tr v-if="!realms || realms.length === 0">
<td colspan="4" class="center muted">No realms configured</td>
<td colspan="5" class="center muted">No realms configured</td>
</tr>
<tr v-for="realm in realms" :key="realm.rp_id">
<td class="perm-name-cell">
@@ -463,7 +464,8 @@ defineExpose({ focusFirstElement })
<span v-if="realm.effective_auth_host">{{ realm.effective_auth_host }}<span v-if="!realm.auth_host" class="muted"> (shared)</span></span>
<span v-else class="muted"></span>
</td>
<td class="center">{{ realm.origins?.length || 0 }}</td>
<td class="center">{{ realm.origins?.length || 'All' }}</td>
<td class="center">{{ realm.related_origins?.length || '—' }}</td>
<td class="center">
<button v-if="!realm.is_default" @click="$emit('deleteRealm', realm)" class="icon-btn delete-icon" aria-label="Delete realm" title="Delete realm"></button>
</td>
+3 -1
View File
@@ -743,9 +743,10 @@ def update_realm(
rp_name: str | None = None,
auth_host: str | None = None,
origins: list[str] | None = None,
related_origins: list[str] | None = None,
ctx: SessionContext | None = None,
) -> None:
"""Update a realm's rp_name, auth_host and origins.
"""Update a realm's rp_name, auth_host, origins and related origins.
The rp-id itself is immutable: credentials are stamped with it, so
changing it would orphan them — delete and recreate the realm instead.
@@ -758,6 +759,7 @@ def update_realm(
realm.rp_name = rp_name
realm.auth_host = auth_host
realm.origins = origins
realm.related_origins = related_origins
def delete_realm(rp_id: str, *, ctx: SessionContext | None = None) -> None:
+6 -4
View File
@@ -623,15 +623,17 @@ class OIDC(msgspec.Struct, dict=True):
class RealmConfig(msgspec.Struct, omit_defaults=True):
"""Configuration for one authentication realm (one WebAuthn rp-id).
A realm is one rp-id with its associated hosts and origins. Origins may
be in the rp-id subtree (classic) or explicit related origins for
WebAuthn Related Origin Requests.
A realm is one rp-id with its associated hosts. ``origins`` restricts
which sites *within* the rp-id domain may authenticate (unset = the
rp-id and all its subdomains); ``related_origins`` lists *other*
domains that may assert this rp-id (WebAuthn Related Origin Requests).
"""
rp_id: str
rp_name: str | None = None
auth_host: str | None = None # This realm's dedicated auth host (URL)
origins: list[str] | None = None # Subdomain origins AND related origins
origins: list[str] | None = None # Allow-list of in-domain sign-in sites
related_origins: list[str] | None = None # Cross-domain ROR origins
class Config(msgspec.Struct, omit_defaults=True):
+42 -17
View File
@@ -1,8 +1,9 @@
"""Realm (rp-id) management API — master admin only.
Realms replace the old single-site server configuration: each realm is one
rp-id with its own rp-name, optional dedicated auth host, and origins
(including Related Origin Requests origins on unrelated domains). All
rp-id with its own rp-name, optional dedicated auth host, an optional
allow-list of in-domain sign-in sites (origins), and optional related
origins on unrelated domains (WebAuthn Related Origin Requests). All
changes are validated cross-realm before being persisted, and the runtime
realm registry is rebuilt after each change so it takes effect immediately.
"""
@@ -37,16 +38,26 @@ def _realm_to_api(realm: realms.Realm, registry: realms.RealmRegistry) -> ApiRea
)
def _normalize_realm_fields(
rp_id: str, auth_host: str | None, origins: list[str] | None
) -> tuple[str | None, list[str] | None]:
"""Normalize and validate auth_host/origins for a realm (raises ValueError)."""
normalized_origins = [
hostutil.normalize_origin(o.strip()) for o in origins or [] if o.strip()
def _normalize_origins(values: list[str] | None) -> list[str] | None:
"""Normalize a list of origin URLs (raises ValueError on malformed)."""
return [
hostutil.normalize_origin(o.strip()) for o in values or [] if o.strip()
] or None
def _normalize_realm_fields(
rp_id: str,
auth_host: str | None,
origins: list[str] | None,
related_origins: list[str] | None,
) -> tuple[str | None, list[str] | None, list[str] | None]:
"""Normalize and validate auth_host/origins for a realm (raises ValueError)."""
if auth_host:
hostutil.validate_auth_host(auth_host, rp_id)
return hostutil.normalize_auth_host_and_origins(auth_host, normalized_origins)
auth_host, origins = hostutil.normalize_auth_host_and_origins(
auth_host, _normalize_origins(origins)
)
return auth_host, origins, _normalize_origins(related_origins)
def _rebuild_registry() -> None:
@@ -80,13 +91,17 @@ async def admin_create_realm(
raise ValueError("rp_id is required")
rp_name = (payload.get("rp_name") or "").strip() or None
auth_host = (payload.get("auth_host") or "").strip() or None
auth_host, origins = _normalize_realm_fields(
rp_id, auth_host, payload.get("origins") or []
auth_host, origins, related_origins = _normalize_realm_fields(
rp_id, auth_host, payload.get("origins"), payload.get("related_origins")
)
config = db.data().config
new_realm = RealmConfig(
rp_id=rp_id, rp_name=rp_name, auth_host=auth_host, origins=origins
rp_id=rp_id,
rp_name=rp_name,
auth_host=auth_host,
origins=origins,
related_origins=related_origins,
)
# Validate the would-be combined configuration before persisting
realms.validate_config(
@@ -105,7 +120,8 @@ async def admin_update_realm(
payload: dict = Body(...),
auth=AUTH_COOKIE,
):
"""Update a realm's rp_name, auth_host and origins (replaced wholesale).
"""Update a realm's rp_name, auth_host, origins and related origins
(lists are replaced wholesale).
The rp-id itself is immutable: credentials are stamped with it.
"""
@@ -120,12 +136,16 @@ async def admin_update_realm(
rp_name = (payload.get("rp_name") or "").strip() or None
auth_host = (payload.get("auth_host") or "").strip() or None
auth_host, origins = _normalize_realm_fields(
rp_id, auth_host, payload.get("origins") or []
auth_host, origins, related_origins = _normalize_realm_fields(
rp_id, auth_host, payload.get("origins"), payload.get("related_origins")
)
updated = RealmConfig(
rp_id=rp_id, rp_name=rp_name, auth_host=auth_host, origins=origins
rp_id=rp_id,
rp_name=rp_name,
auth_host=auth_host,
origins=origins,
related_origins=related_origins,
)
would_be = Config(
realms=[updated if r.rp_id == rp_id else r for r in config.realms],
@@ -134,7 +154,12 @@ async def admin_update_realm(
realms.validate_config(would_be)
db.update_realm(
rp_id, rp_name=rp_name, auth_host=auth_host, origins=origins, ctx=ctx
rp_id,
rp_name=rp_name,
auth_host=auth_host,
origins=origins,
related_origins=related_origins,
ctx=ctx,
)
_rebuild_registry()
return {"status": "ok"}
+3 -3
View File
@@ -130,9 +130,9 @@ async def openid_configuration(request: Request):
async def webauthn_related_origins(request: Request):
"""WebAuthn Related Origin Requests discovery document.
Served on the realm's rp-id site; lists the realm's related
(non-subdomain) origins that may assert this rp-id. 404 when the
realm has no related origins.
Served on the realm's rp-id site; lists the realm's related origins
(other domains) that may assert this rp-id. 404 when the realm has no
related origins.
"""
related = request.state.realm.related_origins
if not related:
+22 -15
View File
@@ -39,6 +39,7 @@ class Realm:
rp_id=config.rp_id,
rp_name=config.rp_name,
origins=config.origins,
related_origins=config.related_origins,
)
@property
@@ -58,13 +59,8 @@ class Realm:
@property
def related_origins(self) -> list[str]:
"""Configured origins outside the rp-id subtree (ROR origins)."""
related = []
for origin in self.config.origins or []:
hostname = hostutil.origin_hostname(origin)
if hostname and not hostutil.is_subdomain(hostname, self.rp_id):
related.append(origin)
return related
"""Configured related (cross-domain) origins for ROR."""
return list(self.config.related_origins or [])
@property
def is_root_mode(self) -> bool:
@@ -185,25 +181,36 @@ def validate_config(
)
auth_hosts[hn] = realm.rp_id
related = 0
for origin in realm.origins or []:
hn = hostutil.origin_hostname(origin)
if not hn:
raise ValueError(f"Invalid origin URL: '{origin}'")
if not hostutil.is_subdomain(hn, realm.rp_id):
raise ValueError(
f"Origin '{origin}' is outside the rp-id domain "
f"'{realm.rp_id}' — configure it as a related origin instead"
)
if len(realm.related_origins or []) > related_origin_cap:
raise ValueError(
f"Realm '{realm.rp_id}' has {len(realm.related_origins or [])} "
f"related origins (maximum {related_origin_cap})"
)
for origin in realm.related_origins or []:
hn = hostutil.origin_hostname(origin)
if not hn:
raise ValueError(f"Invalid related origin URL: '{origin}'")
if hostutil.is_subdomain(hn, realm.rp_id):
continue # Classic subtree origin
related += 1
raise ValueError(
f"Related origin '{origin}' is within the rp-id domain "
f"'{realm.rp_id}' — subdomains need no related origin entry"
)
if hn in related_hosts:
raise ValueError(
f"Related origin host '{hn}' is configured for both "
f"'{related_hosts[hn]}' and '{realm.rp_id}'"
)
related_hosts[hn] = realm.rp_id
if related > related_origin_cap:
raise ValueError(
f"Realm '{realm.rp_id}' has {related} related origins "
f"(maximum {related_origin_cap})"
)
for hn, owner in auth_hosts.items():
if hn in rp_ids:
+33 -10
View File
@@ -46,6 +46,7 @@ class Passkey:
rp_id: str,
rp_name: str | None = None,
origins: list[str] | None = None,
related_origins: list[str] | None = None,
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
):
"""
@@ -54,14 +55,17 @@ class Passkey:
Args:
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: List of allowed origin URLs (e.g. ["https://app.example.com", "https://auth.example.com"]).
Origins may be subdomains of rp_id (classic) or explicit related
origins on unrelated domains (Related Origin Requests).
If not provided, any subdomain of rp_id is allowed.
origins: Allow-list of sign-in site origins within the rp-id domain
(e.g. ["https://app.example.com"]). If not provided, the
rp-id and any subdomain of it may authenticate.
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).
Raises:
ValueError: If rp_id is not a valid domain or an origin is malformed.
ValueError: If rp_id is not a valid domain, an origin is malformed,
an allow-list origin is outside the rp-id domain, or a
related origin is inside it.
"""
self.rp_id = rp_id
hostutil.validate_rp_id(rp_id)
@@ -71,7 +75,23 @@ class Passkey:
# Validate and deduplicate origins into a set for O(1) lookups
for o in origins:
self._validate_origin_url(o)
hostname = hostutil.origin_hostname(o)
if 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)
self.related_origins: set[str] = set()
for o in related_origins or []:
self._validate_origin_url(o)
hostname = hostutil.origin_hostname(o)
if hostutil.is_subdomain(hostname, rp_id):
raise ValueError(
f"Related origin '{o}' is within the rp-id domain '{rp_id}'"
"subdomains need no related origin entry"
)
self.related_origins.add(o)
self.supported_pub_key_algs = supported_pub_key_algs or [
COSEAlgorithmIdentifier.EDDSA,
COSEAlgorithmIdentifier.ECDSA_SHA_256,
@@ -92,8 +112,10 @@ class Passkey:
def validate_origin(self, origin: str) -> str:
"""Validate that origin is allowed and return it.
An origin is valid if its hostname is in the rp-id subtree **or** it
is explicitly listed in the configured origins (related origins).
An in-domain origin (rp-id or subdomain) is valid unless an
allow-list of origins is configured, in which case it must be
listed. An origin outside the rp-id domain is valid only when
explicitly listed as a related origin (Related Origin Requests).
Args:
origin: The origin URL to validate (from WebSocket request header)
@@ -102,12 +124,13 @@ class Passkey:
The validated origin URL
Raises:
ValueError: If origin is neither in the rp-id subtree nor listed
ValueError: If origin is not allowed
"""
self._validate_origin_url(origin)
if self._origin_in_subtree(origin):
return origin
if self.allowed_origins is not None and origin in self.allowed_origins:
if self.allowed_origins is None or origin in self.allowed_origins:
return origin
elif origin in self.related_origins:
return origin
raise ValueError(f"Origin '{origin}' is not allowed for rp_id '{self.rp_id}'")
+2
View File
@@ -102,6 +102,8 @@ def print_startup_config(
lines.append(line(f" Origin: {origin}"))
else:
lines.append(line(f" Origin: {realm.rp_id} and subdomains"))
for origin in sorted(realm.config.related_origins or []):
lines.append(line(f" Related: {origin}"))
lines.append(bottom())
stderr.write("".join(lines))
+26 -3
View File
@@ -1928,7 +1928,8 @@ class TestRealms:
json={
"rp_id": "example.com",
"rp_name": "Example",
"origins": ["https://app.example.com", "https://unrelated-site.com"],
"origins": ["https://app.example.com"],
"related_origins": ["https://unrelated-site.com"],
},
headers=headers,
)
@@ -1983,13 +1984,35 @@ class TestRealms:
# Related origin host may not collide across realms
r = await client.post(
"/auth/api/admin/realms/",
json={"rp_id": "example.com", "origins": ["https://shared-app.com"]},
json={
"rp_id": "example.com",
"related_origins": ["https://shared-app.com"],
},
headers=headers,
)
assert r.status_code == 200
r = await client.post(
"/auth/api/admin/realms/",
json={"rp_id": "other.com", "origins": ["https://shared-app.com"]},
json={"rp_id": "other.com", "related_origins": ["https://shared-app.com"]},
headers=headers,
)
assert r.status_code == 400
# Cross-domain entries are rejected from the in-domain origins list
r = await client.post(
"/auth/api/admin/realms/",
json={"rp_id": "another.com", "origins": ["https://elsewhere.com"]},
headers=headers,
)
assert r.status_code == 400
# In-domain entries are rejected from the related origins list
r = await client.post(
"/auth/api/admin/realms/",
json={
"rp_id": "another.com",
"related_origins": ["https://app.another.com"],
},
headers=headers,
)
assert r.status_code == 400
+89 -5
View File
@@ -28,6 +28,7 @@ from paskia.db.lifecycle import format_log_uuid
from paskia.db.operations import DB
from paskia.db.structs import Client, Config, Credential, RealmConfig
from paskia.fastapi.dispatch import DispatchMiddleware
from paskia.sansio import Passkey
# -------------------------------------------------------------------------
# Registry construction helpers
@@ -45,7 +46,8 @@ ROR_CONFIG = Config(
RealmConfig(
rp_id="company.com",
auth_host="https://auth.company.com",
origins=["https://auth.company.com", "https://app.com"],
origins=["https://auth.company.com"],
related_origins=["https://app.com"],
),
RealmConfig(rp_id="pro.com"),
]
@@ -169,7 +171,7 @@ class TestValidateConfig:
realms=[
RealmConfig(
rp_id="company.com",
origins=[f"https://app{i}.com" for i in range(5)],
related_origins=[f"https://app{i}.com" for i in range(5)],
)
]
)
@@ -180,7 +182,31 @@ class TestValidateConfig:
realms=[
RealmConfig(
rp_id="company.com",
origins=[f"https://app{i}.com" for i in range(6)],
related_origins=[f"https://app{i}.com" for i in range(6)],
)
]
)
)
def test_origin_outside_rp_id_rejected(self):
"""In-domain origins are an allow-list; cross-domain needs related."""
with pytest.raises(ValueError, match="outside the rp-id domain"):
realms.validate_config(
Config(
realms=[
RealmConfig(rp_id="a.com", origins=["https://elsewhere.com"])
]
)
)
def test_related_origin_inside_own_realm_rejected(self):
"""Subdomains of the rp-id are covered already; listing is an error."""
with pytest.raises(ValueError, match="within the rp-id domain"):
realms.validate_config(
Config(
realms=[
RealmConfig(
rp_id="a.com", related_origins=["https://app.a.com"]
)
]
)
@@ -194,7 +220,7 @@ class TestValidateConfig:
RealmConfig(rp_id="a.com", auth_host="https://auth.a.com"),
RealmConfig(
rp_id="b.com",
origins=["https://auth.a.com"],
related_origins=["https://auth.a.com"],
),
]
)
@@ -205,7 +231,9 @@ class TestValidateConfig:
realms.validate_config(
Config(
realms=[
RealmConfig(rp_id="a.com", origins=["https://app.b.com"]),
RealmConfig(
rp_id="a.com", related_origins=["https://app.b.com"]
),
RealmConfig(rp_id="b.com"),
]
)
@@ -223,6 +251,62 @@ class TestValidateConfig:
)
# -------------------------------------------------------------------------
# Origin validation semantics (Passkey)
# -------------------------------------------------------------------------
class TestOriginValidation:
"""In-domain allow-list and related origins are separate concerns."""
def test_default_allows_whole_subtree(self):
p = Passkey(rp_id="example.com")
assert p.validate_origin("https://example.com") == "https://example.com"
assert p.validate_origin("https://app.example.com")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://other.com")
def test_allow_list_restricts_subtree(self):
p = Passkey(rp_id="example.com", origins=["https://app.example.com"])
assert p.validate_origin("https://app.example.com")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://www.example.com")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://example.com")
def test_related_origins_are_additive(self):
p = Passkey(rp_id="example.com", related_origins=["https://app2.com"])
assert p.validate_origin("https://app.example.com") # subtree stays open
assert p.validate_origin("https://app2.com")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://other.com")
def test_related_origins_combined_with_allow_list(self):
p = Passkey(
rp_id="example.com",
origins=["https://app.example.com"],
related_origins=["https://app2.com"],
)
assert p.validate_origin("https://app.example.com")
assert p.validate_origin("https://app2.com")
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://www.example.com")
def test_constructor_rejects_mixed_up_fields(self):
with pytest.raises(ValueError, match="related origin"):
Passkey(rp_id="example.com", origins=["https://app2.com"])
with pytest.raises(ValueError, match="within the rp-id domain"):
Passkey(rp_id="example.com", related_origins=["https://app.example.com"])
def test_realm_wires_both_lists(self):
reg = build_registry(*ROR_CONFIG.realms)
p = reg.get("company.com").passkey
assert p.validate_origin("https://app.com") # related origin
assert p.validate_origin("https://auth.company.com") # allow-listed
with pytest.raises(ValueError, match="not allowed"):
p.validate_origin("https://www.company.com") # not allow-listed
# -------------------------------------------------------------------------
# ASGI dispatch
# -------------------------------------------------------------------------