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
3 changes: 3 additions & 0 deletions messages/en/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"PERMISSION_DENIED": "Permission denied",
"TOKEN_REQUIRED": "Authentication token required",
"INVALID_TOKEN": "Invalid authentication token",
"PROXY_INVALID_API_KEY": "Invalid API key. The provided key does not exist or has been deleted.",
"PROXY_API_KEY_DISABLED": "This API key has been disabled. Please contact your administrator to re-enable it, or use a different key.",
"PROXY_API_KEY_EXPIRED": "This API key has expired. Please contact your administrator to renew it or rotate to a new key.",

"INTERNAL_ERROR": "Internal server error, please try again later",
"DATABASE_ERROR": "Database error",
Expand Down
3 changes: 3 additions & 0 deletions messages/ja/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"PERMISSION_DENIED": "アクセス権限がありません",
"TOKEN_REQUIRED": "認証トークンが必要です",
"INVALID_TOKEN": "無効な認証トークン",
"PROXY_INVALID_API_KEY": "API キーが無効です。指定されたキーは存在しないか、削除されています。",
"PROXY_API_KEY_DISABLED": "この API キーは無効化されています。管理者に再有効化を依頼するか、別のキーをご使用ください。",
"PROXY_API_KEY_EXPIRED": "この API キーは期限切れです。管理者に更新を依頼するか、新しいキーへ切り替えてください。",

"INTERNAL_ERROR": "内部サーバーエラー、後でもう一度お試しください",
"DATABASE_ERROR": "データベースエラー",
Expand Down
3 changes: 3 additions & 0 deletions messages/ru/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"PERMISSION_DENIED": "Доступ запрещен",
"TOKEN_REQUIRED": "Требуется токен аутентификации",
"INVALID_TOKEN": "Недействительный токен аутентификации",
"PROXY_INVALID_API_KEY": "Неверный API-ключ. Указанный ключ не существует или был удалён.",
"PROXY_API_KEY_DISABLED": "Этот API-ключ отключён. Обратитесь к администратору, чтобы повторно включить его, или используйте другой ключ.",
"PROXY_API_KEY_EXPIRED": "Срок действия этого API-ключа истёк. Обратитесь к администратору, чтобы продлить срок, или замените ключ.",

"INTERNAL_ERROR": "Внутренняя ошибка сервера, попробуйте позже",
"DATABASE_ERROR": "Ошибка базы данных",
Expand Down
3 changes: 3 additions & 0 deletions messages/zh-CN/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"PERMISSION_DENIED": "权限不足",
"TOKEN_REQUIRED": "需要提供认证令牌",
"INVALID_TOKEN": "无效的认证令牌",
"PROXY_INVALID_API_KEY": "API 密钥无效。提供的密钥不存在或已被删除。",
"PROXY_API_KEY_DISABLED": "API 密钥已被禁用。请联系管理员重新启用,或使用其他可用密钥。",
"PROXY_API_KEY_EXPIRED": "API 密钥已过期。请联系管理员续期或更换密钥。",

"INTERNAL_ERROR": "系统内部错误,请稍后重试",
"DATABASE_ERROR": "数据库错误",
Expand Down
3 changes: 3 additions & 0 deletions messages/zh-TW/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
"PERMISSION_DENIED": "權限不足",
"TOKEN_REQUIRED": "需要提供認證令牌",
"INVALID_TOKEN": "無效的認證令牌",
"PROXY_INVALID_API_KEY": "API 金鑰無效。提供的金鑰不存在或已被刪除。",
"PROXY_API_KEY_DISABLED": "API 金鑰已被停用。請聯絡管理員重新啟用,或使用其他可用金鑰。",
"PROXY_API_KEY_EXPIRED": "API 金鑰已過期。請聯絡管理員續期或更換金鑰。",

