diff --git a/README.md b/README.md index 0d37990..f389d4f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/API.md b/docs/API.md index 3c6aac0..ce485a6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 | diff --git a/docs/MultiSite.md b/docs/MultiSite.md index 65f29c1..af7bc37 100644 --- a/docs/MultiSite.md +++ b/docs/MultiSite.md @@ -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=` 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 `/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- diff --git a/e2e/tests/50-multirealm.spec.ts b/e2e/tests/50-multirealm.spec.ts index ce3a20b..ef277a8 100644 --- a/e2e/tests/50-multirealm.spec.ts +++ b/e2e/tests/50-multirealm.spec.ts @@ -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`) diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index f0fc8f0..fad002f 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -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) diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index b3658bb..5b7edbe 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -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() {