/** * 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 totp_code = document.getElementById('mfa-code').value.trim(); if (!totp_code) return; 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({ mfa_token: _pendingMfaToken, totp_code }), }); const data = await res.json(); if (!res.ok) { 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.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); 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 }; })();