Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions open-sse/utils/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}));
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/v1/realtime/auth/route.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { extractApiKey, evaluateApiKeyAuth } from "@/sse/services/auth.js";
import { resolveClientApiKey } from "@/sse/services/auth.js";
import { getSettings } from "@/lib/localDb";

/**
Expand All @@ -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": "*" } });
}
Expand Down
25 changes: 12 additions & 13 deletions src/app/api/v1beta/models/[...path]/route.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { handleChat } from "@/sse/handlers/chat.js";
import {
clearAccountError,
evaluateApiKeyAuth,
extractApiKey,
resolveClientApiKey,
getProviderCredentials,
markAccountUnavailable,
} from "@/sse/services/auth.js";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
});
Expand Down
37 changes: 28 additions & 9 deletions src/dashboardGuard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -241,6 +250,7 @@ export const __test__ = {
hasExactRequestOrigin,
isPublicLlmApi,
extractApiKey,
extractApiKeyCandidates,
canAccessPublicLlmApi,
canAccessLocalOnlyRoute,
};
Expand Down Expand Up @@ -296,6 +306,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 });
}

Expand Down
2 changes: 1 addition & 1 deletion src/shared/utils/wsHandshake.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 12 additions & 18 deletions src/sse/handlers/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import {
projectProviderCredentials,
markAccountUnavailable,
clearAccountError,
extractApiKey,
evaluateApiKeyAuth,
resolveClientApiKey,
isProviderConnectionModelLocked,
providerAllowsPublicNoAuthFallback,
} from "../services/auth.js";
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
9 changes: 5 additions & 4 deletions src/sse/handlers/countTokens.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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).
Expand Down
14 changes: 6 additions & 8 deletions src/sse/handlers/embeddings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)");
Expand All @@ -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;

Expand Down
14 changes: 6 additions & 8 deletions src/sse/handlers/fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import {
getProviderCredentialsWithQuotaPreflight,
markAccountUnavailable,
clearAccountError,
extractApiKey,
evaluateApiKeyAuth,
resolveClientApiKey,
projectProviderCredentials,
} from "../services/auth.js";
import { getSettings, getCombos, getApiKeyByKey, getProviderConnections } from "@/lib/localDb";
Expand Down Expand Up @@ -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)");
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading