Add OAuth credential management for model provider gateways (#46318)

## What changed

- Export `GatewayAuthConfig` and `GatewayAuthManager` with PKCE browser sign-in, loopback callbacks, cached token resolution, and refresh after expiry or rejection.
- Store gateway credentials in a dedicated encrypted namespace with an independent keyring key. Serialize token exchanges and persistence across processes, preserve refresh rotations after caller cancellation, and retain pending credentials when saving fails.
- Validate OAuth endpoints and token responses, disable token-request redirects and logging, and redact sensitive error details.

## Testing

Add tests covering browser authorization, callback state validation and cleanup, concurrent refreshes, cancellation, failed-save recovery, storage isolation, endpoint validation, and credential redaction.

GitOrigin-RevId: e0c17f1eab7acca378a14b2d00b80940d8542e47
This commit is contained in:
alexsong-oai
2026-09-17 22:23:23 +00:00
committed by copyberry
parent c775dd3c33
commit a129392ebb
11 changed files with 1965 additions and 15 deletions

View File

@@ -381,7 +381,7 @@ fn assert_keyring_saved_auth_and_removed_fallback(
mock_keyring.saved_value(&old_key).is_none(),
"legacy keyring auth entry should not be used"
);
let secrets_key = compute_keyring_account(codex_home);
let secrets_key = compute_keyring_account(codex_home, LocalSecretsNamespace::CodexAuth);
assert!(
mock_keyring.saved_value(&secrets_key).is_some(),
"secrets backend should persist an encryption passphrase in the keyring"
@@ -576,7 +576,10 @@ fn factory_uses_secrets_backend_only_when_requested() -> anyhow::Result<()> {
secrets_storage.save(&secrets_auth)?;
assert!(
secrets_keyring
.saved_value(&compute_keyring_account(secrets_home.path()))
.saved_value(&compute_keyring_account(
secrets_home.path(),
LocalSecretsNamespace::CodexAuth,
))
.is_some()
);
assert!(encrypted_auth_file(secrets_home.path()).exists());
@@ -724,7 +727,7 @@ fn auto_auth_storage_load_falls_back_when_keyring_errors() -> anyhow::Result<()>
Arc::new(mock_keyring.clone()),
AuthKeyringBackendKind::Secrets,
);
let key = compute_keyring_account(codex_home.path());
let key = compute_keyring_account(codex_home.path(), LocalSecretsNamespace::CodexAuth);
let encrypted = auth_with_prefix("encrypted");
seed_secrets_backend_with_auth(&mock_keyring, codex_home.path(), &encrypted)?;
@@ -766,7 +769,7 @@ fn auto_auth_storage_save_falls_back_when_keyring_errors() -> anyhow::Result<()>
Arc::new(mock_keyring.clone()),
AuthKeyringBackendKind::Secrets,
);
let key = compute_keyring_account(codex_home.path());
let key = compute_keyring_account(codex_home.path(), LocalSecretsNamespace::CodexAuth);
mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "save".into()));
let auth = auth_with_prefix("fallback");

View File

