anthropic auth proxy for multi-user claude code
Local HTTP proxy on 127.0.0.1:5051 intercepts Claude Code API requests from sandboxed member users, injects the real OAuth token server-side, and forwards to Anthropic. Users only see a proxy secret, never the real credentials. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import { desktopWebsocket } from './servers/api/desktop/websocket';
|
|||||||
import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
|
import { findEntryByProxyId, touchEntry } from './servers/api/dev-server/router';
|
||||||
import officerWeb from './apps/officer-web/index.html';
|
import officerWeb from './apps/officer-web/index.html';
|
||||||
import { startBrowserRelay } from './servers/api/browser/relay';
|
import { startBrowserRelay } from './servers/api/browser/relay';
|
||||||
|
import { startAnthropicProxy } from './servers/api/anthropic-proxy';
|
||||||
import { toShellUsername } from './servers/data-path';
|
import { toShellUsername } from './servers/data-path';
|
||||||
|
|
||||||
const { PORT = '5000' } = process.env;
|
const { PORT = '5000' } = process.env;
|
||||||
@@ -228,6 +229,12 @@ try {
|
|||||||
console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
|
console.error('[browser-relay] failed to start:', err instanceof Error ? err.message : err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startAnthropicProxy();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[anthropic-proxy] failed to start:', err instanceof Error ? err.message : err);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void initTerminalSidecars();
|
void initTerminalSidecars();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
const PROXY_PORT = Number(process.env.ANTHROPIC_PROXY_PORT ?? '5051');
|
||||||
|
const ANTHROPIC_API_BASE = 'https://api.anthropic.com';
|
||||||
|
const CREDENTIALS_PATH = join(homedir(), '.claude', '.credentials.json');
|
||||||
|
|
||||||
|
// Generate a random proxy secret at startup — shared with Claude Code spawns
|
||||||
|
// Prefix with sk-ant- so Claude Code accepts it as a valid API key format
|
||||||
|
export const proxySecret = `sk-ant-proxy01-${crypto.randomUUID()}`;
|
||||||
|
|
||||||
|
type CredentialsFile = {
|
||||||
|
claudeAiOauth?: {
|
||||||
|
accessToken?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readOAuthToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const file = Bun.file(CREDENTIALS_PATH);
|
||||||
|
if (!(await file.exists())) return null;
|
||||||
|
const data = (await file.json()) as CredentialsFile;
|
||||||
|
return data.claudeAiOauth?.accessToken?.trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAnthropicProxy() {
|
||||||
|
Bun.serve({
|
||||||
|
port: PROXY_PORT,
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
idleTimeout: 0,
|
||||||
|
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
// Validate proxy secret
|
||||||
|
const incomingKey = req.headers.get('x-api-key');
|
||||||
|
if (incomingKey !== proxySecret) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
|
||||||
|
status: 401,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read fresh OAuth token
|
||||||
|
const token = await readOAuthToken();
|
||||||
|
if (!token) {
|
||||||
|
return new Response(JSON.stringify({ error: 'No OAuth token available' }), {
|
||||||
|
status: 502,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build upstream URL
|
||||||
|
const upstream = `${ANTHROPIC_API_BASE}${url.pathname}${url.search}`;
|
||||||
|
|
||||||
|
// Clone headers, replace proxy secret with real OAuth token
|
||||||
|
const headers = new Headers(req.headers);
|
||||||
|
headers.set('x-api-key', token);
|
||||||
|
headers.delete('host');
|
||||||
|
|
||||||
|
// Read request body fully before forwarding — avoids stream-in-stream issues
|
||||||
|
const body = req.body ? await req.arrayBuffer() : null;
|
||||||
|
|
||||||
|
// Forward request
|
||||||
|
const upstreamRes = await fetch(upstream, {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build clean response headers
|
||||||
|
const resHeaders = new Headers();
|
||||||
|
for (const [key, value] of upstreamRes.headers) {
|
||||||
|
const lower = key.toLowerCase();
|
||||||
|
// Skip hop-by-hop and encoding headers that may have been consumed by fetch
|
||||||
|
if (lower === 'content-encoding' || lower === 'content-length' || lower === 'transfer-encoding') continue;
|
||||||
|
resHeaders.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(upstreamRes.body, {
|
||||||
|
status: upstreamRes.status,
|
||||||
|
statusText: upstreamRes.statusText,
|
||||||
|
headers: resHeaders,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[anthropic-proxy] listening on 127.0.0.1:${PROXY_PORT}`);
|
||||||
|
}
|
||||||
@@ -56,9 +56,11 @@ claudeCodeRouter.get('/auth', async (ctx) => {
|
|||||||
const proc = Bun.spawn(['claude', 'auth', 'status'], { stdout: 'pipe', stderr: 'pipe' });
|
const proc = Bun.spawn(['claude', 'auth', 'status'], { stdout: 'pipe', stderr: 'pipe' });
|
||||||
const output = await new Response(proc.stdout).text();
|
const output = await new Response(proc.stdout).text();
|
||||||
await proc.exited;
|
await proc.exited;
|
||||||
if (proc.exitCode !== 0) return ctx.json({ authenticated: false });
|
if (proc.exitCode === 0) {
|
||||||
const status = JSON.parse(output.trim());
|
const status = JSON.parse(output.trim());
|
||||||
return ctx.json({ authenticated: status.loggedIn ?? false, ...status });
|
if (status.loggedIn) return ctx.json({ authenticated: true, ...status });
|
||||||
|
}
|
||||||
|
return ctx.json({ authenticated: false });
|
||||||
} catch {
|
} catch {
|
||||||
return ctx.json({ authenticated: false });
|
return ctx.json({ authenticated: false });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { existsSync } from 'node:fs';
|
|
||||||
import {
|
import {
|
||||||
getHomeDir,
|
getHomeDir,
|
||||||
DATA_PATH,
|
|
||||||
getNativeToolsDir,
|
getNativeToolsDir,
|
||||||
getGlobalToolsDir,
|
getGlobalToolsDir,
|
||||||
getUserToolsDir,
|
getUserToolsDir,
|
||||||
@@ -15,9 +13,11 @@ import { readToolDirs, parseFrontmatter as parseToolFrontmatter } from '@@/api/t
|
|||||||
import { readSkillDirs, parseFrontmatter as parseSkillFrontmatter } from '@@/api/skills/skills';
|
import { readSkillDirs, parseFrontmatter as parseSkillFrontmatter } from '@@/api/skills/skills';
|
||||||
import { buildHostToolEnv } from '@@/api/pi/pi-bridge';
|
import { buildHostToolEnv } from '@@/api/pi/pi-bridge';
|
||||||
import { logger } from '@@/api/pi/logger';
|
import { logger } from '@@/api/pi/logger';
|
||||||
|
import { proxySecret } from '@@/api/anthropic-proxy';
|
||||||
import type { MessageCost, PiEvent } from '@@/api/pi/types';
|
import type { MessageCost, PiEvent } from '@@/api/pi/types';
|
||||||
|
|
||||||
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
|
const PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
|
||||||
|
|
||||||
// Resolve absolute path to claude binary so sudo -u can find it regardless of target user's PATH
|
// Resolve absolute path to claude binary so sudo -u can find it regardless of target user's PATH
|
||||||
const CLAUDE_BIN = (() => {
|
const CLAUDE_BIN = (() => {
|
||||||
@@ -162,10 +162,13 @@ export async function sendClaudeCode(params: ClaudeCodeParams): Promise<ClaudeCo
|
|||||||
|
|
||||||
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
||||||
|
|
||||||
// For service user, keep real HOME so Claude Code finds its credentials
|
// For service user, keep real HOME so Claude Code finds its credentials.
|
||||||
|
// For other users, route through the local Anthropic proxy.
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
...toolEnv,
|
...toolEnv,
|
||||||
...(isServiceUser ? { HOME: process.env.HOME ?? '' } : {}),
|
...(isServiceUser
|
||||||
|
? { HOME: process.env.HOME ?? '' }
|
||||||
|
: { ANTHROPIC_BASE_URL: `http://127.0.0.1:${PROXY_PORT}`, ANTHROPIC_API_KEY: proxySecret }),
|
||||||
PATH: process.env.PATH ?? '',
|
PATH: process.env.PATH ?? '',
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
};
|
};
|
||||||
@@ -317,10 +320,13 @@ export async function sendClaudeCodeStreaming(params: ClaudeCodeStreamingParams)
|
|||||||
|
|
||||||
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
const isServiceUser = shellUsername === (process.env.USER ?? '');
|
||||||
|
|
||||||
// For service user, keep real HOME so Claude Code finds its credentials
|
// For service user, keep real HOME so Claude Code finds its credentials.
|
||||||
|
// For other users, route through the local Anthropic proxy.
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
...toolEnv,
|
...toolEnv,
|
||||||
...(isServiceUser ? { HOME: cleanEnv.HOME ?? '' } : {}),
|
...(isServiceUser
|
||||||
|
? { HOME: cleanEnv.HOME ?? '' }
|
||||||
|
: { ANTHROPIC_BASE_URL: `http://127.0.0.1:${PROXY_PORT}`, ANTHROPIC_API_KEY: proxySecret }),
|
||||||
PATH: cleanEnv.PATH ?? '',
|
PATH: cleanEnv.PATH ?? '',
|
||||||
TERM: 'xterm-256color',
|
TERM: 'xterm-256color',
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user