/**
 * @fileoverview Authentication and security middleware.
 *
 * Extracted from server.ts setupRoutes() — handles:
 * - HTTP Basic Auth with session cookies
 * - Rate limiting (per-IP failure tracking)
 * - Security headers (CSP, X-Frame-Options, HSTS)
 * - CORS (localhost only)
 */
import { randomBytes, timingSafeEqual } from 'node:crypto';
import { StaleExpirationMap } from '../../utils/index.js';
import { isAllowedRequestHost, isAllowedRequestOrigin } from '../network-auth-policy.js';
import { AUTH_SESSION_TTL_MS, MAX_AUTH_SESSIONS, AUTH_FAILURE_MAX, AUTH_FAILURE_WINDOW_MS, } from '../../config/auth-config.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../../config/hook-secret.js';
// Auth session cookie name
export const AUTH_COOKIE_NAME = 'codeman_session';
/**
 * Register HTTP Basic Auth middleware with session cookies and rate limiting.
 * Only active when CODEMAN_PASSWORD is set.
 *
 * @param getTunnelRunning - returns true while a managed tunnel is active. Used
 *   to gate the `/api/hook-event` localhost bypass: when a tunnel is up, tunneled
 *   internet traffic reaches the loopback origin with `req.ip === 127.0.0.1`, so
 *   the bypass additionally requires the shared hook secret (COD-54). When no
 *   tunnel is running (loopback-only, the normal case) the plain localhost bypass
 *   is kept so already-deployed (pre-secret) hooks + the loop channel keep working.
 *   Optional; defaults to "no tunnel" (unchanged behavior) when omitted.
 * @returns AuthState for lifecycle management (dispose on server stop)
 */
