/** * Passkeys.js - Frontend WebAuthn/Passkey Management * * This module handles passkey (WebAuthn) registration, authentication, and management. * Passkeys provide phishing-resistant, passwordless authentication using biometrics * or hardware security keys (Touch ID, Face ID, Windows Hello, YubiKey, etc.) */ const Passkeys = { /** * Check if WebAuthn is supported in the browser * @returns {boolean} */ isSupported() { return window.PublicKeyCredential !== undefined && navigator.credentials !== undefined; }, /** * Show browser not supported error */ showNotSupportedError() { showNotification('error', 'Browser Not Supported', 'Your browser does not support passkeys. Please use a modern browser like Chrome, Safari, Edge, or Firefox.'); }, /** * Register a new passkey */ async registerPasskey() { if (!this.isSupported()) { this.showNotSupportedError(); return; } try { // Prompt for device name const deviceName = await this.promptDeviceName(); if (!deviceName) {return;} // User cancelled const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken'); if (!token) { showNotification('error', 'Authentication Required', 'Please log in first'); return; } // Get registration options from server showNotification('info', 'Starting Registration', 'Requesting registration options...'); const optionsResponse = await fetch('/api/auth/passkey/register/options', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); if (!optionsResponse.ok) { const error = await optionsResponse.json(); throw new Error(error.error || 'Failed to get registration options'); } const { options } = await optionsResponse.json(); // Convert options for WebAuthn API const publicKeyOptions = this.convertRegistrationOptions(options); // Prompt user for biometric/security key showNotification('info', 'Authenticate', 'Please authenticate with your device (Touch ID, Face ID, Windows Hello, or security key)...'); let credential; try { credential = await navigator.credentials.create({ publicKey: publicKeyOptions }); } catch (err) { if (err.name === 'NotAllowedError') { showNotification('warning', 'Cancelled', 'Registration was cancelled'); } else if (err.name === 'InvalidStateError') { showNotification('error', 'Already Registered', 'This authenticator is already registered'); } else { throw err; } return; } if (!credential) { showNotification('error', 'Registration Failed', 'No credential was created'); return; } // Send credential to server for verification const verificationResponse = await fetch('/api/auth/passkey/register/verify', { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ response: this.encodeCredential(credential), deviceName: deviceName }) }); if (!verificationResponse.ok) { const error = await verificationResponse.json(); throw new Error(error.error || 'Failed to verify registration'); } const result = await verificationResponse.json(); showNotification('success', 'Passkey Registered', `${deviceName} has been successfully registered!`); // Reload passkey list if (typeof loadPasskeyStatus === 'function') { loadPasskeyStatus(); } if (typeof listPasskeys === 'function') { listPasskeys(); } } catch (error) { console.error('Passkey registration error:', error); showNotification('error', 'Registration Error', error.message); } }, /** * Prompt user for device name * @returns {Promise} */ async promptDeviceName() { return new Promise((resolve) => { const modal = document.createElement('div'); modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); const input = modal.querySelector('#passkey-device-name'); const continueBtn = modal.querySelector('#continue-registration-btn'); // Auto-suggest device name input.value = this.suggestDeviceName(); input.select(); const handleContinue = () => { const value = input.value.trim(); if (value) { modal.remove(); resolve(value); } else { input.style.borderColor = 'var(--danger)'; showNotification('warning', 'Name Required', 'Please enter a device name'); } }; continueBtn.addEventListener('click', handleContinue); input.addEventListener('keypress', (e) => { if (e.key === 'Enter') { e.preventDefault(); handleContinue(); } }); // Handle modal close modal.addEventListener('click', (e) => { if (e.target === modal) { modal.remove(); resolve(null); } }); }); }, /** * Suggest a device name based on user agent * @returns {string} */ suggestDeviceName() { const ua = navigator.userAgent; // Mobile devices if (/iPhone/.test(ua)) {return 'iPhone';} if (/iPad/.test(ua)) {return 'iPad';} if (/Android/.test(ua)) { if (/Mobile/.test(ua)) {return 'Android Phone';} return 'Android Tablet'; } // Desktop OS if (/Macintosh/.test(ua)) {return 'Mac';} if (/Windows/.test(ua)) {return 'Windows PC';} if (/Linux/.test(ua)) {return 'Linux PC';} if (/CrOS/.test(ua)) {return 'Chromebook';} return 'My Device'; }, /** * Convert registration options from server to WebAuthn format * @param {object} options - Options from server * @returns {object} - WebAuthn PublicKeyCredentialCreationOptions */ convertRegistrationOptions(options) { return { challenge: this.base64urlToBuffer(options.challenge), rp: options.rp, user: { id: this.stringToBuffer(options.user.id), // Handle both base64url and plain text name: options.user.name, displayName: options.user.displayName }, pubKeyCredParams: options.pubKeyCredParams, timeout: options.timeout, attestation: options.attestation, excludeCredentials: options.excludeCredentials?.map(cred => ({ id: this.base64urlToBuffer(cred.id), type: cred.type, transports: cred.transports })) || [], authenticatorSelection: options.authenticatorSelection }; }, /** * Convert authentication options from server to WebAuthn format * @param {object} options - Options from server * @returns {object} - WebAuthn PublicKeyCredentialRequestOptions */ convertAuthenticationOptions(options) { return { challenge: this.base64urlToBuffer(options.challenge), rpId: options.rpId, timeout: options.timeout, userVerification: options.userVerification, allowCredentials: options.allowCredentials?.map(cred => ({ id: this.base64urlToBuffer(cred.id), type: cred.type, transports: cred.transports })) || [] }; }, /** * Encode credential for sending to server * @param {PublicKeyCredential} credential * @returns {object} */ encodeCredential(credential) { const response = credential.response; return { id: credential.id, rawId: this.bufferToBase64url(credential.rawId), type: credential.type, response: { clientDataJSON: this.bufferToBase64url(response.clientDataJSON), attestationObject: this.bufferToBase64url(response.attestationObject), transports: response.getTransports ? response.getTransports() : [] } }; }, /** * Encode authentication credential for sending to server * @param {PublicKeyCredential} credential * @returns {object} */ encodeAuthCredential(credential) { const response = credential.response; return { id: credential.id, rawId: this.bufferToBase64url(credential.rawId), type: credential.type, response: { clientDataJSON: this.bufferToBase64url(response.clientDataJSON), authenticatorData: this.bufferToBase64url(response.authenticatorData), signature: this.bufferToBase64url(response.signature), userHandle: response.userHandle ? this.bufferToBase64url(response.userHandle) : null } }; }, /** * Convert string to ArrayBuffer (handles both plain text and base64url) * @param {string} str - String to convert * @returns {ArrayBuffer} */ stringToBuffer(str) { // Check if it looks like base64url (only contains base64url-safe characters) if (/^[A-Za-z0-9_-]+$/.test(str) && str.length > 10) { // Likely base64url encoded, try to decode try { return this.base64urlToBuffer(str); } catch (e) { // If decoding fails, treat as plain text console.warn('Failed to decode as base64url, treating as plain text:', e); } } // Plain text - convert to UTF-8 bytes const encoder = new TextEncoder(); return encoder.encode(str).buffer; }, /** * Convert base64url string to ArrayBuffer * @param {string} base64url * @returns {ArrayBuffer} */ base64urlToBuffer(base64url) { try { // Ensure input is a string and trim whitespace if (typeof base64url !== 'string') { throw new Error(`Expected string, got ${typeof base64url}`); } base64url = base64url.trim(); // Convert base64url to base64 const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); // Add padding const padLen = (4 - (base64.length % 4)) % 4; const padded = base64 + '='.repeat(padLen); // Decode base64 to binary string const binary = atob(padded); // Convert to ArrayBuffer const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } catch (error) { console.error('base64urlToBuffer error:', error); console.error('Input value:', base64url); console.error('Input type:', typeof base64url); throw new Error(`Failed to decode base64url: ${error.message}`); } }, /** * Convert ArrayBuffer to base64url string * @param {ArrayBuffer} buffer * @returns {string} */ bufferToBase64url(buffer) { const bytes = new Uint8Array(buffer); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } const base64 = btoa(binary); return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); }, /** * Delete a passkey * @param {string} credentialId - Credential ID to delete * @param {string} deviceName - Device name for confirmation */ async deletePasskey(credentialId, deviceName) { if (!confirm(`Are you sure you want to delete the passkey "${deviceName}"?\n\nThis action cannot be undone.`)) { return; } try { const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken'); if (!token) { showNotification('error', 'Authentication Required', 'Please log in first'); return; } const response = await fetch(`/api/auth/passkey/${encodeURIComponent(credentialId)}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to delete passkey'); } showNotification('success', 'Passkey Deleted', `${deviceName} has been removed`); // Reload passkey list if (typeof loadPasskeyStatus === 'function') { loadPasskeyStatus(); } if (typeof listPasskeys === 'function') { listPasskeys(); } } catch (error) { console.error('Delete passkey error:', error); showNotification('error', 'Delete Error', error.message); } }, /** * Rename a passkey * @param {string} credentialId - Credential ID to rename * @param {string} currentName - Current device name */ async renamePasskey(credentialId, currentName) { const newName = prompt('Enter a new name for this passkey:', currentName); if (!newName || newName === currentName) {return;} try { const token = localStorage.getItem('authToken') || sessionStorage.getItem('authToken'); if (!token) { showNotification('error', 'Authentication Required', 'Please log in first'); return; } const response = await fetch(`/api/auth/passkey/${encodeURIComponent(credentialId)}/rename`, { method: 'PUT', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ deviceName: newName }) }); if (!response.ok) { const error = await response.json(); throw new Error(error.error || 'Failed to rename passkey'); } showNotification('success', 'Passkey Renamed', `Renamed to "${newName}"`); // Reload passkey list if (typeof listPasskeys === 'function') { listPasskeys(); } } catch (error) { console.error('Rename passkey error:', error); showNotification('error', 'Rename Error', error.message); } }, /** * Authenticate with passkey * @param {string} username - Username (optional for resident keys) * @returns {Promise} - Authentication result with token */ async authenticate(username = null) { if (!this.isSupported()) { this.showNotSupportedError(); return null; } try { // Get authentication options from server const optionsResponse = await fetch('/api/auth/passkey/authenticate/options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }); if (!optionsResponse.ok) { const error = await optionsResponse.json(); throw new Error(error.error || 'Failed to get authentication options'); } const { options } = await optionsResponse.json(); // Convert options for WebAuthn API const publicKeyOptions = this.convertAuthenticationOptions(options); // Prompt user for biometric/security key showNotification('info', 'Authenticate', 'Please authenticate with your passkey...'); let credential; try { credential = await navigator.credentials.get({ publicKey: publicKeyOptions }); } catch (err) { if (err.name === 'NotAllowedError') { showNotification('warning', 'Cancelled', 'Authentication was cancelled'); } else { throw err; } return null; } if (!credential) { showNotification('error', 'Authentication Failed', 'No credential was provided'); return null; } // Send credential to server for verification const verificationResponse = await fetch('/api/auth/passkey/authenticate/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: username, response: this.encodeAuthCredential(credential) }) }); if (!verificationResponse.ok) { const error = await verificationResponse.json(); throw new Error(error.error || 'Authentication failed'); } const result = await verificationResponse.json(); showNotification('success', 'Authenticated', 'Successfully logged in with passkey!'); return result; } catch (error) { console.error('Passkey authentication error:', error); showNotification('error', 'Authentication Error', error.message); return null; } } }; // Make globally available window.Passkeys = Passkeys;