// @vitest-environment node import { afterEach, describe, expect, it, vi } from "vitest"; import { WebSocket, WebSocketServer } from "ws"; const waitForEvent = ( target: { once: (event: string, cb: (...args: unknown[]) => void) => void }, event: string ) => new Promise((resolve) => { target.once(event, (...args: unknown[]) => resolve(args as unknown as T)); }); const closeHttpServer = (server: import("node:http").Server) => new Promise((resolve) => server.close(() => resolve())); const closeWebSocketServer = (server: WebSocketServer) => new Promise((resolve) => server.close(() => resolve())); const closeWebSocket = (ws: WebSocket) => new Promise((resolve) => { if (ws.readyState === WebSocket.CLOSED) { resolve(); return; } ws.once("close", () => resolve()); ws.close(); }); describe("createGatewayProxy", () => { afterEach(() => { vi.restoreAllMocks(); }); it("injects gateway token into connect request", async () => { const upstream = new WebSocketServer({ port: 0 }); const address = upstream.address(); if (!address || typeof address === "string") { throw new Error("expected upstream server to have a port"); } const upstreamUrl = `ws://127.0.0.1:${address.port}`; let seenToken: string | null = null; let seenOrigin: string | undefined; upstream.on("connection", (ws, req) => { seenOrigin = req.headers.origin; ws.on("message", (raw) => { const parsed = JSON.parse(String(raw)); if (parsed?.method === "connect") { seenToken = parsed?.params?.auth?.token ?? null; ws.send( JSON.stringify({ type: "res", id: parsed.id, ok: true, payload: { type: "hello-ok", protocol: 3, auth: {} }, }) ); } }); }); const { createGatewayProxy } = await import("../../server/gateway-proxy"); const proxyHttp = await import("node:http").then((m) => m.createServer()); const proxy = createGatewayProxy({ loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "token-123" }), allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", logError: () => {}, }); proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); const proxyAddr = proxyHttp.address(); if (!proxyAddr || typeof proxyAddr === "string") { throw new Error("expected proxy server to have a port"); } const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); try { await waitForEvent(browser, "open"); browser.send( JSON.stringify({ type: "req", id: "connect-1", method: "connect", params: { auth: {} }, }) ); await waitForEvent(browser, "message"); expect(seenToken).toBe("token-123"); expect(seenOrigin).toBe(`http://localhost:${address.port}`); } finally { for (const client of upstream.clients) { client.close(); } await Promise.all([ closeWebSocket(browser), closeWebSocketServer(upstream), closeHttpServer(proxyHttp), ]); } }); it("forwards upstream connect.challenge before browser connect and then passes nonce-based device auth", async () => { const upstream = new WebSocketServer({ port: 0 }); const address = upstream.address(); if (!address || typeof address === "string") { throw new Error("expected upstream server to have a port"); } const upstreamUrl = `ws://127.0.0.1:${address.port}`; let seenDeviceNonce: string | null = null; upstream.on("connection", (ws) => { ws.send( JSON.stringify({ type: "event", event: "connect.challenge", payload: { nonce: "strict-gateway-nonce" }, }) ); ws.on("message", (raw) => { const parsed = JSON.parse(String(raw)); if (parsed?.method === "connect") { seenDeviceNonce = parsed?.params?.device?.nonce ?? null; ws.send( JSON.stringify({ type: "res", id: parsed.id, ok: true, payload: { type: "hello-ok", protocol: 3, auth: {} }, }) ); } }); }); const { createGatewayProxy } = await import("../../server/gateway-proxy"); const proxyHttp = await import("node:http").then((m) => m.createServer()); const proxy = createGatewayProxy({ loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", logError: () => {}, }); proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); const proxyAddr = proxyHttp.address(); if (!proxyAddr || typeof proxyAddr === "string") { throw new Error("expected proxy server to have a port"); } const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); try { await waitForEvent(browser, "open"); const [challengeRaw] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); const challenge = JSON.parse(String(challengeRaw ?? "")); expect(challenge).toMatchObject({ type: "event", event: "connect.challenge", payload: { nonce: "strict-gateway-nonce" }, }); browser.send( JSON.stringify({ type: "req", id: "connect-after-challenge", method: "connect", params: { device: { id: "device-id-123", publicKey: "device-public-key-123", signature: "device-signature-123", signedAt: Date.now(), nonce: "strict-gateway-nonce", }, }, }) ); const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); const response = JSON.parse(String(rawMessage ?? "")); expect(response).toMatchObject({ type: "res", id: "connect-after-challenge", ok: true }); expect(seenDeviceNonce).toBe("strict-gateway-nonce"); } finally { for (const client of upstream.clients) { client.close(); } await Promise.all([ closeWebSocket(browser), closeWebSocketServer(upstream), closeHttpServer(proxyHttp), ]); } }); it("allows browser auth token passthrough when host token is missing", async () => { const upstream = new WebSocketServer({ port: 0 }); const address = upstream.address(); if (!address || typeof address === "string") { throw new Error("expected upstream server to have a port"); } const upstreamUrl = `ws://127.0.0.1:${address.port}`; let seenToken: string | null = null; upstream.on("connection", (ws) => { ws.on("message", (raw) => { const parsed = JSON.parse(String(raw)); if (parsed?.method === "connect") { seenToken = parsed?.params?.auth?.token ?? null; ws.send( JSON.stringify({ type: "res", id: parsed.id, ok: true, payload: { type: "hello-ok", protocol: 3, auth: {} }, }) ); } }); }); const { createGatewayProxy } = await import("../../server/gateway-proxy"); const proxyHttp = await import("node:http").then((m) => m.createServer()); const proxy = createGatewayProxy({ loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", logError: () => {}, }); proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); const proxyAddr = proxyHttp.address(); if (!proxyAddr || typeof proxyAddr === "string") { throw new Error("expected proxy server to have a port"); } const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); try { await waitForEvent(browser, "open"); browser.send( JSON.stringify({ type: "req", id: "connect-pass-token", method: "connect", params: { auth: { token: "browser-token-123" } }, }) ); const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); const response = JSON.parse(String(rawMessage ?? "")); expect(response).toMatchObject({ type: "res", id: "connect-pass-token", ok: true }); expect(seenToken).toBe("browser-token-123"); } finally { for (const client of upstream.clients) { client.close(); } await Promise.all([ closeWebSocket(browser), closeWebSocketServer(upstream), closeHttpServer(proxyHttp), ]); } }); it("preserves browser auth token when both browser and host tokens are present", async () => { const upstream = new WebSocketServer({ port: 0 }); const address = upstream.address(); if (!address || typeof address === "string") { throw new Error("expected upstream server to have a port"); } const upstreamUrl = `ws://127.0.0.1:${address.port}`; let seenToken: string | null = null; upstream.on("connection", (ws) => { ws.on("message", (raw) => { const parsed = JSON.parse(String(raw)); if (parsed?.method === "connect") { seenToken = parsed?.params?.auth?.token ?? null; ws.send( JSON.stringify({ type: "res", id: parsed.id, ok: true, payload: { type: "hello-ok", protocol: 3, auth: {} }, }) ); } }); }); const { createGatewayProxy } = await import("../../server/gateway-proxy"); const proxyHttp = await import("node:http").then((m) => m.createServer()); const proxy = createGatewayProxy({ loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "host-token-456" }), allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", logError: () => {}, }); proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); const proxyAddr = proxyHttp.address(); if (!proxyAddr || typeof proxyAddr === "string") { throw new Error("expected proxy server to have a port"); } const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); try { await waitForEvent(browser, "open"); browser.send( JSON.stringify({ type: "req", id: "connect-browser-precedence", method: "connect", params: { auth: { token: "browser-token-789" } }, }) ); const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); const response = JSON.parse(String(rawMessage ?? "")); expect(response).toMatchObject({ type: "res", id: "connect-browser-precedence", ok: true }); expect(seenToken).toBe("browser-token-789"); } finally { for (const client of upstream.clients) { client.close(); } await Promise.all([ closeWebSocket(browser), closeWebSocketServer(upstream), closeHttpServer(proxyHttp), ]); } }); it("allows browser device signature passthrough when host token is missing", async () => { const upstream = new WebSocketServer({ port: 0 }); const address = upstream.address(); if (!address || typeof address === "string") { throw new Error("expected upstream server to have a port"); } const upstreamUrl = `ws://127.0.0.1:${address.port}`; let seenToken: string | null = null; let seenDeviceSignature: string | null = null; let seenDeviceId: string | null = null; let seenDevicePublicKey: string | null = null; let seenDeviceNonce: string | null = null; let seenDeviceSignedAt: number | null = null; upstream.on("connection", (ws) => { ws.on("message", (raw) => { const parsed = JSON.parse(String(raw)); if (parsed?.method === "connect") { seenToken = parsed?.params?.auth?.token ?? null; seenDeviceSignature = parsed?.params?.device?.signature ?? null; seenDeviceId = parsed?.params?.device?.id ?? null; seenDevicePublicKey = parsed?.params?.device?.publicKey ?? null; seenDeviceNonce = parsed?.params?.device?.nonce ?? null; seenDeviceSignedAt = parsed?.params?.device?.signedAt ?? null; ws.send( JSON.stringify({ type: "res", id: parsed.id, ok: true, payload: { type: "hello-ok", protocol: 3, auth: {} }, }) ); } }); }); const { createGatewayProxy } = await import("../../server/gateway-proxy"); const proxyHttp = await import("node:http").then((m) => m.createServer()); const proxy = createGatewayProxy({ loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", logError: () => {}, }); proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); const proxyAddr = proxyHttp.address(); if (!proxyAddr || typeof proxyAddr === "string") { throw new Error("expected proxy server to have a port"); } const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); try { await waitForEvent(browser, "open"); browser.send( JSON.stringify({ type: "req", id: "connect-pass-device", method: "connect", params: { device: { id: "device-id-123", publicKey: "device-public-key-123", signature: "device-signature-123", signedAt: Date.now(), nonce: "device-nonce-123", }, }, }) ); const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); const response = JSON.parse(String(rawMessage ?? "")); expect(response).toMatchObject({ type: "res", id: "connect-pass-device", ok: true }); expect(seenDeviceSignature).toBe("device-signature-123"); expect(seenDeviceId).toBe("device-id-123"); expect(seenDevicePublicKey).toBe("device-public-key-123"); expect(seenDeviceNonce).toBe("device-nonce-123"); expect(typeof seenDeviceSignedAt).toBe("number"); expect(seenToken).toBeNull(); } finally { for (const client of upstream.clients) { client.close(); } await Promise.all([ closeWebSocket(browser), closeWebSocketServer(upstream), closeHttpServer(proxyHttp), ]); } }); it("allows browser password passthrough when host token is missing", async () => { const upstream = new WebSocketServer({ port: 0 }); const address = upstream.address(); if (!address || typeof address === "string") { throw new Error("expected upstream server to have a port"); } const upstreamUrl = `ws://127.0.0.1:${address.port}`; let seenPassword: string | null = null; let seenToken: string | null = null; upstream.on("connection", (ws) => { ws.on("message", (raw) => { const parsed = JSON.parse(String(raw)); if (parsed?.method === "connect") { seenPassword = parsed?.params?.auth?.password ?? null; seenToken = parsed?.params?.auth?.token ?? null; ws.send( JSON.stringify({ type: "res", id: parsed.id, ok: true, payload: { type: "hello-ok", protocol: 3, auth: {} }, }) ); } }); }); const { createGatewayProxy } = await import("../../server/gateway-proxy"); const proxyHttp = await import("node:http").then((m) => m.createServer()); const proxy = createGatewayProxy({ loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", logError: () => {}, }); proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); const proxyAddr = proxyHttp.address(); if (!proxyAddr || typeof proxyAddr === "string") { throw new Error("expected proxy server to have a port"); } const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); try { await waitForEvent(browser, "open"); browser.send( JSON.stringify({ type: "req", id: "connect-pass-password", method: "connect", params: { auth: { password: "browser-password-123" } }, }) ); const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); const response = JSON.parse(String(rawMessage ?? "")); expect(response).toMatchObject({ type: "res", id: "connect-pass-password", ok: true }); expect(seenPassword).toBe("browser-password-123"); expect(seenToken).toBeNull(); } finally { for (const client of upstream.clients) { client.close(); } await Promise.all([ closeWebSocket(browser), closeWebSocketServer(upstream), closeHttpServer(proxyHttp), ]); } }); it("allows browser deviceToken passthrough when host token is missing", async () => { const upstream = new WebSocketServer({ port: 0 }); const address = upstream.address(); if (!address || typeof address === "string") { throw new Error("expected upstream server to have a port"); } const upstreamUrl = `ws://127.0.0.1:${address.port}`; let seenDeviceToken: string | null = null; let seenToken: string | null = null; upstream.on("connection", (ws) => { ws.on("message", (raw) => { const parsed = JSON.parse(String(raw)); if (parsed?.method === "connect") { seenDeviceToken = parsed?.params?.auth?.deviceToken ?? null; seenToken = parsed?.params?.auth?.token ?? null; ws.send( JSON.stringify({ type: "res", id: parsed.id, ok: true, payload: { type: "hello-ok", protocol: 3, auth: {} }, }) ); } }); }); const { createGatewayProxy } = await import("../../server/gateway-proxy"); const proxyHttp = await import("node:http").then((m) => m.createServer()); const proxy = createGatewayProxy({ loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", logError: () => {}, }); proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); const proxyAddr = proxyHttp.address(); if (!proxyAddr || typeof proxyAddr === "string") { throw new Error("expected proxy server to have a port"); } const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); try { await waitForEvent(browser, "open"); browser.send( JSON.stringify({ type: "req", id: "connect-pass-device-token", method: "connect", params: { auth: { deviceToken: "browser-device-token-123" } }, }) ); const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); const response = JSON.parse(String(rawMessage ?? "")); expect(response).toMatchObject({ type: "res", id: "connect-pass-device-token", ok: true }); expect(seenDeviceToken).toBe("browser-device-token-123"); expect(seenToken).toBeNull(); } finally { for (const client of upstream.clients) { client.close(); } await Promise.all([ closeWebSocket(browser), closeWebSocketServer(upstream), closeHttpServer(proxyHttp), ]); } }); it("returns studio.gateway_token_missing when browser auth and host token are both missing", async () => { const upstream = new WebSocketServer({ port: 0 }); const address = upstream.address(); if (!address || typeof address === "string") { throw new Error("expected upstream server to have a port"); } const upstreamUrl = `ws://127.0.0.1:${address.port}`; let upstreamConnectionCount = 0; upstream.on("connection", () => { upstreamConnectionCount += 1; }); const { createGatewayProxy } = await import("../../server/gateway-proxy"); const proxyHttp = await import("node:http").then((m) => m.createServer()); const proxy = createGatewayProxy({ loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }), allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws", logError: () => {}, }); proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head)); await new Promise((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve)); const proxyAddr = proxyHttp.address(); if (!proxyAddr || typeof proxyAddr === "string") { throw new Error("expected proxy server to have a port"); } const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`); try { await waitForEvent(browser, "open"); const closePromise = waitForEvent<[number, Buffer]>(browser, "close"); browser.send( JSON.stringify({ type: "req", id: "connect-missing-token", method: "connect", params: { auth: {} }, }) ); const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message"); const response = JSON.parse(String(rawMessage ?? "")); expect(response).toMatchObject({ type: "res", id: "connect-missing-token", ok: false, error: { code: "studio.gateway_token_missing" }, }); const [closeCode] = await closePromise; expect(closeCode).toBe(1011); expect(upstreamConnectionCount).toBe(1); } finally { for (const client of upstream.clients) { client.close(); } await Promise.all([ closeWebSocket(browser), closeWebSocketServer(upstream), closeHttpServer(proxyHttp), ]); } }); });