import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { createWebAuth, isSecureRequest, readCookie, SESSION_COOKIE } from '../src/http/web-auth.ts'; /** * The app's login. scrypt is deliberately slow, so these share one authenticator * rather than building one per test. */ const PASSWORD = 'ein-sehr-langes-testpasswort'; const auth = createWebAuth(PASSWORD); function cookieHeader(value: string): string { return `${SESSION_COOKIE}=${value}`; } describe('createWebAuth without a password', () => { it('is disabled, and nothing it returns opens anything', () => { // The app is not served at all in this case; the object exists so callers // need no branch, and every answer it gives is "no". const off = createWebAuth(undefined); assert.equal(off.enabled, false); assert.equal(off.check(PASSWORD, '::1').ok, false); assert.equal(off.verify(cookieHeader('anything')), false); }); }); describe('password check', () => { it('accepts the password and rejects everything else', () => { assert.equal(auth.check(PASSWORD, 'a').ok, true); assert.equal(auth.check(PASSWORD + 'x', 'a').ok, false); assert.equal(auth.check('', 'a').ok, false); }); it('locks an address out after repeated failures', () => { const from = 'brute-force'; let blocked; for (let attempt = 0; attempt < 12; attempt++) { blocked = auth.check('wrong', from); if (blocked.retryAfterSeconds !== undefined) break; } assert.ok(blocked?.retryAfterSeconds, 'expected a lockout with a retry hint'); // And the lockout holds even for the *right* password, or it would be no // lockout at all — the attacker only has to guess it once. assert.equal(auth.check(PASSWORD, from).ok, false); }); it('counts per address, so one attacker cannot lock the user out', () => { assert.equal(auth.check(PASSWORD, 'somebody-else').ok, true); }); it('forgets the failures once a login succeeds', () => { const from = 'recovers'; auth.check('wrong', from); auth.check('wrong', from); assert.equal(auth.check(PASSWORD, from).ok, true); assert.equal(auth.check(PASSWORD, from).ok, true); }); }); describe('session cookies', () => { it('mints a cookie it accepts back', () => { assert.equal(auth.verify(cookieHeader(auth.mint())), true); }); it('mints a different value every time', () => { assert.notEqual(auth.mint(), auth.mint()); }); it('refuses a tampered signature', () => { const value = auth.mint(); assert.equal(auth.verify(cookieHeader(`${value.slice(0, -1)}${value.at(-1) === 'A' ? 'B' : 'A'}`)), false); }); it('refuses an extended expiry, which is the point of signing it', () => { const value = auth.mint(); const signature = value.slice(value.lastIndexOf('.') + 1); assert.equal(auth.verify(cookieHeader(`${Date.now() + 10 ** 12}.nonce.${signature}`)), false); }); it('refuses an expired cookie even with a good signature', () => { // Signed by this key, but for a moment that has passed. const body = `${Date.now() - 1000}.nonce`; const fresh = auth.mint(); const shape = `${body}.${fresh.slice(fresh.lastIndexOf('.') + 1)}`; assert.equal(auth.verify(cookieHeader(shape)), false); }); it('refuses nonsense and an absent cookie', () => { for (const value of ['', 'x', 'a.b', '...']) assert.equal(auth.verify(cookieHeader(value)), false, value); assert.equal(auth.verify(undefined), false); assert.equal(auth.verify('other=1'), false); }); it('is not accepted by an authenticator built from a different password', () => { // Changing the password logs everyone out, because the signing key is // derived from it. const other = createWebAuth('ein-ganz-anderes-passwort'); assert.equal(other.verify(cookieHeader(auth.mint())), false); }); it('is HttpOnly and SameSite=Strict, and Secure only over TLS', () => { const secure = auth.cookie('v', { secure: true }); assert.match(secure, /HttpOnly/); assert.match(secure, /SameSite=Strict/); assert.match(secure, /Secure/); // Marking it Secure on a plain connection makes it vanish, which looks // exactly like a broken login. assert.doesNotMatch(auth.cookie('v', { secure: false }), /Secure/); }); it('clears with an immediate expiry', () => { assert.match(auth.clearCookie({ secure: true }), /Max-Age=0/); }); }); describe('readCookie', () => { it('finds one cookie among several', () => { assert.equal(readCookie('a=1; sc_app=wanted; b=2', 'sc_app'), 'wanted'); }); it('does not match a name that merely ends the same way', () => { assert.equal(readCookie('not_sc_app=no', 'sc_app'), undefined); }); it('is undefined for no header', () => { assert.equal(readCookie(undefined, 'sc_app'), undefined); }); }); describe('isSecureRequest', () => { const request = (headers: Record, protocol = 'http') => ({ get: (name: string) => headers[name.toLowerCase()], protocol }) as never; it('trusts the forwarded protocol, which is all there is behind a proxy', () => { assert.equal(isSecureRequest(request({ 'x-forwarded-proto': 'https' })), true); assert.equal(isSecureRequest(request({ 'x-forwarded-proto': 'http' })), false); }); it('reads only the first hop of a chain', () => { assert.equal(isSecureRequest(request({ 'x-forwarded-proto': 'https, http' })), true); }); it('falls back to the connection when nothing forwarded it', () => { assert.equal(isSecureRequest(request({}, 'https')), true); assert.equal(isSecureRequest(request({})), false); }); });