import { TranscriptionServiceFactory } from "../src/services/transcription/TranscriptionServiceFactory"; import { API_KEY_SUBPROTOCOL_PREFIX, DEFAULT_STT_SUBPROTOCOL, SofyaAuthError, redactAuthConfig, resolveAuthHeaders, resolveRealtimeProtocols, } from "../src/services/transcription/auth"; import { MockWebSocket } from "./helpers/MockWebSocket"; import { advanceTimersAndFlush } from "./helpers/testUtils"; const API_KEY = "sk-token-safe-key"; const createWhisperService = (config: Record) => TranscriptionServiceFactory.create("sofya_as_service", { language: "en-US", endpoint: { endpoint: "ws://test", }, ...config, }); describe("direct mode api key authentication", () => { const originalWebSocket = global.WebSocket; let logSpy: jest.SpyInstance; beforeEach(() => { jest.useFakeTimers(); MockWebSocket.reset(); logSpy = jest.spyOn(console, "log").mockImplementation(() => undefined); Object.defineProperty(global, "WebSocket", { value: MockWebSocket, writable: true, configurable: true, }); }); afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); logSpy.mockRestore(); Object.defineProperty(global, "WebSocket", { value: originalWebSocket, writable: true, configurable: true, }); }); it("keeps the url and the protocols untouched when auth is omitted", async () => { await createWhisperService({ protocols: ["soap"] }); await advanceTimersAndFlush(0); const socket = MockWebSocket.latest(); expect(socket.protocols).toEqual(["soap"]); expect(new URL(socket.url).searchParams.has("x-api-key")).toBe(false); }); it('keeps the url and the protocols untouched for auth type "none"', async () => { await createWhisperService({ auth: { type: "none" } }); await advanceTimersAndFlush(0); const socket = MockWebSocket.latest(); expect(socket.protocols).toEqual([]); expect(new URL(socket.url).searchParams.has("x-api-key")).toBe(false); }); it("appends the key token after the default companion protocol", async () => { await createWhisperService({ auth: { type: "api_key", key: API_KEY } }); await advanceTimersAndFlush(0); const socket = MockWebSocket.latest(); expect(socket.protocols).toEqual([ DEFAULT_STT_SUBPROTOCOL, `${API_KEY_SUBPROTOCOL_PREFIX}${API_KEY}`, ]); expect(new URL(socket.url).searchParams.has("x-api-key")).toBe(false); }); it("keeps a protocol configured as a string first and the key last", async () => { await createWhisperService({ protocols: "bearer.jwt-token", auth: { type: "api_key", key: API_KEY, transport: "subprotocol" }, }); await advanceTimersAndFlush(0); expect(MockWebSocket.latest().protocols).toEqual([ "bearer.jwt-token", `${API_KEY_SUBPROTOCOL_PREFIX}${API_KEY}`, ]); }); it("keeps protocols configured as an array first and the key last", async () => { await createWhisperService({ protocols: ["bearer.jwt-token", "sofya-stt.v1"], auth: { type: "api_key", key: API_KEY }, }); await advanceTimersAndFlush(0); expect(MockWebSocket.latest().protocols).toEqual([ "bearer.jwt-token", "sofya-stt.v1", `${API_KEY_SUBPROTOCOL_PREFIX}${API_KEY}`, ]); }); it("sends the key as a query parameter and keeps the protocols untouched", async () => { await createWhisperService({ protocols: ["bearer.jwt-token"], auth: { type: "api_key", key: API_KEY, transport: "query" }, }); await advanceTimersAndFlush(0); const socket = MockWebSocket.latest(); expect(socket.protocols).toEqual(["bearer.jwt-token"]); expect(new URL(socket.url).searchParams.get("x-api-key")).toBe(API_KEY); }); it("repeats the key on every reconnection", async () => { await createWhisperService({ auth: { type: "api_key", key: API_KEY }, resilience: { connectTimeoutMs: 50, reconnectBaseDelayMs: 10, reconnectMaxDelayMs: 10, minConnectionUptimeMs: 0, }, }); await advanceTimersAndFlush(0); const firstSocket = MockWebSocket.latest(); firstSocket.open(); await advanceTimersAndFlush(0); firstSocket.fail(1006, "connection lost"); await advanceTimersAndFlush(10); expect(MockWebSocket.instances.length).toBeGreaterThanOrEqual(2); MockWebSocket.instances.forEach((socket) => { expect(socket.protocols).toEqual([ DEFAULT_STT_SUBPROTOCOL, `${API_KEY_SUBPROTOCOL_PREFIX}${API_KEY}`, ]); }); }); it("adds the x-api-key header to the http calls of the connection context", async () => { const service = await createWhisperService({ headers: { "x-client-id": "demo" }, auth: { type: "api_key", key: API_KEY }, }); expect((service as any).resolvedConnectionContext.headers).toEqual({ "x-api-key": API_KEY, "x-client-id": "demo", }); }); it("keeps an x-api-key header already configured by the integrator", () => { expect( resolveAuthHeaders( { "X-Api-Key": "integrator-key" }, { type: "api_key", key: API_KEY } ) ).toEqual({ "X-Api-Key": "integrator-key" }); }); it("throws before connecting when the key is not an RFC 6455 token", async () => { await expect( createWhisperService({ auth: { type: "api_key", key: "key/with=invalid chars" }, }) ).rejects.toBeInstanceOf(SofyaAuthError); expect(MockWebSocket.instances).toHaveLength(0); }); it("accepts a key with invalid token characters on the query transport", async () => { await createWhisperService({ auth: { type: "api_key", key: "key/with=invalid chars", transport: "query", }, }); await advanceTimersAndFlush(0); expect(new URL(MockWebSocket.latest().url).searchParams.get("x-api-key")).toBe( "key/with=invalid chars" ); }); it("throws before connecting when the key is empty", async () => { await expect( createWhisperService({ auth: { type: "api_key", key: "" } }) ).rejects.toBeInstanceOf(SofyaAuthError); expect(MockWebSocket.instances).toHaveLength(0); }); it("never prints the key in the factory debug output", async () => { await createWhisperService({ headers: { "x-api-key": API_KEY }, auth: { type: "api_key", key: API_KEY }, }); await advanceTimersAndFlush(0); const printed = JSON.stringify(logSpy.mock.calls); expect(printed).not.toContain(API_KEY); expect(printed).toContain("[redacted]"); }); it("redacts the key in urls, protocols, headers and auth config", () => { const redacted = redactAuthConfig({ auth: { type: "api_key", key: API_KEY, transport: "query" }, protocols: [ "bearer.jwt-token", `${API_KEY_SUBPROTOCOL_PREFIX}${API_KEY}`, ], headers: { "x-api-key": API_KEY, "x-client-id": "demo" }, endpoint: { endpoint: `ws://test?x-api-key=${API_KEY}&record=true` }, }) as Record; expect(JSON.stringify(redacted)).not.toContain(API_KEY); expect(redacted.auth.key).toBe("[redacted]"); expect(redacted.protocols[0]).toBe("bearer.jwt-token"); expect(redacted.headers["x-client-id"]).toBe("demo"); expect(redacted.endpoint.endpoint).toContain("record=true"); }); it("forwards the configured protocols unchanged without api key auth", () => { expect(resolveRealtimeProtocols("soap")).toBe("soap"); expect(resolveRealtimeProtocols(undefined)).toBeUndefined(); expect( resolveRealtimeProtocols(["soap"], { type: "none" }) ).toEqual(["soap"]); }); }); describe("api key rejection on the handshake", () => { const originalWebSocket = global.WebSocket; beforeEach(() => { jest.useFakeTimers(); MockWebSocket.reset(); jest.spyOn(console, "log").mockImplementation(() => undefined); Object.defineProperty(global, "WebSocket", { value: MockWebSocket, writable: true, configurable: true, }); }); afterEach(() => { jest.clearAllTimers(); jest.useRealTimers(); jest.restoreAllMocks(); Object.defineProperty(global, "WebSocket", { value: originalWebSocket, writable: true, configurable: true, }); }); it("emits error and stops reconnecting when the server closes with 4401", async () => { const service = await createWhisperService({ auth: { type: "api_key", key: API_KEY }, resilience: { connectTimeoutMs: 50, reconnectBaseDelayMs: 10, reconnectMaxDelayMs: 10, minConnectionUptimeMs: 0, maxReconnectAttempts: 5, }, }); const errors: unknown[] = []; (service as any).on("error", (error: unknown) => errors.push(error)); const disconnects: unknown[] = []; (service as any).on("disconnected", (details: unknown) => disconnects.push(details) ); await advanceTimersAndFlush(0); expect(MockWebSocket.instances).toHaveLength(1); MockWebSocket.latest().fail(4401, "invalid api key"); await advanceTimersAndFlush(1000); expect(errors).toHaveLength(1); expect(errors[0]).toBeInstanceOf(SofyaAuthError); expect((errors[0] as SofyaAuthError).code).toBe("AUTH_REJECTED"); expect(String((errors[0] as Error).message)).not.toContain(API_KEY); expect(disconnects).toHaveLength(1); expect(MockWebSocket.instances).toHaveLength(1); }); });