export function registerAuthMiddleware(app, https, getTunnelRunning = () => false) {
    const state = {
        authSessions: null,
        authFailures: null,
        qrAuthFailures: null,
        hookSecretFailures: null,
    };
    const authPassword = process.env.CODEMAN_PASSWORD;
    if (!authPassword)
        return state;
    const authUsername = process.env.CODEMAN_USERNAME || 'admin';
    const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64');
    // Session token store — active sessions extend TTL on access
    state.authSessions = new StaleExpirationMap({
        ttlMs: AUTH_SESSION_TTL_MS,
        refreshOnGet: true,
    });
    // Failure counter per IP — decay naturally after 15 minutes
    state.authFailures = new StaleExpirationMap({
        ttlMs: AUTH_FAILURE_WINDOW_MS,
        refreshOnGet: false,
    });
    // Separate QR auth failure counter — independent from Basic Auth failures
    state.qrAuthFailures = new StaleExpirationMap({
        ttlMs: AUTH_FAILURE_WINDOW_MS,
        refreshOnGet: false,
    });
    // Separate hook-secret failure counter (COD-54). MUST NOT share authFailures:
    // legacy (pre-secret) hook configs fire constantly from 127.0.0.1, and counting
    // their 401s against the shared bucket would 429 every cookie-less request from
    // loopback — locking out the Basic-Auth login path (and, through a tunnel, every
    // client, since tunneled traffic also arrives as 127.0.0.1).
    state.hookSecretFailures = new StaleExpirationMap({
        ttlMs: AUTH_FAILURE_WINDOW_MS,
        refreshOnGet: false,
    });
    const authSessions = state.authSessions;
    const authFailures = state.authFailures;
    const hookSecretFailures = state.hookSecretFailures;
    function sendAuthRateLimit(reply, clientIp, failures = authFailures) {
        const remainingMs = failures.getRemainingTtl(clientIp) ?? AUTH_FAILURE_WINDOW_MS;
        const retryAfterSeconds = Math.max(1, Math.ceil(remainingMs / 1000));
        reply.header('Retry-After', String(retryAfterSeconds));
        reply.code(429).send('Too Many Requests — try again later');
    }
    app.addHook('onRequest', (req, reply, done) => {
        // Hook events come from local Claude Code hooks (curl from localhost) — no
        // Basic-Auth credentials available. Validated downstream by HookEventSchema.
        //
        // COD-54: the bare localhost bypass is unsafe while a tunnel is running, because
        // `cloudflared --url http://127.0.0.1:port` proxies internet traffic INTO the
        // loopback origin, so a tunneled request arrives with req.ip === 127.0.0.1 and
        // would pass. So:
        //   - tunnel running → bypass requires the shared hook secret (local hooks present
        //     it via the X-Codeman-Hook-Secret header; internet traffic can't know it),
        //   - tunnel not running (loopback-only, the normal case) → keep the plain
        //     localhost bypass so already-deployed (pre-secret) hooks + the loop's own
        //     credential-less hook channel keep working.
        if (req.url === '/api/hook-event' && req.method === 'POST') {
            const ip = req.ip;
            const isLoopback = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
            if (isLoopback) {
                if (!getTunnelRunning()) {
                    // Loopback-only: unchanged behavior.
                    done();
                    return;
                }
                // Tunnel up: require the shared secret (constant-time compare).
                const presented = Buffer.from(req.headers[HOOK_SECRET_HEADER.toLowerCase()]?.toString() ?? '');
                const expected = Buffer.from(getHookSecret());
                if (presented.length === expected.length && timingSafeEqual(presented, expected)) {
                    done();
                    return;
                }
                // Wrong/absent secret while tunneled — rate-limit per IP in the DEDICATED
                // hook bucket (never authFailures, which would lock out the login path).
                const hookIp = req.ip;
                const hookFailures = hookSecretFailures.get(hookIp) ?? 0;
                if (hookFailures >= AUTH_FAILURE_MAX) {
                    sendAuthRateLimit(reply, hookIp, hookSecretFailures);
                    return;
                }
                hookSecretFailures.set(hookIp, hookFailures + 1);
                reply.code(401).send('Unauthorized: hook secret required');
                return;
            }
            // Non-localhost hook requests fall through to normal auth
        }
        // QR auth path — handled by the route itself (token validation + rate limiting)
        if (req.url?.startsWith('/q/')) {
            done();
            return;
        }
        const clientIp = req.ip;
        // Check session cookie first (avoids re-sending credentials on every request)
        // Use get() instead of has() so refreshOnGet extends the TTL on active sessions
        const sessionToken = req.cookies[AUTH_COOKIE_NAME];
        if (sessionToken && authSessions.get(sessionToken) !== undefined) {
            done();
            return;
        }
        // Check Basic Auth header (timing-safe comparison to prevent side-channel attacks)
        const auth = req.headers.authorization;
        const authBuf = Buffer.from(auth ?? '');
        const expectedBuf = Buffer.from(expectedHeader);
        if (authBuf.length === expectedBuf.length && timingSafeEqual(authBuf, expectedBuf)) {
            // Issue session token cookie so browser doesn't need to re-send credentials
            const token = randomBytes(32).toString('hex');
            // Evict oldest if at capacity (prevent unbounded growth)
            if (authSessions.size >= MAX_AUTH_SESSIONS) {
                const oldestKey = authSessions.keys().next().value;
                if (oldestKey !== undefined)
                    authSessions.delete(oldestKey);
            }
            authSessions.set(token, {
                ip: clientIp,
                ua: req.headers['user-agent'] ?? '',
                createdAt: Date.now(),
                method: 'basic',
            });
            // Reset failure count on successful auth
            authFailures.delete(clientIp);
            reply.setCookie(AUTH_COOKIE_NAME, token, {
                httpOnly: true,
                secure: https,
                sameSite: 'lax',
                maxAge: AUTH_SESSION_TTL_MS / 1000, // seconds
                path: '/',
            });
            done();
            return;
        }
        // Rate limit only requests that failed to authenticate on this attempt.
        const failures = authFailures.get(clientIp) ?? 0;
        if (failures >= AUTH_FAILURE_MAX) {
            sendAuthRateLimit(reply, clientIp);
            return;
        }
        // Auth failed — track failure count
        authFailures.set(clientIp, failures + 1);
        reply.header('WWW-Authenticate', 'Basic realm="Codeman"');
        reply.code(401).send('Unauthorized');
    });
    return state;
}
/** Methods that don't change server state and so skip the cross-site Origin check. */
const SAFE_HTTP_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/**
 * Register the anti-DNS-rebinding Host allowlist + cross-site (CSRF) Origin guard.
 *
 * This protects the API even on the default no-password install, where there is no
 * cookie/credential to gate on. It must be registered BEFORE the auth middleware so
 * forged cross-site or DNS-rebound requests are rejected up front. `getPolicy` is
 * evaluated per request so a tunnel started at runtime is reflected immediately.
 *
 * - Every request: the `Host` header must be in the allowlist (blocks DNS rebinding,
 *   where a custom domain is rebound to 127.0.0.1 but still sends its own name).
 * - State-changing methods: the `Origin` (when the client sends one — i.e. a browser)
 *   must be same-site (blocks cross-site CSRF, including the text/plain simple-request
 *   trick). Non-browser clients (curl, Claude Code hooks) omit Origin and pass.
 *
 * WebSocket upgrades are validated separately in the ws route handler.
 */
