Skip to content

Commit 0ca3a4b

Browse files
CopilotBunsDev
andauthored
Add API auth seamline and minimal Claude proxy (#425)
* Add auth API seamline and Claude proxy Agent-Logs-Url: https://github.com/OpenKnots/okcode/sessions/6cd8bf08-8ada-493c-b8a5-fc5b9a543673 Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> * Tighten anthropic proxy URL handling Agent-Logs-Url: https://github.com/OpenKnots/okcode/sessions/6cd8bf08-8ada-493c-b8a5-fc5b9a543673 Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: BunsDev <68980965+BunsDev@users.noreply.github.com>
1 parent a8da70b commit 0ca3a4b

4 files changed

Lines changed: 515 additions & 30 deletions

File tree

apps/server/src/api/authRouter.ts

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import http from "node:http";
2+
import type { TokenManager } from "../tokenManager.ts";
3+
4+
const PAIRING_PATHS = new Set(["/api/pairing", "/api/auth/pairing"]);
5+
const ANTHROPIC_PROXY_PREFIX = "/api/auth/anthropic";
6+
const ANTHROPIC_MESSAGES_PATH = `${ANTHROPIC_PROXY_PREFIX}/v1/messages`;
7+
const CLAUDE_CODE_BETA = "claude-code-20250219";
8+
const CLAUDE_CODE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude.";
9+
const DEFAULT_ANTHROPIC_VERSION = "2023-06-01";
10+
const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
11+
12+
interface AuthApiRouterOptions {
13+
readonly authToken: string | undefined;
14+
readonly host: string | undefined;
15+
readonly port: number;
16+
readonly tokenManager: TokenManager;
17+
readonly fetchImpl?: typeof fetch;
18+
readonly anthropicBaseUrl?: string;
19+
}
20+
21+
function respondJson(
22+
res: http.ServerResponse,
23+
statusCode: number,
24+
body: unknown,
25+
headers?: Record<string, string>,
26+
): void {
27+
res.writeHead(statusCode, {
28+
"Content-Type": "application/json",
29+
...(headers ?? {}),
30+
});
31+
res.end(JSON.stringify(body));
32+
}
33+
34+
function buildServerUrl(host: string | undefined, port: number): string {
35+
const effectiveHost =
36+
!host || host === "0.0.0.0" || host === "::" || host === "[::]" ? "localhost" : host;
37+
const formattedHost =
38+
effectiveHost.includes(":") && !effectiveHost.startsWith("[")
39+
? `[${effectiveHost}]`
40+
: effectiveHost;
41+
return `http://${formattedHost}:${port}`;
42+
}
43+
44+
function mergeAnthropicBetaHeader(value: string | null): string {
45+
const parts = (value ?? "")
46+
.split(",")
47+
.map((part) => part.trim())
48+
.filter((part) => part.length > 0);
49+
if (!parts.includes(CLAUDE_CODE_BETA)) {
50+
parts.unshift(CLAUDE_CODE_BETA);
51+
}
52+
return parts.join(",");
53+
}
54+
55+
async function readJsonRequestBody(req: http.IncomingMessage): Promise<Record<string, unknown> | null> {
56+
const chunks: Buffer[] = [];
57+
for await (const chunk of req) {
58+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk));
59+
}
60+
61+
if (chunks.length === 0) {
62+
return null;
63+
}
64+
65+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
66+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
67+
return null;
68+
}
69+
return parsed as Record<string, unknown>;
70+
}
71+
72+
function injectClaudeCodeSystemPrompt(body: Record<string, unknown>): Record<string, unknown> {
73+
const systemBlock = { type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT };
74+
const existingSystem = body.system;
75+
76+
if (existingSystem === undefined) {
77+
return {
78+
...body,
79+
system: [systemBlock],
80+
};
81+
}
82+
83+
if (typeof existingSystem === "string") {
84+
return {
85+
...body,
86+
system: [systemBlock, { type: "text", text: existingSystem }],
87+
};
88+
}
89+
90+
if (Array.isArray(existingSystem)) {
91+
return {
92+
...body,
93+
system: [systemBlock, ...existingSystem],
94+
};
95+
}
96+
97+
return {
98+
...body,
99+
system: [systemBlock],
100+
};
101+
}
102+
103+
export function createAuthApiRouter(options: AuthApiRouterOptions) {
104+
const fetchImpl = options.fetchImpl ?? fetch;
105+
const anthropicBaseUrl = new URL(options.anthropicBaseUrl ?? DEFAULT_ANTHROPIC_BASE_URL);
106+
const serverUrl = buildServerUrl(options.host, options.port);
107+
let requestCount = 0;
108+
109+
return async function tryHandleAuthApiRequest(
110+
req: http.IncomingMessage,
111+
res: http.ServerResponse,
112+
url: URL,
113+
): Promise<boolean> {
114+
if (PAIRING_PATHS.has(url.pathname) && req.method === "GET") {
115+
if (!options.authToken) {
116+
respondJson(
117+
res,
118+
200,
119+
{ error: "Auth is not enabled on this server." },
120+
{ "Access-Control-Allow-Origin": "*" },
121+
);
122+
return true;
123+
}
124+
125+
const ttlParam = url.searchParams.get("ttl");
126+
const ttlSeconds = ttlParam ? Math.min(Math.max(Number(ttlParam), 30), 3600) : 300;
127+
const record = options.tokenManager.generatePairingToken({ ttlSeconds, label: "http-api" });
128+
const pairingUrl = `okcode://pair?server=${encodeURIComponent(serverUrl)}&token=${encodeURIComponent(record.tokenValue)}`;
129+
respondJson(
130+
res,
131+
200,
132+
{
133+
pairingUrl,
134+
expiresAt: record.expiresAt,
135+
serverUrl,
136+
},
137+
{ "Access-Control-Allow-Origin": "*" },
138+
);
139+
return true;
140+
}
141+
142+
if (url.pathname === `${ANTHROPIC_PROXY_PREFIX}/health` && req.method === "GET") {
143+
respondJson(res, 200, {
144+
status: "ok",
145+
proxy: "anthropic",
146+
upstreamOrigin: anthropicBaseUrl.origin,
147+
});
148+
return true;
149+
}
150+
151+
if (url.pathname === `${ANTHROPIC_PROXY_PREFIX}/status` && req.method === "GET") {
152+
respondJson(res, 200, {
153+
status: "running",
154+
proxy: "anthropic",
155+
upstreamOrigin: anthropicBaseUrl.origin,
156+
requestsServed: requestCount,
157+
});
158+
return true;
159+
}
160+
161+
if (req.method !== "POST" || url.pathname !== ANTHROPIC_MESSAGES_PATH) {
162+
return false;
163+
}
164+
165+
const apiKey = req.headers["x-api-key"];
166+
if (typeof apiKey !== "string" || apiKey.trim().length === 0) {
167+
respondJson(res, 401, { error: "Missing x-api-key header." });
168+
return true;
169+
}
170+
171+
let body: Record<string, unknown> | null;
172+
try {
173+
body = await readJsonRequestBody(req);
174+
} catch {
175+
respondJson(res, 400, { error: "Invalid JSON body." });
176+
return true;
177+
}
178+
179+
if (!body) {
180+
respondJson(res, 400, { error: "Request body must be a JSON object." });
181+
return true;
182+
}
183+
184+
const proxiedBody = injectClaudeCodeSystemPrompt(body);
185+
const upstreamUrl = new URL(anthropicBaseUrl);
186+
upstreamUrl.pathname = "/v1/messages";
187+
upstreamUrl.search = "";
188+
const payload = JSON.stringify(proxiedBody);
189+
const headers = new Headers({
190+
"content-type": "application/json",
191+
"anthropic-version":
192+
typeof req.headers["anthropic-version"] === "string"
193+
? req.headers["anthropic-version"]
194+
: DEFAULT_ANTHROPIC_VERSION,
195+
"anthropic-beta": mergeAnthropicBetaHeader(
196+
typeof req.headers["anthropic-beta"] === "string" ? req.headers["anthropic-beta"] : null,
197+
),
198+
"x-api-key": apiKey,
199+
});
200+
if (typeof req.headers.accept === "string" && req.headers.accept.length > 0) {
201+
headers.set("accept", req.headers.accept);
202+
}
203+
if (
204+
typeof req.headers["anthropic-dangerous-direct-browser-access-control"] === "string" &&
205+
req.headers["anthropic-dangerous-direct-browser-access-control"].length > 0
206+
) {
207+
headers.set(
208+
"anthropic-dangerous-direct-browser-access-control",
209+
req.headers["anthropic-dangerous-direct-browser-access-control"],
210+
);
211+
}
212+
213+
let upstreamResponse: Response;
214+
try {
215+
upstreamResponse = await fetchImpl(upstreamUrl, {
216+
method: "POST",
217+
headers,
218+
body: payload,
219+
});
220+
requestCount += 1;
221+
} catch (error) {
222+
respondJson(res, 502, {
223+
error: `Upstream error: ${error instanceof Error ? error.message : String(error)}`,
224+
});
225+
return true;
226+
}
227+
228+
const responseHeaders = Object.fromEntries(upstreamResponse.headers.entries());
229+
res.writeHead(upstreamResponse.status, responseHeaders);
230+
if (!upstreamResponse.body) {
231+
res.end();
232+
return true;
233+
}
234+
235+
try {
236+
const reader = upstreamResponse.body.getReader();
237+
while (true) {
238+
const next = await reader.read();
239+
if (next.done) {
240+
break;
241+
}
242+
if (!res.writableEnded) {
243+
res.write(next.value);
244+
}
245+
}
246+
if (!res.writableEnded) {
247+
res.end();
248+
}
249+
} catch {
250+
if (!res.destroyed) {
251+
res.destroy();
252+
}
253+
}
254+
return true;
255+
};
256+
}

0 commit comments

Comments
 (0)