diff --git a/frontend/js/app.js b/frontend/js/app.js
index 4593fc7..966386b 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -275,14 +275,20 @@ function route() {
}
}
+ // Password-reset links arrive from email on a browser that is by definition NOT logged
+ // in, and carry a one-time token in the hash. This must be handled BEFORE the redirect
+ // below: rewriting the hash would discard the token and the emailed link would silently
+ // do nothing. The login view reads the token off the hash and shows the new-password form.
+ const isResetRoute = hash.startsWith('#/reset-password');
+
// Auth check - redirect to login if not authenticated
- if (!isAuthenticated() && hash !== '#/login') {
+ if (!isAuthenticated() && hash !== '#/login' && !isResetRoute) {
window.location.hash = '#/login';
return;
}
// If authenticated and on login page, redirect to dashboard or onboarding
- if (isAuthenticated() && hash === '#/login') {
+ if (isAuthenticated() && (hash === '#/login' || isResetRoute)) {
window.location.hash = localStorage.getItem('rd_onboarded') ? '#/' : '#/onboarding';
return;
}
@@ -359,8 +365,8 @@ function route() {
return;
}
- // Login page - hide sidebar
- if (hash === '#/login') {
+ // Login page (and password-reset links from email) - hide sidebar
+ if (hash === '#/login' || isResetRoute) {
sidebar.style.display = 'none';
app.style.marginLeft = '0';
const mb = document.getElementById('mobileMenuBtn');
diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js
index 39f03b2..61115b0 100644
--- a/frontend/js/i18n/en.js
+++ b/frontend/js/i18n/en.js
@@ -78,6 +78,14 @@ export default {
'auth.verify_title': 'Confirm your email',
'auth.verify_body': "We've sent a verification link to",
'auth.verify_resend': 'Resend the email',
+ 'auth.forgot_password': 'Forgot your password?',
+ 'auth.forgot_send': 'Send reset link',
+ 'auth.forgot_sent': 'If that address has an account, a reset link is on its way. Check your inbox.',
+ 'auth.back_to_signin': 'Back to sign in',
+ 'auth.new_password': 'New password',
+ 'auth.reset_submit': 'Set new password',
+ 'auth.reset_done': 'Password updated — sign in with your new password.',
+ 'auth.reset_failed': 'That reset link is invalid or has expired. Request a new one.',
'auth.verify_resent': "If that address needs confirming, we've sent a new link.",
'auth.verify_resend_failed': "Couldn't resend right now — try again in a moment.",
'auth.verify_ok': 'Email confirmed. You can sign in now.',
diff --git a/frontend/js/views/login.js b/frontend/js/views/login.js
index 88fc3c5..cdaf376 100644
--- a/frontend/js/views/login.js
+++ b/frontend/js/views/login.js
@@ -89,6 +89,11 @@ export async function render(container) {
${isSetup ? t('auth.create_admin_account') : t('auth.sign_in')}
+ ${!isSetup ? `
+
${t('auth.create_account')}
@@ -188,6 +193,23 @@ export async function render(container) {
+
+
${t('auth.terms')}
@@ -274,6 +296,80 @@ function setupHandlers(config, isSetup) {
}
// "Check your email" panel shown when signup/login returns verification_required (hosted).
+ // ---- Self-service password reset -------------------------------------------------
+ // Two cards swapped into the same login shell. The request step ALWAYS shows the same
+ // confirmation regardless of the server's answer, matching the server's deliberate
+ // refusal to reveal whether an address exists.
+ function showCard(id) {
+ ['localAuthForm', 'registerForm', 'mfaForm', 'ssoBlock', 'forgotForm', 'resetForm'].forEach((x) => {
+ const el = document.getElementById(x); if (el) el.style.display = (x === id ? 'block' : 'none');
+ });
+ const errEl = document.getElementById('loginError'); if (errEl) errEl.style.display = 'none';
+ }
+
+ const forgotLink = document.getElementById('forgotLink');
+ if (forgotLink) forgotLink.addEventListener('click', (e) => {
+ e.preventDefault();
+ showCard('forgotForm');
+ const src = document.getElementById('loginEmail');
+ const dst = document.getElementById('forgotEmail');
+ if (src && dst) dst.value = src.value; // carry over whatever they already typed
+ });
+
+ const forgotBackBtn = document.getElementById('forgotBackBtn');
+ if (forgotBackBtn) forgotBackBtn.addEventListener('click', () => showCard('localAuthForm'));
+
+ const forgotSendBtn = document.getElementById('forgotSendBtn');
+ if (forgotSendBtn) forgotSendBtn.addEventListener('click', async () => {
+ const email = (document.getElementById('forgotEmail').value || '').trim();
+ forgotSendBtn.disabled = true;
+ try {
+ await fetch('/api/auth/forgot-password', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }),
+ });
+ } catch (e) { /* deliberately ignored — see below */ }
+ // Same confirmation either way. Surfacing a network/server error here would leak
+ // whether the address matched, undoing the server-side enumeration resistance.
+ document.getElementById('forgotNotice').style.display = 'block';
+ forgotSendBtn.disabled = false;
+ });
+
+ // A link from the reset email: #/reset-password?token=...
+ function resetTokenFromHash() {
+ const h = window.location.hash || '';
+ const q = h.indexOf('?');
+ if (!h.startsWith('#/reset-password') || q < 0) return null;
+ return new URLSearchParams(h.slice(q + 1)).get('token');
+ }
+
+ const pendingResetToken = resetTokenFromHash();
+ if (pendingResetToken) showCard('resetForm');
+
+ const resetBackBtn = document.getElementById('resetBackBtn');
+ if (resetBackBtn) resetBackBtn.addEventListener('click', () => { window.location.hash = '#/login'; window.location.reload(); });
+
+ const resetSubmitBtn = document.getElementById('resetSubmitBtn');
+ if (resetSubmitBtn) resetSubmitBtn.addEventListener('click', async () => {
+ const password = document.getElementById('resetPassword').value || '';
+ resetSubmitBtn.disabled = true;
+ try {
+ const res = await fetch('/api/auth/reset-password', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: pendingResetToken, password }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) { showError(data.error || t('auth.reset_failed')); resetSubmitBtn.disabled = false; return; }
+ // No session is issued by design, so send them through a normal sign-in — which is
+ // what keeps TOTP in the loop for accounts that have it.
+ showToast(t('auth.reset_done'), 'success');
+ window.location.hash = '#/login';
+ window.location.reload();
+ } catch (e) {
+ showError(t('auth.reset_failed'));
+ resetSubmitBtn.disabled = false;
+ }
+ });
+
function showVerifyNotice(email) {
// The server refused a session — make sure no stale token from a prior login lingers,
// else the router would treat this browser as authenticated and bounce it into the app.
diff --git a/server/db/database.js b/server/db/database.js
index f5a28f1..d09abc5 100644
--- a/server/db/database.js
+++ b/server/db/database.js
@@ -389,6 +389,12 @@ const migrations = [
"ALTER TABLE content ADD COLUMN captions_lang TEXT",
"ALTER TABLE content ADD COLUMN subtitle_url TEXT",
"ALTER TABLE content ADD COLUMN subtitle_lang TEXT",
+ // Self-service password reset. Mirrors the email-verification columns: the emailed token
+ // is stored ONLY as a SHA-256 hash (single-use), with its own expiry, and one pending
+ // token per user so a re-request simply overwrites the previous one. Nullable and
+ // additive — existing rows are unaffected and a code-only rollback leaves dead columns.
+ "ALTER TABLE users ADD COLUMN password_reset_hash TEXT",
+ "ALTER TABLE users ADD COLUMN password_reset_expires INTEGER",
// AUTH-05: make break-glass recovery revocable, single-use and auditable.
//
// scripts/reset-admin.js mints a JWT carrying `recovery: true`, which middleware/auth.js
diff --git a/server/lib/passwordReset.js b/server/lib/passwordReset.js
new file mode 100644
index 0000000..6b8e11f
--- /dev/null
+++ b/server/lib/passwordReset.js
@@ -0,0 +1,48 @@
+'use strict';
+
+// Self-service password-reset tokens. Deliberately the same shape as lib/emailVerify.js:
+// the emailed token is random, stored ONLY as a SHA-256 hash (single-use, same discipline
+// as recovery codes and api tokens), with the plaintext living just in the email link. One
+// pending token per user on the users row, so re-requesting overwrites the previous one.
+//
+// TTL is much shorter than email verification's 24h: this token changes a credential, so
+// the window in which a leaked link is useful should be small.
+
+const crypto = require('crypto');
+const bcrypt = require('bcryptjs');
+const { db } = require('../db/database');
+const { hashToken } = require('../middleware/apiToken');
+
+const TTL_SEC = 60 * 60; // 1 hour
+const MIN_PASSWORD_LENGTH = 8; // same minimum as registration and PUT /api/auth/me
+
+// Mint a token for a user, store its hash + expiry, return the PLAINTEXT (emailed once).
+function issue(userId) {
+ const token = crypto.randomBytes(32).toString('hex');
+ const expires = Math.floor(Date.now() / 1000) + TTL_SEC;
+ db.prepare('UPDATE users SET password_reset_hash = ?, password_reset_expires = ? WHERE id = ?')
+ .run(hashToken(token), expires, userId);
+ return token;
+}
+
+// Consume a token and set the new password. Returns the user id on success, else null
+// (unknown / expired / already used). Single-use: the hash is cleared in the same statement
+// that sets the password, and that UPDATE is conditioned on the hash still being present,
+// so two concurrent redemptions cannot both win.
+//
+// Clears must_change_password too — the user has just chosen a password, which is exactly
+// what that flag was demanding.
+function consume(token, newPassword) {
+ if (!token || typeof token !== 'string') return null;
+ if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) return null;
+ const hash = hashToken(token);
+ const row = db.prepare('SELECT id, password_reset_expires FROM users WHERE password_reset_hash = ?').get(hash);
+ if (!row) return null;
+ if (!row.password_reset_expires || row.password_reset_expires < Math.floor(Date.now() / 1000)) return null;
+ const res = db.prepare(`UPDATE users SET password_hash = ?, password_reset_hash = NULL,
+ password_reset_expires = NULL, must_change_password = 0, updated_at = strftime('%s','now')
+ WHERE id = ? AND password_reset_hash = ?`).run(bcrypt.hashSync(newPassword, 10), row.id, hash);
+ return res.changes === 1 ? row.id : null;
+}
+
+module.exports = { issue, consume, TTL_SEC, MIN_PASSWORD_LENGTH };
diff --git a/server/routes/auth.js b/server/routes/auth.js
index 8d028fb..49b1b45 100644
--- a/server/routes/auth.js
+++ b/server/routes/auth.js
@@ -12,7 +12,8 @@ const totp = require('../lib/totp');
const totpLockout = require('../lib/totp-lockout');
const loginLockout = require('../lib/login-lockout');
const QRCode = require('qrcode');
-const { sendSignupEmails, sendVerificationEmail } = require('../services/signupEmails');
+const { sendSignupEmails, sendVerificationEmail, sendPasswordResetEmail } = require('../services/signupEmails');
+const passwordReset = require('../lib/passwordReset');
const emailVerify = require('../lib/emailVerify');
const emailSvc = require('../services/email');
const { deleteUserCascade, OrgHasOtherMembersError } = require('../lib/user-deletion');
@@ -263,6 +264,60 @@ router.post('/resend-verification', (req, res) => {
res.json({ ok: true });
});
+// ==================== Self-service password reset ====================
+// Two endpoints, both unauthenticated by necessity (the user cannot log in).
+//
+// The request endpoint ALWAYS answers the same way — same status, same body — whether the
+// address exists, is an SSO identity with no local password, or is malformed. Anything
+// else turns it into an account-existence oracle, which is the classic mistake here.
+//
+// Completing a reset deliberately does NOT return a session. The user logs in afterwards,
+// so a TOTP-enabled account still has to clear its second factor; issuing a token here
+// would turn "read one email" into a full session and quietly bypass MFA.
+const RESET_GENERIC_OK = { ok: true, message: 'If that address has an account, a reset link is on its way.' };
+
+router.post('/forgot-password', (req, res) => {
+ const email = String(req.body?.email || '').toLowerCase().trim();
+ // Respond identically no matter what happens below.
+ try {
+ if (email) {
+ const user = db.prepare("SELECT * FROM users WHERE email = ? AND auth_provider = 'local'").get(email);
+ if (user) {
+ if (!emailSvc.isConfigured()) {
+ // Loud, because the user will wait for an email that can never arrive and the
+ // generic response cannot tell them.
+ console.error(`[password-reset] NO EMAIL TRANSPORT CONFIGURED — reset requested for ${email} cannot be delivered.`);
+ } else {
+ const token = passwordReset.issue(user.id);
+ sendPasswordResetEmail(user, token, req).catch(e =>
+ console.error('[password-reset] send failed:', e && e.message));
+ logActivity(user.id, 'auth:password_reset_requested', null, null, getClientIp(req));
+ }
+ }
+ }
+ } catch (e) {
+ console.error('[password-reset] request error:', e && e.message);
+ }
+ return res.json(RESET_GENERIC_OK);
+});
+
+router.post('/reset-password', (req, res) => {
+ const { token, password } = req.body || {};
+ if (!password || String(password).length < passwordReset.MIN_PASSWORD_LENGTH) {
+ return res.status(400).json({ error: `Password must be at least ${passwordReset.MIN_PASSWORD_LENGTH} characters` });
+ }
+ const userId = passwordReset.consume(token, String(password));
+ if (!userId) return res.status(400).json({ error: 'This reset link is invalid or has expired. Request a new one.' });
+ // Someone who locked themselves out guessing must not stay locked out after proving
+ // control of the mailbox and choosing a new password.
+ loginLockout.reset(userId);
+ const u = db.prepare('SELECT email FROM users WHERE id = ?').get(userId);
+ logActivity(userId, 'auth:password_reset_completed', null, null, getClientIp(req));
+ console.log(`[password-reset] password changed for ${u ? u.email : userId}`);
+ // No session on purpose — see above.
+ return res.json({ ok: true, message: 'Password updated. You can now sign in.' });
+});
+
// ==================== TOTP MFA (#100) ====================
// Opt-in per-user, LOCAL accounts only (SSO IdPs own MFA). Enrollment is a two-step
// confirm (setup -> enable) so a mistyped secret can't lock anyone out. Recovery
diff --git a/server/server.js b/server/server.js
index 26ce7ac..a050fa4 100644
--- a/server/server.js
+++ b/server/server.js
@@ -362,6 +362,11 @@ app.use('/api/auth/register', rateLimit(60000, 5)); // 5 registrations per minut
app.use('/api/auth/totp/verify', rateLimit(60000, 10));
// Email-verification resend: cap so it can't be used to spray mail at an address.
app.use('/api/auth/resend-verification', rateLimit(60000, 5));
+// Self-service password reset. The request endpoint is the spray surface (it sends mail to
+// an address the caller supplies), so it gets the tighter cap; the redeem endpoint is a
+// 32-byte-token guess, capped mostly to keep the bcrypt work bounded.
+app.use('/api/auth/forgot-password', rateLimit(60000, 5));
+app.use('/api/auth/reset-password', rateLimit(60000, 10));
// Admin password-reset endpoint: even if an admin's session is compromised,
// cap the blast radius to 20 resets/min/IP. Express matches the longest
// path prefix first, so this fires before /api/auth catches the request.
diff --git a/server/services/signupEmails.js b/server/services/signupEmails.js
index a3eea96..00ed519 100644
--- a/server/services/signupEmails.js
+++ b/server/services/signupEmails.js
@@ -212,4 +212,29 @@ async function sendVerificationEmail(user, token, req) {
return sendEmail({ to: user.email, subject: 'Verify your email for ScreenTinker', text, html });
}
-module.exports = { sendSignupEmails, sendVerificationEmail };
+function escapeHtml(s) { return String(s == null ? '' : s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); }
+
+async function sendPasswordResetEmail(user, token, req) {
+ // Same public-origin resolution as verification/invites. The link lands on the SPA,
+ // which posts the token back to /api/auth/reset-password with the new password — the
+ // token is never redeemed by a bare GET, so a link-prefetching mail client cannot
+ // consume it.
+ const base = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
+ const url = `${base}/app#/reset-password?token=${encodeURIComponent(token)}`;
+ const who = user.name || user.email;
+ const text = `Hi ${who},
+
+Someone asked to reset the password for your ScreenTinker account.
+
+Open this link to choose a new password (valid for 1 hour, and usable once):
+${url}
+
+If this wasn't you, you can ignore this email — your password has not changed.`;
+ const html = `
Hi ${escapeHtml(who)},
+Someone asked to reset the password for your ScreenTinker account.
+Choose a new password — the link is valid for 1 hour and can be used once.
+If this wasn't you, you can ignore this email — your password has not changed.
`;
+ return sendEmail({ to: user.email, subject: 'Reset your ScreenTinker password', text, html });
+}
+
+module.exports = { sendSignupEmails, sendVerificationEmail, sendPasswordResetEmail };
diff --git a/server/test/password-reset.test.js b/server/test/password-reset.test.js
new file mode 100644
index 0000000..9d6c429
--- /dev/null
+++ b/server/test/password-reset.test.js
@@ -0,0 +1,195 @@
+'use strict';
+
+// Self-service password reset. Until now the only ways back into an account were an admin
+// setting your password for you, or shell access to run scripts/reset-admin.js — so a
+// self-hosted operator who forgot their password had no path at all.
+//
+// The security-relevant properties, each pinned below:
+//
+// - NO ENUMERATION. The request endpoint answers identically whether or not the address
+// exists, and whether or not it is an SSO account with no password to reset.
+// - NO MFA BYPASS. Completing a reset does NOT issue a session. The user logs in
+// afterwards, so a TOTP-enabled account still has to pass its second factor. A reset
+// that returned a token would be a way to turn "I read one email" into a full session
+// without the second factor.
+// - SINGLE USE, SHORT LIVED. The token is stored only as a hash, works once, and expires.
+// - LOCAL ACCOUNTS ONLY. SSO identities have no local password.
+// - IT ACTUALLY UNBLOCKS YOU. A reset clears the per-account login lockout, otherwise
+// someone who locked themselves out by guessing would reset and still be locked out.
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert/strict');
+const { spawn } = require('node:child_process');
+const path = require('node:path');
+const os = require('node:os');
+const fs = require('node:fs');
+const crypto = require('node:crypto');
+const Database = require('better-sqlite3');
+
+const { freePort } = require('./helpers/free-port');
+let PORT, BASE, proc, db;
+const DATA_DIR = path.join(os.tmpdir(), 'st-pwreset-' + crypto.randomBytes(4).toString('hex'));
+const LOG = path.join(os.tmpdir(), 'st-pwreset-' + crypto.randomBytes(4).toString('hex') + '.log');
+const PW = 'Passw0rd123';
+const NEW_PW = 'BrandNewPw456';
+const S = {};
+
+const jfetch = async (p, opts = {}) => {
+ const res = await fetch(BASE + p, opts);
+ let body = null; try { body = await res.json(); } catch { /* */ }
+ return { status: res.status, body };
+};
+const post = (obj) => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(obj) });
+
+const forgot = (email) => jfetch('/api/auth/forgot-password', post({ email }));
+const reset = (token, password) => jfetch('/api/auth/reset-password', post({ token, password }));
+const login = (email, password) => jfetch('/api/auth/login', post({ email, password }));
+
+// The emailed token is only ever stored as a hash, so a test reads the plaintext the way
+// the user would: it cannot. Instead we mint through the same lib the route uses.
+const issueTokenFor = (userId) => require('../lib/passwordReset').issue(userId);
+
+async function register(email) {
+ const r = await jfetch('/api/auth/register', post({ email, password: PW }));
+ return r.body;
+}
+
+before(async () => {
+ PORT = await freePort();
+ BASE = `http://127.0.0.1:${PORT}`;
+ const logFd = fs.openSync(LOG, 'w');
+ proc = spawn('node', ['server.js'], {
+ cwd: path.join(__dirname, '..'),
+ env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' },
+ stdio: ['ignore', logFd, logFd],
+ });
+ let up = false;
+ for (let i = 0; i < 80; i++) {
+ try { const r = await fetch(BASE + '/api/status'); if (r.ok) { up = true; break; } } catch { /* */ }
+ await new Promise(r => setTimeout(r, 250));
+ }
+ if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
+ process.env.DATA_DIR = DATA_DIR; // so the lib below opens the same DB the server uses
+ db = new Database(path.join(DATA_DIR, 'db', 'remote_display.db'));
+
+ S.admin = await register('admin' + crypto.randomBytes(4).toString('hex') + '@x.local');
+ S.email = 'u' + crypto.randomBytes(5).toString('hex') + '@x.local';
+ S.user = await register(S.email);
+});
+after(() => { try { db && db.close(); } catch { /* */ } try { proc.kill('SIGKILL'); } catch { /* */ } });
+
+// ---------------------------------------------------------------------------
+// Enumeration resistance
+// ---------------------------------------------------------------------------
+test('the request endpoint cannot be used to discover which addresses exist', async () => {
+ const real = await forgot(S.email);
+ const fake = await forgot('definitely-not-a-user-' + crypto.randomBytes(4).toString('hex') + '@x.local');
+ assert.equal(real.status, fake.status, 'status must match for real and unknown addresses');
+ assert.deepEqual(real.body, fake.body, 'body must match for real and unknown addresses');
+ assert.equal(real.status, 200);
+});
+
+test('a malformed address is answered the same way, not validated into an oracle', async () => {
+ const bad = await forgot('not-an-email');
+ const real = await forgot(S.email);
+ assert.equal(bad.status, real.status);
+ assert.deepEqual(bad.body, real.body);
+});
+
+// ---------------------------------------------------------------------------
+// The reset itself
+// ---------------------------------------------------------------------------
+test('a valid token sets a new password, and the old one stops working', async () => {
+ const token = issueTokenFor(S.user.user.id);
+ const r = await reset(token, NEW_PW);
+ assert.equal(r.status, 200, `reset should succeed, got ${JSON.stringify(r.body)}`);
+
+ assert.equal((await login(S.email, PW)).status, 401, 'the OLD password must stop working');
+ const ok = await login(S.email, NEW_PW);
+ assert.equal(ok.status, 200, 'the NEW password works');
+ assert.ok(ok.body.token, 'and yields a session on normal login');
+});
+
+test('a token works exactly once', async () => {
+ const token = issueTokenFor(S.user.user.id);
+ assert.equal((await reset(token, 'FirstUse12345')).status, 200);
+ assert.equal((await reset(token, 'SecondUse12345')).status, 400, 'replay must fail');
+ assert.equal((await login(S.email, 'SecondUse12345')).status, 401, 'and must not have changed the password');
+});
+
+test('an unknown or expired token is refused', async () => {
+ assert.equal((await reset(crypto.randomBytes(32).toString('hex'), NEW_PW)).status, 400, 'unknown token');
+ assert.equal((await reset('', NEW_PW)).status, 400, 'empty token');
+
+ const token = issueTokenFor(S.user.user.id);
+ db.prepare('UPDATE users SET password_reset_expires = ? WHERE id = ?')
+ .run(Math.floor(Date.now() / 1000) - 60, S.user.user.id);
+ assert.equal((await reset(token, NEW_PW)).status, 400, 'expired token');
+});
+
+test('the new password must meet the same minimum as registration', async () => {
+ const token = issueTokenFor(S.user.user.id);
+ const r = await reset(token, 'short');
+ assert.equal(r.status, 400, 'a too-short password is refused');
+ assert.match(r.body.error, /8/, 'and says why');
+});
+
+// ---------------------------------------------------------------------------
+// The properties that keep this from becoming a bypass
+// ---------------------------------------------------------------------------
+test('completing a reset does NOT hand out a session (so TOTP is still enforced)', async () => {
+ const token = issueTokenFor(S.user.user.id);
+ const r = await reset(token, 'AnotherPw7890');
+ assert.equal(r.status, 200);
+ assert.equal(r.body.token, undefined, 'a reset must never return a session token');
+ assert.equal(r.body.user, undefined, 'nor a user object');
+});
+
+test('a reset clears the per-account login lockout', async () => {
+ // Drive this over HTTP, not against the lib: the lockout Map lives in the SERVER
+ // process, so touching it in the test process would prove nothing.
+ //
+ // Each attempt carries a different X-Forwarded-For so the per-IP limiter (10/min) gives
+ // a fresh bucket every time while the per-ACCOUNT counter still accumulates — which is
+ // precisely the distributed case the account lockout exists for.
+ const lockout = require('../lib/login-lockout');
+ const email = 'lock' + crypto.randomBytes(5).toString('hex') + '@x.local';
+ const u = await register(email);
+
+ const failFrom = (i) => jfetch('/api/auth/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': `203.0.113.${i}` },
+ body: JSON.stringify({ email, password: 'wrong-password' }),
+ });
+ for (let i = 1; i <= lockout.MAX_FAILS; i++) {
+ const r = await failFrom(i);
+ assert.equal(r.status, 401, `failure ${i} must reach the handler, not the IP limiter`);
+ }
+
+ // Locked: even the CORRECT password is refused, with the same generic body.
+ const blocked = await jfetch('/api/auth/login', {
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.200' },
+ body: JSON.stringify({ email, password: PW }),
+ });
+ assert.equal(blocked.status, 401, 'the account is locked before the reset');
+
+ assert.equal((await reset(issueTokenFor(u.user.id), 'UnlockedPw123')).status, 200);
+
+ const after = await jfetch('/api/auth/login', {
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.201' },
+ body: JSON.stringify({ email, password: 'UnlockedPw123' }),
+ });
+ assert.equal(after.status, 200, 'resetting a password must let you back in');
+ assert.ok(after.body.token);
+});
+
+test('an SSO account has no local password to reset', async () => {
+ const ssoEmail = 'sso' + crypto.randomBytes(4).toString('hex') + '@x.local';
+ const id = crypto.randomUUID();
+ db.prepare("INSERT INTO users (id, email, password_hash, auth_provider, plan_id) VALUES (?,?,NULL,'google','free')")
+ .run(id, ssoEmail);
+ const r = await forgot(ssoEmail);
+ assert.equal(r.status, 200, 'still answered identically — no oracle');
+ const row = db.prepare('SELECT password_reset_hash FROM users WHERE id = ?').get(id);
+ assert.equal(row.password_reset_hash, null, 'but no reset token is minted for an SSO identity');
+});