@@ -0,0 +1,469 @@
//! Owns gateway credentials and coordinates refresh and browser login through shared OAuth operations.
//! Rotated credentials survive caller cancellation and remain pending until persistence succeeds.
use std::fmt;
use std::io;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use crate::oauth::AuthorizationCodeGrant;
use crate::oauth::AuthorizationRequest;
use crate::oauth::ErrorBodyLimit;
use crate::oauth::OAuthClient;
use crate::oauth::OAuthError;
use crate::oauth::RefreshTokenGrant;
use crate::oauth::TokenEncoding;
use crate::oauth::TokenEndpoint;
use crate::oauth::build_authorization_url;
use crate::oauth::generate_pkce;
use crate::oauth::generate_state;
use chrono::Utc;
use codex_http_client::ClientRouteClass;
use codex_http_client::HttpClient;
use codex_http_client::HttpClientBuilder;
use codex_http_client::HttpClientFactory;
use codex_keyring_store::KeyringStore;
use http::StatusCode;
use sha2::Digest;
use sha2::Sha256;
use tokio::sync::Mutex;
use url::Host;
use url::Url;
#[path = "gateway_auth_callback.rs"]
mod callback;
#[path = "gateway_auth_storage.rs"]
mod storage;
#[path = "gateway_auth_token.rs"]
mod token;
use callback::CallbackListener;
use storage::GatewayAuthStorage;
use token::StoredToken;
use token::TokenResponse;
const REFRESH_SKEW_SECONDS: i64 = 30;
const HTTP_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 20);
/// Public-client OAuth settings for a model provider.
#[derive(Clone, PartialEq, Eq)]
pub struct GatewayAuthConfig {
pub authorization_url: String,
pub token_url: String,
pub client_id: String,
pub resource: Option<String>,
pub scopes: Vec<String>,
pub redirect_port: Option<u16>,
}
/// Resolves and persists an OAuth access token for a model provider.
#[derive(Clone)]
pub struct GatewayAuthManager {
state: Arc<GatewayAuthState>,
}
enum RefreshPolicy {
WhenExpired,
AfterRejection(String),
}
impl RefreshPolicy {
fn can_reuse(&self, token: &StoredToken) -> bool {
token_is_usable(token)
&& match self {
Self::WhenExpired => true,
Self::AfterRejection(rejected_access_token) => {
token.access_token != *rejected_access_token
}
}
}
}
enum RefreshOutcome {
AccessToken(String),
Authorize,
}
struct GatewayAuthState {
config: GatewayAuthConfig,
codex_home: PathBuf,
storage: GatewayAuthStorage,
http_client: HttpClient,
cached_token: Arc<Mutex<GatewayAuthCache>>,
}
#[derive(Default)]
struct GatewayAuthCache {
token: Option<StoredToken>,
// Retain rotated credentials across a failed save. The prior persisted token remains
// available for comparison so retrying cannot overwrite a newer external login.
pending: Option<StoredToken>,
}
impl GatewayAuthManager {
/// Creates independent gateway credentials in their encrypted store using the caller's HTTP policy.
/// Token grants never follow redirects or include primary-provider credentials or request logs.
pub fn new(
config: GatewayAuthConfig,
codex_home: PathBuf,
http_client_factory: &HttpClientFactory,
keyring: Arc<dyn KeyringStore>,
) -> io::Result<Self> {
let http_client = HttpClientBuilder::new()
.without_redirects()
.without_request_logging()
.build_respecting_outbound_proxy_policy(
http_client_factory,
&config.token_url,
ClientRouteClass::Auth,
)
.map_err(|_| io::Error::other("failed to create provider OAuth HTTP client"))?;
Ok(Self {
state: Arc::new(GatewayAuthState {
config,
storage: GatewayAuthStorage::new(codex_home.clone(), keyring),
codex_home,
http_client,
cached_token: Arc::new(Mutex::new(GatewayAuthCache::default())),
}),
})
}
/// Returns a cached access token or refreshes/authorizes when it is no longer usable.
pub async fn resolve_access_token(&self) -> io::Result<String> {
self.resolve(RefreshPolicy::WhenExpired).await
}
/// Recovers after a request rejects `rejected_access_token`, reusing a usable replacement
/// from storage or refreshing/authorizing when necessary. Pass the access token used by the
/// failed request; callers must bound retries and decide whether the request is safe to replay.
pub async fn refresh_access_token(&self, rejected_access_token: &str) -> io::Result<String> {
self.resolve(RefreshPolicy::AfterRejection(
rejected_access_token.to_owned(),
))
.await
}
async fn resolve(&self, policy: RefreshPolicy) -> io::Result<String> {
validate_config(&self.state.config)?;
let mut cached = Arc::clone(&self.state.cached_token).lock_owned().await;
if cached.token.is_none() && cached.pending.is_none() {
cached.token = self.load_token()?;
}
if cached.pending.is_none()
&& matches!(policy, RefreshPolicy::WhenExpired)
&& let Some(token) = cached.token.as_ref()
&& token_is_usable(token)
{
return Ok(token.access_token.clone());
}
// The provider may rotate its token before the HTTP response arrives. Keep the
// refresh, persistence, and cache update alive if the caller drops this future.
let manager = self.clone();
let (mut cached, result) = tokio::spawn(async move {
let result = manager.refresh(&mut cached, &policy).await;
(cached, result)
})
.await
.map_err(|_| io::Error::other("provider OAuth refresh task failed"))?;
match result? {
RefreshOutcome::AccessToken(access_token) => Ok(access_token),
RefreshOutcome::Authorize => self.authorize(&mut cached).await,
}
}
fn persist_pending(&self, cached: &mut GatewayAuthCache) -> io::Result<String> {
let token = cached
.pending
.as_ref()
.ok_or_else(|| io::Error::other("provider OAuth credentials are missing"))?;
self.save_token(token)?;
let access_token = token.access_token.clone();
cached.token = cached.pending.take();
Ok(access_token)
}
async fn refresh(
&self,
cached: &mut GatewayAuthCache,
policy: &RefreshPolicy,
) -> io::Result<RefreshOutcome> {
let _credential_lock = storage::lock_credentials(&self.state.codex_home).await?;
// Recovery always rereads under the cross-process lock before choosing a token.
// Even a replacement from storage must differ from the token rejected by this request.
for _ in 0..2 {
let stored = self.load_token()?;
if stored != cached.token {
cached.token = stored;
cached.pending = None;
}
if cached.pending.is_some() {
self.persist_pending(cached)?;
}
if let Some(token) = cached.token.as_ref()
&& policy.can_reuse(token)
{
return Ok(RefreshOutcome::AccessToken(token.access_token.clone()));
}
let Some(refresh_token) = cached
.token
.as_ref()
.and_then(|token| token.refresh_token.as_deref())
else {
break;
};
match self
.oauth()
.refresh::<TokenResponse>(RefreshTokenGrant {
refresh_token,
resource: self.state.config.resource.as_deref(),
})
.await
{
Ok(response) => {
cached.pending = Some(response.into_stored(Some(refresh_token))?);
return self
.persist_pending(cached)
.map(RefreshOutcome::AccessToken);
}
Err(OAuthError::Rejected(rejection))
if rejection.status == StatusCode::BAD_REQUEST
&& matches!(
rejection.detail.error_code(),
Some(
"invalid_grant" | "unauthorized_client" | "unsupported_grant_type"
)
) =>
{
// Some public clients receive refresh tokens despite being unable to use
// that grant. Reauthorize after explicit rejection without disabling refresh.
// Also recover updates from clients that predate the credential lock.
if self.load_token()? == cached.token {
break;
}
}
Err(error) => {
return Err(token::endpoint_error(
error,
&self.state.config,
"refresh_token",
/*redirect_uri*/ None,
));
}
}
}
let stored = self.load_token()?;
if stored != cached.token {
cached.token = stored;
cached.pending = None;
if let Some(token) = cached.token.as_ref()
&& policy.can_reuse(token)
{
return Ok(RefreshOutcome::AccessToken(token.access_token.clone()));
}
}
Ok(RefreshOutcome::Authorize)
}
fn credential_id(&self) -> String {
let config = &self.state.config;
let mut digest = Sha256::new();
digest.update(self.state.codex_home.to_string_lossy().as_bytes());
digest.update([0]);
for value in [
config.authorization_url.as_str(),
config.token_url.as_str(),
config.client_id.as_str(),
config.resource.as_deref().unwrap_or_default(),
] {
digest.update(value.as_bytes());
digest.update([0]);
}
for scope in &config.scopes {
digest.update(scope.as_bytes());
digest.update([0]);
}
format!("provider-oauth|{:x}", digest.finalize())
}
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()
}
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)
}
async fn authorize(&self, cached: &mut GatewayAuthCache) -> io::Result<String> {
self.authorize_with_browser(cached, |authorization_url| {
eprintln!("Authorize the model provider by opening this URL:\n{authorization_url}\n");
if webbrowser::open(authorization_url.as_str()).is_err() {
eprintln!("Browser launch failed; open the URL above manually.");
}
})
.await
}
async fn authorize_with_browser(
&self,
cached: &mut GatewayAuthCache,
open_browser: impl FnOnce(&Url),
) -> io::Result<String> {
let pkce = generate_pkce();
let state = generate_state();
let mut listener = CallbackListener::new(self.state.config.redirect_port, state.clone())?;
let redirect_uri = listener.redirect_uri().to_string();
let scope = self.state.config.scopes.join(" ");
let authorization_url = build_authorization_url(AuthorizationRequest {
endpoint: &self.state.config.authorization_url,
client_id: &self.state.config.client_id,
redirect_uri: &redirect_uri,
scope: (!scope.is_empty()).then_some(scope.as_str()),
resource: self.state.config.resource.as_deref(),
pkce: &pkce,
state: &state,
extra_parameters: &[],
})
.map_err(|_| io::Error::other("invalid provider OAuth authorization endpoint"))?;
open_browser(&authorization_url);
let code = listener.wait().await?;
drop(listener);
// Wait for user interaction without the store lock, then serialize issuance and
// persistence with refreshes. Use the current stored token as the failed-save baseline.
let _credential_lock = storage::lock_credentials(&self.state.codex_home).await?;
cached.token = self.load_token()?;
let response = self
.oauth()
.exchange_code::<TokenResponse>(AuthorizationCodeGrant {
code: &code,
redirect_uri: &redirect_uri,
pkce: &pkce,
resource: self.state.config.resource.as_deref(),
})
.await
.map_err(|error| {
token::endpoint_error(
error,
&self.state.config,
"authorization_code",
Some(&redirect_uri),
)
})?;
cached.pending = Some(response.into_stored(/*previous_refresh_token*/ None)?);
self.persist_pending(cached)
}
fn oauth(&self) -> OAuthClient<'_> {
OAuthClient::new(
&self.state.http_client,
TokenEndpoint {
url: &self.state.config.token_url,
client_id: &self.state.config.client_id,
encoding: TokenEncoding::Form,
timeout: Some(HTTP_TIMEOUT),
error_body_limit: ErrorBodyLimit::Bytes(8 * 1024),
},
)
}
}
impl fmt::Debug for GatewayAuthConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
// Configured URLs may contain issuer-specific credentials in arbitrary query keys.
formatter
.debug_struct("GatewayAuthConfig")
.field("client_id", &self.client_id)
.field("scopes", &self.scopes)
.field("redirect_port", &self.redirect_port)
.finish_non_exhaustive()
}
}
impl fmt::Debug for GatewayAuthManager {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("GatewayAuthManager")
.field("config", &self.state.config)
.finish_non_exhaustive()
}
}
fn token_is_usable(token: &StoredToken) -> bool {
token
.expires_at
.is_none_or(|expires_at| expires_at > Utc::now().timestamp() + REFRESH_SKEW_SECONDS)
}
fn validate_config(config: &GatewayAuthConfig) -> io::Result<()> {
let authorization = validate_oauth_url(
&config.authorization_url,
"provider OAuth authorization endpoint",
)?;
if authorization.query_pairs().any(|(name, _)| {
matches!(
name.as_ref(),
"response_type"
| "client_id"
| "redirect_uri"
| "state"
| "scope"
| "resource"
| "code_challenge"
| "code_challenge_method"
)
}) {
return Err(io::Error::other(
"provider OAuth authorization endpoint cannot include OAuth request parameters",
));
}
validate_oauth_url(&config.token_url, "provider OAuth token endpoint")?;
if config.client_id.trim().is_empty() {
return Err(io::Error::other(
"provider OAuth client ID must not be empty",
));
}
if config.redirect_port == Some(0) {
return Err(io::Error::other(
"provider OAuth redirect port must not be zero",
));
}
Ok(())
}
fn validate_oauth_url(value: &str, description: &str) -> io::Result<Url> {
let url = Url::parse(value).map_err(|_| io::Error::other(format!("invalid {description}")))?;
if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() {
return Err(io::Error::other(format!(
"{description} cannot include embedded credentials or fragments"
)));
}
let is_loopback = match url.host() {
Some(Host::Ipv4(address)) => address.is_loopback(),
Some(Host::Ipv6(address)) => address.is_loopback(),
Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
None => false,
};
if url.scheme() != "https" && !(url.scheme() == "http" && is_loopback) {
return Err(io::Error::other(format!(
"{description} must use HTTPS unless it is loopback"
)));
}
Ok(url)
}
#[cfg(test)]
#[path = "gateway_auth_tests.rs"]
mod tests;