"INTERNAL_ERROR": "系統內部錯誤,請稍後重試",
"DATABASE_ERROR": "資料庫錯誤",
Expand Down
51 changes: 46 additions & 5 deletions src/app/v1/_lib/models/available-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import { request as undiciRequest } from "undici";
import { normalizeAllowedModelRules } from "@/lib/allowed-model-rules";
import { logger } from "@/lib/logger";
import { createProxyAgentForProvider } from "@/lib/proxy-agent";
import { ERROR_CODES, getErrorMessageServer } from "@/lib/utils/error-messages";
import { isProviderActiveNow } from "@/lib/utils/provider-schedule";
import { resolveSystemTimezone } from "@/lib/utils/timezone";
import { validateApiKeyAndGetUser } from "@/repository/key";
import { resolveApiKeyAuthOutcome } from "@/repository/key";
import { findAllProviders } from "@/repository/provider";
import type {
AnthropicModelsResponse,
Expand Down Expand Up @@ -60,12 +61,52 @@ async function authenticateRequest(c: Context): Promise<{
throw c.json({ error: { message: "未提供认证凭据", type: "authentication_error" } }, 401);
}

const authResult = await validateApiKeyAndGetUser(apiKey);
if (!authResult) {
throw c.json({ error: { message: "API 密钥无效", type: "invalid_api_key" } }, 401);
const outcome = await resolveApiKeyAuthOutcome(apiKey);
if (!outcome.ok) {
// Exhaustive switch: see auth-guard.ts for rationale. Adding a new
// ApiKeyAuthFailureReason will produce a TypeScript error on the
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] [TEST-MISSING-CRITICAL] /v1/models key_disabled / key_expired branches have no unit coverage

Why this is a problem: This PR adds new auth behavior in authenticateRequest (distinct 401 key_disabled vs key_expired). There is currently no unit test that asserts these new branches, so a regression back to a generic invalid_api_key (or other behavior) would go undetected. Guideline: 2. **Test Coverage** - All new features must have unit test coverage of at least 80%.

Suggested fix:

// tests/unit/models/available-models-auth-outcome.test.ts
import { describe, expect, it, vi } from "vitest";

vi.mock("@/repository/key", () => ({
  resolveApiKeyAuthOutcome: vi.fn(),
}));

function makeCtx(apiKey: string) {
  return {
    req: {
      path: "/v1/models",
      header: (name: string) => (name.toLowerCase() === "x-api-key" ? apiKey : undefined),
      query: () => undefined,
    },
    json: (body: unknown, status?: number) =>
      new Response(JSON.stringify(body), {
        status: status ?? 200,
        headers: { "content-type": "application/json" },
      }),
  } as any;
}

describe("handleAvailableModels auth outcomes", () => {
  it("returns 401 key_disabled", async () => {
    const { resolveApiKeyAuthOutcome } = await import("@/repository/key");
    vi.mocked(resolveApiKeyAuthOutcome).mockResolvedValueOnce({ ok: false, reason: "key_disabled" });

    const { handleAvailableModels } = await import("@/app/v1/_lib/models/available-models");
    const response = await handleAvailableModels(makeCtx("sk-disabled"));

    expect(response.status).toBe(401);
    const payload = (await response.json()) as { error: { type: string } };
    expect(payload.error.type).toBe("key_disabled");
  });

  it("returns 401 key_expired", async () => {
    const { resolveApiKeyAuthOutcome } = await import("@/repository/key");
    vi.mocked(resolveApiKeyAuthOutcome).mockResolvedValueOnce({ ok: false, reason: "key_expired" });

    const { handleAvailableModels } = await import("@/app/v1/_lib/models/available-models");
    const response = await handleAvailableModels(makeCtx("sk-expired"));

    expect(response.status).toBe(401);
    const payload = (await response.json()) as { error: { type: string } };
    expect(payload.error.type).toBe("key_expired");
  });
});

// exhaustiveness fallthrough until this branch is handled explicitly.
const { getLocale } = await import("next-intl/server");
const locale = await getLocale();
switch (outcome.reason) {
case "key_disabled":
throw c.json(
{
error: {
message: await getErrorMessageServer(locale, ERROR_CODES.PROXY_API_KEY_DISABLED),
type: "key_disabled",
},
},
401
);
case "key_expired":
throw c.json(
{
error: {
message: await getErrorMessageServer(locale, ERROR_CODES.PROXY_API_KEY_EXPIRED),
type: "key_expired",
},
},
401
);
case "not_found":
throw c.json(
{
error: {
message: await getErrorMessageServer(locale, ERROR_CODES.PROXY_INVALID_API_KEY),
type: "invalid_api_key",
},
},
401
);
default: {
const _exhaustive: never = outcome.reason;
throw new Error(`Unhandled auth outcome reason: ${JSON.stringify(_exhaustive)}`);
}
}
}

const { user, key } = authResult;
const { user, key } = outcome;

if (!user.isEnabled) {
throw c.json({ error: { message: "用户账户已被禁用", type: "user_disabled" } }, 401);
Expand Down
171 changes: 122 additions & 49 deletions src/app/v1/_lib/proxy/auth-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,51 @@ import { extractApiKeyFromHeaders as sharedExtractApiKeyFromHeaders } from "@/li
import { getClientIpWithFreshSettings } from "@/lib/ip";
import { logger } from "@/lib/logger";
import { LoginAbusePolicy } from "@/lib/security/login-abuse-policy";
import { validateApiKeyAndGetUser } from "@/repository/key";
import { ERROR_CODES, getErrorMessageServer } from "@/lib/utils/error-messages";
import { resolveApiKeyAuthOutcome } from "@/repository/key";
import { markUserExpired } from "@/repository/user";
import { GEMINI_PROTOCOL } from "../gemini/protocol";
import { ProxyResponses } from "./responses";
import type { AuthState, ProxySession } from "./session";
import type { AuthFailureKind, AuthState, ProxySession } from "./session";

async function getRequestLocale(): Promise<string> {
// Match the rate-limit-guard pattern: read next-intl's request locale
// (Accept-Language or cookie) lazily inside the guard so the proxy hot
// path does not pay the import cost on every cold start.
const { getLocale } = await import("next-intl/server");
return await getLocale();
}

/**
* Build an auth failure AuthState. The factory exists so every call site is
* forced (by the function signature) to supply both `failureKind` and an
* `errorResponse` — preventing the footgun where a new failure branch could
* forget to tag itself and silently get classified as a credentials failure
* by the brute-force rate limiter.
*/
function buildAuthFailure(params: {
failureKind: AuthFailureKind;
errorResponse: Response;
apiKey?: string | null;
}): AuthState {
return {
user: null,
key: null,
apiKey: params.apiKey ?? null,
success: false,
failureKind: params.failureKind,
errorResponse: params.errorResponse,
};
}

/**
* Exhaustiveness helper. Calling this from a "should be unreachable" branch
* gives a compile-time error when a new union member is added without an
* explicit handler.
*/
function assertNever(value: never, context: string): never {
throw new Error(`Unhandled discriminant in ${context}: ${JSON.stringify(value)}`);
}

/**
* Pre-auth rate limiter: throttles repeated authentication failures per IP
Expand Down Expand Up @@ -73,8 +113,13 @@ export class ProxyAuthenticator {
return null;
}

// Record failure for rate limiting
proxyAuthPolicy.recordFailure(clientIp, authState.apiKey ?? candidateApiKey ?? undefined);
// Only `credentials` failures should feed the brute-force rate limiter.
// `account_state` failures (key/user disabled or expired) match a real
// record, so recording them would let an admin lock themselves out by
// simply disabling a key and watching the owner retry.
if (authState.failureKind !== "account_state") {
proxyAuthPolicy.recordFailure(clientIp, authState.apiKey ?? candidateApiKey ?? undefined);
}

// 返回详细的错误信息,帮助用户快速定位问题
return authState.errorResponse ?? ProxyResponses.buildError(401, "认证失败");
Expand Down Expand Up @@ -128,17 +173,14 @@ export class ProxyAuthenticator {
hasGeminiApiKeyHeader: !!headers.geminiApiKeyHeader,
hasGeminiApiKeyQuery: !!headers.geminiApiKeyQuery,
});
return {
user: null,
key: null,
apiKey: null,
success: false,
return buildAuthFailure({
failureKind: "credentials",
errorResponse: ProxyResponses.buildError(
401,
"未提供认证凭据。请在 Authorization 头部、x-api-key 头部或 x-goog-api-key 头部中包含 API 密钥。",
"authentication_error"
),
};
});
}

const [firstKey] = providedKeys;
Expand All @@ -148,61 +190,94 @@ export class ProxyAuthenticator {
logger.warn("[ProxyAuthenticator] Multiple conflicting API keys provided", {
keyCount: providedKeys.length,
});
return {
user: null,
key: null,
apiKey: null,
success: false,
return buildAuthFailure({
failureKind: "credentials",
errorResponse: ProxyResponses.buildError(
401,
"提供了多个冲突的 API 密钥。请仅使用一种认证方式。",
"authentication_error"
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] [STANDARD-VIOLATION] New auth error messages hardcode user-facing text (bypasses i18n)

Why this is a problem: Guideline: 3. **i18n Required** - All user-facing strings must use i18n (5 languages supported). Never hardcode display text. The new responses embed hardcoded message strings (e.g. "API 密钥无效。提供的密钥不存在或已被删除。"), which cannot be localized and will leak a single-language UX into non-zh locales.

Suggested fix:

// src/app/v1/_lib/proxy/auth-guard.ts
const { getLocale, getTranslations } = await import("next-intl/server");
const locale = await getLocale();
const t = await getTranslations({ locale, namespace: "auth" });

return {
  user: null,
  key: null,
  apiKey,
  success: false,
  failureKind: "credentials",
  errorResponse: ProxyResponses.buildError(
    401,
    t("errors.apiKeyNotFoundOrDeleted"),
    "invalid_api_key"
  ),
};

Add the new keys to messages/{locale}/auth.json under errors (e.g. apiKeyNotFoundOrDeleted, apiKeyDisabled, apiKeyExpired) so all supported locales are covered.

};
});
}

const apiKey = firstKey;
const authResult = await validateApiKeyAndGetUser(apiKey);
const outcome = await resolveApiKeyAuthOutcome(apiKey);

if (!authResult) {
logger.debug("[ProxyAuthenticator] API key validation failed", {
apiKeyLength: apiKey.length,
fromHeader: !!headers.authHeader || !!headers.apiKeyHeader || !!headers.geminiApiKeyHeader,
fromQuery: !!headers.geminiApiKeyQuery,
});
return {
user: null,
key: null,
apiKey,
success: false,
errorResponse: ProxyResponses.buildError(
401,
"API 密钥无效。提供的密钥不存在、已被删除、已被禁用或已过期。",
"invalid_api_key"
),
};
if (!outcome.ok) {
// Exhaustive switch: adding a new ApiKeyAuthFailureReason in the
// repository layer will trigger a compile error in assertNever below
// until this guard is updated, preventing a new variant from silently
// falling into the wrong branch.
const locale = await getRequestLocale();
switch (outcome.reason) {
case "not_found":
logger.debug("[ProxyAuthenticator] API key validation failed: not found", {
apiKeyLength: apiKey.length,
fromHeader:
!!headers.authHeader || !!headers.apiKeyHeader || !!headers.geminiApiKeyHeader,
fromQuery: !!headers.geminiApiKeyQuery,
});
return buildAuthFailure({
apiKey,
failureKind: "credentials",
errorResponse: ProxyResponses.buildError(
401,
await getErrorMessageServer(locale, ERROR_CODES.PROXY_INVALID_API_KEY),
"invalid_api_key"
),
});

case "key_disabled":
logger.warn("[ProxyAuthenticator] API key is disabled", {
apiKeyLength: apiKey.length,
});
return buildAuthFailure({
apiKey,
failureKind: "account_state",
errorResponse: ProxyResponses.buildError(
401,
await getErrorMessageServer(locale, ERROR_CODES.PROXY_API_KEY_DISABLED),
"key_disabled"
),
});

case "key_expired":
logger.warn("[ProxyAuthenticator] API key has expired", {
apiKeyLength: apiKey.length,
});
return buildAuthFailure({
apiKey,
failureKind: "account_state",
errorResponse: ProxyResponses.buildError(
401,
await getErrorMessageServer(locale, ERROR_CODES.PROXY_API_KEY_EXPIRED),
"key_expired"
),
});

default:
assertNever(outcome.reason, "ProxyAuthenticator.validate outcome.reason");
}
}

// Check user status and expiration
const { user } = authResult;
const { user } = outcome;

// 1. Check if user is disabled
if (!user.isEnabled) {
logger.warn("[ProxyAuthenticator] User is disabled", {
userId: user.id,
userName: user.name,
});
return {
user: null,
key: null,
return buildAuthFailure({
apiKey,
success: false,
failureKind: "account_state",
errorResponse: ProxyResponses.buildError(
401,
"用户账户已被禁用。请联系管理员。",
"user_disabled"
),
};
});
}

// 2. Check if user is expired (lazy expiration check)
Expand All @@ -219,26 +294,24 @@ export class ProxyAuthenticator {
error: error instanceof Error ? error.message : String(error),
});
});
return {
user: null,
key: null,
return buildAuthFailure({
apiKey,
success: false,
failureKind: "account_state",
errorResponse: ProxyResponses.buildError(
401,
`用户账户已于 ${user.expiresAt.toISOString().split("T")[0]} 过期。请续费订阅。`,
"user_expired"
),
};
});
}

logger.debug("[ProxyAuthenticator] Authentication successful", {
userId: authResult.user.id,
userName: authResult.user.name,
keyName: authResult.key.name,
userId: outcome.user.id,
userName: outcome.user.name,
keyName: outcome.key.name,
});

return { user: authResult.user, key: authResult.key, apiKey, success: true };
return { user: outcome.user, key: outcome.key, apiKey, success: true };
}

private static extractKeyFromAuthorization(authHeader?: string): string | null {
Expand Down
Loading
Loading