From 21d5acbb40a2477ecf7e9e870fd1e409c30236e8 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:48:14 -0300 Subject: [PATCH] feat(api): accept x-goog-api-key header for client-facing auth (#7034) (#7236) gemini-cli (and any @google/genai-based client) sends its credential exclusively via x-goog-api-key and it is not client-configurable to use Authorization/x-api-key instead. Add it as an unconditional fallback, after Authorization: Bearer and x-api-key, before the path-scoped URL token, in both the real enforcement gate (src/server/authz/policies/clientApi.ts::extractBearer()) and the general extractor (src/sse/services/auth.ts::extractApiKey()). The header-read/trim logic is extracted into a new leaf module (src/sse/services/googApiKeyAuth.ts) shared by both call sites, so the frozen auth.ts file only takes the minimal chokepoint wiring (config/quality/file-size-baseline.json rebaselined 2458->2461 with justification, matching this repo's established extraction pattern). Closes #7034 --- .../7034-x-goog-api-key-client-auth.md | 1 + config/quality/file-size-baseline.json | 3 +- src/server/authz/policies/clientApi.ts | 9 +++ src/sse/services/auth.ts | 7 ++- src/sse/services/googApiKeyAuth.ts | 21 +++++++ tests/unit/auth-extract-api-key.test.ts | 42 +++++++++++++ tests/unit/authz/client-api-policy.test.ts | 59 +++++++++++++++++++ 7 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 changelog.d/features/7034-x-goog-api-key-client-auth.md create mode 100644 src/sse/services/googApiKeyAuth.ts diff --git a/changelog.d/features/7034-x-goog-api-key-client-auth.md b/changelog.d/features/7034-x-goog-api-key-client-auth.md new file mode 100644 index 0000000000..7228d880e6 --- /dev/null +++ b/changelog.d/features/7034-x-goog-api-key-client-auth.md @@ -0,0 +1 @@ +- **feat(auth):** accept the `x-goog-api-key` header for client-facing auth so `gemini-cli` and other `@google/genai`-based clients can use OmniRoute as a native `/v1beta` gateway (#7034 — thanks @QRcode1337). diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index da8168b8fb..c6cea123af 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,4 +1,5 @@ { + "_rebaseline_2026_07_14_7034_goog_api_key": "Issue #7034 (gemini-cli x-goog-api-key client auth) own growth: src/sse/services/auth.ts 2458->2461 (+3 = import + the two-line extractGoogApiKeyHeader() call/return at the existing extractApiKey() chokepoint, plus a 1-line doc-comment mention offset by a 1-line net save elsewhere in the same edit). The actual header-read/trim logic was EXTRACTED into a new leaf module src/sse/services/googApiKeyAuth.ts (shared by both extractApiKey() here and extractBearer() in src/server/authz/policies/clientApi.ts, which is not frozen) to keep this frozen file's growth to the irreducible call-site wiring. Covered by tests/unit/auth-extract-api-key.test.ts and tests/unit/authz/client-api-policy.test.ts.", "_rebaseline_2026_07_14_6928_comfyui_baseurl_override": "Issue #6928 own growth: open-sse/handlers/videoGeneration.ts 1265->1275 (+10 = resolveComfyUiBaseUrl import + expanding the comfyui dispatch call into a multi-line object literal so the per-connection providerSpecificData.baseUrl override — same storage convention self-hosted chat providers use — is threaded through to handleComfyUIVideoGeneration; Prettier's 100-char width forces the multi-line form), src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts 1053->1054 (+1 = comfyui added to CONFIGURABLE_BASE_URL_PROVIDERS/DEFAULT_PROVIDER_BASE_URLS/getProviderBaseUrlPlaceholder so the Add/Edit connection modals render an editable base-URL field for ComfyUI, mirroring self-hosted chat providers). The identical dispatch pattern was also applied to imageGeneration.ts and musicGeneration.ts, both well under their frozen caps. Covered by tests/unit/comfyui-baseurl-override-6928.test.ts (resolver unit tests + handler-level fetch-mock overrides for image/video/music) and the new provider-page-helpers-3501.test.ts assertion.", "_rebaseline_2026_07_07_v3846_proxy_insecure_random": "PR #6580 (v3.8.46 post-release closing fix): proxies.ts 1173->1177 (+4) — o fix de segurança CodeQL #698/#699 troca Math.random por crypto.randomInt no random rotation strategy (#6365) e adiciona 4 linhas de comentário explicando por que (a seleção flui para credenciais do proxy). Crescimento irreducivel do proprio fix; frozen so encolhe daqui.", "_rebaseline_2026_07_07_v3846_release_close": "Release v3.8.46 Phase 0 (generate-release) — drift de ciclo absorvido no fechamento (fast-gates PR->release nao rodam check:file-size). PROD god-files crescidos por merges do ciclo (nao meus; DECOMPOR idealmente, debt #3501): proxies.ts 1060->1173, chat.ts 1681->1751, ApiManagerPageClient.tsx 3058->3120, ProxyRegistryManager.tsx 1125->1437 (feature de proxy). TEST frozen: models-catalog-route.test.ts 1600->1605 (+5 do fix#2 do captain, #6408 catalogo cache), vscode-token-routes.test.ts 1212->1285 (cycle drift + os asserts effort_tiers/supportsThinking do #6241 alinhados no release-PR-CI base-red), que adiciona o import + 2 chamadas do hook __resetCatalogBuilderRunsForTest existente no setup (harness, sem asserts). Shrink estrutural rastreado no roadmap #3501.", @@ -268,7 +269,7 @@ "_rebaseline_2026_07_09_6678_routing_strategy_9router": "#6678 (SeaXen) — 9router-parity Routing Strategy settings card + per-provider/combo sticky-round-robin override. Own growth: ProviderDetailPageClient.tsx 784->786 (single ProviderAccountRoutingCard mount + import), auth.ts 2448->2458 (providerStrategies override resolution: fallbackStrategy/stickyRoundRobinLimit per-provider cascade in getProviderCredentials). Both additive, zero unrelated refactor; new UI/logic lives in new files (ProviderAccountRoutingCard.tsx, RoutingStrategyCard.tsx, rrState.ts::resolveComboStickyRoundRobinLimit). chat.ts value below reflects the current release tip (grown by other concurrent PRs, e.g. #6640), not this PR own change.", "src/sse/handlers/chat.ts": 1796, "src/sse/handlers/chatHelpers.ts": 876, - "src/sse/services/auth.ts": 2458, + "src/sse/services/auth.ts": 2461, "open-sse/executors/default.ts": 877, "open-sse/translator/request/openai-responses.ts": 902, "open-sse/executors/kiro.ts": 944, diff --git a/src/server/authz/policies/clientApi.ts b/src/server/authz/policies/clientApi.ts index 6545b11d02..aff9d2d533 100644 --- a/src/server/authz/policies/clientApi.ts +++ b/src/server/authz/policies/clientApi.ts @@ -1,6 +1,7 @@ import { isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth.ts"; import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags"; import { extractApiKey } from "@/sse/services/auth.ts"; +import { extractGoogApiKeyHeader } from "@/sse/services/googApiKeyAuth.ts"; import type { AuthOutcome, PolicyContext, RoutePolicy } from "../context"; import { allow, reject } from "../context"; @@ -20,6 +21,7 @@ function isWsHandshake(ctx: PolicyContext): boolean { function extractBearer(request: Request): string | null { const raw = request.headers.get("authorization") ?? request.headers.get("Authorization"); const xApiKey = request.headers.get("x-api-key") ?? request.headers.get("X-Api-Key"); + const xGoogApiKey = extractGoogApiKeyHeader(request.headers); if (raw) { const trimmed = raw.trim(); if (trimmed.toLowerCase().startsWith("bearer ")) { @@ -37,6 +39,13 @@ function extractBearer(request: Request): string | null { return xApiKey.trim() || null; } + // Issue #7034: gemini-cli (and any @google/genai-based client) sends its + // key via x-goog-api-key exclusively — accept it unconditionally, same + // shape as the x-api-key fallback above. + if (xGoogApiKey) { + return xGoogApiKey; + } + return extractApiKey(request); } diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 1d3b3f31a4..aff185ef83 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -1,4 +1,5 @@ import { randomUUID, createHash } from "crypto"; +import { extractGoogApiKeyHeader } from "./googApiKeyAuth.ts"; import { getProviderConnections, getProviderNodes, @@ -201,7 +202,7 @@ function toBooleanOrDefault(value: unknown, fallback: boolean): boolean { return typeof value === "boolean" ? value : fallback; } -function readHeaderValue( +export function readHeaderValue( headers: | Headers | { get?: (name: string) => string | null } @@ -2391,7 +2392,7 @@ function readNonEmptyUrlToken(request: AuthRequestLike): string | null { * path-scoped URL token: * - `Authorization: Bearer ` (OpenAI / OmniRoute / Codex CLI / Bearer clients) * - `x-api-key: ` (Anthropic Messages API contract — Claude Code, - * `@anthropic-ai/sdk`, any SDK that sets `anthropic-version`) + * `@anthropic-ai/sdk`, any SDK that sets `anthropic-version`) / `x-goog-api-key` (#7034) * - `/vscode//...` (path-scoped tokenized aliases — only when `allowUrl`) * * When multiple inputs are present, explicit auth headers win. @@ -2436,6 +2437,8 @@ export function extractApiKey(request: AuthRequestLike, opts?: { allowUrl?: bool } } + const xGoogApiKey = extractGoogApiKeyHeader(request?.headers); // Issue #7034 + if (xGoogApiKey) return xGoogApiKey; if (opts?.allowUrl === false) return null; return readNonEmptyUrlToken(request); } diff --git a/src/sse/services/googApiKeyAuth.ts b/src/sse/services/googApiKeyAuth.ts new file mode 100644 index 0000000000..aa5244953b --- /dev/null +++ b/src/sse/services/googApiKeyAuth.ts @@ -0,0 +1,21 @@ +import { readHeaderValue } from "./auth.ts"; + +type AuthRequestHeaders = Headers | Record; + +/** + * Issue #7034: `gemini-cli` (and any `@google/genai`-based client) sends its + * credential exclusively via `x-goog-api-key`, and it is not + * client-configurable to use `Authorization`/`x-api-key` instead — accept it + * unconditionally, mirroring the existing `x-api-key` fallback shape, just + * without an `anthropic-version`-style gate (the header name is unambiguous). + * + * Extracted to its own module so the two call sites — the real enforcement + * gate in `src/server/authz/policies/clientApi.ts::extractBearer()` and the + * general extractor `extractApiKey()` in `./auth.ts` — stay in lockstep + * without growing the frozen `auth.ts` file (`config/quality/file-size-baseline.json`). + */ +export function extractGoogApiKeyHeader( + headers: AuthRequestHeaders | null | undefined +): string | null { + return readHeaderValue(headers, "x-goog-api-key") || readHeaderValue(headers, "X-Goog-Api-Key"); +} diff --git a/tests/unit/auth-extract-api-key.test.ts b/tests/unit/auth-extract-api-key.test.ts index 255abd9d41..0fc167124e 100644 --- a/tests/unit/auth-extract-api-key.test.ts +++ b/tests/unit/auth-extract-api-key.test.ts @@ -90,6 +90,48 @@ test("extractApiKey accepts Anthropic-Version (TitleCase) header", () => { assert.equal(extractApiKey(req), "sk-titlecase-version"); }); +test("extractApiKey returns the key from x-goog-api-key when Authorization and x-api-key are absent (#7034)", () => { + const req = makeRequest({ "x-goog-api-key": "sk-goog-native" }); + assert.equal(extractApiKey(req), "sk-goog-native"); +}); + +test("extractApiKey accepts uppercase X-Goog-Api-Key header casing (#7034)", () => { + const req = makeRequest({ "X-Goog-Api-Key": "sk-goog-uppercase" }); + assert.equal(extractApiKey(req), "sk-goog-uppercase"); +}); + +test("extractApiKey trims surrounding whitespace from x-goog-api-key value (#7034)", () => { + const req = makeRequest({ "x-goog-api-key": " sk-goog-padded " }); + assert.equal(extractApiKey(req), "sk-goog-padded"); +}); + +test("extractApiKey returns null when x-goog-api-key contains only whitespace (#7034)", () => { + const req = makeRequest({ "x-goog-api-key": " " }); + assert.equal(extractApiKey(req), null); +}); + +test("extractApiKey prefers Bearer over x-goog-api-key when both are present (#7034)", () => { + const req = makeRequest({ + Authorization: "Bearer sk-bearer-wins", + "x-goog-api-key": "sk-goog-loser", + }); + assert.equal(extractApiKey(req), "sk-bearer-wins"); +}); + +test("extractApiKey prefers x-api-key (with anthropic-version) over x-goog-api-key when both are present (#7034)", () => { + const req = makeRequest({ + "x-api-key": "sk-anthropic-wins", + "x-goog-api-key": "sk-goog-loser", + ...ANTHROPIC, + }); + assert.equal(extractApiKey(req), "sk-anthropic-wins"); +}); + +test("extractApiKey does not require anthropic-version for the x-goog-api-key fallback (#7034)", () => { + const req = makeRequest({ "x-goog-api-key": "sk-goog-no-version-needed" }); + assert.equal(extractApiKey(req), "sk-goog-no-version-needed"); +}); + test("extractApiKey extracts a path-scoped token from /api/v1/vscode//...", () => { const req = new Request("https://omniroute.test/api/v1/vscode/sk-test-path-token/models"); assert.equal(extractApiKey(req), "sk-test-path-token"); diff --git a/tests/unit/authz/client-api-policy.test.ts b/tests/unit/authz/client-api-policy.test.ts index 081ab50e43..95613bad15 100644 --- a/tests/unit/authz/client-api-policy.test.ts +++ b/tests/unit/authz/client-api-policy.test.ts @@ -237,6 +237,65 @@ test("clientApiPolicy: x-api-key header is accepted as client_api_key subject", } }); +test("clientApiPolicy: x-goog-api-key header is accepted as client_api_key subject (#7034)", async () => { + const created = await apiKeysDb.createApiKey("policy-test-googkey", "machine-googkey-1234"); + assert.ok(created?.key, "createApiKey must return a key"); + + const policy = await loadPolicy(); + const headers = new Headers({ "x-goog-api-key": created.key }); + const out = await policy.evaluate(ctx(headers)); + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "client_api_key"); + assert.match(out.subject.id, /^key_/); + } +}); + +test("clientApiPolicy: Authorization Bearer wins over x-goog-api-key when both present (#7034)", async () => { + const created = await apiKeysDb.createApiKey("policy-test-goog-precedence", "machine-goog-2345"); + assert.ok(created?.key, "createApiKey must return a key"); + + const policy = await loadPolicy(); + const headers = new Headers({ + authorization: `Bearer ${created.key}`, + "x-goog-api-key": "sk-goog-should-lose", + }); + const out = await policy.evaluate(ctx(headers)); + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "client_api_key"); + assert.match(out.subject.id, /^key_/); + } +}); + +test("clientApiPolicy: existing x-api-key still wins over x-goog-api-key when both present (#7034)", async () => { + const created = await apiKeysDb.createApiKey("policy-test-xkey-precedence", "machine-xkey-2345"); + assert.ok(created?.key, "createApiKey must return a key"); + + const policy = await loadPolicy(); + const headers = new Headers({ + "x-api-key": created.key, + "x-goog-api-key": "sk-goog-should-lose", + }); + const out = await policy.evaluate(ctx(headers)); + assert.equal(out.allow, true); + if (out.allow) { + assert.equal(out.subject.kind, "client_api_key"); + assert.match(out.subject.id, /^key_/); + } +}); + +test("clientApiPolicy: invalid x-goog-api-key is rejected with 401 AUTH_002 (#7034)", async () => { + const policy = await loadPolicy(); + const headers = new Headers({ "x-goog-api-key": "sk-invalid-goog-key" }); + const out = await policy.evaluate(ctx(headers)); + assert.equal(out.allow, false); + if (!out.allow) { + assert.equal(out.status, 401); + assert.equal(out.code, "AUTH_002"); + } +}); + test("clientApiPolicy: ROUTER_API_KEY remains accepted for client API routes", async () => { process.env.ROUTER_API_KEY = "sk-router-policy-test";