/** * auth.js — Register and Login flows (including TOTP MFA step) * * Master password never leaves the browser. Only the PBKDF2-derived authHash * is sent to the server for authentication. */ const Auth = (() => { // ── Helpers ────────────────────────────────────────────────────────────── function csrfToken() { const meta = document.querySelector('meta[name="csrf-token"]'); return meta ? meta.content : ""; } function showError(formEl, message) { let el = formEl.querySelector(".form-error"); if (!el) { el = document.createElement("p"); el.className = "form-error"; formEl.prepend(el); } el.textContent = message; el.classList.remove("hidden"); } function setLoading(btn, loading) { btn.disabled = loading; btn.textContent = loading ? btn.dataset.loadingText || "Please wait…" : btn.dataset.originalText; } // ── Register ───────────────────────────────────────────────────────────── async function handleRegister(e) { e.preventDefault(); const form = e.target; const btn = form.querySelector('[type="submit"]'); btn.dataset.originalText = btn.textContent; const email = form.email.value.trim().toLowerCase(); const password = form.password.value; const confirm = form.confirm_password.value; if (password !== confirm) { showError(form, "Passwords do not match."); return; } if (password.length < 12) { showError(form, "Master password must be at least 12 characters."); return; } setLoading(btn, true); try { const authHash = await Crypto.deriveAuthHash(password, email); const enc_key_salt = Crypto.generateSalt(16); const res = await fetch("/api/auth/register", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken(), }, body: JSON.stringify({ email, auth_hash: authHash, enc_key_salt }), }); const data = await res.json(); if (!res.ok) { showError(form, data.error || "Registration failed."); return; } window.location.href = "/login?registered=1"; } catch (err) { showError(form, "An unexpected error occurred. Please try again."); console.error(err); } finally { setLoading(btn, false); } } // ── Login ───────────────────────────────────────────────────────────────── // Temporarily held between Step 1 and Step 2 let _pendingMfaToken = null; let _pendingEncKeySalt = null; let _pendingPassword = null; async function handleLogin(e) { e.preventDefault(); const form = e.target; const btn = form.querySelector('[type="submit"]'); btn.dataset.originalText = btn.textContent; const email = form.email.value.trim().toLowerCase(); const password = form.password.value; setLoading(btn, true); try { const authHash = await Crypto.deriveAuthHash(password, email); const res = await fetch("/api/auth/login", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken(), }, body: JSON.stringify({ email, auth_hash: authHash }), }); const data = await res.json(); if (!res.ok) { showError(form, data.error || "Invalid email or password."); return; } if (data.mfa_required) { // Step 2: collect TOTP code _pendingMfaToken = data.mfa_token; _pendingEncKeySalt = data.enc_key_salt; _pendingPassword = password; sessionStorage.setItem("enc_key_salt", data.enc_key_salt); showMfaStep(); return; } await completeLogin(password, data); } catch (err) { showError(form, "An unexpected error occurred. Please try again."); console.error(err); } finally { setLoading(btn, false); } } async function handleMfaVerify(e) { e.preventDefault(); const form = e.target; const btn = form.querySelector('[type="submit"]'); btn.dataset.originalText = btn.textContent; const errEl = document.getElementById("mfa-error"); errEl.classList.add("hidden"); const usingBackup = !document .getElementById("mfa-backup-section") .classList.contains("hidden"); const body = { mfa_token: _pendingMfaToken }; if (usingBackup) { const code = document .getElementById("mfa-backup-code") .value.trim() .toLowerCase() .replace(/[\s-]/g, ""); if (!code) { errEl.textContent = "Enter your backup code."; errEl.classList.remove("hidden"); return; } body.backup_code = code; } else { const totp_code = document.getElementById("mfa-code").value.trim(); if (!totp_code) return; body.totp_code = totp_code; } setLoading(btn, true); try { const res = await fetch("/api/auth/mfa/verify", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRFToken": csrfToken(), }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) { errEl.textContent = data.error || "Invalid code. Try again."; errEl.classList.remove("hidden"); return; } await completeLogin(_pendingPassword, { access_token: data.access_token, refresh_token: data.refresh_token, enc_key_salt: _pendingEncKeySalt, }); } catch (err) { errEl.textContent = "An unexpected error occurred. Please try again."; errEl.classList.remove("hidden"); console.error(err); } finally { setLoading(btn, false); } } async function completeLogin(password, data) { sessionStorage.setItem("access_token", data.access_token); localStorage.setItem("refresh_token", data.refresh_token); sessionStorage.setItem("enc_key_salt", data.enc_key_salt); // Notify the browser extension (if installed) so it can share the session window.dispatchEvent( new CustomEvent("passkeeper:session", { detail: { access_token: data.access_token, refresh_token: data.refresh_token, enc_key_salt: data.enc_key_salt, }, }), ); const vaultKey = await Crypto.deriveVaultKey(password, data.enc_key_salt); VaultSession.setKey(vaultKey); window.location.href = "/vault"; } function showMfaStep() { document.getElementById("login-step-1").classList.add("hidden"); document.getElementById("login-step-2").classList.remove("hidden"); document.getElementById("mfa-code").focus(); } function hideMfaStep() { document.getElementById("login-step-2").classList.add("hidden"); document.getElementById("login-step-1").classList.remove("hidden"); document.getElementById("mfa-code").value = ""; document.getElementById("mfa-error").classList.add("hidden"); _pendingMfaToken = null; _pendingEncKeySalt = null; _pendingPassword = null; } // ── Init ────────────────────────────────────────────────────────────────── function initPasswordToggle(btnId, inputId) { const btn = document.getElementById(btnId); const input = document.getElementById(inputId); if (btn && input) { btn.addEventListener("click", () => { input.type = input.type === "password" ? "text" : "password"; }); } } function initStrengthMeter() { const input = document.getElementById("password"); const bar = document.getElementById("strength-bar"); if (!input || !bar) return; input.addEventListener("input", function () { const v = this.value; let score = 0; if (v.length >= 12) score++; if (v.length >= 16) score++; if (/[A-Z]/.test(v)) score++; if (/[0-9]/.test(v)) score++; if (/[^A-Za-z0-9]/.test(v)) score++; const labels = ["", "Very weak", "Weak", "Fair", "Strong", "Very strong"]; const classes = [ "", "strength-1", "strength-2", "strength-3", "strength-4", "strength-5", ]; bar.textContent = v ? labels[score] : ""; bar.className = "password-strength " + (v ? classes[score] : ""); }); } function init() { const registerForm = document.getElementById("register-form"); if (registerForm) registerForm.addEventListener("submit", handleRegister); const loginForm = document.getElementById("login-form"); if (loginForm) loginForm.addEventListener("submit", handleLogin); const mfaForm = document.getElementById("mfa-form"); if (mfaForm) mfaForm.addEventListener("submit", handleMfaVerify); document .getElementById("btn-back-to-password") ?.addEventListener("click", hideMfaStep); document .getElementById("btn-use-backup-code") ?.addEventListener("click", () => { document.getElementById("mfa-totp-section").classList.add("hidden"); document .getElementById("mfa-backup-section") .classList.remove("hidden"); document.getElementById("btn-use-backup-code").classList.add("hidden"); document.getElementById("btn-use-totp-code").classList.remove("hidden"); document.getElementById("mfa-backup-code").focus(); }); document .getElementById("btn-use-totp-code") ?.addEventListener("click", () => { document.getElementById("mfa-backup-section").classList.add("hidden"); document.getElementById("mfa-totp-section").classList.remove("hidden"); document.getElementById("btn-use-totp-code").classList.add("hidden"); document .getElementById("btn-use-backup-code") .classList.remove("hidden"); document.getElementById("mfa-code").focus(); }); initPasswordToggle("toggle-login-pass", "password"); initPasswordToggle("toggle-reg-pass", "password"); initStrengthMeter(); if (window.location.search.includes("registered=1")) { const notice = document.getElementById("register-notice"); if (notice) notice.classList.remove("hidden"); } } return { init }; })(); document.addEventListener("DOMContentLoaded", Auth.init); // ── VaultSession — holds the vault key for the lifetime of the browser tab ── const VaultSession = (() => { let _vaultKey = null; function setKey(key) { _vaultKey = key; } function getKey() { return _vaultKey; } function clear() { _vaultKey = null; } return { setKey, getKey, clear }; })();