View File

@@ -0,0 +1,115 @@
//! Owns the loopback listener lifecycle; OAuth callback parsing and state validation are shared.
use std::io;
use std::sync::Arc;
use std::time::Duration;
use crate::oauth::CallbackError;
use crate::oauth::CallbackParameters;
use tiny_http::Response;
use tiny_http::Server;
use tokio::sync::oneshot;
use tokio::time::timeout;
use url::Url;
const BROWSER_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 180);
pub(super) struct CallbackListener {
server: Arc<Server>,
receiver: oneshot::Receiver<io::Result<String>>,
redirect_uri: String,
}
impl CallbackListener {
pub(super) fn new(redirect_port: Option<u16>, expected_state: String) -> io::Result<Self> {
let callback_address = format!("127.0.0.1:{}", redirect_port.unwrap_or_default());
let server =
Arc::new(Server::http(callback_address).map_err(|_| {
io::Error::other("failed to bind provider OAuth loopback callback")
})?);
let redirect_uri = match server.server_addr() {
tiny_http::ListenAddr::IP(address) => format!("http://{address}/callback"),
#[cfg(not(target_os = "windows"))]
_ => return Err(io::Error::other("invalid provider OAuth loopback address")),
};
let (sender, receiver) = oneshot::channel();
let callback_server = Arc::clone(&server);
tokio::task::spawn_blocking(move || {
while let Ok(request) = callback_server.recv() {
let Ok(callback) = Url::parse(&format!("http://127.0.0.1{}", request.url())) else {
let _ = request.respond(
Response::from_string("Invalid OAuth callback")
.with_status_code(/*code*/ 400),
);
continue;
};
if callback.path() != "/callback" {
let _ = request
.respond(Response::from_string("Not found").with_status_code(/*code*/ 404));
continue;
}
let params = CallbackParameters::from_url(&callback);
let result = match params.validate(&expected_state) {
Ok(code) => Ok(code.to_string()),
Err(CallbackError::StateMismatch) => {
let _ = request.respond(
Response::from_string("OAuth callback state did not match")
.with_status_code(/*code*/ 400),
);
continue;
}
Err(CallbackError::Provider { .. }) => Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"provider OAuth authorization was denied",
)),
Err(CallbackError::MissingCode) => {
Err(io::Error::other("provider OAuth callback omitted its code"))
}
};
let mut response = if result.is_ok() {
Response::from_string(
"<!doctype html><html><body><p>Sign-in complete. You may close this window.</p><script>window.close()</script></body></html>",
)
} else {
Response::from_string("Sign-in failed.").with_status_code(/*code*/ 400)
};
if result.is_ok()
&& let Ok(header) = tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"text/html; charset=utf-8"[..],
)
{
response.add_header(header);
}
let _ = request.respond(response);
let _ = sender.send(result);
break;
}
});
Ok(Self {
server,
receiver,
redirect_uri,
})
}
pub(super) fn redirect_uri(&self) -> &str {
&self.redirect_uri
}
pub(super) async fn wait(&mut self) -> io::Result<String> {
timeout(BROWSER_TIMEOUT, &mut self.receiver)
.await
.map_err(|_| io::Error::other("timed out waiting for provider OAuth sign-in"))?
.map_err(|_| io::Error::other("provider OAuth sign-in was cancelled"))?
}
}
impl Drop for CallbackListener {
fn drop(&mut self) {
self.server.unblock();
}
}

