diff --git a/README.md b/README.md index 47e4aa1..c5a0188 100644 --- a/README.md +++ b/README.md @@ -224,7 +224,7 @@ Set the auth host in the admin panel's Domains section. One Paskia instance can serve several domains (rp-ids) from the same database: users, orgs and permissions are shared, while passkeys are registered per domain. The master admin adds domains in the admin panel's Domains section; no restart is needed. -A domain 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 domain settings, and paskia serves the required `/.well-known/webauthn` declaration on the domain's main site. This is separate from the allowed-sign-in-sites list, which only restricts which subdomains of the domain's own rp-id may authenticate (empty = the rp-id and all subdomains). +A domain can also let *other* domain names use its passkeys via WebAuthn [Related Origin Requests](https://passkeys.dev/docs/advanced/related-origins/) — list them in the domain's allowed origins (they show as related domains), and paskia serves the required `/.well-known/webauthn` declaration on the domain's main site. In-domain entries of the same list restrict which sites of the domain's own name may authenticate (a new domain defaults to `*.{domain}` — the whole domain over https). See [Multi-Site documentation](docs/MultiSite.md) for details. diff --git a/docs/API.md b/docs/API.md index 927e836..822ec0d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -79,7 +79,7 @@ E.g. Org admin cannot see anything of the other orgs that he has no admin access Domain endpoints require the `auth:admin` permission; writes additionally require recent authentication (5 minutes). Changes are validated cross-domain and apply immediately. -`origins` is an object keyed by sign-in sites *within* the domain (bare hosts, `*.` wildcards matching the base domain and subdomains over https only — any scheme and port under localhost — full origins when not https, or the bare `*` as shorthand for a wildcard over the rp-id itself); an empty object means the rp-id and all subdomains may authenticate on any scheme. A value of `true` marks presence; `{"auth_host": true}` additionally marks the entry as the domain's authentication host. `related` is an object keyed by *other* domains that may assert this domain's rp-id (WebAuthn Related Origin Requests, max 5); those are published at `/.well-known/webauthn` on the rp-id host. Entries filed under the wrong map are rejected: cross-domain entries in `origins`, in-domain entries in `related`. +`origins` is a single object keyed by all of the domain's sign-in sites. Entries *within* the rp-id domain are in-domain sites: bare hosts, `*.` wildcards under the rp-id (matching the base domain and subdomains over https only — any scheme and port under localhost), or full origins when not https; the plain `*` wildcard is not accepted. Entries *outside* the rp-id domain are related origins that may assert this domain's rp-id (WebAuthn Related Origin Requests, max 5, no wildcards); those are published at `/.well-known/webauthn` on the rp-id host. An empty object allows nothing of the domain itself. A value of `true` marks presence; `{"auth_host": true}` additionally marks an in-domain entry as the domain's authentication host. ### WebSockets: /auth/ws/* diff --git a/docs/MultiSite.md b/docs/MultiSite.md index 29eb4ff..71b0450 100644 --- a/docs/MultiSite.md +++ b/docs/MultiSite.md @@ -50,22 +50,23 @@ well. ## Allowed origins (sign-in sites) -This list controls which sites may sign in with the domain's passkeys: +This list controls which sites may sign in with the domain's passkeys. +Everything is explicit: an empty list allows nothing of the domain itself +(related origins, below, still work — a domain can in principle run +entirely on related origins). A new domain starts with one entry, +`*.{domain}`, which suits most deployments. -- **Empty list (the default):** the domain and all its subdomains may - sign in, on any scheme. This suits most deployments; the domain dialog - shows the default as a `*` placeholder row. -- **Once you add entries, the list becomes an allow-list** — only listed - sites may sign in: - - `app.company.com` — exactly this host, https only. - - `*.company.com` — the base domain and all its subdomains, https only. - Under `localhost`, wildcards match any scheme and any port, as a - development convenience. - - `http://localhost:8080` — a full origin with scheme, for non-https - exceptions. - - `*` — shorthand for `*.{domain}`: the whole domain over https. +Entry forms: -Entries must be inside the domain. An entry on a different domain name +- `*.company.com` — the domain and all its subdomains, https only. Under + `localhost`, wildcards match any scheme and any port, as a development + convenience. +- `app.company.com` — exactly this host, https only. +- `http://localhost:8080` — a full origin with scheme, for non-https + exceptions. + +Wildcards must stay within the domain, and plain `*` is not accepted — it +would suggest "anything goes". An entry on a different domain name automatically becomes a related origin (🔗) instead — see below. ## The auth host (🔑) @@ -165,7 +166,9 @@ model: aside to `{domain}.paskiadb.converted-bak`. With several legacy databases, the positional argument selects one by name. Legacy wildcard origins convert as-is (https only, except any scheme and port under - `localhost`). + `localhost`); a legacy database without configured origins — where that + meant the whole domain was allowed — gets an explicit `*.{domain}` + entry. - Origins, auth hosts and related origins are no longer environment settings — they live in the database and are managed in the admin panel's Domains section. `PASKIA_AUTH_HOST` remains only as a diff --git a/e2e/tests/50-multidomain.spec.ts b/e2e/tests/50-multidomain.spec.ts index 635de15..be9d8e6 100644 --- a/e2e/tests/50-multidomain.spec.ts +++ b/e2e/tests/50-multidomain.spec.ts @@ -82,12 +82,13 @@ test.describe('Multi-domain E2E', () => { const domains = await list.json() expect(domains.map((r: any) => r.rp_id).sort()).toEqual(['localhost', 'test.localhost']) const localhostDomain = domains.find((r: any) => r.rp_id === 'localhost') - expect(localhostDomain.origins).toEqual({}) + expect(localhostDomain.origins).toEqual({ '*.localhost': true }) - // Add a related origin (unrelated domain) to the localhost domain + // Add a related origin (unrelated domain) to the localhost domain — + // same origins table; classification is derived from the rp-id const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, { headers, - data: { rp_name: '', origins: {}, related: { 'app.example.com': true } }, + data: { rp_name: '', origins: { '*.localhost': true, 'app.example.com': true } }, }) expect(patch.ok()).toBeTruthy() @@ -105,10 +106,10 @@ test.describe('Multi-domain E2E', () => { expect(relResp.ok()).toBeTruthy() expect((await relResp.json()).rp_id).toBe('localhost') - // Restore: remove related origins again so later tests see the pristine state + // Restore: back to the pristine seeded state for later tests const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, { headers, - data: { rp_name: '', origins: {}, related: {} }, + data: { rp_name: '', origins: { '*.localhost': true } }, }) expect(restore.ok()).toBeTruthy() const after = await page.request.get(`${baseUrl}/.well-known/webauthn`) @@ -137,7 +138,7 @@ test.describe('Multi-domain E2E', () => { // *.localhost hostname to loopback, so the auth host is reachable. const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, { headers, - data: { rp_name: '', origins: { [`http://${authHost}`]: { auth_host: true } }, related: {} }, + data: { rp_name: '', origins: { [`http://${authHost}`]: { auth_host: true }, '*.test.localhost': true } }, }) expect(patch.ok()).toBeTruthy() @@ -163,10 +164,11 @@ test.describe('Multi-domain E2E', () => { expect(redir.status()).toBe(307) expect(redir.headers()['location']).toMatch(/^http:\/\/auth\.test\.localhost(:\d+)?\/$/) } finally { - // Restore: no origins, no auth host (later tests expect pristine state) + // Restore: back to the pristine seeded state (later tests sign in on + // test.localhost, and an empty table would allow nothing) const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/test.localhost`, { headers, - data: { rp_name: '', origins: {}, related: {} }, + data: { rp_name: '', origins: { '*.test.localhost': true } }, }) expect(restore.ok()).toBeTruthy() }