From c0a02279bd44e75213d546f208b469e1fc0ccd3d Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Thu, 11 Jun 2026 00:56:18 +0700 Subject: [PATCH] feat: add API key authentication for AI proxy endpoints - Add API_KEY config (env var) to all entry points - requireAuth helper checks Authorization: Bearer or x-api-key header - Auth applied to /v1/chat/completions, /v1/messages, /v1/models - When API_KEY is empty/unset, auth is disabled (backward compatible) - Update wrangler.toml with API_KEY variable documentation Co-Authored-By: Claude Fable 5 --- api/relay.ts | 21 +++++++++++++++++++++ src/index.ts | 26 ++++++++++++++++++++++++++ src/worker.ts | 21 +++++++++++++++++++++ wrangler.toml | 1 + 4 files changed, 69 insertions(+) diff --git a/api/relay.ts b/api/relay.ts index 514ca9e..82c35ea 100644 --- a/api/relay.ts +++ b/api/relay.ts @@ -39,6 +39,21 @@ const RELAY_TIMEOUT_MS = Number.parseInt( const SERVER_START_TIME = Date.now(); const RELAY_VERSION = "1.0.0"; +// ─── API Key Authentication ───────────────────────────────────────────────────── + +const API_KEY = process.env.API_KEY ?? ""; + +function requireAuth(req: Request): Response | null { + if (!API_KEY) return null; + const header = req.headers.get("authorization") ?? req.headers.get("x-api-key") ?? ""; + const key = header.replace(/^Bearer\s+/i, "").trim(); + if (key === API_KEY) return null; + return new Response( + JSON.stringify({ error: { message: "Unauthorized", type: "auth_error" } }), + { status: 401, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }, + ); +} + // ─── Middleware instances (singletons — persist across warm invocations) ───────── const rateLimiter = createRateLimiter({ @@ -400,6 +415,8 @@ export default { if (url.pathname === "/v1/chat/completions") { if (req.method === "OPTIONS") return createCorsPreflightResponse(); if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 }); + const authErr = requireAuth(req); + if (authErr) return authErr; try { const body = await req.json(); return handleChatCompletion(body); @@ -415,6 +432,8 @@ export default { if (url.pathname === "/v1/messages") { if (req.method === "OPTIONS") return createCorsPreflightResponse(); if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 }); + const authErr = requireAuth(req); + if (authErr) return authErr; try { const body = await req.json(); return handleAnthropicMessages(body); @@ -428,6 +447,8 @@ export default { // Models list if (url.pathname === "/v1/models" && req.method === "GET") { + const authErr = requireAuth(req); + if (authErr) return authErr; const models = listModels().map((id) => ({ id, object: "model", diff --git a/src/index.ts b/src/index.ts index a394b0e..281ca62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,26 @@ const RELAY_TIMEOUT_MS = Number.parseInt( const SERVER_START_TIME = Date.now(); const RELAY_VERSION = "1.0.0"; +// ─── API Key Authentication ───────────────────────────────────────────────────── + +const API_KEY = process.env.API_KEY ?? ""; + +/** + * Check if a request is authorized. + * Returns a 401 Response if unauthorized, or null if allowed. + * When API_KEY is empty, all requests pass through. + */ +function requireAuth(req: Request): Response | null { + if (!API_KEY) return null; // auth disabled + const header = req.headers.get("authorization") ?? req.headers.get("x-api-key") ?? ""; + const key = header.replace(/^Bearer\s+/i, "").trim(); + if (key === API_KEY) return null; + return new Response( + JSON.stringify({ error: { message: "Unauthorized", type: "auth_error" } }), + { status: 401, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }, + ); +} + // ─── Middleware instances (singletons) ─────────────────────────────────────────── const rateLimiter = createRateLimiter({ @@ -483,6 +503,8 @@ const server: Server = Bun.serve({ if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405 }); } + const authErr = requireAuth(req); + if (authErr) return authErr; try { const body = await req.json(); return handleChatCompletion(body, proxyPool); @@ -502,6 +524,8 @@ const server: Server = Bun.serve({ if (req.method !== "POST") { return new Response("Method Not Allowed", { status: 405 }); } + const authErr = requireAuth(req); + if (authErr) return authErr; try { const body = await req.json(); return handleAnthropicMessages(body, proxyPool); @@ -517,6 +541,8 @@ const server: Server = Bun.serve({ } if (url.pathname === "/v1/models" && req.method === "GET") { + const authErr = requireAuth(req); + if (authErr) return authErr; return new Response( JSON.stringify({ object: "list", diff --git a/src/worker.ts b/src/worker.ts index ca9465d..432685d 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -40,6 +40,8 @@ export interface Env { RATE_LIMIT_WINDOW_MS?: string; /** Server listen port (unused on Workers, here for local dev compatibility) */ PORT?: string; + /** API key for AI proxy auth (empty = disabled) */ + API_KEY?: string; } // ─── Helpers ───────────────────────────────────────────────────────────────────── @@ -66,6 +68,19 @@ function getClientIP(req: Request): string { return "unknown"; } +// ─── Auth Helper ───────────────────────────────────────────────────────────────── + +function requireAuth(req: Request, apiKey: string | undefined): Response | null { + if (!apiKey) return null; // auth disabled + const header = req.headers.get("authorization") ?? req.headers.get("x-api-key") ?? ""; + const key = header.replace(/^Bearer\s+/i, "").trim(); + if (key === apiKey) return null; + return new Response( + JSON.stringify({ error: { message: "Unauthorized", type: "auth_error" } }), + { status: 401, headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }, + ); +} + // ─── Route Handlers ────────────────────────────────────────────────────────────── const SERVER_START_TIME = Date.now(); @@ -396,6 +411,8 @@ export default { if (url.pathname === "/v1/chat/completions") { if (req.method === "OPTIONS") return createCorsPreflightResponse(); if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 }); + const authErr = requireAuth(req, env.API_KEY); + if (authErr) return authErr; try { const body = await req.json(); return handleChatCompletion(body); @@ -411,6 +428,8 @@ export default { if (url.pathname === "/v1/messages") { if (req.method === "OPTIONS") return createCorsPreflightResponse(); if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 }); + const authErr = requireAuth(req, env.API_KEY); + if (authErr) return authErr; try { const body = await req.json(); return handleAnthropicMessages(body); @@ -424,6 +443,8 @@ export default { // Models list if (url.pathname === "/v1/models" && req.method === "GET") { + const authErr = requireAuth(req, env.API_KEY); + if (authErr) return authErr; const models = listModels().map((id) => ({ id, object: "model", diff --git a/wrangler.toml b/wrangler.toml index a825ccc..f2a60aa 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -12,3 +12,4 @@ workers_dev = true # RELAY_TIMEOUT_MS = "30000" # RATE_LIMIT_MAX = "200" # RATE_LIMIT_WINDOW_MS = "60000" +# API_KEY = "sk-your-secret-key"