An OPTIONAL shared token so the app can be used off-LAN (via the relay/tunnel) more safely than "anyone who reaches the port gets a shell". Sits IN FRONT OF the existing Origin/CSRF model — never replacing it — and is fully inert when unset. - src/http/auth.ts (new, pure): constantTimeEqual hashes both inputs to sha256 (32 bytes) then crypto.timingSafeEqual — no `===`, no length side-channel, never throws. parseCookieHeader / cookieIsAuthed / buildSetCookie / isAuthEnabled. - WEBTERM_TOKEN in config: 16–512 URL/cookie-safe chars or the server refuses to start. - GET /?token=<t> or POST /auth (rate-limited 10/min) validates → sets HttpOnly; SameSite=Strict; Secure-when-https cookie; public/login.html (no <script>). - When enabled: the WS handshake (AFTER the Origin check, before handleUpgrade) + a central authGate over all remote HTTP require the cookie. Open: /login, /auth. Loopback bypass scoped to /hook* ONLY (tighter than the plan — gates the local browser too). - Unset ⇒ isAuthEnabled false ⇒ pure passthrough (LAN zero-config unchanged). Honest tradeoff (in code + CLAUDE.md + login page): a bar-raiser, NOT a TLS/Tailscale substitute — on bare ws:// the token is cleartext + replayable; only hardens the TLS-terminated relay path. Verified: typecheck + build:web clean, 2118 pass at --test-timeout=30000 (disabled-mode regression proven; only the known tmux flake red); auth.ts 100% line coverage.
140 lines
5.9 KiB
TypeScript
140 lines
5.9 KiB
TypeScript
/**
|
|
* test/auth.test.ts — unit tests for src/http/auth.ts (w5-access-token).
|
|
*
|
|
* Mirrors the discipline of test/origin.test.ts. Security-critical: the
|
|
* constant-time comparison, the cookie flags, and the disabled short-circuit.
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest'
|
|
import {
|
|
AUTH_COOKIE_NAME,
|
|
buildSetCookie,
|
|
constantTimeEqual,
|
|
cookieIsAuthed,
|
|
isAuthEnabled,
|
|
isHttpsRequest,
|
|
parseCookieHeader,
|
|
} from '../src/http/auth.js'
|
|
|
|
const TOKEN = 'super-secret-token-1234' // ≥16, safe charset
|
|
|
|
// ── constantTimeEqual ─────────────────────────────────────────────────────────
|
|
describe('constantTimeEqual', () => {
|
|
it('returns true for equal strings', () => {
|
|
expect(constantTimeEqual(TOKEN, TOKEN)).toBe(true)
|
|
})
|
|
|
|
it('returns false for unequal same-length strings', () => {
|
|
expect(constantTimeEqual('aaaaaaaaaaaaaaaa', 'aaaaaaaaaaaaaaab')).toBe(false)
|
|
})
|
|
|
|
it('returns false for different-length strings without throwing (fixed-length guard)', () => {
|
|
// The SHA-256-to-fixed-32-bytes guard means unequal lengths never throw.
|
|
expect(() => constantTimeEqual('short', 'a-much-longer-candidate-value')).not.toThrow()
|
|
expect(constantTimeEqual('short', 'a-much-longer-candidate-value')).toBe(false)
|
|
})
|
|
|
|
it('returns false for an empty candidate (present-vs-absent short-circuit)', () => {
|
|
expect(constantTimeEqual('', TOKEN)).toBe(false)
|
|
expect(constantTimeEqual(TOKEN, '')).toBe(false)
|
|
})
|
|
|
|
it('returns false for an undefined candidate', () => {
|
|
expect(constantTimeEqual(undefined, TOKEN)).toBe(false)
|
|
expect(constantTimeEqual(TOKEN, undefined)).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ── parseCookieHeader ─────────────────────────────────────────────────────────
|
|
describe('parseCookieHeader', () => {
|
|
it('parses a multi-pair cookie header into a map', () => {
|
|
const map = parseCookieHeader('a=1; webterm_auth=xyz; b=2')
|
|
expect(map).toEqual({ a: '1', webterm_auth: 'xyz', b: '2' })
|
|
})
|
|
|
|
it('returns {} for a missing or empty header', () => {
|
|
expect(parseCookieHeader(undefined)).toEqual({})
|
|
expect(parseCookieHeader('')).toEqual({})
|
|
})
|
|
|
|
it('ignores malformed pairs (no = / empty name)', () => {
|
|
const map = parseCookieHeader('novalue; =orphan; webterm_auth=ok')
|
|
expect(map).toEqual({ webterm_auth: 'ok' })
|
|
})
|
|
})
|
|
|
|
// ── isAuthEnabled ─────────────────────────────────────────────────────────────
|
|
describe('isAuthEnabled', () => {
|
|
it('is false when the token is undefined', () => {
|
|
expect(isAuthEnabled({ webtermToken: undefined })).toBe(false)
|
|
})
|
|
|
|
it('is false when the token is an empty string', () => {
|
|
expect(isAuthEnabled({ webtermToken: '' })).toBe(false)
|
|
})
|
|
|
|
it('is true when a token is set', () => {
|
|
expect(isAuthEnabled({ webtermToken: TOKEN })).toBe(true)
|
|
})
|
|
})
|
|
|
|
// ── cookieIsAuthed ────────────────────────────────────────────────────────────
|
|
describe('cookieIsAuthed', () => {
|
|
it('is true for a correct auth cookie', () => {
|
|
expect(cookieIsAuthed({ webtermToken: TOKEN }, `${AUTH_COOKIE_NAME}=${TOKEN}`)).toBe(true)
|
|
})
|
|
|
|
it('is false for a wrong cookie value', () => {
|
|
expect(cookieIsAuthed({ webtermToken: TOKEN }, `${AUTH_COOKIE_NAME}=nope-nope-nope-nope`)).toBe(false)
|
|
})
|
|
|
|
it('is false when the auth cookie is absent', () => {
|
|
expect(cookieIsAuthed({ webtermToken: TOKEN }, 'other=1')).toBe(false)
|
|
expect(cookieIsAuthed({ webtermToken: TOKEN }, undefined)).toBe(false)
|
|
})
|
|
|
|
it('is false when auth is disabled (no token) regardless of cookie', () => {
|
|
expect(cookieIsAuthed({ webtermToken: undefined }, `${AUTH_COOKIE_NAME}=anything`)).toBe(false)
|
|
})
|
|
})
|
|
|
|
// ── buildSetCookie ────────────────────────────────────────────────────────────
|
|
describe('buildSetCookie', () => {
|
|
it('includes the token value and the required flags', () => {
|
|
const c = buildSetCookie({ webtermToken: TOKEN }, { secure: false })
|
|
expect(c).toContain(`${AUTH_COOKIE_NAME}=${TOKEN}`)
|
|
expect(c).toContain('Path=/')
|
|
expect(c).toContain('Max-Age=')
|
|
expect(c).toContain('HttpOnly')
|
|
expect(c).toContain('SameSite=Strict')
|
|
})
|
|
|
|
it('omits Secure when opts.secure is false (LAN over http)', () => {
|
|
expect(buildSetCookie({ webtermToken: TOKEN }, { secure: false })).not.toContain('Secure')
|
|
})
|
|
|
|
it('includes Secure when opts.secure is true (relay over https)', () => {
|
|
expect(buildSetCookie({ webtermToken: TOKEN }, { secure: true })).toContain('; Secure')
|
|
})
|
|
})
|
|
|
|
// ── isHttpsRequest ────────────────────────────────────────────────────────────
|
|
describe('isHttpsRequest', () => {
|
|
it('is true when x-forwarded-proto is https (TLS-terminating edge)', () => {
|
|
expect(isHttpsRequest({ headers: { 'x-forwarded-proto': 'https' } })).toBe(true)
|
|
})
|
|
|
|
it('reads the first value of a comma-joined x-forwarded-proto', () => {
|
|
expect(isHttpsRequest({ headers: { 'x-forwarded-proto': 'https, http' } })).toBe(true)
|
|
})
|
|
|
|
it('is true when the socket is encrypted (direct TLS)', () => {
|
|
expect(isHttpsRequest({ headers: {}, socket: { encrypted: true } })).toBe(true)
|
|
})
|
|
|
|
it('is false for a plain http request', () => {
|
|
expect(isHttpsRequest({ headers: {}, socket: { encrypted: false } })).toBe(false)
|
|
expect(isHttpsRequest({ headers: { 'x-forwarded-proto': 'http' } })).toBe(false)
|
|
})
|
|
})
|