towerops/assets/js/webauthn.ts
Graham McIntire ab6ecbdfdc
Convert JavaScript to TypeScript with proper type definitions
- 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
2026-01-15 13:29:49 -06:00

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'
})
}
})
}
}