Skip to content

Commit 81a8fa2

Browse files
authored
Merge pull request #235 from utahkanz-ops/feature/133-ws-auth
feat: add Bearer token auth to WebSocket server (#133)
2 parents 78d4bf4 + 3251fb3 commit 81a8fa2

2 files changed

Lines changed: 73 additions & 2 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import http from 'http';
2+
import { NotificationServer } from '../websocket-server.js';
3+
import WebSocket, { Server as WS } from 'ws';
4+
5+
// Helper to create a mock HTTP server
6+
function createHttpServer(): http.Server {
7+
const server = http.createServer();
8+
server.listen(0);
9+
return server;
10+
}
11+
12+
describe('NotificationServer WebSocket authentication', () => {
13+
const originalEnv = process.env;
14+
15+
beforeEach(() => {
16+
jest.resetModules();
17+
process.env = { ...originalEnv, GEMINI_API_KEY: 'test-secret-key' };
18+
});
19+
20+
afterAll(() => {
21+
process.env = originalEnv;
22+
});
23+
24+
test('rejects connection without Authorization header', (done) => {
25+
const httpServer = createHttpServer();
26+
const ns = new NotificationServer(httpServer, { allowedOrigins: [] });
27+
const wsPort = (httpServer.address() as any).port;
28+
const ws = new WebSocket(`ws://localhost:${wsPort}?userId=user123`);
29+
ws.on('error', (err) => {
30+
// Server should close with code 4000 (custom close) -> ws error
31+
expect(err).toBeDefined();
32+
httpServer.close();
33+
done();
34+
});
35+
ws.on('open', () => {
36+
// Should not open
37+
});
38+
});
39+
40+
test('accepts connection with correct Bearer token', (done) => {
41+
const httpServer = createHttpServer();
42+
const ns = new NotificationServer(httpServer, { allowedOrigins: [] });
43+
const wsPort = (httpServer.address() as any).port;
44+
const ws = new WebSocket(`ws://localhost:${wsPort}?userId=user123`, {
45+
headers: { Authorization: 'Bearer test-secret-key' },
46+
});
47+
ws.on('open', () => {
48+
// Connection succeeded
49+
ws.close();
50+
httpServer.close();
51+
done();
52+
});
53+
ws.on('error', (err) => {
54+
// Should not error
55+
httpServer.close();
56+
done(err);
57+
});
58+
});
59+
});

agent/websocket-server.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
DEFAULT_RATE_LIMIT_WINDOW_MS,
1919
} from "./websocket-limits.js";
2020
import { projectGoalStatus, type GoalProjection } from "./goal-projection.js";
21+
import { loadAgentEnv } from "./env.js";
2122

2223
interface ActiveClient {
2324
ws: WebSocket;
@@ -87,7 +88,11 @@ export class NotificationServer {
8788
private readonly maxMessagesPerSecond: number;
8889
private readonly rateLimitWindowMs: number;
8990

91+
private readonly apiKey: string;
92+
9093
constructor(httpServer: Server, options: NotificationServerOptions = {}) {
94+
const env = loadAgentEnv();
95+
this.apiKey = env.GEMINI_API_KEY;
9196
const {
9297
maxMessageBytes = DEFAULT_MAX_WS_MESSAGE_BYTES,
9398
allowedOrigins = [],
@@ -107,9 +112,16 @@ export class NotificationServer {
107112
secure: boolean;
108113
req: IncomingMessage;
109114
}) => {
110-
// info.origin is empty string '' for non-browser clients in ws@8
111115
const origin = info.origin === "" ? undefined : info.origin;
112-
return isOriginAllowed(origin, this.allowedOrigins);
116+
if (!isOriginAllowed(origin, this.allowedOrigins)) {
117+
return false;
118+
}
119+
const authHeader = info.req.headers["authorization"];
120+
if (!authHeader || typeof authHeader !== "string" || !authHeader.startsWith("Bearer ")) {
121+
return false;
122+
}
123+
const token = authHeader.slice(7).trim();
124+
return token === this.apiKey;
113125
},
114126
});
115127
this.setupConnectionHandlers();

0 commit comments

Comments
 (0)