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:
@@ -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
@@ -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'];
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user