Skip to content

Commit 6ccecc1

Browse files
lpcoxCopilotCopilot
authored
docs: add apiProxy.auth OIDC configuration to spec and schema (#2772)
* docs: add apiProxy.auth OIDC configuration to spec and schema Add §9.5 OIDC Authentication to awf-config-spec.md documenting the apiProxy.auth configuration object and its mapping to AWF_AUTH_* environment variables for GitHub OIDC → Azure AD/Entra token exchange. Add apiProxy.auth object to both docs/awf-config.schema.json and src/awf-config-schema.json with properties: type (github-oidc), oidcAudience, azureTenantId, azureClientId, azureScope, azureCloud. Add CLI mapping entries for all apiProxy.auth.* paths (config-only). Add api-proxy-sidecar.md to informative references. Relates to github/gh-aw#31099. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(oidc): add AWS and GCP provider support for OIDC authentication Extend the OIDC authentication system to support three cloud providers (Azure, AWS, GCP) via a new apiProxy.auth.provider field. Schema & spec changes: - Add 'provider' enum field (azure|aws|gcp) with azure as default - Add AWS properties: awsRoleArn, awsRegion, awsRoleSessionName - Add GCP properties: gcpWorkloadIdentityProvider, gcpServiceAccount, gcpScope - Provider-specific OIDC audience defaults documented - Expand §9.5 with subsections for each provider (9.5.1-9.5.3) - Add CLI mapping entries for all new config paths - Both schemas kept in sync (src/ and docs/) Code changes: - Extract shared GitHub OIDC minting to github-oidc.js utility - Create aws-oidc-token-provider.js (STS AssumeRoleWithWebIdentity) - Create gcp-oidc-token-provider.js (STS + optional SA impersonation) - Update openai.js adapter to select provider via AWF_AUTH_PROVIDER - Update server.js to initialize/shutdown AWS OIDC providers - Forward new AWF_AUTH_* env vars in api-proxy-service.ts Note: AWS Bedrock uses SigV4 request signing (not Bearer tokens). The credential acquisition is complete; SigV4 request signing integration with server.js proxy pipeline is a follow-up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add OIDC authentication section to authentication-architecture.md Document the complete OIDC credential exchange flow including: - How native GitHub Actions OIDC works (background) - How AWF isolates the OIDC exchange in the api-proxy sidecar - Step-by-step token flow (config forwarding → minting → exchange → caching → injection) - Provider-specific exchange protocols (Azure, AWS SigV4, GCP STS + SA impersonation) - Comparison table: static keys vs OIDC federation - Updated key files reference with all OIDC-related source files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove unused variables flagged by CodeQL in OIDC test files --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 3708f24 commit 6ccecc1

15 files changed

Lines changed: 1973 additions & 50 deletions
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
'use strict';
2+
3+
/**
4+
* OIDC Token Provider for AWS Workload Identity Federation.
5+
*
6+
* Mints a GitHub Actions OIDC token, exchanges it for temporary AWS
7+
* credentials via STS AssumeRoleWithWebIdentity, caches the result,
8+
* and proactively refreshes before expiry.
9+
*
10+
* Token flow:
11+
* 1. Request GitHub OIDC JWT from Actions runtime (audience: sts.amazonaws.com)
12+
* 2. Exchange JWT for temporary AWS credentials via STS AssumeRoleWithWebIdentity
13+
* 3. Cache credentials, schedule refresh at 75% of lifetime
14+
* 4. Serve cached credentials synchronously via getCredentials()
15+
*
16+
* Note: AWS uses SigV4 request signing, not Bearer tokens. The consumer
17+
* must use getCredentials() and sign the complete request (method, path,
18+
* headers, body hash) with the returned access key, secret key, and
19+
* session token.
20+
*/
21+
22+
const { mintGitHubOidcToken, httpGet } = require('./github-oidc');
23+
const { logRequest } = require('./logging');
24+
25+
// Refresh at 75% of credential lifetime (STS default is 3600s)
26+
const REFRESH_FACTOR = 0.75;
27+
const MIN_REFRESH_MARGIN_SECS = 300;
28+
const REFRESH_RETRY_DELAY_MS = 30_000;
29+
const MAX_INIT_RETRIES = 3;
30+
31+
/**
32+
* @typedef {Object} AwsCredentials
33+
* @property {string} accessKeyId
34+
* @property {string} secretAccessKey
35+
* @property {string} sessionToken
36+
*/
37+
38+
/**
39+
* @typedef {Object} AwsOidcTokenProviderConfig
40+
* @property {string} requestUrl - ACTIONS_ID_TOKEN_REQUEST_URL
41+
* @property {string} requestToken - ACTIONS_ID_TOKEN_REQUEST_TOKEN
42+
* @property {string} roleArn - AWS IAM role ARN to assume
43+
* @property {string} region - AWS region (e.g., us-east-1)
44+
* @property {string} [roleSessionName] - Session name (default: awf-oidc-session)
45+
* @property {string} [oidcAudience] - Audience for GitHub OIDC token (default: sts.amazonaws.com)
46+
* @property {number} [retryDelayMs] - Retry delay after failed refresh (default: 30000)
47+
* @property {number} [maxInitRetries] - Maximum retries for initial token acquisition (default: 3)
48+
*/
49+
50+
class AwsOidcTokenProvider {
51+
/**
52+
* @param {AwsOidcTokenProviderConfig} config
53+
*/
54+
constructor(config) {
55+
this._requestUrl = config.requestUrl;
56+
this._requestToken = config.requestToken;
57+
this._roleArn = config.roleArn;
58+
this._region = config.region;
59+
this._roleSessionName = config.roleSessionName || 'awf-oidc-session';
60+
this._oidcAudience = config.oidcAudience || 'sts.amazonaws.com';
61+
this._retryDelayMs = config.retryDelayMs ?? REFRESH_RETRY_DELAY_MS;
62+
this._maxInitRetries = config.maxInitRetries ?? MAX_INIT_RETRIES;
63+
64+
/** @type {AwsCredentials|null} */
65+
this._cachedCredentials = null;
66+
this._expiresAt = 0;
67+
this._refreshTimer = null;
68+
this._refreshInFlight = null;
69+
this._initialized = false;
70+
this._initError = null;
71+
}
72+
73+
/**
74+
* Initialize by acquiring the first set of credentials.
75+
* @returns {Promise<void>}
76+
*/
77+
async initialize() {
78+
for (let attempt = 1; attempt <= this._maxInitRetries; attempt++) {
79+
try {
80+
await this._refreshCredentials();
81+
this._initialized = true;
82+
this._initError = null;
83+
logRequest('info', 'aws_oidc_init_success', {
84+
role_arn: this._roleArn,
85+
region: this._region,
86+
expires_in_secs: this._expiresAt - Math.floor(Date.now() / 1000),
87+
});
88+
return;
89+
} catch (err) {
90+
this._initError = err;
91+
logRequest('warn', 'aws_oidc_init_retry', {
92+
attempt,
93+
max_retries: this._maxInitRetries,
94+
error: err.message,
95+
});
96+
if (attempt < this._maxInitRetries) {
97+
await this._sleep(this._retryDelayMs * attempt);
98+
}
99+
}
100+
}
101+
logRequest('error', 'aws_oidc_init_failed', {
102+
error: this._initError?.message,
103+
role_arn: this._roleArn,
104+
});
105+
}
106+
107+
/**
108+
* Get the current cached AWS credentials synchronously.
109+
* Returns null if no valid credentials are available.
110+
* @returns {AwsCredentials|null}
111+
*/
112+
getCredentials() {
113+
const now = Math.floor(Date.now() / 1000);
114+
if (this._cachedCredentials && this._expiresAt > now) {
115+
return this._cachedCredentials;
116+
}
117+
if (!this._refreshInFlight) {
118+
this._scheduleRefresh(0);
119+
}
120+
return null;
121+
}
122+
123+
/**
124+
* Get the AWS region for this provider.
125+
* @returns {string}
126+
*/
127+
getRegion() {
128+
return this._region;
129+
}
130+
131+
/** @returns {boolean} */
132+
isReady() {
133+
const now = Math.floor(Date.now() / 1000);
134+
return !!(this._cachedCredentials && this._expiresAt > now);
135+
}
136+
137+
shutdown() {
138+
if (this._refreshTimer) {
139+
clearTimeout(this._refreshTimer);
140+
this._refreshTimer = null;
141+
}
142+
}
143+
144+
/**
145+
* Exchange GitHub OIDC JWT for temporary AWS credentials via STS.
146+
* Uses the HTTPS query API (no SDK dependency).
147+
* @param {string} oidcJwt
148+
* @returns {Promise<{credentials: AwsCredentials, expires_in: number}>}
149+
*/
150+
async _assumeRoleWithWebIdentity(oidcJwt) {
151+
const params = new URLSearchParams({
152+
Action: 'AssumeRoleWithWebIdentity',
153+
Version: '2011-06-15',
154+
RoleArn: this._roleArn,
155+
RoleSessionName: this._roleSessionName,
156+
WebIdentityToken: oidcJwt,
157+
});
158+
159+
const stsHost = this._resolveStsHost();
160+
const url = `https://${stsHost}/?${params.toString()}`;
161+
162+
const response = await httpGet(url, {
163+
'Accept': 'application/json',
164+
});
165+
166+
if (response.statusCode !== 200) {
167+
throw new Error(`AWS STS AssumeRoleWithWebIdentity failed: HTTP ${response.statusCode}${response.body}`);
168+
}
169+
170+
// STS returns XML by default, but JSON when Accept: application/json is set
171+
// Parse the response to extract credentials
172+
const data = JSON.parse(response.body);
173+
const result = data.AssumeRoleWithWebIdentityResponse?.AssumeRoleWithWebIdentityResult;
174+
if (!result?.Credentials) {
175+
throw new Error('AWS STS response missing Credentials');
176+
}
177+
178+
const creds = result.Credentials;
179+
const expiration = new Date(creds.Expiration);
180+
const expiresIn = Math.floor((expiration.getTime() - Date.now()) / 1000);
181+
182+
return {
183+
credentials: {
184+
accessKeyId: creds.AccessKeyId,
185+
secretAccessKey: creds.SecretAccessKey,
186+
sessionToken: creds.SessionToken,
187+
},
188+
expires_in: expiresIn > 0 ? expiresIn : 3600,
189+
};
190+
}
191+
192+
/**
193+
* Resolve the STS endpoint for the configured region.
194+
* Uses regional STS endpoints for lower latency.
195+
* @returns {string}
196+
*/
197+
_resolveStsHost() {
198+
// China regions use a separate partition
199+
if (this._region.startsWith('cn-')) {
200+
return `sts.${this._region}.amazonaws.com.cn`;
201+
}
202+
// GovCloud
203+
if (this._region.startsWith('us-gov-')) {
204+
return `sts.${this._region}.amazonaws.com`;
205+
}
206+
// Standard regions — use regional endpoint
207+
return `sts.${this._region}.amazonaws.com`;
208+
}
209+
210+
/**
211+
* Full credential refresh: GitHub OIDC → AWS STS.
212+
*/
213+
async _refreshCredentials() {
214+
const oidcJwt = await mintGitHubOidcToken({
215+
requestUrl: this._requestUrl,
216+
requestToken: this._requestToken,
217+
audience: this._oidcAudience,
218+
});
219+
220+
const { credentials, expires_in } = await this._assumeRoleWithWebIdentity(oidcJwt);
221+
222+
const now = Math.floor(Date.now() / 1000);
223+
this._cachedCredentials = credentials;
224+
this._expiresAt = now + expires_in;
225+
226+
const refreshInSecs = Math.max(
227+
0,
228+
Math.min(
229+
expires_in * REFRESH_FACTOR,
230+
expires_in - MIN_REFRESH_MARGIN_SECS
231+
)
232+
);
233+
this._scheduleRefresh(Math.floor(refreshInSecs * 1000));
234+
}
235+
236+
/** @param {number} delayMs */
237+
_scheduleRefresh(delayMs) {
238+
if (this._refreshTimer) clearTimeout(this._refreshTimer);
239+
this._refreshTimer = setTimeout(() => {
240+
this._refreshInFlight = this._refreshCredentials()
241+
.then(() => {
242+
logRequest('info', 'aws_oidc_refresh_success', {
243+
expires_in_secs: this._expiresAt - Math.floor(Date.now() / 1000),
244+
});
245+
})
246+
.catch((err) => {
247+
logRequest('error', 'aws_oidc_refresh_failed', { error: err.message });
248+
const now = Math.floor(Date.now() / 1000);
249+
if (this._expiresAt > now) {
250+
this._scheduleRefresh(this._retryDelayMs);
251+
}
252+
})
253+
.finally(() => { this._refreshInFlight = null; });
254+
}, delayMs);
255+
if (this._refreshTimer.unref) this._refreshTimer.unref();
256+
}
257+
258+
/** @param {number} ms */
259+
_sleep(ms) {
260+
return new Promise(resolve => setTimeout(resolve, ms));
261+
}
262+
}
263+
264+
module.exports = { AwsOidcTokenProvider };

0 commit comments

Comments
 (0)