Skip to content

Commit bbded10

Browse files
authored
feat(auth): add downstream api key support (#2)
* feat(auth): add downstream api key support * feat(auth): harden api key security * fix(auth): refine api key parsing * chore(responses): align review feedback
1 parent 47d507d commit bbded10

7 files changed

Lines changed: 278 additions & 5 deletions

File tree

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Compared with the original upstream project, this fork keeps the README intentio
1313

1414
- OpenAI-compatible endpoints for chat, models, embeddings, and responses
1515
- Anthropic-compatible messages endpoint
16+
- Optional downstream API key protection from CLI arg or environment variable
1617
- Usage and token inspection endpoints
1718
- Optional rate limit control and manual approval flow
1819
- Support for individual, business, and enterprise Copilot accounts
@@ -44,6 +45,26 @@ bun run start
4445
- Test: `bun test`
4546
- Start: `bun run start`
4647

48+
## API Key Protection
49+
50+
You can require clients to send an API key for all incoming requests.
51+
52+
Priority order:
53+
54+
- CLI arg `--api-key`
55+
- env `API_KEY`
56+
- env `COPILOT_API_KEY`
57+
- default empty, meaning disabled
58+
59+
Example:
60+
61+
`bun run start -- --port 3000 --api-key my-secret-key`
62+
63+
Then call the API with either header:
64+
65+
- `Authorization: Bearer my-secret-key`
66+
- `x-api-key: my-secret-key`
67+
4768
## API Endpoints
4869

4970
### OpenAI-compatible

src/lib/api-key.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import type { Context, Next } from "hono"
2+
3+
import consola from "consola"
4+
5+
import { state } from "~/lib/state"
6+
7+
const AUTH_WINDOW_MS = 5 * 60 * 1000
8+
const AUTH_MAX_FAILURES = 10
9+
const AUTH_BLOCK_MS = 15 * 60 * 1000
10+
11+
function getBearerToken(
12+
authorizationHeader: string | undefined,
13+
): string | undefined {
14+
if (!authorizationHeader) return undefined
15+
16+
const [scheme, token] = authorizationHeader.trim().split(/\s+/, 2)
17+
if (scheme.toLowerCase() !== "bearer" || !token) return undefined
18+
19+
return token
20+
}
21+
22+
function getForwardedIp(
23+
forwardedHeader: string | undefined,
24+
): string | undefined {
25+
if (!forwardedHeader) return undefined
26+
27+
const match = forwardedHeader.match(/for="?\[?([^;,"]+)/i)
28+
return match?.[1]?.trim()
29+
}
30+
31+
function getClientAddress(c: Context): string {
32+
const candidates = [
33+
c.req.header("cf-connecting-ip"),
34+
c.req.header("x-real-ip"),
35+
c.req.header("x-client-ip"),
36+
c.req.header("x-forwarded-for")?.split(",")[0]?.trim(),
37+
getForwardedIp(c.req.header("forwarded")),
38+
c.req.header("fly-client-ip"),
39+
]
40+
41+
return candidates.find((value) => value && value.length > 0) ?? "unknown"
42+
}
43+
44+
function getRequestTarget(c: Context): string {
45+
try {
46+
const url = new URL(c.req.url)
47+
return `${c.req.method} ${url.pathname}`
48+
} catch {
49+
return `${c.req.method} unknown`
50+
}
51+
}
52+
53+
function isClientBlocked(clientAddress: string, now: number): boolean {
54+
const entry = state.authFailures.get(clientAddress)
55+
if (!entry?.blockedUntil) return false
56+
57+
if (entry.blockedUntil <= now) {
58+
state.authFailures.delete(clientAddress)
59+
return false
60+
}
61+
62+
return true
63+
}
64+
65+
function recordAuthFailure(clientAddress: string, now: number): void {
66+
const entry = state.authFailures.get(clientAddress)
67+
68+
if (!entry || entry.resetAt <= now) {
69+
state.authFailures.set(clientAddress, {
70+
blockedUntil: undefined,
71+
count: 1,
72+
resetAt: now + AUTH_WINDOW_MS,
73+
})
74+
return
75+
}
76+
77+
entry.count += 1
78+
if (entry.count >= AUTH_MAX_FAILURES) {
79+
entry.blockedUntil = now + AUTH_BLOCK_MS
80+
}
81+
}
82+
83+
function clearAuthFailures(clientAddress: string): void {
84+
state.authFailures.delete(clientAddress)
85+
}
86+
87+
function rejectUnauthorized() {
88+
return {
89+
error: {
90+
message: "Invalid API key",
91+
type: "authentication_error",
92+
},
93+
}
94+
}
95+
96+
export async function safeRequestLogger(c: Context, next: Next) {
97+
const startedAt = Date.now()
98+
const target = getRequestTarget(c)
99+
100+
consola.info(`<-- ${target}`)
101+
await next()
102+
consola.info(`--> ${target} ${c.res.status} ${Date.now() - startedAt}ms`)
103+
}
104+
105+
export async function requireApiKey(c: Context, next: Next) {
106+
if (!state.apiKey) {
107+
await next()
108+
return
109+
}
110+
111+
const now = Date.now()
112+
const clientAddress = getClientAddress(c)
113+
114+
if (isClientBlocked(clientAddress, now)) {
115+
consola.warn(
116+
`Blocked API key request from ${clientAddress} to ${getRequestTarget(c)}`,
117+
)
118+
return c.json(rejectUnauthorized(), 429)
119+
}
120+
121+
const authorization = c.req.header("authorization")
122+
const bearerToken = getBearerToken(authorization)
123+
const xApiKey = c.req.header("x-api-key")
124+
125+
if (bearerToken === state.apiKey || xApiKey === state.apiKey) {
126+
clearAuthFailures(clientAddress)
127+
await next()
128+
return
129+
}
130+
131+
recordAuthFailure(clientAddress, now)
132+
consola.warn(
133+
`Rejected API key request from ${clientAddress} to ${getRequestTarget(c)}`,
134+
)
135+
136+
return c.json(rejectUnauthorized(), 401)
137+
}

