diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index fcb998a..5dfefe9 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -6,6 +6,10 @@ function mockRequest(headers: Record = {}): IncomingMessage { return { headers } as unknown as IncomingMessage; } +function mockRequestWithUrl(url: string, headers: Record = {}): IncomingMessage { + return { url, headers } as unknown as IncomingMessage; +} + describe('authenticateRequest', () => { describe('no auth configured', () => { it('allows all connections', () => { @@ -30,7 +34,7 @@ describe('authenticateRequest', () => { it('rejects missing Authorization header', () => { const result = authenticateRequest(mockRequest(), config); expect(result.authenticated).toBe(false); - expect(result.error).toContain('Authorization header required'); + expect(result.error).toContain('Authorization required'); }); it('rejects invalid token', () => { @@ -82,4 +86,44 @@ describe('authenticateRequest', () => { expect(result.identity).toBe('anonymous'); }); }); + + describe('query parameter token', () => { + const config = { token: 'secret-token-123' }; + + it('accepts valid token in query parameter', () => { + const result = authenticateRequest( + mockRequestWithUrl('/?token=secret-token-123'), + config, + ); + expect(result.authenticated).toBe(true); + expect(result.identity).toBe('token-user'); + }); + + it('rejects invalid token in query parameter', () => { + const result = authenticateRequest( + mockRequestWithUrl('/?token=wrong'), + config, + ); + expect(result.authenticated).toBe(false); + expect(result.error).toContain('Invalid token'); + }); + + it('prefers header over query parameter', () => { + const result = authenticateRequest( + mockRequestWithUrl('/?token=wrong', { authorization: 'Bearer secret-token-123' }), + config, + ); + expect(result.authenticated).toBe(true); + expect(result.identity).toBe('token-user'); + }); + + it('rejects when neither header nor query parameter provided', () => { + const result = authenticateRequest( + mockRequestWithUrl('/'), + config, + ); + expect(result.authenticated).toBe(false); + expect(result.error).toContain('Authorization required'); + }); + }); }); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index edc7466..8fa1478 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -14,27 +14,39 @@ export interface AuthResult { } /** - * Authenticates a WebSocket upgrade request. + * Authenticates a WebSocket upgrade request or HTTP request. * * Auth is checked in this order: - * 1. If token is configured, validate Authorization header (Bearer token) + * 1. If token is configured, validate via: + * a. Authorization: Bearer header + * b. ?token= query parameter (useful for browser WebSocket clients) * 2. If tailscaleIdentity is enabled, extract identity from Tailscale-User-Login header * 3. If no auth is configured, allow all connections */ export function authenticateRequest(req: IncomingMessage, config: AuthConfig): AuthResult { // If token auth is configured, it's required if (config.token) { + let tokenValid = false; + + // Check Authorization header first const authHeader = req.headers['authorization']; - if (!authHeader) { - return { authenticated: false, error: 'Authorization header required' }; + if (authHeader) { + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + return { authenticated: false, error: 'Invalid Authorization format (expected: Bearer )' }; + } + tokenValid = parts[1] === config.token; + } else { + // Check query parameter as fallback (useful for WebSocket from browsers) + const queryToken = extractQueryToken(req); + if (queryToken !== undefined) { + tokenValid = queryToken === config.token; + } else { + return { authenticated: false, error: 'Authorization required (Bearer token header or ?token= query parameter)' }; + } } - const parts = authHeader.split(' '); - if (parts.length !== 2 || parts[0] !== 'Bearer') { - return { authenticated: false, error: 'Invalid Authorization format (expected: Bearer )' }; - } - - if (parts[1] !== config.token) { + if (!tokenValid) { return { authenticated: false, error: 'Invalid token' }; } @@ -57,6 +69,16 @@ export function authenticateRequest(req: IncomingMessage, config: AuthConfig): A return { authenticated: true, identity: 'anonymous' }; } +function extractQueryToken(req: IncomingMessage): string | undefined { + try { + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + const token = url.searchParams.get('token'); + return token ?? undefined; + } catch { + return undefined; + } +} + function extractTailscaleIdentity(req: IncomingMessage, config: AuthConfig): string | undefined { if (!config.tailscaleIdentity) return undefined; const header = req.headers['tailscale-user-login']; diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index f83906d..7bd37d1 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -218,3 +218,41 @@ describe('GatewayServer integration', () => { expect(res.status).toBe(404); }); }); + +describe('GatewayServer HTTP auth', () => { + const AUTH_PORT = 18898; + let authServer: GatewayServer; + + beforeAll(async () => { + authServer = new GatewayServer({ + port: AUTH_PORT, + sessionManager: mockSessionManager as unknown as GatewayServerConfig['sessionManager'], + modelClient: mockModelClient, + systemPrompt: 'Test prompt', + toolRegistry: mockToolRegistry as unknown as GatewayServerConfig['toolRegistry'], + toolExecutor: mockToolExecutor as unknown as GatewayServerConfig['toolExecutor'], + auth: { token: 'test-secret' }, + authHttp: true, + uiDir: resolve(import.meta.dirname, 'ui'), + }); + await authServer.start(); + }); + + afterAll(async () => { + await authServer.stop(); + }); + + it('returns 401 for HTTP request without token', async () => { + const res = await fetch(`http://127.0.0.1:${AUTH_PORT}/`); + expect(res.status).toBe(401); + expect(res.headers.get('www-authenticate')).toBe('Bearer'); + }); + + it('serves content with valid Bearer token', async () => { + const res = await fetch(`http://127.0.0.1:${AUTH_PORT}/`, { + headers: { Authorization: 'Bearer test-secret' }, + }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('text/html'); + }); +}); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 7a1dd7b..8897c2e 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -35,6 +35,8 @@ export interface GatewayServerConfig { toolExecutor: ToolExecutor; version?: string; auth?: AuthConfig; + /** Whether to apply token auth to HTTP requests too (default: true when token is set). */ + authHttp?: boolean; uiDir?: string; config?: Config; /** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */ @@ -195,10 +197,24 @@ export class GatewayServer { /** * Handle incoming HTTP requests. + * Optionally applies auth (when authHttp is enabled and a token is configured). * Delegates to serveStatic for UI files; returns 404 if no UI dir or file not found. - * Auth is NOT applied to HTTP requests — only to WS upgrade. */ private async handleHttpRequest(req: IncomingMessage, res: ServerResponse): Promise { + // Apply auth to HTTP requests when configured + const authConfig = this.config.auth ?? {}; + if (this.config.authHttp !== false && authConfig.token) { + const authResult = authenticateRequest(req, authConfig); + if (!authResult.authenticated) { + res.writeHead(401, { + 'Content-Type': 'text/plain', + 'WWW-Authenticate': 'Bearer', + }); + res.end(authResult.error ?? 'Unauthorized'); + return; + } + } + const uiDir = this.config.uiDir; if (uiDir) {