Skip to content

Commit 8ce7a7d

Browse files
authored
fix(api-proxy): address OIDC review feedback
Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/074a922b-ae9a-4c09-a168-6379e90be199
1 parent 1748d36 commit 8ce7a7d

5 files changed

Lines changed: 164 additions & 65 deletions

File tree

containers/api-proxy/oidc-token-provider.js

Lines changed: 32 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
const https = require('https');
1818
const http = require('http');
19+
const { HttpsProxyAgent } = require('https-proxy-agent');
1920
const { logRequest } = require('./logging');
2021

2122
// Refresh at 75% of token lifetime (Azure tokens typically last 3600s)
@@ -219,8 +220,11 @@ class OidcTokenProvider {
219220

220221
// Schedule proactive refresh
221222
const refreshInSecs = Math.max(
223+
0,
224+
Math.min(
222225
expires_in * REFRESH_FACTOR,
223226
expires_in - MIN_REFRESH_MARGIN_SECS
227+
)
224228
);
225229
this._scheduleRefresh(Math.floor(refreshInSecs * 1000));
226230
}
@@ -262,7 +266,10 @@ class OidcTokenProvider {
262266
return new Promise((resolve, reject) => {
263267
const parsedUrl = new URL(url);
264268
const mod = parsedUrl.protocol === 'https:' ? https : http;
265-
const req = mod.get(url, { headers }, (res) => {
269+
const req = mod.get(url, {
270+
headers,
271+
agent: this._getProxyAgent(parsedUrl),
272+
}, (res) => {
266273
let body = '';
267274
res.on('data', (chunk) => { body += chunk; });
268275
res.on('end', () => resolve({ statusCode: res.statusCode, body }));
@@ -285,60 +292,37 @@ class OidcTokenProvider {
285292
const options = {
286293
method: 'POST',
287294
hostname: parsedUrl.hostname,
288-
port: parsedUrl.port || 443,
295+
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
289296
path: parsedUrl.pathname + parsedUrl.search,
290297
headers: { ...headers, 'Content-Length': Buffer.byteLength(body) },
298+
agent: this._getProxyAgent(parsedUrl),
291299
};
292300

293-
// Use HTTP_PROXY if set (sidecar routes through Squid)
294-
const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY;
295-
let req;
296-
if (proxyUrl && parsedUrl.protocol === 'https:') {
297-
// For HTTPS through proxy, use CONNECT method via http module
298-
const proxy = new URL(proxyUrl);
299-
const connectReq = http.request({
300-
host: proxy.hostname,
301-
port: proxy.port || 3128,
302-
method: 'CONNECT',
303-
path: `${parsedUrl.hostname}:443`,
304-
});
305-
connectReq.on('connect', (connectRes, socket) => {
306-
if (connectRes.statusCode !== 200) {
307-
reject(new Error(`Proxy CONNECT failed: ${connectRes.statusCode}`));
308-
return;
309-
}
310-
req = https.request({
311-
...options,
312-
socket,
313-
agent: false,
314-
}, (res) => {
315-
let responseBody = '';
316-
res.on('data', (chunk) => { responseBody += chunk; });
317-
res.on('end', () => resolve({ statusCode: res.statusCode, body: responseBody }));
318-
});
319-
req.on('error', reject);
320-
req.setTimeout(10_000, () => { req.destroy(new Error('Azure token exchange timeout')); });
321-
req.write(body);
322-
req.end();
323-
});
324-
connectReq.on('error', reject);
325-
connectReq.setTimeout(10_000, () => { connectReq.destroy(new Error('Proxy connect timeout')); });
326-
connectReq.end();
327-
} else {
328-
const mod = parsedUrl.protocol === 'https:' ? https : http;
329-
req = mod.request(options, (res) => {
330-
let responseBody = '';
331-
res.on('data', (chunk) => { responseBody += chunk; });
332-
res.on('end', () => resolve({ statusCode: res.statusCode, body: responseBody }));
333-
});
334-
req.on('error', reject);
335-
req.setTimeout(10_000, () => { req.destroy(new Error('Azure token exchange timeout')); });
336-
req.write(body);
337-
req.end();
338-
}
301+
const mod = parsedUrl.protocol === 'https:' ? https : http;
302+
const req = mod.request(options, (res) => {
303+
let responseBody = '';
304+
res.on('data', (chunk) => { responseBody += chunk; });
305+
res.on('end', () => resolve({ statusCode: res.statusCode, body: responseBody }));
306+
});
307+
req.on('error', reject);
308+
req.setTimeout(10_000, () => { req.destroy(new Error('Azure token exchange timeout')); });
309+
req.write(body);
310+
req.end();
339311
});
340312
}
341313

314+
/**
315+
* Build proxy agent from env vars when configured.
316+
* @param {URL} parsedUrl
317+
* @returns {import('http').Agent|undefined}
318+
*/
319+
_getProxyAgent(parsedUrl) {
320+
const proxyUrl = parsedUrl.protocol === 'https:'
321+
? (process.env.HTTPS_PROXY || process.env.HTTP_PROXY)
322+
: (process.env.HTTP_PROXY || process.env.HTTPS_PROXY);
323+
return proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined;
324+
}
325+
342326
/** @param {number} ms */
343327
_sleep(ms) {
344328
return new Promise(resolve => setTimeout(resolve, ms));

containers/api-proxy/oidc-token-provider.test.js

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@ describe('OidcTokenProvider', () => {
7070
// Override login host to use mock server
7171
provider._loginHost = `127.0.0.1:${serverPort}`;
7272
// Override _httpPost to use http (not https)
73-
const originalPost = provider._httpPost.bind(provider);
7473
provider._httpPost = function (url, body, headers) {
7574
// Rewrite https to http for mock
7675
const httpUrl = url.replace('https://', 'http://');
@@ -173,12 +172,54 @@ describe('OidcTokenProvider', () => {
173172
provider.shutdown();
174173
await new Promise(resolve => failServer.close(resolve));
175174
});
175+
176+
it('should schedule refresh at 75% or 5 minutes-before-expiry, whichever is earlier', async () => {
177+
const provider = new OidcTokenProvider({
178+
requestUrl: 'http://localhost/token',
179+
requestToken: 'test',
180+
tenantId: 'test',
181+
clientId: 'test',
182+
});
183+
184+
provider._mintGitHubOidcToken = jest.fn().mockResolvedValue('oidc-jwt');
185+
provider._exchangeForAzureToken = jest.fn().mockResolvedValue({
186+
access_token: 'azure-token',
187+
expires_in: 600,
188+
});
189+
provider._scheduleRefresh = jest.fn();
190+
191+
await provider._refreshToken();
192+
193+
expect(provider._scheduleRefresh).toHaveBeenCalledWith(300000);
194+
provider.shutdown();
195+
});
196+
197+
it('should schedule immediate refresh when token lifetime is below minimum margin', async () => {
198+
const provider = new OidcTokenProvider({
199+
requestUrl: 'http://localhost/token',
200+
requestToken: 'test',
201+
tenantId: 'test',
202+
clientId: 'test',
203+
});
204+
205+
provider._mintGitHubOidcToken = jest.fn().mockResolvedValue('oidc-jwt');
206+
provider._exchangeForAzureToken = jest.fn().mockResolvedValue({
207+
access_token: 'azure-token',
208+
expires_in: 240,
209+
});
210+
provider._scheduleRefresh = jest.fn();
211+
212+
await provider._refreshToken();
213+
214+
expect(provider._scheduleRefresh).toHaveBeenCalledWith(0);
215+
provider.shutdown();
216+
});
176217
});
177218

178219
describe('OpenAI adapter with OIDC', () => {
179220
const { createOpenAIAdapter } = require('./providers/openai');
180221

181-
it('should report enabled when OIDC is configured', () => {
222+
it('should report disabled until OIDC token is initialized', () => {
182223
const adapter = createOpenAIAdapter({
183224
AWF_AUTH_TYPE: 'github-oidc',
184225
ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token',
@@ -188,7 +229,7 @@ describe('OpenAI adapter with OIDC', () => {
188229
OPENAI_API_TARGET: 'my-resource.openai.azure.com',
189230
});
190231

191-
expect(adapter.isEnabled()).toBe(true);
232+
expect(adapter.isEnabled()).toBe(false);
192233
expect(adapter.getOidcProvider()).not.toBeNull();
193234
expect(adapter.getValidationProbe()).toEqual({ skip: true, reason: 'OIDC auth; validation via token acquisition' });
194235
expect(adapter.getModelsFetchConfig()).toBeNull();
@@ -217,7 +258,7 @@ describe('OpenAI adapter with OIDC', () => {
217258
expect(adapter.getOidcProvider()).toBeNull();
218259
});
219260

220-
it('should return oidc-token-unavailable when OIDC token not yet acquired', () => {
261+
it('should return empty auth headers when OIDC token is not yet acquired', () => {
221262
const adapter = createOpenAIAdapter({
222263
AWF_AUTH_TYPE: 'github-oidc',
223264
ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token',
@@ -228,7 +269,27 @@ describe('OpenAI adapter with OIDC', () => {
228269

229270
// Before initialization, token should be unavailable
230271
const headers = adapter.getAuthHeaders({});
231-
expect(headers['Authorization']).toBe('Bearer oidc-token-unavailable');
272+
expect(headers).toEqual({});
273+
274+
adapter.getOidcProvider().shutdown();
275+
});
276+
277+
it('should inject only Authorization header in OIDC mode', () => {
278+
const adapter = createOpenAIAdapter({
279+
AWF_AUTH_TYPE: 'github-oidc',
280+
ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token',
281+
ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-token',
282+
AWF_AUTH_AZURE_TENANT_ID: 'test-tenant',
283+
AWF_AUTH_AZURE_CLIENT_ID: 'test-client',
284+
});
285+
286+
const provider = adapter.getOidcProvider();
287+
provider._cachedToken = 'azure-ad-token';
288+
provider._expiresAt = Math.floor(Date.now() / 1000) + 600;
289+
290+
const headers = adapter.getAuthHeaders({});
291+
expect(headers).toEqual({ Authorization: 'Bearer azure-ad-token' });
292+
expect(headers['api-key']).toBeUndefined();
232293

233294
adapter.getOidcProvider().shutdown();
234295
});

containers/api-proxy/providers/openai.js

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ function createOpenAIAdapter(env, deps = {}) {
5656
});
5757
}
5858
}
59-
const oidcEnabled = !!oidcProvider;
59+
const oidcConfigured = !!oidcProvider;
6060

6161
return {
6262
name: 'openai',
@@ -74,7 +74,7 @@ function createOpenAIAdapter(env, deps = {}) {
7474
/** Port 10000 always counts toward the startup validation latch. */
7575
participatesInValidation: true,
7676

77-
isEnabled() { return !!apiKey || oidcEnabled; },
77+
isEnabled() { return !!apiKey || !!oidcProvider?.isReady(); },
7878
getTargetHost() { return rawTarget; },
7979
getBasePath() { return basePath; },
8080

@@ -90,11 +90,9 @@ function createOpenAIAdapter(env, deps = {}) {
9090
if (oidcProvider) {
9191
const token = oidcProvider.getToken();
9292
if (token) {
93-
return { 'Authorization': `Bearer ${token}`, 'api-key': token };
93+
return { 'Authorization': `Bearer ${token}` };
9494
}
95-
// Token not yet available (pre-init or refresh failure)
96-
// Return empty — server will return 503 via isEnabled() short-circuit
97-
return { 'Authorization': 'Bearer oidc-token-unavailable' };
95+
return {};
9896
}
9997
return { 'Authorization': `Bearer ${apiKey}` };
10098
},
@@ -109,7 +107,7 @@ function createOpenAIAdapter(env, deps = {}) {
109107
* @returns {{ url: string, opts: object }|{ skip: true, reason: string }|null}
110108
*/
111109
getValidationProbe() {
112-
if (oidcEnabled) {
110+
if (oidcConfigured) {
113111
return { skip: true, reason: 'OIDC auth; validation via token acquisition' };
114112
}
115113
if (!apiKey) return null;
@@ -130,7 +128,7 @@ function createOpenAIAdapter(env, deps = {}) {
130128
* @returns {{ url: string, opts: object, cacheKey: string }|null}
131129
*/
132130
getModelsFetchConfig() {
133-
if (oidcEnabled) return null; // Models fetched after OIDC init
131+
if (oidcConfigured) return null; // Models fetched after OIDC init
134132
if (!apiKey) return null;
135133
const modelsPath = basePath ? `${basePath}/models` : '/v1/models';
136134
return {
@@ -145,15 +143,21 @@ function createOpenAIAdapter(env, deps = {}) {
145143
provider: 'openai',
146144
port: 10000,
147145
base_url: 'http://api-proxy:10000',
148-
configured: !!apiKey || oidcEnabled,
149-
auth_type: oidcEnabled ? 'github-oidc' : 'static-key',
146+
configured: !!apiKey || oidcConfigured,
147+
auth_type: oidcConfigured ? 'github-oidc' : 'static-key',
150148
models_cache_key: 'openai',
151149
models_url: 'http://api-proxy:10000/v1/models',
152150
};
153151
},
154152

155153
/** Response returned when port 10000 receives a proxy request but no key is set. */
156154
getUnconfiguredResponse() {
155+
if (oidcConfigured) {
156+
return {
157+
statusCode: 503,
158+
body: { error: 'OpenAI OIDC token unavailable; retry shortly' },
159+
};
160+
}
157161
return {
158162
statusCode: 404,
159163
body: { error: 'OpenAI proxy not configured (no OPENAI_API_KEY or OIDC auth)' },

src/services/api-proxy-service.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,55 @@ describe('API proxy sidecar', () => {
492492
expect(env.AWF_ENABLE_OPENCODE).toBeUndefined();
493493
});
494494

495+
describe('OIDC runtime env forwarding', () => {
496+
let savedEnv: Record<string, string | undefined>;
497+
const oidcVars = [
498+
'AWF_AUTH_TYPE',
499+
'ACTIONS_ID_TOKEN_REQUEST_URL',
500+
'ACTIONS_ID_TOKEN_REQUEST_TOKEN',
501+
];
502+
503+
beforeEach(() => {
504+
savedEnv = {};
505+
for (const key of oidcVars) {
506+
savedEnv[key] = process.env[key];
507+
delete process.env[key];
508+
}
509+
});
510+
511+
afterEach(() => {
512+
for (const key of oidcVars) {
513+
if (savedEnv[key] !== undefined) {
514+
process.env[key] = savedEnv[key];
515+
} else {
516+
delete process.env[key];
517+
}
518+
}
519+
});
520+
521+
it('should forward ACTIONS_ID_TOKEN_REQUEST_* when AWF_AUTH_TYPE normalizes to github-oidc', () => {
522+
process.env.AWF_AUTH_TYPE = ' GitHub-OIDC ';
523+
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://actions.local/token';
524+
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'runtime-token';
525+
const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' };
526+
const result = generateDockerCompose(config, mockNetworkConfigWithProxy);
527+
const env = result.services['api-proxy'].environment as Record<string, string>;
528+
expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBe('https://actions.local/token');
529+
expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBe('runtime-token');
530+
});
531+
532+
it('should not forward ACTIONS_ID_TOKEN_REQUEST_* when AWF_AUTH_TYPE is not github-oidc', () => {
533+
process.env.AWF_AUTH_TYPE = 'api-key';
534+
process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://actions.local/token';
535+
process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'runtime-token';
536+
const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' };
537+
const result = generateDockerCompose(config, mockNetworkConfigWithProxy);
538+
const env = result.services['api-proxy'].environment as Record<string, string>;
539+
expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined();
540+
expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined();
541+
});
542+
});
543+
495544
describe('AWF_ANTHROPIC_* env var forwarding', () => {
496545
let savedEnv: Record<string, string | undefined>;
497546
const anthropicVars = [

src/services/api-proxy-service.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export interface ApiProxyServiceParams {
3434
export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBuildResult {
3535
const { config, networkConfig, apiProxyLogsPath, imageConfig } = params;
3636
const { useGHCR, registry, parsedTag, projectRoot } = imageConfig;
37+
const normalizedAuthType = (process.env.AWF_AUTH_TYPE || '').trim().toLowerCase();
3738

3839
if (!networkConfig.proxyIp) {
3940
throw new Error('buildApiProxyService: networkConfig.proxyIp is required');
@@ -111,10 +112,10 @@ export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBui
111112
...(process.env.AWF_AUTH_AZURE_SCOPE && { AWF_AUTH_AZURE_SCOPE: process.env.AWF_AUTH_AZURE_SCOPE }),
112113
...(process.env.AWF_AUTH_AZURE_CLOUD && { AWF_AUTH_AZURE_CLOUD: process.env.AWF_AUTH_AZURE_CLOUD }),
113114
// GitHub Actions OIDC runtime tokens (needed by OIDC token provider in api-proxy)
114-
...(process.env.AWF_AUTH_TYPE === 'github-oidc' && process.env.ACTIONS_ID_TOKEN_REQUEST_URL && {
115+
...(normalizedAuthType === 'github-oidc' && process.env.ACTIONS_ID_TOKEN_REQUEST_URL && {
115116
ACTIONS_ID_TOKEN_REQUEST_URL: process.env.ACTIONS_ID_TOKEN_REQUEST_URL,
116117
}),
117-
...(process.env.AWF_AUTH_TYPE === 'github-oidc' && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN && {
118+
...(normalizedAuthType === 'github-oidc' && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN && {
118119
ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN,
119120
}),
120121
// Anthropic request optimisations (all opt-in via env vars on the host)

0 commit comments

Comments
 (0)