From 7df68f655045066563dd1bd9f842b792003efbf9 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 25 Apr 2026 11:50:41 -0700 Subject: [PATCH 1/4] fix(auth): add current password verification endpoint Add a read-only credential check so client-side vault setup can verify the current account password before persisting encrypted data. Made-with: Cursor --- routes/auth.js | 54 ++++++++++++++++++ tests/auth.routes.test.js | 113 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) diff --git a/routes/auth.js b/routes/auth.js index 97ac945..5782a31 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -74,6 +74,16 @@ const VerifySchema = z.object({ token: z.string().regex(/^[0-9a-f]{64}$/), }); +// Body schema for /auth/verify-password — a re-prove-password ceremony with +// no side effects. Used by callers that need to bind a client-side derived +// secret to the account credential before they act on it (e.g. the vault +// first-write flow, where the same password derives both authHash AND the +// client-only vaultKey, and an unverified mismatch would silently lock the +// vault under a key that diverges from the account credential). +const VerifyPasswordSchema = z.object({ + authHash: HEX_32_SCHEMA, +}); + // PR 7 — account deletion (GDPR "right to erasure"). // // Requires the user to re-prove possession of the current password @@ -890,6 +900,50 @@ function createAuthRouter({ }) ); + // ------------------------------------------------------------------------- + // POST /auth/verify-password + // + // Read-only credential check. Caller submits an authHash derived from + // a typed password (same client-side PBKDF2 → HKDF chain as /login, + // /change-password, /totp/setup), and the server confirms it matches + // the stored credential for the authenticated user. + // + // 204 on match, 401 on mismatch, 400 on malformed body, 503 on KDF + // misconfiguration. The handler never mutates server state — no + // session rotation, no counter bump, nothing — so it's safe to call + // as a precondition step inside other client flows without disturbing + // the live session. + // + // Specifically motivated by the vault first-write path: the same + // password is fed into both `deriveAuthHash` (compared here) and + // `deriveVaultKey` (used client-side only). Verifying authHash + // against the server before saving the encrypted blob guarantees + // the vaultKey that wraps the blob is consistent with the account + // credential. Without this check a typo at first import diverges the + // two and locks the vault to a credential the user no longer + // remembers — a bug we hit in practice (Apr 2026). + // ------------------------------------------------------------------------- + router.post( + '/verify-password', + sessionMw.requireAuth, + csrfMw.require, + (req, res) => { + const parsed = VerifyPasswordSchema.safeParse(req.body); + if (!parsed.success) return badRequest(res, 'invalid_body'); + if ( + !verifyPasswordStepUp( + req, + res, + parsed.data.authHash, + 'auth.verify_password' + ) + ) { + return undefined; + } + return res.status(204).end(); + } + ); + // ------------------------------------------------------------------------- // DELETE /auth/account // diff --git a/tests/auth.routes.test.js b/tests/auth.routes.test.js index c1fb4f7..f9f3faa 100644 --- a/tests/auth.routes.test.js +++ b/tests/auth.routes.test.js @@ -303,6 +303,119 @@ describe('auth routes', () => { expect(res.status).toBe(401); }); + // --------------------------------------------------------------------- + // POST /auth/verify-password — read-only "is this the user's current + // password?" probe. Used by callers that need to bind a client-side + // derivation to the account credential before acting on it (vault + // first-write being the motivating case). MUST be a no-op on success + // (204) and not rotate sessions / counters / anything. + // --------------------------------------------------------------------- + + describe('POST /auth/verify-password', () => { + test('204 on matching authHash; the session and the stored credential are untouched', async () => { + const agent = request.agent(ctx.app); + await agent + .post('/auth/register') + .send({ email: 'user@example.com', authHash: SAMPLE_AUTH }); + const token = ctx.mailer.outbox[0].html.match(/token=([0-9a-f]{64})/)[1]; + await agent.post('/auth/verify-email').send({ token }); + const loginRes = await agent + .post('/auth/login') + .send({ email: 'user@example.com', authHash: SAMPLE_AUTH }); + const csrf = extractCookies(loginRes).csrf; + + const res = await agent + .post('/auth/verify-password') + .set('X-CSRF-Token', csrf) + .send({ authHash: SAMPLE_AUTH }); + expect(res.status).toBe(204); + // 204 → no body. + expect(res.text).toBe(''); + + // The current session is still usable (verify did not log us out). + const me = await agent.get('/auth/me'); + expect(me.status).toBe(200); + + // And the credential itself was not rotated by the call — a fresh + // login with the same authHash still succeeds. + const fresh = await request(ctx.app) + .post('/auth/login') + .send({ email: 'user@example.com', authHash: SAMPLE_AUTH }); + expect(fresh.status).toBe(200); + }); + + test('401 on mismatching authHash; subsequent login with the real password still works', async () => { + const agent = request.agent(ctx.app); + await agent + .post('/auth/register') + .send({ email: 'user@example.com', authHash: SAMPLE_AUTH }); + const token = ctx.mailer.outbox[0].html.match(/token=([0-9a-f]{64})/)[1]; + await agent.post('/auth/verify-email').send({ token }); + const loginRes = await agent + .post('/auth/login') + .send({ email: 'user@example.com', authHash: SAMPLE_AUTH }); + const csrf = extractCookies(loginRes).csrf; + + const bad = await agent + .post('/auth/verify-password') + .set('X-CSRF-Token', csrf) + .send({ authHash: 'deadbeef'.repeat(8) }); + expect(bad.status).toBe(401); + expect(bad.body.error).toBe('invalid_credentials'); + + // Re-prove the credential really wasn't rotated. + const fresh = await request(ctx.app) + .post('/auth/login') + .send({ email: 'user@example.com', authHash: SAMPLE_AUTH }); + expect(fresh.status).toBe(200); + }); + + test('400 on malformed body', async () => { + const agent = request.agent(ctx.app); + await agent + .post('/auth/register') + .send({ email: 'user@example.com', authHash: SAMPLE_AUTH }); + const token = ctx.mailer.outbox[0].html.match(/token=([0-9a-f]{64})/)[1]; + await agent.post('/auth/verify-email').send({ token }); + const loginRes = await agent + .post('/auth/login') + .send({ email: 'user@example.com', authHash: SAMPLE_AUTH }); + const csrf = extractCookies(loginRes).csrf; + + const res = await agent + .post('/auth/verify-password') + .set('X-CSRF-Token', csrf) + .send({ authHash: 'not-hex' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_body'); + }); + + test('401 when unauthenticated', async () => { + const res = await request(ctx.app) + .post('/auth/verify-password') + .send({ authHash: SAMPLE_AUTH }); + expect(res.status).toBe(401); + }); + + test('403 when CSRF header is missing', async () => { + const agent = request.agent(ctx.app); + await agent + .post('/auth/register') + .send({ email: 'user@example.com', authHash: SAMPLE_AUTH }); + const token = ctx.mailer.outbox[0].html.match(/token=([0-9a-f]{64})/)[1]; + await agent.post('/auth/verify-email').send({ token }); + await agent + .post('/auth/login') + .send({ email: 'user@example.com', authHash: SAMPLE_AUTH }); + + const res = await agent + .post('/auth/verify-password') + .send({ authHash: SAMPLE_AUTH }); + expect(res.status).toBe(403); + expect(res.body.error).toBe('csrf_missing'); + }); + }); + // --------------------------------------------------------------------- // PR 7 — atomic vault rewrap inside /auth/change-password // --------------------------------------------------------------------- From 7883fc748244803f424087a617307d05f27cc51d Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 25 Apr 2026 12:09:18 -0700 Subject: [PATCH 2/4] fix(auth): rate limit password verification per session Throttle current-password verification with a session-scoped bucket so abuse of one stolen session cannot block other active sessions for the same user. Made-with: Cursor --- lib/appFactory.js | 2 ++ middleware/rateLimit.js | 26 ++++++++++++++++++++++++++ middleware/rateLimit.test.js | 25 +++++++++++++++++++++++++ routes/auth.js | 1 + tests/auth.routes.test.js | 2 +- 5 files changed, 55 insertions(+), 1 deletion(-) diff --git a/lib/appFactory.js b/lib/appFactory.js index 1f236a0..ad7793e 100644 --- a/lib/appFactory.js +++ b/lib/appFactory.js @@ -178,6 +178,7 @@ function mountAuthAndVault( ? { login: rateLimiters.disabled(), mfaLogin: rateLimiters.disabled(), + verifyPassword: rateLimiters.disabled(), register: rateLimiters.disabled(), verifyEmail: rateLimiters.disabled(), vote: rateLimiters.disabled(), @@ -186,6 +187,7 @@ function mountAuthAndVault( : { login: rateLimiters.loginLimiter(), mfaLogin: rateLimiters.mfaLoginLimiter(), + verifyPassword: rateLimiters.verifyPasswordLimiter(), register: rateLimiters.registerLimiter(), verifyEmail: rateLimiters.verifyEmailLimiter(), vote: rateLimiters.voteLimiter(), diff --git a/middleware/rateLimit.js b/middleware/rateLimit.js index ca4b2ff..f80585f 100644 --- a/middleware/rateLimit.js +++ b/middleware/rateLimit.js @@ -47,6 +47,18 @@ function mfaLoginKey(req) { return `login-totp|${ipBucket(req)}`; } +// Per-session bucket for authenticated password re-checks. This endpoint is +// a credential oracle only after an attacker already has a live session+CSRF +// pair; keying by session contains abuse to that stolen session instead of +// letting it burn the whole user's account budget. The IP fallback keeps the +// limiter safe if a future caller mounts it in the wrong order. +function verifyPasswordKey(req) { + const sessionId = + req.session && req.session.id != null ? String(req.session.id) : null; + if (sessionId) return `verify-password|s${sessionId}`; + return `verify-password|ip|${ipBucket(req)}`; +} + function registerKey(req) { return `register|${ipBucket(req)}`; } @@ -117,6 +129,18 @@ function mfaLoginLimiter() { }); } +function verifyPasswordLimiter() { + return rateLimit({ + windowMs: 15 * MINUTE, + max: 10, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: verifyPasswordKey, + message: { error: 'too_many_attempts' }, + handler: trippedHandler('verify-password'), + }); +} + function registerLimiter() { return rateLimit({ windowMs: 60 * MINUTE, @@ -182,6 +206,7 @@ function disabled() { module.exports = { loginLimiter, mfaLoginLimiter, + verifyPasswordLimiter, registerLimiter, verifyEmailLimiter, voteLimiter, @@ -190,6 +215,7 @@ module.exports = { // Exported for direct unit testing. loginKey, mfaLoginKey, + verifyPasswordKey, registerKey, verifyEmailKey, voteKey, diff --git a/middleware/rateLimit.test.js b/middleware/rateLimit.test.js index c9d2aa3..2946ee5 100644 --- a/middleware/rateLimit.test.js +++ b/middleware/rateLimit.test.js @@ -1,6 +1,7 @@ const { loginKey, mfaLoginKey, + verifyPasswordKey, registerKey, reconcileKey, voteKey, @@ -70,6 +71,30 @@ describe('rate-limit key generators', () => { }); }); + describe('verifyPasswordKey', () => { + function mkSession(body, ip, session) { + return { ...mk(body, ip, { id: 42 }), session }; + } + + test('buckets by authenticated session.id when present', () => { + const a = verifyPasswordKey(mkSession({}, '1.2.3.4', { id: 100 })); + const b = verifyPasswordKey(mkSession({}, '9.9.9.9', { id: 100 })); + expect(a).toBe(b); + expect(a).toBe('verify-password|s100'); + }); + + test('two sessions for the same user get distinct password-check buckets', () => { + const a = verifyPasswordKey(mkSession({}, '1.2.3.4', { id: 100 })); + const b = verifyPasswordKey(mkSession({}, '1.2.3.4', { id: 200 })); + expect(a).not.toBe(b); + }); + + test('falls back to IP bucket when the user is missing', () => { + const a = verifyPasswordKey(mk({}, '1.2.3.4')); + expect(a).toBe('verify-password|ip|1.2.3.4'); + }); + }); + describe('registerKey', () => { test('is scoped per IPv4 only', () => { const a = registerKey(mk({ email: 'a@b.com' }, '1.2.3.4')); diff --git a/routes/auth.js b/routes/auth.js index 5782a31..4ede821 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -927,6 +927,7 @@ function createAuthRouter({ '/verify-password', sessionMw.requireAuth, csrfMw.require, + limiters.verifyPassword, (req, res) => { const parsed = VerifyPasswordSchema.safeParse(req.body); if (!parsed.success) return badRequest(res, 'invalid_body'); diff --git a/tests/auth.routes.test.js b/tests/auth.routes.test.js index f9f3faa..9c95ad9 100644 --- a/tests/auth.routes.test.js +++ b/tests/auth.routes.test.js @@ -1576,7 +1576,7 @@ describe('createAuthRouter factory contract (Codex round-2 P3)', () => { }, sessionMw: { requireAuth: mw, parse: mw, setSessionCookie: noop, clearSessionCookie: noop }, csrfMw: { require: mw, parse: mw, issueCookie: noop, clearCookie: noop }, - limiters: { login: mw, mfaLogin: mw, register: mw, verifyEmail: mw, vote: mw }, + limiters: { login: mw, mfaLogin: mw, verifyPassword: mw, register: mw, verifyEmail: mw, vote: mw }, baseUrl: 'http://api.test', frontendUrl: 'http://app.test', scheduler: (fn) => fn(), From ef75cc19bd26024c6120763ce1f6d0adcc747888 Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 25 Apr 2026 12:12:58 -0700 Subject: [PATCH 3/4] test(auth): wire verify password limiter in harnesses Update direct auth-router test mounts so the new verify-password middleware is present in full-suite CI. Made-with: Cursor --- tests/govProposals.routes.test.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/govProposals.routes.test.js b/tests/govProposals.routes.test.js index 232e08e..b4a929e 100644 --- a/tests/govProposals.routes.test.js +++ b/tests/govProposals.routes.test.js @@ -92,6 +92,7 @@ function buildApp({ limiters: { login: rateLimiters.disabled(), mfaLogin: rateLimiters.disabled(), + verifyPassword: rateLimiters.disabled(), register: rateLimiters.disabled(), verifyEmail: rateLimiters.disabled(), vote: rateLimiters.disabled(), @@ -571,6 +572,7 @@ describe('drafts CRUD', () => { limiters: { login: rateLimiters.disabled(), mfaLogin: rateLimiters.disabled(), + verifyPassword: rateLimiters.disabled(), register: rateLimiters.disabled(), verifyEmail: rateLimiters.disabled(), vote: rateLimiters.disabled(), From 864cfb0fde5ebddfe023188bccbf36ecded913db Mon Sep 17 00:00:00 2001 From: jagdeep sidhu Date: Sat, 25 Apr 2026 12:19:19 -0700 Subject: [PATCH 4/4] fix(auth): default verify password limiter middleware Avoid direct auth-router mount crashes when older test or integration harnesses omit the new verify-password limiter key. Made-with: Cursor --- routes/auth.js | 13 ++++++++++++- tests/auth.routes.test.js | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/routes/auth.js b/routes/auth.js index 4ede821..5ed4a76 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -120,6 +120,10 @@ function asyncHandler(fn) { Promise.resolve(fn(req, res, next)).catch(next); } +function noopMiddleware(_req, _res, next) { + next(); +} + function createAuthRouter({ users, sessions, @@ -138,6 +142,13 @@ function createAuthRouter({ if (typeof runAtomic !== 'function') { throw new Error('createAuthRouter: runAtomic is required'); } + const effectiveLimiters = { + ...limiters, + verifyPassword: + limiters && typeof limiters.verifyPassword === 'function' + ? limiters.verifyPassword + : noopMiddleware, + }; // `vaults` is optional in principle (some test harnesses mount auth // alone without a vault store), but /auth/change-password refuses to // serve when it is missing and the caller requests a vault-bearing @@ -927,7 +938,7 @@ function createAuthRouter({ '/verify-password', sessionMw.requireAuth, csrfMw.require, - limiters.verifyPassword, + effectiveLimiters.verifyPassword, (req, res) => { const parsed = VerifyPasswordSchema.safeParse(req.body); if (!parsed.success) return badRequest(res, 'invalid_body'); diff --git a/tests/auth.routes.test.js b/tests/auth.routes.test.js index 9c95ad9..086770e 100644 --- a/tests/auth.routes.test.js +++ b/tests/auth.routes.test.js @@ -1611,6 +1611,23 @@ describe('createAuthRouter factory contract (Codex round-2 P3)', () => { expect(() => createAuthRouter(buildArgs({ vaults: undefined }))).not.toThrow(); }); + test('accepts previous limiter shape without verifyPassword', () => { + const mw = (_req, _res, next) => next(); + expect(() => + createAuthRouter( + buildArgs({ + limiters: { + login: mw, + mfaLogin: mw, + register: mw, + verifyEmail: mw, + vote: mw, + }, + }) + ) + ).not.toThrow(); + }); + test('still rejects missing runAtomic regardless of vaults', () => { expect(() => createAuthRouter(buildArgs({ vaults: undefined, runAtomic: undefined }))