50 lines
2.3 KiB
JavaScript
50 lines
2.3 KiB
JavaScript
|
|
// MFA ticket binding tests — the second login step's tickets are bound to the
|
||
|
|
// issuing request's IP + User-Agent (hashed) so a stolen ticket replayed from
|
||
|
|
// a different origin can't complete the second factor.
|
||
|
|
import { test } from 'node:test';
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import { issueTicket, redeemTicket } from '../../src/auth/mfa-tickets.js';
|
||
|
|
|
||
|
|
test('ticket round-trips when ip + userAgent match', () => {
|
||
|
|
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
||
|
|
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), 'user-1');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('ticket rejects redemption from a different IP', () => {
|
||
|
|
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
||
|
|
assert.equal(redeemTicket(id, { ip: '9.9.9.9', userAgent: 'curl/8' }), null);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('ticket rejects redemption with a different User-Agent', () => {
|
||
|
|
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
||
|
|
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'Mozilla/5.0' }), null);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('ticket is single-use even on binding mismatch', () => {
|
||
|
|
// A wrong-binding probe must still burn the ticket — otherwise an attacker
|
||
|
|
// could try multiple IPs/UAs against the same ticket.
|
||
|
|
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
||
|
|
assert.equal(redeemTicket(id, { ip: '9.9.9.9', userAgent: 'curl/8' }), null);
|
||
|
|
// Same ticket with correct bindings now also fails — it was consumed.
|
||
|
|
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), null);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('redeemTicket returns null for missing or unknown id', () => {
|
||
|
|
assert.equal(redeemTicket(null), null);
|
||
|
|
assert.equal(redeemTicket(undefined), null);
|
||
|
|
assert.equal(redeemTicket(''), null);
|
||
|
|
assert.equal(redeemTicket('not-a-real-id', { ip: 'x', userAgent: 'y' }), null);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('ticket is single-use on success', () => {
|
||
|
|
const id = issueTicket('user-1', { ip: '1.2.3.4', userAgent: 'curl/8' });
|
||
|
|
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), 'user-1');
|
||
|
|
assert.equal(redeemTicket(id, { ip: '1.2.3.4', userAgent: 'curl/8' }), null);
|
||
|
|
});
|
||
|
|
|
||
|
|
test('issueTicket without bindings still works (back-compat / tests)', () => {
|
||
|
|
const id = issueTicket('user-1');
|
||
|
|
// No bindings on redeem either — both sides skip the check.
|
||
|
|
assert.equal(redeemTicket(id), 'user-1');
|
||
|
|
});
|