- Add comprehensive type definitions for vendor libraries (Chart.js, topbar) - Add TypeScript types for LiveView hooks and WebAuthn interfaces - Convert all JS files to TypeScript with proper typing: - app.js → app.ts - webauthn.js → webauthn.ts - webauthn_utils.js → webauthn_utils.ts - passkey_settings.js → passkey_settings.ts - passkey_login.js → passkey_login.ts - passkey_discoverable.js → passkey_discoverable.ts - Update tsconfig.json with modern ES2022 and strict type checking - Update esbuild config to use app.ts as entry point - All assets compile successfully with esbuild's native TypeScript support - No node_modules required, following Phoenix/Elixir conventions
144 lines
4.8 KiB
TypeScript
144 lines
4.8 KiB
TypeScript
// Passkey authentication for login page
|
|
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
|
|
import type { WebAuthnAuthChallengeData, ErrorResponse, AuthSuccessResponse } from './types/webauthn'
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const passkeyLoginBtn = document.getElementById('passkey-login-btn') as HTMLButtonElement | null
|
|
const emailInput = document.querySelector<HTMLInputElement>('#passkey_login_form input[type="email"]')
|
|
const errorDiv = document.getElementById('passkey-login-error')
|
|
|
|
if (!passkeyLoginBtn || !emailInput || !errorDiv) return
|
|
|
|
const showError = (message: string) => {
|
|
errorDiv.classList.remove('hidden')
|
|
const errorText = errorDiv.querySelector('p')
|
|
if (errorText) {
|
|
errorText.textContent = message
|
|
}
|
|
}
|
|
|
|
const hideError = () => {
|
|
errorDiv.classList.add('hidden')
|
|
const errorText = errorDiv.querySelector('p')
|
|
if (errorText) {
|
|
errorText.textContent = ''
|
|
}
|
|
}
|
|
|
|
const authenticateWithPasskey = async () => {
|
|
const email = emailInput.value.trim()
|
|
|
|
if (!email) {
|
|
showError('Please enter your email address')
|
|
emailInput.focus()
|
|
return
|
|
}
|
|
|
|
try {
|
|
hideError()
|
|
passkeyLoginBtn.disabled = true
|
|
passkeyLoginBtn.textContent = 'Authenticating...'
|
|
|
|
// Fetch challenge from server
|
|
const challengeResponse = await fetch('/api/webauthn/authentication/challenge', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': getCsrfToken()
|
|
},
|
|
body: JSON.stringify({ email })
|
|
})
|
|
|
|
if (!challengeResponse.ok) {
|
|
const error: ErrorResponse = await challengeResponse.json()
|
|
showError(error.error || 'No passkeys found for this email')
|
|
return
|
|
}
|
|
|
|
const challengeData: WebAuthnAuthChallengeData = await challengeResponse.json()
|
|
|
|
// Convert challenge to ArrayBuffer
|
|
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
|
|
}
|
|
|
|
// Get assertion from authenticator
|
|
const assertion = await navigator.credentials.get({
|
|
publicKey: publicKeyCredentialRequestOptions
|
|
}) as PublicKeyCredential
|
|
|
|
if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) {
|
|
throw new Error('Invalid assertion response')
|
|
}
|
|
|
|
const authData = {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: AuthSuccessResponse = await authResponse.json()
|
|
// Redirect to the provided URL
|
|
window.location.href = result.redirect
|
|
} else {
|
|
showError('Authentication failed')
|
|
}
|
|
} catch (error) {
|
|
console.error('Authentication error:', error)
|
|
|
|
let errorMessage = 'Failed to authenticate'
|
|
if ((error as Error).name === 'NotAllowedError') {
|
|
errorMessage = 'Authentication was cancelled or timed out'
|
|
} else if ((error as Error).message) {
|
|
errorMessage = (error as Error).message
|
|
}
|
|
|
|
showError(errorMessage)
|
|
} finally {
|
|
passkeyLoginBtn.disabled = false
|
|
passkeyLoginBtn.innerHTML = '<svg class="h-5 w-5"><use href="#hero-key"></use></svg> Log in with passkey'
|
|
}
|
|
}
|
|
|
|
passkeyLoginBtn.addEventListener('click', authenticateWithPasskey)
|
|
|
|
// Allow pressing Enter in email field to trigger passkey login
|
|
emailInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
authenticateWithPasskey()
|
|
}
|
|
})
|
|
})
|