src/lib/state.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ModelsResponse } from "~/services/copilot/get-models"
33
export interface State {
44
githubToken?: string
55
copilotToken?: string
6+
apiKey?: string
67

78
accountType: string
89
models?: ModelsResponse
@@ -15,10 +16,16 @@ export interface State {
1516
// Rate limiting configuration
1617
rateLimitSeconds?: number
1718
lastRequestTimestamp?: number
19+
20+
authFailures: Map<
21+
string,
22+
{ count: number; resetAt: number; blockedUntil?: number }
23+
>
1824
}
1925

2026
export const state: State = {
2127
accountType: "individual",
28+
authFailures: new Map(),
2229
manualApprove: false,
2330
rateLimitWait: false,
2431
showToken: false,

src/server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Hono } from "hono"
22
import { cors } from "hono/cors"
3-
import { logger } from "hono/logger"
43

4+
import { requireApiKey, safeRequestLogger } from "./lib/api-key"
55
import { completionRoutes } from "./routes/chat-completions/route"
66
import { embeddingRoutes } from "./routes/embeddings/route"
77
import { messageRoutes } from "./routes/messages/route"
@@ -12,8 +12,9 @@ import { usageRoute } from "./routes/usage/route"
1212

1313
export const server = new Hono()
1414

15-
server.use(logger())
15+
server.use(safeRequestLogger)
1616
server.use(cors())
17+
server.use("*", requireApiKey)
1718

1819
server.get("/", (c) => c.text("Server running"))
1920

src/services/copilot/create-responses.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
/// <reference lib="dom" />
2-
31
import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config"
42
import { HTTPError } from "~/lib/error"
53
import { state } from "~/lib/state"
@@ -19,7 +17,7 @@ export const createResponses = async (payload: ResponsesPayload) => {
1917
body: JSON.stringify(payload),
2018
})
2119

22-
if (!response.ok) throw new HTTPError("Failed to create response", response)
20+
if (!response.ok) throw new HTTPError("Failed to create responses", response)
2321

2422
return response
2523
}

