Files
paskia/e2e/tests/50-multidomain.spec.ts
T
LeoVasanko f2e6f5784e Rename realms to domains; object-keyed origins/related config format
Finish the realm→domain terminology removal across source, tests, e2e
and docs. The stored config drops all lists: Config.domains is keyed by
rp-id, DomainConfig.origins/related are objects keyed by host (https://
omitted), values True or OriginEntry(auth_host=True). The default/primary
domain concept is gone; ordering is display-time. Tests and e2e updated
to the new API shapes (not run). Database re-migrated from the legacy
backup into the new format.
2026-09-07 02:05:12 +00:00

147 lines
6.1 KiB
TypeScript

import { test, expect } from './fixtures/virtual-authenticator'
import {
registerPasskey,
getSessionCookieName,
popDeviceToken,
} from './fixtures/passkey-helpers'
import {
startRemoteAuthRequest,
awaitRemoteAuthSession,
permitRemoteAuth,
} from './fixtures/remote-auth'
/**
* Multi-domain E2E tests.
*
* The server is bootstrapped with two domains: localhost (default) and
* test.localhost. Chrome resolves any *.localhost hostname to loopback, so
* both domains are reachable over real HTTP from the browser.
*
* Covers:
* - Host-based domain dispatch (settings, 421 for unknown hosts)
* - Related Origin Requests well-known endpoint + admin domain API
* - Cross-domain remote login: a passkey registered on localhost permits a
* session on test.localhost via pairing code
* - The profile enrollment prompt on a domain where the user has no passkey
*/
test.describe('Multi-domain E2E', () => {
test.describe.configure({ mode: 'serial' })
const baseUrl = process.env.BASE_URL || 'http://localhost:4404'
const domainUrl = 'http://test.localhost:4404'
test('dispatches domains by host header', async ({ page }) => {
// Browser navigation: Chrome maps *.localhost to loopback
const domainResp = await page.goto(`${domainUrl}/auth/api/settings`)
expect(domainResp?.status()).toBe(200)
const domainSettings = await domainResp?.json()
expect(domainSettings.rp_id).toBe('test.localhost')
expect(domainSettings.own_auth_host).toBeNull()
const defaultResp = await page.goto(`${baseUrl}/auth/api/settings`)
expect(defaultResp?.status()).toBe(200)
const defaultSettings = await defaultResp?.json()
expect(defaultSettings.rp_id).toBe('localhost')
// Unknown host is rejected with 421 Misdirected Request.
// page.request is Node-side, so target loopback with an explicit Host.
const unknownResp = await page.request.get(`${baseUrl}/auth/api/settings`, {
headers: { Host: 'unknown.example.org' },
})
expect(unknownResp.status()).toBe(421)
})
test('well-known webauthn endpoint reflects related origins', async ({ page }) => {
// No related origins configured initially → 404
const before = await page.request.get(`${baseUrl}/.well-known/webauthn`)
expect(before.status()).toBe(404)
})
test('master admin manages domains and related origins via API', async ({ page, virtualAuthenticator }) => {
// Fresh session via device token (domain writes require recent auth)
const deviceToken = popDeviceToken()
test.skip(!deviceToken, 'No device tokens available')
await page.goto('/auth/')
const reg = await registerPasskey(page, baseUrl, { resetToken: deviceToken })
expect(reg.session_token).toBeTruthy()
const headers = { Cookie: `${getSessionCookieName()}=${reg.session_token}` }
// List domains
const list = await page.request.get(`${baseUrl}/auth/api/admin/domains/`, { headers })
expect(list.ok()).toBeTruthy()
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({})
// Add a related origin (unrelated domain) to the localhost domain
const patch = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
headers,
data: { rp_name: '', origins: {}, related: { 'app.example.com': true } },
})
expect(patch.ok()).toBeTruthy()
// The well-known endpoint now lists it
const wk = await page.request.get(`${baseUrl}/.well-known/webauthn`)
expect(wk.ok()).toBeTruthy()
const wkJson = await wk.json()
expect(wkJson.origins).toContain('https://app.example.com')
// Restore: remove related origins again so later tests see the pristine state
const restore = await page.request.patch(`${baseUrl}/auth/api/admin/domains/localhost`, {
headers,
data: { rp_name: '', origins: {}, related: {} },
})
expect(restore.ok()).toBeTruthy()
const after = await page.request.get(`${baseUrl}/.well-known/webauthn`)
expect(after.status()).toBe(404)
})
test('cross-domain remote login via pairing code', async ({ page, virtualAuthenticator }) => {
// Register a fresh passkey on localhost (this test's virtual authenticator)
const deviceToken = popDeviceToken()
test.skip(!deviceToken, 'No device tokens available')
await page.goto('/auth/')
const reg = await registerPasskey(page, baseUrl, { resetToken: deviceToken })
expect(reg.session_token).toBeTruthy()
// Requester page on the other domain (no session there)
const reqPage = await page.context().newPage()
await reqPage.goto(`${domainUrl}/auth/`)
const pairingCode = await startRemoteAuthRequest(reqPage)
expect(pairingCode.split('.')).toHaveLength(3)
// Approver permits with the localhost passkey; the "found" message names
// the requesting domain
const found = await permitRemoteAuth(page, pairingCode)
expect(found.rp_id).toBe('test.localhost')
// The requester redeems the exchange code on its own domain and the
// session validates there for the same user
const validation = await awaitRemoteAuthSession(reqPage)
expect(validation.ctx.user.uuid).toBe(reg.user)
// The session is recorded with the requesting host
const userInfo = await reqPage.evaluate(async () => {
const resp = await fetch('/auth/api/user-info')
if (!resp.ok) throw new Error(`user-info failed: ${resp.status}`)
return resp.json()
})
const current = Object.values(userInfo.sessions as any[]).find((s: any) => s.is_current) as any
expect(current.host).toContain('test.localhost')
// The profile on test.localhost prompts adding a passkey for this domain,
// and the existing localhost passkey carries a domain badge
await reqPage.goto(`${domainUrl}/auth/`)
const notice = reqPage.locator('.domain-enroll-notice')
await expect(notice).toBeVisible({ timeout: 15000 })
await expect(notice).toContainText('test.localhost')
await expect(reqPage.locator('.badge-domain').first()).toHaveText('localhost')
await reqPage.close()
})
})