Enable GitHub Actions OIDC authentication in the Responses API proxy (#55320)

## Why

GitHub Actions workflows can use short-lived OpenAI credentials instead of storing a long-lived OpenAI API key in GitHub secrets.

## What changed

- Add `--github-oidc` with required audience, identity provider, service account, and GitHub token-request URL settings. Read the GitHub request credential from stdin and connect it to the existing federated authentication flow.
- Require the default OpenAI Responses endpoint in OIDC mode before reading credentials, and complete the initial token exchange before publishing server info.
- Close the npm launcher's copy of stdin after the native child starts.
- Document OIDC setup, credential handling, renewal behavior, and caller responsibilities. Keep API-key authentication as the default.

## Testing

Add tests for required OIDC flags, upstream restrictions before credential reads, rejection of unsafe or empty settings without exposing sensitive input, and failure when a configured custom CA cannot be loaded in the CLI blocking context.

GitOrigin-RevId: 7de6fd4c3491e7ccd6a2c2ccdee0bc2eedd0e642
This commit is contained in:
ee
2026-10-19 16:56:42 +00:00
committed by copyberry
parent e4568a3538
commit 57560e9d53
8 changed files with 306 additions and 12 deletions
+12 -1
View File
@@ -33,6 +33,8 @@ use tokio::runtime::Runtime;
use url::Url;
use zeroize::Zeroizing;
use crate::OPENAI_RESPONSES_URL;
const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
pub(crate) const MAX_TOKEN_BYTES: usize = 16 * 1024;
const MAX_RESPONSE_BYTES: usize = 64 * 1024;
@@ -117,7 +119,16 @@ impl AuthHeader {
}
impl Auth {
#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))]
/// Must be checked before reading any source credential, including stdin.
pub(crate) fn validate_federated_upstream(upstream: &str) -> Result<()> {
if upstream != OPENAI_RESPONSES_URL {
return Err(anyhow!(
"workload identity requires the default OpenAI Responses endpoint"
));
}
Ok(())
}
pub(crate) fn federated(
source: impl SubjectTokenSource + 'static,
provider: String,
@@ -166,6 +166,66 @@ fn oversized_serialized_exchange_request_is_rejected_before_sending() {
assert!(server.try_recv().unwrap().is_none());
}
#[test]
fn shared_oidc_transport_honors_custom_ca_in_cli_blocking_context() {
// Use a subprocess so certificate settings cannot affect parallel tests.
// run_main also runs under spawn_blocking when invoked via the Codex CLI.
const CHILD_ENV: &str = "CODEX_TEST_PROXY_OIDC_CUSTOM_CA";
if std::env::var_os(CHILD_ENV).is_some() {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
tokio::task::spawn_blocking(|| {
let (server, url) = server();
let auth = Auth::federated(
RotatingSource { calls: 0 },
"ip-test".to_owned(),
"sa-test".to_owned(),
)
.unwrap();
let Auth::Federated { state, .. } = &auth else {
unreachable!()
};
// Exercise the production pool, only replacing the destination.
state.lock().unwrap().token_url = url.join("/oauth/token").unwrap();
let error = format!("{:#}", auth.header().unwrap_err());
assert_eq!(error, "OpenAI workload identity token exchange failed");
assert!(
server
.recv_timeout(Duration::from_millis(100))
.unwrap()
.is_none(),
"must fail closed when a configured CA cannot be loaded"
);
})
.await
.unwrap();
});
return;
}
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"auth::tests::shared_oidc_transport_honors_custom_ca_in_cli_blocking_context",
"--nocapture",
])
.env(CHILD_ENV, "1")
.env(
"CODEX_CA_CERTIFICATE",
"/invalid-oidc-test-ca/credential.pem",
)
.output()
.unwrap();
assert!(
output.status.success(),
"stdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
#[test]
fn oversized_exchange_never_becomes_a_cached_credential() {
let (server, url) = server();
@@ -29,7 +29,6 @@ pub(crate) struct GithubOidc {
}
impl GithubOidc {
#[expect(dead_code, reason = "CLI is wired in a later stack layer")]
pub(crate) fn from_stdin(request_url: &str, audience: &str) -> Result<Self> {
// Avoid echoing caller-supplied URL parameters in parsing errors.
let mut request_url =
@@ -1,5 +1,7 @@
use super::*;
use crate::Args;
use crate::auth::tests::direct_token_client;
use clap::Parser;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::io::Cursor;
@@ -11,6 +13,75 @@ use tiny_http::Server;
use tiny_http::StatusCode;
use tokio::runtime::Runtime;
const FLAGS: &[&str] = &[
"responses-api-proxy",
"--github-oidc",
"--oidc-audience",
"https://api.openai.com/v1",
"--identity-provider-id",
"ip-test",
"--service-account-id",
"sa-test",
"--github-oidc-request-url",
"https://example.github.test/oidc?opaque=1",
];
#[test]
fn cli_requires_complete_oidc_configuration_and_keeps_api_key_default() {
assert!(!Args::try_parse_from(["proxy"]).unwrap().github_oidc);
assert!(Args::try_parse_from(["proxy", "--github-oidc"]).is_err());
assert!(Args::try_parse_from(["proxy", "--oidc-audience", "openai"]).is_err());
assert!(Args::try_parse_from(FLAGS).unwrap().github_oidc);
for name in [
"--oidc-audience",
"--identity-provider-id",
"--service-account-id",
"--github-oidc-request-url",
] {
let pos = FLAGS.iter().position(|flag| *flag == name).unwrap();
let mut missing = FLAGS.to_vec();
missing.drain(pos..pos + 2);
assert!(Args::try_parse_from(missing).is_err(), "missing {name}");
}
}
#[test]
fn oidc_disallows_provider_overrides_before_reading_any_credentials() {
let mut flags = FLAGS.to_vec();
flags.extend(["--upstream-url", "https://example.test/v1/responses"]);
let args = Args::try_parse_from(flags).unwrap();
assert!(
crate::run_main(args)
.unwrap_err()
.to_string()
.contains("default OpenAI")
);
}
#[test]
fn rejects_unsafe_or_empty_oidc_settings_without_echoing_input() {
for url in [
"http://example.test?secret=sensitive",
"https://name:sensitive@example.test/",
"https://example.test/#sensitive",
"sensitive",
] {
let mut args = Args::try_parse_from(FLAGS).unwrap();
args.github_oidc_request_url = Some(url.to_owned());
let Err(err) = args.auth() else {
panic!("accepted {url}")
};
let error = format!("{err:#}");
assert!(!error.contains("sensitive"), "{error}");
}
let mut args = Args::try_parse_from(FLAGS).unwrap();
args.oidc_audience = Some(" ".to_owned());
let Err(err) = args.auth() else {
panic!("accepted empty audience")
};
assert!(err.to_string().contains("--oidc-audience"));
}
#[test]
fn stdin_accepts_bounded_bearer_tokens_in_short_reads_and_rejects_injection() {
let token = format!("{}.payload.sig", "a".repeat(2000));
+61 -3
View File
@@ -30,15 +30,17 @@ use tiny_http::StatusCode;
mod auth;
mod dump;
#[cfg_attr(not(test), allow(dead_code, reason = "used by a later stack layer"))]
mod github_oidc;
mod read_api_key;
use auth::Auth;
use dump::ExchangeDumper;
use github_oidc::GithubOidc;
use read_api_key::read_auth_header_from_stdin;
const OPENAI_RESPONSES_URL: &str = "https://api.openai.com/v1/responses";
/// CLI arguments for the proxy.
#[derive(Debug, Clone, Parser)]
#[derive(Clone, Debug, Parser)]
#[command(name = "responses-api-proxy", about = "Minimal OpenAI responses proxy")]
pub struct Args {
/// Port to listen on. If not set, an ephemeral port is used.
@@ -60,6 +62,57 @@ pub struct Args {
/// Directory where request/response dumps should be written as JSON.
#[arg(long, value_name = "DIR")]
pub dump_dir: Option<PathBuf>,
/// Authenticate to the OpenAI API using GitHub Actions workload identity.
/// In this mode stdin supplies the GitHub OIDC request token, not an API key.
#[arg(long, requires_all = ["oidc_audience", "identity_provider_id", "service_account_id", "github_oidc_request_url"])]
pub github_oidc: bool,
/// Audience configured on the OpenAI workload identity provider.
#[arg(long, requires = "github_oidc", value_name = "AUDIENCE")]
pub oidc_audience: Option<String>,
/// OpenAI workload identity provider ID.
#[arg(long, requires = "github_oidc", value_name = "ID")]
pub identity_provider_id: Option<String>,
/// OpenAI service account ID selected by the provider mapping.
#[arg(long, requires = "github_oidc", value_name = "ID")]
pub service_account_id: Option<String>,
/// HTTPS token-request URL from GitHub's runner-provided ACTIONS_ID_TOKEN_REQUEST_URL.
///
/// Use the value directly from the trusted runner environment, never from
/// a workflow input or repository content. The proxy sends the GitHub
/// request credential to this URL.
#[arg(long, requires = "github_oidc", value_name = "URL")]
pub github_oidc_request_url: Option<String>,
}
impl Args {
fn auth(&self) -> Result<Auth> {
if self.github_oidc {
Auth::validate_federated_upstream(&self.upstream_url)?;
let provider = required(&self.identity_provider_id, "--identity-provider-id")?;
let service_account = required(&self.service_account_id, "--service-account-id")?;
let audience = required(&self.oidc_audience, "--oidc-audience")?;
let request_url = required(&self.github_oidc_request_url, "--github-oidc-request-url")?;
Auth::federated(
GithubOidc::from_stdin(request_url, audience)?,
provider.to_owned(),
service_account.to_owned(),
)
} else {
Ok(Auth::ApiKey(read_auth_header_from_stdin()?))
}
}
}
fn required<'a>(value: &'a Option<String>, name: &str) -> Result<&'a str> {
value
.as_deref()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| anyhow!("workload identity requires a nonempty {name}"))
}
#[derive(Serialize)]
@@ -75,7 +128,12 @@ struct ForwardConfig {
/// Entry point for the library main, for parity with other crates.
pub fn run_main(args: Args) -> Result<()> {
let auth = Arc::new(Auth::ApiKey(read_auth_header_from_stdin()?));
let auth = Arc::new(args.auth()?);
if args.github_oidc {
// Complete the initial exchange before publishing server info.
// API keys have already been validated when read from stdin.
auth.header()?;
}
let upstream_url = Url::parse(&args.upstream_url).context("parsing --upstream-url")?;
let host = match (upstream_url.host_str(), upstream_url.port()) {
@@ -215,7 +215,7 @@ fn validate_auth_header_bytes(mut key_bytes: &[u8]) -> Result<usize> {
}
if key_bytes.is_empty() {
return Err(anyhow!(
"API key must be provided via stdin (e.g. printenv OPENAI_API_KEY | codex responses-api-proxy)"
"API key must be provided via stdin (e.g. printenv OPENAI_API_KEY | env -u OPENAI_API_KEY codex-responses-api-proxy)"
));
}
if key_bytes