pediatric-ai-scribe-v3/test/login-codes.test.js
Daniel 22683f3584
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
feat: sign in with a code emailed to you, offered beside the password
The sign-in screen asks for an email first, then offers both ways in together:
a six-digit code sent to that address, or the password. Beside rather than
instead — a code depends on mail being delivered and a password does not, so
neither may be the only route. "Use a different email" goes back a step, and
creating an account stays where it was.

What keeps it from being a second, weaker front door:

- Only a bcrypt hash is stored, so a code read out of the database is not a
  working credential.
- Ten minutes, single use, marked used before the session is issued so a replay
  cannot race it, and requesting a new one deletes the old.
- Five wrong guesses burn it. Six digits is a million possibilities, which is
  plenty against a person and nothing against a script with unlimited tries.
- Requesting a code answers identically whether or not the address exists, and
  every verify failure returns one message. A sign-in screen that says "no such
  account" is a way of finding out who has one.
- Two-factor still applies: a code proves you can read the mailbox, which is one
  factor, and an account that asked for a second still wants it.
- Its own rate limits, tighter for requesting than for attempting, because
  requesting sends mail to someone else's address. These had to be separate
  limiters: Express matches app.use paths on segment boundaries, so
  /api/auth/login does not cover /api/auth/login-code — checked against a real
  router rather than assumed.

Two bugs found while building it, both mine:

authFetch keeps an allowlist of endpoints callable with no verified owner and
rejects everything else before it is sent. The new endpoints were not on it, so
the request never left the browser and surfaced as "Connection error".

reveal() hid elements by appending 'hidden' to className and showed them with a
non-global replace, so hiding twice left two copies and showing stripped one.
The "use a different email" link never reappeared. It uses classList now, which
is idempotent.

Verified against the running server: correct code signs in, the same code again
is refused, a superseded code is refused, five wrong guesses burn it, an expired
one is refused, and the stored value is a hash. In the browser: requesting a
code advances the screen, a wrong code is refused without losing the screen, and
the password route still signs in.

Not yet demonstrated: a correct code typed into the browser. The harness keeps
racing the one-live-code rule — the page's own request supersedes whatever code
the test holds, and with SMTP off the delivered one cannot be read. The same
request reaches the server on the wrong-code path, and the endpoint itself is
verified, but that last step is untested end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 20:12:03 +02:00

96 lines
5.1 KiB
JavaScript

// ============================================================
// SIGN-IN CODES
// ============================================================
// A code emailed to you, offered beside the password rather than instead of it.
// The rules here are what keep it from being a second, weaker front door.
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const path = require('path');
const lc = require('../src/utils/loginCodes');
const read = p => fs.readFileSync(path.join(__dirname, '..', p), 'utf8');
test('codes are six digits and uniformly drawn', () => {
const seen = new Set();
for (let i = 0; i < 3000; i++) {
const code = lc.generate();
assert.match(code, /^\d{6}$/);
seen.add(code);
}
// Rejection sampling, not modulo on a random byte, which would make low
// digits likelier.
assert.match(read('src/utils/loginCodes.js'), /while \(value >= Math\.floor\(0xFFFFFFFF \/ max\) \* max\)/);
assert.ok(seen.size > 2800, 'not repeating itself');
assert.equal(lc.normalise(' 12 34-56 '), '123456');
});
test('a code is stored as a hash, used once, and superseded by the next', () => {
const src = read('src/utils/loginCodes.js');
// A code read out of the database must not be a working credential.
assert.match(src, /bcrypt\.hash\(code, 10\)/);
// The value bound to code_hash is the hash, never the code itself.
assert.match(src, /\[userId, await bcrypt\.hash\(code, 10\)\]/);
assert.doesNotMatch(src, /\[userId, code\]/, 'the plaintext is never inserted');
// Asking for a new one must not leave the old one working.
assert.match(src, /DELETE FROM login_codes WHERE user_id = \?/);
// Marked used before a session is issued, so a replay cannot race it.
assert.match(src, /UPDATE login_codes SET used_at = NOW\(\) WHERE id = \? AND used_at IS NULL RETURNING id/);
assert.match(src, /expires_at > NOW\(\)/);
assert.match(src, /row\.attempts >= MAX_ATTEMPTS/);
assert.equal(lc.MAX_ATTEMPTS, 5);
assert.equal(lc.TTL_MINUTES, 10);
});
test('neither endpoint says whether an account exists', () => {
const route = read('src/routes/auth.js');
// A sign-in screen that answers "no such account" is a way of finding out who
// has one, which is worth more to an attacker than the code is.
assert.match(route, /var GENERIC = \{ success: true, message: 'If that account exists, a code is on its way\.' \};/);
assert.match(route, /if \(!user \|\| user\.disabled\) \{\s*\n\s*console\.warn\('\[Auth\] login-code: no deliverable account/);
// One refusal for every failure on verify, too.
assert.match(route, /var REFUSED = \{ error: 'That code is not valid\./);
// A code proves you can read the mailbox. An account that asked for a second
// factor still wants it.
assert.match(route, /if \(user\.totp_enabled\) \{\s*\n\s*if \(!totpCode\) return res\.json\(\{ requires2FA: true \}\);/);
});
test('the new endpoints are rate limited, and reachable while signed out', () => {
const server = read('server.js');
// Express matches app.use paths on segment boundaries, so the /api/auth/login
// limiter does not cover /api/auth/login-code — verified against a real
// router, not assumed.
assert.match(server, /app\.use\('\/api\/auth\/login-code\/request', rateLimit\(\{/);
assert.match(server, /app\.use\('\/api\/auth\/login-code\/verify', rateLimit\(\{/);
// Asking for a code sends mail to someone else's address, so it is limited
// more tightly than an attempt to sign in.
assert.match(server, /LOGIN_CODE_RATE_LIMIT_MAX \|\| '5'/);
// authFetch rejects any /api path not on its allowlist before it is sent,
// which surfaced as "Connection error" with no request leaving the browser.
const wrapper = read('public/js/authFetch.js');
assert.match(wrapper, /'\/api\/auth\/login-code\/request', '\/api\/auth\/login-code\/verify'/);
assert.match(wrapper, /url\.pathname === '\/api\/auth\/login-code\/verify'/, 'and it counts as a login');
});
test('the screen asks for the email first and always offers both ways in', () => {
const html = read('public/index.html');
assert.match(html, /id="btn-login-continue"/);
assert.match(html, /id="btn-login-send-code"/);
assert.match(html, /id="btn-login-use-password"/);
assert.match(html, /id="login-change-email"/);
// Creating an account stays on the first step.
assert.match(html, /id="show-register"/);
const js = read('public/js/auth.js');
// A code needs mail to arrive and a password does not, so the password option
// sits beside the code option rather than behind it.
assert.match(js, /reveal\('login-choice', step === 'choice'\)/);
assert.match(js, /loginMode === 'code' \? submitCode : submitLogin/);
// classList, not a string replace: hiding twice appended 'hidden' twice and a
// non-global replace stripped only one, so the element stayed hidden forever.
assert.match(js, /if \(on\) el\.classList\.remove\('hidden'\); else el\.classList\.add\('hidden'\);/);
// One response handler, so a code sign-in gets the same 2FA prompt and the
// same welcome as a password one.
assert.match(js, /function handleSignIn\(data, attempt\)/);
});