screentinker/server/test/totp.test.js
ScreenTinker f4c5865013
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
fix(server): strip totp_secret_enc/totp_last_step from login responses (#100)
Review caught the encrypted TOTP secret riding in the login/verify response body:
issueSession receives a SELECT * user row and only destructured out password_hash, so
totp_secret_enc (and the internal replay counter totp_last_step) leaked. Encrypted, so
not catastrophic, but it regresses the API work's "secrets never in responses" rule.
Strip both in issueSession (covers /login and /totp/verify); add an assertion that a
verify response carries no totp_secret_enc.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:48:55 -05:00

116 lines
6 KiB
JavaScript

'use strict';
// #100 integration: boots the real server and drives the TOTP route flow end to end.
// /totp/verify completions use RECOVERY codes (deterministic) - the TOTP-code path +
// replay are covered in totp-unit.test.js (time-based codes are awkward over HTTP).
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 { authenticator } = require('otplib');
const PORT = 3979;
const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-totp-test-' + crypto.randomBytes(4).toString('hex'));
const LOG = path.join(os.tmpdir(), 'st-totp-' + crypto.randomBytes(4).toString('hex') + '.log');
let proc;
async function jfetch(p, opts = {}) {
const res = await fetch(BASE + p, opts);
let body = null; try { body = await res.json(); } catch { /* non-JSON */ }
return { status: res.status, body };
}
const auth = (tok, extra = {}) => ({ headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json', ...extra } });
const post = (tok, obj, extra) => ({ method: 'POST', ...auth(tok, extra), body: JSON.stringify(obj || {}) });
before(async () => {
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 { /* not yet */ }
await new Promise(r => setTimeout(r, 250));
}
if (!up) throw new Error('server did not boot:\n' + fs.readFileSync(LOG, 'utf8').slice(-2000));
});
after(() => { try { proc.kill('SIGKILL'); } catch { /* ignore */ } });
const PW = 'Passw0rd123';
async function newUser() {
const email = 'u' + crypto.randomBytes(5).toString('hex') + '@x.local';
const r = await jfetch('/api/auth/register', post(null, { email, password: PW }));
return { email, token: r.body.token };
}
async function enroll(token) {
const s = await jfetch('/api/auth/totp/setup', post(token, {}));
const e = await jfetch('/api/auth/totp/enable', post(token, { code: authenticator.generate(s.body.secret) }));
return { secret: s.body.secret, recovery: e.body.recovery_codes };
}
test('enrollment: setup -> enable issues 10 recovery codes; status reflects it', async () => {
const u = await newUser();
const { recovery } = await enroll(u.token);
assert.equal(recovery.length, 10);
const st = await jfetch('/api/auth/totp/status', auth(u.token));
assert.equal(st.body.enabled, true);
assert.equal(st.body.recovery_codes_remaining, 10);
});
test('login with TOTP -> mfa_required (no full token); route-level bite: mfa_token 401s a protected route', async () => {
const u = await newUser(); await enroll(u.token);
const login = await jfetch('/api/auth/login', post(null, { email: u.email, password: PW }));
assert.equal(login.body.mfa_required, true);
assert.ok(login.body.mfa_token, 'got an mfa_token');
assert.equal(login.body.token, undefined, 'NO full session token before the TOTP step');
const me = await jfetch('/api/auth/me', auth(login.body.mfa_token));
assert.equal(me.status, 401, 'mfa_pending token must 401 a protected route');
});
test('/totp/verify completes login via recovery code; single-use; surfaces remaining', async () => {
const u = await newUser(); const { recovery } = await enroll(u.token);
const l1 = await jfetch('/api/auth/login', post(null, { email: u.email, password: PW }));
const v1 = await jfetch('/api/auth/totp/verify', post(null, { mfa_token: l1.body.mfa_token, code: recovery[0] }));
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 }));
const v2 = await jfetch('/api/auth/totp/verify', post(null, { mfa_token: l2.body.mfa_token, code: recovery[0] }));
assert.equal(v2.status, 401, 'used recovery code is rejected');
});
test('API token BYPASSES TOTP: an st_ token works while the owner has TOTP enabled', async () => {
const u = await newUser();
await enroll(u.token);
// the pre-existing session token (issued at register, before enroll) still works -
// enabling TOTP does NOT invalidate it - so it can mint an API token:
const t = await jfetch('/api/tokens', post(u.token, { name: 'ci', scope: 'read' }));
const secret = Object.values(t.body || {}).find(v => typeof v === 'string' && v.startsWith('st_'));
assert.ok(secret, 'got an st_ token (existing JWT still valid post-enroll)');
const r = await jfetch('/api/devices', auth(secret));
assert.equal(r.status, 200, 'st_ token reaches a protected route despite TOTP being on');
});
test('verify lockout: repeated bad codes -> 429 (per-user, atop the route rate-limit)', async () => {
const u = await newUser(); await enroll(u.token);
const mfa = (await jfetch('/api/auth/login', post(null, { email: u.email, password: PW }))).body.mfa_token;
let last;
for (let i = 0; i < 6; i++) {
last = await jfetch('/api/auth/totp/verify', post(null, { mfa_token: mfa, code: '000000' }));
}
assert.equal(last.status, 429, 'locked out after repeated bad codes');
});