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;
|
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('authenticateRequest', () => {
|
||||||
describe('no auth configured', () => {
|
describe('no auth configured', () => {
|
||||||
it('allows all connections', () => {
|
it('allows all connections', () => {
|
||||||
@@ -30,7 +34,7 @@ describe('authenticateRequest', () => {
|
|||||||
it('rejects missing Authorization header', () => {
|
it('rejects missing Authorization header', () => {
|
||||||
const result = authenticateRequest(mockRequest(), config);
|
const result = authenticateRequest(mockRequest(), config);
|
||||||
expect(result.authenticated).toBe(false);
|
expect(result.authenticated).toBe(false);
|
||||||
expect(result.error).toContain('Authorization header required');
|
expect(result.error).toContain('Authorization required');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects invalid token', () => {
|
it('rejects invalid token', () => {
|
||||||
@@ -82,4 +86,44 @@ describe('authenticateRequest', () => {
|
|||||||
expect(result.identity).toBe('anonymous');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+29
-7
@@ -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:
|
* 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
|
* 2. If tailscaleIdentity is enabled, extract identity from Tailscale-User-Login header
|
||||||
* 3. If no auth is configured, allow all connections
|
* 3. If no auth is configured, allow all connections
|
||||||
*/
|
*/
|
||||||
export function authenticateRequest(req: IncomingMessage, config: AuthConfig): AuthResult {
|
export function authenticateRequest(req: IncomingMessage, config: AuthConfig): AuthResult {
|
||||||
// If token auth is configured, it's required
|
// If token auth is configured, it's required
|
||||||
if (config.token) {
|
if (config.token) {
|
||||||
const authHeader = req.headers['authorization'];
|
let tokenValid = false;
|
||||||
if (!authHeader) {
|
|
||||||
return { authenticated: false, error: 'Authorization header required' };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Check Authorization header first
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
if (authHeader) {
|
||||||
const parts = authHeader.split(' ');
|
const parts = authHeader.split(' ');
|
||||||
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
if (parts.length !== 2 || parts[0] !== 'Bearer') {
|
||||||
return { authenticated: false, error: 'Invalid Authorization format (expected: Bearer <token>)' };
|
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)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (parts[1] !== config.token) {
|
if (!tokenValid) {
|
||||||
return { authenticated: false, error: 'Invalid token' };
|
return { authenticated: false, error: 'Invalid token' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +69,16 @@ export function authenticateRequest(req: IncomingMessage, config: AuthConfig): A
|
|||||||
return { authenticated: true, identity: 'anonymous' };
|
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 {
|
function extractTailscaleIdentity(req: IncomingMessage, config: AuthConfig): string | undefined {
|
||||||
if (!config.tailscaleIdentity) return undefined;
|
if (!config.tailscaleIdentity) return undefined;
|
||||||
const header = req.headers['tailscale-user-login'];
|
const header = req.headers['tailscale-user-login'];
|
||||||
|
|||||||
@@ -218,3 +218,41 @@ describe('GatewayServer integration', () => {
|
|||||||
expect(res.status).toBe(404);
|
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;
|
toolExecutor: ToolExecutor;
|
||||||
version?: string;
|
version?: string;
|
||||||
auth?: AuthConfig;
|
auth?: AuthConfig;
|
||||||
|
/** Whether to apply token auth to HTTP requests too (default: true when token is set). */
|
||||||
|
authHttp?: boolean;
|
||||||
uiDir?: string;
|
uiDir?: string;
|
||||||
config?: Config;
|
config?: Config;
|
||||||
/** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */
|
/** Optional callback for system.restart. Should trigger graceful shutdown + process restart. */
|
||||||
@@ -195,10 +197,24 @@ export class GatewayServer {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle incoming HTTP requests.
|
* 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.
|
* 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> {
|
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;
|
const uiDir = this.config.uiDir;
|
||||||
|
|
||||||
if (uiDir) {
|
if (uiDir) {
|
||||||
|
|||||||
Reference in New Issue
Block a user