import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static targets = ["nickname", "submitButton", "status", "error"]; static values = { challengeUrl: String, createUrl: String, checkUrl: String }; connect() { // Check if WebAuthn is supported if (!this.isWebAuthnSupported()) { console.warn("WebAuthn is not supported in this browser"); return; } } // Check if browser supports WebAuthn isWebAuthnSupported() { return ( window.PublicKeyCredential !== undefined && typeof window.PublicKeyCredential === "function" ); } // Check if user has passkeys (for login page) async checkWebAuthnSupport(event) { const email = event.target.value.trim(); if (!email || !this.isValidEmail(email)) { return; } try { const response = await fetch(`${this.checkUrlValue}?email=${encodeURIComponent(email)}`); const data = await response.json(); console.debug("WebAuthn check response:", data); if (data.has_webauthn) { console.debug("Dispatching webauthn-available event"); // Trigger custom event for login form to show passkey option this.dispatch("webauthn-available", { detail: { hasWebauthn: data.has_webauthn, requiresWebauthn: data.requires_webauthn, preferredMethod: data.preferred_method } }); // Auto-trigger passkey authentication if required if (data.requires_webauthn) { setTimeout(() => this.authenticate(), 100); } } else { console.debug("No WebAuthn credentials found for this email"); } } catch (error) { console.error("Error checking WebAuthn support:", error); } } // Start registration ceremony async register(event) { event.preventDefault(); if (!this.isWebAuthnSupported()) { this.showError("WebAuthn is not supported in your browser"); return; } const nickname = this.nicknameTarget.value.trim(); if (!nickname) { this.showError("Please enter a nickname for this passkey"); return; } this.setLoading(true); this.clearMessages(); try { // Get registration challenge from server const challengeResponse = await fetch(this.challengeUrlValue, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": this.getCSRFToken() } }); if (!challengeResponse.ok) { throw new Error("Failed to get registration challenge"); } const credentialCreationOptions = await challengeResponse.json(); // Use modern Web Authentication API Level 3 to parse options // This automatically handles all base64url encoding/decoding const publicKeyOptions = PublicKeyCredential.parseCreationOptionsFromJSON( credentialCreationOptions ); // Create credential via WebAuthn API const credential = await navigator.credentials.create({ publicKey: publicKeyOptions }); if (!credential) { throw new Error("Failed to create credential"); } // Send credential to server for verification // Use toJSON() to properly serialize the credential const credentialResponse = await fetch(this.createUrlValue, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": this.getCSRFToken() }, body: JSON.stringify({ credential: credential.toJSON(), nickname: nickname }) }); const result = await credentialResponse.json(); if (result.success) { this.showSuccess(result.message); // Clear the form this.nicknameTarget.value = ""; // Dispatch event to refresh the passkey list this.dispatch("passkey-registered", { detail: { nickname: nickname, credentialId: result.credential_id } }); // Optionally close modal or redirect setTimeout(() => { if (window.location.pathname === "/webauthn/new") { window.location.href = "/profile"; } }, 1500); } else { this.showError(result.error || "Failed to register passkey"); } } catch (error) { console.error("WebAuthn registration error:", error); this.showError(this.getErrorMessage(error)); } finally { this.setLoading(false); } } // Start authentication ceremony async authenticate(event) { if (event) { event.preventDefault(); } if (!this.isWebAuthnSupported()) { this.showError("WebAuthn is not supported in your browser"); return; } this.setLoading(true); this.clearMessages(); try { // Get authentication challenge from server const response = await fetch("/sessions/webauthn/challenge", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": this.getCSRFToken() }, body: JSON.stringify({ email: this.getUserEmail() }) }); if (!response.ok) { throw new Error("Failed to get authentication challenge"); } const credentialRequestOptions = await response.json(); // Use modern Web Authentication API Level 3 to parse options // This automatically handles all base64url encoding/decoding const publicKeyOptions = PublicKeyCredential.parseRequestOptionsFromJSON( credentialRequestOptions ); // Get credential via WebAuthn API const credential = await navigator.credentials.get({ publicKey: publicKeyOptions }); if (!credential) { throw new Error("Failed to get credential"); } // Send assertion to server for verification // Use toJSON() to properly serialize the credential const authResponse = await fetch("/sessions/webauthn/verify", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": this.getCSRFToken() }, body: JSON.stringify({ credential: credential.toJSON(), email: this.getUserEmail() }) }); const result = await authResponse.json(); if (result.success) { // Redirect to dashboard or intended URL window.location.href = result.redirect_to || "/"; } else { this.showError(result.error || "Authentication failed"); } } catch (error) { console.error("WebAuthn authentication error:", error); this.showError(this.getErrorMessage(error)); } finally { this.setLoading(false); } } // UI helper methods setLoading(isLoading) { if (this.hasSubmitButtonTarget) { this.submitButtonTarget.disabled = isLoading; this.submitButtonTarget.textContent = isLoading ? "Registering..." : "Register Passkey"; } } showSuccess(message) { if (this.hasStatusTarget) { this.statusTarget.textContent = message; this.statusTarget.className = "mt-2 text-sm text-green-600"; this.statusTarget.style.display = "block"; } } showError(message) { if (this.hasErrorTarget) { this.errorTarget.textContent = message; this.errorTarget.className = "mt-2 text-sm text-red-600"; this.errorTarget.style.display = "block"; } } clearMessages() { if (this.hasStatusTarget) { this.statusTarget.style.display = "none"; this.statusTarget.textContent = ""; } if (this.hasErrorTarget) { this.errorTarget.style.display = "none"; this.errorTarget.textContent = ""; } } getCSRFToken() { const meta = document.querySelector('meta[name="csrf-token"]'); return meta ? meta.getAttribute("content") : ""; } getUserEmail() { // Try multiple ways to get the user email from login form let emailInput = document.querySelector('input[type="email"]'); if (!emailInput) { emailInput = document.querySelector('input[name="email"]'); } if (!emailInput) { emailInput = document.querySelector('input[name="session[email_address]"]'); } if (!emailInput) { emailInput = document.querySelector('input[name="user[email_address]"]'); } return emailInput ? emailInput.value.trim() : ""; } isValidEmail(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); } getErrorMessage(error) { // Common WebAuthn errors if (error.name === "NotAllowedError") { return "Authentication was cancelled or timed out. Please try again."; } if (error.name === "SecurityError") { return "Security requirements not met. Make sure you're using HTTPS."; } if (error.name === "NotSupportedError") { return "This device doesn't support the requested authentication method."; } if (error.name === "InvalidStateError") { return "This authenticator has already been registered."; } // Fallback to error message return error.message || "An unexpected error occurred"; } }