mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 07:12:12 +03:00
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:
committed by
GitHub
parent
6cdb77a0c2
commit
21d5acbb40
1
changelog.d/features/7034-x-goog-api-key-client-auth.md
Normal file
1
changelog.d/features/7034-x-goog-api-key-client-auth.md
Normal 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).
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
21
src/sse/services/googApiKeyAuth.ts
Normal file
21
src/sse/services/googApiKeyAuth.ts
Normal 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");
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user