diff --git a/e2e/tests/50-multirealm.spec.ts b/e2e/tests/50-multirealm.spec.ts new file mode 100644 index 0000000..ce3a20b --- /dev/null +++ b/e2e/tests/50-multirealm.spec.ts @@ -0,0 +1,146 @@ +import { test, expect } from './fixtures/virtual-authenticator' +import { + registerPasskey, + getSessionCookieName, + popDeviceToken, +} from './fixtures/passkey-helpers' +import { + startRemoteAuthRequest, + awaitRemoteAuthSession, + permitRemoteAuth, +} from './fixtures/remote-auth' + +/** + * Multi-realm E2E tests. + * + * The server is bootstrapped with two realms: localhost (default) and + * test.localhost. Chrome resolves any *.localhost hostname to loopback, so + * both realms are reachable over real HTTP from the browser. + * + * Covers: + * - Host-based realm dispatch (settings, 421 for unknown hosts) + * - Related Origin Requests well-known endpoint + admin realm API + * - Cross-realm remote login: a passkey registered on localhost permits a + * session on test.localhost via pairing code + * - The profile enrollment prompt on a realm where the user has no passkey + */ + +test.describe('Multi-realm E2E', () => { + test.describe.configure({ mode: 'serial' }) + + const baseUrl = process.env.BASE_URL || 'http://localhost:4404' + const realmUrl = 'http://test.localhost:4404' + + test('dispatches realms by host header', async ({ page }) => { + // Browser navigation: Chrome maps *.localhost to loopback + const realmResp = await page.goto(`${realmUrl}/auth/api/settings`) + expect(realmResp?.status()).toBe(200) + const realmSettings = await realmResp?.json() + expect(realmSettings.rp_id).toBe('test.localhost') + expect(realmSettings.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 realms and related origins via API', async ({ page, virtualAuthenticator }) => { + // Fresh session via device token (realm 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 realms + const list = await page.request.get(`${baseUrl}/auth/api/admin/realms/`, { headers }) + expect(list.ok()).toBeTruthy() + const realms = await list.json() + expect(realms.map((r: any) => r.rp_id).sort()).toEqual(['localhost', 'test.localhost']) + const localhostRealm = realms.find((r: any) => r.rp_id === 'localhost') + expect(localhostRealm.is_default).toBe(true) + + // 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'] }, + }) + 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 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: [] }, + }) + expect(restore.ok()).toBeTruthy() + const after = await page.request.get(`${baseUrl}/.well-known/webauthn`) + expect(after.status()).toBe(404) + }) + + test('cross-realm 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 realm (no session there) + const reqPage = await page.context().newPage() + await reqPage.goto(`${realmUrl}/auth/`) + + const pairingCode = await startRemoteAuthRequest(reqPage) + expect(pairingCode.split('.')).toHaveLength(3) + + // Approver permits with the localhost passkey; the "found" message names + // the requesting realm + const found = await permitRemoteAuth(page, pairingCode) + expect(found.rp_id).toBe('test.localhost') + + // The requester redeems the exchange code on its own realm 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 realm, + // and the existing localhost passkey carries a realm badge + await reqPage.goto(`${realmUrl}/auth/`) + const notice = reqPage.locator('.realm-enroll-notice') + await expect(notice).toBeVisible({ timeout: 15000 }) + await expect(notice).toContainText('test.localhost') + await expect(reqPage.locator('.badge-realm').first()).toHaveText('localhost') + + await reqPage.close() + }) +}) diff --git a/e2e/tests/fixtures/remote-auth.ts b/e2e/tests/fixtures/remote-auth.ts new file mode 100644 index 0000000..b3f3957 --- /dev/null +++ b/e2e/tests/fixtures/remote-auth.ts @@ -0,0 +1,187 @@ +import { type Page } from '@playwright/test' + +/** + * Remote authentication (pairing code) helpers for E2E tests. + * These drive the /auth/ws/remote-auth/* protocol directly in browser context, + * so requests carry the page origin's cookies and Chrome's host resolution. + */ + +// PBKDF2-SHA512 PoW solver; must match frontend/src/utils/pow.js. +// Passed as source into page.evaluate and instantiated with eval there. +const solvePoWSource = `async (challengeBytes, work) => { + const baseKey = await crypto.subtle.importKey('raw', challengeBytes, 'PBKDF2', false, ['deriveBits']) + const solution = new Uint8Array(8 * work) + const nonce = new Uint32Array(2) + const mask = 0x7FF + for (let i = 0; i < work; i++) { + let result + do { + if (++nonce[0] === 0x100000000) ++nonce[1] + result = new Uint32Array(await crypto.subtle.deriveBits( + { name: 'PBKDF2', salt: nonce, iterations: 128, hash: 'SHA-512' }, baseKey, 32)) + } while (result[0] & mask) + solution.set(new Uint8Array(nonce.buffer), i * 8) + } + return solution +}` + +const b64helpersSource = ` + const b64dec = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)) + const b64enc = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '') +` + +/** + * Start a remote auth request on the given page (the device wanting to log in). + * The page must already be navigated to the requesting realm's origin. + * Keeps the WebSocket open on window.__raWs and collects later messages into + * window.__raMsgs; resolves with the pairing code. + */ +export async function startRemoteAuthRequest(page: Page): Promise { + return page.evaluate(async ({ powSrc, b64src }) => { + const solvePoW = eval(`(${powSrc})`) + const { b64dec, b64enc } = eval(`(() => { ${b64src}; return { b64dec, b64enc } })()`) + const w = window as any + w.__raMsgs = [] + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://${location.host}/auth/ws/remote-auth/request`) + w.__raWs = ws + ws.onmessage = async (event) => { + const data = JSON.parse(event.data) + w.__raMsgs.push(data) + if (typeof data.status === 'number' && data.status >= 400) { + ws.close() + reject(new Error(data.detail || `request failed: ${data.status}`)) + return + } + if (data.pow && !data.pairing_code) { + const solution = await solvePoW(b64dec(data.pow.challenge), data.pow.work) + ws.send(JSON.stringify({ pow: b64enc(solution), action: 'login' })) + return + } + if (data.pairing_code) { + resolve(data.pairing_code) + } + } + ws.onerror = () => reject(new Error('WebSocket error during remote auth request')) + ws.onclose = (event) => { + if (!event.wasClean && event.code !== 1000) reject(new Error(`WebSocket closed unexpectedly: ${event.code}`)) + } + }) + }, { powSrc: solvePoWSource, b64src: b64helpersSource }) +} + +/** + * Wait for the remote auth request on the page to complete, redeem the + * exchange code via set-session, and return the /auth/api/validate response. + */ +export async function awaitRemoteAuthSession(page: Page, timeoutMs = 90000): Promise { + return page.evaluate(async ({ timeoutMs }) => { + const w = window as any + const msgs: any[] = w.__raMsgs + if (!msgs) throw new Error('No remote auth request started on this page') + const exchangeCode: string = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('Timed out waiting for remote auth completion')), timeoutMs) + const iv = setInterval(() => { + const done = msgs.find(m => m.status === 'authenticated' && m.exchange_code) + const failed = msgs.find(m => ['denied', 'expired', 'timeout', 'cancelled'].includes(m.status) || (typeof m.status === 'number' && m.status >= 400)) + if (done) { + clearTimeout(timer); clearInterval(iv) + resolve(done.exchange_code) + } else if (failed) { + clearTimeout(timer); clearInterval(iv) + reject(new Error(failed.detail || `Remote auth ${failed.status}`)) + } + }, 50) + }) + const resp = await fetch('/auth/api/set-session', { + method: 'POST', + headers: { 'Authorization': `Bearer ${exchangeCode}` }, + }) + if (!resp.ok) throw new Error(`set-session failed: ${resp.status}`) + const validate = await fetch('/auth/api/validate', { method: 'POST' }) + if (!validate.ok) throw new Error(`validate failed: ${validate.status}`) + return await validate.json() + }, { timeoutMs }) +} + +/** + * Permit a remote auth request from the given page (the authenticating device). + * The page must be on the approver's origin with a valid session cookie and a + * virtual authenticator holding a credential for that realm. + * Resolves with the "found" message (includes the requesting realm's rp_id). + */ +export async function permitRemoteAuth(page: Page, code: string): Promise { + return page.evaluate(async ({ code, powSrc, b64src }) => { + const solvePoW = eval(`(${powSrc})`) + const { b64dec, b64enc } = eval(`(() => { ${b64src}; return { b64dec, b64enc } })()`) + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://${location.host}/auth/ws/remote-auth/permit`) + let stage = 0 + let foundMsg: any = null + ws.onmessage = async (event) => { + const data = JSON.parse(event.data) + try { + if (typeof data.status === 'number' && data.status >= 400) { + ws.close() + reject(new Error(data.detail || `permit failed: ${data.status}`)) + return + } + if (data.pow && stage === 0) { + const solution = await solvePoW(b64dec(data.pow.challenge), data.pow.work) + stage = 1 + ws.send(JSON.stringify({ code, pow: b64enc(solution) })) + return + } + if (data.status === 'found') { + foundMsg = data + const solution = await solvePoW(b64dec(data.pow.challenge), data.pow.work) + stage = 2 + ws.send(JSON.stringify({ authenticate: true, pow: b64enc(solution) })) + return + } + if (data.optionsJSON) { + const opts = data.optionsJSON + const credential = await navigator.credentials.get({ + publicKey: { + challenge: b64dec(opts.challenge), + rpId: opts.rpId, + timeout: opts.timeout, + userVerification: opts.userVerification, + allowCredentials: opts.allowCredentials?.map((cred: any) => ({ + type: cred.type, + id: b64dec(cred.id), + transports: cred.transports, + })) || [], + } + }) as PublicKeyCredential | null + if (!credential) throw new Error('Failed to get credential') + const response = credential.response as AuthenticatorAssertionResponse + ws.send(JSON.stringify({ + id: credential.id, + rawId: b64enc(credential.rawId), + response: { + clientDataJSON: b64enc(response.clientDataJSON), + authenticatorData: b64enc(response.authenticatorData), + signature: b64enc(response.signature), + userHandle: response.userHandle ? b64enc(response.userHandle) : null, + }, + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults(), + authenticatorAttachment: (credential as any).authenticatorAttachment, + })) + return + } + if (data.status === 'success') { + ws.close() + resolve(foundMsg) + return + } + } catch (err: any) { + ws.close() + reject(new Error(err.message || 'Permit failed')) + } + } + ws.onerror = () => reject(new Error('WebSocket error during permit')) + }) + }, { code, powSrc: solvePoWSource, b64src: b64helpersSource }) +} diff --git a/e2e/tests/global-setup.ts b/e2e/tests/global-setup.ts index fcceb0a..0a140b3 100644 --- a/e2e/tests/global-setup.ts +++ b/e2e/tests/global-setup.ts @@ -1,4 +1,4 @@ -import { execSync, spawn } from 'child_process' +import { execFileSync, spawn, spawnSync } from 'child_process' import { join, dirname } from 'path' import { existsSync, mkdirSync, rmSync, writeFileSync } from 'fs' import { fileURLToPath } from 'url' @@ -20,55 +20,73 @@ interface TestState { /** * Global setup for E2E tests. * - * Uses in-memory SQLite database for fast, isolated tests. - * Captures the bootstrap reset token for initial user registration. + * Bootstraps a fresh combined database (paskia.kantadb) with two realms — + * localhost (default) and test.localhost — then starts the server with the + * test data directory as its working directory. Captures the bootstrap reset + * token from 'paskia init' output for initial user registration. */ export default async function globalSetup() { console.log('\nšŸ”§ Setting up E2E test environment...\n') - // Create test data directory for state file - if (!existsSync(testDataDir)) { - mkdirSync(testDataDir, { recursive: true }) - } + // Start from a clean slate: the test data directory doubles as the server + // working directory, so paskia.kantadb and paskia.data/ are created here + rmSync(testDataDir, { recursive: true, force: true }) + mkdirSync(testDataDir, { recursive: true }) // Build the package first console.log(' Building package with uv build...') - execSync('uv build', { cwd: projectRoot, stdio: 'inherit' }) + execFileSync('uv', ['build'], { cwd: projectRoot, stdio: 'inherit' }) console.log(' āœ… Build complete\n') - console.log(' Starting server with in-memory database...') if (COLLECT_COVERAGE) { console.log(' šŸ“Š Coverage collection enabled for Python backend') } const state: TestState = {} - // Build server command - with or without coverage - const serverArgs = COLLECT_COVERAGE - ? [ - 'run', 'coverage', 'run', '--parallel-mode', - '-m', 'paskia', '-l', 'localhost:4404', - '--rp-id', 'localhost' - ] - : [ - 'run', 'paskia', '-l', 'localhost:4404', - '--rp-id', 'localhost' - ] - - // Use a fresh database file for tests - const testDbFile = join(testDataDir, 'test.paskiadb') - - if (existsSync(testDbFile)) { - console.log(' Removing stale test database...') - rmSync(testDbFile, { force: true, recursive: true }) + // Bootstrap the database: two realms, localhost (default) and test.localhost + console.log(' Bootstrapping database with paskia init...') + const initResult = spawnSync( + 'uv', + [ + 'run', '--project', projectRoot, + 'paskia', 'init', '-l', 'localhost:4404', + '--rp-id', 'localhost,test.localhost', + ], + { cwd: testDataDir, encoding: 'utf-8' } + ) + const initOutput = `${initResult.stdout}${initResult.stderr}` + process.stdout.write(initOutput) + if (initResult.status !== 0) { + throw new Error(`paskia init failed with exit code ${initResult.status}`) } - // Start the server using Node's spawn + // Parse the reset token from init output + // Format: http://localhost:4404/auth/{token} where token is dot-separated words + const match = initOutput.match(/https?:\/\/localhost(?::\d+)?\/auth\/([a-z]+(?:\.[a-z]+)+)/) + if (!match) { + throw new Error('Failed to capture reset token from paskia init output') + } + state.resetToken = match[1] + console.log(`\n āœ… Captured reset token: ${state.resetToken}\n`) + + // Start the server (serve mode: all configuration comes from the database) + console.log(' Starting server...') + const serverArgs = COLLECT_COVERAGE + ? [ + 'run', '--project', projectRoot, + 'coverage', 'run', '--parallel-mode', + '-m', 'paskia', '-l', 'localhost:4404' + ] + : [ + 'run', '--project', projectRoot, + 'paskia', '-l', 'localhost:4404' + ] + const serverProcess = spawn('uv', serverArgs, { - cwd: projectRoot, + cwd: testDataDir, env: { ...process.env, - PASKIA_DB: testDbFile, COVERAGE_FILE: join(projectRoot, '.coverage'), }, stdio: ['ignore', 'pipe', 'pipe'], @@ -76,66 +94,38 @@ export default async function globalSetup() { state.serverPid = serverProcess.pid - // Capture output to find reset token - const resetTokenPromise = new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Timed out waiting for server bootstrap (30s)')) - }, 30000) + serverProcess.stdout?.on('data', (data: Buffer) => process.stdout.write(data)) + serverProcess.stderr?.on('data', (data: Buffer) => process.stderr.write(data)) - let output = '' - - const handleData = (data: Buffer) => { - const text = data.toString() - output += text - process.stdout.write(text) // Echo to console - - // Look for the reset token URL in the output - // Format: https://localhost/auth/{token} or http://localhost:4404/auth/{token} - // where token is word.word.word.word.word (dot separated) - const match = output.match(/https?:\/\/localhost(?::\d+)?\/auth\/([a-z]+(?:\.[a-z]+)+)/) - if (match) { - clearTimeout(timeout) - // Wait a bit for server to fully start - setTimeout(() => resolve(match[1]), 1000) - } + serverProcess.on('exit', (code) => { + if (code !== 0 && code !== null) { + console.error(`Server exited unexpectedly with code ${code}`) } - - serverProcess.stdout?.on('data', handleData) - serverProcess.stderr?.on('data', handleData) - - serverProcess.on('error', (err) => { - clearTimeout(timeout) - reject(err) - }) - - serverProcess.on('exit', (code) => { - if (code !== 0 && code !== null) { - clearTimeout(timeout) - reject(new Error(`Server exited with code ${code}`)) - } - }) }) - try { - state.resetToken = await resetTokenPromise - console.log(`\n āœ… Captured reset token: ${state.resetToken}\n`) - } catch (err) { - console.error('Failed to capture reset token:', err) - serverProcess.kill() - throw err + // Wait for the server to become ready and fetch the session cookie name + console.log(' Waiting for server readiness...') + const deadline = Date.now() + 30000 + let settings: any = null + while (Date.now() < deadline) { + try { + const response = await fetch('http://localhost:4404/auth/api/settings') + if (response.ok) { + settings = await response.json() + break + } + } catch { + // Not up yet + } + await new Promise(r => setTimeout(r, 250)) } - - // Fetch session cookie name from server settings - try { - const response = await fetch('http://localhost:4404/auth/api/settings') - const settings = await response.json() - state.sessionCookie = settings.session_cookie - console.log(` āœ… Session cookie name: ${state.sessionCookie}\n`) - } catch (err) { - console.error('Failed to fetch settings:', err) + if (!settings) { serverProcess.kill() - throw err + throw new Error('Server did not become ready in time (30s)') } + state.sessionCookie = settings.session_cookie + console.log(` āœ… Session cookie name: ${state.sessionCookie}`) + console.log(` āœ… Realm: ${settings.rp_id} (${settings.rp_name})\n`) // Save state for tests writeFileSync(stateFile, JSON.stringify(state, null, 2)) diff --git a/e2e/tests/global-teardown.ts b/e2e/tests/global-teardown.ts index 1570058..25ccdcb 100644 --- a/e2e/tests/global-teardown.ts +++ b/e2e/tests/global-teardown.ts @@ -59,11 +59,13 @@ export default async function globalTeardown() { rmSync(stateFile, { force: true }) } - // Clean up test database - const testDbFile = join(testDataDir, 'test.paskiadb') - if (existsSync(testDbFile)) { - console.log(' Removing test database...') - rmSync(testDbFile, { force: true, recursive: true }) + // Clean up test database and auxiliary data + for (const name of ['paskia.kantadb', 'paskia.data']) { + const p = join(testDataDir, name) + if (existsSync(p)) { + console.log(` Removing ${name}...`) + rmSync(p, { force: true, recursive: true }) + } } // Generate Python coverage report if coverage was collected