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
+94 -6
View File
@@ -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 <key>` 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 <key>` 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 <key>` 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": <u16>, "pid": <u32> }`.
- 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 <PORT>] [--server-info <FILE>] [--http-shutdown] [--upstream-url <URL>] [--dump-dir <DIR>]
[--github-oidc --oidc-audience <AUDIENCE> --identity-provider-id <ID>
--service-account-id <ID> --github-oidc-request-url <URL>]
```
- `--port <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 <PORT>] [--server-info <FILE>] [--http-shutdow
- `--http-shutdown`: If set, enables `GET /shutdown` to exit the process with code `0`.
- `--upstream-url <URL>`: Absolute URL to forward requests to. Defaults to `https://api.openai.com/v1/responses`.
- `--dump-dir <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 <key>` to match the Codex CLI expectations.
- Authentication is `Authorization: Bearer <key>` in API-key mode and `Authorization: Bearer <OpenAI access token>` in OIDC mode.
For Azure, for example (ensure your deployment accepts `Authorization: Bearer <key>`):
@@ -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);
+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