- 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
216 lines
7.5 KiB
TypeScript
216 lines
7.5 KiB
TypeScript
// 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<WebAuthnRegistrationResult> {
|
|
// 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<WebAuthnAuthenticationResult> {
|
|
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'
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|