diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 47f323f..2cdddfe 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -14,7 +14,7 @@ import { UsageLogFilters, UsageLogSearchData, } from '@/types' -import { clearAdminCredentials, getStoredAdminCredential } from '@/lib/admin-auth' +import { clearAdminCredentials } from '@/lib/admin-auth' const BASE_URL = import.meta.env.VITE_API_BASE_URL || '' @@ -37,6 +37,8 @@ type RequestBehavior = { skipUnauthorizedHandler?: boolean } +const isAdminEndpoint = (endpoint: string): boolean => endpoint.startsWith('/api/admin/') + export function setErrorHandlers(handlers: { onUnauthorized?: () => void onError?: (error: ApiError) => void @@ -54,12 +56,6 @@ async function requestInterceptor(config: RequestInit): Promise { headers.set('Content-Type', 'application/json') } - // 添加 admin token - const credential = getStoredAdminCredential() - if (credential) { - headers.set(credential.headerName, credential.token) - } - return { ...config, headers } } @@ -165,7 +161,10 @@ async function request( try { const config = await requestInterceptor(options) - const response = await fetch(url, config) + const requestConfig = isAdminEndpoint(endpoint) + ? { ...config, credentials: 'include' as const } + : config + const response = await fetch(url, requestConfig) return responseInterceptor(response, behavior) } catch (error) { // 处理网络错误 @@ -291,6 +290,7 @@ export const apiClient = { const url = `${BASE_URL}${endpoint}` const response = await fetch(url, { method: 'POST', + credentials: 'omit', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, diff --git a/frontend/src/lib/admin-auth.ts b/frontend/src/lib/admin-auth.ts index 28716f3..fc9b9c2 100644 --- a/frontend/src/lib/admin-auth.ts +++ b/frontend/src/lib/admin-auth.ts @@ -1,59 +1,10 @@ export const ADMIN_SESSION_TOKEN_KEY = "adminSessionToken"; export const LEGACY_ADMIN_TOKEN_KEY = "adminToken"; -export const ADMIN_SESSION_HEADER = "x-admin-session"; -export const LEGACY_ADMIN_TOKEN_HEADER = "x-admin-token"; const canUseLocalStorage = (): boolean => { return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; }; -export const getStoredAdminSessionToken = (): string | null => { - if (!canUseLocalStorage()) { - return null; - } - - const value = window.localStorage.getItem(ADMIN_SESSION_TOKEN_KEY); - return value && value.trim().length > 0 ? value.trim() : null; -}; - -export const getStoredLegacyAdminToken = (): string | null => { - if (!canUseLocalStorage()) { - return null; - } - - const value = window.localStorage.getItem(LEGACY_ADMIN_TOKEN_KEY); - return value && value.trim().length > 0 ? value.trim() : null; -}; - -export const getStoredAdminCredential = (): { headerName: string; token: string } | null => { - const sessionToken = getStoredAdminSessionToken(); - if (sessionToken) { - return { - headerName: ADMIN_SESSION_HEADER, - token: sessionToken, - }; - } - - const legacyToken = getStoredLegacyAdminToken(); - if (legacyToken) { - return { - headerName: LEGACY_ADMIN_TOKEN_HEADER, - token: legacyToken, - }; - } - - return null; -}; - -export const storeAdminSessionToken = (token: string): void => { - if (!canUseLocalStorage()) { - return; - } - - window.localStorage.removeItem(LEGACY_ADMIN_TOKEN_KEY); - window.localStorage.setItem(ADMIN_SESSION_TOKEN_KEY, token); -}; - export const clearAdminCredentials = (): void => { if (!canUseLocalStorage()) { return; @@ -64,7 +15,9 @@ export const clearAdminCredentials = (): void => { }; export const getAdminCredentialScopeToken = (): string => { - return getStoredAdminSessionToken() - || getStoredLegacyAdminToken() - || "anonymous"; + if (typeof window === "undefined") { + return "server"; + } + + return window.location.origin || "anonymous"; }; diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 8313919..4ae7ed1 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -2,11 +2,7 @@ import { create } from 'zustand' import { apiClient } from '@/api/client' import { clearScopedCacheByPrefix } from '@/lib/local-cache' import { type AdminLoginResponse } from '@/types' -import { - clearAdminCredentials, - getStoredAdminCredential, - storeAdminSessionToken, -} from '@/lib/admin-auth' +import { clearAdminCredentials } from '@/lib/admin-auth' interface AuthState { isAuthenticated: boolean @@ -38,13 +34,14 @@ export const useAuthStore = create((set) => ({ throw new Error('登录响应无效') } - if (loginResult.sessionToken) { - storeAdminSessionToken(loginResult.sessionToken) + clearAdminCredentials() + + if (!loginResult.requiresVerification) { set({ isAuthenticated: true, isLoading: false, showAuthModal: false }) return loginResult } - if (!loginResult.requiresVerification || !loginResult.challengeId) { + if (!loginResult.challengeId) { throw new Error('登录响应缺少验证码挑战信息') } @@ -68,11 +65,11 @@ export const useAuthStore = create((set) => ({ const response = await apiClient.verifyAdminLogin(challengeId, code) const loginResult = response.data as AdminLoginResponse - if (!loginResult.sessionToken) { - throw new Error('登录响应缺少会话令牌') + if (!loginResult || loginResult.requiresVerification) { + throw new Error('验证码验证后未建立会话') } - storeAdminSessionToken(loginResult.sessionToken) + clearAdminCredentials() set({ isAuthenticated: true, isLoading: false, showAuthModal: false }) } catch (error) { clearAdminCredentials() @@ -99,13 +96,9 @@ export const useAuthStore = create((set) => ({ }, checkAuth: async () => { - if (!getStoredAdminCredential()) { - set({ isAuthenticated: false, isLoading: false }) - return - } - set({ isLoading: true }) try { + clearAdminCredentials() await apiClient.checkAuth() set({ isAuthenticated: true, isLoading: false }) } catch (error) { diff --git a/src/admin/auth_api.ts b/src/admin/auth_api.ts index 00400fd..b667894 100644 --- a/src/admin/auth_api.ts +++ b/src/admin/auth_api.ts @@ -5,11 +5,13 @@ import { z } from "zod"; import { CommonErrorResponse, CommonSuccessfulResponse } from "../model"; import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config"; import { + clearAdminSessionCookie, createAdminLoginChallenge, createAdminSession, deleteAdminLoginChallenge, - getAdminSessionHeaderName, + getAdminSessionTokenFromRequest, invalidateAdminSession, + setAdminSessionCookie, sendAdminLoginCodeNotification, sendAdminLoginResultNotification, verifyAdminLoginChallenge, @@ -36,6 +38,7 @@ const buildDirectLoginResponse = async ( c: Context ) => { const session = await createAdminSession(c); + setAdminSessionCookie(c, session.sessionToken, session.expiresAt); return { success: true, @@ -43,7 +46,7 @@ const buildDirectLoginResponse = async ( requiresVerification: false, challengeId: null, challengeExpiresAt: null, - sessionToken: session.sessionToken, + sessionToken: null, sessionExpiresAt: session.expiresAt, }, message: "Login successful", @@ -188,6 +191,7 @@ export class AdminLoginVerifyEndpoint extends OpenAPIRoute { } const session = await createAdminSession(c); + setAdminSessionCookie(c, session.sessionToken, session.expiresAt); await sendAdminLoginResultNotification( securityConfig, @@ -203,7 +207,7 @@ export class AdminLoginVerifyEndpoint extends OpenAPIRoute { requiresVerification: false, challengeId: null, challengeExpiresAt: null, - sessionToken: session.sessionToken, + sessionToken: null, sessionExpiresAt: session.expiresAt, }, message: "Login verified successfully", @@ -222,9 +226,10 @@ export class AdminLogoutEndpoint extends OpenAPIRoute { }; async handle(c: Context) { - const sessionToken = c.req.header(getAdminSessionHeaderName()); + const sessionToken = getAdminSessionTokenFromRequest(c); await invalidateAdminSession(c, sessionToken); + clearAdminSessionCookie(c); return { success: true, diff --git a/src/admin/auth_shared.ts b/src/admin/auth_shared.ts index 9a22769..273194b 100644 --- a/src/admin/auth_shared.ts +++ b/src/admin/auth_shared.ts @@ -1,10 +1,12 @@ import { Context } from "hono"; +import { deleteCookie, getCookie, setCookie } from "hono/cookie"; -const ADMIN_SESSION_HEADER = "x-admin-session"; +const ADMIN_SESSION_COOKIE_NAME = "oaw_admin_session"; const ADMIN_SESSION_TTL_MS = 12 * 60 * 60 * 1000; const ADMIN_LOGIN_CHALLENGE_TTL_MS = 5 * 60 * 1000; const ADMIN_LOGIN_CHALLENGE_MAX_ATTEMPTS = 5; const DEFAULT_SYSTEM_TIMEZONE = "Asia/Shanghai"; +const LOCAL_DEV_HOSTNAMES = new Set(["0.0.0.0", "127.0.0.1", "::1", "localhost"]); type LoginRequestMetadata = { clientIp: string; @@ -144,8 +146,52 @@ const buildBaseMessageLines = ( ]; }; -export const getAdminSessionHeaderName = (): string => { - return ADMIN_SESSION_HEADER; +const isSecureCookieRequest = (c: Context): boolean => { + const requestUrl = new URL(c.req.url); + return requestUrl.protocol === "https:" && !LOCAL_DEV_HOSTNAMES.has(requestUrl.hostname); +}; + +const buildAdminSessionCookieOptions = ( + c: Context, + expiresAt?: string +) => { + return { + httpOnly: true, + maxAge: Math.floor(ADMIN_SESSION_TTL_MS / 1000), + path: "/api/admin", + sameSite: "Lax" as const, + secure: isSecureCookieRequest(c), + ...(expiresAt ? { expires: new Date(expiresAt) } : {}), + }; +}; + +export const getAdminSessionTokenFromRequest = ( + c: Context +): string | null => { + return getCookie(c, ADMIN_SESSION_COOKIE_NAME) || null; +}; + +export const setAdminSessionCookie = ( + c: Context, + sessionToken: string, + expiresAt: string +): void => { + setCookie( + c, + ADMIN_SESSION_COOKIE_NAME, + sessionToken, + buildAdminSessionCookieOptions(c, expiresAt) + ); +}; + +export const clearAdminSessionCookie = ( + c: Context +): void => { + deleteCookie( + c, + ADMIN_SESSION_COOKIE_NAME, + buildAdminSessionCookieOptions(c) + ); }; export const createAdminSession = async ( diff --git a/src/admin/index.ts b/src/admin/index.ts index 33606df..043c882 100644 --- a/src/admin/index.ts +++ b/src/admin/index.ts @@ -32,7 +32,11 @@ import { UsageLogSearchEndpoint, } from "./analytics_api" import { getSystemConfig, isTelegramSecurityEnabled } from "../system-config" -import { getAdminSessionHeaderName, validateAdminSession } from "./auth_shared" +import { + clearAdminSessionCookie, + getAdminSessionTokenFromRequest, + validateAdminSession, +} from "./auth_shared" const app = new Hono() export const api = fromHono(app) @@ -54,11 +58,15 @@ app.use('/api/admin/*', async (c, next) => { return; } - const sessionToken = c.req.header(getAdminSessionHeaderName()); + const sessionToken = getAdminSessionTokenFromRequest(c); - if (await validateAdminSession(c, sessionToken)) { - await next(); - return; + if (sessionToken) { + if (await validateAdminSession(c, sessionToken)) { + await next(); + return; + } + + clearAdminSessionCookie(c); } const systemConfig = await getSystemConfig(c); diff --git a/src/providers/azure-openai-proxy.ts b/src/providers/azure-openai-proxy.ts index de8ca74..555f669 100644 --- a/src/providers/azure-openai-proxy.ts +++ b/src/providers/azure-openai-proxy.ts @@ -1,6 +1,10 @@ import { Context } from "hono" import { checkoutUsageData, handleStreamResponse } from "./shared/openai-stream-utils" import { buildAzureTargetUrl } from "./shared/azure-target-url" +import { + buildUpstreamRequestHeaders, + OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST, +} from "./shared/upstream-request-headers" const buildProxyRequest = ( request: Request, @@ -10,11 +14,12 @@ const buildProxyRequest = ( const targetUrl = buildAzureTargetUrl(request, config.endpoint) const apiKey = config.api_key || "" - const targetHeaders = new Headers(request.headers) - targetHeaders.delete("Authorization") - targetHeaders.delete("Host") - targetHeaders.delete("Cookie") - targetHeaders.set("api-key", apiKey) + const targetHeaders = buildUpstreamRequestHeaders(request, { + allowHeaders: OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST, + overrideHeaders: { + "api-key": apiKey, + }, + }) return new Request(targetUrl, { method: request.method, diff --git a/src/providers/azure-openai-responses-proxy.ts b/src/providers/azure-openai-responses-proxy.ts index 4d83845..ba07f77 100644 --- a/src/providers/azure-openai-responses-proxy.ts +++ b/src/providers/azure-openai-responses-proxy.ts @@ -4,6 +4,10 @@ import { checkoutResponsesUsageData, } from "./shared/responses-stream-utils" import { buildAzureTargetUrl } from "./shared/azure-target-url" +import { + buildUpstreamRequestHeaders, + OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST, +} from "./shared/upstream-request-headers" const buildProxyRequest = ( request: Request, @@ -13,11 +17,12 @@ const buildProxyRequest = ( const targetUrl = buildAzureTargetUrl(request, config.endpoint) const apiKey = config.api_key || "" - const targetHeaders = new Headers(request.headers) - targetHeaders.delete("Authorization") - targetHeaders.delete("Host") - targetHeaders.delete("Cookie") - targetHeaders.set("api-key", apiKey) + const targetHeaders = buildUpstreamRequestHeaders(request, { + allowHeaders: OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST, + overrideHeaders: { + "api-key": apiKey, + }, + }) return new Request(targetUrl, { method: request.method, diff --git a/src/providers/claude-proxy.ts b/src/providers/claude-proxy.ts index a916f08..8dee224 100644 --- a/src/providers/claude-proxy.ts +++ b/src/providers/claude-proxy.ts @@ -1,6 +1,10 @@ import { Context } from "hono" import { DEFAULT_CLAUDE_API_VERSION } from "../channel-config" import { buildPrefixedTargetUrl } from "./shared/prefixed-target-url" +import { + buildUpstreamRequestHeaders, + CLAUDE_UPSTREAM_HEADER_ALLOWLIST, +} from "./shared/upstream-request-headers" /** * Claude API Proxy Provider @@ -44,11 +48,7 @@ type ClaudeStreamEvent = { } /** - * Builds the proxy request for Claude API - * - Removes Authorization header (client may send OpenAI format) - * - Sets x-api-key header (Claude uses this instead of Authorization) - * - Sets anthropic-version header (required by Claude API) - * - Preserves all other headers from original request + * Builds the proxy request for Claude API with an allowlisted header set. */ const buildProxyRequest = ( request: Request, @@ -59,16 +59,13 @@ const buildProxyRequest = ( const targetUrl = buildPrefixedTargetUrl(config.endpoint, url.pathname) const apiKey = config.api_key || "" - const targetHeaders = new Headers(request.headers) - - // Remove Authorization header (client may send OpenAI format: "Authorization: Bearer sk-xxx") - targetHeaders.delete("Authorization") - targetHeaders.delete("x-api-key") - - // Claude uses x-api-key header instead of Authorization - targetHeaders.set("x-api-key", apiKey) - // Claude requires anthropic-version header - targetHeaders.set("anthropic-version", DEFAULT_CLAUDE_API_VERSION) + const targetHeaders = buildUpstreamRequestHeaders(request, { + allowHeaders: CLAUDE_UPSTREAM_HEADER_ALLOWLIST, + overrideHeaders: { + "anthropic-version": DEFAULT_CLAUDE_API_VERSION, + "x-api-key": apiKey, + }, + }) return new Request(targetUrl, { method: request.method, diff --git a/src/providers/claude-to-openai-proxy.ts b/src/providers/claude-to-openai-proxy.ts index 7b358c4..13ff58d 100644 --- a/src/providers/claude-to-openai-proxy.ts +++ b/src/providers/claude-to-openai-proxy.ts @@ -1,6 +1,10 @@ import { Context } from "hono" import { normalizeUsage as normalizeResponsesUsage } from "./shared/usage-utils" import { buildPrefixedTargetUrl } from "./shared/prefixed-target-url" +import { + buildUpstreamRequestHeaders, + OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST, +} from "./shared/upstream-request-headers" type OpenAIStreamChoice = { index?: number; @@ -80,12 +84,13 @@ const buildProxyRequest = ( config: ChannelConfig ): Request => { const targetUrl = buildPrefixedTargetUrl(config.endpoint, "/v1/chat/completions"); - const targetHeaders = new Headers(request.headers); const apiKey = config.api_key || ""; - - targetHeaders.delete("Authorization"); - targetHeaders.delete("x-api-key"); - targetHeaders.set("Authorization", `Bearer ${apiKey}`); + const targetHeaders = buildUpstreamRequestHeaders(request, { + allowHeaders: OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST, + overrideHeaders: { + Authorization: `Bearer ${apiKey}`, + }, + }); return new Request(targetUrl, { method: request.method, diff --git a/src/providers/openai-proxy.ts b/src/providers/openai-proxy.ts index 807a995..ffcc41d 100644 --- a/src/providers/openai-proxy.ts +++ b/src/providers/openai-proxy.ts @@ -1,6 +1,10 @@ import { Context } from "hono" import { checkoutUsageData, handleStreamResponse } from "./shared/openai-stream-utils" import { buildPrefixedTargetUrl } from "./shared/prefixed-target-url" +import { + buildUpstreamRequestHeaders, + OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST, +} from "./shared/upstream-request-headers" const buildProxyRequest = ( request: Request, @@ -11,10 +15,12 @@ const buildProxyRequest = ( const targetUrl = buildPrefixedTargetUrl(config.endpoint, url.pathname, "/v1", config.type) const apiKey = config.api_key || "" - const targetHeaders = new Headers(request.headers) - targetHeaders.delete("Host") - targetHeaders.delete("Cookie") - targetHeaders.set("Authorization", `Bearer ${apiKey}`) + const targetHeaders = buildUpstreamRequestHeaders(request, { + allowHeaders: OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST, + overrideHeaders: { + Authorization: `Bearer ${apiKey}`, + }, + }) return new Request(targetUrl, { method: request.method, diff --git a/src/providers/openai-responses-proxy.ts b/src/providers/openai-responses-proxy.ts index a601b5a..1f0f258 100644 --- a/src/providers/openai-responses-proxy.ts +++ b/src/providers/openai-responses-proxy.ts @@ -4,6 +4,10 @@ import { checkoutResponsesUsageData, } from "./shared/responses-stream-utils" import { buildPrefixedTargetUrl } from "./shared/prefixed-target-url" +import { + buildUpstreamRequestHeaders, + OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST, +} from "./shared/upstream-request-headers" const buildProxyRequest = ( request: Request, @@ -14,10 +18,12 @@ const buildProxyRequest = ( const targetUrl = buildPrefixedTargetUrl(config.endpoint, url.pathname) const apiKey = config.api_key || "" - const targetHeaders = new Headers(request.headers) - targetHeaders.delete("Host") - targetHeaders.delete("Cookie") - targetHeaders.set("Authorization", `Bearer ${apiKey}`) + const targetHeaders = buildUpstreamRequestHeaders(request, { + allowHeaders: OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST, + overrideHeaders: { + Authorization: `Bearer ${apiKey}`, + }, + }) return new Request(targetUrl, { method: request.method, diff --git a/src/providers/shared/upstream-request-headers.ts b/src/providers/shared/upstream-request-headers.ts new file mode 100644 index 0000000..af12a64 --- /dev/null +++ b/src/providers/shared/upstream-request-headers.ts @@ -0,0 +1,86 @@ +const BLOCKED_HEADER_NAMES = new Set([ + "api-key", + "authorization", + "content-length", + "cookie", + "host", + "true-client-ip", + "x-admin-session", + "x-admin-token", + "x-api-key", + "x-real-ip", +]); + +const BLOCKED_HEADER_PREFIXES = [ + "cf-", + "x-forwarded-", +]; + +const normalizeHeaderName = (value: string): string => { + return value.trim().toLowerCase(); +}; + +const shouldForwardHeader = ( + name: string, + allowlist: Set +): boolean => { + if (!allowlist.has(name)) { + return false; + } + + if (BLOCKED_HEADER_NAMES.has(name)) { + return false; + } + + return !BLOCKED_HEADER_PREFIXES.some((prefix) => name.startsWith(prefix)); +}; + +type BuildUpstreamHeadersOptions = { + allowHeaders: string[]; + overrideHeaders?: Record; +}; + +export const OPENAI_COMPAT_UPSTREAM_HEADER_ALLOWLIST = [ + "accept", + "accept-encoding", + "content-type", + "idempotency-key", + "openai-beta", +]; + +export const CLAUDE_UPSTREAM_HEADER_ALLOWLIST = [ + "accept", + "accept-encoding", + "anthropic-beta", + "content-type", + "idempotency-key", +]; + +export const buildUpstreamRequestHeaders = ( + request: Request, + options: BuildUpstreamHeadersOptions +): Headers => { + const allowlist = new Set(options.allowHeaders.map(normalizeHeaderName)); + const headers = new Headers(); + + for (const [name, value] of request.headers.entries()) { + const normalizedName = normalizeHeaderName(name); + + if (!value || !shouldForwardHeader(normalizedName, allowlist)) { + continue; + } + + headers.set(normalizedName, value); + } + + for (const [name, value] of Object.entries(options.overrideHeaders || {})) { + const normalizedName = normalizeHeaderName(name); + headers.delete(normalizedName); + + if (typeof value === "string" && value.length > 0) { + headers.set(normalizedName, value); + } + } + + return headers; +}; diff --git a/tests/mock-upstream.ts b/tests/mock-upstream.ts index 323a885..1ed48e5 100644 --- a/tests/mock-upstream.ts +++ b/tests/mock-upstream.ts @@ -5,6 +5,56 @@ */ const PORT = 9999 +const INSPECTABLE_REQUEST_KEYS = ["chat-completions", "messages", "models", "responses"] as const + +type InspectableRequestKey = typeof INSPECTABLE_REQUEST_KEYS[number] + +type CapturedRequest = { + body: unknown + headers: Record + method: string + path: string +} + +const capturedRequests = new Map() + +const toHeaderRecord = (headers: Headers): Record => { + return Object.fromEntries(Array.from(headers.entries()).map(([key, value]) => [key.toLowerCase(), value])) +} + +const getCaptureKey = (path: string): InspectableRequestKey | null => { + if (path.endsWith("/chat/completions")) { + return "chat-completions" + } + + if (path.endsWith("/messages")) { + return "messages" + } + + if (path.endsWith("/responses")) { + return "responses" + } + + if (path.endsWith("/models")) { + return "models" + } + + return null +} + +const rememberRequest = (req: Request, path: string, body: unknown) => { + const captureKey = getCaptureKey(path) + if (!captureKey) { + return + } + + capturedRequests.set(captureKey, { + body, + headers: toHeaderRecord(req.headers), + method: req.method, + path, + }) +} // --- OpenAI Chat Completions (non-stream) --- const openaiChatResponse = (model: string) => ({ @@ -87,11 +137,29 @@ const server = Bun.serve({ async fetch(req) { const url = new URL(req.url) const path = url.pathname + const inspectKey = url.searchParams.get("key") + + if (path === "/__reset" && req.method === "POST") { + capturedRequests.clear() + return Response.json({ ok: true }) + } + + if (path === "/__inspect" && req.method === "GET") { + if (!inspectKey || !INSPECTABLE_REQUEST_KEYS.includes(inspectKey as InspectableRequestKey)) { + return Response.json({ + keys: INSPECTABLE_REQUEST_KEYS, + }, { status: 400 }) + } + + return Response.json(capturedRequests.get(inspectKey as InspectableRequestKey) || null) + } + const body = req.method === "POST" ? await req.json().catch(() => ({})) : {} const model = (body as any).model || "mock-model" const isStream = (body as any).stream === true console.log(`[MOCK] ${req.method} ${path} model=${model} stream=${isStream}`) + rememberRequest(req, path, body) // OpenAI Chat Completions if (path.endsWith("/chat/completions")) { diff --git a/tests/run-e2e.sh b/tests/run-e2e.sh index fec784b..be8fafc 100755 --- a/tests/run-e2e.sh +++ b/tests/run-e2e.sh @@ -2,7 +2,11 @@ # E2E tests against mock upstream # Prerequisites: mock-upstream running on :9999, wrangler dev on :8788 -API="http://localhost:8788" +API="${API:-http://localhost:8787}" +MOCK_API="${MOCK_API:-http://localhost:9999}" +WRANGLER_CONFIG="${WRANGLER_CONFIG:-wrangler.local.jsonc}" +DB_NAME="${DB_NAME:-one-api}" +DB_EXEC=(npx wrangler d1 execute "$DB_NAME" --local --config "$WRANGLER_CONFIG" --command) PASS=0 FAIL=0 TOTAL=0 @@ -21,6 +25,20 @@ assert() { fi } +assert_not_contains() { + local name="$1" unexpected="$2" actual="$3" + TOTAL=$((TOTAL + 1)) + if echo "$actual" | grep -q "$unexpected"; then + echo " ❌ #$TOTAL $name" + echo " unexpected: $unexpected" + echo " actual: $(echo "$actual" | head -1)" + FAIL=$((FAIL + 1)) + else + echo " ✅ #$TOTAL $name" + PASS=$((PASS + 1)) + fi +} + assert_http() { local name="$1" expected_code="$2" expected_body="$3" actual="$4" local code=$(echo "$actual" | tail -1) @@ -39,6 +57,15 @@ assert_http() { do_get() { curl -s -w "\n%{http_code}" "$@"; } do_post() { curl -s -w "\n%{http_code}" -X POST "$@"; } +reset_mock_inspection() { curl -s -X POST "$MOCK_API/__reset" > /dev/null; } +get_mock_inspection() { curl -sG "$MOCK_API/__inspect" --data-urlencode "key=$1"; } + +if [[ -f ".dev.vars" ]]; then + set -a + # shellcheck disable=SC1091 + source ".dev.vars" + set +a +fi echo "" echo "=========================================" @@ -78,10 +105,10 @@ R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/chat/completions" -H assert_http "no model → 400" "400" "Model is required" "$R" R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"nonexistent","messages":[]}') -assert_http "unmapped model → 400" "400" "Model not mapped" "$R" +assert_http "unmapped model → 400" "400" "Model not supported" "$R" R=$(do_post -H "Authorization: Bearer sk-mock-openai" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"claude-3-opus","messages":[]}') -assert_http "limited token, wrong model → 400" "400" "Model not mapped" "$R" +assert_http "limited token, wrong model → 400" "400" "Model not supported" "$R" # ---- /v1/chat/completions via openai provider (non-stream) ---- echo "" @@ -97,6 +124,22 @@ echo "--- POST /v1/chat/completions (openai, stream) ---" R=$(do_post -H "Authorization: Bearer sk-mock-openai" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"gpt-4o","stream":true,"messages":[{"role":"user","content":"hi"}]}') assert_http "openai stream → 200 + SSE" "200" "data:" "$R" +reset_mock_inspection +R=$(do_post \ + -H "Authorization: Bearer sk-mock-openai" \ + -H "x-api-key: leaked-client-key" \ + -H "Cookie: session=leak-me" \ + -H "X-Forwarded-For: 203.0.113.10" \ + "$API/v1/chat/completions" \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-4o","messages":[{"role":"user","content":"header check"}]}') +assert_http "openai header whitelist → 200" "200" "Mock response" "$R" +INSPECT=$(get_mock_inspection "chat-completions") +assert "openai upstream auth replaced with channel key" "\"authorization\":\"Bearer mock-key\"" "$INSPECT" +assert_not_contains "openai upstream strips client x-api-key" "leaked-client-key" "$INSPECT" +assert_not_contains "openai upstream strips cookie" "\"cookie\":" "$INSPECT" +assert_not_contains "openai upstream strips x-forwarded-for" "\"x-forwarded-for\":" "$INSPECT" + # ---- /v1/chat/completions via azure-openai (non-stream) ---- echo "" echo "--- POST /v1/chat/completions (azure-openai, non-stream) ---" @@ -125,6 +168,21 @@ echo "--- POST /v1/messages (claude, stream) ---" R=$(do_post -H "x-api-key: sk-mock-all" "$API/v1/messages" -H "Content-Type: application/json" -d '{"model":"claude-3-opus","stream":true,"max_tokens":100,"messages":[{"role":"user","content":"hi"}]}') assert_http "claude stream → 200 + SSE" "200" "event:" "$R" +reset_mock_inspection +R=$(do_post \ + -H "x-api-key: sk-mock-all" \ + -H "Cookie: session=leak-me" \ + -H "X-Forwarded-For: 203.0.113.11" \ + "$API/v1/messages" \ + -H "Content-Type: application/json" \ + -d '{"model":"claude-3-opus","max_tokens":100,"messages":[{"role":"user","content":"header check"}]}') +assert_http "claude header whitelist → 200" "200" "Mock Claude response" "$R" +INSPECT=$(get_mock_inspection "messages") +assert "claude upstream x-api-key replaced with channel key" "\"x-api-key\":\"mock-key\"" "$INSPECT" +assert_not_contains "claude upstream strips client token value" "sk-mock-all" "$INSPECT" +assert_not_contains "claude upstream strips cookie" "\"cookie\":" "$INSPECT" +assert_not_contains "claude upstream strips x-forwarded-for" "\"x-forwarded-for\":" "$INSPECT" + # ---- /v1/messages via claude-to-openai (non-stream) ---- echo "" echo "--- POST /v1/messages (claude-to-openai, non-stream) ---" @@ -140,7 +198,7 @@ R=$(do_post "$API/v1/responses" -H "Content-Type: application/json" -d '{"model" assert_http "no auth → 401" "401" "Authorization header" "$R" R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/responses" -H "Content-Type: application/json" -d '{"model":"gpt-4o","input":"hi"}') -assert_http "non-responses model → 400" "400" "Model not mapped" "$R" +assert_http "non-responses model → 400" "400" "Model not supported" "$R" # ---- /v1/responses via openai-responses (non-stream) ---- echo "" @@ -174,10 +232,34 @@ assert_http "azure responses stream → 200 + SSE" "200" "event:" "$R" echo "" echo "--- Quota check ---" -npx wrangler d1 execute one-api-workers --local --command "UPDATE api_token SET usage = 99999999 WHERE key = 'sk-mock-all'" > /dev/null 2>&1 +"${DB_EXEC[@]}" "UPDATE api_token SET usage = 99999999 WHERE key = 'sk-mock-all'" > /dev/null 2>&1 R=$(do_post -H "Authorization: Bearer sk-mock-all" "$API/v1/chat/completions" -H "Content-Type: application/json" -d '{"model":"gpt-4o","messages":[]}') assert_http "quota exceeded → 402" "402" "Quota exceeded" "$R" -npx wrangler d1 execute one-api-workers --local --command "UPDATE api_token SET usage = 0 WHERE key = 'sk-mock-all'" > /dev/null 2>&1 +"${DB_EXEC[@]}" "UPDATE api_token SET usage = 0 WHERE key = 'sk-mock-all'" > /dev/null 2>&1 + +# ---- Admin cookie session ---- +echo "" +echo "--- Admin session cookie ---" + +"${DB_EXEC[@]}" "INSERT OR REPLACE INTO settings (key, value) VALUES ('SYSTEM_CONFIG', '{\"displayDecimals\":6,\"adminSecurity\":{\"enabled\":false,\"telegramBotToken\":\"\",\"telegramChatId\":\"\",\"verifiedFingerprint\":\"\",\"verifiedAt\":null},\"apiDocs\":{\"enabled\":true}}')" > /dev/null 2>&1 +COOKIE_JAR=$(mktemp) +LOGIN_PAYLOAD=$(printf '{"token":"%s"}' "$ADMIN_TOKEN") + +R=$(do_post "$API/api/admin/auth/login" -H "Content-Type: application/json" -c "$COOKIE_JAR" -d "$LOGIN_PAYLOAD") +assert_http "admin login → 200" "200" "\"requiresVerification\":false" "$R" +assert "admin login response keeps session token out of body" "\"sessionToken\":null" "$R" +assert "admin login sets session cookie" "oaw_admin_session" "$(cat "$COOKIE_JAR")" + +R=$(do_get -b "$COOKIE_JAR" "$API/api/admin/channel") +assert_http "admin cookie grants access" "200" "\"success\":true" "$R" + +R=$(do_post -b "$COOKIE_JAR" -c "$COOKIE_JAR" "$API/api/admin/auth/logout") +assert_http "admin logout → 200" "200" "\"success\":true" "$R" + +R=$(do_get -b "$COOKIE_JAR" "$API/api/admin/channel") +assert_http "admin cookie invalid after logout" "401" "Unauthorized" "$R" + +rm -f "$COOKIE_JAR" # ---- Error recovery ---- echo "" @@ -197,10 +279,10 @@ echo "=========================================" echo "" echo "Cleaning up test data..." for key in test-openai test-azure test-claude test-c2o test-responses test-azure-resp; do - npx wrangler d1 execute one-api-workers --local --command "DELETE FROM channel_config WHERE key = '$key'" > /dev/null 2>&1 + "${DB_EXEC[@]}" "DELETE FROM channel_config WHERE key = '$key'" > /dev/null 2>&1 done for key in sk-mock-all sk-mock-openai sk-mock-quota; do - npx wrangler d1 execute one-api-workers --local --command "DELETE FROM api_token WHERE key = '$key'" > /dev/null 2>&1 + "${DB_EXEC[@]}" "DELETE FROM api_token WHERE key = '$key'" > /dev/null 2>&1 done echo "Cleanup done." diff --git a/tests/setup-test-data.sh b/tests/setup-test-data.sh index e99cf31..1a5b790 100755 --- a/tests/setup-test-data.sh +++ b/tests/setup-test-data.sh @@ -3,34 +3,41 @@ # All channels point to http://localhost:9999 MOCK="http://localhost:9999" +API_URL="${API_URL:-http://localhost:8787}" +WRANGLER_CONFIG="${WRANGLER_CONFIG:-wrangler.local.jsonc}" +DB_NAME="${DB_NAME:-one-api}" +DB_EXEC=(npx wrangler d1 execute "$DB_NAME" --local --config "$WRANGLER_CONFIG" --command) echo "Setting up test channels and tokens..." +# Warm up the worker so schema migrations run against the same local D1 database. +curl -s "$API_URL/api/admin/channel" > /dev/null 2>&1 || true + # Channel: openai type -npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-openai', '{\"name\":\"test-openai\",\"type\":\"openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"gpt-4o\":\"gpt-4o\",\"gpt-4\":\"gpt-4\"}}')" +"${DB_EXEC[@]}" "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-openai', '{\"name\":\"test-openai\",\"type\":\"openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"gpt-4o\":\"gpt-4o\",\"gpt-4\":\"gpt-4\"}}')" # Channel: azure-openai type -npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-azure', '{\"name\":\"test-azure\",\"type\":\"azure-openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"api_version\":\"2024-02-01\",\"deployment_mapper\":{\"gpt-4o-azure\":\"gpt-4o-deployment\"}}')" +"${DB_EXEC[@]}" "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-azure', '{\"name\":\"test-azure\",\"type\":\"azure-openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"api_version\":\"2024-02-01\",\"deployment_mapper\":{\"gpt-4o-azure\":\"gpt-4o-deployment\"}}')" # Channel: claude type -npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-claude', '{\"name\":\"test-claude\",\"type\":\"claude\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"claude-3-opus\":\"claude-3-opus-20240229\"}}')" +"${DB_EXEC[@]}" "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-claude', '{\"name\":\"test-claude\",\"type\":\"claude\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"claude-3-opus\":\"claude-3-opus-20240229\"}}')" # Channel: claude-to-openai type -npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-c2o', '{\"name\":\"test-c2o\",\"type\":\"claude-to-openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"claude-via-openai\":\"claude-3-opus\"}}')" +"${DB_EXEC[@]}" "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-c2o', '{\"name\":\"test-c2o\",\"type\":\"claude-to-openai\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"claude-via-openai\":\"claude-3-opus\"}}')" # Channel: openai-responses type -npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-responses', '{\"name\":\"test-responses\",\"type\":\"openai-responses\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"gpt-4o-resp\":\"gpt-4o\"}}')" +"${DB_EXEC[@]}" "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-responses', '{\"name\":\"test-responses\",\"type\":\"openai-responses\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"deployment_mapper\":{\"gpt-4o-resp\":\"gpt-4o\"}}')" # Channel: azure-openai-responses type -npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-azure-resp', '{\"name\":\"test-azure-resp\",\"type\":\"azure-openai-responses\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"api_version\":\"2025-01-01\",\"deployment_mapper\":{\"gpt-4o-azure-resp\":\"gpt-4o-deployment\"}}')" +"${DB_EXEC[@]}" "INSERT OR REPLACE INTO channel_config (key, value) VALUES ('test-azure-resp', '{\"name\":\"test-azure-resp\",\"type\":\"azure-openai-responses\",\"endpoint\":\"$MOCK\",\"api_key\":\"mock-key\",\"api_version\":\"2025-01-01\",\"deployment_mapper\":{\"gpt-4o-azure-resp\":\"gpt-4o-deployment\"}}')" # Token: access to all test channels only -npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-all', '{\"name\":\"mock-all\",\"channel_keys\":[\"test-openai\",\"test-azure\",\"test-claude\",\"test-c2o\",\"test-responses\",\"test-azure-resp\"],\"total_quota\":99999999}', 0)" +"${DB_EXEC[@]}" "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-all', '{\"name\":\"mock-all\",\"channel_keys\":[\"test-openai\",\"test-azure\",\"test-claude\",\"test-c2o\",\"test-responses\",\"test-azure-resp\"],\"total_quota\":99999999}', 0)" # Token: limited to openai only -npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-openai', '{\"name\":\"mock-openai\",\"channel_keys\":[\"test-openai\"],\"total_quota\":99999999}', 0)" +"${DB_EXEC[@]}" "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-openai', '{\"name\":\"mock-openai\",\"channel_keys\":[\"test-openai\"],\"total_quota\":99999999}', 0)" # Token: quota almost full -npx wrangler d1 execute one-api-workers --local --command "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-quota', '{\"name\":\"mock-quota\",\"channel_keys\":[\"test-openai\"],\"total_quota\":100}', 99)" +"${DB_EXEC[@]}" "INSERT OR REPLACE INTO api_token (key, value, usage) VALUES ('sk-mock-quota', '{\"name\":\"mock-quota\",\"channel_keys\":[\"test-openai\"],\"total_quota\":100}', 99)" echo "Test data setup complete!"