From 1748d36d16622c427b7198844ae4701a01499e3c Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 5 May 2026 15:47:56 -0700 Subject: [PATCH 1/2] feat(api-proxy): add OIDC authentication for Azure OpenAI (Entra-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GitHub Actions OIDC → Azure AD workload identity federation support to the api-proxy sidecar. This enables BYOK mode with Azure OpenAI deployments that have API keys disabled (Entra-only authentication). Components: - oidc-token-provider.js: Mints GitHub OIDC token, exchanges for Azure AD access token, caches with proactive background refresh - OpenAI adapter: Now supports AWF_AUTH_TYPE=github-oidc as alternative to static OPENAI_API_KEY - api-proxy-service.ts: Forwards OIDC env vars to sidecar container - server.js: Initializes OIDC providers on startup, cleans up on shutdown New env vars (set by gh-aw when engine.auth is configured): - AWF_AUTH_TYPE=github-oidc - AWF_AUTH_AZURE_TENANT_ID - AWF_AUTH_AZURE_CLIENT_ID - AWF_AUTH_OIDC_AUDIENCE (default: api://AzureADTokenExchange) - AWF_AUTH_AZURE_SCOPE (default: https://cognitiveservices.azure.com/.default) - AWF_AUTH_AZURE_CLOUD (public|usgovernment|china) - ACTIONS_ID_TOKEN_REQUEST_URL (from Actions runtime) - ACTIONS_ID_TOKEN_REQUEST_TOKEN (from Actions runtime) Closes #2544 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- containers/api-proxy/Dockerfile | 2 +- containers/api-proxy/oidc-token-provider.js | 348 ++++++++++++++++++ .../api-proxy/oidc-token-provider.test.js | 235 ++++++++++++ containers/api-proxy/providers/openai.js | 57 ++- containers/api-proxy/server.js | 55 ++- src/services/api-proxy-service.ts | 14 + 6 files changed, 695 insertions(+), 16 deletions(-) create mode 100644 containers/api-proxy/oidc-token-provider.js create mode 100644 containers/api-proxy/oidc-token-provider.test.js diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 6e75c777c..94a3b1049 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -17,7 +17,7 @@ RUN npm ci --omit=dev # Copy application files COPY server.js logging.js metrics.js rate-limiter.js token-tracker.js \ model-resolver.js proxy-utils.js anthropic-transforms.js \ - proxy-request.js model-discovery.js management.js ./ + proxy-request.js model-discovery.js management.js oidc-token-provider.js ./ COPY providers/ ./providers/ # Create non-root user diff --git a/containers/api-proxy/oidc-token-provider.js b/containers/api-proxy/oidc-token-provider.js new file mode 100644 index 000000000..2e8b747cc --- /dev/null +++ b/containers/api-proxy/oidc-token-provider.js @@ -0,0 +1,348 @@ +'use strict'; + +/** + * OIDC Token Provider for Azure Workload Identity Federation. + * + * Mints a GitHub Actions OIDC token, exchanges it for an Azure AD access token + * via workload identity federation, caches the result, and proactively refreshes + * before expiry. + * + * Token flow: + * 1. Request GitHub OIDC JWT from Actions runtime (with audience for Azure) + * 2. Exchange JWT for Azure AD access token via token endpoint + * 3. Cache token, schedule refresh at 75% of lifetime + * 4. Serve cached token synchronously via getToken() + */ + +const https = require('https'); +const http = require('http'); +const { logRequest } = require('./logging'); + +// Refresh at 75% of token lifetime (Azure tokens typically last 3600s) +const REFRESH_FACTOR = 0.75; +// Minimum seconds before expiry to trigger refresh +const MIN_REFRESH_MARGIN_SECS = 300; +// Retry delay after failed refresh (ms) +const REFRESH_RETRY_DELAY_MS = 30_000; +// Maximum retries for initial token acquisition +const MAX_INIT_RETRIES = 3; + +/** + * @typedef {Object} OidcTokenProviderConfig + * @property {string} requestUrl - ACTIONS_ID_TOKEN_REQUEST_URL + * @property {string} requestToken - ACTIONS_ID_TOKEN_REQUEST_TOKEN + * @property {string} tenantId - Azure AD tenant ID + * @property {string} clientId - Azure AD app/client ID (federated credential) + * @property {string} [oidcAudience] - Audience for GitHub OIDC token (default: api://AzureADTokenExchange) + * @property {string} [azureScope] - Azure token scope (default: https://cognitiveservices.azure.com/.default) + * @property {string} [azureCloud] - Azure cloud (public, usgovernment, china) for login endpoint + * @property {number} [retryDelayMs] - Retry delay after failed refresh (default: 30000) + * @property {number} [maxInitRetries] - Maximum retries for initial token acquisition (default: 3) + */ + +class OidcTokenProvider { + /** + * @param {OidcTokenProviderConfig} config + */ + constructor(config) { + this._requestUrl = config.requestUrl; + this._requestToken = config.requestToken; + this._tenantId = config.tenantId; + this._clientId = config.clientId; + this._oidcAudience = config.oidcAudience || 'api://AzureADTokenExchange'; + this._azureScope = config.azureScope || 'https://cognitiveservices.azure.com/.default'; + this._loginHost = this._resolveLoginHost(config.azureCloud); + this._retryDelayMs = config.retryDelayMs ?? REFRESH_RETRY_DELAY_MS; + this._maxInitRetries = config.maxInitRetries ?? MAX_INIT_RETRIES; + + // Token state + this._cachedToken = null; + this._expiresAt = 0; // Unix timestamp (seconds) + this._refreshTimer = null; + this._refreshInFlight = null; + this._initialized = false; + this._initError = null; + } + + /** + * Resolve the Azure login endpoint for the specified cloud. + * @param {string} [cloud] + * @returns {string} + */ + _resolveLoginHost(cloud) { + switch (cloud) { + case 'usgovernment': return 'login.microsoftonline.us'; + case 'china': return 'login.chinacloudapi.cn'; + default: return 'login.microsoftonline.com'; + } + } + + /** + * Initialize the token provider by acquiring the first token. + * Must be called (and awaited) before getToken() is usable. + * @returns {Promise} + */ + async initialize() { + for (let attempt = 1; attempt <= this._maxInitRetries; attempt++) { + try { + await this._refreshToken(); + this._initialized = true; + this._initError = null; + logRequest('info', 'oidc_init_success', { + tenant_id: this._tenantId, + client_id: this._clientId, + scope: this._azureScope, + expires_in_secs: this._expiresAt - Math.floor(Date.now() / 1000), + }); + return; + } catch (err) { + this._initError = err; + logRequest('warn', 'oidc_init_retry', { + attempt, + max_retries: this._maxInitRetries, + error: err.message, + }); + if (attempt < this._maxInitRetries) { + await this._sleep(this._retryDelayMs * attempt); + } + } + } + // All retries failed — log but don't throw; getToken() will return null + logRequest('error', 'oidc_init_failed', { + error: this._initError?.message, + tenant_id: this._tenantId, + client_id: this._clientId, + }); + } + + /** + * Get the current cached token synchronously. + * Returns null if no valid token is available. + * @returns {string|null} + */ + getToken() { + const now = Math.floor(Date.now() / 1000); + if (this._cachedToken && this._expiresAt > now) { + return this._cachedToken; + } + // Token expired and refresh hasn't replaced it — trigger emergency refresh + if (!this._refreshInFlight) { + this._scheduleRefresh(0); + } + return null; + } + + /** + * Whether the provider has a usable token. + * @returns {boolean} + */ + isReady() { + const now = Math.floor(Date.now() / 1000); + return !!(this._cachedToken && this._expiresAt > now); + } + + /** + * Stop background refresh timers. + */ + shutdown() { + if (this._refreshTimer) { + clearTimeout(this._refreshTimer); + this._refreshTimer = null; + } + } + + /** + * Mint a GitHub OIDC token with the specified audience. + * @returns {Promise} The GitHub-issued JWT + */ + async _mintGitHubOidcToken() { + const url = new URL(this._requestUrl); + url.searchParams.set('audience', this._oidcAudience); + + const response = await this._httpGet(url.toString(), { + 'Authorization': `Bearer ${this._requestToken}`, + 'Accept': 'application/json', + }); + + if (response.statusCode !== 200) { + throw new Error(`GitHub OIDC token request failed: HTTP ${response.statusCode} — ${response.body}`); + } + + const data = JSON.parse(response.body); + if (!data.value) { + throw new Error('GitHub OIDC response missing "value" field'); + } + return data.value; + } + + /** + * Exchange a GitHub OIDC JWT for an Azure AD access token via workload identity federation. + * @param {string} oidcJwt - The GitHub-issued JWT + * @returns {Promise<{access_token: string, expires_in: number}>} + */ + async _exchangeForAzureToken(oidcJwt) { + const tokenEndpoint = `https://${this._loginHost}/${this._tenantId}/oauth2/v2.0/token`; + + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: this._clientId, + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: oidcJwt, + scope: this._azureScope, + }).toString(); + + const response = await this._httpPost(tokenEndpoint, body, { + 'Content-Type': 'application/x-www-form-urlencoded', + }); + + if (response.statusCode !== 200) { + throw new Error(`Azure token exchange failed: HTTP ${response.statusCode} — ${response.body}`); + } + + const data = JSON.parse(response.body); + if (!data.access_token) { + throw new Error('Azure token response missing "access_token" field'); + } + return { access_token: data.access_token, expires_in: data.expires_in || 3600 }; + } + + /** + * Perform full token refresh: mint GitHub OIDC → exchange for Azure AD. + */ + async _refreshToken() { + const oidcJwt = await this._mintGitHubOidcToken(); + const { access_token, expires_in } = await this._exchangeForAzureToken(oidcJwt); + + const now = Math.floor(Date.now() / 1000); + this._cachedToken = access_token; + this._expiresAt = now + expires_in; + + // Schedule proactive refresh + const refreshInSecs = Math.max( + expires_in * REFRESH_FACTOR, + expires_in - MIN_REFRESH_MARGIN_SECS + ); + this._scheduleRefresh(Math.floor(refreshInSecs * 1000)); + } + + /** + * Schedule a background token refresh. + * @param {number} delayMs + */ + _scheduleRefresh(delayMs) { + if (this._refreshTimer) clearTimeout(this._refreshTimer); + this._refreshTimer = setTimeout(() => { + this._refreshInFlight = this._refreshToken() + .then(() => { + logRequest('info', 'oidc_refresh_success', { + expires_in_secs: this._expiresAt - Math.floor(Date.now() / 1000), + }); + }) + .catch((err) => { + logRequest('error', 'oidc_refresh_failed', { error: err.message }); + // Retry after delay if token is still valid + const now = Math.floor(Date.now() / 1000); + if (this._expiresAt > now) { + this._scheduleRefresh(this._retryDelayMs); + } + }) + .finally(() => { this._refreshInFlight = null; }); + }, delayMs); + // Don't let refresh timer keep the process alive + if (this._refreshTimer.unref) this._refreshTimer.unref(); + } + + /** + * HTTP GET helper. + * @param {string} url + * @param {Record} headers + * @returns {Promise<{statusCode: number, body: string}>} + */ + _httpGet(url, headers) { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const mod = parsedUrl.protocol === 'https:' ? https : http; + const req = mod.get(url, { headers }, (res) => { + let body = ''; + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => resolve({ statusCode: res.statusCode, body })); + }); + req.on('error', reject); + req.setTimeout(10_000, () => { req.destroy(new Error('OIDC request timeout')); }); + }); + } + + /** + * HTTP POST helper. + * @param {string} url + * @param {string} body + * @param {Record} headers + * @returns {Promise<{statusCode: number, body: string}>} + */ + _httpPost(url, body, headers) { + return new Promise((resolve, reject) => { + const parsedUrl = new URL(url); + const options = { + method: 'POST', + hostname: parsedUrl.hostname, + port: parsedUrl.port || 443, + path: parsedUrl.pathname + parsedUrl.search, + headers: { ...headers, 'Content-Length': Buffer.byteLength(body) }, + }; + + // Use HTTP_PROXY if set (sidecar routes through Squid) + const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY; + let req; + if (proxyUrl && parsedUrl.protocol === 'https:') { + // For HTTPS through proxy, use CONNECT method via http module + const proxy = new URL(proxyUrl); + const connectReq = http.request({ + host: proxy.hostname, + port: proxy.port || 3128, + method: 'CONNECT', + path: `${parsedUrl.hostname}:443`, + }); + connectReq.on('connect', (connectRes, socket) => { + if (connectRes.statusCode !== 200) { + reject(new Error(`Proxy CONNECT failed: ${connectRes.statusCode}`)); + return; + } + req = https.request({ + ...options, + socket, + agent: false, + }, (res) => { + let responseBody = ''; + res.on('data', (chunk) => { responseBody += chunk; }); + res.on('end', () => resolve({ statusCode: res.statusCode, body: responseBody })); + }); + req.on('error', reject); + req.setTimeout(10_000, () => { req.destroy(new Error('Azure token exchange timeout')); }); + req.write(body); + req.end(); + }); + connectReq.on('error', reject); + connectReq.setTimeout(10_000, () => { connectReq.destroy(new Error('Proxy connect timeout')); }); + connectReq.end(); + } else { + const mod = parsedUrl.protocol === 'https:' ? https : http; + req = mod.request(options, (res) => { + let responseBody = ''; + res.on('data', (chunk) => { responseBody += chunk; }); + res.on('end', () => resolve({ statusCode: res.statusCode, body: responseBody })); + }); + req.on('error', reject); + req.setTimeout(10_000, () => { req.destroy(new Error('Azure token exchange timeout')); }); + req.write(body); + req.end(); + } + }); + } + + /** @param {number} ms */ + _sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} + +module.exports = { OidcTokenProvider }; diff --git a/containers/api-proxy/oidc-token-provider.test.js b/containers/api-proxy/oidc-token-provider.test.js new file mode 100644 index 000000000..bd2744bb3 --- /dev/null +++ b/containers/api-proxy/oidc-token-provider.test.js @@ -0,0 +1,235 @@ +'use strict'; + +const http = require('http'); +const { OidcTokenProvider } = require('./oidc-token-provider'); + +// Helper to create a mock HTTP server that responds to token requests +function createMockOidcServer(handlers = {}) { + const server = http.createServer((req, res) => { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + const url = new URL(req.url, `http://localhost`); + + // GitHub OIDC token endpoint + if (url.pathname === '/token' && req.method === 'GET') { + const handler = handlers.oidcToken || (() => ({ + statusCode: 200, + body: JSON.stringify({ value: 'mock-github-oidc-jwt', count: 1 }), + })); + const result = handler(url, req); + res.writeHead(result.statusCode, { 'Content-Type': 'application/json' }); + res.end(result.body); + return; + } + + // Azure token exchange endpoint + if (url.pathname.includes('/oauth2/v2.0/token') && req.method === 'POST') { + const handler = handlers.azureToken || (() => ({ + statusCode: 200, + body: JSON.stringify({ access_token: 'mock-azure-ad-token', expires_in: 3600 }), + })); + const result = handler(body, req); + res.writeHead(result.statusCode, { 'Content-Type': 'application/json' }); + res.end(result.body); + return; + } + + res.writeHead(404); + res.end('Not found'); + }); + }); + return server; +} + +describe('OidcTokenProvider', () => { + let mockServer; + let serverPort; + + beforeAll((done) => { + mockServer = createMockOidcServer(); + mockServer.listen(0, '127.0.0.1', () => { + serverPort = mockServer.address().port; + done(); + }); + }); + + afterAll((done) => { + mockServer.close(done); + }); + + it('should mint GitHub OIDC token and exchange for Azure AD token', async () => { + const provider = new OidcTokenProvider({ + requestUrl: `http://127.0.0.1:${serverPort}/token`, + requestToken: 'mock-request-token', + tenantId: 'test-tenant-id', + clientId: 'test-client-id', + oidcAudience: 'api://AzureADTokenExchange', + azureScope: 'https://cognitiveservices.azure.com/.default', + }); + // Override login host to use mock server + provider._loginHost = `127.0.0.1:${serverPort}`; + // Override _httpPost to use http (not https) + const originalPost = provider._httpPost.bind(provider); + provider._httpPost = function (url, body, headers) { + // Rewrite https to http for mock + const httpUrl = url.replace('https://', 'http://'); + return new Promise((resolve, reject) => { + const parsedUrl = new URL(httpUrl); + const req = http.request({ + method: 'POST', + hostname: parsedUrl.hostname, + port: parsedUrl.port, + path: parsedUrl.pathname + parsedUrl.search, + headers: { ...headers, 'Content-Length': Buffer.byteLength(body) }, + }, (res) => { + let responseBody = ''; + res.on('data', (chunk) => { responseBody += chunk; }); + res.on('end', () => resolve({ statusCode: res.statusCode, body: responseBody })); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); + }; + + await provider.initialize(); + + expect(provider.isReady()).toBe(true); + const token = provider.getToken(); + expect(token).toBe('mock-azure-ad-token'); + + provider.shutdown(); + }); + + it('should return null when not initialized', () => { + const provider = new OidcTokenProvider({ + requestUrl: 'http://localhost:0/token', + requestToken: 'test', + tenantId: 'test', + clientId: 'test', + }); + + expect(provider.isReady()).toBe(false); + expect(provider.getToken()).toBeNull(); + provider.shutdown(); + }); + + it('should resolve correct login host for sovereign clouds', () => { + const providerPublic = new OidcTokenProvider({ + requestUrl: 'http://localhost/token', + requestToken: 'test', + tenantId: 'test', + clientId: 'test', + }); + expect(providerPublic._loginHost).toBe('login.microsoftonline.com'); + + const providerGov = new OidcTokenProvider({ + requestUrl: 'http://localhost/token', + requestToken: 'test', + tenantId: 'test', + clientId: 'test', + azureCloud: 'usgovernment', + }); + expect(providerGov._loginHost).toBe('login.microsoftonline.us'); + + const providerChina = new OidcTokenProvider({ + requestUrl: 'http://localhost/token', + requestToken: 'test', + tenantId: 'test', + clientId: 'test', + azureCloud: 'china', + }); + expect(providerChina._loginHost).toBe('login.chinacloudapi.cn'); + + providerPublic.shutdown(); + providerGov.shutdown(); + providerChina.shutdown(); + }); + + it('should handle GitHub OIDC token failure gracefully', async () => { + const failServer = http.createServer((req, res) => { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'unauthorized' })); + }); + + await new Promise(resolve => failServer.listen(0, '127.0.0.1', resolve)); + const failPort = failServer.address().port; + + const provider = new OidcTokenProvider({ + requestUrl: `http://127.0.0.1:${failPort}/token`, + requestToken: 'bad-token', + tenantId: 'test', + clientId: 'test', + retryDelayMs: 10, // Fast retries for testing + maxInitRetries: 2, + }); + + await provider.initialize(); // Should not throw, just log + + expect(provider.isReady()).toBe(false); + expect(provider.getToken()).toBeNull(); + + provider.shutdown(); + await new Promise(resolve => failServer.close(resolve)); + }); +}); + +describe('OpenAI adapter with OIDC', () => { + const { createOpenAIAdapter } = require('./providers/openai'); + + it('should report enabled when OIDC is configured', () => { + const adapter = createOpenAIAdapter({ + AWF_AUTH_TYPE: 'github-oidc', + ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-token', + AWF_AUTH_AZURE_TENANT_ID: 'test-tenant', + AWF_AUTH_AZURE_CLIENT_ID: 'test-client', + OPENAI_API_TARGET: 'my-resource.openai.azure.com', + }); + + expect(adapter.isEnabled()).toBe(true); + expect(adapter.getOidcProvider()).not.toBeNull(); + expect(adapter.getValidationProbe()).toEqual({ skip: true, reason: 'OIDC auth; validation via token acquisition' }); + expect(adapter.getModelsFetchConfig()).toBeNull(); + expect(adapter.getReflectionInfo().auth_type).toBe('github-oidc'); + + adapter.getOidcProvider().shutdown(); + }); + + it('should not create OIDC provider when auth type is not github-oidc', () => { + const adapter = createOpenAIAdapter({ + OPENAI_API_KEY: 'sk-test', + }); + + expect(adapter.isEnabled()).toBe(true); + expect(adapter.getOidcProvider()).toBeNull(); + expect(adapter.getReflectionInfo().auth_type).toBe('static-key'); + }); + + it('should not create OIDC provider when required vars are missing', () => { + const adapter = createOpenAIAdapter({ + AWF_AUTH_TYPE: 'github-oidc', + // Missing ACTIONS_ID_TOKEN_REQUEST_URL, etc. + }); + + expect(adapter.isEnabled()).toBe(false); + expect(adapter.getOidcProvider()).toBeNull(); + }); + + it('should return oidc-token-unavailable when OIDC token not yet acquired', () => { + const adapter = createOpenAIAdapter({ + AWF_AUTH_TYPE: 'github-oidc', + ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-token', + AWF_AUTH_AZURE_TENANT_ID: 'test-tenant', + AWF_AUTH_AZURE_CLIENT_ID: 'test-client', + }); + + // Before initialization, token should be unavailable + const headers = adapter.getAuthHeaders({}); + expect(headers['Authorization']).toBe('Bearer oidc-token-unavailable'); + + adapter.getOidcProvider().shutdown(); + }); +}); diff --git a/containers/api-proxy/providers/openai.js b/containers/api-proxy/providers/openai.js index 8df7bac9e..b9b6df8dd 100644 --- a/containers/api-proxy/providers/openai.js +++ b/containers/api-proxy/providers/openai.js @@ -4,13 +4,14 @@ * OpenAI provider adapter. * * Port: 10000 (also serves as the management port for /health, /metrics, /reflect) - * Auth: Bearer token via Authorization header - * Credentials: OPENAI_API_KEY + * Auth: Bearer token via Authorization header (static key or OIDC) + * Credentials: OPENAI_API_KEY or AWF_AUTH_TYPE=github-oidc (for Azure OpenAI with Entra) * Target: OPENAI_API_TARGET (default: api.openai.com) * Base path: OPENAI_API_BASE_PATH (default: /v1 for the public endpoint) */ const { createBaseAdapterConfig } = require('../proxy-utils'); +const { OidcTokenProvider } = require('../oidc-token-provider'); /** * Create the OpenAI provider adapter. @@ -34,6 +35,29 @@ function createOpenAIAdapter(env, deps = {}) { const bodyTransform = deps.bodyTransform || null; + // OIDC auth strategy for Azure OpenAI (Entra-only deployments) + const authType = (env.AWF_AUTH_TYPE || '').trim().toLowerCase(); + let oidcProvider = null; + if (authType === 'github-oidc') { + const requestUrl = env.ACTIONS_ID_TOKEN_REQUEST_URL; + const requestToken = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + const tenantId = env.AWF_AUTH_AZURE_TENANT_ID; + const clientId = env.AWF_AUTH_AZURE_CLIENT_ID; + + if (requestUrl && requestToken && tenantId && clientId) { + oidcProvider = new OidcTokenProvider({ + requestUrl, + requestToken, + tenantId, + clientId, + oidcAudience: env.AWF_AUTH_OIDC_AUDIENCE || 'api://AzureADTokenExchange', + azureScope: env.AWF_AUTH_AZURE_SCOPE || 'https://cognitiveservices.azure.com/.default', + azureCloud: env.AWF_AUTH_AZURE_CLOUD, + }); + } + } + const oidcEnabled = !!oidcProvider; + return { name: 'openai', port: 10000, @@ -50,11 +74,28 @@ function createOpenAIAdapter(env, deps = {}) { /** Port 10000 always counts toward the startup validation latch. */ participatesInValidation: true, - isEnabled() { return !!apiKey; }, + isEnabled() { return !!apiKey || oidcEnabled; }, getTargetHost() { return rawTarget; }, getBasePath() { return basePath; }, + /** + * Get the OIDC token provider (if configured). + * Used by server.js to initialize OIDC on startup. + * @returns {OidcTokenProvider|null} + */ + getOidcProvider() { return oidcProvider; }, + getAuthHeaders() { + // OIDC takes precedence when configured + if (oidcProvider) { + const token = oidcProvider.getToken(); + if (token) { + return { 'Authorization': `Bearer ${token}`, 'api-key': token }; + } + // Token not yet available (pre-init or refresh failure) + // Return empty — server will return 503 via isEnabled() short-circuit + return { 'Authorization': 'Bearer oidc-token-unavailable' }; + } return { 'Authorization': `Bearer ${apiKey}` }; }, @@ -63,10 +104,14 @@ function createOpenAIAdapter(env, deps = {}) { /** * Returns the validation probe config, or null to skip. * Custom targets are skipped — we don't know their probe endpoints. + * OIDC-auth targets are skipped — validation requires an async token mint. * * @returns {{ url: string, opts: object }|{ skip: true, reason: string }|null} */ getValidationProbe() { + if (oidcEnabled) { + return { skip: true, reason: 'OIDC auth; validation via token acquisition' }; + } if (!apiKey) return null; if (rawTarget !== 'api.openai.com') { return { skip: true, reason: `Custom target ${rawTarget}; validation skipped` }; @@ -85,6 +130,7 @@ function createOpenAIAdapter(env, deps = {}) { * @returns {{ url: string, opts: object, cacheKey: string }|null} */ getModelsFetchConfig() { + if (oidcEnabled) return null; // Models fetched after OIDC init if (!apiKey) return null; const modelsPath = basePath ? `${basePath}/models` : '/v1/models'; return { @@ -99,7 +145,8 @@ function createOpenAIAdapter(env, deps = {}) { provider: 'openai', port: 10000, base_url: 'http://api-proxy:10000', - configured: !!apiKey, + configured: !!apiKey || oidcEnabled, + auth_type: oidcEnabled ? 'github-oidc' : 'static-key', models_cache_key: 'openai', models_url: 'http://api-proxy:10000/v1/models', }; @@ -109,7 +156,7 @@ function createOpenAIAdapter(env, deps = {}) { getUnconfiguredResponse() { return { statusCode: 404, - body: { error: 'OpenAI proxy not configured (no OPENAI_API_KEY)' }, + body: { error: 'OpenAI proxy not configured (no OPENAI_API_KEY or OIDC auth)' }, }; }, }; diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 988546c08..38cb2413a 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -580,6 +580,27 @@ if (require.main === module) { providers_configured: registeredAdapters.filter(a => a.isEnabled()).map(a => a.name), }); + // ── Initialize OIDC token providers (if any adapter uses them) ──────────── + const oidcInitPromises = []; + for (const adapter of registeredAdapters) { + if (typeof adapter.getOidcProvider === 'function') { + const provider = adapter.getOidcProvider(); + if (provider) { + logRequest('info', 'oidc_startup', { + message: `Initializing OIDC token provider for ${adapter.name}`, + }); + oidcInitPromises.push( + provider.initialize().catch((err) => { + logRequest('error', 'oidc_startup_failed', { + adapter: adapter.name, + error: String(err), + }); + }) + ); + } + } + } + // Determine which adapters to bind and count validation participants const adaptersToStart = registeredAdapters.filter(a => a.alwaysBind || a.isEnabled()); const expectedListeners = adaptersToStart.filter(a => a.participatesInValidation).length; @@ -591,16 +612,20 @@ if (require.main === module) { logRequest('info', 'startup_complete', { message: `All ${expectedListeners} validation-participating listeners ready, starting key validation`, }); - validateApiKeys(adaptersToStart).catch((err) => { - logRequest('error', 'key_validation_error', { message: 'Unexpected error during key validation', error: String(err) }); - keyValidationComplete = true; - }); - fetchStartupModels(adaptersToStart).then(() => { - writeModelsJson(); - }).catch((err) => { - logRequest('error', 'model_fetch_error', { message: 'Unexpected error fetching startup models', error: String(err) }); - modelFetchComplete = true; - writeModelsJson(); + + // Wait for OIDC init before key validation (OIDC providers need tokens to probe) + Promise.all(oidcInitPromises).then(() => { + validateApiKeys(adaptersToStart).catch((err) => { + logRequest('error', 'key_validation_error', { message: 'Unexpected error during key validation', error: String(err) }); + keyValidationComplete = true; + }); + fetchStartupModels(adaptersToStart).then(() => { + writeModelsJson(); + }).catch((err) => { + logRequest('error', 'model_fetch_error', { message: 'Unexpected error fetching startup models', error: String(err) }); + modelFetchComplete = true; + writeModelsJson(); + }); }); } } @@ -620,12 +645,22 @@ if (require.main === module) { process.on('SIGTERM', async () => { logRequest('info', 'shutdown', { message: 'Received SIGTERM, shutting down gracefully' }); + for (const adapter of registeredAdapters) { + if (typeof adapter.getOidcProvider === 'function') { + adapter.getOidcProvider()?.shutdown(); + } + } await closeLogStream(); process.exit(0); }); process.on('SIGINT', async () => { logRequest('info', 'shutdown', { message: 'Received SIGINT, shutting down gracefully' }); + for (const adapter of registeredAdapters) { + if (typeof adapter.getOidcProvider === 'function') { + adapter.getOidcProvider()?.shutdown(); + } + } await closeLogStream(); process.exit(0); }); diff --git a/src/services/api-proxy-service.ts b/src/services/api-proxy-service.ts index e1a53a469..d86acd592 100644 --- a/src/services/api-proxy-service.ts +++ b/src/services/api-proxy-service.ts @@ -103,6 +103,20 @@ export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBui }), // Enable OpenCode listener only when explicitly requested ...(config.enableOpenCode && { AWF_ENABLE_OPENCODE: 'true' }), + // OIDC authentication for Azure OpenAI (Entra-only deployments) + ...(process.env.AWF_AUTH_TYPE && { AWF_AUTH_TYPE: process.env.AWF_AUTH_TYPE }), + ...(process.env.AWF_AUTH_AZURE_TENANT_ID && { AWF_AUTH_AZURE_TENANT_ID: process.env.AWF_AUTH_AZURE_TENANT_ID }), + ...(process.env.AWF_AUTH_AZURE_CLIENT_ID && { AWF_AUTH_AZURE_CLIENT_ID: process.env.AWF_AUTH_AZURE_CLIENT_ID }), + ...(process.env.AWF_AUTH_OIDC_AUDIENCE && { AWF_AUTH_OIDC_AUDIENCE: process.env.AWF_AUTH_OIDC_AUDIENCE }), + ...(process.env.AWF_AUTH_AZURE_SCOPE && { AWF_AUTH_AZURE_SCOPE: process.env.AWF_AUTH_AZURE_SCOPE }), + ...(process.env.AWF_AUTH_AZURE_CLOUD && { AWF_AUTH_AZURE_CLOUD: process.env.AWF_AUTH_AZURE_CLOUD }), + // GitHub Actions OIDC runtime tokens (needed by OIDC token provider in api-proxy) + ...(process.env.AWF_AUTH_TYPE === 'github-oidc' && process.env.ACTIONS_ID_TOKEN_REQUEST_URL && { + ACTIONS_ID_TOKEN_REQUEST_URL: process.env.ACTIONS_ID_TOKEN_REQUEST_URL, + }), + ...(process.env.AWF_AUTH_TYPE === 'github-oidc' && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN && { + ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, + }), // Anthropic request optimisations (all opt-in via env vars on the host) ...(process.env.AWF_ANTHROPIC_AUTO_CACHE && { AWF_ANTHROPIC_AUTO_CACHE: process.env.AWF_ANTHROPIC_AUTO_CACHE }), ...(process.env.AWF_ANTHROPIC_CACHE_TAIL_TTL && { AWF_ANTHROPIC_CACHE_TAIL_TTL: process.env.AWF_ANTHROPIC_CACHE_TAIL_TTL }), From 8ce7a7d75aaa199d1a5c37beb17a57b89942bc44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 23:04:00 +0000 Subject: [PATCH 2/2] fix(api-proxy): address OIDC review feedback Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/074a922b-ae9a-4c09-a168-6379e90be199 --- containers/api-proxy/oidc-token-provider.js | 80 ++++++++----------- .../api-proxy/oidc-token-provider.test.js | 71 ++++++++++++++-- containers/api-proxy/providers/openai.js | 24 +++--- src/services/api-proxy-service.test.ts | 49 ++++++++++++ src/services/api-proxy-service.ts | 5 +- 5 files changed, 164 insertions(+), 65 deletions(-) diff --git a/containers/api-proxy/oidc-token-provider.js b/containers/api-proxy/oidc-token-provider.js index 2e8b747cc..7a7954b76 100644 --- a/containers/api-proxy/oidc-token-provider.js +++ b/containers/api-proxy/oidc-token-provider.js @@ -16,6 +16,7 @@ const https = require('https'); const http = require('http'); +const { HttpsProxyAgent } = require('https-proxy-agent'); const { logRequest } = require('./logging'); // Refresh at 75% of token lifetime (Azure tokens typically last 3600s) @@ -219,8 +220,11 @@ class OidcTokenProvider { // Schedule proactive refresh const refreshInSecs = Math.max( + 0, + Math.min( expires_in * REFRESH_FACTOR, expires_in - MIN_REFRESH_MARGIN_SECS + ) ); this._scheduleRefresh(Math.floor(refreshInSecs * 1000)); } @@ -262,7 +266,10 @@ class OidcTokenProvider { return new Promise((resolve, reject) => { const parsedUrl = new URL(url); const mod = parsedUrl.protocol === 'https:' ? https : http; - const req = mod.get(url, { headers }, (res) => { + const req = mod.get(url, { + headers, + agent: this._getProxyAgent(parsedUrl), + }, (res) => { let body = ''; res.on('data', (chunk) => { body += chunk; }); res.on('end', () => resolve({ statusCode: res.statusCode, body })); @@ -285,60 +292,37 @@ class OidcTokenProvider { const options = { method: 'POST', hostname: parsedUrl.hostname, - port: parsedUrl.port || 443, + port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), path: parsedUrl.pathname + parsedUrl.search, headers: { ...headers, 'Content-Length': Buffer.byteLength(body) }, + agent: this._getProxyAgent(parsedUrl), }; - // Use HTTP_PROXY if set (sidecar routes through Squid) - const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY; - let req; - if (proxyUrl && parsedUrl.protocol === 'https:') { - // For HTTPS through proxy, use CONNECT method via http module - const proxy = new URL(proxyUrl); - const connectReq = http.request({ - host: proxy.hostname, - port: proxy.port || 3128, - method: 'CONNECT', - path: `${parsedUrl.hostname}:443`, - }); - connectReq.on('connect', (connectRes, socket) => { - if (connectRes.statusCode !== 200) { - reject(new Error(`Proxy CONNECT failed: ${connectRes.statusCode}`)); - return; - } - req = https.request({ - ...options, - socket, - agent: false, - }, (res) => { - let responseBody = ''; - res.on('data', (chunk) => { responseBody += chunk; }); - res.on('end', () => resolve({ statusCode: res.statusCode, body: responseBody })); - }); - req.on('error', reject); - req.setTimeout(10_000, () => { req.destroy(new Error('Azure token exchange timeout')); }); - req.write(body); - req.end(); - }); - connectReq.on('error', reject); - connectReq.setTimeout(10_000, () => { connectReq.destroy(new Error('Proxy connect timeout')); }); - connectReq.end(); - } else { - const mod = parsedUrl.protocol === 'https:' ? https : http; - req = mod.request(options, (res) => { - let responseBody = ''; - res.on('data', (chunk) => { responseBody += chunk; }); - res.on('end', () => resolve({ statusCode: res.statusCode, body: responseBody })); - }); - req.on('error', reject); - req.setTimeout(10_000, () => { req.destroy(new Error('Azure token exchange timeout')); }); - req.write(body); - req.end(); - } + const mod = parsedUrl.protocol === 'https:' ? https : http; + const req = mod.request(options, (res) => { + let responseBody = ''; + res.on('data', (chunk) => { responseBody += chunk; }); + res.on('end', () => resolve({ statusCode: res.statusCode, body: responseBody })); + }); + req.on('error', reject); + req.setTimeout(10_000, () => { req.destroy(new Error('Azure token exchange timeout')); }); + req.write(body); + req.end(); }); } + /** + * Build proxy agent from env vars when configured. + * @param {URL} parsedUrl + * @returns {import('http').Agent|undefined} + */ + _getProxyAgent(parsedUrl) { + const proxyUrl = parsedUrl.protocol === 'https:' + ? (process.env.HTTPS_PROXY || process.env.HTTP_PROXY) + : (process.env.HTTP_PROXY || process.env.HTTPS_PROXY); + return proxyUrl ? new HttpsProxyAgent(proxyUrl) : undefined; + } + /** @param {number} ms */ _sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); diff --git a/containers/api-proxy/oidc-token-provider.test.js b/containers/api-proxy/oidc-token-provider.test.js index bd2744bb3..cf3ec8ddf 100644 --- a/containers/api-proxy/oidc-token-provider.test.js +++ b/containers/api-proxy/oidc-token-provider.test.js @@ -70,7 +70,6 @@ describe('OidcTokenProvider', () => { // Override login host to use mock server provider._loginHost = `127.0.0.1:${serverPort}`; // Override _httpPost to use http (not https) - const originalPost = provider._httpPost.bind(provider); provider._httpPost = function (url, body, headers) { // Rewrite https to http for mock const httpUrl = url.replace('https://', 'http://'); @@ -173,12 +172,54 @@ describe('OidcTokenProvider', () => { provider.shutdown(); await new Promise(resolve => failServer.close(resolve)); }); + + it('should schedule refresh at 75% or 5 minutes-before-expiry, whichever is earlier', async () => { + const provider = new OidcTokenProvider({ + requestUrl: 'http://localhost/token', + requestToken: 'test', + tenantId: 'test', + clientId: 'test', + }); + + provider._mintGitHubOidcToken = jest.fn().mockResolvedValue('oidc-jwt'); + provider._exchangeForAzureToken = jest.fn().mockResolvedValue({ + access_token: 'azure-token', + expires_in: 600, + }); + provider._scheduleRefresh = jest.fn(); + + await provider._refreshToken(); + + expect(provider._scheduleRefresh).toHaveBeenCalledWith(300000); + provider.shutdown(); + }); + + it('should schedule immediate refresh when token lifetime is below minimum margin', async () => { + const provider = new OidcTokenProvider({ + requestUrl: 'http://localhost/token', + requestToken: 'test', + tenantId: 'test', + clientId: 'test', + }); + + provider._mintGitHubOidcToken = jest.fn().mockResolvedValue('oidc-jwt'); + provider._exchangeForAzureToken = jest.fn().mockResolvedValue({ + access_token: 'azure-token', + expires_in: 240, + }); + provider._scheduleRefresh = jest.fn(); + + await provider._refreshToken(); + + expect(provider._scheduleRefresh).toHaveBeenCalledWith(0); + provider.shutdown(); + }); }); describe('OpenAI adapter with OIDC', () => { const { createOpenAIAdapter } = require('./providers/openai'); - it('should report enabled when OIDC is configured', () => { + it('should report disabled until OIDC token is initialized', () => { const adapter = createOpenAIAdapter({ AWF_AUTH_TYPE: 'github-oidc', ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token', @@ -188,7 +229,7 @@ describe('OpenAI adapter with OIDC', () => { OPENAI_API_TARGET: 'my-resource.openai.azure.com', }); - expect(adapter.isEnabled()).toBe(true); + expect(adapter.isEnabled()).toBe(false); expect(adapter.getOidcProvider()).not.toBeNull(); expect(adapter.getValidationProbe()).toEqual({ skip: true, reason: 'OIDC auth; validation via token acquisition' }); expect(adapter.getModelsFetchConfig()).toBeNull(); @@ -217,7 +258,7 @@ describe('OpenAI adapter with OIDC', () => { expect(adapter.getOidcProvider()).toBeNull(); }); - it('should return oidc-token-unavailable when OIDC token not yet acquired', () => { + it('should return empty auth headers when OIDC token is not yet acquired', () => { const adapter = createOpenAIAdapter({ AWF_AUTH_TYPE: 'github-oidc', ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token', @@ -228,7 +269,27 @@ describe('OpenAI adapter with OIDC', () => { // Before initialization, token should be unavailable const headers = adapter.getAuthHeaders({}); - expect(headers['Authorization']).toBe('Bearer oidc-token-unavailable'); + expect(headers).toEqual({}); + + adapter.getOidcProvider().shutdown(); + }); + + it('should inject only Authorization header in OIDC mode', () => { + const adapter = createOpenAIAdapter({ + AWF_AUTH_TYPE: 'github-oidc', + ACTIONS_ID_TOKEN_REQUEST_URL: 'http://localhost/token', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'test-token', + AWF_AUTH_AZURE_TENANT_ID: 'test-tenant', + AWF_AUTH_AZURE_CLIENT_ID: 'test-client', + }); + + const provider = adapter.getOidcProvider(); + provider._cachedToken = 'azure-ad-token'; + provider._expiresAt = Math.floor(Date.now() / 1000) + 600; + + const headers = adapter.getAuthHeaders({}); + expect(headers).toEqual({ Authorization: 'Bearer azure-ad-token' }); + expect(headers['api-key']).toBeUndefined(); adapter.getOidcProvider().shutdown(); }); diff --git a/containers/api-proxy/providers/openai.js b/containers/api-proxy/providers/openai.js index b9b6df8dd..f5f971e06 100644 --- a/containers/api-proxy/providers/openai.js +++ b/containers/api-proxy/providers/openai.js @@ -56,7 +56,7 @@ function createOpenAIAdapter(env, deps = {}) { }); } } - const oidcEnabled = !!oidcProvider; + const oidcConfigured = !!oidcProvider; return { name: 'openai', @@ -74,7 +74,7 @@ function createOpenAIAdapter(env, deps = {}) { /** Port 10000 always counts toward the startup validation latch. */ participatesInValidation: true, - isEnabled() { return !!apiKey || oidcEnabled; }, + isEnabled() { return !!apiKey || !!oidcProvider?.isReady(); }, getTargetHost() { return rawTarget; }, getBasePath() { return basePath; }, @@ -90,11 +90,9 @@ function createOpenAIAdapter(env, deps = {}) { if (oidcProvider) { const token = oidcProvider.getToken(); if (token) { - return { 'Authorization': `Bearer ${token}`, 'api-key': token }; + return { 'Authorization': `Bearer ${token}` }; } - // Token not yet available (pre-init or refresh failure) - // Return empty — server will return 503 via isEnabled() short-circuit - return { 'Authorization': 'Bearer oidc-token-unavailable' }; + return {}; } return { 'Authorization': `Bearer ${apiKey}` }; }, @@ -109,7 +107,7 @@ function createOpenAIAdapter(env, deps = {}) { * @returns {{ url: string, opts: object }|{ skip: true, reason: string }|null} */ getValidationProbe() { - if (oidcEnabled) { + if (oidcConfigured) { return { skip: true, reason: 'OIDC auth; validation via token acquisition' }; } if (!apiKey) return null; @@ -130,7 +128,7 @@ function createOpenAIAdapter(env, deps = {}) { * @returns {{ url: string, opts: object, cacheKey: string }|null} */ getModelsFetchConfig() { - if (oidcEnabled) return null; // Models fetched after OIDC init + if (oidcConfigured) return null; // Models fetched after OIDC init if (!apiKey) return null; const modelsPath = basePath ? `${basePath}/models` : '/v1/models'; return { @@ -145,8 +143,8 @@ function createOpenAIAdapter(env, deps = {}) { provider: 'openai', port: 10000, base_url: 'http://api-proxy:10000', - configured: !!apiKey || oidcEnabled, - auth_type: oidcEnabled ? 'github-oidc' : 'static-key', + configured: !!apiKey || oidcConfigured, + auth_type: oidcConfigured ? 'github-oidc' : 'static-key', models_cache_key: 'openai', models_url: 'http://api-proxy:10000/v1/models', }; @@ -154,6 +152,12 @@ function createOpenAIAdapter(env, deps = {}) { /** Response returned when port 10000 receives a proxy request but no key is set. */ getUnconfiguredResponse() { + if (oidcConfigured) { + return { + statusCode: 503, + body: { error: 'OpenAI OIDC token unavailable; retry shortly' }, + }; + } return { statusCode: 404, body: { error: 'OpenAI proxy not configured (no OPENAI_API_KEY or OIDC auth)' }, diff --git a/src/services/api-proxy-service.test.ts b/src/services/api-proxy-service.test.ts index 84bf9d996..f2e15c167 100644 --- a/src/services/api-proxy-service.test.ts +++ b/src/services/api-proxy-service.test.ts @@ -492,6 +492,55 @@ describe('API proxy sidecar', () => { expect(env.AWF_ENABLE_OPENCODE).toBeUndefined(); }); + describe('OIDC runtime env forwarding', () => { + let savedEnv: Record; + const oidcVars = [ + 'AWF_AUTH_TYPE', + 'ACTIONS_ID_TOKEN_REQUEST_URL', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', + ]; + + beforeEach(() => { + savedEnv = {}; + for (const key of oidcVars) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + }); + + afterEach(() => { + for (const key of oidcVars) { + if (savedEnv[key] !== undefined) { + process.env[key] = savedEnv[key]; + } else { + delete process.env[key]; + } + } + }); + + it('should forward ACTIONS_ID_TOKEN_REQUEST_* when AWF_AUTH_TYPE normalizes to github-oidc', () => { + process.env.AWF_AUTH_TYPE = ' GitHub-OIDC '; + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://actions.local/token'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'runtime-token'; + const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBe('https://actions.local/token'); + expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBe('runtime-token'); + }); + + it('should not forward ACTIONS_ID_TOKEN_REQUEST_* when AWF_AUTH_TYPE is not github-oidc', () => { + process.env.AWF_AUTH_TYPE = 'api-key'; + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://actions.local/token'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'runtime-token'; + const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-openai-test' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBeUndefined(); + expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBeUndefined(); + }); + }); + describe('AWF_ANTHROPIC_* env var forwarding', () => { let savedEnv: Record; const anthropicVars = [ diff --git a/src/services/api-proxy-service.ts b/src/services/api-proxy-service.ts index d86acd592..4737dd1b3 100644 --- a/src/services/api-proxy-service.ts +++ b/src/services/api-proxy-service.ts @@ -34,6 +34,7 @@ export interface ApiProxyServiceParams { export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBuildResult { const { config, networkConfig, apiProxyLogsPath, imageConfig } = params; const { useGHCR, registry, parsedTag, projectRoot } = imageConfig; + const normalizedAuthType = (process.env.AWF_AUTH_TYPE || '').trim().toLowerCase(); if (!networkConfig.proxyIp) { throw new Error('buildApiProxyService: networkConfig.proxyIp is required'); @@ -111,10 +112,10 @@ export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBui ...(process.env.AWF_AUTH_AZURE_SCOPE && { AWF_AUTH_AZURE_SCOPE: process.env.AWF_AUTH_AZURE_SCOPE }), ...(process.env.AWF_AUTH_AZURE_CLOUD && { AWF_AUTH_AZURE_CLOUD: process.env.AWF_AUTH_AZURE_CLOUD }), // GitHub Actions OIDC runtime tokens (needed by OIDC token provider in api-proxy) - ...(process.env.AWF_AUTH_TYPE === 'github-oidc' && process.env.ACTIONS_ID_TOKEN_REQUEST_URL && { + ...(normalizedAuthType === 'github-oidc' && process.env.ACTIONS_ID_TOKEN_REQUEST_URL && { ACTIONS_ID_TOKEN_REQUEST_URL: process.env.ACTIONS_ID_TOKEN_REQUEST_URL, }), - ...(process.env.AWF_AUTH_TYPE === 'github-oidc' && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN && { + ...(normalizedAuthType === 'github-oidc' && process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN && { ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN, }), // Anthropic request optimisations (all opt-in via env vars on the host)