diff --git a/src/server/authz/policies/clientApi.ts b/src/server/authz/policies/clientApi.ts index e291205e3b..1f2363d36c 100644 --- a/src/server/authz/policies/clientApi.ts +++ b/src/server/authz/policies/clientApi.ts @@ -9,12 +9,19 @@ function extractBearer(request: Request): string | null { const xApiKey = request.headers.get("x-api-key") ?? request.headers.get("X-Api-Key"); if (raw) { const trimmed = raw.trim(); - if (!trimmed.toLowerCase().startsWith("bearer ")) return null; - return trimmed.slice(7).trim() || null; + if (trimmed.toLowerCase().startsWith("bearer ")) { + const token = trimmed.slice(7).trim(); + if (token) return token; + } + // A non-"Bearer " Authorization header (an empty "Bearer ", or a + // client's own non-OmniRoute token — VS Code Copilot sends one even when the + // OmniRoute key lives in the URL path of a /vscode tokenized endpoint) must + // NOT short-circuit auth. Fall through to x-api-key and the path-scoped URL + // token below instead of rejecting the request with "Authentication required". } if (xApiKey) { - return xApiKey?.trim() || null; + return xApiKey.trim() || null; } return extractApiKey(request); diff --git a/tests/unit/authz/client-api-policy-fallback.test.ts b/tests/unit/authz/client-api-policy-fallback.test.ts index 9971bf9f33..ddb7e34935 100644 --- a/tests/unit/authz/client-api-policy-fallback.test.ts +++ b/tests/unit/authz/client-api-policy-fallback.test.ts @@ -218,3 +218,53 @@ test("#2257 — no bearer + REQUIRE_API_KEY=false → anonymous (unchanged, no f console.warn = originalWarn; } }); + +// ─── #3504 — non-usable Authorization must NOT short-circuit the URL path token ─ +// VS Code Copilot sends its own (empty / non-OmniRoute) Authorization header even +// when the OmniRoute key lives in the URL path of a /vscode tokenized endpoint. +// A non-"Bearer " Authorization must fall through to the URL token instead +// of returning null and 401'ing under REQUIRE_API_KEY=true. + +// validateApiKey is the real (no-DB → always-false) implementation here, so we +// distinguish "URL token was extracted" from "no token found" by the rejection +// MESSAGE: an extracted-but-unknown token → "Invalid API key"; nothing extracted +// → "Authentication required". On the pre-fix code a non-Bearer Authorization +// returned null, so these would all 401 with "Authentication required". + +test("#3504 — empty 'Bearer ' Authorization falls through to the URL path token", async () => { + process.env.REQUIRE_API_KEY = "true"; + const policy = await loadPolicy(); + const headers = new Headers({ authorization: "Bearer " }); + const out = await policy.evaluate( + ctx(headers, "/api/v1/vscode/sk-url-token/chat/completions") + ); + assert.equal(out.allow, false); + if (!out.allow) { + assert.equal(out.status, 401); + assert.equal( + out.message, + "Invalid API key", + "URL token must be extracted (→ 'Invalid API key'), not skipped (→ 'Authentication required')" + ); + } +}); + +test("#3504 — a non-Bearer scheme (Basic) also falls through to the URL token", async () => { + process.env.REQUIRE_API_KEY = "true"; + const policy = await loadPolicy(); + const headers = new Headers({ authorization: "Basic Zm9vOmJhcg==" }); + const out = await policy.evaluate( + ctx(headers, "/api/v1/vscode/sk-url-token/chat/completions") + ); + assert.equal(out.allow, false); + if (!out.allow) assert.equal(out.message, "Invalid API key"); +}); + +test("#3504 — non-Bearer Authorization with NO URL token still rejects as unauthenticated", async () => { + process.env.REQUIRE_API_KEY = "true"; + const policy = await loadPolicy(); + const headers = new Headers({ authorization: "Bearer " }); + const out = await policy.evaluate(ctx(headers, "/api/v1/chat/completions")); + assert.equal(out.allow, false); + if (!out.allow) assert.equal(out.message, "Authentication required"); +});