Add file-backed storage for gateway OAuth credentials (#58219)

## What changed

Allow gateway OAuth configuration to select `credentials_store = "file"` to persist credentials in plaintext at `$CODEX_HOME/secrets/gateway_oauth.json`. Keep `keyring` as the default.

Read, update, and delete file-backed credentials by credential ID, using atomic writes and the existing cross-process lock. Preserve the serialized provider identity when using the default store.

## Testing

Add an app-server test that completes gateway OAuth login with file storage, verifies Unix file permissions are `0600`, and checks that login remains successful after a server restart.

GitOrigin-RevId: dc1d17922bd80bf65034cbc5aa3d97959e962dcc
This commit is contained in:
alexsong-oai
2026-10-30 02:28:51 +00:00
committed by copyberry
parent 55c099a142
commit cc2f80d515
15 changed files with 211 additions and 34 deletions
+1
View File
@@ -3965,6 +3965,7 @@ dependencies = [
"codex-secrets",
"codex-terminal-detection",
"codex-utils-cargo-bin",
"codex-utils-path",
"codex-utils-process",
"codex-utils-template",
"codex-workload-identity",
@@ -7,6 +7,7 @@ use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::GatewayOAuthCancelResponse;
use codex_app_server_protocol::GatewayOAuthChangedNotification;
use codex_app_server_protocol::GatewayOAuthLoginResponse;
use codex_app_server_protocol::GatewayOAuthReadResponse;
use codex_app_server_protocol::GatewayOAuthStatus;
use codex_app_server_protocol::InitializeCapabilities;
@@ -172,6 +173,60 @@ async fn model_list_leaves_oauth_gateway_after_account_change() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn file_credentials_reuse_gateway_login_after_restart() -> Result<()> {
let home = TempDir::new()?;
let oauth = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/token"))
.and(body_string_contains("grant_type=authorization_code"))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(serde_json::json!({
"access_token": "gateway-access", "refresh_token": "gateway-refresh", "expires_in": 3600,
})))
.expect(/*r*/ 1)
.mount(&oauth)
.await;
// Exercise the managed provider definition used by headless installations.
std::fs::write(
home.path().join("requirements.toml"),
format!("{}\ncredentials_store = 'file'\n", gateway_config(&oauth)),
)?;
let mut server = gateway_server(home.path()).await?;
let login = server
.send_raw_request("account/gatewayOAuth/login", /*params*/ None)
.await?;
let url = authorization_url(&mut server).await?;
let query = url
.query_pairs()
.collect::<std::collections::HashMap<_, _>>();
let mut callback = url::Url::parse(query.get("redirect_uri").context("callback URI")?)?;
callback
.query_pairs_mut()
.append_pair("code", "accepted")
.append_pair("state", query.get("state").context("OAuth state")?);
codex_login::default_client::create_client_without_request_logging()
.get(callback.as_str())
.send()
.await?
.error_for_status()?;
let _: GatewayOAuthLoginResponse = server.read_response(login).await?;
server.shutdown_gracefully().await?;
let path = home.path().join("secrets/gateway_oauth.json");
assert!(path.is_file());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(std::fs::metadata(path)?.permissions().mode() & 0o777, 0o600);
}
let mut restarted = gateway_server(home.path()).await?;
assert_eq!(
read_gateway(&mut restarted).await?.status,
Some(GatewayOAuthStatus::Succeeded)
);
Ok(())
}
#[tokio::test]
async fn token_exchange_failure_completes_login_and_updates_readiness() -> Result<()> {
let home = TempDir::new()?;
@@ -44,6 +44,7 @@ fn oauth_config(base_url: &str) -> OAuthSessionConfig {
resource: None,
scopes: vec![],
redirect_port: None,
credentials_store: Default::default(),
}
}
+26
View File
@@ -1991,6 +1991,14 @@
"client_id": {
"type": "string"
},
"credentials_store": {
"allOf": [
{
"$ref": "#/definitions/OAuthCredentialsStore"
}
],
"description": "Where Codex stores this gateway's OAuth credentials. Defaults to the keyring-backed store."
},
"delivery": {
"allOf": [
{
@@ -3722,6 +3730,24 @@
}
]
},
"OAuthCredentialsStore": {
"oneOf": [
{
"description": "Encrypt credentials using a passphrase kept in the OS keyring.",
"enum": [
"keyring"
],
"type": "string"
},
{
"description": "Store credentials in plaintext in `$CODEX_HOME/secrets/gateway_oauth.json`.",
"enum": [
"file"
],
"type": "string"
}
]
},
"OAuthCredentialsStoreMode": {
"description": "Determine where Codex should store and read MCP credentials.",
"oneOf": [
@@ -96,6 +96,7 @@ fn gateway_config(server: &MockServer, delivery: GatewayOAuthDelivery) -> Gatewa
resource: None,
scopes: vec![],
redirect_port: None,
credentials_store: Default::default(),
delivery,
}
}
+1
View File
@@ -21,6 +21,7 @@ codex-otel = { workspace = true }
codex-protocol = { workspace = true }
codex-secrets = { workspace = true }
codex-terminal-detection = { workspace = true }
codex-utils-path = { workspace = true }
codex-utils-template = { workspace = true }
codex-workload-identity = { workspace = true }
http = { workspace = true }
+8 -13
View File
@@ -125,7 +125,7 @@ struct OAuthSessionCache {
}
impl OAuthSessionManager {
/// Creates a configured OAuth session using the caller's encrypted store and HTTP policy.
/// Creates an OAuth session using the configured credential store and the caller's HTTP policy.
/// Token grants never follow redirects or include primary-provider credentials or request logs.
pub fn new(
config: OAuthSessionConfig,
@@ -146,9 +146,13 @@ impl OAuthSessionManager {
control: OAuthLoginControl::for_host(&codex_home, http_client_factory),
allow_interactive_login: true,
state: Arc::new(OAuthSessionState {
storage: OAuthSessionStorage::new(
codex_home.clone(),
config.credentials_store,
keyring,
),
config,
status_tx: login::status_sender_for_host(&codex_home, http_client_factory),
storage: OAuthSessionStorage::new(codex_home.clone(), keyring),
codex_home,
http_client,
cached_token: Arc::new(Mutex::new(OAuthSessionCache::default())),
@@ -425,20 +429,11 @@ impl OAuthSessionManager {
}
fn load_token(&self) -> io::Result<Option<StoredToken>> {
self.state
.storage
.load(&self.credential_id())?
.map(|value| {
serde_json::from_str(&value)
.map_err(|_| io::Error::other("stored provider OAuth credentials are invalid"))
})
.transpose()
self.state.storage.load(&self.credential_id())
}
fn save_token(&self, token: &StoredToken) -> io::Result<()> {
let value = serde_json::to_string(token)
.map_err(|_| io::Error::other("failed to encode provider OAuth credentials"))?;
self.state.storage.save(&self.credential_id(), &value)
self.state.storage.save(&self.credential_id(), token)
}
async fn authorize_with_browser(
+78 -21
View File
@@ -1,6 +1,7 @@
//! Persists configured OAuth grants and serializes exchanges in one encrypted store.
//! Persists configured OAuth grants and serializes exchanges across processes.
//! Gateway storage paths and namespace are retained so existing credentials remain usable.
use std::collections::BTreeMap;
use std::fs::File;
use std::fs::OpenOptions;
use std::fs::TryLockError;
@@ -11,16 +12,20 @@ use std::sync::Arc;
use std::time::Duration;
use codex_keyring_store::KeyringStore;
use codex_model_provider_info::OAuthCredentialsStore;
use codex_secrets::LocalSecretsNamespace;
use codex_secrets::SecretName;
use codex_secrets::SecretScope;
use codex_secrets::SecretsBackendKind;
use codex_secrets::SecretsManager;
use codex_utils_path::write_atomically;
use super::StoredToken;
pub(super) async fn lock_credentials(codex_home: &Path) -> io::Result<File> {
let directory = codex_home.join("secrets");
std::fs::create_dir_all(&directory)?;
// Configurations have separate credential entries but rewrite the same encrypted file.
// Configurations have separate credential entries but rewrite the same store file.
// Keep one stable sidecar locked from the initial read through token exchange and save.
let path = directory.join("gateway_oauth.lock");
let file = OpenOptions::new()
@@ -50,37 +55,89 @@ pub(super) async fn lock_credentials(codex_home: &Path) -> io::Result<File> {
Ok(file)
}
pub(super) struct OAuthSessionStorage(SecretsManager);
pub(super) enum OAuthSessionStorage {
Keyring(SecretsManager),
File(PathBuf),
}
impl OAuthSessionStorage {
pub(super) fn new(codex_home: PathBuf, keyring: Arc<dyn KeyringStore>) -> Self {
Self(SecretsManager::new_with_keyring_store_and_namespace(
codex_home,
SecretsBackendKind::Local,
keyring,
LocalSecretsNamespace::GatewayOAuth,
))
pub(super) fn new(
codex_home: PathBuf,
mode: OAuthCredentialsStore,
keyring: Arc<dyn KeyringStore>,
) -> Self {
match mode {
OAuthCredentialsStore::Keyring => {
Self::Keyring(SecretsManager::new_with_keyring_store_and_namespace(
codex_home,
SecretsBackendKind::Local,
keyring,
LocalSecretsNamespace::GatewayOAuth,
))
}
OAuthCredentialsStore::File => {
Self::File(codex_home.join("secrets").join("gateway_oauth.json"))
}
}
}
pub(super) fn load(&self, credential_id: &str) -> io::Result<Option<String>> {
self.0
.get(&SecretScope::Global, &secret_name(credential_id)?)
.map_err(|_| io::Error::other("failed to load provider OAuth credentials"))
pub(super) fn load(&self, credential_id: &str) -> io::Result<Option<StoredToken>> {
match self {
Self::Keyring(secrets) => secrets
.get(&SecretScope::Global, &secret_name(credential_id)?)
.map_err(|_| io::Error::other("failed to load provider OAuth credentials"))?
.map(|value| serde_json::from_str(&value).map_err(invalid_credentials))
.transpose(),
Self::File(path) => Ok(read_tokens(path)?.remove(credential_id)),
}
}
pub(super) fn save(&self, credential_id: &str, value: &str) -> io::Result<()> {
self.0
.set(&SecretScope::Global, &secret_name(credential_id)?, value)
.map_err(|_| io::Error::other("failed to save provider OAuth credentials"))
pub(super) fn save(&self, credential_id: &str, token: &StoredToken) -> io::Result<()> {
match self {
Self::Keyring(secrets) => {
let value = serde_json::to_string(token)?;
secrets
.set(&SecretScope::Global, &secret_name(credential_id)?, &value)
.map_err(|_| io::Error::other("failed to save provider OAuth credentials"))
}
Self::File(path) => {
let mut tokens = read_tokens(path)?;
tokens.insert(credential_id.to_owned(), token.clone());
write_atomically(path, &serde_json::to_string(&tokens)?)
}
}
}
pub(super) fn delete(&self, credential_id: &str) -> io::Result<bool> {
self.0
.delete(&SecretScope::Global, &secret_name(credential_id)?)
.map_err(|_| io::Error::other("failed to remove provider OAuth credentials"))
match self {
Self::Keyring(secrets) => secrets
.delete(&SecretScope::Global, &secret_name(credential_id)?)
.map_err(|_| io::Error::other("failed to remove provider OAuth credentials")),
Self::File(path) => {
let mut tokens = read_tokens(path)?;
let removed = tokens.remove(credential_id).is_some();
if removed {
write_atomically(path, &serde_json::to_string(&tokens)?)?;
}
Ok(removed)
}
}
}
}
fn read_tokens(path: &Path) -> io::Result<BTreeMap<String, StoredToken>> {
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(invalid_credentials),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(BTreeMap::new()),
Err(err) => Err(err),
}
}
fn invalid_credentials(_: serde_json::Error) -> io::Error {
// Deserialization errors can contain values from the credential file.
io::Error::other("stored provider OAuth credentials are invalid")
}
fn secret_name(credential_id: &str) -> io::Result<SecretName> {
let digest = credential_id
.strip_prefix("provider-oauth|")
@@ -57,6 +57,7 @@ fn config(server: &MockServer) -> OAuthSessionConfig {
resource: Some("https://gateway.example.test/codex".to_string()),
scopes: vec!["openid".to_string(), "gateway.inference".to_string()],
redirect_port: None,
credentials_store: Default::default(),
}
}
@@ -69,6 +70,7 @@ fn loopback_config() -> OAuthSessionConfig {
resource: None,
scopes: Vec::new(),
redirect_port: None,
credentials_store: Default::default(),
}
}
@@ -10,6 +10,7 @@ use serde::Deserialize;
use serde::Serialize;
use crate::ModelProviderInfo;
use crate::OAuthCredentialsStore;
// Reserve authentication, routing/framing, and internal protocol headers.
const RESERVED_GATEWAY_OAUTH_HEADERS: &[&str] = &[
@@ -36,11 +37,23 @@ pub struct GatewayOAuthConfig {
#[serde(default)]
pub scopes: Vec<String>,
pub redirect_port: Option<u16>,
/// Where Codex stores this gateway's OAuth credentials. Defaults to the keyring-backed store.
#[serde(default, skip_serializing_if = "is_keyring")]
pub credentials_store: OAuthCredentialsStore,
/// Omit to send the gateway token via the Authorization header
#[serde(default)]
pub delivery: GatewayOAuthDelivery,
}
// Preserve the serialized provider and model cache identity for the default store.
#[expect(
clippy::trivially_copy_pass_by_ref,
reason = "Serde passes a reference to skip_serializing_if predicates"
)]
fn is_keyring(store: &OAuthCredentialsStore) -> bool {
*store == OAuthCredentialsStore::Keyring
}
impl fmt::Debug for GatewayOAuthConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("GatewayOAuthConfig")
+1
View File
@@ -41,6 +41,7 @@ pub use capabilities::ModelProviderCapabilities;
pub use capabilities::RemoteCompactionSupport;
pub use gateway_oauth::GatewayOAuthConfig;
pub use gateway_oauth::GatewayOAuthDelivery;
pub use oauth_session::OAuthCredentialsStore;
pub use oauth_session::OAuthSessionConfig;
pub const RESIDENCY_HEADER_NAME: &str = "x-openai-internal-codex-residency";
@@ -1,5 +1,6 @@
//! Public-client OAuth settings shared by managed configuration and service sign-in.
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::fmt;
@@ -17,6 +18,19 @@ pub struct OAuthSessionConfig {
#[serde(default)]
pub scopes: Vec<String>,
pub redirect_port: Option<u16>,
/// Selected by a gateway provider. Managed config-server OAuth uses the default store.
#[serde(skip)]
pub credentials_store: OAuthCredentialsStore,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum OAuthCredentialsStore {
/// Encrypt credentials using a passphrase kept in the OS keyring.
#[default]
Keyring,
/// Store credentials in plaintext in `$CODEX_HOME/secrets/gateway_oauth.json`.
File,
}
impl fmt::Debug for OAuthSessionConfig {
@@ -46,6 +46,7 @@ impl ModelProviderSharedState {
resource: config.resource.clone(),
scopes: config.scopes.clone(),
redirect_port: config.redirect_port,
credentials_store: config.credentials_store,
};
let mut managers = self
.gateway_managers
@@ -79,6 +79,7 @@ pub fn seed_gateway_auth(
resource: config.resource,
scopes: config.scopes,
redirect_port: config.redirect_port,
credentials_store: config.credentials_store,
};
// Repeated fixtures sharing a Codex home must use the same encryption key.
let keyring = Arc::new(TestKeyring(Mutex::new(Some(
@@ -74,6 +74,7 @@ async fn gateway_credentials_accompany_primary_auth_in_models_and_responses() {
resource: None,
scopes: vec![],
redirect_port: None,
credentials_store: Default::default(),
delivery: delivery.clone(),
};
let info = ModelProviderInfo {
@@ -210,6 +211,7 @@ async fn gateway_preserves_runtime_auth_and_rejects_header_collisions() {
resource: None,
scopes: vec![],
redirect_port: None,
credentials_store: Default::default(),
delivery: GatewayOAuthDelivery::Header {
name: "x-gateway-auth".into(),
scheme: "Bearer".into(),
@@ -311,6 +313,7 @@ async fn gateway_refresh_preserves_primary_auth_and_hides_issuer_errors() {
resource: None,
scopes: vec![],
redirect_port: None,
credentials_store: Default::default(),
delivery: GatewayOAuthDelivery::Header {
name: "x-gateway-auth".into(),
scheme: "Bearer".into(),
@@ -405,6 +408,7 @@ async fn models_cache_is_reused_only_for_matching_gateway_configuration() {
resource: None,
scopes: vec![],
redirect_port: None,
credentials_store: Default::default(),
delivery: GatewayOAuthDelivery::Header {
name: "x-gateway-auth".into(),
scheme: "Bearer".into(),
@@ -485,6 +489,7 @@ async fn models_observe_gateway_rotation(instance: ProviderInstance) {
resource: None,
scopes: vec![],
redirect_port: None,
credentials_store: Default::default(),
delivery: GatewayOAuthDelivery::Header {
name: "x-gateway-auth".into(),
scheme: "Bearer".into(),
@@ -599,6 +604,7 @@ async fn gateway_setup_errors_are_reported_when_authentication_is_requested() {
resource: None,
scopes: vec![],
redirect_port: None,
credentials_store: Default::default(),
delivery: GatewayOAuthDelivery::Header {
name: "x-gateway-auth".into(),
scheme: "Bearer".into(),
@@ -666,6 +672,7 @@ async fn gateway_login_required_invalidates_the_last_successful_catalog() {
resource: None,
scopes: vec![],
redirect_port: None,
credentials_store: Default::default(),
delivery: GatewayOAuthDelivery::Header {
name: "x-gateway-auth".into(),
scheme: "Bearer".into(),
@@ -750,6 +757,7 @@ async fn gateway_login_states_stop_request_retries() {
resource: None,
scopes: vec![],
redirect_port: None,
credentials_store: Default::default(),
delivery: GatewayOAuthDelivery::Header {
name: "x-gateway-auth".into(),
scheme: "Bearer".into(),