Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions containers/api-proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ Required (at least one):

Optional:
- `COPILOT_API_TARGET` - Target hostname for GitHub Copilot API requests (default: `api.githubcopilot.com`). Useful for GHES deployments.
- `AWF_BYOK_EXTRA_HEADERS` - JSON object of additional headers to inject into upstream requests when the Copilot BYOK API key (`COPILOT_PROVIDER_API_KEY`) is in use. Useful for provider-native observability (e.g. OpenRouter session grouping, Helicone user tracking):
```
AWF_BYOK_EXTRA_HEADERS='{"x-session-id":"my-session","HTTP-Referer":"https://example.com"}'
```
Auth-critical header names (`authorization`, `x-api-key`, etc.) are rejected. Headers are only sent when the BYOK API key is used; standard GitHub OAuth (`COPILOT_GITHUB_TOKEN`) requests are unaffected.

Set by AWF:
- `HTTP_PROXY` - Squid proxy URL (http://172.30.0.10:3128)
Expand Down
73 changes: 73 additions & 0 deletions containers/api-proxy/providers/copilot.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
* Special routing: GET /models (and /models/*) always uses COPILOT_GITHUB_TOKEN
* regardless of which auth mode is active, because the /models endpoint only
* accepts OAuth tokens, not API keys.
*
* BYOK extra headers: AWF_BYOK_EXTRA_HEADERS (JSON object) injects supplemental
* headers (e.g. x-session-id, HTTP-Referer) into upstream requests when the
* BYOK API key is in use. Auth-critical header names are rejected at parse time.
*/

const {
Expand All @@ -24,6 +28,69 @@ const {
const { sanitizeNullToolCallTypes } = require('../body-transform');
const { URL } = require('url');

/**
* Header names that must never be overridden by caller-supplied extra headers.
* These are the auth/proxy headers stripped or injected by the proxy itself.
*/
const PROTECTED_HEADER_NAMES = new Set([
'authorization',
'x-api-key',
'x-goog-api-key',
'proxy-authorization',
]);

/**
* Parse the AWF_BYOK_EXTRA_HEADERS environment variable into a plain header map.
*
* The value must be a JSON object whose keys are valid HTTP header names and
* whose values are strings. Invalid entries are skipped with a console warning;
* the function always returns a (possibly empty) object rather than throwing.
*
* Auth-critical header names (authorization, x-api-key, etc.) are rejected to
* prevent accidental credential injection via this configuration path.
*
* @param {string|undefined} raw - Raw value of AWF_BYOK_EXTRA_HEADERS
* @returns {Record<string, string>} Validated header map (may be empty)
*/
function parseByokExtraHeaders(raw) {
if (!raw || !raw.trim()) return {};

let parsed;
try {
parsed = JSON.parse(raw.trim());
} catch {
console.warn('AWF_BYOK_EXTRA_HEADERS: invalid JSON; ignoring extra headers');
return {};
}

if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
console.warn('AWF_BYOK_EXTRA_HEADERS: expected a JSON object; ignoring extra headers');
return {};
}

const result = {};
const http = require('http');
for (const [name, value] of Object.entries(parsed)) {
if (PROTECTED_HEADER_NAMES.has(name.toLowerCase())) {
console.warn(`AWF_BYOK_EXTRA_HEADERS: "${name}" is an auth-critical header and cannot be overridden; skipping`);
continue;
}
try {
http.validateHeaderName(name);
} catch {
console.warn(`AWF_BYOK_EXTRA_HEADERS: "${name}" is not a valid HTTP header name; skipping`);
continue;
}
if (typeof value !== 'string') {
console.warn(`AWF_BYOK_EXTRA_HEADERS: value for "${name}" must be a string; skipping`);
continue;
}
result[name] = value;
}
Comment thread
lpcox marked this conversation as resolved.

return result;
}

// AWF injects this sentinel value into the *agent* environment for credential isolation.
// The ghu_ prefix is intentional: it matches the GitHub token shape that Copilot CLI
// auth pre-checks expect, but the 36 repeated 'a' characters make it unambiguous as
Expand Down Expand Up @@ -245,6 +312,10 @@ function createCopilotAdapter(env, deps = {}) {
const integrationId = env.COPILOT_INTEGRATION_ID || 'copilot-developer-cli';
const rawTarget = deriveCopilotApiTarget(env);
const basePath = normalizeBasePath(env.COPILOT_API_BASE_PATH);
// Extra headers to inject on all requests that use the BYOK API key.
// Only populated when AWF_BYOK_EXTRA_HEADERS is set; ignored for standard
// GitHub OAuth (COPILOT_GITHUB_TOKEN-only) requests.
const byokExtraHeaders = parseByokExtraHeaders(env.AWF_BYOK_EXTRA_HEADERS);

const bodyTransform = composeBodyTransforms(
deps.bodyTransform || null,
Expand Down Expand Up @@ -380,6 +451,7 @@ function createCopilotAdapter(env, deps = {}) {
return {
'Authorization': `Bearer ${authToken}`,
'Copilot-Integration-Id': integrationId,
...(apiKey ? byokExtraHeaders : {}),
};
Comment thread
lpcox marked this conversation as resolved.
},

Expand Down Expand Up @@ -425,5 +497,6 @@ module.exports = {
deriveGitHubApiBasePath,
isGithubCopilotCatalogTarget,
COPILOT_PLACEHOLDER_TOKEN,
parseByokExtraHeaders,
},
};
168 changes: 168 additions & 0 deletions containers/api-proxy/server.auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const {
resolveApiKey,
stripBearerPrefix,
COPILOT_PLACEHOLDER_TOKEN,
parseByokExtraHeaders,
},
createCopilotAdapter,
} = require('./providers/copilot');
Expand Down Expand Up @@ -338,6 +339,173 @@ describe('createCopilotAdapter — BYOK getAuthHeaders', () => {
});
});

// ── parseByokExtraHeaders ─────────────────────────────────────────────────────

describe('parseByokExtraHeaders', () => {
it('returns empty object for undefined input', () => {
expect(parseByokExtraHeaders(undefined)).toEqual({});
});

it('returns empty object for empty string', () => {
expect(parseByokExtraHeaders('')).toEqual({});
});

it('returns empty object for whitespace-only string', () => {
expect(parseByokExtraHeaders(' ')).toEqual({});
});

it('parses a valid JSON object of string headers', () => {
const result = parseByokExtraHeaders('{"x-session-id":"sess-123","HTTP-Referer":"https://example.com"}');
expect(result).toEqual({
'x-session-id': 'sess-123',
'HTTP-Referer': 'https://example.com',
});
});

it('returns empty object and warns for invalid JSON', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('{not-valid-json}');
expect(result).toEqual({});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid JSON'));
warnSpy.mockRestore();
});

it('returns empty object and warns when value is a JSON array (not object)', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('["x-session-id","value"]');
expect(result).toEqual({});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('expected a JSON object'));
warnSpy.mockRestore();
});

