feat: add query-param token auth and optional HTTP auth to gateway

Support ?token= query parameter as a fallback for WebSocket clients that
cannot set Authorization headers (e.g. browsers). Add authHttp option to
GatewayServer so token auth can be applied to HTTP requests too, returning
401 with WWW-Authenticate header on failure.
This commit is contained in:
William Valentin
2026-02-06 16:51:41 -08:00
parent 0eb1f7a073
commit 20930a4816
4 changed files with 132 additions and 12 deletions
+45 -1
View File
@@ -6,6 +6,10 @@ function mockRequest(headers: Record<string, string> = {}): IncomingMessage {
return { headers } as unknown as IncomingMessage;
}
function mockRequestWithUrl(url: string, headers: Record<string, string> = {}): 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');
});
});
});
+32 -10
View File
@@ -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 <token> header
* b. ?token=<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 <token>)' };
}
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 <token>)' };
}
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'];
+38
View File
@@ -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');
});
});
+17 -1
View File
@@ -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<void> {
// 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) {