diff --git a/server/routes/auth.js b/server/routes/auth.js index 724dc19..7c35f3e 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -165,7 +165,10 @@ function issueSession(req, res, user, extra = {}) { logSuccessfulLogin(user.id, user.email, getClientIp(req)); const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup }); const token = generateToken(user, workspaceId); - const { password_hash, ...safeUser } = user; + // #100: callers pass a SELECT * row. Strip password_hash AND the TOTP internals + // (the encrypted secret + the replay counter) so no secret/internal rides in the + // response body - "secrets never in responses", same as the API token work. + const { password_hash, totp_secret_enc, totp_last_step, ...safeUser } = user; res.json({ token, user: safeUser, current_workspace_id: workspaceId, ...extra }); } diff --git a/server/test/totp.test.js b/server/test/totp.test.js index c2de1c5..08151bf 100644 --- a/server/test/totp.test.js +++ b/server/test/totp.test.js @@ -81,6 +81,10 @@ test('/totp/verify completes login via recovery code; single-use; surfaces remai assert.ok(v1.body.token, 'recovery code yields a full session token'); assert.equal(v1.body.via_recovery, true); assert.equal(v1.body.recovery_codes_remaining, 9, 'one code consumed'); + // "secrets never in responses": the encrypted TOTP secret + replay counter must not leak + assert.ok(!JSON.stringify(v1.body).includes('totp_secret_enc'), 'no encrypted TOTP secret in the response body'); + assert.equal(v1.body.user.totp_secret_enc, undefined, 'user object carries no totp_secret_enc'); + assert.equal(v1.body.user.totp_last_step, undefined, 'user object carries no totp_last_step'); assert.equal((await jfetch('/api/auth/me', auth(v1.body.token))).status, 200, 'full token works'); // reuse the SAME recovery code -> rejected (single-use) const l2 = await jfetch('/api/auth/login', post(null, { email: u.email, password: PW }));