it('returns empty object and warns when value is a JSON string (not object)', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('"just-a-string"');
expect(result).toEqual({});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('expected a JSON object'));
warnSpy.mockRestore();
});

it('skips auth-critical header "authorization" with a warning', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('{"authorization":"******","x-session-id":"sess-1"}');
expect(result).not.toHaveProperty('authorization');
expect(result['x-session-id']).toBe('sess-1');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('auth-critical'));
warnSpy.mockRestore();
});

it('skips auth-critical header "Authorization" (case-insensitive) with a warning', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('{"Authorization":"******"}');
expect(result).not.toHaveProperty('Authorization');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('auth-critical'));
warnSpy.mockRestore();
});

it('skips auth-critical header "x-api-key" with a warning', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('{"x-api-key":"leaked-key"}');
expect(result).not.toHaveProperty('x-api-key');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('auth-critical'));
warnSpy.mockRestore();
});

it('skips invalid HTTP header names with a warning', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('{"invalid header name":"value","x-valid":"ok"}');
expect(result).not.toHaveProperty('invalid header name');
expect(result['x-valid']).toBe('ok');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('not a valid HTTP header name'));
warnSpy.mockRestore();
});

it('skips entries with non-string values with a warning', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const result = parseByokExtraHeaders('{"x-count":42,"x-session-id":"sess-1"}');
expect(result).not.toHaveProperty('x-count');
expect(result['x-session-id']).toBe('sess-1');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('must be a string'));
warnSpy.mockRestore();
});
});

