diff --git a/README.md b/README.md index e5b39099..dcc1cc6e 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ A reverse-engineered proxy for the GitHub Copilot API that exposes it as an Open - **OpenAI & Anthropic Compatibility**: Exposes GitHub Copilot as an OpenAI-compatible (`/v1/chat/completions`, `/v1/models`, `/v1/embeddings`) and Anthropic-compatible (`/v1/messages`) API. - **Claude Code Integration**: Easily configure and launch [Claude Code](https://docs.anthropic.com/en/docs/claude-code/overview) to use Copilot as its backend with a simple command-line flag (`--claude-code`). +- **API Key Authentication**: Secure your server with configurable API keys for client authentication, enabling safe exposure to the open web (`--api-keys`). - **Usage Dashboard**: A web-based dashboard to monitor your Copilot API usage, view quotas, and see detailed statistics. - **Rate Limit Control**: Manage API usage with rate-limiting options (`--rate-limit`) and a waiting mechanism (`--wait`) to prevent errors from rapid requests. - **Manual Request Approval**: Manually approve or deny each API request for fine-grained control over usage (`--manual`). @@ -162,6 +163,7 @@ The following command line options are available for the `start` command: | --github-token | Provide GitHub token directly (must be generated using the `auth` subcommand) | none | -g | | --claude-code | Generate a command to launch Claude Code with Copilot API config | false | -c | | --show-token | Show GitHub and Copilot tokens on fetch and refresh | false | none | +| --api-keys | Comma-separated list of API keys for client authentication | none | -k | ### Auth Command Options @@ -251,6 +253,12 @@ npx copilot-api@latest debug # Display debug information in JSON format npx copilot-api@latest debug --json + +# Enable API key authentication for secure access +npx copilot-api@latest start --api-keys sk-key1,sk-key2,sk-key3 + +# Combine API keys with other options +npx copilot-api@latest start --api-keys mykey123 --rate-limit 30 --wait --port 8080 ``` ## Using the Usage Viewer @@ -309,6 +317,19 @@ Here is an example `.claude/settings.json` file: } ``` +If you've configured API keys using the `--api-keys` flag, you'll need to include one of your configured keys as the `ANTHROPIC_AUTH_TOKEN`: + +```json +{ + "env": { + "ANTHROPIC_BASE_URL": "http://localhost:4141", + "ANTHROPIC_AUTH_TOKEN": "sk-mykey123", + "ANTHROPIC_MODEL": "gpt-4.1", + "ANTHROPIC_SMALL_FAST_MODEL": "gpt-4.1" + } +} +``` + You can find more options here: [Claude Code settings](https://docs.anthropic.com/en/docs/claude-code/settings#environment-variables) You can also read more about IDE integration here: [Add Claude Code to your IDE](https://docs.anthropic.com/en/docs/claude-code/ide-integrations) @@ -331,6 +352,13 @@ bun run start ## Usage Tips +- **API Key Authentication**: Secure your API by enabling authentication with the `--api-keys` flag: + - `--api-keys key1,key2,key3`: Provide comma-separated API keys. Clients must include one of these keys in the `Authorization` header (as `Bearer ` or just ``). + - When API keys are configured, all API endpoints (`/v1/chat/completions`, `/v1/models`, `/v1/embeddings`, `/v1/messages`) require authentication. + - Public endpoints (`/`, `/usage`, `/token`) remain accessible without authentication. + - If no API keys are configured, the server operates in open mode (backward compatible). + - Example: `npx copilot-api@latest start --api-keys sk-mykey123,sk-anotherkey456` + - Clients can then authenticate using: `Authorization: Bearer sk-mykey123` - To avoid hitting GitHub Copilot's rate limits, you can use the following flags: - `--manual`: Enables manual approval for each request, giving you full control over when requests are sent. - `--rate-limit `: Enforces a minimum time interval between requests. For example, `copilot-api start --rate-limit 30` will ensure there's at least a 30-second gap between requests. diff --git a/src/lib/auth-middleware.ts b/src/lib/auth-middleware.ts new file mode 100644 index 00000000..8c88ea6b --- /dev/null +++ b/src/lib/auth-middleware.ts @@ -0,0 +1,45 @@ +import type { Context, Next } from "hono" + +import { state } from "~/lib/state" + +export async function authMiddleware(c: Context, next: Next) { + // If no API keys are configured, skip authentication + if (!state.apiKeys || state.apiKeys.length === 0) { + return next() + } + + // Get the Authorization header + const authHeader = c.req.header("authorization") + + if (!authHeader) { + return c.json( + { + error: { + message: "Missing authorization header", + type: "invalid_request_error", + }, + }, + 401, + ) + } + + // Support both "Bearer " and just "" formats + const token = + authHeader.startsWith("Bearer ") ? authHeader.slice(7) : authHeader + + // Check if the provided token matches any configured API key + if (!state.apiKeys.includes(token)) { + return c.json( + { + error: { + message: "Invalid API key", + type: "invalid_request_error", + }, + }, + 401, + ) + } + + // Authentication successful, continue to the next handler + return next() +} diff --git a/src/lib/state.ts b/src/lib/state.ts index 5ba4dc1d..c0d4bc16 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -15,6 +15,9 @@ export interface State { // Rate limiting configuration rateLimitSeconds?: number lastRequestTimestamp?: number + + // API key authentication + apiKeys?: Array } export const state: State = { @@ -22,4 +25,5 @@ export const state: State = { manualApprove: false, rateLimitWait: false, showToken: false, + apiKeys: undefined, } diff --git a/src/server.ts b/src/server.ts index 3cb2bb86..efa1203b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import { Hono } from "hono" import { cors } from "hono/cors" import { logger } from "hono/logger" +import { authMiddleware } from "./lib/auth-middleware" import { completionRoutes } from "./routes/chat-completions/route" import { embeddingRoutes } from "./routes/embeddings/route" import { messageRoutes } from "./routes/messages/route" @@ -16,17 +17,31 @@ server.use(cors()) server.get("/", (c) => c.text("Server running")) +// Public endpoints (no authentication required) +server.route("/usage", usageRoute) +server.route("/token", tokenRoute) + +// Protected endpoints (authentication required if API keys are configured) +server.use("/chat/completions/*", authMiddleware) server.route("/chat/completions", completionRoutes) + +server.use("/models/*", authMiddleware) server.route("/models", modelRoutes) + +server.use("/embeddings/*", authMiddleware) server.route("/embeddings", embeddingRoutes) -server.route("/usage", usageRoute) -server.route("/token", tokenRoute) // Compatibility with tools that expect v1/ prefix +server.use("/v1/chat/completions/*", authMiddleware) server.route("/v1/chat/completions", completionRoutes) + +server.use("/v1/models/*", authMiddleware) server.route("/v1/models", modelRoutes) + +server.use("/v1/embeddings/*", authMiddleware) server.route("/v1/embeddings", embeddingRoutes) // Anthropic compatible endpoints +server.use("/v1/messages/*", authMiddleware) server.route("/v1/messages", messageRoutes) server.post("/v1/messages/count_tokens", (c) => c.json({ input_tokens: 1 })) diff --git a/src/start.ts b/src/start.ts index a1b02303..7240efe2 100644 --- a/src/start.ts +++ b/src/start.ts @@ -23,6 +23,7 @@ interface RunServerOptions { githubToken?: string claudeCode: boolean showToken: boolean + apiKeys?: string } export async function runServer(options: RunServerOptions): Promise { @@ -41,10 +42,24 @@ export async function runServer(options: RunServerOptions): Promise { state.rateLimitWait = options.rateLimitWait state.showToken = options.showToken + // Parse and set API keys if provided + if (options.apiKeys) { + state.apiKeys = options.apiKeys + .split(",") + .map((key) => key.trim()) + .filter((key) => key.length > 0) + if (state.apiKeys.length > 0) { + consola.info( + `API key authentication enabled with ${state.apiKeys.length} key(s)`, + ) + } + } + await ensurePaths() await cacheVSCodeVersion() if (options.githubToken) { + // eslint-disable-next-line require-atomic-updates state.githubToken = options.githubToken consola.info("Using provided GitHub token") } else { @@ -169,6 +184,12 @@ export const start = defineCommand({ default: false, description: "Show GitHub and Copilot tokens on fetch and refresh", }, + "api-keys": { + alias: "k", + type: "string", + description: + "Comma-separated list of API keys for client authentication (e.g., 'key1,key2,key3'). If provided, clients must send one of these keys in the Authorization header", + }, }, run({ args }) { const rateLimitRaw = args["rate-limit"] @@ -186,6 +207,7 @@ export const start = defineCommand({ githubToken: args["github-token"], claudeCode: args["claude-code"], showToken: args["show-token"], + apiKeys: args["api-keys"], }) }, }) diff --git a/tests/api-key-integration.test.ts b/tests/api-key-integration.test.ts new file mode 100644 index 00000000..63dce4e1 --- /dev/null +++ b/tests/api-key-integration.test.ts @@ -0,0 +1,190 @@ +import { expect, test, describe, beforeAll, afterAll } from "bun:test" + +import { state } from "../src/lib/state" +import { server } from "../src/server" + +describe("API Key Authentication Integration", () => { + beforeAll(() => { + // Reset state before tests + state.apiKeys = undefined + state.models = { + data: [ + { + id: "gpt-4o-2024-05-13", + name: "GPT-4o", + vendor: "openai", + capabilities: {}, + }, + ], + } + }) + + afterAll(() => { + // Clean up after tests + state.apiKeys = undefined + }) + + test("should allow access to / without authentication", async () => { + state.apiKeys = ["test-key-123"] + + const res = await server.request("/") + expect(res.status).toBe(200) + }) + + test("should allow access to /usage without authentication", async () => { + state.apiKeys = ["test-key-123"] + + const res = await server.request("/usage") + // Note: will fail with actual error, but shouldn't be 401 + expect(res.status).not.toBe(401) + }) + + test("should allow access to /token without authentication", async () => { + state.apiKeys = ["test-key-123"] + + const res = await server.request("/token") + expect(res.status).toBe(200) + }) + + test("should reject /v1/models without authentication when keys are set", async () => { + state.apiKeys = ["test-key-123"] + + const res = await server.request("/v1/models") + expect(res.status).toBe(401) + const body = (await res.json()) as { + error: { message: string; type: string } + } + expect(body.error.message).toBe("Missing authorization header") + }) + + test("should allow /v1/models with valid authentication", async () => { + state.apiKeys = ["test-key-123"] + + const res = await server.request("/v1/models", { + headers: { + authorization: "Bearer test-key-123", + }, + }) + expect(res.status).toBe(200) + const body = (await res.json()) as { object: string; data: Array } + expect(body.object).toBe("list") + expect(Array.isArray(body.data)).toBe(true) + }) + + test("should reject /v1/models with invalid authentication", async () => { + state.apiKeys = ["test-key-123"] + + const res = await server.request("/v1/models", { + headers: { + authorization: "Bearer wrong-key", + }, + }) + expect(res.status).toBe(401) + const body = (await res.json()) as { + error: { message: string; type: string } + } + expect(body.error.message).toBe("Invalid API key") + }) + + test("should allow /v1/models without authentication when no keys are set", async () => { + state.apiKeys = undefined + + const res = await server.request("/v1/models") + expect(res.status).toBe(200) + }) + + test("should reject /v1/chat/completions without authentication when keys are set", async () => { + state.apiKeys = ["test-key-123"] + + const res = await server.request("/v1/chat/completions", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4o-2024-05-13", + messages: [{ role: "user", content: "Hello" }], + }), + }) + expect(res.status).toBe(401) + }) + + test("should reject /v1/embeddings without authentication when keys are set", async () => { + state.apiKeys = ["test-key-123"] + + const res = await server.request("/v1/embeddings", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "text-embedding-ada-002", + input: "test", + }), + }) + expect(res.status).toBe(401) + }) + + test("should reject /v1/messages without authentication when keys are set", async () => { + state.apiKeys = ["test-key-123"] + + const res = await server.request("/v1/messages", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "claude-3-sonnet", + messages: [{ role: "user", content: "Hello" }], + }), + }) + expect(res.status).toBe(401) + }) + + test("should work with multiple API keys", async () => { + state.apiKeys = ["key1", "key2", "key3"] + + // Test first key + let res = await server.request("/v1/models", { + headers: { + authorization: "Bearer key1", + }, + }) + expect(res.status).toBe(200) + + // Test second key + res = await server.request("/v1/models", { + headers: { + authorization: "Bearer key2", + }, + }) + expect(res.status).toBe(200) + + // Test third key + res = await server.request("/v1/models", { + headers: { + authorization: "Bearer key3", + }, + }) + expect(res.status).toBe(200) + + // Test invalid key + res = await server.request("/v1/models", { + headers: { + authorization: "Bearer invalid-key", + }, + }) + expect(res.status).toBe(401) + }) + + test("should work without Bearer prefix", async () => { + state.apiKeys = ["test-key-123"] + + const res = await server.request("/v1/models", { + headers: { + authorization: "test-key-123", + }, + }) + expect(res.status).toBe(200) + }) +}) diff --git a/tests/auth-middleware.test.ts b/tests/auth-middleware.test.ts new file mode 100644 index 00000000..1545a2c3 --- /dev/null +++ b/tests/auth-middleware.test.ts @@ -0,0 +1,174 @@ +import { expect, test, describe, beforeEach } from "bun:test" +import { Hono } from "hono" + +import { authMiddleware } from "../src/lib/auth-middleware" +import { state } from "../src/lib/state" + +describe("API Key Authentication Middleware", () => { + let app: Hono + + beforeEach(() => { + // Reset state before each test + state.apiKeys = undefined + + // Create a fresh Hono app with the middleware and a test route + app = new Hono() + app.use("/*", authMiddleware) + app.get("/test", (c) => c.json({ success: true })) + }) + + test("should allow requests when no API keys are configured", async () => { + state.apiKeys = undefined + + const res = await app.request("/test") + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ success: true }) + }) + + test("should allow requests when API keys array is empty", async () => { + state.apiKeys = [] + + const res = await app.request("/test") + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ success: true }) + }) + + test("should reject requests without authorization header when API keys are configured", async () => { + state.apiKeys = ["test-key-123"] + + const res = await app.request("/test") + expect(res.status).toBe(401) + const body = (await res.json()) as { + error: { message: string; type: string } + } + expect(body.error.message).toBe("Missing authorization header") + expect(body.error.type).toBe("invalid_request_error") + }) + + test("should accept valid API key with Bearer prefix", async () => { + state.apiKeys = ["test-key-123"] + + const res = await app.request("/test", { + headers: { + authorization: "Bearer test-key-123", + }, + }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ success: true }) + }) + + test("should accept valid API key without Bearer prefix", async () => { + state.apiKeys = ["test-key-123"] + + const res = await app.request("/test", { + headers: { + authorization: "test-key-123", + }, + }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ success: true }) + }) + + test("should reject invalid API key", async () => { + state.apiKeys = ["test-key-123"] + + const res = await app.request("/test", { + headers: { + authorization: "Bearer wrong-key", + }, + }) + expect(res.status).toBe(401) + const body = (await res.json()) as { + error: { message: string; type: string } + } + expect(body.error.message).toBe("Invalid API key") + expect(body.error.type).toBe("invalid_request_error") + }) + + test("should accept any of multiple configured API keys", async () => { + state.apiKeys = ["key1", "key2", "key3"] + + // Test first key + let res = await app.request("/test", { + headers: { + authorization: "Bearer key1", + }, + }) + expect(res.status).toBe(200) + + // Test middle key + res = await app.request("/test", { + headers: { + authorization: "Bearer key2", + }, + }) + expect(res.status).toBe(200) + + // Test last key + res = await app.request("/test", { + headers: { + authorization: "Bearer key3", + }, + }) + expect(res.status).toBe(200) + }) + + test("should reject key that is not in the configured list", async () => { + state.apiKeys = ["key1", "key2", "key3"] + + const res = await app.request("/test", { + headers: { + authorization: "Bearer key4", + }, + }) + expect(res.status).toBe(401) + }) + + test("should handle case-sensitive API keys correctly", async () => { + state.apiKeys = ["TestKey123"] + + // Correct case + let res = await app.request("/test", { + headers: { + authorization: "Bearer TestKey123", + }, + }) + expect(res.status).toBe(200) + + // Wrong case should fail + res = await app.request("/test", { + headers: { + authorization: "Bearer testkey123", + }, + }) + expect(res.status).toBe(401) + }) + + test("should handle API keys with special characters", async () => { + state.apiKeys = ["sk-test_key-123!@#$%"] + + const res = await app.request("/test", { + headers: { + authorization: "Bearer sk-test_key-123!@#$%", + }, + }) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ success: true }) + }) + + test("should not allow empty string as valid API key", async () => { + state.apiKeys = ["valid-key"] + + const res = await app.request("/test", { + headers: { + authorization: "Bearer ", + }, + }) + expect(res.status).toBe(401) + }) +})