- 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
122 lines
4.3 KiB
TypeScript
122 lines
4.3 KiB
TypeScript
// Discoverable passkey authentication (usernameless login)
|
|
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
|
|
import type { WebAuthnAuthChallengeData, ErrorResponse, AuthSuccessResponse } from './types/webauthn'
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const discoverableBtn = document.getElementById('passkey-discoverable-btn') as HTMLButtonElement | null
|
|
const errorDiv = document.getElementById('passkey-discoverable-error')
|
|
|
|
if (!discoverableBtn || !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 authenticateWithDiscoverablePasskey = async () => {
|
|
try {
|
|
hideError()
|
|
discoverableBtn.disabled = true
|
|
discoverableBtn.textContent = 'Authenticating...'
|
|
|
|
// Fetch challenge from server (no email required)
|
|
const challengeResponse = await fetch('/api/webauthn/authentication/challenge', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': getCsrfToken()
|
|
}
|
|
})
|
|
|
|
if (!challengeResponse.ok) {
|
|
const error: ErrorResponse = await challengeResponse.json()
|
|
showError(error.error || 'Failed to get authentication challenge')
|
|
return
|
|
}
|
|
|
|
const challengeData: WebAuthnAuthChallengeData = await challengeResponse.json()
|
|
|
|
// Convert challenge to ArrayBuffer
|
|
const challenge = base64urlToBuffer(challengeData.challenge)
|
|
|
|
const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
|
|
challenge,
|
|
rpId: challengeData.rpId,
|
|
timeout: challengeData.timeout,
|
|
userVerification: challengeData.userVerification as UserVerificationRequirement
|
|
// No allowCredentials - let the authenticator show all credentials for this RP ID
|
|
}
|
|
|
|
// 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('Discoverable 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).name === 'NotSupportedError') {
|
|
errorMessage = 'Your browser or authenticator does not support passkeys'
|
|
} else if ((error as Error).message) {
|
|
errorMessage = (error as Error).message
|
|
}
|
|
|
|
showError(errorMessage)
|
|
} finally {
|
|
discoverableBtn.disabled = false
|
|
discoverableBtn.innerHTML = '<svg class="h-5 w-5"><use href="#hero-key"></use></svg> Log in with passkey'
|
|
}
|
|
}
|
|
|
|
discoverableBtn.addEventListener('click', authenticateWithDiscoverablePasskey)
|
|
})
|