// ── createCopilotAdapter — AWF_BYOK_EXTRA_HEADERS injection ──────────────────

describe('createCopilotAdapter — AWF_BYOK_EXTRA_HEADERS injection', () => {
const fakeReq = { url: '/v1/chat/completions', method: 'POST', headers: {} };
const fakeModelsReq = { url: '/models', method: 'GET', headers: {} };

it('injects extra BYOK headers on inference request when BYOK API key is set', () => {
const adapter = createCopilotAdapter({
COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123',
AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-42","HTTP-Referer":"https://example.com"}',
});
const headers = adapter.getAuthHeaders(fakeReq);
expect(headers['x-session-id']).toBe('sess-42');
expect(headers['HTTP-Referer']).toBe('https://example.com');
});

it('does not override Authorization or Copilot-Integration-Id with extra headers', () => {
const adapter = createCopilotAdapter({
COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123',
AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-1"}',
});
const headers = adapter.getAuthHeaders(fakeReq);
expect(headers['Authorization']).toBe('Bearer sk-or-v1-abc123');
expect(headers['Copilot-Integration-Id']).toBe('copilot-developer-cli');
});
Comment thread
lpcox marked this conversation as resolved.

it('does NOT inject extra headers when only GitHub OAuth token is set (no BYOK key)', () => {
const adapter = createCopilotAdapter({
COPILOT_GITHUB_TOKEN: 'gho_oauth_token',
AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-42"}',
});
const headers = adapter.getAuthHeaders(fakeReq);
expect(headers['x-session-id']).toBeUndefined();
});

it('does NOT inject extra headers on /models GET when GitHub OAuth token is available', () => {
const adapter = createCopilotAdapter({
COPILOT_GITHUB_TOKEN: 'gho_oauth_token',
COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123',
AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-42"}',
});
// /models GET with GitHub token goes to GitHub Copilot — extra headers must not be sent there
const headers = adapter.getAuthHeaders(fakeModelsReq);
expect(headers['Authorization']).toBe('Bearer gho_oauth_token');
expect(headers['x-session-id']).toBeUndefined();
});

it('injects extra BYOK headers on /models GET when only BYOK API key is set (no GitHub token)', () => {
const adapter = createCopilotAdapter({
COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123',
AWF_BYOK_EXTRA_HEADERS: '{"x-session-id":"sess-42"}',
});
// Without a GitHub token, /models GET uses the BYOK key and goes to the BYOK provider
const headers = adapter.getAuthHeaders(fakeModelsReq);
expect(headers['x-session-id']).toBe('sess-42');
});

it('does not inject extra headers when AWF_BYOK_EXTRA_HEADERS is not set', () => {
const adapter = createCopilotAdapter({ COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123' });
const headers = adapter.getAuthHeaders(fakeReq);
expect(Object.keys(headers)).toEqual(['Authorization', 'Copilot-Integration-Id']);
});

it('ignores invalid AWF_BYOK_EXTRA_HEADERS JSON and still authenticates normally', () => {
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
const adapter = createCopilotAdapter({
COPILOT_PROVIDER_API_KEY: 'sk-or-v1-abc123',
AWF_BYOK_EXTRA_HEADERS: '{bad-json}',
});
const headers = adapter.getAuthHeaders(fakeReq);
expect(headers['Authorization']).toBe('Bearer sk-or-v1-abc123');
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid JSON'));
warnSpy.mockRestore();
});
});

describe('createAnthropicAdapter — OIDC getAuthHeaders', () => {
const fakeReq = { url: '/v1/messages', method: 'POST', headers: {} };

Expand Down
Loading