diff --git a/codex-rs/responses-api-proxy/README.md b/codex-rs/responses-api-proxy/README.md index 6105592441..f6881132e8 100644 --- a/codex-rs/responses-api-proxy/README.md +++ b/codex-rs/responses-api-proxy/README.md @@ -28,9 +28,13 @@ codex -p proxy # Detailed docs -A strict HTTP proxy that only forwards `POST` requests to `/v1/responses` to the OpenAI API (`https://api.openai.com`), injecting the `Authorization: Bearer $OPENAI_API_KEY` header. Everything else is rejected with `403 Forbidden`. +A strict HTTP proxy that forwards `POST /v1/responses` to the OpenAI API +(`https://api.openai.com`). It authenticates with an API key by default, or with +short-lived OpenAI credentials obtained through GitHub Actions OIDC. Other +requests are rejected with `403 Forbidden`, except for the optional shutdown +endpoint described below. -## Expected Usage +## API-key usage (default) **IMPORTANT:** `codex-responses-api-proxy` is designed to be run by a privileged user with access to `OPENAI_API_KEY` so that an unprivileged user cannot inspect or tamper with the process. Though if `--http-shutdown` is specified, an unprivileged user _can_ make a `GET` request to `/shutdown` to shutdown the server, as an unprivileged user could not send `SIGTERM` to kill the process. @@ -56,12 +60,94 @@ When the unprivileged user was finished, they could shutdown the server using `c curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown" ``` +## GitHub Actions OIDC (OpenAI API) + +The proxy can exchange GitHub Actions OIDC identities for short-lived OpenAI +API service account credentials. This removes the need to store a long-lived +OpenAI API key in GitHub secrets. It requires a workflow/job with +`permissions: { id-token: write, contents: read }` and an OpenAI organization +owner to configure a workload identity provider and service account mapping. +See the [OpenAI GitHub Actions federation guide](https://developers.openai.com/api/docs/guides/workload-identity-federation/github-actions). +Set the provider audience to the exact value used by the workflow, and restrict +the mapping to trusted repository, ref, and workflow claims. + +In this mode, stdin supplies GitHub's `ACTIONS_ID_TOKEN_REQUEST_TOKEN`, **not** +the minted GitHub OIDC JWT or an OpenAI API key. The proxy needs that request +credential to request fresh subject tokens during long runs. + +For the npm-installed proxy, use Node.js 24 or newer for the example below. +It sets `NODE_OPTIONS=--disable-sigusr1` to protect the launcher's credential +input from Node's debugger. Some older Node versions accepted by the npm package +do not support this option and will exit before starting the proxy. To run +without Node.js, replace `codex-responses-api-proxy` in the example with the +path to the native binary; keep the same pipe and environment handling. + +Supply the audience, provider ID, and service account ID as GitHub Actions +variables (they are not secrets). Start the proxy as a privileged user, as in +the API-key example: + +```bash +printenv ACTIONS_ID_TOKEN_REQUEST_TOKEN | ( + exec env -u ACTIONS_ID_TOKEN_REQUEST_TOKEN -u NODE_OPTIONS \ + NODE_OPTIONS=--disable-sigusr1 \ + codex-responses-api-proxy \ + --github-oidc \ + --oidc-audience "$OPENAI_WIF_AUDIENCE" \ + --identity-provider-id "$OPENAI_IDENTITY_PROVIDER_ID" \ + --service-account-id "$OPENAI_SERVICE_ACCOUNT_ID" \ + --github-oidc-request-url "$ACTIONS_ID_TOKEN_REQUEST_URL" \ + --http-shutdown --server-info /tmp/server-info.json +) +``` + +All four OIDC settings are required with `--github-oidc` and are rejected +without it. Pass `--github-oidc-request-url` directly from +`ACTIONS_ID_TOKEN_REQUEST_URL` supplied by the trusted GitHub runner, as shown +above. Do not set or overwrite it from workflow inputs, repository content, or +an untrusted subprocess. The proxy sends the GitHub request credential to that +URL. It requires HTTPS and refuses redirects, but it does not verify that GitHub +owns the destination. Do not substitute an OIDC issuer or discovery URL. + +In OIDC mode only the default `https://api.openai.com/v1/responses` upstream is +allowed, and redirects on both token and Responses requests are disabled. +Azure/custom provider URLs continue to work in API-key mode. + +Use a real pipe for either credential type, as in the examples above. Bash +here-strings and here-documents can create a seekable temporary file containing +the credential. If you background the proxy, do not wrap the entire pipeline +in another shell: it could retain the original credential environment while +it waits. + +The proxy performs an initial exchange before writing server info. It keeps +the access token in process memory, renews it using a fresh GitHub JWT before +expiry, and serializes concurrent renewals. A failed renewal fails closed with +HTTP 502; it does not reuse an expired token or replay a Responses POST. It +shares the failure with waiting requests and delays further exchange attempts +by one second, doubling after each failure up to 30 seconds. A successful +exchange resets that delay. If the Responses endpoint returns 401, the proxy +passes through that response and discards only the credential used for it; +the next request can obtain a fresh one. + +The proxy never returns tokens over its local HTTP API. Token endpoint error +bodies and credential-bearing URL errors are suppressed in diagnostics. + +**Caller responsibilities:** run the proxy with the existing privileged-process +protections. Remove `ACTIONS_ID_TOKEN_REQUEST_TOKEN` and +`ACTIONS_ID_TOKEN_REQUEST_URL` from the environment **before** launching Codex +or any untrusted subprocess. The proxy cannot sanitize the parent GitHub +runner's environment for other steps. Never put GitHub or OpenAI bearer tokens +in arguments, Codex config, server-info files, `GITHUB_OUTPUT`, or +`GITHUB_ENV`. Retain the same privilege separation as API-key mode. The +OIDC mode uses sensitive HTTP headers and zeroizes temporary credential +buffers; it does not make the API-key path's single static, mlocked-allocation +guarantee for tokens passing through the HTTP/JSON client. + ## Behavior -- Reads the API key from `stdin`. All callers should pipe the key in (for example, `printenv OPENAI_API_KEY | codex-responses-api-proxy`). -- Formats the header value as `Bearer ` and attempts to `mlock(2)` the memory holding that header so it is not swapped to disk. +- By default reads the API key from `stdin`. With `--github-oidc`, reads the GitHub OIDC request credential instead; see above. +- In API-key mode, formats the header value as `Bearer ` and attempts to `mlock(2)` the memory holding that header so it is not swapped to disk. - Listens on the provided port or an ephemeral port if `--port` is not specified. -- Accepts exactly `POST /v1/responses` (no query string). The request body is forwarded to `https://api.openai.com/v1/responses` with `Authorization: Bearer ` set. All original request headers (except any incoming `Authorization`) are forwarded upstream, with `Host` overridden to `api.openai.com`. For other requests, it responds with `403`. +- Accepts exactly `POST /v1/responses` (no query string). The request body is forwarded to the configured upstream (by default `https://api.openai.com/v1/responses`) with `Authorization` set to the selected API key or OpenAI access token. All original request headers (except any incoming `Authorization`) are forwarded upstream, with `Host` overridden to the upstream host. For other requests, it responds with `403`, aside from the optional shutdown endpoint. - Optionally writes a single-line JSON file with server info, currently `{ "port": , "pid": }`. - Optionally writes request/response JSON dumps to a directory. Each accepted request gets a pair of files that share a sequence/timestamp prefix, for example `000001-1846179912345-request.json` and `000001-1846179912345-response.json`. Header values are dumped in full except `Authorization` and any header whose name includes `cookie`, which are redacted. Bodies are written as parsed JSON when possible, otherwise as UTF-8 text. - Optional `--http-shutdown` enables `GET /shutdown` to terminate the process with exit code `0`. This allows one user (e.g., `root`) to start the proxy and another unprivileged user on the host to shut it down. @@ -70,6 +156,8 @@ curl --fail --silent --show-error "${PROXY_BASE_URL}/shutdown" ``` codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdown] [--upstream-url ] [--dump-dir ] + [--github-oidc --oidc-audience --identity-provider-id + --service-account-id --github-oidc-request-url ] ``` - `--port `: Port to bind on `127.0.0.1`. If omitted, an ephemeral port is chosen. @@ -77,7 +165,7 @@ codex-responses-api-proxy [--port ] [--server-info ] [--http-shutdow - `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`. - `--upstream-url `: Absolute URL to forward requests to. Defaults to `https://api.openai.com/v1/responses`. - `--dump-dir `: If set, writes one request JSON file and one response JSON file per accepted proxy call under this directory. Filenames use a shared sequence/timestamp prefix so each pair is easy to correlate. -- Authentication is fixed to `Authorization: Bearer ` to match the Codex CLI expectations. +- Authentication is `Authorization: Bearer ` in API-key mode and `Authorization: Bearer ` in OIDC mode. For Azure, for example (ensure your deployment accepts `Authorization: Bearer `): diff --git a/codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js b/codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js index e2c3ee7d3e..e482f79369 100755 --- a/codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js +++ b/codex-rs/responses-api-proxy/npm/bin/codex-responses-api-proxy.js @@ -2,6 +2,7 @@ // Entry point for the Codex responses API proxy binary. import { spawn } from "node:child_process"; +import { closeSync } from "node:fs"; import path from "path"; import { fileURLToPath } from "url"; @@ -61,6 +62,12 @@ const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit", }); +child.once("spawn", () => { + // The child has inherited stdin. Close our copy so credential input + // cannot be reread through the long-lived Node launcher. + closeSync(0); +}); + child.on("error", (err) => { console.error(err); process.exit(1); diff --git a/codex-rs/responses-api-proxy/src/auth.rs b/codex-rs/responses-api-proxy/src/auth.rs index 87b8a531c8..18b3ac8024 100644 --- a/codex-rs/responses-api-proxy/src/auth.rs +++ b/codex-rs/responses-api-proxy/src/auth.rs @@ -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, diff --git a/codex-rs/responses-api-proxy/src/auth_tests.rs b/codex-rs/responses-api-proxy/src/auth_tests.rs index e45d12dc55..c8fb755af5 100644 --- a/codex-rs/responses-api-proxy/src/auth_tests.rs +++ b/codex-rs/responses-api-proxy/src/auth_tests.rs @@ -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(); diff --git a/codex-rs/responses-api-proxy/src/github_oidc.rs b/codex-rs/responses-api-proxy/src/github_oidc.rs index 778335818e..ace819ba12 100644 --- a/codex-rs/responses-api-proxy/src/github_oidc.rs +++ b/codex-rs/responses-api-proxy/src/github_oidc.rs @@ -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 { // Avoid echoing caller-supplied URL parameters in parsing errors. let mut request_url = diff --git a/codex-rs/responses-api-proxy/src/github_oidc_tests.rs b/codex-rs/responses-api-proxy/src/github_oidc_tests.rs index b029defb1c..144e8f895b 100644 --- a/codex-rs/responses-api-proxy/src/github_oidc_tests.rs +++ b/codex-rs/responses-api-proxy/src/github_oidc_tests.rs @@ -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)); diff --git a/codex-rs/responses-api-proxy/src/lib.rs b/codex-rs/responses-api-proxy/src/lib.rs index 83288eeb48..8fb7a0c73e 100644 --- a/codex-rs/responses-api-proxy/src/lib.rs +++ b/codex-rs/responses-api-proxy/src/lib.rs @@ -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, + + /// 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, + + /// OpenAI workload identity provider ID. + #[arg(long, requires = "github_oidc", value_name = "ID")] + pub identity_provider_id: Option, + + /// OpenAI service account ID selected by the provider mapping. + #[arg(long, requires = "github_oidc", value_name = "ID")] + pub service_account_id: Option, + + /// 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, +} + +impl Args { + fn auth(&self) -> Result { + 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, 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()) { diff --git a/codex-rs/responses-api-proxy/src/read_api_key.rs b/codex-rs/responses-api-proxy/src/read_api_key.rs index a406ea4f84..2d7e9ddb5f 100644 --- a/codex-rs/responses-api-proxy/src/read_api_key.rs +++ b/codex-rs/responses-api-proxy/src/read_api_key.rs @@ -215,7 +215,7 @@ fn validate_auth_header_bytes(mut key_bytes: &[u8]) -> Result { } 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