/** * test/integration/auth.test.ts — real-server integration for the optional * access-token gate (w5-access-token). Pattern mirrors integration/server.test.ts. * * Two axes: * - DISABLED (no WEBTERM_TOKEN): everything works byte-identically to today — * WS connects on Origin alone, state-changing routes gated only by Origin. * - ENABLED: the gate + WS cookie check activate ALONGSIDE (never replacing) * the Origin/CSRF model. * * All traffic here is loopback (127.0.0.1). The gate's loopback bypass is scoped * to the /hook* ingest paths only, so non-hook routes are genuinely gated even * from 127.0.0.1 — which is what makes the "unauthed → 401/302" cases testable. * * Each test gets its own port + server (beforeEach) so the per-IP auth rate * limiter never leaks across cases. */ import net from 'node:net' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import WebSocket from 'ws' import { loadConfig } from '../../src/config.js' import { startServer } from '../../src/server.js' import type { Config } from '../../src/types.js' const TOKEN = 'integration-secret-token-01' // ≥16, safe charset const COOKIE = `webterm_auth=${TOKEN}` function getFreePort(): Promise { return new Promise((resolve, reject) => { const srv = net.createServer() srv.listen(0, '127.0.0.1', () => { const addr = srv.address() if (!addr || typeof addr === 'string') { srv.close() reject(new Error('unexpected address type')) return } const port = addr.port srv.close(() => resolve(port)) }) srv.on('error', reject) }) } /** Build a config, optionally with WEBTERM_TOKEN set (auth enabled). */ function makeCfg(port: number, withToken: boolean): Config { const env: Record = { PORT: String(port), BIND_HOST: '127.0.0.1', SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, SCROLLBACK_BYTES: String(64 * 1024), IDLE_TTL: '86400', USE_TMUX: '0', } if (withToken) env['WEBTERM_TOKEN'] = TOKEN return loadConfig(env) } /** Resolve iff the WS opens; reject if it errors/closes before open. */ function waitForOpen(ws: WebSocket, timeoutMs = 3_000): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('WS open timeout')), timeoutMs) ws.once('open', () => { clearTimeout(timer) resolve() }) ws.once('error', (err) => { clearTimeout(timer) reject(err) }) ws.once('close', () => { clearTimeout(timer) reject(new Error('WS closed before open')) }) }) } // ── DISABLED: zero behaviour change (the regression proof) ────────────────────── describe('access-token gate — DISABLED (WEBTERM_TOKEN unset)', () => { let port: number let handle: { close(): Promise } beforeEach(async () => { port = await getFreePort() handle = startServer(makeCfg(port, false)) await new Promise((r) => setTimeout(r, 80)) }) afterEach(async () => { await handle.close() await new Promise((r) => setTimeout(r, 50)) }) it('WS handshake succeeds with a valid Origin and NO cookie (unchanged)', async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) await expect(waitForOpen(ws)).resolves.toBeUndefined() ws.terminate() }) it('GET /live-sessions is open (no cookie required)', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions`) expect(res.status).toBe(200) expect(Array.isArray(await res.json())).toBe(true) }) it('GET / serves the app (200, no login redirect)', async () => { const res = await fetch(`http://127.0.0.1:${port}/`, { headers: { Accept: 'text/html' }, redirect: 'manual', }) expect(res.status).toBe(200) }) it('DELETE /live-sessions works with a valid Origin and no cookie (Origin-only, as today)', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { method: 'DELETE', headers: { Origin: `http://127.0.0.1:${port}` }, }) expect(res.status).toBe(200) }) it('DELETE /live-sessions still 403s a foreign Origin (CSRF layer intact)', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { method: 'DELETE', headers: { Origin: 'http://evil.example' }, }) expect(res.status).toBe(403) }) }) // ── ENABLED: the gate + WS cookie check activate ──────────────────────────────── describe('access-token gate — ENABLED (WEBTERM_TOKEN set)', () => { let port: number let handle: { close(): Promise } beforeEach(async () => { port = await getFreePort() handle = startServer(makeCfg(port, true)) await new Promise((r) => setTimeout(r, 80)) }) afterEach(async () => { await handle.close() await new Promise((r) => setTimeout(r, 50)) }) // ── WS handshake ────────────────────────────────────────────────────────── it('WS: valid Origin + NO cookie → handshake rejected (401)', async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: `http://127.0.0.1:${port}` }, }) await expect(waitForOpen(ws)).rejects.toThrow() ws.terminate() }) it('WS: valid Origin + WRONG cookie → handshake rejected (401)', async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: `http://127.0.0.1:${port}`, Cookie: 'webterm_auth=wrong-value-000000' }, }) await expect(waitForOpen(ws)).rejects.toThrow() ws.terminate() }) it('WS: valid Origin + VALID cookie → handshake accepted (opens)', async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: `http://127.0.0.1:${port}`, Cookie: COOKIE }, }) await expect(waitForOpen(ws)).resolves.toBeUndefined() ws.terminate() }) it('WS: the Origin check still runs FIRST — bad Origin + valid cookie → 401 (additive, not replacing)', async () => { const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: 'http://evil.example', Cookie: COOKIE }, }) await expect(waitForOpen(ws)).rejects.toThrow() ws.terminate() }) // ── POST /auth ──────────────────────────────────────────────────────────── it('POST /auth with the WRONG token → 401 JSON (XHR)', async () => { const res = await fetch(`http://127.0.0.1:${port}/auth`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: 'nope-nope-nope-nope-00' }), }) expect(res.status).toBe(401) }) it('POST /auth with the CORRECT token (form) → 302 + Set-Cookie with HttpOnly/SameSite/Path/Max-Age, NO Secure over http', async () => { const res = await fetch(`http://127.0.0.1:${port}/auth`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', Accept: 'text/html' }, body: `token=${encodeURIComponent(TOKEN)}`, redirect: 'manual', }) expect(res.status).toBe(302) const setCookie = res.headers.get('set-cookie') ?? '' expect(setCookie).toContain(`webterm_auth=${TOKEN}`) expect(setCookie).toContain('HttpOnly') expect(setCookie).toContain('SameSite=Strict') expect(setCookie).toContain('Path=/') expect(setCookie).toContain('Max-Age=') expect(setCookie).not.toContain('Secure') }) it('POST /auth with the CORRECT token (XHR) → 204 + Set-Cookie', async () => { const res = await fetch(`http://127.0.0.1:${port}/auth`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: TOKEN }), }) expect(res.status).toBe(204) expect(res.headers.get('set-cookie') ?? '').toContain('webterm_auth=') }) it('POST /auth over x-forwarded-proto: https → Set-Cookie INCLUDES Secure', async () => { const res = await fetch(`http://127.0.0.1:${port}/auth`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-forwarded-proto': 'https' }, body: JSON.stringify({ token: TOKEN }), }) expect(res.status).toBe(204) expect(res.headers.get('set-cookie') ?? '').toContain('Secure') }) it('POST /auth is rate-limited: >10 bad attempts/min → 429', async () => { let saw429 = false for (let i = 0; i < 12; i++) { const res = await fetch(`http://127.0.0.1:${port}/auth`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ token: 'bad-attempt-value-000' }), }) if (res.status === 429) saw429 = true } expect(saw429).toBe(true) }) // ── GET /?token= bootstrap ────────────────────────────────────────────────── it('GET /?token= → 302 to a path with NO token + Set-Cookie', async () => { const res = await fetch(`http://127.0.0.1:${port}/?token=${encodeURIComponent(TOKEN)}`, { redirect: 'manual', }) expect(res.status).toBe(302) const loc = res.headers.get('location') ?? '' expect(loc).not.toContain('token') expect(res.headers.get('set-cookie') ?? '').toContain('webterm_auth=') }) it('GET /?token= → 302 to /login, NO cookie', async () => { const res = await fetch(`http://127.0.0.1:${port}/?token=wrong-token-value-00`, { redirect: 'manual', }) expect(res.status).toBe(302) expect(res.headers.get('location')).toBe('/login') expect(res.headers.get('set-cookie')).toBeNull() }) // ── /login reachable + unauthed routing ───────────────────────────────────── it('GET /login → 200 HTML, reachable while unauthed', async () => { const res = await fetch(`http://127.0.0.1:${port}/login`) expect(res.status).toBe(200) expect(res.headers.get('content-type') ?? '').toContain('text/html') expect(await res.text()).toContain(' { const res = await fetch(`http://127.0.0.1:${port}/login?e=1`) expect(await res.text()).toContain('show-error') }) it('unauthed HTML navigation (GET /, no cookie) → 302 /login', async () => { const res = await fetch(`http://127.0.0.1:${port}/`, { headers: { Accept: 'text/html' }, redirect: 'manual', }) expect(res.status).toBe(302) expect(res.headers.get('location')).toBe('/login') }) it('unauthed XHR (GET /live-sessions, no cookie) → 401 JSON', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions`) expect(res.status).toBe(401) expect(await res.json()).toEqual({ error: 'authentication required' }) }) it('authed XHR (GET /live-sessions with valid cookie) → 200', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { headers: { Cookie: COOKIE }, }) expect(res.status).toBe(200) expect(Array.isArray(await res.json())).toBe(true) }) // ── Gate + CSRF stacking on a state-changing route ────────────────────────── it('DELETE /live-sessions, valid Origin, NO cookie → 401 (gate blocks before CSRF)', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { method: 'DELETE', headers: { Origin: `http://127.0.0.1:${port}` }, }) expect(res.status).toBe(401) }) it('DELETE /live-sessions, valid Origin + valid cookie → 200 (passes gate, then CSRF)', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { method: 'DELETE', headers: { Origin: `http://127.0.0.1:${port}`, Cookie: COOKIE }, }) expect(res.status).toBe(200) }) it('DELETE /live-sessions, valid cookie but FOREIGN Origin → 403 (CSRF layer still independent)', async () => { const res = await fetch(`http://127.0.0.1:${port}/live-sessions`, { method: 'DELETE', headers: { Origin: 'http://evil.example', Cookie: COOKIE }, }) expect(res.status).toBe(403) }) // ── Loopback hook ingest bypass (guard against regressing the side-channel) ── it('POST /hook from loopback with NO cookie still returns 204 (hooks unaffected)', async () => { const res = await fetch(`http://127.0.0.1:${port}/hook`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-webterm-session': '00000000-0000-4000-8000-000000000000', }, body: JSON.stringify({ hook_event_name: 'Stop' }), }) expect(res.status).toBe(204) }) })