From 13e4724a0e0b6631d8d3385febd3e41db79fa109 Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 12 Aug 2026 20:08:39 +0000 Subject: [PATCH 1/2] port(upstream): #2926 - accept valid x-api-key even when an Authorization header is present (C --- open-sse/utils/error.js | 25 ++++++ .../playgroundConnectionPinning.test.js | 4 + src/app/api/v1/realtime/auth/route.js | 7 +- src/app/api/v1beta/models/[...path]/route.js | 25 +++--- src/dashboardGuard.js | 37 ++++++--- src/shared/utils/wsHandshake.js | 2 +- src/sse/handlers/chat.js | 30 +++---- src/sse/handlers/countTokens.js | 9 ++- src/sse/handlers/embeddings.js | 14 ++-- src/sse/handlers/fetch.js | 14 ++-- src/sse/handlers/imageEdit.js | 10 +-- src/sse/handlers/imageGeneration.js | 10 +-- src/sse/handlers/moderations.js | 13 ++-- src/sse/handlers/music.js | 9 ++- src/sse/handlers/rerank.js | 10 +-- src/sse/handlers/search.js | 14 ++-- src/sse/handlers/stt.js | 9 ++- src/sse/handlers/tts.js | 9 ++- src/sse/handlers/video.js | 30 +++---- src/sse/services/apiKeyPolicy.js | 18 ++--- src/sse/services/auth.js | 78 ++++++++++++------- .../antigravity-combo-capacity-cycle.test.js | 4 + tests/unit/api-key-auth-expiry.test.js | 35 ++++++++- .../api-key-policy-handler-contract.test.js | 6 +- tests/unit/api-key-policy-routing.test.js | 6 +- tests/unit/chat-policy-sequence.test.js | 2 + tests/unit/chat-quota-fallback.test.js | 4 + .../unit/chat-unauthorized-no-lookup.test.js | 1 + tests/unit/combo-access-control.test.js | 4 + .../combo-token-saver-no-double-count.test.js | 4 + tests/unit/dashboard-guard.test.js | 45 +++++++++++ tests/unit/gemini-native-endpoint.test.js | 10 ++- tests/unit/music-fallback.test.js | 4 + tests/unit/omniroute-image-guard-http.test.js | 1 + tests/unit/ponytail-routing.test.js | 1 + tests/unit/realtime-auth-route.test.js | 11 +-- tests/unit/vision-bridge-chat.test.js | 5 +- 37 files changed, 345 insertions(+), 175 deletions(-) diff --git a/open-sse/utils/error.js b/open-sse/utils/error.js index b73ce9ec9..09345c717 100644 --- a/open-sse/utils/error.js +++ b/open-sse/utils/error.js @@ -50,6 +50,31 @@ export function errorResponse(statusCode, message) { }); } +/** + * Return a generic 401 using the client's native error envelope. + * Anthropic Messages clients require `authentication_error`; other endpoints + * retain the OpenAI-compatible response shape. + * + * @param {string | null} endpoint + * @param {string} message + * @returns {Response} + */ +export function authErrorResponse(endpoint, message) { + if (endpoint?.includes("/v1/messages")) { + return new Response(JSON.stringify({ + type: "error", + error: { type: "authentication_error", message }, + }), { + status: 401, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + }); + } + return errorResponse(401, message); +} + /** * Write error to SSE stream (for streaming) * @param {WritableStreamDefaultWriter} writer - Stream writer diff --git a/src/app/(dashboard)/dashboard/playground/playgroundConnectionPinning.test.js b/src/app/(dashboard)/dashboard/playground/playgroundConnectionPinning.test.js index f859daf8e..c6da92864 100644 --- a/src/app/(dashboard)/dashboard/playground/playgroundConnectionPinning.test.js +++ b/src/app/(dashboard)/dashboard/playground/playgroundConnectionPinning.test.js @@ -40,6 +40,10 @@ vi.mock("../../../../sse/services/auth.js", () => ({ clearAccountError: mocks.clearAccountError, extractApiKey: vi.fn(() => null), evaluateApiKeyAuth: mocks.evaluateApiKeyAuth, + resolveClientApiKey: async (request, options) => ({ + apiKey: null, + auth: await mocks.evaluateApiKeyAuth(null, { ...options, request }), + }), providerAllowsPublicNoAuthFallback: vi.fn(() => false), isProviderConnectionModelLocked: vi.fn(() => false), })); diff --git a/src/app/api/v1/realtime/auth/route.js b/src/app/api/v1/realtime/auth/route.js index c857c4236..ef5b208db 100644 --- a/src/app/api/v1/realtime/auth/route.js +++ b/src/app/api/v1/realtime/auth/route.js @@ -1,4 +1,4 @@ -import { extractApiKey, evaluateApiKeyAuth } from "@/sse/services/auth.js"; +import { resolveClientApiKey } from "@/sse/services/auth.js"; import { getSettings } from "@/lib/localDb"; /** @@ -17,9 +17,10 @@ import { getSettings } from "@/lib/localDb"; * `requireApiKey` or validate the key at all. */ export async function GET(request) { - const apiKey = extractApiKey(request); const settings = await getSettings(); - const auth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); + const { auth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (auth.ok) { return Response.json({ ok: true }, { headers: { "Access-Control-Allow-Origin": "*" } }); } diff --git a/src/app/api/v1beta/models/[...path]/route.js b/src/app/api/v1beta/models/[...path]/route.js index f8e003720..e1160addb 100644 --- a/src/app/api/v1beta/models/[...path]/route.js +++ b/src/app/api/v1beta/models/[...path]/route.js @@ -1,8 +1,7 @@ import { handleChat } from "@/sse/handlers/chat.js"; import { clearAccountError, - evaluateApiKeyAuth, - extractApiKey, + resolveClientApiKey, getProviderCredentials, markAccountUnavailable, } from "@/sse/services/auth.js"; @@ -175,16 +174,16 @@ function buildGeminiNativeUrl(requestUrl, model, action) { async function validateGeminiNativeClientKey(request) { const settings = await getSettings(); - const apiKey = extractApiKey(request); - const result = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); - if (!result.ok && result.reason === "missing") { - return Response.json({ error: { message: "Missing API key" } }, { status: 401 }); + const { apiKey, auth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); + if (!auth.ok && auth.reason === "missing") { + return { apiKey, error: Response.json({ error: { message: "Missing API key" } }, { status: 401 }) }; } - if (!result.ok) { - return Response.json({ error: { message: "Invalid API key" } }, { status: 401 }); + if (!auth.ok) { + return { apiKey, error: Response.json({ error: { message: "Invalid API key" } }, { status: 401 }) }; } - - return null; + return { apiKey, error: null }; } function buildGeminiNativeAuthHeaders(credentials) { @@ -229,14 +228,14 @@ function getSafeGeminiNativeErrorText(error) { } async function forwardGeminiNativeRequest(request, body, model, action) { - const authError = await validateGeminiNativeClientKey(request); + const { apiKey, error: authError } = await validateGeminiNativeClientKey(request); if (authError) return authError; const modelId = normalizeGeminiNativeModel(model); if (!GEMINI_NATIVE_MODEL_PATTERN.test(modelId)) { return Response.json({ error: { message: "Invalid model" } }, { status: 400 }); } - const policyError = await enforceApiKeyModelPolicy(request, `gemini/${modelId}`); + const policyError = await enforceApiKeyModelPolicy(request, `gemini/${modelId}`, apiKey); if (policyError) return policyError; const excludeConnectionIds = new Set(); const bodyText = JSON.stringify(body); @@ -337,7 +336,7 @@ async function forwardGeminiNativeRequest(request, body, model, action) { // request estimate closes the zero-accounting bypass; quota batch 5 adds // authoritative streamed non-chat usage and cost extraction. const committedTokens = Math.ceil(bodyText.length / 4); - await recordApiKeyUsageForResponse(extractApiKey(request), clientResponse, { + await recordApiKeyUsageForResponse(apiKey, clientResponse, { tokens: committedTokens, cost: 0, }); diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 683c102d4..9a1e46c8e 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -137,19 +137,28 @@ function isPublicLlmApi(pathname) { } function extractApiKey(request) { + return extractApiKeyCandidates(request)[0] || null; +} + +/** Collect distinct credentials presented by this request in precedence order. */ +function extractApiKeyCandidates(request) { + const candidates = []; + const add = (value) => { + if (value && !candidates.includes(value)) candidates.push(value); + }; const authHeader = request.headers.get("Authorization"); - if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7); - const apiKeyHeader = request.headers.get("x-api-key"); - if (apiKeyHeader) return apiKeyHeader; - const googleApiKeyHeader = request.headers.get("x-goog-api-key"); - if (googleApiKeyHeader) return googleApiKeyHeader; - return request.nextUrl.searchParams?.get("key") || null; + if (authHeader?.startsWith("Bearer ")) add(authHeader.slice(7)); + add(request.headers.get("x-api-key")); + add(request.headers.get("x-goog-api-key")); + add(request.nextUrl.searchParams?.get("key")); + return candidates; } async function hasValidApiKey(request) { - const apiKey = extractApiKey(request); - if (!apiKey) return false; - return await validateApiKey(apiKey); + for (const apiKey of extractApiKeyCandidates(request)) { + if (await validateApiKey(apiKey)) return true; + } + return false; } async function hasValidGatewayKey(request) { @@ -241,6 +250,7 @@ export const __test__ = { hasExactRequestOrigin, isPublicLlmApi, extractApiKey, + extractApiKeyCandidates, canAccessPublicLlmApi, canAccessLocalOnlyRoute, }; @@ -279,6 +289,15 @@ export async function proxy(request) { if (isPublicLlmApi(pathname)) { if (await canAccessPublicLlmApi(request)) return NextResponse.next(); + if (pathname.includes("/v1/messages")) { + return NextResponse.json({ + type: "error", + error: { + type: "authentication_error", + message: "API key required for remote API access", + }, + }, { status: 401 }); + } return NextResponse.json({ error: "API key required for remote API access" }, { status: 401 }); } diff --git a/src/shared/utils/wsHandshake.js b/src/shared/utils/wsHandshake.js index 3c5a1437e..c6a2544d8 100644 --- a/src/shared/utils/wsHandshake.js +++ b/src/shared/utils/wsHandshake.js @@ -110,7 +110,7 @@ function extractRealtimeKey(req) { * The realtime socket runs in plain Node (no alias resolution), so it cannot * import `src/sse/services/auth.js` directly. Instead we call a tiny Next-owned * bridge route (`/api/v1/realtime/auth`) that reuses the EXACT auth code the - * HTTP chat path uses (`extractApiKey` + `evaluateApiKeyAuth` + + * HTTP chat path uses (`resolveClientApiKey` + * `getSettings().requireApiKey`). A bad/expired/missing-when-required key → 401. * * @param {object} opts diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 67a5dc845..715bcecc5 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -5,8 +5,7 @@ import { projectProviderCredentials, markAccountUnavailable, clearAccountError, - extractApiKey, - evaluateApiKeyAuth, + resolveClientApiKey, isProviderConnectionModelLocked, providerAllowsPublicNoAuthFallback, } from "../services/auth.js"; @@ -24,7 +23,7 @@ import { isAutoComboId } from "open-sse/services/autoComboResolver.js"; import { applyVisionBridgeReroute } from "open-sse/services/model.js"; import { handleChatCore } from "open-sse/handlers/chatCore.js"; import { DEFAULT_HEADROOM_URL } from "@/lib/headroom/detect"; -import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; +import { authErrorResponse, errorResponse, unavailableResponse } from "open-sse/utils/error.js"; import { isLocalStreamLifecycleError } from "open-sse/utils/streamLifecycle.js"; import { getComboModelQuotaHealth, @@ -319,27 +318,22 @@ export async function handleChat(request, clientRawRequest = null) { // Request summary is emitted as the unified "▶" line in chatCore (has fmt/thinking/account) - // Log API key (masked) - const authHeader = request.headers.get("Authorization"); - const apiKey = extractApiKey(request); - if (authHeader && apiKey) { - const masked = log.maskKey(apiKey); - log.debug("AUTH", `API Key: ${masked}`); + const settings = await getSettings(); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); + if (apiKey) { + log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`); } else { log.debug("AUTH", "No API key provided (local mode)"); } - - // Stored credentials are always validated; local mode only permits unknown - // placeholders when API-key enforcement is disabled. - const settings = await getSettings(); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); if (!apiKeyAuth.ok) { if (apiKeyAuth.reason === "missing") { log.warn("AUTH", "Missing API key (requireApiKey=true)"); - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key"); + return authErrorResponse(clientRawRequest.endpoint, "Missing API key"); } log.warn("AUTH", "Invalid API key"); - return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); + return authErrorResponse(clientRawRequest.endpoint, "Invalid API key"); } // Per-key combo access control. Retain the authenticated record so local @@ -705,7 +699,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re // Enforce per-API-key model policy against the resolved underlying model when // the request started as a combo; the top-level combo name is not a model id. - const policyError2 = await enforceApiKeyModelPolicy(request, `${provider}/${model}`); + const policyError2 = await enforceApiKeyModelPolicy(request, `${provider}/${model}`, apiKey); if (policyError2) return policyError2; // Vision Bridge (#6640): after policy passes, resolve custom capabilities and @@ -720,7 +714,7 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re : null; const vb = applyVisionBridgeReroute({ body, modelStr, settings, capabilities: singleModelCaps, targetCapabilities: visionTargetCaps }); if (vb.rerouted) { - const policyError = await enforceApiKeyModelPolicy(request, vb.modelStr); + const policyError = await enforceApiKeyModelPolicy(request, vb.modelStr, apiKey); if (policyError) { log.warn("CHAT", `Vision Bridge target "${vb.toModel}" denied by API key policy; keeping "${modelStr}"`); } else { diff --git a/src/sse/handlers/countTokens.js b/src/sse/handlers/countTokens.js index ce98b102c..b3d478756 100644 --- a/src/sse/handlers/countTokens.js +++ b/src/sse/handlers/countTokens.js @@ -1,4 +1,4 @@ -import { getProviderCredentials, extractApiKey, evaluateApiKeyAuth } from "../services/auth.js"; +import { getProviderCredentials, resolveClientApiKey } from "../services/auth.js"; import { getSettings } from "@/lib/localDb"; import { getModelInfo } from "../services/model.js"; import { handleCountTokensCore, estimateTokens } from "open-sse/handlers/countTokensCore.js"; @@ -22,8 +22,9 @@ export async function handleCountTokens(request) { } const settings = await getSettings(); - const apiKey = extractApiKey(request); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (!apiKeyAuth.ok) return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key"); const modelStr = body.model; @@ -38,7 +39,7 @@ export async function handleCountTokens(request) { } const { provider, model } = modelInfo; - const policyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`); + const policyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`, apiKey); if (policyError) return policyError; // count_tokens is best-effort: a single credential attempt is enough (the core // falls back to the estimate on any failure / no native endpoint). diff --git a/src/sse/handlers/embeddings.js b/src/sse/handlers/embeddings.js index d7c0e2432..f4985fbaf 100644 --- a/src/sse/handlers/embeddings.js +++ b/src/sse/handlers/embeddings.js @@ -2,8 +2,7 @@ import { getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, clearAccountError, - extractApiKey, - evaluateApiKeyAuth, + resolveClientApiKey, } from "../services/auth.js"; import { getSettings } from "@/lib/localDb"; import { getModelInfo } from "../services/model.js"; @@ -34,16 +33,15 @@ export async function handleEmbeddings(request) { log.request("POST", `${url.pathname} | ${modelStr}`); - // Log API key (masked) - const apiKey = extractApiKey(request); + const settings = await getSettings(); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (apiKey) { log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`); } else { log.debug("AUTH", "No API key provided (local mode)"); } - - const settings = await getSettings(); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); if (!apiKeyAuth.ok) { if (apiKeyAuth.reason === "missing") { log.warn("AUTH", "Missing API key (requireApiKey=true)"); @@ -70,7 +68,7 @@ export async function handleEmbeddings(request) { } const { provider, model } = modelInfo; - const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`); + const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`, apiKey); if (resolvedPolicyError) return resolvedPolicyError; const estimatedTokens = JSON.stringify(body.input).length / 4; diff --git a/src/sse/handlers/fetch.js b/src/sse/handlers/fetch.js index 977a983d3..ba37d745c 100644 --- a/src/sse/handlers/fetch.js +++ b/src/sse/handlers/fetch.js @@ -2,8 +2,7 @@ import { getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, clearAccountError, - extractApiKey, - evaluateApiKeyAuth, + resolveClientApiKey, projectProviderCredentials, } from "../services/auth.js"; import { getSettings, getCombos, getApiKeyByKey, getProviderConnections } from "@/lib/localDb"; @@ -44,16 +43,15 @@ export async function handleFetch(request) { log.request("POST", `${reqUrl.pathname} | ${providerInput}`); - // Log API key (masked) - const apiKey = extractApiKey(request); + const settings = await getSettings(); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (apiKey) { log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`); } else { log.debug("AUTH", "No API key provided (local mode)"); } - - const settings = await getSettings(); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); if (!apiKeyAuth.ok) { if (apiKeyAuth.reason === "missing") { log.warn("AUTH", "Missing API key (requireApiKey=true)"); @@ -168,7 +166,7 @@ async function handleSingleProviderFetch(body, providerInput, request, apiKey, s return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown provider: ${providerInput}`); } - const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${providerId}/fetch`); + const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${providerId}/fetch`, apiKey); if (resolvedPolicyError) return resolvedPolicyError; const providerConfig = resolvedProvider.fetchConfig; diff --git a/src/sse/handlers/imageEdit.js b/src/sse/handlers/imageEdit.js index 3d1548cae..40245da02 100644 --- a/src/sse/handlers/imageEdit.js +++ b/src/sse/handlers/imageEdit.js @@ -2,8 +2,7 @@ import { getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, clearAccountError, - extractApiKey, - evaluateApiKeyAuth, + resolveClientApiKey, } from "../services/auth.js"; import { getSettings } from "@/lib/localDb"; import { getModelInfo } from "../services/model.js"; @@ -36,9 +35,10 @@ export async function handleImageEdit(request) { const url = new URL(request.url); log.request("POST", `${url.pathname} | ${modelStr}`); - const apiKey = extractApiKey(request); const settings = await getSettings(); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (!apiKeyAuth.ok) return errorResponse( HTTP_STATUS.UNAUTHORIZED, apiKeyAuth.reason === "missing" ? "Missing API key" : "Invalid API key", @@ -61,7 +61,7 @@ async function handleSingleModelImageEdit(modelStr, formData, request, apiKey) { if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); const { provider, model } = modelInfo; - const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`); + const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`, apiKey); if (resolvedPolicyError) return resolvedPolicyError; const prompt = formData.get("prompt"); const estimatedTokens = prompt ? String(prompt).length / 4 : 0; diff --git a/src/sse/handlers/imageGeneration.js b/src/sse/handlers/imageGeneration.js index 5fbc2ea07..b45850abb 100644 --- a/src/sse/handlers/imageGeneration.js +++ b/src/sse/handlers/imageGeneration.js @@ -2,8 +2,7 @@ import { getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, clearAccountError, - extractApiKey, - evaluateApiKeyAuth, + resolveClientApiKey, } from "../services/auth.js"; import { getSettings, getApiKeyByKey } from "@/lib/localDb"; import { getModelInfo, getComboModels } from "../services/model.js"; @@ -37,9 +36,10 @@ export async function handleImageGeneration(request) { const binaryOutput = url.searchParams.get("response_format") === "binary"; const modelStr = body.model; - const apiKey = extractApiKey(request); const settings = await getSettings(); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (!apiKeyAuth.ok) return errorResponse( HTTP_STATUS.UNAUTHORIZED, apiKeyAuth.reason === "missing" ? "Missing API key" : "Invalid API key", @@ -92,7 +92,7 @@ async function handleSingleModelImage(body, modelStr, request, apiKey, { wantsSt if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); const { provider, model } = modelInfo; - const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`); + const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`, apiKey); if (resolvedPolicyError) return resolvedPolicyError; const estimatedTokens = String(body.prompt || "").length / 4; diff --git a/src/sse/handlers/moderations.js b/src/sse/handlers/moderations.js index ff4a0943f..25d5069e4 100644 --- a/src/sse/handlers/moderations.js +++ b/src/sse/handlers/moderations.js @@ -2,8 +2,7 @@ import { getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, clearAccountError, - extractApiKey, - evaluateApiKeyAuth, + resolveClientApiKey, } from "../services/auth.js"; import { getSettings } from "@/lib/localDb"; import { getModelInfo } from "../services/model.js"; @@ -36,15 +35,15 @@ export async function handleModerations(request) { log.request("POST", `${url.pathname} | ${modelStr}`); - const apiKey = extractApiKey(request); + const settings = await getSettings(); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (apiKey) { log.debug("AUTH", "API key provided"); } else { log.debug("AUTH", "No API key provided (local mode)"); } - - const settings = await getSettings(); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); if (!apiKeyAuth.ok) return errorResponse( HTTP_STATUS.UNAUTHORIZED, apiKeyAuth.reason === "missing" ? "Missing API key" : "Invalid API key", @@ -69,7 +68,7 @@ async function handleSingleModelModeration(modelStr, body, request, apiKey) { } const { provider, model } = modelInfo; - const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`); + const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`, apiKey); if (resolvedPolicyError) return resolvedPolicyError; const estimatedTokens = JSON.stringify(body.input).length / 4; diff --git a/src/sse/handlers/music.js b/src/sse/handlers/music.js index 81c3d1816..f07797d85 100644 --- a/src/sse/handlers/music.js +++ b/src/sse/handlers/music.js @@ -1,4 +1,4 @@ -import { getProviderCredentialsWithQuotaPreflight, extractApiKey, evaluateApiKeyAuth, markAccountUnavailable } from "../services/auth.js"; +import { getProviderCredentialsWithQuotaPreflight, resolveClientApiKey, markAccountUnavailable } from "../services/auth.js"; import { getSettings } from "@/lib/localDb"; import { getModelInfo } from "../services/model.js"; import { handleMusicGenerationCore } from "open-sse/handlers/musicGenerationCore.js"; @@ -15,8 +15,9 @@ export async function handleMusicGeneration(request) { } const settings = await getSettings(); - const apiKey = extractApiKey(request); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (!apiKeyAuth.ok) return errorResponse( HTTP_STATUS.UNAUTHORIZED, apiKeyAuth.reason === "missing" ? "Missing API key" : "Invalid API key", @@ -27,7 +28,7 @@ export async function handleMusicGeneration(request) { const modelInfo = await getModelInfo(body.model); if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); const { provider, model } = modelInfo; - const policyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`); + const policyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`, apiKey); if (policyError) return policyError; const estimatedTokens = String(body.prompt || "").length / 4; diff --git a/src/sse/handlers/rerank.js b/src/sse/handlers/rerank.js index 40dd3a342..56a10ae94 100644 --- a/src/sse/handlers/rerank.js +++ b/src/sse/handlers/rerank.js @@ -2,8 +2,7 @@ import { getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, clearAccountError, - extractApiKey, - evaluateApiKeyAuth, + resolveClientApiKey, } from "../services/auth.js"; import { getSettings } from "@/lib/localDb"; import { getModelInfo } from "../services/model.js"; @@ -34,9 +33,10 @@ export async function handleRerank(request) { const modelStr = body.model; log.request("POST", `${url.pathname} | ${modelStr}`); - const apiKey = extractApiKey(request); const settings = await getSettings(); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (!apiKeyAuth.ok) return errorResponse( HTTP_STATUS.UNAUTHORIZED, apiKeyAuth.reason === "missing" ? "Missing API key" : "Invalid API key", @@ -62,7 +62,7 @@ async function handleSingleModelRerank(modelStr, body, request, apiKey) { } const { provider, model } = modelInfo; - const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`); + const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`, apiKey); if (resolvedPolicyError) return resolvedPolicyError; const estimatedTokens = (String(body.query).length + JSON.stringify(body.documents).length) / 4; if (modelStr !== `${provider}/${model}`) { diff --git a/src/sse/handlers/search.js b/src/sse/handlers/search.js index bf4a6ce6a..3de96ca92 100644 --- a/src/sse/handlers/search.js +++ b/src/sse/handlers/search.js @@ -2,8 +2,7 @@ import { getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, clearAccountError, - extractApiKey, - evaluateApiKeyAuth, + resolveClientApiKey, } from "../services/auth.js"; import { getSettings, getCombos, getApiKeyByKey } from "@/lib/localDb"; import { AI_PROVIDERS, resolveProviderId } from "@/shared/constants/providers.js"; @@ -40,16 +39,15 @@ export async function handleSearch(request) { log.request("POST", `${url.pathname} | ${providerInput}`); - // Log API key (masked) - const apiKey = extractApiKey(request); + const settings = await getSettings(); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (apiKey) { log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`); } else { log.debug("AUTH", "No API key provided (local mode)"); } - - const settings = await getSettings(); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); if (!apiKeyAuth.ok) { if (apiKeyAuth.reason === "missing") { log.warn("AUTH", "Missing API key (requireApiKey=true)"); @@ -126,7 +124,7 @@ async function handleSingleProviderSearch(body, providerInput, request, apiKey, return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown provider: ${providerInput}`); } - const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${providerId}/search`); + const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${providerId}/search`, apiKey); if (resolvedPolicyError) return resolvedPolicyError; const providerConfig = resolvedProvider.searchConfig; diff --git a/src/sse/handlers/stt.js b/src/sse/handlers/stt.js index 8b6c15398..fb166db18 100644 --- a/src/sse/handlers/stt.js +++ b/src/sse/handlers/stt.js @@ -1,5 +1,5 @@ import { - extractApiKey, evaluateApiKeyAuth, + resolveClientApiKey, getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, } from "../services/auth.js"; import { getSettings } from "@/lib/localDb"; @@ -30,8 +30,9 @@ export async function handleStt(request) { log.request("POST", `/v1/audio/transcriptions | ${modelStr}`); const settings = await getSettings(); - const apiKey = extractApiKey(request); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (!apiKeyAuth.ok) return errorResponse( HTTP_STATUS.UNAUTHORIZED, apiKeyAuth.reason === "missing" ? "Missing API key" : "Invalid API key", @@ -44,7 +45,7 @@ export async function handleStt(request) { if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); const { provider, model } = modelInfo; - const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`); + const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`, apiKey); if (resolvedPolicyError) return resolvedPolicyError; // Audio bytes are not model tokens. Stage 1 records the successful request; // authoritative speech usage accounting is completed in the quota program. diff --git a/src/sse/handlers/tts.js b/src/sse/handlers/tts.js index c6d2d7ab5..27b775a40 100644 --- a/src/sse/handlers/tts.js +++ b/src/sse/handlers/tts.js @@ -1,5 +1,5 @@ import { - extractApiKey, evaluateApiKeyAuth, + resolveClientApiKey, getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, } from "../services/auth.js"; import { getSettings, getApiKeyByKey } from "@/lib/localDb"; @@ -35,8 +35,9 @@ export async function handleTts(request) { log.request("POST", `${url.pathname} | ${modelStr} | format=${responseFormat}${language ? ` | lang=${language}` : ""}`); const settings = await getSettings(); - const apiKey = extractApiKey(request); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (!apiKeyAuth.ok) return errorResponse( HTTP_STATUS.UNAUTHORIZED, apiKeyAuth.reason === "missing" ? "Missing API key" : "Invalid API key", @@ -89,7 +90,7 @@ async function handleSingleModelTts(body, modelStr, responseFormat, language, re if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); const { provider, model } = modelInfo; - const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`); + const resolvedPolicyError = await enforceApiKeyModelPolicy(request, `${provider}/${model}`, apiKey); if (resolvedPolicyError) return resolvedPolicyError; const estimatedTokens = String(body.input).length / 4; log.info("ROUTING", `Provider: ${provider}, Voice: ${model}`); diff --git a/src/sse/handlers/video.js b/src/sse/handlers/video.js index 3a52206d8..75019306d 100644 --- a/src/sse/handlers/video.js +++ b/src/sse/handlers/video.js @@ -1,4 +1,4 @@ -import { getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, clearAccountError, extractApiKey, evaluateApiKeyAuth } from "../services/auth.js"; +import { getProviderCredentialsWithQuotaPreflight, markAccountUnavailable, clearAccountError, resolveClientApiKey } from "../services/auth.js"; import { getSettings } from "@/lib/localDb"; import { getModelInfo } from "../services/model.js"; import { handleVideoGenerationCore } from "open-sse/handlers/videoGenerationCore.js"; @@ -14,8 +14,8 @@ import * as log from "../utils/logger.js"; // deliberately don't parse) land here. const DEFAULT_VIDEO_PROVIDER = "xai"; -async function enforceVideoPolicy(request, provider, model) { - return enforceApiKeyModelPolicy(request, `${provider}/${model}`); +async function enforceVideoPolicy(request, provider, model, apiKey) { + return enforceApiKeyModelPolicy(request, `${provider}/${model}`, apiKey); } function shouldMarkAccountUnavailable(status) { @@ -33,8 +33,9 @@ export async function handleVideoGeneration(request) { } const settings = await getSettings(); - const apiKey = extractApiKey(request); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (!apiKeyAuth.ok) return errorResponse( HTTP_STATUS.UNAUTHORIZED, apiKeyAuth.reason === "missing" ? "Missing API key" : "Invalid API key", @@ -43,7 +44,7 @@ export async function handleVideoGeneration(request) { if (!body.model) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); const modelInfo = await getModelInfo(body.model); if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format"); - const policyError = await enforceVideoPolicy(request, modelInfo.provider, modelInfo.model); + const policyError = await enforceVideoPolicy(request, modelInfo.provider, modelInfo.model, apiKey); if (policyError) return policyError; const credentials = await getProviderCredentialsWithQuotaPreflight(modelInfo.provider, null, modelInfo.model); if (credentials?.providerDisabled) { @@ -132,8 +133,7 @@ function withConnectionHeader(response, connectionId) { * loop here: one credential is selected (honoring a preferred `x-connection-id`) * and the upstream result is returned. A network error or 5xx is surfaced as-is * rather than re-sent, because the job may already exist upstream. - * - * Ported from decolua/9router#2593, adapted to fork auth (`evaluateApiKeyAuth` + * Ported from decolua/9router#2593, adapted to fork auth (`resolveClientApiKey` * + `enforceApiKeyModelPolicy`) and single-credential dispatch. */ export async function handleVideoCreate(request, action) { @@ -142,8 +142,9 @@ export async function handleVideoCreate(request, action) { } const settings = await getSettings(); - const apiKey = extractApiKey(request); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (!apiKeyAuth.ok) return errorResponse( HTTP_STATUS.UNAUTHORIZED, apiKeyAuth.reason === "missing" ? "Missing API key" : "Invalid API key", @@ -158,7 +159,7 @@ export async function handleVideoCreate(request, action) { // Policy needs a concrete model; bodies that omit `model` (allowed by the // upstream xAI video API) default to the provider's video model. - const policyError = await enforceVideoPolicy(request, provider, model || "grok-imagine-video"); + const policyError = await enforceVideoPolicy(request, provider, model || "grok-imagine-video", apiKey); if (policyError) return policyError; // Strip the provider prefix (e.g. "xai/grok-imagine-video") before forwarding; @@ -234,8 +235,9 @@ export async function handleVideoCreate(request, action) { */ export async function handleVideoGet(request, requestId) { const settings = await getSettings(); - const apiKey = extractApiKey(request); - const apiKeyAuth = await evaluateApiKeyAuth(apiKey, { required: settings.requireApiKey === true, request }); + const { apiKey, auth: apiKeyAuth } = await resolveClientApiKey(request, { + required: settings.requireApiKey === true, + }); if (!apiKeyAuth.ok) return errorResponse( HTTP_STATUS.UNAUTHORIZED, apiKeyAuth.reason === "missing" ? "Missing API key" : "Invalid API key", @@ -244,7 +246,7 @@ export async function handleVideoGet(request, requestId) { if (!requestId) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing video request id"); const provider = DEFAULT_VIDEO_PROVIDER; - const policyError = await enforceVideoPolicy(request, provider, "grok-imagine-video"); + const policyError = await enforceVideoPolicy(request, provider, "grok-imagine-video", apiKey); if (policyError) return policyError; const preferredConnectionId = request.headers.get("x-connection-id") || null; diff --git a/src/sse/services/apiKeyPolicy.js b/src/sse/services/apiKeyPolicy.js index 4e6598093..711ca5205 100644 --- a/src/sse/services/apiKeyPolicy.js +++ b/src/sse/services/apiKeyPolicy.js @@ -71,23 +71,23 @@ export async function recordApiKeyUsageForResponse(apiKey, response, usage) { /** * Enforce API key policy on a request: model allowlist + token/cost limits. * - * Call this AFTER the shared evaluateApiKeyAuth guard. - * If no API key is provided, returns null (allow — requireApiKey handles that case). - * If the key has no policy or an empty allowedModels list, returns null (allow all). - * If the model is not in the allowlist, returns a 403 error Response. - * If token or cost limit is exceeded, returns a 429 error Response. + * Call this AFTER the shared credential resolver and pass its resolved key so + * stale lower-precedence credentials cannot bypass the authenticated key's + * policy. If no key is provided, legacy callers use the first credential. + * If the key has no policy or an empty allowedModels list, returns null. * * @param {Request} request - * @param {string} modelStr - The model string from the request body - * @returns {Promise} null if allowed, error Response if rejected + * @param {string} modelStr + * @param {string | null} [apiKey] + * @returns {Promise} */ -export async function enforceApiKeyModelPolicy(request, modelStr) { +export async function enforceApiKeyModelPolicy(request, modelStr, apiKey) { // Skip policy enforcement for internal dashboard/CLI requests only when the CLI // token is genuinely valid. An arbitrary non-empty header should not bypass policy. const hasCli = await hasValidCliToken(request); if (hasCli) return null; - const apiKey = extractApiKey(request); + if (apiKey === undefined) apiKey = extractApiKey(request); if (!apiKey) return null; const keyRecord = await getApiKeyByKey(apiKey); diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index a0b29f9c3..f5b8cfdc5 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -1004,42 +1004,58 @@ export async function clearAccountError(connectionId, currentConnection, model = } /** - * Extract API key from request headers - */ -/** - * Resolve the API key credential used for the request. - * Supports: - * - Authorization: Bearer - * - x-api-key: - * - x-goog-api-key: (Gemini native clients) - * - ?key= query parameter (Gemini native clients) + * Extract the first API key credential presented by a request. * * @param {Request} request * @returns {string | null} */ export function extractApiKey(request) { - if (!request?.headers?.get) return null; + return extractApiKeyCandidates(request)[0] || null; +} - // Check Authorization header first +/** + * Extract every distinct API key credential presented by one request, in + * precedence order. Some Anthropic clients send a stale Bearer token beside a + * valid `x-api-key`, so authentication must check both without trusting any + * credential from another request. + * + * @param {Request} request + * @returns {string[]} + */ +export function extractApiKeyCandidates(request) { + if (!request?.headers?.get) return []; + const candidates = []; + const add = (value) => { + if (value && !candidates.includes(value)) candidates.push(value); + }; const authHeader = request.headers.get("Authorization"); - if (authHeader?.startsWith("Bearer ")) { - return authHeader.slice(7); - } + if (authHeader?.startsWith("Bearer ")) add(authHeader.slice(7)); + add(request.headers.get("x-api-key")); + add(request.headers.get("x-goog-api-key")); + add(new URL(request.url).searchParams.get("key")); + return candidates; +} - // Check Anthropic x-api-key header - const xApiKey = request.headers.get("x-api-key"); - if (xApiKey) { - return xApiKey; +/** + * Resolve all credentials presented by one request and return the credential + * that authenticated. Unknown placeholder keys remain allowed only in local + * mode, after every candidate has been checked for a valid stored identity. + * + * @param {Request} request + * @param {{ required?: boolean, now?: number }} options + * @returns {Promise<{apiKey: string | null, auth: object}>} + */ +export async function resolveClientApiKey(request, { required = false, now = Date.now() } = {}) { + const candidates = extractApiKeyCandidates(request); + if (await hasValidCliToken(request)) { + return { apiKey: candidates[0] || null, auth: { ok: true, reason: null, stored: false, operator: true } }; } - - // Check Gemini native header and query parameter - const googleApiKey = request.headers.get("x-goog-api-key"); - if (googleApiKey) { - return googleApiKey; + for (const apiKey of candidates) { + const auth = await evaluateApiKeyCredential(apiKey, { required: true, now }); + if (auth.ok) return { apiKey, auth }; } - - const url = new URL(request.url); - return url.searchParams.get("key") || null; + const apiKey = candidates[0] || null; + return { apiKey, auth: await evaluateApiKeyCredential(apiKey, { required, now }) }; } /** @@ -1058,10 +1074,7 @@ export async function isValidApiKey(apiKey) { * This prevents an expired or malformed stored key from becoming a policy/usage * identity when global API-key enforcement is disabled. */ -export async function evaluateApiKeyAuth(apiKey, { required = false, now = Date.now(), request = null } = {}) { - if (await hasValidCliToken(request)) { - return { ok: true, reason: null, stored: false, operator: true }; - } +async function evaluateApiKeyCredential(apiKey, { required, now }) { if (!apiKey) { return { ok: !required, reason: required ? "missing" : null, stored: false }; } @@ -1074,3 +1087,8 @@ export async function evaluateApiKeyAuth(apiKey, { required = false, now = Date. const valid = record.isActive === true && !isApiKeyExpired(record.expiresAt, now); return { ok: valid, reason: valid ? null : "invalid", stored: true }; } + +export async function evaluateApiKeyAuth(apiKey, { required = false, now = Date.now(), request = null } = {}) { + if (request) return (await resolveClientApiKey(request, { required, now })).auth; + return evaluateApiKeyCredential(apiKey, { required, now }); +} diff --git a/tests/unit/antigravity-combo-capacity-cycle.test.js b/tests/unit/antigravity-combo-capacity-cycle.test.js index ef90d4fa5..3d5fd78bc 100644 --- a/tests/unit/antigravity-combo-capacity-cycle.test.js +++ b/tests/unit/antigravity-combo-capacity-cycle.test.js @@ -31,6 +31,10 @@ vi.mock("../../src/sse/services/auth.js", () => ({ clearAccountError: mocks.clearAccountError, extractApiKey: mocks.extractApiKey, evaluateApiKeyAuth: mocks.evaluateApiKeyAuth, + resolveClientApiKey: async (request, options) => ({ + apiKey: mocks.extractApiKey(request), + auth: await mocks.evaluateApiKeyAuth(mocks.extractApiKey(request), { ...options, request }), + }), hasValidCliToken: mocks.hasValidCliToken, })); diff --git a/tests/unit/api-key-auth-expiry.test.js b/tests/unit/api-key-auth-expiry.test.js index 02897c373..bfff9e318 100644 --- a/tests/unit/api-key-auth-expiry.test.js +++ b/tests/unit/api-key-auth-expiry.test.js @@ -15,7 +15,7 @@ vi.mock("@/lib/localDb", () => ({ validateApiKey: vi.fn(), })); -const { evaluateApiKeyAuth, extractApiKey } = await import("../../src/sse/services/auth.js"); +const { evaluateApiKeyAuth, extractApiKey, extractApiKeyCandidates, resolveClientApiKey } = await import("../../src/sse/services/auth.js"); describe("API-key authentication expiry", () => { beforeEach(() => { @@ -41,6 +41,39 @@ describe("API-key authentication expiry", () => { expect(extractApiKey(new Request(url, { headers }))).toBe(expected); }); + it("resolves a valid x-api-key when the Bearer credential is stale", async () => { + mocks.getApiKeyByKey.mockImplementation(async (key) => key === "sk-valid" + ? { isActive: true, expiresAt: null } + : null); + const request = new Request("http://localhost/v1/messages", { + headers: { + Authorization: "Bearer stale-session-token", + "x-api-key": "sk-valid", + }, + }); + + expect(extractApiKeyCandidates(request)).toEqual(["stale-session-token", "sk-valid"]); + await expect(resolveClientApiKey(request, { required: true })).resolves.toMatchObject({ + apiKey: "sk-valid", + auth: { ok: true, stored: true }, + }); + }); + + it("rejects when every presented credential is invalid", async () => { + mocks.getApiKeyByKey.mockResolvedValue(null); + const request = new Request("http://localhost/v1/messages", { + headers: { + Authorization: "Bearer stale-session-token", + "x-api-key": "sk-invalid", + }, + }); + + await expect(resolveClientApiKey(request, { required: true })).resolves.toEqual({ + apiKey: "stale-session-token", + auth: { ok: false, reason: "invalid", stored: false }, + }); + }); + it("requires a key only when configured", async () => { await expect(evaluateApiKeyAuth(null, { required: false, now: 1 })).resolves.toEqual({ ok: true, diff --git a/tests/unit/api-key-policy-handler-contract.test.js b/tests/unit/api-key-policy-handler-contract.test.js index 6e6ffe4c4..da9164c5c 100644 --- a/tests/unit/api-key-policy-handler-contract.test.js +++ b/tests/unit/api-key-policy-handler-contract.test.js @@ -24,8 +24,8 @@ const policyHandlers = authHandlers.filter((file) => !["chat.js", "countTokens.j describe("non-chat API-key policy contract", () => { it.each(authHandlers)("%s authenticates before model, executor, or provider work", (file) => { const source = fs.readFileSync(path.join(repoRoot, "src/sse/handlers", file), "utf8"); - const guard = source.indexOf("await evaluateApiKeyAuth("); - expect(guard, `${file} must use the shared credential guard`).toBeGreaterThan(-1); + const guard = source.indexOf("await resolveClientApiKey("); + expect(guard, `${file} must use the shared credential resolver`).toBeGreaterThan(-1); for (const work of ["await getModelInfo(", "getExecutor(", "await getProviderCredentials("]) { const position = source.indexOf(work); @@ -55,7 +55,7 @@ describe("non-chat API-key policy contract", () => { path.join(repoRoot, "src/app/api/v1beta/models/[...path]/route.js"), "utf8", ); - const guard = source.indexOf("await evaluateApiKeyAuth("); + const guard = source.indexOf("await resolveClientApiKey("); const providerWork = source.indexOf("await getProviderCredentials("); expect(guard).toBeGreaterThan(-1); expect(providerWork).toBeGreaterThan(guard); diff --git a/tests/unit/api-key-policy-routing.test.js b/tests/unit/api-key-policy-routing.test.js index aa66a902a..18dd19d76 100644 --- a/tests/unit/api-key-policy-routing.test.js +++ b/tests/unit/api-key-policy-routing.test.js @@ -17,6 +17,10 @@ vi.mock("../../src/sse/services/model.js", () => ({ getModelInfo: mocks.getModel vi.mock("../../src/sse/services/auth.js", () => ({ extractApiKey: vi.fn(() => "sk-test"), evaluateApiKeyAuth: mocks.evaluateApiKeyAuth, + resolveClientApiKey: async (request, options) => ({ + apiKey: "sk-test", + auth: await mocks.evaluateApiKeyAuth("sk-test", { ...options, request }), + }), getProviderCredentials: mocks.getProviderCredentials, getProviderCredentialsWithQuotaPreflight: vi.fn(), markAccountUnavailable: vi.fn(), @@ -84,7 +88,7 @@ describe("resolved-target API-key policy routing", () => { const response = await handleModerations(request("/v1/moderations", { model: "friendly-alias", input: "hello" })); expect(response.status).toBe(403); - expect(mocks.enforce).toHaveBeenCalledWith(expect.any(Request), "openai/friendly-alias-resolved"); + expect(mocks.enforce).toHaveBeenCalledWith(expect.any(Request), "openai/friendly-alias-resolved", "sk-test"); expect(mocks.moderationCore).not.toHaveBeenCalled(); expect(mocks.record).not.toHaveBeenCalled(); }); diff --git a/tests/unit/chat-policy-sequence.test.js b/tests/unit/chat-policy-sequence.test.js index 6887fef8a..0cf67865e 100644 --- a/tests/unit/chat-policy-sequence.test.js +++ b/tests/unit/chat-policy-sequence.test.js @@ -33,6 +33,7 @@ vi.mock("../../src/sse/services/auth.js", () => ({ clearAccountError: vi.fn(), extractApiKey: vi.fn(() => "sk-test"), evaluateApiKeyAuth: vi.fn(async () => ({ ok: true, stored: true })), + resolveClientApiKey: vi.fn(async () => ({ apiKey: "sk-test", auth: { ok: true, stored: true } })), })); vi.mock("../../src/sse/services/apiKeyPolicy.js", () => ({ enforceApiKeyModelPolicy: mocks.enforceApiKeyModelPolicy, @@ -98,6 +99,7 @@ describe("chat policy enforcement sequence", () => { expect(mocks.enforceApiKeyModelPolicy).toHaveBeenCalledWith( expect.any(Request), "openai/gpt-4o", + "sk-test", ); }); diff --git a/tests/unit/chat-quota-fallback.test.js b/tests/unit/chat-quota-fallback.test.js index c776b171e..db43683c4 100644 --- a/tests/unit/chat-quota-fallback.test.js +++ b/tests/unit/chat-quota-fallback.test.js @@ -40,6 +40,10 @@ vi.mock("../../src/sse/services/auth.js", () => ({ clearAccountError: mocks.clearAccountError, extractApiKey: vi.fn(() => null), evaluateApiKeyAuth: mocks.evaluateApiKeyAuth, + resolveClientApiKey: async (request, options) => ({ + apiKey: null, + auth: await mocks.evaluateApiKeyAuth(null, { ...options, request }), + }), providerAllowsPublicNoAuthFallback: vi.fn(() => false), })); diff --git a/tests/unit/chat-unauthorized-no-lookup.test.js b/tests/unit/chat-unauthorized-no-lookup.test.js index f3912d3dc..76f008db9 100644 --- a/tests/unit/chat-unauthorized-no-lookup.test.js +++ b/tests/unit/chat-unauthorized-no-lookup.test.js @@ -32,6 +32,7 @@ vi.mock("../../src/sse/services/auth.js", () => ({ clearAccountError: vi.fn(), extractApiKey: vi.fn(() => "sk-test"), evaluateApiKeyAuth: vi.fn(async () => ({ ok: true, stored: true })), + resolveClientApiKey: vi.fn(async () => ({ apiKey: "sk-test", auth: { ok: true, stored: true } })), })); vi.mock("../../src/sse/services/apiKeyPolicy.js", () => ({ enforceApiKeyModelPolicy: mocks.enforceApiKeyModelPolicy, diff --git a/tests/unit/combo-access-control.test.js b/tests/unit/combo-access-control.test.js index 834f8edb2..ae21df612 100644 --- a/tests/unit/combo-access-control.test.js +++ b/tests/unit/combo-access-control.test.js @@ -36,6 +36,10 @@ vi.mock("../../src/sse/services/model.js", async (importOriginal) => ({ vi.mock("../../src/sse/services/auth.js", () => ({ extractApiKey: mocks.extractApiKey, evaluateApiKeyAuth: mocks.evaluateApiKeyAuth, + resolveClientApiKey: async (request, options) => ({ + apiKey: mocks.extractApiKey(request), + auth: await mocks.evaluateApiKeyAuth(mocks.extractApiKey(request), { ...options, request }), + }), isValidApiKey: vi.fn(async () => true), getProviderCredentials: vi.fn(), getProviderCredentialsWithQuotaPreflight: vi.fn(), diff --git a/tests/unit/combo-token-saver-no-double-count.test.js b/tests/unit/combo-token-saver-no-double-count.test.js index 925eaa06b..4eac5833a 100644 --- a/tests/unit/combo-token-saver-no-double-count.test.js +++ b/tests/unit/combo-token-saver-no-double-count.test.js @@ -33,6 +33,10 @@ vi.mock("../../src/sse/services/auth.js", () => ({ clearAccountError: mocks.clearAccountError, extractApiKey: mocks.extractApiKey, evaluateApiKeyAuth: mocks.evaluateApiKeyAuth, + resolveClientApiKey: async (request, options) => ({ + apiKey: mocks.extractApiKey(request), + auth: await mocks.evaluateApiKeyAuth(mocks.extractApiKey(request), { ...options, request }), + }), hasValidCliToken: mocks.hasValidCliToken, isProviderConnectionModelLocked: () => false, providerAllowsPublicNoAuthFallback: () => false, diff --git a/tests/unit/dashboard-guard.test.js b/tests/unit/dashboard-guard.test.js index 8fc6e89d8..37464d117 100644 --- a/tests/unit/dashboard-guard.test.js +++ b/tests/unit/dashboard-guard.test.js @@ -99,6 +99,36 @@ describe("dashboard guard public LLM API access", () => { expect(mocks.validateApiKey).not.toHaveBeenCalled(); }); + it("accepts a valid x-api-key alongside a stale Bearer credential", async () => { + mocks.validateApiKey.mockImplementation(async (key) => key === "sk-valid"); + + const response = await proxy(request("/v1/messages", { + host: "router.example.com", + authorization: "Bearer stale-session-token", + "x-api-key": "sk-valid", + })); + + expect(response).toBe(mocks.nextResponse); + expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid"); + }); + + it("rejects all-invalid Anthropic credentials with its native error envelope", async () => { + const response = await proxy(request("/v1/messages", { + host: "router.example.com", + authorization: "Bearer stale-session-token", + "x-api-key": "sk-invalid", + })); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ + type: "error", + error: { + type: "authentication_error", + message: "API key required for remote API access", + }, + }); + }); + it("allows a remote dashboard session (JWT cookie) to GET the model-list API", async () => { // The dashboard reads /api/v1/models/* to render provider/embedding grids; // those fetches carry the session cookie, not an API key, and a Tailscale @@ -467,4 +497,19 @@ describe("dashboard guard helpers", () => { expect(__test__.extractApiKey(apiRequest)).toBe("header-key"); }); + + it("collects every presented credential once in precedence order", () => { + const apiRequest = request("/v1beta/models?key=query-key", { + authorization: "Bearer bearer-key", + "x-api-key": "header-key", + "x-goog-api-key": "google-key", + }); + + expect(__test__.extractApiKeyCandidates(apiRequest)).toEqual([ + "bearer-key", + "header-key", + "google-key", + "query-key", + ]); + }); }); diff --git a/tests/unit/gemini-native-endpoint.test.js b/tests/unit/gemini-native-endpoint.test.js index 132a268a0..02d199127 100644 --- a/tests/unit/gemini-native-endpoint.test.js +++ b/tests/unit/gemini-native-endpoint.test.js @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ markAccountUnavailable: vi.fn(), clearAccountError: vi.fn(), extractApiKey: vi.fn(), + resolveClientApiKey: vi.fn(), enforceApiKeyModelPolicy: vi.fn(), recordApiKeyUsageForResponse: vi.fn(), })); @@ -22,6 +23,7 @@ vi.mock("@/sse/services/auth.js", () => ({ markAccountUnavailable: mocks.markAccountUnavailable, clearAccountError: mocks.clearAccountError, extractApiKey: mocks.extractApiKey, + resolveClientApiKey: mocks.resolveClientApiKey, })); vi.mock("@/sse/services/apiKeyPolicy.js", () => ({ enforceApiKeyModelPolicy: mocks.enforceApiKeyModelPolicy, @@ -73,6 +75,10 @@ describe("Gemini native v1beta endpoint", () => { const auth = request.headers.get("authorization"); return auth?.startsWith("Bearer ") ? auth.slice(7) : request.headers.get("x-api-key") || request.headers.get("x-goog-api-key") || new URL(request.url).searchParams.get("key"); }); + mocks.resolveClientApiKey.mockImplementation(async (request) => ({ + apiKey: mocks.extractApiKey(request), + auth: await mocks.evaluateApiKeyAuth(), + })); mocks.enforceApiKeyModelPolicy.mockResolvedValue(null); mocks.recordApiKeyUsageForResponse.mockImplementation(async (_apiKey, response) => response); mocks.getProviderCredentials.mockResolvedValue({ @@ -141,7 +147,7 @@ describe("Gemini native v1beta endpoint", () => { params: Promise.resolve({ path: ["gemini-2.5-flash-preview-tts:generateContent"] }), }); - expect(mocks.evaluateApiKeyAuth).toHaveBeenCalledWith("client-router-key", { required: true, request }); + expect(mocks.resolveClientApiKey).toHaveBeenCalledWith(request, { required: true }); expect(global.fetch.mock.calls[0][1].headers["x-goog-api-key"]).toBe("real-gemini-key"); expect(global.fetch.mock.calls[0][1].headers["x-goog-api-key"]).not.toBe("client-router-key"); }); @@ -151,7 +157,7 @@ describe("Gemini native v1beta endpoint", () => { const request = makeGeminiRequest("gemini-3.1-flash-tts-preview:generateContent", audioBody()); const response = await POST(request, { params: Promise.resolve({ path: ["gemini-3.1-flash-tts-preview:generateContent"] }) }); expect(response.status).toBe(403); - expect(mocks.enforceApiKeyModelPolicy).toHaveBeenCalledWith(request, "gemini/gemini-3.1-flash-tts-preview"); + expect(mocks.enforceApiKeyModelPolicy).toHaveBeenCalledWith(request, "gemini/gemini-3.1-flash-tts-preview", "router-client-key"); expect(mocks.getProviderCredentials).not.toHaveBeenCalled(); expect(global.fetch).not.toHaveBeenCalled(); }); diff --git a/tests/unit/music-fallback.test.js b/tests/unit/music-fallback.test.js index bce909966..b19198d21 100644 --- a/tests/unit/music-fallback.test.js +++ b/tests/unit/music-fallback.test.js @@ -25,6 +25,10 @@ vi.mock("../../src/sse/services/auth.js", () => ({ markAccountUnavailable: mocks.markAccountUnavailable, extractApiKey: mocks.extractApiKey, evaluateApiKeyAuth: mocks.evaluateApiKeyAuth, + resolveClientApiKey: async (request, options) => ({ + apiKey: mocks.extractApiKey(request), + auth: await mocks.evaluateApiKeyAuth(mocks.extractApiKey(request), { ...options, request }), + }), hasValidCliToken: mocks.hasValidCliToken, })); diff --git a/tests/unit/omniroute-image-guard-http.test.js b/tests/unit/omniroute-image-guard-http.test.js index 4cc7d6121..13c3408ff 100644 --- a/tests/unit/omniroute-image-guard-http.test.js +++ b/tests/unit/omniroute-image-guard-http.test.js @@ -29,6 +29,7 @@ vi.mock("../../src/sse/services/auth.js", () => ({ clearAccountError: vi.fn(), extractApiKey: vi.fn(() => "test-key"), evaluateApiKeyAuth: vi.fn(async () => ({ ok: true })), + resolveClientApiKey: vi.fn(async () => ({ apiKey: "test-key", auth: { ok: true } })), })); vi.mock("../../src/sse/services/apiKeyPolicy.js", () => ({ diff --git a/tests/unit/ponytail-routing.test.js b/tests/unit/ponytail-routing.test.js index 63f7e712b..a18839478 100644 --- a/tests/unit/ponytail-routing.test.js +++ b/tests/unit/ponytail-routing.test.js @@ -31,6 +31,7 @@ vi.mock("../../src/sse/services/auth.js", () => ({ clearAccountError: vi.fn(), extractApiKey: vi.fn(() => "sk-test"), evaluateApiKeyAuth: vi.fn(async () => ({ ok: true, stored: true })), + resolveClientApiKey: vi.fn(async () => ({ apiKey: "sk-test", auth: { ok: true, stored: true } })), })); vi.mock("../../src/sse/services/apiKeyPolicy.js", () => ({ enforceApiKeyModelPolicy: mocks.enforceApiKeyModelPolicy, diff --git a/tests/unit/realtime-auth-route.test.js b/tests/unit/realtime-auth-route.test.js index de1496de6..08d513b6a 100644 --- a/tests/unit/realtime-auth-route.test.js +++ b/tests/unit/realtime-auth-route.test.js @@ -1,14 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ - evaluateApiKeyAuth: vi.fn(), - extractApiKey: vi.fn(), + resolveClientApiKey: vi.fn(), getSettings: vi.fn(), })); vi.mock("@/sse/services/auth.js", () => ({ - evaluateApiKeyAuth: mocks.evaluateApiKeyAuth, - extractApiKey: mocks.extractApiKey, + resolveClientApiKey: mocks.resolveClientApiKey, })); vi.mock("@/lib/localDb", () => ({ getSettings: mocks.getSettings })); @@ -17,9 +15,8 @@ const { GET } = await import("@/app/api/v1/realtime/auth/route.js"); describe("realtime auth bridge", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.extractApiKey.mockReturnValue(null); mocks.getSettings.mockResolvedValue({ requireApiKey: true }); - mocks.evaluateApiKeyAuth.mockResolvedValue({ ok: true, operator: true }); + mocks.resolveClientApiKey.mockResolvedValue({ apiKey: null, auth: { ok: true, operator: true } }); }); it("passes the original request so CLI operator identity can be verified", async () => { @@ -28,6 +25,6 @@ describe("realtime auth bridge", () => { }); const response = await GET(request); expect(response.status).toBe(200); - expect(mocks.evaluateApiKeyAuth).toHaveBeenCalledWith(null, { required: true, request }); + expect(mocks.resolveClientApiKey).toHaveBeenCalledWith(request, { required: true }); }); }); diff --git a/tests/unit/vision-bridge-chat.test.js b/tests/unit/vision-bridge-chat.test.js index 1e5349ba0..41cd3864d 100644 --- a/tests/unit/vision-bridge-chat.test.js +++ b/tests/unit/vision-bridge-chat.test.js @@ -30,6 +30,7 @@ vi.mock("../../src/sse/services/auth.js", () => ({ clearAccountError: vi.fn(), extractApiKey: vi.fn(() => "test-key"), evaluateApiKeyAuth: vi.fn(async () => ({ ok: true })), + resolveClientApiKey: vi.fn(async () => ({ apiKey: "test-key", auth: { ok: true } })), })); vi.mock("../../src/sse/services/apiKeyPolicy.js", () => ({ @@ -173,7 +174,7 @@ describe("Vision Bridge (#6640) chat wiring", () => { expect(res.status).toBe(200); expect(mocks.handleChatCore.mock.calls[0][0].body.model).toBe(NON_VISION); // Original model's own policy gate in handleSingleModelChat still ran. - expect(mocks.enforceApiKeyModelPolicy).toHaveBeenCalledWith(expect.anything(), NON_VISION); + expect(mocks.enforceApiKeyModelPolicy).toHaveBeenCalledWith(expect.anything(), NON_VISION, "test-key"); }); it("does not reroute when the request has no current-turn image", async () => { @@ -188,6 +189,6 @@ describe("Vision Bridge (#6640) chat wiring", () => { expect(res.status).toBe(200); expect(mocks.handleChatCore.mock.calls[0][0].body.model).toBe(NON_VISION); // No bridge policy check against the vision target. - expect(mocks.enforceApiKeyModelPolicy).not.toHaveBeenCalledWith(expect.anything(), VISION); + expect(mocks.enforceApiKeyModelPolicy).not.toHaveBeenCalledWith(expect.anything(), VISION, "test-key"); }); }); From f1d946d0f2ae72e0b158fbedd5cea3b851051652 Mon Sep 17 00:00:00 2001 From: Agent Date: Wed, 12 Aug 2026 21:38:54 +0000 Subject: [PATCH 2/2] test(auth): reject credentialless requests without borrowing keys --- tests/unit/api-key-auth-expiry.test.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/unit/api-key-auth-expiry.test.js b/tests/unit/api-key-auth-expiry.test.js index bfff9e318..f091fb6e1 100644 --- a/tests/unit/api-key-auth-expiry.test.js +++ b/tests/unit/api-key-auth-expiry.test.js @@ -32,6 +32,22 @@ describe("API-key authentication expiry", () => { await expect(evaluateApiKeyAuth(null, { required: true, request: invalid })).resolves.toMatchObject({ ok: false, reason: "missing" }); }); + it("rejects a credentialless request without borrowing a stored API key", async () => { + const storedApiKey = "sk-valid-stored"; + mocks.getApiKeyByKey.mockImplementation(async (key) => key === storedApiKey + ? { isActive: true, expiresAt: null } + : null); + const request = new Request("http://localhost/v1/chat"); + + expect(extractApiKeyCandidates(request)).toEqual([]); + await expect(resolveClientApiKey(request, { required: true })).resolves.toEqual({ + apiKey: null, + auth: { ok: false, reason: "missing", stored: false }, + }); + expect(mocks.getApiKeyByKey).not.toHaveBeenCalledWith(storedApiKey); + expect(mocks.getApiKeyByKey).not.toHaveBeenCalled(); + }); + it.each([ ["authorization", { Authorization: "Bearer sk-bearer" }, "http://localhost/v1/chat", "sk-bearer"], ["Anthropic", { "x-api-key": "sk-anthropic" }, "http://localhost/v1/messages", "sk-anthropic"],