Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 66 additions & 23 deletions src/credential-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ vi.mock('./logger.js', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));

// Mock getFreshOAuthToken — returns the env value or null
vi.mock('./oauth-token.js', () => ({
getFreshOAuthToken: vi.fn(
async () => mockEnv.CLAUDE_CODE_OAUTH_TOKEN || null,
),
}));

import { startCredentialProxy } from './credential-proxy.js';

function makeRequest(
Expand Down Expand Up @@ -49,6 +56,7 @@ describe('credential-proxy', () => {
let proxyPort: number;
let upstreamPort: number;
let lastUpstreamHeaders: http.IncomingHttpHeaders;
const originalEnv = { ...process.env };

beforeEach(async () => {
lastUpstreamHeaders = {};
Expand All @@ -68,16 +76,29 @@ describe('credential-proxy', () => {
await new Promise<void>((r) => proxyServer?.close(() => r()));
await new Promise<void>((r) => upstreamServer?.close(() => r()));
for (const key of Object.keys(mockEnv)) delete mockEnv[key];
// Restore process.env
process.env = { ...originalEnv };
});

async function startProxy(env: Record<string, string>): Promise<number> {
Object.assign(mockEnv, env, {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
});
Object.assign(mockEnv, env);
process.env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${upstreamPort}`;
proxyServer = await startCredentialProxy(0);
return (proxyServer.address() as AddressInfo).port;
}

it('health endpoint returns 200', async () => {
proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });

const res = await makeRequest(proxyPort, {
method: 'GET',
path: '/health',
});

expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual({ status: 'ok' });
});

it('API-key mode injects x-api-key and strips placeholder', async () => {
proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });

Expand All @@ -97,50 +118,51 @@ describe('credential-proxy', () => {
expect(lastUpstreamHeaders['x-api-key']).toBe('sk-ant-real-key');
});

it('OAuth mode replaces Authorization when container sends one', async () => {
proxyPort = await startProxy({
CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
});
it('API-key mode strips any Authorization header from container', async () => {
proxyPort = await startProxy({ ANTHROPIC_API_KEY: 'sk-ant-real-key' });

await makeRequest(
proxyPort,
{
method: 'POST',
path: '/api/oauth/claude_cli/create_api_key',
path: '/v1/messages',
headers: {
'content-type': 'application/json',
authorization: 'Bearer placeholder',
'x-api-key': 'placeholder',
authorization: 'Bearer should-be-stripped',
},
},
'{}',
);

expect(lastUpstreamHeaders['authorization']).toBe(
'Bearer real-oauth-token',
);
expect(lastUpstreamHeaders['x-api-key']).toBe('sk-ant-real-key');
expect(lastUpstreamHeaders['authorization']).toBeUndefined();
});

it('OAuth mode does not inject Authorization when container omits it', async () => {
it('OAuth mode injects Bearer unconditionally and adds beta header', async () => {
proxyPort = await startProxy({
CLAUDE_CODE_OAUTH_TOKEN: 'real-oauth-token',
});

// Post-exchange: container uses x-api-key only, no Authorization header
await makeRequest(
proxyPort,
{
method: 'POST',
path: '/v1/messages',
headers: {
'content-type': 'application/json',
'x-api-key': 'temp-key-from-exchange',
'x-api-key': 'placeholder',
},
},
'{}',
);

expect(lastUpstreamHeaders['x-api-key']).toBe('temp-key-from-exchange');
expect(lastUpstreamHeaders['authorization']).toBeUndefined();
expect(lastUpstreamHeaders['authorization']).toBe(
'Bearer real-oauth-token',
);
expect(lastUpstreamHeaders['anthropic-beta']).toBe('oauth-2025-04-20');
// x-api-key must be stripped in OAuth mode
expect(lastUpstreamHeaders['x-api-key']).toBeUndefined();
});

it('strips hop-by-hop headers', async () => {
Expand All @@ -162,17 +184,16 @@ describe('credential-proxy', () => {
);

// Proxy strips client hop-by-hop headers. Node's HTTP client may re-add
// its own Connection header (standard HTTP/1.1 behavior), but the client's
// custom keep-alive and transfer-encoding must not be forwarded.
// its own transfer-encoding when piping (standard HTTP/1.1 behavior),
// but the client's custom keep-alive must not be forwarded.
expect(lastUpstreamHeaders['keep-alive']).toBeUndefined();
expect(lastUpstreamHeaders['transfer-encoding']).toBeUndefined();
});

it('returns 502 when upstream is unreachable', async () => {
it('returns JSON 502 when upstream is unreachable', async () => {
Object.assign(mockEnv, {
ANTHROPIC_API_KEY: 'sk-ant-real-key',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:59999',
});
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:59999';
proxyServer = await startCredentialProxy(0);
proxyPort = (proxyServer.address() as AddressInfo).port;

Expand All @@ -187,6 +208,28 @@ describe('credential-proxy', () => {
);

expect(res.statusCode).toBe(502);
expect(res.body).toBe('Bad Gateway');
const body = JSON.parse(res.body);
expect(body.error).toMatch(/Proxy error/);
});

it('returns 503 when no credentials are configured', async () => {
// No API key, no OAuth token
process.env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${upstreamPort}`;
proxyServer = await startCredentialProxy(0);
proxyPort = (proxyServer.address() as AddressInfo).port;

const res = await makeRequest(
proxyPort,
{
method: 'POST',
path: '/v1/messages',
headers: { 'content-type': 'application/json' },
},
'{}',
);

expect(res.statusCode).toBe(503);
const body = JSON.parse(res.body);
expect(body.error).toMatch(/No credentials/);
});
});
166 changes: 105 additions & 61 deletions src/credential-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,80 +3,106 @@
* Containers connect here instead of directly to the Anthropic API.
* The proxy injects real credentials so containers never see them.
*
* Two auth modes:
* API key: Proxy injects x-api-key on every request.
* OAuth: Container CLI exchanges its placeholder token for a temp
* API key via /api/oauth/claude_cli/create_api_key.
* Proxy injects real OAuth token on that exchange request;
* subsequent requests carry the temp key which is valid as-is.
* Data flow:
* Container (ANTHROPIC_BASE_URL=http://host.docker.internal:<port>)
* → This proxy (replaces auth headers)
* → api.anthropic.com (real credentials)
* → SSE streams back through proxy to container
*
* Security model (OAuth):
* The SDK's normal OAuth flow calls /api/oauth/claude_cli/create_api_key to
* exchange an OAuth token for a temporary API key, then uses that key for
* subsequent requests. In our setup the container is untrusted — if we allowed
* the exchange, the response body would deliver a working temp API key into
* the container, defeating credential isolation entirely.
*
* Instead, we inject the real credential on every outbound request. The SDK
* inside the container only ever has a placeholder key, which is worthless
* outside this proxy. No credential ever enters the container.
*/
import { createServer, Server } from 'http';
import { createServer, IncomingMessage, Server, ServerResponse } from 'http';
import { request as httpsRequest } from 'https';
import { request as httpRequest, RequestOptions } from 'http';

import { readEnvFile } from './env.js';
import { logger } from './logger.js';
import { getFreshOAuthToken } from './oauth-token.js';

export type AuthMode = 'api-key' | 'oauth';

export interface ProxyConfig {
authMode: AuthMode;
}
const REQUEST_TIMEOUT = 600_000; // 10 minutes for long agent conversations

export function startCredentialProxy(
port: number,
host = '127.0.0.1',
): Promise<Server> {
const secrets = readEnvFile([
'ANTHROPIC_API_KEY',
'CLAUDE_CODE_OAUTH_TOKEN',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_BASE_URL',
]);

const authMode: AuthMode = secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth';
const oauthToken =
secrets.CLAUDE_CODE_OAUTH_TOKEN || secrets.ANTHROPIC_AUTH_TOKEN;

const upstreamUrl = new URL(
secrets.ANTHROPIC_BASE_URL || 'https://api.anthropic.com',
process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com',
);
const isHttps = upstreamUrl.protocol === 'https:';
const makeRequest = isHttps ? httpsRequest : httpRequest;

return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const body = Buffer.concat(chunks);
const headers: Record<string, string | number | string[] | undefined> =
{
...(req.headers as Record<string, string>),
host: upstreamUrl.host,
'content-length': body.length,
};
const server = createServer(
async (req: IncomingMessage, res: ServerResponse) => {
const start = Date.now();

// Health check
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
return;
}

// Read API key from .env on each request (rotated keys take effect
// immediately). OAuth tokens are handled by getFreshOAuthToken() which
// reads from ~/.claude/.credentials.json with auto-refresh.
const creds = readEnvFile([
'ANTHROPIC_API_KEY',
'CLAUDE_CODE_OAUTH_TOKEN',
'ANTHROPIC_AUTH_TOKEN',
]);
const apiKey = creds.ANTHROPIC_API_KEY;
const oauthToken = apiKey ? null : await getFreshOAuthToken();

// Strip hop-by-hop headers that must not be forwarded by proxies
delete headers['connection'];
if (!apiKey && !oauthToken) {
res.writeHead(503, { 'content-type': 'application/json' });
res.end(
JSON.stringify({ error: 'No credentials configured on host' }),
);
return;
}

// Build forwarded headers — replace auth
const headers: Record<string, string | string[] | undefined> = {
...req.headers,
};
// Strip hop-by-hop headers that must not be forwarded (RFC 2616 §13.5.1).
// transfer-encoding is critical: we stream via req.pipe(), so forwarding
// the client's chunked framing header while the upstream negotiates its
// own would cause mismatches.
delete headers.host;
delete headers.connection;
delete headers['keep-alive'];
delete headers['transfer-encoding'];

if (authMode === 'api-key') {
// API key mode: inject x-api-key on every request
// Credential injection — always unconditional, never exchange-based.
//
// See module docstring for security rationale. We inject the real
// credential on every request. The container SDK only has a placeholder,
// so the OAuth exchange endpoint is never called — no temp API key ever
// enters the container.
if (apiKey) {
headers['x-api-key'] = apiKey;
delete headers['authorization'];
} else if (oauthToken) {
headers['authorization'] = `Bearer ${oauthToken}`;
// OAuth on the Messages API requires this beta feature flag.
// Without it, api.anthropic.com returns 401 "OAuth authentication is
// currently not supported." The SDK normally sends this itself, but
// since we bypass the exchange flow (the SDK thinks it has an API key
// via the placeholder), it won't. We must inject it.
// If Anthropic graduates OAuth out of beta, this becomes a no-op.
headers['anthropic-beta'] = 'oauth-2025-04-20';
delete headers['x-api-key'];
headers['x-api-key'] = secrets.ANTHROPIC_API_KEY;
} else {
// OAuth mode: replace placeholder Bearer token with the real one
// only when the container actually sends an Authorization header
// (exchange request + auth probes). Post-exchange requests use
// x-api-key only, so they pass through without token injection.
if (headers['authorization']) {
delete headers['authorization'];
if (oauthToken) {
headers['authorization'] = `Bearer ${oauthToken}`;
}
}
}

const upstream = makeRequest(
Expand All @@ -89,28 +115,46 @@ export function startCredentialProxy(
} as RequestOptions,
(upRes) => {
res.writeHead(upRes.statusCode!, upRes.headers);
upRes.pipe(res);
upRes.pipe(res); // Stream SSE without buffering
},
);

upstream.setTimeout(REQUEST_TIMEOUT, () => {
logger.warn({ path: req.url }, 'Proxy request timed out');
upstream.destroy();
});

upstream.on('error', (err) => {
const duration = Date.now() - start;
logger.error(
{ err, url: req.url },
{ err, path: req.url, duration },
'Credential proxy upstream error',
);
if (!res.headersSent) {
res.writeHead(502);
res.end('Bad Gateway');
res.writeHead(502, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: `Proxy error: ${err.message}` }));
}
});

upstream.write(body);
upstream.end();
});
});
req.pipe(upstream); // Stream request body (handles large base64 images)

res.on('finish', () => {
const duration = Date.now() - start;
logger.debug(
{
method: req.method,
path: req.url,
status: res.statusCode,
duration,
},
'Proxied API request',
);
});
},
);

server.listen(port, host, () => {
logger.info({ port, host, authMode }, 'Credential proxy started');
logger.info({ port, host }, 'Credential proxy started');
resolve(server);
});

Expand All @@ -119,7 +163,7 @@ export function startCredentialProxy(
}

/** Detect which auth mode the host is configured for. */
export function detectAuthMode(): AuthMode {
export function detectAuthMode(): 'api-key' | 'oauth' {
const secrets = readEnvFile(['ANTHROPIC_API_KEY']);
return secrets.ANTHROPIC_API_KEY ? 'api-key' : 'oauth';
}
Loading
Loading