View File

@@ -0,0 +1,83 @@
//! Persists gateway credentials and serializes exchanges across all configurations in one store.
use std::fs::File;
use std::fs::OpenOptions;
use std::fs::TryLockError;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use codex_keyring_store::KeyringStore;
use codex_secrets::LocalSecretsNamespace;
use codex_secrets::SecretName;
use codex_secrets::SecretScope;
use codex_secrets::SecretsBackendKind;
use codex_secrets::SecretsManager;
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.
// 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()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(path)?;
tokio::time::timeout(Duration::from_secs(/*secs*/ 60), async {
loop {
match file.try_lock() {
Ok(()) => return Ok(()),
Err(TryLockError::WouldBlock) => {
tokio::time::sleep(Duration::from_millis(/*millis*/ 50)).await
}
Err(error) => return Err(io::Error::from(error)),
}
}
})
.await
.map_err(|_| {
io::Error::new(
io::ErrorKind::TimedOut,
"timed out waiting for provider OAuth credentials",
)
})??;
Ok(file)
}
pub(super) struct GatewayAuthStorage(SecretsManager);
impl GatewayAuthStorage {
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 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 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"))
}
}
fn secret_name(credential_id: &str) -> io::Result<SecretName> {
let digest = credential_id
.strip_prefix("provider-oauth|")
.ok_or_else(|| io::Error::other("invalid provider OAuth credential account"))?;
SecretName::new(&format!("PROVIDER_OAUTH_{}", digest.to_ascii_uppercase()))
.map_err(io::Error::other)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,147 @@
//! Validates gateway token lifetimes and renders bounded diagnostics from shared OAuth errors.
use std::io;
use crate::oauth::OAuthError;
use crate::oauth::sanitize_url_for_logging;
use chrono::Utc;
use codex_secrets::redact_secrets;
use serde::Deserialize;
use serde::Serialize;
use super::GatewayAuthConfig;
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
pub(super) struct StoredToken {
pub access_token: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refresh_token: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<i64>,
}
#[derive(Deserialize)]
pub(super) struct TokenResponse {
access_token: String,
#[serde(default)]
token_type: Option<String>,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
expires_in: Option<u64>,
}
impl TokenResponse {
pub(super) fn into_stored(
self,
previous_refresh_token: Option<&str>,
) -> io::Result<StoredToken> {
if self.access_token.trim().is_empty() {
return Err(io::Error::other(
"provider OAuth token response omitted its access token",
));
}
if self
.token_type
.as_deref()
.is_some_and(|value| !value.eq_ignore_ascii_case("bearer"))
{
return Err(io::Error::other(
"provider OAuth token response returned an unsupported token type",
));
}
if self.expires_in == Some(0) {
return Err(io::Error::other(
"provider OAuth token response returned a zero lifetime",
));
}
let expires_at = self
.expires_in
.map(|expires_in| {
let expires_in = i64::try_from(expires_in)
.map_err(|_| io::Error::other("provider OAuth token lifetime is too large"))?;
Utc::now()
.timestamp()
.checked_add(expires_in)
.ok_or_else(|| io::Error::other("provider OAuth token expiry overflows"))
})
.transpose()?;
Ok(StoredToken {
access_token: self.access_token,
refresh_token: self
.refresh_token
.or_else(|| previous_refresh_token.map(str::to_string)),
expires_at,
})
}
}
pub(super) fn endpoint_error(
error: OAuthError,
config: &GatewayAuthConfig,
grant_type: &str,
redirect_uri: Option<&str>,
) -> io::Error {
let endpoint = diagnostic_url(&config.token_url);
match error {
OAuthError::Rejected(rejection) => {
// Issuers can echo custom URL credentials that shared grant redaction does not know.
// Request IDs are already truncated, so even replacing complete values is unsafe.
let has_query = std::iter::once(config.token_url.as_str())
.chain(config.resource.as_deref())
.any(|value| value.contains('?'));
let (detail, request_id) = if has_query {
(
"provider response details omitted".to_string(),
String::new(),
)
} else {
// The shared layer redacts complete grant credentials before display limits.
let detail: String = redact_secrets(rejection.detail.to_string())
.chars()
.take(/*n*/ 512)
.collect();
let request_id = rejection
.request_id
.map(|value| format!(" (request id: {value})"))
.unwrap_or_default();
(detail, request_id)
};
let client_id = &config.client_id;
let resource = config
.resource
.as_deref()
.map(diagnostic_url)
.unwrap_or_else(|| "<not sent>".to_string());
let redirect_uri = redirect_uri.unwrap_or("<not sent>");
let scopes = if config.scopes.is_empty() {
"<not sent>".to_string()
} else {
config.scopes.join(" ")
};
let pkce = if grant_type == "authorization_code" {
"S256"
} else {
"not applicable"
};
io::Error::other(format!(
"provider OAuth token endpoint {endpoint} returned HTTP {} - {detail}{request_id}. Request: grant_type={grant_type}, client_id={client_id}, client_auth=none (public client), pkce={pkce}, redirect_uri={redirect_uri}, resource={resource}, authorization_scopes={scopes}",
rejection.status.as_u16(),
))
}
OAuthError::Transport(_) => io::Error::other(format!(
"provider OAuth token exchange failed for {endpoint}"
)),
OAuthError::InvalidResponse => io::Error::other("provider OAuth token response is invalid"),
}
}
fn diagnostic_url(value: &str) -> String {
// Issuers may use custom query keys for credentials that the shared allowlist cannot know.
let sanitized = sanitize_url_for_logging(value);
sanitized
.split('?')
.next()
.unwrap_or("<invalid-url>")
.to_string()
}

View File

@@ -9,6 +9,7 @@ pub use auth::WorkspaceRoutingSession;
mod callback_params;
mod device_code_auth;
mod gateway_auth;
mod oauth;
mod outbound_proxy;
mod pkce;
@@ -72,3 +73,6 @@ pub use auth_env_telemetry::AuthEnvTelemetry;
pub use auth_env_telemetry::collect_auth_env_telemetry;
pub use outbound_proxy::AuthRouteConfig;
pub use token_data::TokenData;
pub use gateway_auth::GatewayAuthConfig;
pub use gateway_auth::GatewayAuthManager;

View File

@@ -41,10 +41,6 @@ impl fmt::Debug for OAuthError {
#[derive(Clone, Copy)]
pub(crate) enum ErrorBodyLimit {
Unlimited,
#[cfg_attr(
not(test),
expect(dead_code, reason = "Used by GatewayAuthManager in the following PR")
)]
Bytes(usize),
}

