mirror of
https://github.com/openai/codex.git
synced 2026-10-22 17:58:46 +00:00
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:
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user