1import { Controller } from "@hotwired/stimulus" 2 3export default class extends Controller { 4 static targets = ["registerButton", "loginButton", "labelInput", "list"] 5 static values = { 6 challengeUrl: String, 7 registerUrl: String, 8 authChallengeUrl: String, 9 authUrl: String 10 } 11 12 connect() { 13 // Hide passkey buttons if WebAuthn is not supported 14 if (!window.PublicKeyCredential) { 15 this.element.style.display = "none" 16 } 17 } 18 19 async register(event) { 20 event.preventDefault() 21 const button = this.registerButtonTarget 22 const originalText = button.textContent 23 button.textContent = "Waiting for device..." 24 button.disabled = true 25 26 try { 27 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content 28 29 // Get challenge from server 30 const challengeResponse = await fetch(this.challengeUrlValue, { 31 method: "POST", 32 headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, "Accept": "application/json" } 33 }) 34 const options = await challengeResponse.json() 35 36 // Convert base64url strings to ArrayBuffers 37 options.challenge = this.base64urlToBuffer(options.challenge) 38 options.user.id = this.base64urlToBuffer(options.user.id) 39 if (options.excludeCredentials) { 40 options.excludeCredentials = options.excludeCredentials.map(cred => ({ 41 ...cred, 42 id: this.base64urlToBuffer(cred.id) 43 })) 44 } 45 46 // Create credential via browser API 47 const credential = await navigator.credentials.create({ publicKey: options }) 48 49 // Determine label 50 const label = this.hasLabelInputTarget && this.labelInputTarget.value.trim() 51 ? this.labelInputTarget.value.trim() 52 : "Passkey" 53 54 // Send credential to server 55 const body = { 56 id: credential.id, 57 rawId: this.bufferToBase64url(credential.rawId), 58 type: credential.type, 59 label: label, 60 response: { 61 attestationObject: this.bufferToBase64url(credential.response.attestationObject), 62 clientDataJSON: this.bufferToBase64url(credential.response.clientDataJSON) 63 } 64 } 65 66 const result = await fetch(this.registerUrlValue, { 67 method: "POST", 68 headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, "Accept": "application/json" }, 69 body: JSON.stringify(body) 70 }) 71 72 const data = await result.json() 73 74 if (data.status === "ok") { 75 // Reload to show updated passkey list 76 window.location.reload() 77 } else { 78 alert(data.message || "Failed to register passkey.") 79 } 80 } catch (err) { 81 if (err.name !== "NotAllowedError") { 82 console.error("Passkey registration error:", err) 83 alert("Passkey registration failed. Please try again.") 84 } 85 } finally { 86 button.textContent = originalText 87 button.disabled = false 88 } 89 } 90 91 async login(event) { 92 event.preventDefault() 93 const button = this.loginButtonTarget 94 const originalText = button.textContent 95 button.textContent = "Waiting for device..." 96 button.disabled = true 97 98 try { 99 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content 100 101 // Get challenge from server 102 const challengeResponse = await fetch(this.authChallengeUrlValue, { 103 method: "POST", 104 headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, "Accept": "application/json" } 105 }) 106 const options = await challengeResponse.json() 107 108 // Convert challenge to ArrayBuffer 109 options.challenge = this.base64urlToBuffer(options.challenge) 110 if (options.allowCredentials) { 111 options.allowCredentials = options.allowCredentials.map(cred => ({ 112 ...cred, 113 id: this.base64urlToBuffer(cred.id) 114 })) 115 } 116 117 // Get credential via browser API 118 const credential = await navigator.credentials.get({ publicKey: options }) 119 120 // Send assertion to server 121 const body = { 122 id: credential.id, 123 rawId: this.bufferToBase64url(credential.rawId), 124 type: credential.type, 125 response: { 126 authenticatorData: this.bufferToBase64url(credential.response.authenticatorData), 127 clientDataJSON: this.bufferToBase64url(credential.response.clientDataJSON), 128 signature: this.bufferToBase64url(credential.response.signature), 129 userHandle: credential.response.userHandle ? this.bufferToBase64url(credential.response.userHandle) : null 130 } 131 } 132 133 const result = await fetch(this.authUrlValue, { 134 method: "POST", 135 headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, "Accept": "application/json" }, 136 body: JSON.stringify(body) 137 }) 138 139 const data = await result.json() 140 141 if (data.status === "ok") { 142 Turbo.visit(data.redirect_to) 143 } else { 144 alert(data.message || "Authentication failed.") 145 } 146 } catch (err) { 147 if (err.name !== "NotAllowedError") { 148 console.error("Passkey login error:", err) 149 alert("Passkey login failed. Please try again.") 150 } 151 } finally { 152 button.textContent = originalText 153 button.disabled = false 154 } 155 } 156 157 // Helpers for base64url <-> ArrayBuffer conversion 158 base64urlToBuffer(base64url) { 159 const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/") 160 const padding = "=".repeat((4 - base64.length % 4) % 4) 161 const binary = atob(base64 + padding) 162 const bytes = new Uint8Array(binary.length) 163 for (let i = 0; i < binary.length; i++) {
164 bytes[i] = binary.charCodeAt(i) 165 } 166 return bytes.buffer 167 } 168 169 bufferToBase64url(buffer) { 170 const bytes = new Uint8Array(buffer) 171 let binary = "" 172 for (const byte of bytes) { 173 binary += String.fromCharCode(byte) 174 } 175 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") 176 } 177}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.