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
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-17 10:48:14 -03:00
committed by GitHub
parent 6cdb77a0c2
commit 21d5acbb40
7 changed files with 139 additions and 3 deletions

View File

@@ -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).

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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 <key>` (OpenAI / OmniRoute / Codex CLI / Bearer clients)
* - `x-api-key: <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/<key>/...` (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);
}

View File

@@ -0,0 +1,21 @@
import { readHeaderValue } from "./auth.ts";
type AuthRequestHeaders = Headers | Record<string, string | string[] | undefined>;
/**
* 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");
}

View File

@@ -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/<token>/...", () => {
const req = new Request("https://omniroute.test/api/v1/vscode/sk-test-path-token/models");
assert.equal(extractApiKey(req), "sk-test-path-token");

View File

@@ -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";