View File

@@ -1526,7 +1526,7 @@ mod tests {
let env = TempCodexHome::new();
let store = MockKeyringStore::default();
store.set_error(
&compute_keyring_account(env.path()),
&compute_keyring_account(env.path(), LocalSecretsNamespace::McpOAuth),
KeyringError::Invalid("error".into(), "save".into()),
);
let tokens = sample_tokens();

View File

@@ -179,8 +179,9 @@ pub fn environment_id_from_cwd(cwd: &Path) -> String {
format!("cwd-{short}")
}
/// Computes the OS keyring account name used to store the local secrets passphrase.
pub fn compute_keyring_account(codex_home: &Path) -> String {
/// Computes the OS keyring account name used to store a local namespace's passphrase.
/// Existing namespaces retain their shared key; gateway credentials use an independent key.
pub fn compute_keyring_account(codex_home: &Path, namespace: LocalSecretsNamespace) -> String {
let canonical = codex_home
.canonicalize()
.unwrap_or_else(|_| codex_home.to_path_buf())
@@ -191,7 +192,15 @@ pub fn compute_keyring_account(codex_home: &Path) -> String {
let digest = hasher.finalize();
let hex = format!("{digest:x}");
let short = hex.get(..16).unwrap_or(hex.as_str());
format!("secrets|{short}")
let home_account = format!("secrets|{short}");
// Separate keys also prevent concurrent first writes to the gateway and primary
// stores from overwriting each other's newly generated encryption key.
match namespace {
LocalSecretsNamespace::GatewayOAuth => format!("{home_account}|gateway-oauth"),
LocalSecretsNamespace::ManagedSecrets
| LocalSecretsNamespace::CodexAuth
| LocalSecretsNamespace::McpOAuth => home_account,
}
}
pub(crate) fn keyring_service() -> &'static str {

View File

@@ -41,6 +41,7 @@ const SECRETS_VERSION: u8 = 1;
const LOCAL_SECRETS_FILENAME: &str = "local.age";
const CODEX_AUTH_SECRETS_FILENAME: &str = "codex_auth.age";
const MCP_OAUTH_SECRETS_FILENAME: &str = "mcp_oauth.age";
const GATEWAY_OAUTH_SECRETS_FILENAME: &str = "gateway_oauth.age";
static MCP_OAUTH_CACHE: Mutex<Option<CachedMcpSecrets>> = Mutex::new(None);
/// Selects the local encrypted file used by a `LocalSecretsBackend`.
@@ -53,6 +54,8 @@ pub enum LocalSecretsNamespace {
CodexAuth,
/// OAuth credentials for external MCP servers.
McpOAuth,
/// Gateway OAuth credentials, isolated from primary auth in file and encryption key.
GatewayOAuth,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
@@ -156,6 +159,7 @@ impl LocalSecretsBackend {
LocalSecretsNamespace::ManagedSecrets => LOCAL_SECRETS_FILENAME,
LocalSecretsNamespace::CodexAuth => CODEX_AUTH_SECRETS_FILENAME,
LocalSecretsNamespace::McpOAuth => MCP_OAUTH_SECRETS_FILENAME,
LocalSecretsNamespace::GatewayOAuth => GATEWAY_OAUTH_SECRETS_FILENAME,
};
self.secrets_dir().join(filename)
}
@@ -235,7 +239,7 @@ impl LocalSecretsBackend {
}
fn load_or_create_passphrase(&self) -> Result<SecretString> {
let account = compute_keyring_account(&self.codex_home);
let account = compute_keyring_account(&self.codex_home, self.namespace);
let loaded = self
.keyring_store
.load(keyring_service(), &account)
@@ -449,7 +453,8 @@ mod tests {
fn set_fails_when_keyring_is_unavailable() -> Result<()> {
let codex_home = tempfile::tempdir().expect("tempdir");
let keyring = Arc::new(MockKeyringStore::default());
let account = compute_keyring_account(codex_home.path());
let account =
compute_keyring_account(codex_home.path(), LocalSecretsNamespace::ManagedSecrets);
keyring.set_error(
&account,
KeyringError::Invalid("error".into(), "load".into()),
@@ -517,14 +522,20 @@ mod tests {
);
let mcp_backend = LocalSecretsBackend::new_with_namespace(
codex_home.path().to_path_buf(),
keyring,
keyring.clone(),
LocalSecretsNamespace::McpOAuth,
);
let gateway_backend = LocalSecretsBackend::new_with_namespace(
codex_home.path().to_path_buf(),
keyring.clone(),
LocalSecretsNamespace::GatewayOAuth,
);
let scope = SecretScope::Global;
let name = SecretName::new("TEST_SECRET")?;
codex_auth_backend.set(&scope, &name, "codex-auth-value")?;
mcp_backend.set(&scope, &name, "mcp-value")?;
gateway_backend.set(&scope, &name, "gateway-value")?;
assert_eq!(
codex_auth_backend.get(&scope, &name)?,
@@ -556,6 +567,23 @@ mod tests {
.exists()
);
assert!(!codex_home.path().join("secrets").join("local.age").exists());
assert!(
codex_home
.path()
.join("secrets/gateway_oauth.age")
.is_file()
);
// Primary-auth key removal must not make the independent gateway file unreadable.
keyring
.delete(
keyring_service(),
&compute_keyring_account(codex_home.path(), LocalSecretsNamespace::CodexAuth),
)
.expect("remove primary secrets key");
assert_eq!(
gateway_backend.get(&scope, &name)?,
Some("gateway-value".to_string())
);
Ok(())
}