538 lines
17 KiB
TypeScript
538 lines
17 KiB
TypeScript
import { type Page } from '@playwright/test'
|
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
import { join, dirname } from 'path'
|
|
import { fileURLToPath } from 'url'
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
const stateFile = join(__dirname, '..', '..', 'test-data', 'test-state.json')
|
|
|
|
/**
|
|
* WebSocket helpers for passkey registration and authentication.
|
|
* These functions mirror the frontend's passkey.js but work in a Playwright context.
|
|
*/
|
|
|
|
export interface RegistrationResult {
|
|
user: string
|
|
credential: string
|
|
session_token: string
|
|
message: string
|
|
}
|
|
|
|
export interface AuthenticationResult {
|
|
user: string
|
|
session_token: string
|
|
}
|
|
|
|
export interface SessionContext {
|
|
user: { uuid: string; display_name: string }
|
|
org: { uuid: string; display_name: string }
|
|
role: { uuid: string; display_name: string }
|
|
permissions: string[]
|
|
}
|
|
|
|
export interface UserInfo {
|
|
ctx: SessionContext
|
|
created_at: string
|
|
last_seen: string
|
|
visits: number
|
|
credentials: Array<{
|
|
credential: string
|
|
aaguid: string
|
|
created_at: string
|
|
last_used: string | null
|
|
last_verified: string | null
|
|
sign_count: number
|
|
is_current_session: boolean
|
|
}>
|
|
aaguid_info: Record<string, { name: string; icon_light?: string; icon_dark?: string }>
|
|
sessions: Array<{
|
|
id: string
|
|
credential: string
|
|
host: string
|
|
ip: string
|
|
user_agent: string
|
|
last_renewed: string
|
|
is_current: boolean
|
|
is_current_host: boolean
|
|
}>
|
|
}
|
|
|
|
/**
|
|
* Get the bootstrap reset token from the test state file.
|
|
*/
|
|
export function getBootstrapResetToken(): string | undefined {
|
|
if (existsSync(stateFile)) {
|
|
try {
|
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
|
return state.resetToken
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Get the session cookie name from the test state file.
|
|
*/
|
|
export function getSessionCookieName(): string {
|
|
if (existsSync(stateFile)) {
|
|
try {
|
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
|
return state.sessionCookie || '__Host-auth'
|
|
} catch {
|
|
return '__Host-auth'
|
|
}
|
|
}
|
|
return '__Host-auth'
|
|
}
|
|
|
|
/**
|
|
* Save a session token to the test state file for sharing across test groups.
|
|
*/
|
|
export function saveSessionToken(sessionToken: string): void {
|
|
if (existsSync(stateFile)) {
|
|
try {
|
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
|
state.savedSessionToken = sessionToken
|
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear the saved session token from the test state file.
|
|
* Call this after logout to prevent accidental reuse of invalidated sessions.
|
|
*/
|
|
export function clearSavedSessionToken(): void {
|
|
if (existsSync(stateFile)) {
|
|
try {
|
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
|
delete state.savedSessionToken
|
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a saved session token from the test state file.
|
|
*/
|
|
export function getSavedSessionToken(): string | undefined {
|
|
if (existsSync(stateFile)) {
|
|
try {
|
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
|
return state.savedSessionToken
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Save device tokens to the test state file for use by other tests.
|
|
* These tokens allow tests to register their own passkeys.
|
|
*/
|
|
export function saveDeviceTokens(tokens: string[]): void {
|
|
if (existsSync(stateFile)) {
|
|
try {
|
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
|
state.deviceTokens = tokens
|
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
|
} catch {
|
|
// Ignore errors
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get and consume a device token from the pool.
|
|
* Returns undefined if no tokens are available.
|
|
*/
|
|
export function popDeviceToken(): string | undefined {
|
|
if (existsSync(stateFile)) {
|
|
try {
|
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
|
if (state.deviceTokens && state.deviceTokens.length > 0) {
|
|
const token = state.deviceTokens.pop()
|
|
writeFileSync(stateFile, JSON.stringify(state, null, 2))
|
|
return token
|
|
}
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Get the count of remaining device tokens.
|
|
*/
|
|
export function getDeviceTokenCount(): number {
|
|
if (existsSync(stateFile)) {
|
|
try {
|
|
const state = JSON.parse(readFileSync(stateFile, 'utf-8'))
|
|
return state.deviceTokens?.length || 0
|
|
} catch {
|
|
return 0
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
/**
|
|
* Perform passkey registration via WebSocket.
|
|
* This runs in the browser context using the virtual authenticator.
|
|
*/
|
|
export async function registerPasskey(
|
|
page: Page,
|
|
baseUrl: string,
|
|
options: { resetToken?: string; displayName?: string } = {}
|
|
): Promise<RegistrationResult> {
|
|
// Step 1: Do WebSocket registration + exchange code in browser context
|
|
const wsResult = await page.evaluate(async ({ baseUrl, resetToken, displayName }) => {
|
|
// Build WebSocket URL with query parameters
|
|
let wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/register`
|
|
const params: string[] = []
|
|
if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`)
|
|
if (displayName) params.push(`name=${encodeURIComponent(displayName)}`)
|
|
if (params.length) wsUrl += `?${params.join('&')}`
|
|
|
|
return new Promise<any>((resolve, reject) => {
|
|
const ws = new WebSocket(wsUrl)
|
|
let done = false
|
|
|
|
ws.onopen = () => {
|
|
console.log('WebSocket connected for registration')
|
|
}
|
|
|
|
ws.onmessage = async (event) => {
|
|
const data = JSON.parse(event.data)
|
|
|
|
// Check for error response
|
|
if (data.detail) {
|
|
done = true
|
|
ws.close()
|
|
reject(new Error(data.detail))
|
|
return
|
|
}
|
|
|
|
// Check if this is the final success response (exchange_code flow)
|
|
if (data.exchange_code) {
|
|
done = true
|
|
ws.close()
|
|
// Exchange the code for a session cookie
|
|
try {
|
|
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
|
|
})
|
|
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
|
|
resolve({
|
|
user: data.user,
|
|
credential: data.credential,
|
|
message: data.message || 'Registration successful',
|
|
})
|
|
} catch (err: any) {
|
|
reject(new Error(`Code exchange failed: ${err.message}`))
|
|
}
|
|
return
|
|
}
|
|
|
|
// This should be the registration options from server (wrapped in optionsJSON)
|
|
// Use the native WebAuthn API with the virtual authenticator
|
|
try {
|
|
// Extract options from the optionsJSON wrapper
|
|
const opts = data.optionsJSON
|
|
|
|
// Convert base64url challenge to ArrayBuffer
|
|
const challenge = Uint8Array.from(atob(opts.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
|
|
|
// Build the credential creation options
|
|
const publicKeyCredentialCreationOptions: CredentialCreationOptions = {
|
|
publicKey: {
|
|
challenge: challenge,
|
|
rp: {
|
|
name: opts.rp.name,
|
|
id: opts.rp.id,
|
|
},
|
|
user: {
|
|
id: Uint8Array.from(atob(opts.user.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
|
name: opts.user.name,
|
|
displayName: opts.user.displayName,
|
|
},
|
|
pubKeyCredParams: opts.pubKeyCredParams,
|
|
authenticatorSelection: opts.authenticatorSelection,
|
|
timeout: opts.timeout,
|
|
attestation: opts.attestation,
|
|
excludeCredentials: opts.excludeCredentials?.map((cred: any) => ({
|
|
...cred,
|
|
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
|
})) || [],
|
|
}
|
|
}
|
|
|
|
// Create the credential using native WebAuthn API (virtual authenticator handles it)
|
|
const credential = await navigator.credentials.create(publicKeyCredentialCreationOptions) as PublicKeyCredential
|
|
|
|
if (!credential) {
|
|
throw new Error('Failed to create credential')
|
|
}
|
|
|
|
const response = credential.response as AuthenticatorAttestationResponse
|
|
|
|
// Convert response to JSON format expected by server
|
|
const registrationResponse = {
|
|
id: credential.id,
|
|
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
|
response: {
|
|
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(response.clientDataJSON))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
|
attestationObject: btoa(String.fromCharCode(...new Uint8Array(response.attestationObject))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
|
transports: response.getTransports?.() || [],
|
|
},
|
|
type: credential.type,
|
|
clientExtensionResults: credential.getClientExtensionResults(),
|
|
authenticatorAttachment: (credential as any).authenticatorAttachment,
|
|
}
|
|
|
|
ws.send(JSON.stringify(registrationResponse))
|
|
} catch (error: any) {
|
|
ws.close()
|
|
reject(new Error(error.message || 'Registration failed'))
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
reject(new Error('WebSocket error during registration'))
|
|
}
|
|
|
|
ws.onclose = (event) => {
|
|
if (!done && !event.wasClean && event.code !== 1000) {
|
|
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
|
}
|
|
}
|
|
})
|
|
}, { baseUrl, resetToken: options.resetToken, displayName: options.displayName })
|
|
|
|
// Step 2: Extract the session token from the cookie set by the exchange
|
|
const cookies = await page.context().cookies()
|
|
const cookieName = getSessionCookieName()
|
|
const sessionCookie = cookies.find(c => c.name === cookieName)
|
|
return {
|
|
...wsResult,
|
|
session_token: sessionCookie?.value || '',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform passkey authentication via WebSocket.
|
|
* This runs in the browser context using the virtual authenticator.
|
|
*/
|
|
export async function authenticatePasskey(
|
|
page: Page,
|
|
baseUrl: string
|
|
): Promise<AuthenticationResult> {
|
|
// Step 1: Do WebSocket authentication + exchange code in browser context
|
|
const wsResult = await page.evaluate(async ({ baseUrl }) => {
|
|
const wsUrl = `${baseUrl.replace('http', 'ws')}/auth/ws/authenticate`
|
|
|
|
return new Promise<any>((resolve, reject) => {
|
|
const ws = new WebSocket(wsUrl)
|
|
let done = false
|
|
|
|
ws.onopen = () => {
|
|
console.log('WebSocket connected for authentication')
|
|
}
|
|
|
|
ws.onmessage = async (event) => {
|
|
const data = JSON.parse(event.data)
|
|
|
|
// Check for error response
|
|
if (data.detail) {
|
|
done = true
|
|
ws.close()
|
|
reject(new Error(data.detail))
|
|
return
|
|
}
|
|
|
|
// Check if this is the final success response (exchange_code flow)
|
|
if (data.exchange_code) {
|
|
done = true
|
|
ws.close()
|
|
// Exchange the code for a session cookie
|
|
try {
|
|
const resp = await fetch(`${baseUrl}/auth/api/set-session`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': `Bearer ${data.exchange_code}` },
|
|
})
|
|
if (!resp.ok) throw new Error(`Exchange failed: ${resp.status}`)
|
|
resolve({ user: data.user })
|
|
} catch (err: any) {
|
|
reject(new Error(`Code exchange failed: ${err.message}`))
|
|
}
|
|
return
|
|
}
|
|
|
|
// This should be the authentication options from server (wrapped in optionsJSON)
|
|
try {
|
|
// Extract options from the optionsJSON wrapper
|
|
const opts = data.optionsJSON
|
|
|
|
// Convert base64url challenge to ArrayBuffer
|
|
const challenge = Uint8Array.from(atob(opts.challenge.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0))
|
|
|
|
// Build the credential request options
|
|
const publicKeyCredentialRequestOptions: CredentialRequestOptions = {
|
|
publicKey: {
|
|
challenge: challenge,
|
|
rpId: opts.rpId,
|
|
timeout: opts.timeout,
|
|
userVerification: opts.userVerification,
|
|
allowCredentials: opts.allowCredentials?.map((cred: any) => ({
|
|
type: cred.type,
|
|
id: Uint8Array.from(atob(cred.id.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)),
|
|
transports: cred.transports,
|
|
})) || [],
|
|
}
|
|
}
|
|
|
|
// Get the credential using native WebAuthn API (virtual authenticator handles it)
|
|
const credential = await navigator.credentials.get(publicKeyCredentialRequestOptions) as PublicKeyCredential
|
|
|
|
if (!credential) {
|
|
throw new Error('Failed to get credential')
|
|
}
|
|
|
|
const response = credential.response as AuthenticatorAssertionResponse
|
|
|
|
// Convert response to JSON format expected by server
|
|
const authenticationResponse = {
|
|
id: credential.id,
|
|
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
|
response: {
|
|
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(response.clientDataJSON))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
|
authenticatorData: btoa(String.fromCharCode(...new Uint8Array(response.authenticatorData))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
|
signature: btoa(String.fromCharCode(...new Uint8Array(response.signature))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''),
|
|
userHandle: response.userHandle ? btoa(String.fromCharCode(...new Uint8Array(response.userHandle))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') : null,
|
|
},
|
|
type: credential.type,
|
|
clientExtensionResults: credential.getClientExtensionResults(),
|
|
authenticatorAttachment: (credential as any).authenticatorAttachment,
|
|
}
|
|
|
|
ws.send(JSON.stringify(authenticationResponse))
|
|
} catch (error: any) {
|
|
ws.close()
|
|
reject(new Error(error.message || 'Authentication failed'))
|
|
}
|
|
}
|
|
|
|
ws.onerror = () => {
|
|
reject(new Error('WebSocket error during authentication'))
|
|
}
|
|
|
|
ws.onclose = (event) => {
|
|
if (!done && !event.wasClean && event.code !== 1000) {
|
|
reject(new Error(`WebSocket closed unexpectedly: ${event.code}`))
|
|
}
|
|
}
|
|
})
|
|
}, { baseUrl })
|
|
|
|
// Step 2: Extract the session token from the cookie set by the exchange
|
|
const cookies = await page.context().cookies()
|
|
const cookieName = getSessionCookieName()
|
|
const sessionCookie = cookies.find(c => c.name === cookieName)
|
|
return {
|
|
...wsResult,
|
|
session_token: sessionCookie?.value || '',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a session token via the API.
|
|
*/
|
|
export async function validateSession(
|
|
page: Page,
|
|
baseUrl: string,
|
|
sessionToken: string
|
|
): Promise<{ valid: boolean; ctx: SessionContext; renewed: boolean }> {
|
|
const cookieName = getSessionCookieName()
|
|
const response = await page.request.post(`${baseUrl}/auth/api/validate`, {
|
|
headers: {
|
|
'Cookie': `${cookieName}=${sessionToken}`,
|
|
},
|
|
})
|
|
return await response.json()
|
|
}
|
|
|
|
/**
|
|
* Get user info via the API.
|
|
*/
|
|
export async function getUserInfo(
|
|
page: Page,
|
|
baseUrl: string,
|
|
sessionToken: string
|
|
): Promise<UserInfo> {
|
|
const cookieName = getSessionCookieName()
|
|
const response = await page.request.get(`${baseUrl}/auth/api/user-info`, {
|
|
headers: {
|
|
'Cookie': `${cookieName}=${sessionToken}`,
|
|
},
|
|
})
|
|
return await response.json()
|
|
}
|
|
|
|
/**
|
|
* Logout via the API.
|
|
* If the session being logged out matches the saved session token, clears it.
|
|
*/
|
|
export async function logout(
|
|
page: Page,
|
|
baseUrl: string,
|
|
sessionToken: string
|
|
): Promise<void> {
|
|
const cookieName = getSessionCookieName()
|
|
await page.request.post(`${baseUrl}/auth/api/logout`, {
|
|
headers: {
|
|
'Cookie': `${cookieName}=${sessionToken}`,
|
|
},
|
|
})
|
|
// Clear saved session token if it matches the one being logged out
|
|
const savedToken = getSavedSessionToken()
|
|
if (savedToken === sessionToken) {
|
|
clearSavedSessionToken()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a device link for adding a new credential to an existing user.
|
|
*/
|
|
export async function createDeviceLink(
|
|
page: Page,
|
|
baseUrl: string,
|
|
sessionToken: string
|
|
): Promise<{ url: string; token: string }> {
|
|
const cookieName = getSessionCookieName()
|
|
const response = await page.request.post(`${baseUrl}/auth/api/user/create-link`, {
|
|
headers: {
|
|
'Cookie': `${cookieName}=${sessionToken}`,
|
|
},
|
|
})
|
|
if (!response.ok()) {
|
|
throw new Error(`Failed to create device link: ${response.status()} - ${await response.text()}`)
|
|
}
|
|
const data = await response.json()
|
|
if (!data.url) {
|
|
throw new Error(`No URL in response: ${JSON.stringify(data)}`)
|
|
}
|
|
// Extract token from URL (last path segment)
|
|
const url = new URL(data.url)
|
|
const token = url.pathname.split('/').pop() || ''
|
|
return { url: data.url, token }
|
|
}
|