diff --git a/src/routes/responses.ts b/src/routes/responses.ts index d61206b..33e3eec 100644 --- a/src/routes/responses.ts +++ b/src/routes/responses.ts @@ -52,12 +52,38 @@ const NOT_FORWARDED_HEADERS = new Set([ "upgrade", ]); +function getBearerToken(authorizationHeader: unknown): string | undefined { + if (typeof authorizationHeader !== "string") { + return undefined; + } + + // We don't validate the token - just extract it in a safe, predictable way. + const match = authorizationHeader.match(/^Bearer\s+(.+)$/i); + if (!match) { + return undefined; + } + + const token = match[1].trim(); + return token.length > 0 ? token : undefined; +} + export const postCreateResponse = async ( req: ValidatedRequest, res: ExpressResponse ): Promise => { + // This service doesn't validate the token, but it cannot proceed without one. + // Fail early before we start streaming (once we write SSE bytes, we can't return a 401). + const apiKey = getBearerToken(req.headers.authorization); + if (!apiKey) { + res.status(401).json({ + success: false, + error: "Unauthorized", + }); + return; + } + // To avoid duplicated code, we run all requests as stream. - const events = runCreateResponseStream(req, res); + const events = runCreateResponseStream(req, res, apiKey); // Then we return in the correct format depending on the user 'stream' flag. if (req.body.stream) { @@ -88,7 +114,8 @@ export const postCreateResponse = async ( */ async function* runCreateResponseStream( req: ValidatedRequest, - res: ExpressResponse + res: ExpressResponse, + apiKey: string ): AsyncGenerator { let sequenceNumber = 0; // Prepare response object that will be iteratively populated @@ -134,7 +161,7 @@ async function* runCreateResponseStream( // Any events (LLM call, MCP call, list tools, etc.) try { - for await (const event of innerRunStream(req, res, responseObject)) { + for await (const event of innerRunStream(req, res, responseObject, apiKey)) { yield { ...event, sequence_number: sequenceNumber++ }; } } catch (error) { @@ -174,17 +201,9 @@ async function* runCreateResponseStream( async function* innerRunStream( req: ValidatedRequest, res: ExpressResponse, - responseObject: IncompleteResponse + responseObject: IncompleteResponse, + apiKey: string ): AsyncGenerator { - // Retrieve API key from headers - const apiKey = req.headers.authorization?.split(" ")[1]; - if (!apiKey) { - res.status(401).json({ - success: false, - error: "Unauthorized", - }); - return; - } // Forward headers (except authorization handled separately) const defaultHeaders = Object.fromEntries( diff --git a/tests/00-missing-auth-header.test.js b/tests/00-missing-auth-header.test.js new file mode 100644 index 0000000..09a6369 --- /dev/null +++ b/tests/00-missing-auth-header.test.js @@ -0,0 +1,51 @@ +import { strict as assert } from "assert"; + +const BASE_URL = process.env.RESPONSES_BASE_URL ?? "http://localhost:3000"; + +describe("missing Authorization header", function () { + // Matches the existing test suite behavior: tests expect a running dev server. + before(async function () { + try { + const response = await fetch(`${BASE_URL}/`); + if (response.status !== 200) { + throw new Error(`Server returned status ${response.status}`); + } + } catch (error) { + console.error("❌ Server is not running. Please start the server with 'pnpm dev' before running the tests."); + throw error; + } + }); + + it("streaming request returns 401 (does not start an SSE stream)", async function () { + const res = await fetch(`${BASE_URL}/v1/responses`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify({ model: "gpt-4.1-mini", input: "hi", stream: true }), + }); + + assert.equal(res.status, 401); + + const contentType = res.headers.get("content-type") ?? ""; + assert.ok(!contentType.includes("text/event-stream"), `Expected non-SSE response, got content-type: ${contentType}`); + }); + + it("non-streaming request returns 401 and server remains healthy", async function () { + const res = await fetch(`${BASE_URL}/v1/responses`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ model: "gpt-4.1-mini", input: "hi" }), + }); + + assert.equal(res.status, 401); + + // Regression: the handler must not crash the whole process after responding. + // (The bug was triggering "Cannot set headers after they are sent to the client".) + const healthRes = await fetch(`${BASE_URL}/health`); + assert.equal(healthRes.status, 200); + }); +});