// Passkey / WebAuthn browser API helpers for login and registration. // Loaded on the login page and dashboard settings tab. (function () { 'use strict'; // -- Encoding helpers (WebAuthn API uses ArrayBuffer, server uses base64url) -- function base64urlToBuffer(base64url) { var base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); var pad = base64.length % 4; if (pad) base64 += '='.repeat(4 - pad); var binary = atob(base64); var bytes = new Uint8Array(binary.length); for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); return bytes.buffer; } function bufferToBase64url(buffer) { var bytes = new Uint8Array(buffer); var binary = ''; for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } // -- Login flow (unauthenticated, on /login page) -- window.loginWithPasskey = async function () { var errorEl = document.getElementById('passkey-login-error'); if (errorEl) errorEl.innerHTML = ''; try { var resp = await fetch('/auth/passkey/start', { method: 'POST' }); if (!resp.ok) throw new Error('Server error'); var options = await resp.json(); // Decode challenge options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge); if (options.publicKey.allowCredentials) { options.publicKey.allowCredentials.forEach(function (c) { c.id = base64urlToBuffer(c.id); }); } var assertion = await navigator.credentials.get(options); // Encode response for server var body = JSON.stringify({ id: assertion.id, rawId: bufferToBase64url(assertion.rawId), type: assertion.type, response: { authenticatorData: bufferToBase64url(assertion.response.authenticatorData), clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON), signature: bufferToBase64url(assertion.response.signature), userHandle: assertion.response.userHandle ? bufferToBase64url(assertion.response.userHandle) : null } }); var finishResp = await fetch('/auth/passkey/finish', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: body }); if (!finishResp.ok) { var err = await finishResp.text(); throw new Error(err || 'Authentication failed'); } var result = await finishResp.json(); if (result.redirect) window.location.href = result.redirect; } catch (e) { if (e.name === 'NotAllowedError') return; // User cancelled if (errorEl) { errorEl.innerHTML = 'Passkey login failed. Try again or use your password.'; } } }; // -- Registration flow (authenticated, on dashboard settings) -- window.registerPasskey = async function () { var statusEl = document.getElementById('passkey-status'); if (statusEl) statusEl.innerHTML = ''; var password = prompt('Enter your password to add a passkey:'); if (!password) return; var csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; try { var resp = await fetch('/api/users/me/passkeys/register/start', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }, body: new URLSearchParams({ password: password }).toString() }); if (!resp.ok) throw new Error('Server error'); var options = await resp.json(); // Decode challenge and user ID options.publicKey.challenge = base64urlToBuffer(options.publicKey.challenge); options.publicKey.user.id = base64urlToBuffer(options.publicKey.user.id); if (options.publicKey.excludeCredentials) { options.publicKey.excludeCredentials.forEach(function (c) { c.id = base64urlToBuffer(c.id); }); } var credential = await navigator.credentials.create(options); // Encode attestation for server var body = JSON.stringify({ id: credential.id, rawId: bufferToBase64url(credential.rawId), type: credential.type, response: { attestationObject: bufferToBase64url(credential.response.attestationObject), clientDataJSON: bufferToBase64url(credential.response.clientDataJSON) } }); var finishResp = await fetch('/api/users/me/passkeys/register/finish', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }, body: body }); if (!finishResp.ok) throw new Error('Registration failed'); // Replace the passkey section with the updated list var html = await finishResp.text(); document.getElementById('passkey-section').innerHTML = html; } catch (e) { if (e.name === 'NotAllowedError') return; // User cancelled if (statusEl) { statusEl.innerHTML = 'Registration failed. Please try again.'; } } }; // -- Management (rename / delete via event delegation) -- // Event delegation on document — works regardless of HTMX swap timing document.addEventListener('click', function (e) { var btn = e.target.closest('.passkey-rename-btn'); if (btn) { var id = btn.getAttribute('data-id'); var currentName = btn.getAttribute('data-name'); var name = prompt('Rename passkey:', currentName); if (!name || name.trim() === '' || name === currentName) return; var csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; var body = new URLSearchParams({ name: name.trim() }); fetch('/api/users/me/passkeys/' + encodeURIComponent(id), { method: 'PUT', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }, body: body.toString() }).then(function (resp) { if (resp.ok) return resp.text(); throw new Error('Rename failed'); }).then(function (html) { var section = document.getElementById('passkey-section'); if (section) section.innerHTML = html; }); return; } var delBtn = e.target.closest('.passkey-delete-btn'); if (delBtn) { var delId = delBtn.getAttribute('data-id'); var password = prompt('Enter your password to remove this passkey:'); if (!password) return; var csrfToken = document.querySelector('meta[name="csrf-token"]')?.content; var body = new URLSearchParams({ password: password }); fetch('/api/users/me/passkeys/' + encodeURIComponent(delId), { method: 'DELETE', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }, body: body.toString() }).then(function (resp) { if (resp.ok) return resp.text(); throw new Error('Delete failed'); }).then(function (html) { var section = document.getElementById('passkey-section'); if (section) section.innerHTML = html; }); return; } var addBtn = e.target.closest('#add-passkey-btn'); if (addBtn) { window.registerPasskey(); } }); })();