src/start.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ interface RunServerOptions {
1818
port: number
1919
verbose: boolean
2020
accountType: string
21+
apiKey?: string
2122
manual: boolean
2223
rateLimit?: number
2324
rateLimitWait: boolean
@@ -38,6 +39,8 @@ export async function runServer(options: RunServerOptions): Promise<void> {
3839
}
3940

4041
state.accountType = options.accountType
42+
state.apiKey =
43+
options.apiKey ?? process.env.API_KEY ?? process.env.COPILOT_API_KEY ?? ""
4144
if (options.accountType !== "individual") {
4245
consola.info(`Using ${options.accountType} plan GitHub account`)
4346
}
@@ -144,6 +147,12 @@ export const start = defineCommand({
144147
default: "individual",
145148
description: "Account type to use (individual, business, enterprise)",
146149
},
150+
"api-key": {
151+
alias: "k",
152+
type: "string",
153+
description:
154+
"API key required by downstream clients. Falls back to API_KEY or COPILOT_API_KEY env vars",
155+
},
147156
manual: {
148157
type: "boolean",
149158
default: false,
@@ -195,6 +204,7 @@ export const start = defineCommand({
195204
port: Number.parseInt(args.port, 10),
196205
verbose: args.verbose,
197206
accountType: args["account-type"],
207+
apiKey: args["api-key"],
198208
manual: args.manual,
199209
rateLimit,
200210
rateLimitWait: args.wait,

tests/api-key.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { afterEach, beforeEach, expect, test } from "bun:test"
2+
3+
import { state } from "../src/lib/state"
4+
import { server } from "../src/server"
5+
6+
const originalApiKey = state.apiKey
7+
8+
beforeEach(() => {
9+
state.authFailures.clear()
10+
state.apiKey = "test-key"
11+
})
12+
13+
afterEach(() => {
14+
state.authFailures.clear()
15+
state.apiKey = originalApiKey
16+
})
17+
18+
test("rejects request without presenting an API key", async () => {
19+
const response = await server.request("http://localhost/v1/models")
20+
21+
expect(response.status).toBe(401)
22+
expect(await response.json()).toEqual({
23+
error: {
24+
message: "Invalid API key",
25+
type: "authentication_error",
26+
},
27+
})
28+
})
29+
30+
test("accepts bearer token authentication", async () => {
31+
const response = await server.request("http://localhost/", {
32+
headers: {
33+
Authorization: "Bearer test-key",
34+
},
35+
})
36+
37+
expect(response.status).toBe(200)
38+
expect(await response.text()).toBe("Server running")
39+
})
40+
41+
test("accepts x-api-key authentication", async () => {
42+
const response = await server.request("http://localhost/", {
43+
headers: {
44+
"x-api-key": "test-key",
45+
},
46+
})
47+
48+
expect(response.status).toBe(200)
49+
expect(await response.text()).toBe("Server running")
50+
})
51+
52+
test("allows requests when API key protection is disabled", async () => {
53+
state.apiKey = ""
54+
55+
const response = await server.request("http://localhost/")
56+
57+
expect(response.status).toBe(200)
58+
expect(await response.text()).toBe("Server running")
59+
})
60+
61+
test("uses proxy headers for auth failure rate limiting", async () => {
62+
for (let index = 0; index < 10; index += 1) {
63+
const response = await server.request("http://localhost/v1/models", {
64+
headers: {
65+
"x-forwarded-for": "203.0.113.10, 127.0.0.1",
66+
},
67+
})
68+
69+
expect(response.status).toBe(401)
70+
}
71+
72+
const blockedResponse = await server.request("http://localhost/v1/models", {
73+
headers: {
74+
"x-forwarded-for": "203.0.113.10, 127.0.0.1",
75+
},
76+
})
77+
78+
expect(blockedResponse.status).toBe(429)
79+
})
80+
81+
test("successful authentication clears previous auth failures", async () => {
82+
const failedResponse = await server.request("http://localhost/v1/models", {
83+
headers: {
84+
"x-real-ip": "198.51.100.1",
85+
},
86+
})
87+
88+
expect(failedResponse.status).toBe(401)
89+
90+
const successResponse = await server.request("http://localhost/", {
91+
headers: {
92+
Authorization: "Bearer test-key",
93+
"x-real-ip": "198.51.100.1",
94+
},
95+
})
96+
97+
expect(successResponse.status).toBe(200)
98+
expect(state.authFailures.has("198.51.100.1")).toBe(false)
99+
})

0 commit comments

Comments
 (0)