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. 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. 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 | | 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 | | 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 | | 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 | | 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 | 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 | | 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. 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/* ### WebSockets: /auth/ws/*
| Path | Used for | Notes | | 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 Model: realm `company.com` with related origin `https://app2.com`. A page
on `app2.com` calls WebAuthn with `rpId: "company.com"`; the passkey is on `app2.com` calls WebAuthn with `rpId: "company.com"`; the passkey is
scoped to `company.com`; `clientDataJSON.origin` is `https://app2.com`, 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 Server side: paskia's `Passkey` passes `expected_origin=<the
pre-validated origin>` and `expected_rp_id=self.rp_id`; the webauthn 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 chooses `rpId` client-side — ceremony options arrive from the server over
the WS. On top of that: the WS. On top of that:
- Origin rule: an origin is valid if it is in the rp-id subtree **or - Origin rule: `origins` and `related_origins` are separate fields.
explicitly listed in the realm's configured origins**. Explicit listing An in-domain origin (rp-id or subdomain) is valid unless the realm's
is the trust boundary. `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 - `GET /.well-known/webauthn` on the canonical rp-id host serves
`{"origins": [...]}` from the realm's configured non-subdomain origins `{"origins": [...]}` from the realm's related origins (404 when there
(404 when there are none). are none).
- Dispatch resolution treats a Host matching a configured related-origin - Dispatch resolution treats a Host matching a configured related-origin
hostname as belonging to that origin's realm (exact match only — hostname as belonging to that origin's realm (exact match only —
`www.app2.com` does not follow `app2.com`). `www.app2.com` does not follow `app2.com`).
@@ -151,7 +153,8 @@ class RealmConfig(msgspec.Struct, omit_defaults=True):
rp_id: str rp_id: str
rp_name: str | None = None rp_name: str | None = None
auth_host: str | None = None # this realm's dedicated auth host 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): class Config(msgspec.Struct, omit_defaults=True):
realms: list[RealmConfig] # at least one; first entry is the default realm 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 - The first entry is the default realm, used only where a default is
genuinely needed (bootstrap reset-link URL, startup box ordering, genuinely needed (bootstrap reset-link URL, startup box ordering,
master-admin entry point) — never for dispatch. master-admin entry point) — never for dispatch.
- **Origin validation**: each configured origin is either in the rp-id - **Origin validation** — two separate concerns: `origins` entries must
subtree (classic) or an explicit related origin. Related origins are be within the rp-id domain (an allow-list; unset = the rp-id and all
counted and capped (default 5) and must not collide with another subdomains may authenticate). `related_origins` entries must be
realm's rp-id/auth-host/related origins. These rules are enforced both outside it, are capped (default 5), and must not collide with another
at startup and at admin write time. Origins are never _implicitly_ 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. cross-domain.
### 3.2 CLI: bootstrap (`paskia init`) vs. serve (`paskia`) ### 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 `PATCH/DELETE /auth/api/admin/realms/{rp_id}`. Writes require recent
authentication (5 minutes). authentication (5 minutes).
- Create: `rp_id` + optional `rp_name` (defaults to the rp-id), - Create: `rp_id` + optional `rp_name` (defaults to the rp-id),
`auth_host`, `origins`; full §3.1 validation (cap, cross-realm `auth_host`, `origins`, `related_origins`; full §3.1 validation (cap,
collisions); registry rebuilt immediately, including the realm's cross-realm collisions); registry rebuilt immediately, including the
`Passkey` instance and OIDC provider entry. realm's `Passkey` instance and OIDC provider entry.
- Update: same validation against the would-be combined config. - Update: same validation against the would-be combined config.
Changing a realm's rp-id itself is **not supported** (it would orphan Changing a realm's rp-id itself is **not supported** (it would orphan
every credential stamped with the old rp-id) — delete and recreate 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 carries the realm's rp-id (re-enroll or delete those credentials
first); cascades nothing else (users/orgs are global). first); cascades nothing else (users/orgs are global).
- The admin UI has a Realms section with a table (rp-id, name, - The admin UI has a Realms section with a table (rp-id, name,
effective auth host, origin count), per-row edit/delete and an effective auth host, sign-in site and related domain counts),
add-realm dialog. The dialog's connectivity probe fetches 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 `<origin>/auth/api/settings` and compares the returned rp-id against
the edited realm — a related origin served by this instance answers the edited realm — a related origin served by this instance answers
with the realm's rp_id. Connectivity/mismatch results are warnings; with the realm's rp_id. Connectivity/mismatch results are warnings;
only malformed entries and an auth host outside the rp-id domain malformed entries, misfiled entries (cross-domain in the allow-list,
block saving. in-domain in related domains), and an auth host outside the rp-id
domain block saving.
- Credential listings: `Credential.rp_id` serializes automatically into - Credential listings: `Credential.rp_id` serializes automatically into
user-info and admin user detail responses; the frontend shows an rp-id user-info and admin user detail responses; the frontend shows an rp-id
badge **only when `credential.rp_id !== settings.rp_id`** — single- 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 // Add a related origin (unrelated domain) to the localhost realm
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/realms/localhost`, { const patch = await page.request.patch(`${baseUrl}/auth/api/admin/realms/localhost`, {
headers, 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() expect(patch.ok()).toBeTruthy()
@@ -89,10 +89,10 @@ test.describe('Multi-realm E2E', () => {
const wkJson = await wk.json() const wkJson = await wk.json()
expect(wkJson.origins).toContain('https://app.example.com') 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`, { const restore = await page.request.patch(`${baseUrl}/auth/api/admin/realms/localhost`, {
headers, headers,
data: { rp_name: '', auth_host: '', origins: [] }, data: { rp_name: '', auth_host: '', origins: [], related_origins: [] },
}) })
expect(restore.ok()).toBeTruthy() expect(restore.ok()).toBeTruthy()
const after = await page.request.get(`${baseUrl}/.well-known/webauthn`) const after = await page.request.get(`${baseUrl}/.well-known/webauthn`)
+11 -3
View File
@@ -483,6 +483,8 @@ function createRealm() {
auth_host: '', auth_host: '',
origins: [], origins: [],
originValidation: [], originValidation: [],
related_origins: [],
relatedValidation: [],
authHostValidation: null, authHostValidation: null,
}) })
} }
@@ -490,6 +492,7 @@ function createRealm() {
function openRealm(realm) { function openRealm(realm) {
// Strip https:// scheme from stored origins and auth_host for editing // Strip https:// scheme from stored origins and auth_host for editing
const origins = (realm.origins || []).map(o => o.replace(/^https:\/\//, '')) const origins = (realm.origins || []).map(o => o.replace(/^https:\/\//, ''))
const related = (realm.related_origins || []).map(o => o.replace(/^https:\/\//, ''))
openDialog('realm-edit', { openDialog('realm-edit', {
isNew: false, isNew: false,
rp_id: realm.rp_id, rp_id: realm.rp_id,
@@ -497,6 +500,8 @@ function openRealm(realm) {
auth_host: (realm.auth_host || '').replace(/^https:\/\//, ''), auth_host: (realm.auth_host || '').replace(/^https:\/\//, ''),
origins, origins,
originValidation: origins.map(() => null), originValidation: origins.map(() => null),
related_origins: related,
relatedValidation: related.map(() => null),
authHostValidation: null, authHostValidation: null,
}) })
} }
@@ -936,18 +941,21 @@ async function submitDialog() {
} else if (t === 'realm-edit') { } else if (t === 'realm-edit') {
const d = dialog.value.data const d = dialog.value.data
const rp_id = d.rp_id?.trim().toLowerCase() 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 rp_name = d.rp_name?.trim() || ''
const auth_host = d.auth_host?.trim() || '' const auth_host = d.auth_host?.trim() || ''
// Origins are stored as-is (hostnames); backend normalizes with https:// // Origins are stored as-is (hostnames); backend normalizes with https://
const origins = (d.origins || []) const origins = (d.origins || [])
.map(o => o.trim()) .map(o => o.trim())
.filter(o => o) .filter(o => o)
const related_origins = (d.related_origins || [])
.map(o => o.trim())
.filter(o => o)
closeDialog() closeDialog()
const req = d.isNew const req = d.isNew
? apiJson('/auth/api/admin/realms/', { method: 'POST', body: { rp_id, 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 } }) : apiJson(`/auth/api/admin/realms/${rp_id}`, { method: 'PATCH', body: { rp_name, auth_host, origins, related_origins } })
req req
.then(() => { .then(() => {
authStore.showMessage(`Realm "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500) 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)) { if (!('originValidation' in props.dialog.data)) {
props.dialog.data.originValidation = (props.dialog.data.origins || []).map(() => null) 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 // Block submit on hard errors: malformed entries, entries filed under the
// rp-id domain, or validation still in flight. Connectivity and rp-id // wrong list, auth-host outside the rp-id domain, or validation still in
// mismatch results are warnings only (e.g. related origins hosted elsewhere, // flight. Connectivity and rp-id mismatch results are warnings only (e.g.
// or a new realm whose DNS is not routed to this instance yet). // related domains hosted elsewhere, or a new realm whose DNS is not routed
// to this instance yet).
const isValidationInvalid = computed(() => { const isValidationInvalid = computed(() => {
if (props.dialog?.type !== 'realm-edit') return false if (props.dialog?.type !== 'realm-edit') return false
const d = props.dialog.data const d = props.dialog.data
if (d.authHostValidation === 'invalid-domain' || d.authHostValidation === 'validating') return true 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 if (props.dialog.type === 'realm-edit' && d.isNew && !isWellFormedDomain(d.rp_id || '')) return true
return false 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. // Browsers always fetch it from the rp-id domain, never the auth host.
const wellKnownUrl = computed(() => { const wellKnownUrl = computed(() => {
const host = (props.dialog?.data?.rp_id || '').replace(/^https:\/\//, '').replace(/\/+$/, '') const host = (props.dialog?.data?.rp_id || '').replace(/^https:\/\//, '').replace(/\/+$/, '')
return host ? `https://${host}/.well-known/webauthn` : '' 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 // Copy-to-clipboard helper
const authStore = useAuthStore() const authStore = useAuthStore()
function copyText(value, label) { 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 const d = props.dialog?.data
if (d) { if (d) {
d.origins.push(realmRpId.value) d[field].splice(i, 1)
d.originValidation.push(null) d[LIST_VALIDATION[field]].splice(i, 1)
validateOrigin(d.origins[d.origins.length - 1], d.origins.length - 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) { function focusOriginStart(e) {
e.target.setSelectionRange(0, 0) e.target.setSelectionRange(0, 0)
} }
@@ -120,44 +111,57 @@ function isWithinDomain(origin, rpId) {
return hostname === rpId || hostname.endsWith('.' + rpId) return hostname === rpId || hostname.endsWith('.' + rpId)
} }
async function validateOriginConnectivity(origin, i) { async function validateEntryConnectivity(field, i) {
const d = props.dialog?.data const d = props.dialog?.data
if (!d) return if (!d) return
const value = d[field][i]
const vlist = d[LIST_VALIDATION[field]]
d.originValidation[i] = 'validating' vlist[i] = 'validating'
try { try {
const cleanOrigin = origin.replace(/\/+$/, '') const cleanValue = value.replace(/\/+$/, '')
const testUrl = cleanOrigin.startsWith('http') ? cleanOrigin : 'https://' + cleanOrigin const testUrl = cleanValue.startsWith('http') ? cleanValue : 'https://' + cleanValue
const response = await fetch(testUrl + '/auth/api/settings', { const response = await fetch(testUrl + '/auth/api/settings', {
method: 'GET', method: 'GET',
headers: { 'Accept': 'application/json' } 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) { if (response.ok) {
const data = await response.json() const data = await response.json()
// Valid when the origin is served by this instance for the edited realm // Valid when the entry is served by this instance for the edited realm
d.originValidation[i] = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch' vlist[i] = (data.rp_id && data.rp_id === realmRpId.value) ? 'valid' : 'mismatch'
} else { } else {
d.originValidation[i] = 'unreachable' vlist[i] = 'unreachable'
} }
} catch (e) { } catch (e) {
if (d.origins[i] === origin) { if (d[field][i] === value) {
d.originValidation[i] = 'unreachable' vlist[i] = 'unreachable'
} }
} }
} }
function validateOrigin(origin, i) { function validateEntry(field, i) {
const d = props.dialog?.data const d = props.dialog?.data
if (!d) return 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 if (!originHostname(value)) {
// well-formed origin passes; connectivity is checked as a hint only. vlist[i] = 'invalid'
if (originHostname(origin)) { return
validateOriginConnectivity(origin, i)
} else {
d.originValidation[i] = 'invalid'
} }
// 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) { async function validateAuthHostConnectivity(authHost) {
@@ -274,10 +278,10 @@ function validateAuthHost() {
</template> </template>
<template v-else-if="dialog.type==='realm-edit'"> <template v-else-if="dialog.type==='realm-edit'">
<template v-if="dialog.data.isNew"> <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 /> <input v-model="dialog.data.rp_id" placeholder="example.com" data-form-type="other" required />
</label> </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> </template>
<p v-else class="small muted">Realm: <strong>{{ dialog.data.rp_id }}</strong></p> <p v-else class="small muted">Realm: <strong>{{ dialog.data.rp_id }}</strong></p>
<label>Display Name (rp-name) <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 === '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-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> <p v-else class="small muted">Optional. Leave empty to serve authentication on {{ dialog.data.rp_id }} itself.</p>
<div class="origin-label"> <div class="origin-label">
Allowed Origins Allowed Sign-in Sites
<button type="button" class="icon-btn origin-add-btn" @click="addOrigin" aria-label="Add origin" title="Add origin"></button> <button type="button" class="icon-btn origin-add-btn" @click="addEntry('origins')" aria-label="Add site" title="Add site"></button>
</div> </div>
<div v-if="dialog.data.origins.length" class="origin-list"> <div v-if="dialog.data.origins.length" class="origin-list">
<div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row"> <div v-for="(_, i) in dialog.data.origins" :key="i" class="origin-row">
<input <input
:value="dialog.data.origins[i]" :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" @focus="focusOriginStart"
class="origin-input" 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> </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-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 === 'mismatch')" class="small muted">Some origins are reachable but do not serve this realm.</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> </div>
<p v-if="!dialog.data.origins.length" class="small muted">{{ dialog.data.rp_id }} and all subdomains allowed.</p> <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 above sites are allowed to authenticate. Origins on unrelated domains count as related origins (max 5 per realm).</p> <p v-else class="small muted">Only the listed sites may sign in with this realm's passkeys.</p>
<template v-if="relatedOriginCount > 0">
<p class="small muted"> <div class="origin-label">
Related origins require the rp-id domain to list them at 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> <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. served automatically when this instance hosts {{ dialog.data.rp_id }}; otherwise copy the document there.
</p> </template>
</template> </p>
</template> </template>
<template v-else-if="dialog.type==='confirm'"> <template v-else-if="dialog.type==='confirm'">
<p>{{ dialog.data.message }}</p> <p>{{ dialog.data.message }}</p>
+6 -4
View File
@@ -430,7 +430,7 @@ defineExpose({ focusFirstElement })
<div class="section-header"> <div class="section-header">
<h2>Realms</h2> <h2>Realms</h2>
<p class="section-description"> <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> </p>
</div> </div>
<div class="section-actions"> <div class="section-actions">
@@ -441,13 +441,14 @@ defineExpose({ focusFirstElement })
<tr> <tr>
<th>Realm</th> <th>Realm</th>
<th>Auth Host</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> <th class="center"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="!realms || realms.length === 0"> <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>
<tr v-for="realm in realms" :key="realm.rp_id"> <tr v-for="realm in realms" :key="realm.rp_id">
<td class="perm-name-cell"> <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-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> <span v-else class="muted"></span>
</td> </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"> <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> <button v-if="!realm.is_default" @click="$emit('deleteRealm', realm)" class="icon-btn delete-icon" aria-label="Delete realm" title="Delete realm"></button>
</td> </td>
+3 -1
View File
@@ -743,9 +743,10 @@ def update_realm(
rp_name: str | None = None, rp_name: str | None = None,
auth_host: str | None = None, auth_host: str | None = None,
origins: list[str] | None = None, origins: list[str] | None = None,
related_origins: list[str] | None = None,
ctx: SessionContext | None = None, ctx: SessionContext | None = 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 The rp-id itself is immutable: credentials are stamped with it, so
changing it would orphan them — delete and recreate the realm instead. changing it would orphan them — delete and recreate the realm instead.
@@ -758,6 +759,7 @@ def update_realm(
realm.rp_name = rp_name realm.rp_name = rp_name
realm.auth_host = auth_host realm.auth_host = auth_host
realm.origins = origins realm.origins = origins
realm.related_origins = related_origins
def delete_realm(rp_id: str, *, ctx: SessionContext | None = None) -> None: 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): class RealmConfig(msgspec.Struct, omit_defaults=True):
"""Configuration for one authentication realm (one WebAuthn rp-id). """Configuration for one authentication realm (one WebAuthn rp-id).
A realm is one rp-id with its associated hosts and origins. Origins may A realm is one rp-id with its associated hosts. ``origins`` restricts
be in the rp-id subtree (classic) or explicit related origins for which sites *within* the rp-id domain may authenticate (unset = the
WebAuthn Related Origin Requests. 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_id: str
rp_name: str | None = None rp_name: str | None = None
auth_host: str | None = None # This realm's dedicated auth host (URL) 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): class Config(msgspec.Struct, omit_defaults=True):
+42 -17
View File
@@ -1,8 +1,9 @@
"""Realm (rp-id) management API — master admin only. """Realm (rp-id) management API — master admin only.
Realms replace the old single-site server configuration: each realm is one 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 rp-id with its own rp-name, optional dedicated auth host, an optional
(including Related Origin Requests origins on unrelated domains). All 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 changes are validated cross-realm before being persisted, and the runtime
realm registry is rebuilt after each change so it takes effect immediately. 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( def _normalize_origins(values: list[str] | None) -> list[str] | None:
rp_id: str, auth_host: str | None, origins: list[str] | None """Normalize a list of origin URLs (raises ValueError on malformed)."""
) -> tuple[str | None, list[str] | None]: return [
"""Normalize and validate auth_host/origins for a realm (raises ValueError).""" hostutil.normalize_origin(o.strip()) for o in values or [] if o.strip()
normalized_origins = [
hostutil.normalize_origin(o.strip()) for o in origins or [] if o.strip()
] or None ] 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: if auth_host:
hostutil.validate_auth_host(auth_host, rp_id) 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: def _rebuild_registry() -> None:
@@ -80,13 +91,17 @@ async def admin_create_realm(
raise ValueError("rp_id is required") raise ValueError("rp_id is required")
rp_name = (payload.get("rp_name") or "").strip() or None rp_name = (payload.get("rp_name") or "").strip() or None
auth_host = (payload.get("auth_host") or "").strip() or None auth_host = (payload.get("auth_host") or "").strip() or None
auth_host, origins = _normalize_realm_fields( auth_host, origins, related_origins = _normalize_realm_fields(
rp_id, auth_host, payload.get("origins") or [] rp_id, auth_host, payload.get("origins"), payload.get("related_origins")
) )
config = db.data().config config = db.data().config
new_realm = RealmConfig( 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 # Validate the would-be combined configuration before persisting
realms.validate_config( realms.validate_config(
@@ -105,7 +120,8 @@ async def admin_update_realm(
payload: dict = Body(...), payload: dict = Body(...),
auth=AUTH_COOKIE, 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. 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 rp_name = (payload.get("rp_name") or "").strip() or None
auth_host = (payload.get("auth_host") or "").strip() or None auth_host = (payload.get("auth_host") or "").strip() or None
auth_host, origins = _normalize_realm_fields( auth_host, origins, related_origins = _normalize_realm_fields(
rp_id, auth_host, payload.get("origins") or [] rp_id, auth_host, payload.get("origins"), payload.get("related_origins")
) )
updated = RealmConfig( 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( would_be = Config(
realms=[updated if r.rp_id == rp_id else r for r in config.realms], 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) realms.validate_config(would_be)
db.update_realm( 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() _rebuild_registry()
return {"status": "ok"} return {"status": "ok"}
+3 -3
View File
@@ -130,9 +130,9 @@ async def openid_configuration(request: Request):
async def webauthn_related_origins(request: Request): async def webauthn_related_origins(request: Request):
"""WebAuthn Related Origin Requests discovery document. """WebAuthn Related Origin Requests discovery document.
Served on the realm's rp-id site; lists the realm's related Served on the realm's rp-id site; lists the realm's related origins
(non-subdomain) origins that may assert this rp-id. 404 when the (other domains) that may assert this rp-id. 404 when the realm has no
realm has no related origins. related origins.
""" """
related = request.state.realm.related_origins related = request.state.realm.related_origins
if not related: if not related:
+22 -15
View File
@@ -39,6 +39,7 @@ class Realm:
rp_id=config.rp_id, rp_id=config.rp_id,
rp_name=config.rp_name, rp_name=config.rp_name,
origins=config.origins, origins=config.origins,
related_origins=config.related_origins,
) )
@property @property
@@ -58,13 +59,8 @@ class Realm:
@property @property
def related_origins(self) -> list[str]: def related_origins(self) -> list[str]:
"""Configured origins outside the rp-id subtree (ROR origins).""" """Configured related (cross-domain) origins for ROR."""
related = [] return list(self.config.related_origins or [])
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
@property @property
def is_root_mode(self) -> bool: def is_root_mode(self) -> bool:
@@ -185,25 +181,36 @@ def validate_config(
) )
auth_hosts[hn] = realm.rp_id auth_hosts[hn] = realm.rp_id
related = 0
for origin in realm.origins or []: for origin in realm.origins or []:
hn = hostutil.origin_hostname(origin) hn = hostutil.origin_hostname(origin)
if not hn: if not hn:
raise ValueError(f"Invalid origin URL: '{origin}'") 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): if hostutil.is_subdomain(hn, realm.rp_id):
continue # Classic subtree origin raise ValueError(
related += 1 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: if hn in related_hosts:
raise ValueError( raise ValueError(
f"Related origin host '{hn}' is configured for both " f"Related origin host '{hn}' is configured for both "
f"'{related_hosts[hn]}' and '{realm.rp_id}'" f"'{related_hosts[hn]}' and '{realm.rp_id}'"
) )
related_hosts[hn] = 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(): for hn, owner in auth_hosts.items():
if hn in rp_ids: if hn in rp_ids:
+33 -10
View File
@@ -46,6 +46,7 @@ class Passkey:
rp_id: str, rp_id: str,
rp_name: str | None = None, rp_name: str | None = None,
origins: list[str] | None = None, origins: list[str] | None = None,
related_origins: list[str] | None = None,
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None, supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
): ):
""" """
@@ -54,14 +55,17 @@ class Passkey:
Args: Args:
rp_id: Your security domain (e.g. "example.com") 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. 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: Allow-list of sign-in site origins within the rp-id domain
Origins may be subdomains of rp_id (classic) or explicit related (e.g. ["https://app.example.com"]). If not provided, the
origins on unrelated domains (Related Origin Requests). rp-id and any subdomain of it may authenticate.
If not provided, any subdomain of rp_id is allowed. 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). supported_pub_key_algs: List of supported COSE algorithms (default is EDDSA, ECDSA_SHA_256, RSASSA_PKCS1_v1_5_SHA_256).
Raises: 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 self.rp_id = rp_id
hostutil.validate_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 # Validate and deduplicate origins into a set for O(1) lookups
for o in origins: for o in origins:
self._validate_origin_url(o) 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.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 [ self.supported_pub_key_algs = supported_pub_key_algs or [
COSEAlgorithmIdentifier.EDDSA, COSEAlgorithmIdentifier.EDDSA,
COSEAlgorithmIdentifier.ECDSA_SHA_256, COSEAlgorithmIdentifier.ECDSA_SHA_256,
@@ -92,8 +112,10 @@ class Passkey:
def validate_origin(self, origin: str) -> str: def validate_origin(self, origin: str) -> str:
"""Validate that origin is allowed and return it. """Validate that origin is allowed and return it.
An origin is valid if its hostname is in the rp-id subtree **or** it An in-domain origin (rp-id or subdomain) is valid unless an
is explicitly listed in the configured origins (related origins). 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: Args:
origin: The origin URL to validate (from WebSocket request header) origin: The origin URL to validate (from WebSocket request header)
@@ -102,12 +124,13 @@ class Passkey:
The validated origin URL The validated origin URL
Raises: Raises:
ValueError: If origin is neither in the rp-id subtree nor listed ValueError: If origin is not allowed
""" """
self._validate_origin_url(origin) self._validate_origin_url(origin)
if self._origin_in_subtree(origin): if self._origin_in_subtree(origin):
return origin if self.allowed_origins is None or origin in self.allowed_origins:
if self.allowed_origins is not None and origin in self.allowed_origins: return origin
elif origin in self.related_origins:
return origin return origin
raise ValueError(f"Origin '{origin}' is not allowed for rp_id '{self.rp_id}'") 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}")) lines.append(line(f" Origin: {origin}"))
else: else:
lines.append(line(f" Origin: {realm.rp_id} and subdomains")) 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()) lines.append(bottom())
stderr.write("".join(lines)) stderr.write("".join(lines))
+26 -3
View File
@@ -1928,7 +1928,8 @@ class TestRealms:
json={ json={
"rp_id": "example.com", "rp_id": "example.com",
"rp_name": "Example", "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, headers=headers,
) )
@@ -1983,13 +1984,35 @@ class TestRealms:
# Related origin host may not collide across realms # Related origin host may not collide across realms
r = await client.post( r = await client.post(
"/auth/api/admin/realms/", "/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, headers=headers,
) )
assert r.status_code == 200 assert r.status_code == 200
r = await client.post( r = await client.post(
"/auth/api/admin/realms/", "/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, headers=headers,
) )
assert r.status_code == 400 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.operations import DB
from paskia.db.structs import Client, Config, Credential, RealmConfig from paskia.db.structs import Client, Config, Credential, RealmConfig
from paskia.fastapi.dispatch import DispatchMiddleware from paskia.fastapi.dispatch import DispatchMiddleware
from paskia.sansio import Passkey
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Registry construction helpers # Registry construction helpers
@@ -45,7 +46,8 @@ ROR_CONFIG = Config(
RealmConfig( RealmConfig(
rp_id="company.com", rp_id="company.com",
auth_host="https://auth.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"), RealmConfig(rp_id="pro.com"),
] ]
@@ -169,7 +171,7 @@ class TestValidateConfig:
realms=[ realms=[
RealmConfig( RealmConfig(
rp_id="company.com", 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=[ realms=[
RealmConfig( RealmConfig(
rp_id="company.com", 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="a.com", auth_host="https://auth.a.com"),
RealmConfig( RealmConfig(
rp_id="b.com", rp_id="b.com",
origins=["https://auth.a.com"], related_origins=["https://auth.a.com"],
), ),
] ]
) )
@@ -205,7 +231,9 @@ class TestValidateConfig:
realms.validate_config( realms.validate_config(
Config( Config(
realms=[ 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"), 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 # ASGI dispatch
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------