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:
@@ -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
@@ -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
@@ -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-
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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}'")
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user