From 84985501f58357c55e6dc2c374f9039503073c80 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 7 Sep 2026 22:14:42 +0000 Subject: [PATCH] MultiSite: one instance serves authentication across many domains (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Serve multiple domains (RP IDs) from one instance: host-based dispatch, per-domain credentials and sessions, domains managed at runtime in the admin UI — previously one RP per instance - Cross-domain sign-in via Related Origin Requests: per-domain related-origins list with a served .well-known/webauthn document - Explicit per-domain origin lists with shell-glob wildcards (**. for apex + any subdomain depth, *. for one level), editable in the admin UI with validation and self-lockout guards - Per-domain auth hosts: the account/admin UI can live on a different host per domain, no longer confined to subdomains of a single RP - CLI: 'paskia init /.well-known/webauthn`. Paskia serves that document + automatically when it hosts the domain's main site; if the main site is + hosted elsewhere, copy the JSON document shown in the domain dialog and + publish it there. The dialog also checks the published document for + you. +- A related origin shares the domain's security boundary completely — do + not mix trust levels within one domain. +- When several domains could claim a host: a host that _is_ a configured + domain name always serves its own domain; otherwise an explicit related + origin listing wins over merely falling under another domain's name. + +Passkeys never move between domains. If you later consolidate separate +domains onto one, users re-enroll: sign in once via remote authorization +(below), then register a new passkey for the common domain from the +profile page. + +## Signing in across domains + +Users exist once, but need a passkey per domain. Two mechanisms smooth +this over: + +- **Remote authorization:** a user without a passkey for the current + domain can start a login request and approve it from any device already + signed in — on _any_ domain of the instance. The approval screen shows + which site is requesting access. +- **Enroll on the spot:** when the signed-in user has no passkey for the + current domain, the profile page offers "Add Passkey for {domain}", so + everyday sign-in stays local from then on. + +## OIDC with multiple domains + +OIDC clients are shared by the whole instance: register a client once and +it works through every domain. Each domain serves its own discovery URL +(`https:///.well-known/openid-configuration`), listed in the admin +OIDC client view. Have each app pick **one** discovery URL and use it +consistently, so its tokens always validate against the same issuer. + +## Command line + +The admin panel covers all domain management after bootstrap. On the +command line: + +- `paskia init [domain] [name]` — creates the database `paskia.kantadb` + with the first domain. Run again with an existing database to add + another domain (or update a display name). +- `paskia migrate [domain]` — converts a legacy 1.x `{domain}.paskiadb` + database to `paskia.kantadb`; see below. +- `paskia` — serves all configured domains; takes no domain options, only + `--listen` as a per-run override. + +## Upgrading from 1.x + +2.0 intentionally changes the on-disk layout and the domain configuration +model: + +- The database is the single file **`paskia.kantadb`** in the working + directory; user files (avatars) live in **`paskia.data/users/`**. + `paskia migrate` performs the conversion and renames the old database + 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`); 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 + development-server (vite) setting. +- OIDC becomes instance-global: one signing key and one client set, + reachable through every domain's discovery URL (previously each rp-id + had its own). Existing clients keep working through any domain. diff --git a/docs/proxy/apisix.md b/docs/proxy/apisix.md index 5361952..aa4b4d0 100644 --- a/docs/proxy/apisix.md +++ b/docs/proxy/apisix.md @@ -173,4 +173,4 @@ The auth check then always returns 204 (except reauth with `max_age`, which stil - The auth request is `GET` by default. Since the `forward-auth` plugin does not forward the request body unless `request_method` is set to `POST`, the default `GET` is the right choice for Paskia. - Hop-by-hop headers are handled by APISIX when it builds the auth request, so no extra configuration is needed for `Connection`/`Upgrade`. -- If Paskia is running on a different host, replace `localhost:4401` with the Paskia service address. For a dedicated authentication host (`--auth-host`), route `auth.example.com` to Paskia instead of `/auth/`. +- If Paskia is running on a different host, replace `localhost:4401` with the Paskia service address. For a dedicated authentication host (the domain's auth-host setting), route `auth.example.com` to Paskia instead of `/auth/`. diff --git a/docs/proxy/caddy.md b/docs/proxy/caddy.md index bab71c2..4361f2d 100644 --- a/docs/proxy/caddy.md +++ b/docs/proxy/caddy.md @@ -81,7 +81,7 @@ auth.example.com { } ``` -Remember to specify `paskia serve --auth-host auth.example.com` to restrict the authentication services to this domain. +Remember to set the auth host for the domain in the admin panel's Domains section to restrict the authentication services to this domain. Note that we still reserve `/auth/` on each site for logout page and any APIs your application may require, while full user profile and global options are only available on the auth host. diff --git a/docs/proxy/envoy.md b/docs/proxy/envoy.md index e409103..edb900a 100644 --- a/docs/proxy/envoy.md +++ b/docs/proxy/envoy.md @@ -161,7 +161,7 @@ See [perm argument](../api/perm.md) and [max_age argument](../api/max-age.md) fo ## WebSocket support for `/auth/` -If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia cluster and you do not need the `/auth/` bypass above. Otherwise, make sure the `/auth/` route keeps the `Upgrade` and `Connection` headers so passkey WebSocket endpoints work. The default Envoy router handles `Upgrade` headers when the client requests them. +If you use a dedicated authentication host (the domain's auth-host setting), route `auth.example.com` to the Paskia cluster and you do not need the `/auth/` bypass above. Otherwise, make sure the `/auth/` route keeps the `Upgrade` and `Connection` headers so passkey WebSocket endpoints work. The default Envoy router handles `Upgrade` headers when the client requests them. ## Public access diff --git a/docs/proxy/haproxy.md b/docs/proxy/haproxy.md index 39f0156..2b684c5 100644 --- a/docs/proxy/haproxy.md +++ b/docs/proxy/haproxy.md @@ -123,4 +123,4 @@ The `Remote-*` success-headers glob already copies the `Remote-Public` header th - The Lua script strips the request body from the auth subrequest, so Paskia's `/auth/api/forward` will only see the headers. - HAProxy variables are limited to alphanumeric characters, dots, and underscores, but the script already normalizes header names for you (e.g. `Remote-User` becomes `req.auth_response_header.remote_user`). The `Remote-*` glob pattern in the success-headers argument handles this automatically. - The auth backend must be reachable without TLS. If you need TLS to Paskia, run a local TCP forwarder or use HAProxy's Lua HTTP support directly (not covered by this script). -- If you use a dedicated authentication host (`--auth-host`), route `auth.example.com` to the Paskia backend and start Paskia with `--auth-host auth.example.com` instead of exposing `/auth/` on every site. +- If you use a dedicated authentication host (the domain's auth-host setting), route `auth.example.com` to the Paskia backend instead of exposing `/auth/` on every site. diff --git a/docs/proxy/traefik.md b/docs/proxy/traefik.md index 180c0d8..0ec971a 100644 --- a/docs/proxy/traefik.md +++ b/docs/proxy/traefik.md @@ -92,7 +92,7 @@ authResponseHeaders: The `/auth/` router above forwards all authentication UI, API, and WebSocket traffic to Paskia. Because this router does **not** use the `paskia-auth` middleware, users can reach the login page and profile UI without being authenticated first. Traefik handles WebSocket upgrades automatically when the client requests them. -If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and start Paskia with `--auth-host auth.example.com`. +If you are using a dedicated authentication host instead of `/auth/`, create a separate router for `auth.example.com` pointing to the Paskia service and set the domain's auth host in the admin panel's Domains section. ## Adjusting requirements diff --git a/e2e/README.md b/e2e/README.md index e946d2c..9ce8faf 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -75,18 +75,22 @@ Runs tests with Playwright Inspector for step-by-step debugging. ``` e2e/ -├── playwright.config.ts # Playwright configuration +├── playwright.config.js # Playwright configuration ├── package.json ├── tsconfig.json ├── test-data/ # Test database (created at runtime) -│ └── test.sqlite +│ └── paskia.kantadb └── tests/ - ├── global-setup.ts # Creates fresh DB, captures reset token + ├── global-setup.ts # Creates fresh DB (localhost + test.localhost domains), captures reset token ├── global-teardown.ts # Cleanup - ├── passkey.spec.ts # Main E2E tests + ├── 10-passkey.spec.ts # Registration, authentication, session tests + ├── 20-api-auth.spec.ts # API-mode iframe flows (401/403/reauth) + ├── 50-multidomain.spec.ts# Multi-domain dispatch, related origins, auth hosts, remote login + ├── 99-logout.spec.ts # Logout (runs last) └── fixtures/ ├── virtual-authenticator.ts # Virtual authenticator setup - └── passkey-helpers.ts # WebSocket helpers + ├── passkey-helpers.ts # WebSocket helpers + └── remote-auth.ts # Pairing-code remote auth helpers ``` ## What's Tested @@ -107,6 +111,13 @@ e2e/ - Logout (`/auth/api/logout`) - Invalid/missing token rejection +### Multi-Domain +- Host-based domain dispatch (`localhost` vs `test.localhost`, 421 for unknown hosts) +- Related Origin Requests well-known endpoint and admin domain API +- Per-domain auth hosts (UI at the site root) +- WebSocket cross-domain rules +- Cross-domain remote login via pairing code + ## How Virtual Authenticator Works The tests use Chrome DevTools Protocol (CDP) to create a virtual authenticator: @@ -142,12 +153,12 @@ This creates an in-browser authenticator that: ## Limitations 1. **Chromium only**: Virtual authenticator is a Chrome DevTools feature -2. **No cross-origin**: Tests run on localhost; production-like origins need additional setup +2. **Multi-domain via `*.localhost`**: Chrome resolves any `*.localhost` hostname to loopback, which the tests use for cross-domain scenarios; non-localhost domains are exercised only via explicit Host headers (Node-side requests) 3. **Single user per run**: Bootstrap creates one admin user; additional users need admin API ## Debugging Tips -1. **Check test database**: `e2e/test-data/test.sqlite` persists after tests +1. **Check test database**: `e2e/test-data/paskia.kantadb` is removed during teardown; comment out the cleanup in `global-teardown.ts` to inspect it after a run 2. **View server output**: Global setup echoes server bootstrap to console 3. **Use trace viewer**: `npx playwright show-trace` on failure traces diff --git a/e2e/tests/50-multidomain.spec.ts b/e2e/tests/50-multidomain.spec.ts new file mode 100644 index 0000000..60dd23e --- /dev/null +++ b/e2e/tests/50-multidomain.spec.ts @@ -0,0 +1,258 @@ +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, + * including HTTP dispatch to a related hostname + * - Per-domain auth hosts: settings, UI at the site root, /auth/ redirect + * - WebSocket cross-domain rule: rejected unless the Host is the origin + * domain's own auth host + * - 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() + expect(domainSettings.auth_host).toBeNull() + expect(domainSettings.ui_base_path).toBe('/auth/') + + 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') + expect(defaultSettings.auth_host).toBeNull() + expect(defaultSettings.ui_base_path).toBe('/auth/') + + // 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({ '**.localhost': true }) + + // 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: { '**.localhost': true, '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') + + // The related hostname now dispatches to the listing domain (HTTP). + // page.request is Node-side, so target loopback with an explicit Host. + const relResp = await page.request.get(`${baseUrl}/auth/api/settings`, { + headers: { Host: 'app.example.com' }, + }) + expect(relResp.ok()).toBeTruthy() + expect((await relResp.json()).rp_id).toBe('localhost') + + // 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: { '**.localhost': true } }, + }) + expect(restore.ok()).toBeTruthy() + const after = await page.request.get(`${baseUrl}/.well-known/webauthn`) + expect(after.status()).toBe(404) + + // ...and the related hostname is unknown again + const relGone = await page.request.get(`${baseUrl}/auth/api/settings`, { + headers: { Host: 'app.example.com' }, + }) + expect(relGone.status()).toBe(421) + }) + + test('per-domain auth host serves the domain UI at its site root', 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}` } + const authHost = 'auth.test.localhost:4404' + + try { + // Mark an auth host on the test.localhost domain. Chrome resolves any + // *.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 }, '**.test.localhost': true } }, + }) + expect(patch.ok()).toBeTruthy() + + // The auth host dispatches to its domain and reports itself in settings + const settingsResp = await page.goto(`http://${authHost}/auth/api/settings`) + expect(settingsResp?.status()).toBe(200) + const settings = await settingsResp?.json() + expect(settings.rp_id).toBe('test.localhost') + expect(settings.auth_host).toBe(authHost) + expect(settings.own_auth_host).toBe(authHost) + expect(settings.ui_base_path).toBe('/') + + // The UI lives at the site root on the auth host + const rootResp = await page.goto(`http://${authHost}/`) + expect(rootResp?.status()).toBe(200) + expect(rootResp?.headers()['content-type']).toContain('text/html') + + // /auth/ on the auth host redirects to the root + const redir = await page.request.get(`${baseUrl}/auth/`, { + headers: { Host: authHost }, + maxRedirects: 0, + }) + expect(redir.status()).toBe(307) + expect(redir.headers()['location']).toMatch(/^http:\/\/auth\.test\.localhost(:\d+)?\/$/) + } finally { + // 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: { '**.test.localhost': true } }, + }) + expect(restore.ok()).toBeTruthy() + } + + const after = await page.request.get(`${baseUrl}/auth/api/settings`, { + headers: { Host: 'test.localhost:4404' }, + }) + expect((await after.json()).auth_host).toBeNull() + }) + + test('WebSocket cross-domain connections require the origin domain\'s own auth host', async ({ page }) => { + await page.goto(`${domainUrl}/auth/`) + + // Same-domain WebSocket receives authentication options... + const sameDomain: any = await page.evaluate(async () => { + return new Promise((resolve) => { + const ws = new WebSocket(`ws://${location.host}/auth/ws/authenticate`) + const timer = setTimeout(() => { ws.close(); resolve({ message: false }) }, 5000) + ws.onmessage = () => { clearTimeout(timer); ws.close(); resolve({ message: true }) } + ws.onerror = () => { clearTimeout(timer); resolve({ message: false }) } + }) + }) + expect(sameDomain.message).toBe(true) + + // ...but a cross-domain connection is closed pre-accept: test.localhost + // has no auth host of its own, so no other host may serve its logins + const crossDomain: any = await page.evaluate(async (host) => { + return new Promise((resolve) => { + const ws = new WebSocket(`ws://${host}/auth/ws/authenticate`) + let message = false + const timer = setTimeout(() => { ws.close(); resolve({ message, code: -1 }) }, 5000) + ws.onmessage = () => { message = true } + ws.onclose = (event) => { + clearTimeout(timer) + resolve({ message, code: event.code, wasClean: event.wasClean }) + } + }) + }, new URL(baseUrl).host) + expect(crossDomain.message).toBe(false) + expect(crossDomain.wasClean).toBe(false) + }) + + 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() + }) +}) diff --git a/e2e/tests/fixtures/remote-auth.ts b/e2e/tests/fixtures/remote-auth.ts new file mode 100644 index 0000000..dc83b9e --- /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 domain'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 domain. + * Resolves with the "found" message (includes the requesting domain'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..b832620 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,81 @@ 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 domains — + * 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 domains, localhost and test.localhost + console.log(' Bootstrapping database with paskia init...') + const initResult = spawnSync( + 'uv', + [ + 'run', '--project', projectRoot, + 'paskia', 'init', '-l', 'localhost:4404', '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}`) + } + const addResult = spawnSync( + 'uv', + ['run', '--project', projectRoot, 'paskia', 'init', 'test.localhost'], + { cwd: testDataDir, encoding: 'utf-8' } + ) + process.stdout.write(`${addResult.stdout}${addResult.stderr}`) + if (addResult.status !== 0) { + throw new Error(`paskia init test.localhost failed with exit code ${addResult.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 +102,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(` ✅ Domain: ${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 diff --git a/frontend/auth/App.vue b/frontend/auth/App.vue index 3933b54..12a696c 100644 --- a/frontend/auth/App.vue +++ b/frontend/auth/App.vue @@ -37,11 +37,11 @@ function normalizeHost(raw) { } /** - * Host mode is active when an auth_host is configured AND the current host differs from it. + * Host mode is active when an own_auth_host is configured AND the current host differs from it. * In host mode, we show a limited profile view with logout and link to full profile. */ const isHostMode = computed(() => { - const authHost = store.settings?.auth_host + const authHost = store.settings?.own_auth_host if (!authHost) return false const currentHost = normalizeHost(window.location.host) const configuredHost = normalizeHost(authHost) @@ -99,7 +99,7 @@ onMounted(async () => { if (rpName) { // In host mode, show "account summary" style title // Settings are loaded but isHostMode depends on them, so check here - const authHost = store.settings?.auth_host + const authHost = store.settings?.own_auth_host const inHostMode = authHost && normalizeHost(window.location.host) !== normalizeHost(authHost) document.title = inHostMode ? `${rpName} · Account summary` : rpName } diff --git a/frontend/auth/admin/AdminApp.vue b/frontend/auth/admin/AdminApp.vue index 8b1949a..c54a3c5 100644 --- a/frontend/auth/admin/AdminApp.vue +++ b/frontend/auth/admin/AdminApp.vue @@ -17,7 +17,7 @@ import { apiJson, SessionValidator, settings as paskiaSettings } from 'paskia' import { updateThemeFromSession } from '@/utils/theme' import { uuidv7 } from 'uuidv7' import { getDirection } from '@/utils/keynav' -import { goBack } from '@/utils/helpers' +import { originDisplayEntries } from '@/utils/helpers' const info = ref(null) const loading = ref(true) @@ -28,6 +28,7 @@ const error = ref(null) const orgs = ref([]) const permissions = ref([]) const oidcClients = ref([]) +const domains = ref([]) const currentOrgId = ref(null) // UUID of selected org for detail view const currentUserId = ref(null) // UUID for user detail view const currentOidcId = ref(null) // UUID for OIDC client detail view @@ -174,6 +175,16 @@ async function loadAdminData() { oidcClients.value = Object.entries(data.oidc_clients).map(([uuid, c]) => ({ uuid, ...c })) } +// Domain list is master-admin only; callers guard on isMasterAdmin +async function loadDomains() { + try { + domains.value = await apiJson('/auth/api/admin/domains/') + } catch (e) { + console.warn('Unable to load domains', e) + domains.value = [] + } +} + // Helper to get users for a role as sorted array of [uuid, user] function roleUsers(org, roleUuid) { return Object.entries(org.users) @@ -207,6 +218,7 @@ function clearSensitiveState() { orgs.value = [] permissions.value = [] oidcClients.value = [] + domains.value = [] userDetail.value = null editingOidcClient.value = null authenticated.value = false @@ -236,6 +248,7 @@ async function load() { await loadAdminData() // If we get here, user has admin access - now fetch user info for display await loadUserInfo() + if (isMasterAdmin.value) await loadDomains() if (!isMasterAdmin.value && isOrgAdmin.value && orgs.value.length === 1) { if (!window.location.hash || window.location.hash === '#overview') { @@ -452,31 +465,48 @@ function resetOidcSecret(clientId) { if (editingOidcClient.value?.client_id === clientId) { editingOidcClient.value = { ...editingOidcClient.value, client_secret } } - // Also update dialog if open (for backwards compatibility) - if (dialog.value.type === 'oidc-edit' && dialog.value.data?.client_id === clientId) { - dialog.value.data.client_secret = client_secret - } } function createPermissionForClient(clientId) { openDialog('perm-create', { display_name: '', scope: '', domain: clientId }) } -async function openServerConfig() { - try { - const config = await apiJson('/auth/api/admin/server-config') - // Strip https:// scheme from stored origins and auth_host for editing - const origins = (config.origins || []).map(o => o.replace(/^https:\/\//, '')) - const auth_host = (config.auth_host || '').replace(/^https:\/\//, '') - openDialog('server-config', { - rp_name: config.rp_name || '', - auth_host, - origins, - originValidation: origins.map(() => null), - }) - } catch (e) { - authStore.showMessage(e.message || 'Failed to load server configuration', 'error') - } +function createDomain() { + openDialog('domain-edit', { + isNew: true, + rp_id: '', + rp_name: '', + auth_host: '', + origins: [], + originValidation: [], + wellKnownCheck: null, + }) +} + +function openDomain(domain) { + // One combined list for editing, in display order: in-domain sites and + // related origins, classified by hostname against the rp-id. + const rows = originDisplayEntries(domain) + openDialog('domain-edit', { + isNew: false, + rp_id: domain.rp_id, + rp_name: domain.rp_name || '', + auth_host: rows.find(r => r.auth)?.key || '', + origins: rows.map(r => r.key), + originValidation: rows.map(() => null), + wellKnownCheck: null, + }) +} + +function deleteDomain(domain) { + openDialog('confirm', { + message: `Delete domain "${domain.rp_id}"? This is refused while any passkeys remain registered for it.`, + action: async () => { + await apiJson(`/auth/api/admin/domains/${domain.rp_id}`, { method: 'DELETE' }) + authStore.showMessage(`Domain "${domain.rp_id}" deleted.`, 'success', 2500) + await loadDomains() + } + }) } function deleteOidcClient(client) { @@ -875,50 +905,38 @@ async function submitDialog() { authStore.showMessage(e.message || 'Failed to create permission', 'error') }) return // Don't call closeDialog() again - } else if (t === 'oidc-edit') { - const { client_id, client_secret, isNew } = dialog.value.data - const name = dialog.value.data.name?.trim() - const uris = dialog.value.data.redirect_uris?.trim() - if (!name) throw new Error('Client name required') + } else if (t === 'domain-edit') { + const d = dialog.value.data + const rp_id = d.rp_id?.trim().toLowerCase() + if (!rp_id) throw new Error('Domain (rp-id) required') + const rp_name = d.rp_name?.trim() || '' + const auth_host = d.auth_host?.trim().toLowerCase() || '' + // One origins object holds in-domain sites and related origins + // (ROR) together; the server classifies each key against the rp-id. + // Keys are stored lowercased, without the https:// scheme. + const keyOf = o => o.replace(/^https:\/\//i, '').replace(/\/+$/, '').toLowerCase() + const origins = {} + for (const o of d.origins || []) { + const key = keyOf(o.trim()) + if (!key) continue + origins[key] = key === auth_host ? { auth_host: true } : true + } - const redirect_uris = uris ? uris.split('\n').map(u => u.trim()).filter(u => u) : [] - - // Close dialog immediately, then perform async operation closeDialog() - - const req = client_secret - ? sha256Hex(client_secret).then(secret_hash => isNew - ? apiJson('/auth/api/admin/oidc-clients', { method: 'POST', body: { client_id, secret_hash, name, redirect_uris } }) - : apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris, secret_hash } })) - : apiJson(`/auth/api/admin/oidc-clients/${client_id}`, { method: 'PATCH', body: { name, redirect_uris } }) + const req = d.isNew + ? apiJson('/auth/api/admin/domains/', { method: 'POST', body: { rp_id, rp_name, origins } }) + : apiJson(`/auth/api/admin/domains/${rp_id}`, { method: 'PATCH', body: { rp_name, origins } }) req .then(() => { - authStore.showMessage(`OIDC client "${name}" ${isNew ? 'created' : 'updated'}.`, 'success', 2500) - loadAdminData() - }) - .catch(e => { - authStore.showMessage(e.message || `Failed to ${isNew ? 'create' : 'update'} OIDC client`, 'error') - }) - return // Don't call closeDialog() again - } else if (t === 'server-config') { - const rp_name = dialog.value.data.rp_name?.trim() || '' - const auth_host = dialog.value.data.auth_host?.trim() || '' - // Origins are stored as-is (hostnames); backend normalizes with https:// - const origins = dialog.value.data.origins - .map(o => o.trim()) - .filter(o => o) - - closeDialog() - apiJson('/auth/api/admin/server-config', { method: 'PATCH', body: { rp_name, auth_host, origins } }) - .then(() => { - authStore.showMessage('Server configuration updated.', 'success', 2500) + authStore.showMessage(`Domain "${rp_id}" ${d.isNew ? 'created' : 'updated'}.`, 'success', 2500) + loadDomains() // Reload settings to reflect rp_name changes - authStore.loadSettings().then(() => { + authStore.loadSettings(true).then(() => { if (authStore.settings?.rp_name) document.title = authStore.settings.rp_name + ' Admin' }) }) .catch(e => { - authStore.showMessage(e.message || 'Failed to update server configuration', 'error') + authStore.showMessage(e.message || 'Failed to save domain', 'error') }) return // Don't call closeDialog() again } else if (t === 'confirm') { @@ -973,6 +991,8 @@ async function submitDialog() { :orgs="orgs" :permissions="permissions" :oidc-clients="oidcClients" + :domains="domains" + :current-rp-id="authStore.settings?.rp_id || ''" :navigation-disabled="hasActiveModal" :permission-summary="permissionSummary" @create-org="createOrg" @@ -986,7 +1006,9 @@ async function submitDialog() { @create-oidc-client="createOidcClient" @open-oidc-client="openOidcClient" @delete-oidc-client="deleteOidcClient" - @open-server-config="openServerConfig" + @create-domain="createDomain" + @open-domain="openDomain" + @delete-domain="deleteDomain" @navigate-out="handlePanelNavigateOut" /> @@ -1029,6 +1051,7 @@ async function submitDialog() { ref="adminOidcDetailRef" :client="editingOidcClient" :permissions="permissions" + :domains="domains" :is-new="editingOidcClient.isNew" :navigation-disabled="hasActiveModal" @save="handleOidcSave" @@ -1047,11 +1070,8 @@ async function submitDialog() { diff --git a/frontend/src/admin/AdminDialogs.vue b/frontend/src/admin/AdminDialogs.vue index fa588c1..408004f 100644 --- a/frontend/src/admin/AdminDialogs.vue +++ b/frontend/src/admin/AdminDialogs.vue @@ -1,37 +1,73 @@ - diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index 5f8d62d..e7f1fce 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -467,6 +467,61 @@ th { font-size: 0.9rem; } +/* Runtime diagnostics list: 🔸 markers with a hanging indent, so wrapped + lines align with the text rather than under the marker */ +.diag-list { + list-style: none; + margin: 0; + padding: 0; +} + +.diag-list li { + position: relative; + padding-left: 1.4em; +} + +.diag-list li + li { + margin-top: 0.3em; +} + +.diag-list li::before { + content: "🔸"; + position: absolute; + left: 0; +} + +/* Dialog attachment panel (runtime diagnostics, related-origin setup): + docked on the right of the dialog, so appearing or disappearing never + shifts the dialog itself. On narrow screens it hangs below instead. */ +.attach-panel { + position: absolute; + top: calc(100% + 0.5rem); + left: 0; + right: 0; + background: var(--color-dialog); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + padding: var(--space-md) var(--space-lg); + max-height: 30vh; + overflow-y: auto; +} + +.attach-panel > * + * { + margin-top: var(--space-md); +} + +@media (min-width: 1200px) { + .attach-panel { + top: 0; + left: calc(100% + 0.75rem); + right: auto; + /* Never wider than the space right of the centered 500px dialog */ + width: min(340px, calc(50vw - 286px)); + max-height: calc(100vh - 3rem); + } +} + .icon-btn { background: none; border: none; @@ -576,6 +631,12 @@ th { padding: 1.5rem; } +/* Positions attachments (e.g. the diagnostics panel) relative to the + dialog; shrink-wraps the panel in the overlay's flex layout */ +.modal-wrap { + position: relative; +} + .device-dialog, .modal { background: var(--color-dialog); @@ -804,6 +865,28 @@ th { border: 1px solid var(--color-border); } +.badge-domain { + font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace; + font-size: 0.75rem; +} + +.domain-enroll-notice { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + border: 1px solid var(--color-accent); + border-radius: var(--radius-sm); + background: var(--color-surface-subtle); +} + +.domain-enroll-notice p { + margin: 0; +} + .session-meta-info { font-size: 0.75rem; diff --git a/frontend/src/components/CredentialList.vue b/frontend/src/components/CredentialList.vue index 6c06315..f56e3ae 100644 --- a/frontend/src/components/CredentialList.vue +++ b/frontend/src/components/CredentialList.vue @@ -32,6 +32,7 @@

{{ getCredentialAuthName(credential) }}

+ {{ credential.rp_id }} Current Selected Linked @@ -61,8 +62,13 @@