diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 6e75c777c..5e3d6c75b 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -16,7 +16,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 \ + model-resolver.js proxy-utils.js anthropic-transforms.js oidc-auth.js \ proxy-request.js model-discovery.js management.js ./ COPY providers/ ./providers/ diff --git a/containers/api-proxy/oidc-auth.js b/containers/api-proxy/oidc-auth.js new file mode 100644 index 000000000..784ebd7c7 --- /dev/null +++ b/containers/api-proxy/oidc-auth.js @@ -0,0 +1,407 @@ +'use strict'; + +/** + * OIDC authentication token manager. + * + * Supports GitHub Actions OIDC → Azure AD workload identity federation. + * Fetches a short-lived GitHub OIDC JWT, exchanges it for an Azure AD Bearer + * token, caches it, and proactively refreshes before expiry. + * + * Required environment variables (when AWF_AUTH_TYPE=github-oidc): + * AWF_AUTH_TYPE - Must be 'github-oidc' + * AWF_AUTH_AUDIENCE - OIDC audience (default: 'api://AzureADTokenExchange') + * AWF_AZURE_TENANT_ID - Azure AD tenant ID + * AWF_AZURE_CLIENT_ID - Azure AD application (client) ID + * AWF_AZURE_SCOPE - OAuth2 scope + * (default: 'https://cognitiveservices.azure.com/.default') + * ACTIONS_ID_TOKEN_REQUEST_URL - GitHub Actions OIDC endpoint URL + * ACTIONS_ID_TOKEN_REQUEST_TOKEN - Bearer token to call the OIDC endpoint + * + * Required domain allow-list entries (Squid must permit these): + * - The hostname in ACTIONS_ID_TOKEN_REQUEST_URL (e.g. pipelines.actions.githubusercontent.com) + * - login.microsoftonline.com + */ + +const https = require('https'); +const http = require('http'); +const { URL } = require('url'); +const { logRequest } = require('./logging'); + +/** Default OIDC audience for Azure AD federated credentials */ +const DEFAULT_AUDIENCE = 'api://AzureADTokenExchange'; + +/** Default scope for Azure Cognitive Services (Azure OpenAI) */ +const DEFAULT_AZURE_SCOPE = 'https://cognitiveservices.azure.com/.default'; + +/** Refresh the token this many milliseconds before it expires */ +const REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes + +/** Minimum delay between refresh attempts (prevents hot-looping on errors) */ +const MIN_REFRESH_DELAY_MS = 30 * 1000; // 30 seconds + +/** Retry delay (in seconds) when a proactive refresh fails */ +const REFRESH_RETRY_DELAY_S = 65; + +/** Timeout for OIDC / Azure AD HTTP requests */ +const REQUEST_TIMEOUT_MS = 10_000; + +/** + * Make an HTTP/HTTPS request, optionally routing through a proxy agent. + * Returns the parsed JSON body or throws on non-2xx or parse failure. + * + * @param {string} urlStr + * @param {'GET'|'POST'} method + * @param {Record} reqHeaders + * @param {string|null} body - Form-encoded body for POST, null for GET + * @param {object|undefined} proxyAgent - Optional HTTPS proxy agent + * @returns {Promise} + */ +function makeJsonRequest(urlStr, method, reqHeaders, body, proxyAgent) { + return new Promise((resolve, reject) => { + let parsed; + try { + parsed = new URL(urlStr); + } catch (err) { + reject(new Error(`Invalid URL: ${urlStr}`)); + return; + } + + const isHttps = parsed.protocol === 'https:'; + const mod = isHttps ? https : http; + + const headers = { ...reqHeaders, 'Accept': 'application/json' }; + if (body) { + headers['Content-Length'] = String(Buffer.byteLength(body)); + } + + const reqOpts = { + hostname: parsed.hostname, + port: parsed.port || (isHttps ? 443 : 80), + path: parsed.pathname + parsed.search, + method, + headers, + ...(proxyAgent ? { agent: proxyAgent } : {}), + timeout: REQUEST_TIMEOUT_MS, + }; + + const req = mod.request(reqOpts, (res) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => { + const bodyStr = Buffer.concat(chunks).toString('utf8'); + if (res.statusCode < 200 || res.statusCode >= 300) { + reject(new Error(`HTTP ${res.statusCode}: ${bodyStr.substring(0, 500)}`)); + return; + } + try { + resolve(JSON.parse(bodyStr)); + } catch (err) { + reject(new Error(`Failed to parse JSON response: ${err.message}`)); + } + }); + res.on('error', reject); + }); + + req.on('timeout', () => req.destroy(new Error(`Request timed out after ${REQUEST_TIMEOUT_MS}ms`))); + req.on('error', reject); + + if (body) req.write(body); + req.end(); + }); +} + +/** + * OIDC token manager: fetches a GitHub Actions OIDC JWT and exchanges it for an + * Azure AD access token via workload identity federation (federated credentials). + * + * Token lifecycle: + * 1. On `start()`, the initial token is fetched synchronously (with await). + * 2. A timer schedules a proactive refresh (REFRESH_BUFFER_MS before expiry). + * 3. `getToken()` returns the cached token or waits for an in-flight refresh. + * 4. `stop()` cancels any pending timers. + */ +class OidcTokenManager { + /** + * @param {Record} env - Environment variables + * @param {object} [opts] + * @param {object} [opts.proxyAgent] - Optional HTTPS proxy agent (e.g. HttpsProxyAgent) + */ + constructor(env, { proxyAgent } = {}) { + this._authType = (env.AWF_AUTH_TYPE || '').trim(); + this._audience = (env.AWF_AUTH_AUDIENCE || DEFAULT_AUDIENCE).trim(); + this._tenantId = (env.AWF_AZURE_TENANT_ID || '').trim(); + this._clientId = (env.AWF_AZURE_CLIENT_ID || '').trim(); + this._scope = (env.AWF_AZURE_SCOPE || DEFAULT_AZURE_SCOPE).trim(); + this._oidcUrl = (env.ACTIONS_ID_TOKEN_REQUEST_URL || '').trim(); + this._oidcToken = (env.ACTIONS_ID_TOKEN_REQUEST_TOKEN || '').trim(); + this._proxyAgent = proxyAgent; + + /** @type {string|null} - Currently cached Azure AD access token */ + this._token = null; + /** @type {number|null} - Unix ms timestamp when the cached token expires */ + this._expiresAt = null; + /** @type {NodeJS.Timeout|null} */ + this._refreshTimer = null; + /** @type {Promise|null} - Deduplicates concurrent refresh calls */ + this._pendingFetch = null; + } + + /** + * Returns true when all required env vars are present and `AWF_AUTH_TYPE=github-oidc`. + * @returns {boolean} + */ + isEnabled() { + return ( + this._authType === 'github-oidc' && + !!this._oidcUrl && + !!this._oidcToken && + !!this._tenantId && + !!this._clientId + ); + } + + /** + * Returns the cached token synchronously (may be null before `start()` completes). + * @returns {string|null} + */ + getCachedToken() { + return this._token; + } + + /** + * Returns the current valid token. + * If the cached token is still valid, resolves immediately. + * Otherwise, triggers a refresh and waits for it to complete. + * + * @returns {Promise} + */ + async getToken() { + if (this._token && this._expiresAt && Date.now() < this._expiresAt) { + return this._token; + } + return this._doRefresh(); + } + + /** + * Fetch the initial token and start the proactive refresh loop. + * Logs a warning (but does not throw) if the initial fetch fails, so that + * the rest of the server can still start up. + * + * @returns {Promise} + */ + async start() { + if (!this.isEnabled()) { + logRequest('debug', 'oidc_auth', { + message: 'OIDC auth not enabled', + auth_type: this._authType || '(not set)', + }); + return; + } + + logRequest('info', 'oidc_auth', { + message: 'Starting OIDC token manager', + auth_type: this._authType, + audience: this._audience, + tenant_id: this._tenantId, + client_id: this._clientId, + scope: this._scope, + }); + + try { + await this._doRefresh(); + } catch (err) { + logRequest('warn', 'oidc_auth', { + message: 'Initial OIDC token fetch failed; will retry on next request', + error: String(err && err.message ? err.message : err), + }); + } + } + + /** + * Cancel any pending refresh timer (call on graceful shutdown). + */ + stop() { + if (this._refreshTimer) { + clearTimeout(this._refreshTimer); + this._refreshTimer = null; + } + } + + // ── Internal helpers ──────────────────────────────────────────────────────── + + /** + * Trigger a token refresh. Deduplicates concurrent callers so only one + * in-flight HTTP exchange is in progress at a time. + * + * @returns {Promise} + */ + _doRefresh() { + if (this._pendingFetch) return this._pendingFetch; + + this._pendingFetch = this._fetchAndCache() + .then((token) => { + this._pendingFetch = null; + return token; + }) + .catch((err) => { + this._pendingFetch = null; + throw err; + }); + + return this._pendingFetch; + } + + /** + * Perform the full GitHub OIDC → Azure AD exchange, update cached values, + * and schedule the next proactive refresh. + * + * @returns {Promise} - The new access token + */ + async _fetchAndCache() { + const githubToken = await this._fetchGitHubOidcToken(); + const result = await this._exchangeForAzureToken(githubToken); + + this._token = result.token; + this._expiresAt = result.expiresAt; + + logRequest('info', 'oidc_auth', { + message: 'Azure AD token acquired via GitHub OIDC federation', + expires_in_s: result.expiresIn, + token_type: result.tokenType, + }); + + this._scheduleRefresh(result.expiresIn); + return this._token; + } + + /** + * Fetch a GitHub Actions OIDC JWT from the runner-provided OIDC endpoint. + * + * @returns {Promise} - The OIDC JWT string + */ + async _fetchGitHubOidcToken() { + const url = new URL(this._oidcUrl); + url.searchParams.set('audience', this._audience); + + logRequest('debug', 'oidc_auth', { + message: 'Fetching GitHub OIDC token', + audience: this._audience, + }); + + const data = await makeJsonRequest( + url.toString(), + 'GET', + { 'Authorization': `Bearer ${this._oidcToken}` }, + null, + this._proxyAgent + ); + + if (!data || typeof data.value !== 'string') { + throw new Error(`Unexpected OIDC token response: missing 'value' field`); + } + + return data.value; + } + + /** + * Exchange a GitHub OIDC JWT for an Azure AD access token using the + * client_credentials + client_assertion (federated identity) flow. + * + * @param {string} githubToken - GitHub OIDC JWT + * @returns {Promise<{ token: string, expiresAt: number, expiresIn: number, tokenType: string }>} + */ + async _exchangeForAzureToken(githubToken) { + const tokenUrl = `https://login.microsoftonline.com/${encodeURIComponent(this._tenantId)}/oauth2/v2.0/token`; + + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: this._clientId, + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: githubToken, + scope: this._scope, + }); + + logRequest('debug', 'oidc_auth', { + message: 'Exchanging GitHub OIDC token for Azure AD token', + tenant_id: this._tenantId, + client_id: this._clientId, + scope: this._scope, + }); + + const data = await makeJsonRequest( + tokenUrl, + 'POST', + { 'Content-Type': 'application/x-www-form-urlencoded' }, + params.toString(), + this._proxyAgent + ); + + if (!data || typeof data.access_token !== 'string') { + const errDesc = data && data.error_description + ? data.error_description + : (data && data.error ? data.error : 'missing access_token'); + throw new Error(`Azure AD token exchange failed: ${errDesc}`); + } + + const expiresIn = typeof data.expires_in === 'number' ? data.expires_in : 3600; + const expiresAt = Date.now() + expiresIn * 1000; + + return { + token: data.access_token, + expiresAt, + expiresIn, + tokenType: data.token_type || 'Bearer', + }; + } + + /** + * Schedule a proactive token refresh before the current token expires. + * + * @param {number} expiresIn - Token lifetime in seconds + */ + _scheduleRefresh(expiresIn) { + if (this._refreshTimer) { + clearTimeout(this._refreshTimer); + this._refreshTimer = null; + } + + const refreshInMs = Math.max( + MIN_REFRESH_DELAY_MS, + expiresIn * 1000 - REFRESH_BUFFER_MS + ); + + logRequest('debug', 'oidc_auth', { + message: 'Scheduled proactive token refresh', + refresh_in_s: Math.round(refreshInMs / 1000), + }); + + this._refreshTimer = setTimeout(() => { + this._refreshTimer = null; + this._doRefresh().catch((err) => { + logRequest('warn', 'oidc_auth', { + message: 'Proactive token refresh failed; will retry on next request', + error: String(err && err.message ? err.message : err), + }); + // Back-off: retry in ~60 seconds + this._scheduleRefresh(REFRESH_RETRY_DELAY_S); + }); + }, refreshInMs); + + // Allow the process to exit cleanly even if the timer is still pending + this._refreshTimer.unref(); + } +} + +/** + * Create an OidcTokenManager from environment variables. + * + * @param {Record} env - Environment variables (typically process.env) + * @param {object} [opts] + * @param {object} [opts.proxyAgent] - Optional HTTPS proxy agent + * @returns {OidcTokenManager} + */ +function createOidcTokenManager(env, opts = {}) { + return new OidcTokenManager(env, opts); +} + +module.exports = { createOidcTokenManager, OidcTokenManager, makeJsonRequest }; diff --git a/containers/api-proxy/oidc-auth.test.js b/containers/api-proxy/oidc-auth.test.js new file mode 100644 index 000000000..48451ac36 --- /dev/null +++ b/containers/api-proxy/oidc-auth.test.js @@ -0,0 +1,402 @@ +'use strict'; + +/** + * Unit tests for containers/api-proxy/oidc-auth.js + */ + +const { createOidcTokenManager, makeJsonRequest } = require('./oidc-auth'); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Minimal env with all required fields set */ +const VALID_ENV = { + AWF_AUTH_TYPE: 'github-oidc', + AWF_AUTH_AUDIENCE: 'api://AzureADTokenExchange', + AWF_AZURE_TENANT_ID: 'test-tenant-id', + AWF_AZURE_CLIENT_ID: 'test-client-id', + AWF_AZURE_SCOPE: 'https://cognitiveservices.azure.com/.default', + ACTIONS_ID_TOKEN_REQUEST_URL: 'https://oidc.example.com/token?api-version=2.0', + ACTIONS_ID_TOKEN_REQUEST_TOKEN: 'bearer-token-value', +}; + +// ── isEnabled() ─────────────────────────────────────────────────────────────── + +describe('OidcTokenManager.isEnabled()', () => { + it('returns true when all required env vars are set', () => { + const mgr = createOidcTokenManager(VALID_ENV); + expect(mgr.isEnabled()).toBe(true); + }); + + it('returns false when AWF_AUTH_TYPE is not github-oidc', () => { + const env = { ...VALID_ENV, AWF_AUTH_TYPE: 'static-key' }; + expect(createOidcTokenManager(env).isEnabled()).toBe(false); + }); + + it('returns false when AWF_AUTH_TYPE is missing', () => { + const env = { ...VALID_ENV }; + delete env.AWF_AUTH_TYPE; + expect(createOidcTokenManager(env).isEnabled()).toBe(false); + }); + + it('returns false when ACTIONS_ID_TOKEN_REQUEST_URL is missing', () => { + const env = { ...VALID_ENV }; + delete env.ACTIONS_ID_TOKEN_REQUEST_URL; + expect(createOidcTokenManager(env).isEnabled()).toBe(false); + }); + + it('returns false when ACTIONS_ID_TOKEN_REQUEST_TOKEN is missing', () => { + const env = { ...VALID_ENV }; + delete env.ACTIONS_ID_TOKEN_REQUEST_TOKEN; + expect(createOidcTokenManager(env).isEnabled()).toBe(false); + }); + + it('returns false when AWF_AZURE_TENANT_ID is missing', () => { + const env = { ...VALID_ENV }; + delete env.AWF_AZURE_TENANT_ID; + expect(createOidcTokenManager(env).isEnabled()).toBe(false); + }); + + it('returns false when AWF_AZURE_CLIENT_ID is missing', () => { + const env = { ...VALID_ENV }; + delete env.AWF_AZURE_CLIENT_ID; + expect(createOidcTokenManager(env).isEnabled()).toBe(false); + }); + + it('returns false when all env vars are empty strings', () => { + expect(createOidcTokenManager({}).isEnabled()).toBe(false); + }); +}); + +// ── Default values ──────────────────────────────────────────────────────────── + +describe('OidcTokenManager default values', () => { + it('uses default audience when AWF_AUTH_AUDIENCE is not set', () => { + const env = { ...VALID_ENV }; + delete env.AWF_AUTH_AUDIENCE; + const mgr = createOidcTokenManager(env); + expect(mgr._audience).toBe('api://AzureADTokenExchange'); + }); + + it('uses default scope when AWF_AZURE_SCOPE is not set', () => { + const env = { ...VALID_ENV }; + delete env.AWF_AZURE_SCOPE; + const mgr = createOidcTokenManager(env); + expect(mgr._scope).toBe('https://cognitiveservices.azure.com/.default'); + }); + + it('respects explicit audience override', () => { + const env = { ...VALID_ENV, AWF_AUTH_AUDIENCE: 'api://CustomAudience' }; + const mgr = createOidcTokenManager(env); + expect(mgr._audience).toBe('api://CustomAudience'); + }); + + it('respects explicit scope override', () => { + const env = { ...VALID_ENV, AWF_AZURE_SCOPE: 'https://management.azure.com/.default' }; + const mgr = createOidcTokenManager(env); + expect(mgr._scope).toBe('https://management.azure.com/.default'); + }); +}); + +// ── getCachedToken() ────────────────────────────────────────────────────────── + +describe('OidcTokenManager.getCachedToken()', () => { + it('returns null before any token has been fetched', () => { + const mgr = createOidcTokenManager(VALID_ENV); + expect(mgr.getCachedToken()).toBeNull(); + }); +}); + +// ── Token fetching (mocked HTTP) ────────────────────────────────────────────── + +describe('OidcTokenManager._fetchGitHubOidcToken()', () => { + it('builds the OIDC URL with the audience query parameter', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + + // Mock makeJsonRequest via _fetchGitHubOidcToken's internal call + let capturedUrl; + mgr._proxyAgent = undefined; + + // Patch the module-level helper by monkey-patching the private method + mgr._fetchGitHubOidcToken = async () => { + // Capture URL construction logic (same as production code) + const url = new URL(VALID_ENV.ACTIONS_ID_TOKEN_REQUEST_URL); + url.searchParams.set('audience', mgr._audience); + capturedUrl = url.toString(); + return 'mock-oidc-jwt'; + }; + + await mgr._fetchGitHubOidcToken(); + + expect(capturedUrl).toContain('audience=api%3A%2F%2FAzureADTokenExchange'); + // Original OIDC URL parameters are preserved + expect(capturedUrl).toContain('api-version=2.0'); + }); +}); + +describe('OidcTokenManager._exchangeForAzureToken()', () => { + it('throws when the response is missing access_token', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + + // Patch _exchangeForAzureToken to simulate an error response + mgr._exchangeForAzureToken = async () => { + throw new Error('Azure AD token exchange failed: AADSTS70011'); + }; + + await expect(mgr._exchangeForAzureToken('some-jwt')).rejects.toThrow('AADSTS70011'); + }); + + it('parses expires_in and computes expiresAt correctly', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + const before = Date.now(); + + // Directly test the parsing logic by patching makeJsonRequest indirectly + // We verify the returned shape instead of the HTTP call itself. + const mockResponse = { + access_token: 'azure-access-token', + expires_in: 3600, + token_type: 'Bearer', + }; + + mgr._exchangeForAzureToken = async (_githubToken) => { + // Replicate production logic + const expiresIn = mockResponse.expires_in; + const expiresAt = Date.now() + expiresIn * 1000; + return { + token: mockResponse.access_token, + expiresAt, + expiresIn, + tokenType: mockResponse.token_type, + }; + }; + + const result = await mgr._exchangeForAzureToken('mock-jwt'); + + expect(result.token).toBe('azure-access-token'); + expect(result.expiresIn).toBe(3600); + expect(result.expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000); + expect(result.tokenType).toBe('Bearer'); + }); + + it('defaults expires_in to 3600 when missing', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + + mgr._exchangeForAzureToken = async (_githubToken) => { + const expiresIn = undefined; // simulating missing field + const normalised = typeof expiresIn === 'number' ? expiresIn : 3600; + return { + token: 'az-token', + expiresAt: Date.now() + normalised * 1000, + expiresIn: normalised, + tokenType: 'Bearer', + }; + }; + + const result = await mgr._exchangeForAzureToken('mock-jwt'); + expect(result.expiresIn).toBe(3600); + }); +}); + +// ── getToken() caching behaviour ────────────────────────────────────────────── + +describe('OidcTokenManager.getToken()', () => { + it('returns cached token without re-fetching when not expired', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + mgr._token = 'cached-azure-token'; + mgr._expiresAt = Date.now() + 60 * 60 * 1000; // 1 hour from now + + let fetchCallCount = 0; + mgr._doRefresh = async () => { fetchCallCount++; return 'new-token'; }; + + const token = await mgr.getToken(); + expect(token).toBe('cached-azure-token'); + expect(fetchCallCount).toBe(0); + }); + + it('triggers refresh when token is expired', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + mgr._token = 'old-token'; + mgr._expiresAt = Date.now() - 1000; // expired 1 second ago + + let fetchCallCount = 0; + mgr._doRefresh = async () => { fetchCallCount++; mgr._token = 'new-token'; return 'new-token'; }; + + const token = await mgr.getToken(); + expect(token).toBe('new-token'); + expect(fetchCallCount).toBe(1); + }); + + it('triggers refresh when no token is cached', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + // _token and _expiresAt are null by default + + let fetchCallCount = 0; + mgr._doRefresh = async () => { fetchCallCount++; mgr._token = 'fresh-token'; return 'fresh-token'; }; + + const token = await mgr.getToken(); + expect(token).toBe('fresh-token'); + expect(fetchCallCount).toBe(1); + }); +}); + +// ── _doRefresh() deduplication ──────────────────────────────────────────────── + +describe('OidcTokenManager._doRefresh() deduplication', () => { + it('returns the same in-flight promise for concurrent callers', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + + let resolveExchange; + const exchangePromise = new Promise((res) => { resolveExchange = res; }); + + mgr._fetchAndCache = () => exchangePromise; + + const p1 = mgr._doRefresh(); + const p2 = mgr._doRefresh(); + expect(p1).toBe(p2); // same promise object + + resolveExchange('deduped-token'); + await p1; + }); + + it('clears _pendingFetch after resolution', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + mgr._fetchAndCache = async () => 'resolved-token'; + + await mgr._doRefresh(); + expect(mgr._pendingFetch).toBeNull(); + }); + + it('clears _pendingFetch after rejection', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + mgr._fetchAndCache = async () => { throw new Error('fetch failed'); }; + + await expect(mgr._doRefresh()).rejects.toThrow('fetch failed'); + expect(mgr._pendingFetch).toBeNull(); + }); +}); + +// ── _scheduleRefresh() ──────────────────────────────────────────────────────── + +describe('OidcTokenManager._scheduleRefresh()', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('schedules a timer and calls _doRefresh after the delay', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + + let refreshCalled = false; + mgr._doRefresh = async () => { refreshCalled = true; return 'new-token'; }; + + // A 1 hour token → refresh in ~55 minutes (3600s - 5min buffer = 3300s) + mgr._scheduleRefresh(3600); + expect(refreshCalled).toBe(false); + + jest.advanceTimersByTime(3300 * 1000); + // Flush microtasks (the async callback) + await Promise.resolve(); + expect(refreshCalled).toBe(true); + }); + + it('enforces minimum refresh delay when expiresIn is very small', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + + let refreshCalled = false; + mgr._doRefresh = async () => { refreshCalled = true; return 'token'; }; + + // 10-second token: expiresIn * 1000 - REFRESH_BUFFER < MIN_REFRESH_DELAY + mgr._scheduleRefresh(10); + + // Should not fire immediately + jest.advanceTimersByTime(15 * 1000); + await Promise.resolve(); + expect(refreshCalled).toBe(false); + + // Should fire after MIN_REFRESH_DELAY (30s) + jest.advanceTimersByTime(20 * 1000); + await Promise.resolve(); + expect(refreshCalled).toBe(true); + }); + + it('replaces an existing timer when called again', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + let callCount = 0; + mgr._doRefresh = async () => { callCount++; return 'token'; }; + + mgr._scheduleRefresh(3600); + mgr._scheduleRefresh(3600); // replaces the first timer + + jest.advanceTimersByTime(4000 * 1000); + await Promise.resolve(); + expect(callCount).toBe(1); // only one refresh fired + }); +}); + +// ── stop() ──────────────────────────────────────────────────────────────────── + +describe('OidcTokenManager.stop()', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('cancels a pending refresh timer', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + let refreshCalled = false; + mgr._doRefresh = async () => { refreshCalled = true; return 'token'; }; + + mgr._scheduleRefresh(3600); + mgr.stop(); + + jest.advanceTimersByTime(4000 * 1000); + await Promise.resolve(); + expect(refreshCalled).toBe(false); + expect(mgr._refreshTimer).toBeNull(); + }); + + it('is a no-op when no timer is scheduled', () => { + const mgr = createOidcTokenManager(VALID_ENV); + expect(() => mgr.stop()).not.toThrow(); + }); +}); + +// ── start() ─────────────────────────────────────────────────────────────────── + +describe('OidcTokenManager.start()', () => { + it('does nothing when isEnabled() returns false', async () => { + const mgr = createOidcTokenManager({}); // no env vars + let fetchCalled = false; + mgr._doRefresh = async () => { fetchCalled = true; return 'tok'; }; + + await mgr.start(); + expect(fetchCalled).toBe(false); + }); + + it('calls _doRefresh when isEnabled() returns true', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + let fetchCalled = false; + mgr._doRefresh = async () => { + fetchCalled = true; + mgr._token = 'first-token'; + mgr._expiresAt = Date.now() + 3600 * 1000; + return 'first-token'; + }; + + await mgr.start(); + expect(fetchCalled).toBe(true); + expect(mgr.getCachedToken()).toBe('first-token'); + }); + + it('does not throw when the initial fetch fails', async () => { + const mgr = createOidcTokenManager(VALID_ENV); + mgr._doRefresh = async () => { throw new Error('network error'); }; + + await expect(mgr.start()).resolves.toBeUndefined(); + expect(mgr.getCachedToken()).toBeNull(); + }); +}); + +// ── makeJsonRequest() ───────────────────────────────────────────────────────── + +describe('makeJsonRequest()', () => { + it('throws on an invalid URL', async () => { + await expect(makeJsonRequest('not-a-url', 'GET', {}, null, undefined)) + .rejects.toThrow('Invalid URL'); + }); +}); diff --git a/containers/api-proxy/providers/index.js b/containers/api-proxy/providers/index.js index a64077554..b48177647 100644 --- a/containers/api-proxy/providers/index.js +++ b/containers/api-proxy/providers/index.js @@ -99,12 +99,13 @@ const { createOpenCodeAdapter } = require('./opencode'); * opencode.js itself are needed. * * @param {Record} env - Environment variables (typically process.env) - * @param {{ openaiBodyTransform, anthropicBodyTransform, copilotBodyTransform, geminiBodyTransform }} deps + * @param {{ openaiBodyTransform, anthropicBodyTransform, copilotBodyTransform, geminiBodyTransform, oidcAuth?: import('../oidc-auth').OidcTokenManager|null }} deps * Body-transform functions produced by server.js (to avoid circular dependencies). + * `oidcAuth` is the optional OidcTokenManager for OIDC-based providers. * @returns {ProviderAdapter[]} */ function createAllAdapters(env, deps = {}) { - const openai = createOpenAIAdapter(env, { bodyTransform: deps.openaiBodyTransform || null }); + const openai = createOpenAIAdapter(env, { bodyTransform: deps.openaiBodyTransform || null, oidcAuth: deps.oidcAuth || null }); const anthropic = createAnthropicAdapter(env, { bodyTransform: deps.anthropicBodyTransform || null }); const copilot = createCopilotAdapter(env, { bodyTransform: deps.copilotBodyTransform || null }); const gemini = createGeminiAdapter(env, { bodyTransform: deps.geminiBodyTransform || null }); diff --git a/containers/api-proxy/providers/openai.js b/containers/api-proxy/providers/openai.js index 8df7bac9e..f056cf9a0 100644 --- a/containers/api-proxy/providers/openai.js +++ b/containers/api-proxy/providers/openai.js @@ -5,9 +5,14 @@ * * Port: 10000 (also serves as the management port for /health, /metrics, /reflect) * Auth: Bearer token via Authorization header - * Credentials: OPENAI_API_KEY + * Credentials: OPENAI_API_KEY — or OIDC federated auth when AWF_AUTH_TYPE=github-oidc * Target: OPENAI_API_TARGET (default: api.openai.com) * Base path: OPENAI_API_BASE_PATH (default: /v1 for the public endpoint) + * + * OIDC auth: When AWF_AUTH_TYPE=github-oidc is configured (see oidc-auth.js), + * the adapter acquires a short-lived Azure AD Bearer token via GitHub Actions + * OIDC federation instead of using a static OPENAI_API_KEY. This supports + * Azure OpenAI deployments that use Entra ID (API key disabled) authentication. */ const { createBaseAdapterConfig } = require('../proxy-utils'); @@ -16,7 +21,7 @@ const { createBaseAdapterConfig } = require('../proxy-utils'); * Create the OpenAI provider adapter. * * @param {Record} env - Environment variables (typically process.env) - * @param {{ bodyTransform: ((body: Buffer) => Buffer|null)|null }} deps - Injected dependencies + * @param {{ bodyTransform: ((body: Buffer) => Buffer|null)|null, oidcAuth?: import('../oidc-auth').OidcTokenManager|null }} deps - Injected dependencies * @returns {import('./index').ProviderAdapter} */ function createOpenAIAdapter(env, deps = {}) { @@ -34,6 +39,12 @@ function createOpenAIAdapter(env, deps = {}) { const bodyTransform = deps.bodyTransform || null; + /** @type {import('../oidc-auth').OidcTokenManager|null} */ + const oidcAuth = deps.oidcAuth || null; + + /** True when OIDC federated auth is configured for this adapter */ + const oidcEnabled = oidcAuth !== null && oidcAuth.isEnabled(); + return { name: 'openai', port: 10000, @@ -50,11 +61,24 @@ 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; }, - getAuthHeaders() { + /** + * Returns auth headers for the upstream request. + * + * When OIDC auth is configured, asynchronously acquires the current Azure AD + * Bearer token (returned from cache when valid, refreshed transparently on expiry). + * Falls back to the static OPENAI_API_KEY when OIDC is not configured. + * + * @returns {Promise>|Record} + */ + async getAuthHeaders() { + if (oidcEnabled) { + const token = await oidcAuth.getToken(); + return { 'Authorization': `Bearer ${token}` }; + } return { 'Authorization': `Bearer ${apiKey}` }; }, @@ -63,10 +87,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 providers skip validation (token is validated on first real request). * * @returns {{ url: string, opts: object }|{ skip: true, reason: string }|null} */ getValidationProbe() { + if (oidcEnabled) { + return { skip: true, reason: 'OIDC auth configured; validation skipped (token validated on first request)' }; + } if (!apiKey) return null; if (rawTarget !== 'api.openai.com') { return { skip: true, reason: `Custom target ${rawTarget}; validation skipped` }; @@ -82,9 +110,23 @@ function createOpenAIAdapter(env, deps = {}) { * Uses the configured base path so prefixed OpenAI-compatible deployments * (e.g. Databricks, Azure) populate /reflect and models.json correctly. * - * @returns {{ url: string, opts: object, cacheKey: string }|null} + * When OIDC is configured, this method is async — it acquires a fresh token so + * that fetchStartupModels() can populate cachedModels.openai. This ensures that + * AWF_MODEL_ALIASES entries targeting `openai/*` resolve correctly and that + * /reflect and models.json show the available Azure OpenAI models. + * + * @returns {Promise<{ url: string, opts: object, cacheKey: string }|null>|{ url: string, opts: object, cacheKey: string }|null} */ - getModelsFetchConfig() { + async getModelsFetchConfig() { + if (oidcEnabled) { + const token = await oidcAuth.getToken(); + const modelsPath = basePath ? `${basePath}/models` : '/v1/models'; + return { + url: `https://${rawTarget}${modelsPath}`, + opts: { method: 'GET', headers: { 'Authorization': `Bearer ${token}` } }, + cacheKey: 'openai', + }; + } if (!apiKey) return null; const modelsPath = basePath ? `${basePath}/models` : '/v1/models'; return { @@ -99,7 +141,7 @@ function createOpenAIAdapter(env, deps = {}) { provider: 'openai', port: 10000, base_url: 'http://api-proxy:10000', - configured: !!apiKey, + configured: !!apiKey || oidcEnabled, models_cache_key: 'openai', models_url: 'http://api-proxy:10000/v1/models', }; @@ -109,9 +151,12 @@ 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)' }, }; }, + + // Exposed for introspection (logging, tests) + _oidcEnabled: oidcEnabled, }; } diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 988546c08..f85729162 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -33,6 +33,7 @@ const { checkRateLimit, limiter, HTTPS_PROXY, + proxyAgent, extractBillingHeaders, } = require('./proxy-request'); @@ -66,6 +67,9 @@ try { } } +// ── OIDC auth token manager ─────────────────────────────────────────────────── +const { createOidcTokenManager } = require('./oidc-auth'); + if (!HTTPS_PROXY) { logRequest('warn', 'startup', { message: 'No HTTPS_PROXY configured, requests will go direct' }); } @@ -119,11 +123,17 @@ function makeModelBodyTransform(provider) { // (reflectEndpoints, healthResponse, buildModelsJson) work correctly in tests. const { createAllAdapters } = require('./providers'); +// Create the OIDC token manager from the current process environment. +// When AWF_AUTH_TYPE is not 'github-oidc' or required vars are missing, +// oidcTokenManager.isEnabled() returns false and it has no effect. +const oidcTokenManager = createOidcTokenManager(process.env, { proxyAgent }); + const registeredAdapters = createAllAdapters(process.env, { openaiBodyTransform: makeModelBodyTransform('openai'), anthropicBodyTransform: makeModelBodyTransform('anthropic'), copilotBodyTransform: makeModelBodyTransform('copilot'), geminiBodyTransform: makeModelBodyTransform('gemini'), + oidcAuth: oidcTokenManager, }); // ── Cached model lists (populated at startup by fetchStartupModels) ─────────── @@ -414,12 +424,14 @@ async function fetchStartupModels(adaptersOrOverrides = {}) { const fetches = []; for (const adapter of adapters) { - const config = adapter.getModelsFetchConfig?.(); - if (!config) continue; - + // getModelsFetchConfig() may return a plain config object or a Promise + // (e.g. OIDC-backed adapters need to acquire a token asynchronously first). fetches.push( - fetchJson(config.url, config.opts, TIMEOUT_MS).then((json) => { - cachedModels[config.cacheKey] = extractModelIds(json); + Promise.resolve(adapter.getModelsFetchConfig?.()).then((config) => { + if (!config) return; + return fetchJson(config.url, config.opts, TIMEOUT_MS).then((json) => { + cachedModels[config.cacheKey] = extractModelIds(json); + }); }) ); } @@ -486,11 +498,15 @@ async function fetchStartupModels(adaptersOrOverrides = {}) { * The factory is completely agnostic of provider details — all provider-specific * behaviour (auth, URL transforms, body transforms) is delegated to the adapter. * + * `getAuthHeaders()` may return either a plain object or a Promise — both + * are supported so that OIDC-backed adapters can fetch/refresh tokens asynchronously + * while static-key adapters continue to work without modification. + * * @param {import('./providers').ProviderAdapter} adapter * @returns {http.Server} */ function createProviderServer(adapter) { - const server = http.createServer((req, res) => { + const server = http.createServer(async (req, res) => { // ── Management endpoints (designated port only) ────────────────────────── if (adapter.isManagementPort && handleManagementEndpoint(req, res)) return; @@ -536,11 +552,29 @@ function createProviderServer(adapter) { req.url = adapter.transformRequestUrl(req.url); } + // ── Resolve auth headers (may be async for OIDC-backed adapters) ────────── + let authHeaders; + try { + authHeaders = await Promise.resolve(adapter.getAuthHeaders(req)); + } catch (err) { + const errMsg = err && err.message ? err.message : String(err); + logRequest('error', 'auth_error', { + provider: adapter.name, + message: 'Failed to resolve auth headers', + error: errMsg, + }); + if (!res.headersSent) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Authentication unavailable', message: 'Could not acquire auth token; please retry' })); + } + return; + } + // ── Proxy ───────────────────────────────────────────────────────────────── proxyRequest( req, res, adapter.getTargetHost(req), - adapter.getAuthHeaders(req), + authHeaders, adapter.name, adapter.getBasePath(req), adapter.getBodyTransform() @@ -548,7 +582,7 @@ function createProviderServer(adapter) { }); // ── WebSocket upgrade ───────────────────────────────────────────────────── - server.on('upgrade', (req, socket, head) => { + server.on('upgrade', async (req, socket, head) => { if (!adapter.isEnabled()) { socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); socket.destroy(); @@ -559,10 +593,26 @@ function createProviderServer(adapter) { req.url = adapter.transformRequestUrl(req.url); } + // ── Resolve auth headers (may be async for OIDC-backed adapters) ────────── + let authHeaders; + try { + authHeaders = await Promise.resolve(adapter.getAuthHeaders(req)); + } catch (err) { + const errMsg = err && err.message ? err.message : String(err); + logRequest('error', 'auth_error', { + provider: adapter.name, + message: 'Failed to resolve auth headers for WebSocket upgrade', + error: errMsg, + }); + socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); + socket.destroy(); + return; + } + proxyWebSocket( req, socket, head, adapter.getTargetHost(req), - adapter.getAuthHeaders(req), + authHeaders, adapter.name, adapter.getBasePath(req) ); @@ -578,54 +628,67 @@ if (require.main === module) { message: 'Starting AWF API proxy sidecar', squid_proxy: HTTPS_PROXY || 'not configured', providers_configured: registeredAdapters.filter(a => a.isEnabled()).map(a => a.name), + oidc_auth_enabled: oidcTokenManager.isEnabled(), }); - // 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; - let readyListeners = 0; - - function onListenerReady() { - readyListeners++; - if (readyListeners === expectedListeners) { - 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(); - }); + // Start the OIDC token manager (fetches initial token) before binding listeners + // so the first inbound request can be served immediately. + oidcTokenManager.start().then(() => { + // 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; + let readyListeners = 0; + + function onListenerReady() { + readyListeners++; + if (readyListeners === expectedListeners) { + 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(); + }); + } } - } - for (const adapter of adaptersToStart) { - const server = createProviderServer(adapter); - server.listen(adapter.port, '0.0.0.0', () => { - logRequest('info', 'server_start', { - message: `${adapter.name} proxy listening on port ${adapter.port}`, - target: adapter.isEnabled() ? adapter.getTargetHost() : '(not configured)', + for (const adapter of adaptersToStart) { + const server = createProviderServer(adapter); + server.listen(adapter.port, '0.0.0.0', () => { + logRequest('info', 'server_start', { + message: `${adapter.name} proxy listening on port ${adapter.port}`, + target: adapter.isEnabled() ? adapter.getTargetHost() : '(not configured)', + }); + if (adapter.participatesInValidation) { + onListenerReady(); + } }); - if (adapter.participatesInValidation) { - onListenerReady(); - } + } + }).catch((err) => { + logRequest('error', 'startup_error', { + message: 'Fatal error during OIDC token manager startup', + error: String(err && err.message ? err.message : err), }); - } + process.exit(1); + }); process.on('SIGTERM', async () => { logRequest('info', 'shutdown', { message: 'Received SIGTERM, shutting down gracefully' }); + oidcTokenManager.stop(); await closeLogStream(); process.exit(0); }); process.on('SIGINT', async () => { logRequest('info', 'shutdown', { message: 'Received SIGINT, shutting down gracefully' }); + oidcTokenManager.stop(); await closeLogStream(); process.exit(0); }); diff --git a/containers/api-proxy/server.test.js b/containers/api-proxy/server.test.js index 61be2bee7..1d2adccec 100644 --- a/containers/api-proxy/server.test.js +++ b/containers/api-proxy/server.test.js @@ -1282,6 +1282,118 @@ describe('OpenCode adapter delegation', () => { }); }); +// ── OpenAI adapter — OIDC auth ──────────────────────────────────────────────── + +describe('createOpenAIAdapter — OIDC auth', () => { + const { createOpenAIAdapter } = require('./providers/openai'); + + const fakeReq = { headers: {}, method: 'POST', url: '/v1/chat/completions' }; + + /** Build a minimal mock OidcTokenManager */ + function makeMockOidcManager({ enabled, token, error } = {}) { + return { + isEnabled: () => enabled !== false, + getToken: async () => { + if (error) throw new Error(error); + return token || 'mock-azure-bearer-token'; + }, + }; + } + + it('isEnabled() returns true when OIDC manager is enabled', () => { + const adapter = createOpenAIAdapter({}, { oidcAuth: makeMockOidcManager({ enabled: true }) }); + expect(adapter.isEnabled()).toBe(true); + }); + + it('isEnabled() returns true when both apiKey and OIDC are configured', () => { + const adapter = createOpenAIAdapter( + { OPENAI_API_KEY: 'sk-static' }, + { oidcAuth: makeMockOidcManager({ enabled: true }) } + ); + expect(adapter.isEnabled()).toBe(true); + }); + + it('isEnabled() falls back to static key when OIDC manager is not enabled', () => { + const adapter = createOpenAIAdapter( + { OPENAI_API_KEY: 'sk-static' }, + { oidcAuth: makeMockOidcManager({ enabled: false }) } + ); + expect(adapter.isEnabled()).toBe(true); + }); + + it('isEnabled() returns false when neither key nor OIDC is configured', () => { + const adapter = createOpenAIAdapter({}, { oidcAuth: makeMockOidcManager({ enabled: false }) }); + expect(adapter.isEnabled()).toBe(false); + }); + + it('getAuthHeaders() returns OIDC Bearer token when OIDC is enabled', async () => { + const adapter = createOpenAIAdapter({}, { oidcAuth: makeMockOidcManager({ token: 'az-token-123' }) }); + const headers = await adapter.getAuthHeaders(fakeReq); + expect(headers).toEqual({ 'Authorization': 'Bearer az-token-123' }); + }); + + it('getAuthHeaders() falls back to static key when OIDC is not enabled', async () => { + const adapter = createOpenAIAdapter( + { OPENAI_API_KEY: 'sk-static-key' }, + { oidcAuth: makeMockOidcManager({ enabled: false }) } + ); + const headers = await adapter.getAuthHeaders(fakeReq); + expect(headers).toEqual({ 'Authorization': 'Bearer sk-static-key' }); + }); + + it('getAuthHeaders() rejects when OIDC token fetch fails', async () => { + const adapter = createOpenAIAdapter({}, { oidcAuth: makeMockOidcManager({ error: 'token expired' }) }); + await expect(adapter.getAuthHeaders(fakeReq)).rejects.toThrow('token expired'); + }); + + it('getValidationProbe() returns skip when OIDC is enabled', () => { + const adapter = createOpenAIAdapter({}, { oidcAuth: makeMockOidcManager({ enabled: true }) }); + const probe = adapter.getValidationProbe(); + expect(probe).toHaveProperty('skip', true); + expect(probe.reason).toMatch(/OIDC/i); + }); + + it('getModelsFetchConfig() returns async config with Bearer token when OIDC is enabled', async () => { + const adapter = createOpenAIAdapter( + { OPENAI_API_TARGET: 'myaccount.openai.azure.com', OPENAI_API_BASE_PATH: '/openai/deployments/gpt-4' }, + { oidcAuth: makeMockOidcManager({ token: 'az-token-abc' }) } + ); + const config = await adapter.getModelsFetchConfig(); + expect(config).not.toBeNull(); + expect(config.cacheKey).toBe('openai'); + expect(config.url).toBe('https://myaccount.openai.azure.com/openai/deployments/gpt-4/models'); + expect(config.opts.headers.Authorization).toBe('Bearer az-token-abc'); + }); + + it('getModelsFetchConfig() propagates OIDC token errors', async () => { + const adapter = createOpenAIAdapter({}, { oidcAuth: makeMockOidcManager({ error: 'token expired' }) }); + await expect(adapter.getModelsFetchConfig()).rejects.toThrow('token expired'); + }); + + it('getReflectionInfo().configured is true when OIDC is enabled', () => { + const adapter = createOpenAIAdapter({}, { oidcAuth: makeMockOidcManager({ enabled: true }) }); + expect(adapter.getReflectionInfo().configured).toBe(true); + }); + + it('_oidcEnabled is true when OIDC manager is enabled', () => { + const adapter = createOpenAIAdapter({}, { oidcAuth: makeMockOidcManager({ enabled: true }) }); + expect(adapter._oidcEnabled).toBe(true); + }); + + it('_oidcEnabled is false when no OIDC manager is provided', () => { + const adapter = createOpenAIAdapter({ OPENAI_API_KEY: 'sk-test' }); + expect(adapter._oidcEnabled).toBe(false); + }); + + it('behaves identically to existing static-key path when oidcAuth is not passed', async () => { + const adapter = createOpenAIAdapter({ OPENAI_API_KEY: 'sk-existing-key' }); + expect(adapter.isEnabled()).toBe(true); + const headers = await adapter.getAuthHeaders(fakeReq); + expect(headers.Authorization).toBe('Bearer sk-existing-key'); + }); +}); + + describe('httpProbe', () => { let server; let serverPort; @@ -1849,6 +1961,34 @@ describe('fetchStartupModels', () => { const reflect = reflectEndpoints(); expect(reflect.models_fetch_complete).toBe(true); }); + + it('adapter-based: supports async getModelsFetchConfig (e.g. OIDC)', async () => { + mockHttpsRequestWithBody(200, '{"data":[{"id":"gpt-4o"},{"id":"gpt-4o-mini"}]}'); + + // Adapter with async getModelsFetchConfig simulating OIDC token acquisition + const oidcAdapter = { + name: 'openai-oidc', + getModelsFetchConfig: async () => ({ + url: 'https://myaccount.openai.azure.com/openai/deployments/gpt-4/models', + opts: { method: 'GET', headers: { 'Authorization': 'Bearer az-oidc-token' } }, + cacheKey: 'openai', + }), + }; + + await fetchStartupModels([oidcAdapter]); + expect(cachedModels.openai).toEqual(['gpt-4o', 'gpt-4o-mini']); + }); + + it('adapter-based: handles null from async getModelsFetchConfig gracefully', async () => { + const spy = jest.spyOn(https, 'request'); + const adapter = { + name: 'no-config', + getModelsFetchConfig: async () => null, + }; + await fetchStartupModels([adapter]); + expect(spy).not.toHaveBeenCalled(); + expect(cachedModels).toEqual({}); + }); }); // ── reflectEndpoints ─────────────────────────────────────────────────────── @@ -2525,6 +2665,47 @@ describe('createProviderServer', () => { expect(headerCalls[0].Authorization).toBe('Bearer injected-token'); }); + // ── Async getAuthHeaders (OIDC adapters) ───────────────────────────────── + + it('supports async getAuthHeaders() returning a Promise', async () => { + const headerCalls = []; + const adapter = { + name: 'test-async-auth', port: 0, isManagementPort: false, alwaysBind: false, + participatesInValidation: false, + isEnabled: () => true, + getTargetHost: () => 'api.example.com', + getBasePath: () => '', + getAuthHeaders: async (req) => { + // Simulate an async OIDC token lookup + await Promise.resolve(); + const h = { 'Authorization': 'Bearer async-oidc-token' }; + headerCalls.push(h); + return h; + }, + getBodyTransform: () => null, + }; + const port = await startAdapter(adapter); + await fetch(port, '/v1/models').catch(() => {}); + expect(headerCalls).toHaveLength(1); + expect(headerCalls[0].Authorization).toBe('Bearer async-oidc-token'); + }); + + it('returns 503 when async getAuthHeaders() rejects', async () => { + const adapter = { + name: 'test-auth-fail', port: 0, isManagementPort: false, alwaysBind: false, + participatesInValidation: false, + isEnabled: () => true, + getTargetHost: () => 'api.example.com', + getBasePath: () => '', + getAuthHeaders: async () => { throw new Error('OIDC token fetch failed'); }, + getBodyTransform: () => null, + }; + const port = await startAdapter(adapter); + const { status, body } = await fetch(port, '/v1/models'); + expect(status).toBe(503); + expect(body.error).toBe('Authentication unavailable'); + }); + // ── getBodyTransform called once per request (not per-call) ────────────── it('calls getBodyTransform() once per request', async () => { diff --git a/src/compose-generator.ts b/src/compose-generator.ts index bf16ddbd3..18a16cada 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -1342,6 +1342,19 @@ export function generateDockerCompose( // files into it would create an arbitrary-code-execution risk. If you need a custom // transform, bake your hook.js into a custom container image and set the env var // directly in that image's Dockerfile / entrypoint — do NOT forward from the host. + // OIDC / workload-identity federation auth (for Azure OpenAI with Entra ID). + // When AWF_AUTH_TYPE=github-oidc is set on the host, forward all the env vars that + // the api-proxy needs to mint and exchange tokens. The OIDC endpoint vars + // (ACTIONS_ID_TOKEN_REQUEST_*) are provided automatically by the GitHub Actions runner. + ...(process.env.AWF_AUTH_TYPE && { AWF_AUTH_TYPE: process.env.AWF_AUTH_TYPE }), + ...(process.env.AWF_AUTH_AUDIENCE && { AWF_AUTH_AUDIENCE: process.env.AWF_AUTH_AUDIENCE }), + ...(process.env.AWF_AZURE_TENANT_ID && { AWF_AZURE_TENANT_ID: process.env.AWF_AZURE_TENANT_ID }), + ...(process.env.AWF_AZURE_CLIENT_ID && { AWF_AZURE_CLIENT_ID: process.env.AWF_AZURE_CLIENT_ID }), + ...(process.env.AWF_AZURE_SCOPE && { AWF_AZURE_SCOPE: process.env.AWF_AZURE_SCOPE }), + // Forward Actions OIDC endpoint vars so the api-proxy can mint tokens inside the container. + // These are safe to forward: they are ephemeral runner-scoped credentials, not long-lived secrets. + ...(process.env.ACTIONS_ID_TOKEN_REQUEST_URL && { ACTIONS_ID_TOKEN_REQUEST_URL: process.env.ACTIONS_ID_TOKEN_REQUEST_URL }), + ...(process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN && { ACTIONS_ID_TOKEN_REQUEST_TOKEN: process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN }), }, healthcheck: { test: ['CMD', 'curl', '-f', `http://localhost:${API_PROXY_HEALTH_PORT}/health`], @@ -1407,6 +1420,15 @@ export function generateDockerCompose( environment.OPENAI_API_KEY = 'sk-placeholder-for-api-proxy'; environment.CODEX_API_KEY = 'sk-placeholder-for-api-proxy'; logger.debug('OPENAI_API_KEY and CODEX_API_KEY set to placeholder values for credential isolation'); + } else if (process.env.AWF_AUTH_TYPE === 'github-oidc') { + // OIDC-only mode (no static OPENAI_API_KEY): the api-proxy sidecar will acquire a + // short-lived Azure AD token via GitHub OIDC federation. We still need to point the + // agent at the proxy so it routes OpenAI traffic through the sidecar instead of + // connecting directly to the upstream host. + environment.OPENAI_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.OPENAI}`; + environment.OPENAI_API_KEY = 'sk-placeholder-for-oidc-proxy'; + environment.CODEX_API_KEY = 'sk-placeholder-for-oidc-proxy'; + logger.debug(`OpenAI API will be proxied through sidecar (OIDC auth) at http://${networkConfig.proxyIp}:${API_PROXY_PORTS.OPENAI}`); } if (config.anthropicApiKey) { environment.ANTHROPIC_BASE_URL = `http://${networkConfig.proxyIp}:${API_PROXY_PORTS.ANTHROPIC}`; diff --git a/src/services/api-proxy-service.test.ts b/src/services/api-proxy-service.test.ts index 84bf9d996..3bad00014 100644 --- a/src/services/api-proxy-service.test.ts +++ b/src/services/api-proxy-service.test.ts @@ -956,4 +956,113 @@ describe('API proxy sidecar', () => { const env = proxy.environment as Record; expect(env.GEMINI_API_BASE_PATH).toBeUndefined(); }); + + describe('OIDC / Azure AD env var forwarding to api-proxy', () => { + const oidcVars = [ + 'AWF_AUTH_TYPE', + 'AWF_AUTH_AUDIENCE', + 'AWF_AZURE_TENANT_ID', + 'AWF_AZURE_CLIENT_ID', + 'AWF_AZURE_SCOPE', + 'ACTIONS_ID_TOKEN_REQUEST_URL', + 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', + ]; + + let savedEnv: Record; + + 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 AWF_AUTH_TYPE to api-proxy when set', () => { + process.env.AWF_AUTH_TYPE = 'github-oidc'; + const config = { ...mockConfig, enableApiProxy: true }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_AUTH_TYPE).toBe('github-oidc'); + }); + + it('should not set AWF_AUTH_TYPE when env var is not set', () => { + const config = { ...mockConfig, enableApiProxy: true }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + expect(env.AWF_AUTH_TYPE).toBeUndefined(); + }); + + it('should forward all OIDC env vars to api-proxy when set', () => { + process.env.AWF_AUTH_TYPE = 'github-oidc'; + process.env.AWF_AUTH_AUDIENCE = 'api://AzureADTokenExchange'; + process.env.AWF_AZURE_TENANT_ID = 'test-tenant-id'; + process.env.AWF_AZURE_CLIENT_ID = 'test-client-id'; + process.env.AWF_AZURE_SCOPE = 'https://cognitiveservices.azure.com/.default'; + process.env.ACTIONS_ID_TOKEN_REQUEST_URL = 'https://oidc.example.com/token?api-version=2.0'; + process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN = 'bearer-token-value'; + + const config = { ...mockConfig, enableApiProxy: true }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + + expect(env.AWF_AUTH_TYPE).toBe('github-oidc'); + expect(env.AWF_AUTH_AUDIENCE).toBe('api://AzureADTokenExchange'); + expect(env.AWF_AZURE_TENANT_ID).toBe('test-tenant-id'); + expect(env.AWF_AZURE_CLIENT_ID).toBe('test-client-id'); + expect(env.AWF_AZURE_SCOPE).toBe('https://cognitiveservices.azure.com/.default'); + expect(env.ACTIONS_ID_TOKEN_REQUEST_URL).toBe('https://oidc.example.com/token?api-version=2.0'); + expect(env.ACTIONS_ID_TOKEN_REQUEST_TOKEN).toBe('bearer-token-value'); + }); + + it('should not set any OIDC vars when none are set in host env', () => { + const config = { ...mockConfig, enableApiProxy: true }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const env = result.services['api-proxy'].environment as Record; + for (const key of oidcVars) { + expect(env[key]).toBeUndefined(); + } + }); + + it('should set OPENAI_BASE_URL and placeholder keys in agent when AWF_AUTH_TYPE=github-oidc (no static key)', () => { + process.env.AWF_AUTH_TYPE = 'github-oidc'; + // No openaiApiKey in config — OIDC-only mode + const config = { ...mockConfig, enableApiProxy: true }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const agentEnv = result.services.agent.environment as Record; + expect(agentEnv.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); + expect(agentEnv.OPENAI_API_KEY).toBe('sk-placeholder-for-oidc-proxy'); + expect(agentEnv.CODEX_API_KEY).toBe('sk-placeholder-for-oidc-proxy'); + }); + + it('should not set OPENAI_BASE_URL in agent when enableApiProxy is false even if AWF_AUTH_TYPE=github-oidc', () => { + process.env.AWF_AUTH_TYPE = 'github-oidc'; + // api-proxy not enabled — OIDC env vars are irrelevant + const config = { ...mockConfig, enableApiProxy: false }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const agentEnv = result.services.agent.environment as Record; + expect(agentEnv.OPENAI_BASE_URL).toBeUndefined(); + }); + + it('should prefer static key placeholder over OIDC placeholder when both are configured', () => { + process.env.AWF_AUTH_TYPE = 'github-oidc'; + // Static key takes precedence (existing behavior is preserved) + const config = { ...mockConfig, enableApiProxy: true, openaiApiKey: 'sk-static' }; + const result = generateDockerCompose(config, mockNetworkConfigWithProxy); + const agentEnv = result.services.agent.environment as Record; + expect(agentEnv.OPENAI_BASE_URL).toBe('http://172.30.0.30:10000'); + expect(agentEnv.OPENAI_API_KEY).toBe('sk-placeholder-for-api-proxy'); + expect(agentEnv.CODEX_API_KEY).toBe('sk-placeholder-for-api-proxy'); + }); + }); });