feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
import { test } from 'node:test';
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
import {
|
|
|
|
|
base32Encode, base32Decode, generateSecret, generateToken,
|
|
|
|
|
verifyToken, otpauthURI, generateRecoveryCodes,
|
|
|
|
|
} from '../../src/auth/totp.js';
|
|
|
|
|
|
|
|
|
|
// ── base32 round-trips ──────────────────────────────────────────────────────
|
|
|
|
|
test('base32 encode/decode round-trips arbitrary bytes', () => {
|
|
|
|
|
for (const s of ['', 'f', 'fo', 'foo', 'foob', 'fooba', 'foobar']) {
|
|
|
|
|
const buf = Buffer.from(s);
|
|
|
|
|
assert.deepEqual(base32Decode(base32Encode(buf)), buf);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── RFC 6238 Appendix B test vectors (SHA-1, 8 digits in the RFC; we use the
|
|
|
|
|
// low 6 here, so compare the last 6 digits of each published value). ──────────
|
|
|
|
|
// The RFC uses the ASCII secret "12345678901234567890". We base32-encode it and
|
|
|
|
|
// check the 6-digit code at each published timestamp.
|
|
|
|
|
test('matches RFC 6238 SHA-1 vectors (low 6 digits)', () => {
|
|
|
|
|
const secret = base32Encode(Buffer.from('12345678901234567890'));
|
|
|
|
|
// [unix seconds, full 8-digit code from the RFC] → expect last 6 digits.
|
|
|
|
|
const vectors = [
|
|
|
|
|
[59, '94287082'],
|
|
|
|
|
[1111111109, '07081804'],
|
|
|
|
|
[1111111111, '14050471'],
|
|
|
|
|
[1234567890, '89005924'],
|
|
|
|
|
[2000000000, '69279037'],
|
|
|
|
|
[20000000000, '65353130'],
|
|
|
|
|
];
|
|
|
|
|
for (const [secs, full8] of vectors) {
|
|
|
|
|
const got = generateToken(secret, secs * 1000);
|
|
|
|
|
assert.equal(got, full8.slice(-6), `t=${secs}`);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── verify with drift window ────────────────────────────────────────────────
|
fix(mam-api): harden TOTP login flow + tighten Google domain check
Review of the v2 auth landing turned up four weak spots in the MFA path.
All four are now fixed; behaviour is unchanged for the password-correct
+ correct-TOTP happy path.
1. TOTP brute-force gate (the big one). /login was calling
ipBackoff.recordSuccess(ip) the instant the password hashed correctly,
*before* the second factor was proven. That cleared the per-IP failure
counter, so each /login retry let an attacker with a known password
hammer the 6-digit /login/totp space (10^6) at full speed.
Now recordSuccess fires only inside establishSession() — i.e. after
every required factor has actually passed (password [+TOTP] or
OAuth [+TOTP]).
2. MFA ticket binding. Tickets issued by /login (and the Google callback)
were unbound — a stolen ticket replayed from a different origin still
worked. Tickets now carry SHA-256 hashes of the issuing request's IP
and User-Agent; redeemTicket rejects on mismatch. The ticket is burned
even on mismatch so a wrong-binding probe can't be retried.
3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier
accepted the same code as many times as you submitted it. Now
verifyToken returns the matched counter, and /login/totp does a CAS
UPDATE on users.totp_last_counter — codes at counters <= the last
accepted value are rejected. New migration 030 adds totp_last_counter,
seeded on /totp/enable so the enrollment code itself can't be reused
at first login, and zeroed on /totp/disable.
4. Google OAuth domain check no longer falls back to the email suffix
when the hd (hosted-domain) claim is missing. Email-suffix matching
let consumer (non-Workspace) Google accounts whose email happens to
end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set,
the operator means "only this Workspace", so accounts without a
verified hd must be rejected.
Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on
mismatch, and bindings-absent back-compat. totp.test.js updated for the
new verifyToken return shape (counter on success, null on failure;
truthiness still works at call sites) and adds an explicit
matched-counter check.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:52:53 -04:00
|
|
|
// verifyToken returns the matched counter (truthy) or null (falsy).
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
test('verifyToken accepts the current code and ±1 step of drift', () => {
|
|
|
|
|
const secret = generateSecret();
|
|
|
|
|
const now = 1_700_000_000_000;
|
|
|
|
|
const code = generateToken(secret, now);
|
fix(mam-api): harden TOTP login flow + tighten Google domain check
Review of the v2 auth landing turned up four weak spots in the MFA path.
All four are now fixed; behaviour is unchanged for the password-correct
+ correct-TOTP happy path.
1. TOTP brute-force gate (the big one). /login was calling
ipBackoff.recordSuccess(ip) the instant the password hashed correctly,
*before* the second factor was proven. That cleared the per-IP failure
counter, so each /login retry let an attacker with a known password
hammer the 6-digit /login/totp space (10^6) at full speed.
Now recordSuccess fires only inside establishSession() — i.e. after
every required factor has actually passed (password [+TOTP] or
OAuth [+TOTP]).
2. MFA ticket binding. Tickets issued by /login (and the Google callback)
were unbound — a stolen ticket replayed from a different origin still
worked. Tickets now carry SHA-256 hashes of the issuing request's IP
and User-Agent; redeemTicket rejects on mismatch. The ticket is burned
even on mismatch so a wrong-binding probe can't be retried.
3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier
accepted the same code as many times as you submitted it. Now
verifyToken returns the matched counter, and /login/totp does a CAS
UPDATE on users.totp_last_counter — codes at counters <= the last
accepted value are rejected. New migration 030 adds totp_last_counter,
seeded on /totp/enable so the enrollment code itself can't be reused
at first login, and zeroed on /totp/disable.
4. Google OAuth domain check no longer falls back to the email suffix
when the hd (hosted-domain) claim is missing. Email-suffix matching
let consumer (non-Workspace) Google accounts whose email happens to
end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set,
the operator means "only this Workspace", so accounts without a
verified hd must be rejected.
Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on
mismatch, and bindings-absent back-compat. totp.test.js updated for the
new verifyToken return shape (counter on success, null on failure;
truthiness still works at call sites) and adds an explicit
matched-counter check.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:52:53 -04:00
|
|
|
const baseCounter = Math.floor(now / 1000 / 30);
|
|
|
|
|
assert.equal(verifyToken(secret, code, now), baseCounter);
|
|
|
|
|
// 30s earlier / later still inside ±1 window — the *issued* code matches the
|
|
|
|
|
// baseCounter, but at now+30s we're in step baseCounter+1, so the issued
|
|
|
|
|
// code matches as drift = -1 step → returns baseCounter.
|
|
|
|
|
assert.equal(verifyToken(secret, code, now + 30_000), baseCounter);
|
|
|
|
|
assert.equal(verifyToken(secret, code, now - 30_000), baseCounter);
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
// 2 steps away → rejected.
|
fix(mam-api): harden TOTP login flow + tighten Google domain check
Review of the v2 auth landing turned up four weak spots in the MFA path.
All four are now fixed; behaviour is unchanged for the password-correct
+ correct-TOTP happy path.
1. TOTP brute-force gate (the big one). /login was calling
ipBackoff.recordSuccess(ip) the instant the password hashed correctly,
*before* the second factor was proven. That cleared the per-IP failure
counter, so each /login retry let an attacker with a known password
hammer the 6-digit /login/totp space (10^6) at full speed.
Now recordSuccess fires only inside establishSession() — i.e. after
every required factor has actually passed (password [+TOTP] or
OAuth [+TOTP]).
2. MFA ticket binding. Tickets issued by /login (and the Google callback)
were unbound — a stolen ticket replayed from a different origin still
worked. Tickets now carry SHA-256 hashes of the issuing request's IP
and User-Agent; redeemTicket rejects on mismatch. The ticket is burned
even on mismatch so a wrong-binding probe can't be retried.
3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier
accepted the same code as many times as you submitted it. Now
verifyToken returns the matched counter, and /login/totp does a CAS
UPDATE on users.totp_last_counter — codes at counters <= the last
accepted value are rejected. New migration 030 adds totp_last_counter,
seeded on /totp/enable so the enrollment code itself can't be reused
at first login, and zeroed on /totp/disable.
4. Google OAuth domain check no longer falls back to the email suffix
when the hd (hosted-domain) claim is missing. Email-suffix matching
let consumer (non-Workspace) Google accounts whose email happens to
end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set,
the operator means "only this Workspace", so accounts without a
verified hd must be rejected.
Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on
mismatch, and bindings-absent back-compat. totp.test.js updated for the
new verifyToken return shape (counter on success, null on failure;
truthiness still works at call sites) and adds an explicit
matched-counter check.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:52:53 -04:00
|
|
|
assert.equal(verifyToken(secret, code, now + 90_000), null);
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('verifyToken rejects malformed / empty input without throwing', () => {
|
|
|
|
|
const secret = generateSecret();
|
fix(mam-api): harden TOTP login flow + tighten Google domain check
Review of the v2 auth landing turned up four weak spots in the MFA path.
All four are now fixed; behaviour is unchanged for the password-correct
+ correct-TOTP happy path.
1. TOTP brute-force gate (the big one). /login was calling
ipBackoff.recordSuccess(ip) the instant the password hashed correctly,
*before* the second factor was proven. That cleared the per-IP failure
counter, so each /login retry let an attacker with a known password
hammer the 6-digit /login/totp space (10^6) at full speed.
Now recordSuccess fires only inside establishSession() — i.e. after
every required factor has actually passed (password [+TOTP] or
OAuth [+TOTP]).
2. MFA ticket binding. Tickets issued by /login (and the Google callback)
were unbound — a stolen ticket replayed from a different origin still
worked. Tickets now carry SHA-256 hashes of the issuing request's IP
and User-Agent; redeemTicket rejects on mismatch. The ticket is burned
even on mismatch so a wrong-binding probe can't be retried.
3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier
accepted the same code as many times as you submitted it. Now
verifyToken returns the matched counter, and /login/totp does a CAS
UPDATE on users.totp_last_counter — codes at counters <= the last
accepted value are rejected. New migration 030 adds totp_last_counter,
seeded on /totp/enable so the enrollment code itself can't be reused
at first login, and zeroed on /totp/disable.
4. Google OAuth domain check no longer falls back to the email suffix
when the hd (hosted-domain) claim is missing. Email-suffix matching
let consumer (non-Workspace) Google accounts whose email happens to
end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set,
the operator means "only this Workspace", so accounts without a
verified hd must be rejected.
Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on
mismatch, and bindings-absent back-compat. totp.test.js updated for the
new verifyToken return shape (counter on success, null on failure;
truthiness still works at call sites) and adds an explicit
matched-counter check.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:52:53 -04:00
|
|
|
assert.equal(verifyToken(secret, ''), null);
|
|
|
|
|
assert.equal(verifyToken(secret, 'abcdef'), null);
|
|
|
|
|
assert.equal(verifyToken(secret, '12345'), null); // too short
|
|
|
|
|
assert.equal(verifyToken(secret, '1234567'), null); // too long
|
|
|
|
|
assert.equal(verifyToken('', '123456'), null);
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('verifyToken tolerates spaces in the user-entered code', () => {
|
|
|
|
|
const secret = generateSecret();
|
|
|
|
|
const now = 1_700_000_000_000;
|
|
|
|
|
const code = generateToken(secret, now);
|
fix(mam-api): harden TOTP login flow + tighten Google domain check
Review of the v2 auth landing turned up four weak spots in the MFA path.
All four are now fixed; behaviour is unchanged for the password-correct
+ correct-TOTP happy path.
1. TOTP brute-force gate (the big one). /login was calling
ipBackoff.recordSuccess(ip) the instant the password hashed correctly,
*before* the second factor was proven. That cleared the per-IP failure
counter, so each /login retry let an attacker with a known password
hammer the 6-digit /login/totp space (10^6) at full speed.
Now recordSuccess fires only inside establishSession() — i.e. after
every required factor has actually passed (password [+TOTP] or
OAuth [+TOTP]).
2. MFA ticket binding. Tickets issued by /login (and the Google callback)
were unbound — a stolen ticket replayed from a different origin still
worked. Tickets now carry SHA-256 hashes of the issuing request's IP
and User-Agent; redeemTicket rejects on mismatch. The ticket is burned
even on mismatch so a wrong-binding probe can't be retried.
3. TOTP replay within the same 30s step (RFC 6238 §5.2). The verifier
accepted the same code as many times as you submitted it. Now
verifyToken returns the matched counter, and /login/totp does a CAS
UPDATE on users.totp_last_counter — codes at counters <= the last
accepted value are rejected. New migration 030 adds totp_last_counter,
seeded on /totp/enable so the enrollment code itself can't be reused
at first login, and zeroed on /totp/disable.
4. Google OAuth domain check no longer falls back to the email suffix
when the hd (hosted-domain) claim is missing. Email-suffix matching
let consumer (non-Workspace) Google accounts whose email happens to
end in the allowed domain through; if GOOGLE_ALLOWED_DOMAIN is set,
the operator means "only this Workspace", so accounts without a
verified hd must be rejected.
Tests: new mfa-tickets.test.js covers ip/UA binding, single-use on
mismatch, and bindings-absent back-compat. totp.test.js updated for the
new verifyToken return shape (counter on success, null on failure;
truthiness still works at call sites) and adds an explicit
matched-counter check.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 08:52:53 -04:00
|
|
|
const expected = Math.floor(now / 1000 / 30);
|
|
|
|
|
assert.equal(verifyToken(secret, code.slice(0, 3) + ' ' + code.slice(3), now), expected);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('verifyToken returns the matched counter (for replay protection)', () => {
|
|
|
|
|
const secret = generateSecret();
|
|
|
|
|
const now = 1_700_000_000_000;
|
|
|
|
|
const code = generateToken(secret, now);
|
|
|
|
|
const counter = verifyToken(secret, code, now);
|
|
|
|
|
assert.ok(typeof counter === 'number' && counter > 0);
|
|
|
|
|
assert.equal(counter, Math.floor(now / 1000 / 30));
|
feat(mam-api,web-ui): TOTP two-factor authentication
Optional time-based 2FA on top of password login. TOTP core is hand-rolled
on node:crypto (RFC 6238) — no runtime dep — and verified against the RFC
test vectors.
- migration 027: users.totp_secret/totp_enabled + user_recovery_codes
- src/auth/totp.js: base32, secret gen, RFC 6238 verify, otpauth URI,
recovery codes
- src/auth/mfa-tickets.js: short-lived single-use tickets bridging the two
login steps (in-memory, single-instance like the rate-limiter)
- auth routes: /totp/setup, /totp/enable (returns recovery codes once),
/totp/disable (password-confirmed); login returns {mfa_required, ticket}
when enabled, /login/totp completes with a code or recovery code
- /auth/me and loadUser surface totp_enabled
- web-ui: login second-factor step; Settings -> Account TOTP enroll (QR +
manual secret + recovery codes + disable)
- qrcode added as an optional dep; setup degrades to manual entry if absent
- tests: totp unit (RFC vectors) + integration (enable/login/recovery/disable)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:42:57 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── otpauth URI ─────────────────────────────────────────────────────────────
|
|
|
|
|
test('otpauthURI embeds secret, issuer, and account', () => {
|
|
|
|
|
const uri = otpauthURI('JBSWY3DPEHPK3PXP', 'alice', 'Dragonflight');
|
|
|
|
|
assert.match(uri, /^otpauth:\/\/totp\/Dragonflight%3Aalice\?/);
|
|
|
|
|
assert.match(uri, /secret=JBSWY3DPEHPK3PXP/);
|
|
|
|
|
assert.match(uri, /issuer=Dragonflight/);
|
|
|
|
|
assert.match(uri, /digits=6/);
|
|
|
|
|
assert.match(uri, /period=30/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── recovery codes ──────────────────────────────────────────────────────────
|
|
|
|
|
test('generateRecoveryCodes returns N distinct formatted codes', () => {
|
|
|
|
|
const codes = generateRecoveryCodes(10);
|
|
|
|
|
assert.equal(codes.length, 10);
|
|
|
|
|
assert.equal(new Set(codes).size, 10);
|
|
|
|
|
for (const c of codes) assert.match(c, /^[0-9a-f]{5}-[0-9a-f]{5}$/);
|
|
|
|
|
});
|