Files
LeoVasanko 84985501f5 MultiSite: one instance serves authentication across many domains (#4)
- 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 <rp-id [rp-name]' initializes or adds a domain to an
  existing database; 'paskia migrate' converts legacy databases

BREAKING CHANGES (v2.0):
- Database schema: config is now per-domain and credentials/sessions carry
  an rp_id — existing databases must be converted with 'paskia migrate'
- Origins are now explicit: main implicitly allowed every subdomain of the
  RP; configure '**.' origins to reproduce that behavior
- CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are
  replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
2026-09-07 22:14:42 +00:00

188 lines
8.0 KiB
TypeScript

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<string> {
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<string>((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<any> {
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<any> {
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 })
}