// WebAuthn utility functions for passkey authentication import { base64urlToBuffer, bufferToBase64url, getCsrfToken } from './webauthn_utils' import type { WebAuthnChallengeData, WebAuthnAuthChallengeData, WebAuthnRegistrationResult, WebAuthnAuthenticationResult, ErrorResponse } from './types/webauthn' import type { LiveViewHook } from './types/liveview' const WebAuthn = { // Registration flow async register(challengeData: WebAuthnChallengeData, credentialName: string): Promise { // Convert challenge and user ID to ArrayBuffer const challenge = base64urlToBuffer(challengeData.challenge) const userId = base64urlToBuffer(challengeData.user.id) const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = { challenge, rp: challengeData.rp, user: { id: userId, name: challengeData.user.name, displayName: challengeData.user.displayName }, pubKeyCredParams: challengeData.pubKeyCredParams as PublicKeyCredentialParameters[], authenticatorSelection: challengeData.authenticatorSelection, timeout: challengeData.timeout, attestation: (challengeData.attestation as AttestationConveyancePreference) || 'none' } try { const credential = await navigator.credentials.create({ publicKey: publicKeyCredentialCreationOptions }) as PublicKeyCredential if (!credential.response || !(credential.response instanceof AuthenticatorAttestationResponse)) { throw new Error('Invalid credential response') } return { name: credentialName, attestation: { id: credential.id, rawId: bufferToBase64url(credential.rawId), type: credential.type, response: { clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), attestationObject: bufferToBase64url(credential.response.attestationObject) } } } } catch (error) { console.error('WebAuthn registration error:', error) throw error } }, // Authentication flow async authenticate(challengeData: WebAuthnAuthChallengeData): Promise { const challenge = base64urlToBuffer(challengeData.challenge) const allowCredentials: PublicKeyCredentialDescriptor[] = challengeData.allowCredentials.map(cred => ({ type: cred.type as PublicKeyCredentialType, id: base64urlToBuffer(cred.id), transports: cred.transports as AuthenticatorTransport[] })) const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = { challenge, rpId: challengeData.rpId, allowCredentials, timeout: challengeData.timeout, userVerification: challengeData.userVerification as UserVerificationRequirement } try { const assertion = await navigator.credentials.get({ publicKey: publicKeyCredentialRequestOptions }) as PublicKeyCredential if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) { throw new Error('Invalid assertion response') } return { assertion: { id: assertion.id, rawId: bufferToBase64url(assertion.rawId), type: assertion.type, response: { clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON), authenticatorData: bufferToBase64url(assertion.response.authenticatorData), signature: bufferToBase64url(assertion.response.signature), userHandle: assertion.response.userHandle ? bufferToBase64url(assertion.response.userHandle) : null } } } } catch (error) { console.error('WebAuthn authentication error:', error) throw error } } } // LiveView Hook for Registration (in credential management page) export const WebAuthnRegister: LiveViewHook = { mounted() { this.handleEvent?.("start_registration", async ({ name }: { name: string }) => { try { // Fetch challenge from server const response = await fetch('/api/webauthn/registration/challenge', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() } }) if (!response.ok) { const error: ErrorResponse = await response.json() this.pushEvent?.("registration_failed", { error: error.error || 'Failed to get registration challenge' }) return } const challengeData: WebAuthnChallengeData = await response.json() // Perform WebAuthn registration const registrationData = await WebAuthn.register(challengeData, name) // Send to server const registerResponse = await fetch('/api/webauthn/register', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() }, body: JSON.stringify(registrationData) }) if (registerResponse.ok) { this.pushEvent?.("registration_success", {}) } else { const error: ErrorResponse = await registerResponse.json() this.pushEvent?.("registration_failed", { error: error.error || 'Failed to register passkey' }) } } catch (error) { console.error('Registration error:', error) this.pushEvent?.("registration_failed", { error: (error as Error).name === 'NotAllowedError' ? 'Registration was cancelled or timed out' : (error as Error).message || 'Failed to register passkey' }) } }) } } // LiveView Hook for Authentication (on login page) export const WebAuthnLogin: LiveViewHook = { mounted() { this.handleEvent?.("start_authentication", async ({ email }: { email: string }) => { try { // Fetch challenge from server const response = await fetch('/api/webauthn/authentication/challenge', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() }, body: JSON.stringify({ email }) }) if (!response.ok) { const error: ErrorResponse = await response.json() this.pushEvent?.("authentication_failed", { error: error.error || 'No passkeys found for this email' }) return } const challengeData: WebAuthnAuthChallengeData = await response.json() // Perform WebAuthn authentication const authData = await WebAuthn.authenticate(challengeData) // Send to server const authResponse = await fetch('/api/webauthn/authenticate', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() }, body: JSON.stringify(authData) }) if (authResponse.ok) { const result = await authResponse.json() // Redirect to the provided URL window.location.href = result.redirect } else { this.pushEvent?.("authentication_failed", { error: 'Authentication failed' }) } } catch (error) { console.error('Authentication error:', error) this.pushEvent?.("authentication_failed", { error: (error as Error).name === 'NotAllowedError' ? 'Authentication was cancelled or timed out' : (error as Error).message || 'Failed to authenticate' }) } }) } }