export function registerHostGuard(app, getPolicy) {
    app.addHook('onRequest', (req, reply, done) => {
        const policy = getPolicy();
        if (!isAllowedRequestHost(req.headers.host, policy)) {
            reply.code(403).send('Forbidden: host not allowed');
            return;
        }
        if (!SAFE_HTTP_METHODS.has(req.method) && !isAllowedRequestOrigin(req.headers.origin, policy)) {
            reply.code(403).send('Forbidden: cross-site request blocked');
            return;
        }
        done();
    });
}
/**
 * Register security headers and CORS middleware on every response.
 */
export function registerSecurityHeaders(app, https) {
    // Gesture-control overlay (opt-in via CODEMAN_GESTURE=1) runs MediaPipe, which
    // needs WebAssembly eval (script-src) and blob workers (worker-src). Its wasm
    // runtime + model are self-hosted under /gesture/ (same-origin, covered by
    // 'self'), so no CDN connect-src entries are needed. OFF by default so the
    // production CSP is byte-for-byte unchanged.
    const gesture = process.env.CODEMAN_GESTURE === '1';
    const scriptSrc = "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net" + (gesture ? " 'wasm-unsafe-eval'" : '');
    const connectSrc = "connect-src 'self' wss://api.deepgram.com";
    // blob: workers are needed unconditionally: terminal-ui's _safeYield tick
    // worker (throttling escape) is created from a Blob URL. Without this, every
    // page load logs a CSP violation and the worker leg of _safeYield is dead.
    // Risk is minimal — only same-origin scripts (already governed by script-src)
    // can construct blob workers.
    const workerSrc = "; worker-src 'self' blob:";
    const csp = `default-src 'self'; ${scriptSrc}; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; ` +
        `img-src 'self' data: blob:; ${connectSrc}; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'${workerSrc}`;
    app.addHook('onRequest', (req, reply, done) => {
        reply.header('X-Content-Type-Options', 'nosniff');
        reply.header('X-Frame-Options', 'SAMEORIGIN');
        reply.header('Content-Security-Policy', csp);
        if (https) {
            reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
        }
        // CORS: restrict to same-origin (localhost) only
        const origin = req.headers.origin;
        if (origin) {
            try {
                const url = new URL(origin);
                if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') {
                    reply.header('Access-Control-Allow-Origin', origin);
                    reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
                    reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
                    reply.header('Access-Control-Max-Age', '86400');
                }
            }
            catch {
                // Invalid origin header — do not set CORS headers
            }
        }
        // Handle CORS preflight
        if (req.method === 'OPTIONS') {
            reply.code(204).send();
            done();
            return;
        }
        done();
    });
}
//# sourceMappingURL=auth.js.map