Files
codex/codex-rs/rmcp-client/src/oauth.rs
T
Matthew Zeng 6bfdc75f0d Make reactive MCP OAuth refreshes cancellation-safe (#60981)
## Why

Cancelling an MCP HTTP request during a reactive OAuth refresh must not interrupt persistence of rotated credentials. Concurrent requests receiving a `401` also need to reuse a fresh replacement token without refreshing it again.

## What changed

- Route authenticated streamable HTTP requests through `OAuthRuntime`, giving token exchanges independent tasks that finish persistence even if the caller is cancelled.
- Serialize refreshes in both legacy and coordinated modes, recheck the rejected token and replacement expiry, and persist legacy credentials before releasing the manager lock.
- Keep requests waiting to start an exchange cancellable, and use cached access credentials for session cleanup without refreshing tokens.

## Testing

Add coverage for reactive refresh cancellation and rotated refresh-token persistence in both modes, concurrent refreshes with fresh or expired replacements, cancelled waiters, and legacy credentials with only a relative expiry.

GitOrigin-RevId: f52da37d82baf89abcb1585e8e24359de53d40d7
2026-11-12 02:53:52 +00:00

2054 lines
74 KiB
Rust

//! This file handles all logic related to managing MCP OAuth credentials.
//! All credentials are stored using the keyring crate which uses os-specific keyring services.
//! https://crates.io/crates/keyring
//! macOS: macOS keychain.
//! Windows: Windows Credential Manager
//! Linux: DBus-based Secret Service, the kernel keyutils, and a combo of the two
//! FreeBSD, OpenBSD: DBus-based Secret Service
//!
//! For Linux, we use linux-native-async-persistent which uses both keyutils and async-secret-service (see below) for storage.
//! See the docs for the keyutils_persistent module for a full explanation of why both are used. Because this store uses the
//! async-secret-service, you must specify the additional features required by that store
//!
//! async-secret-service provides access to the DBus-based Secret Service storage on Linux, FreeBSD, and OpenBSD. This is an asynchronous
//! keystore that always encrypts secrets when they are transferred across the bus. If DBus isn't installed the keystore will fall back to the json
//! file because we don't use the "vendored" feature.
//!
//! If the keyring is not available or fails, we fall back to CODEX_HOME/.credentials.json which is consistent with other coding CLI agents.
mod credential_store;
mod ema_identity;
mod enterprise_generation;
mod issuer_binding;
mod refresh_lock;
mod refresh_transaction;
mod resolved_store;
mod runtime;
mod store_lock;
mod telemetry;
mod transport;
#[cfg(test)]
#[path = "oauth/test_support.rs"]
pub(crate) mod test_support;
use anyhow::Context;
use anyhow::Error;
use anyhow::Result;
use codex_config::types::AuthKeyringBackendKind;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_otel::auth_storage::Operation;
use codex_otel::auth_storage::Store;
use codex_secrets::LocalSecretsNamespace;
use codex_secrets::SecretName;
use codex_secrets::SecretScope;
use codex_secrets::SecretsBackendKind;
use codex_secrets::SecretsManager;
use oauth2::AccessToken;
use oauth2::RefreshToken;
use oauth2::Scope;
use oauth2::TokenResponse;
use oauth2::basic::BasicTokenType;
use rmcp::transport::auth::OAuthTokenResponse;
use rmcp::transport::auth::VendorExtraTokenFields;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use serde_json::map::Map as JsonMap;
use sha2::Digest;
use sha2::Sha256;
use std::collections::BTreeMap;
use std::fs;
use std::io::ErrorKind;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
use tracing::warn;
use self::store_lock::OAuthStore;
use self::store_lock::OAuthStoreLock;
use self::store_lock::OAuthStoreLockFailure;
use codex_config::McpServerOAuthConfig;
use codex_keyring_store::DefaultKeyringStore;
use codex_keyring_store::KeyringStore;
use rmcp::transport::auth::AuthError;
use rmcp::transport::auth::AuthorizationManager;
use tokio::sync::Mutex;
use codex_utils_home_dir::find_codex_home;
pub(crate) use self::credential_store::OAuthCredentialStore;
pub use self::ema_identity::EmaCredentialLease;
pub use self::ema_identity::EmaCredentialSnapshot;
pub(crate) use self::ema_identity::stored_oidc_identity;
pub(crate) use self::enterprise_generation::EnterpriseOAuthGeneration;
pub(crate) use self::enterprise_generation::EnterpriseOAuthGenerationFile;
pub(crate) use self::enterprise_generation::EnterpriseOAuthGenerationKind;
use self::enterprise_generation::invalidate_enterprise_credential_version;
pub(crate) use self::issuer_binding::validate_authorization_server_endpoints;
pub(crate) use self::issuer_binding::validate_refresh_token_issuer;
pub(crate) use self::refresh_lock::RefreshCredentialLock;
pub(crate) use self::refresh_transaction::install_tokens_in_manager;
pub(crate) use self::resolved_store::ResolvedOAuthCredentialStore;
pub(crate) use self::resolved_store::ResolvedOAuthTokens;
pub(crate) use self::resolved_store::resolve_oauth_tokens_from_store_policy;
use self::resolved_store::try_resolve_oauth_tokens_from_store_policy;
pub(crate) use self::runtime::OAuthRuntime;
pub(crate) use self::transport::OAuthStreamableHttpClient;
const KEYRING_SERVICE: &str = "Codex MCP Credentials";
const MCP_OAUTH_SECRET_PREFIX: &str = "MCP_OAUTH";
const REFRESH_SKEW_MILLIS: u64 = 30_000;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StoredOAuthTokens {
pub server_name: String,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
pub client_id: String,
pub token_response: WrappedOAuthTokenResponse,
#[serde(default)]
pub expires_at: Option<u64>,
}
impl StoredOAuthTokens {
pub(crate) fn has_refresh_token(&self) -> bool {
self.token_response
.0
.refresh_token()
.is_some_and(|refresh_token| !refresh_token.secret().trim().is_empty())
}
pub(crate) fn bound_issuer(&self) -> Option<&str> {
self.issuer
.as_deref()
.filter(|issuer| !issuer.trim().is_empty())
}
pub(crate) fn access_token_is_usable_without_refresh(&self) -> bool {
!token_needs_refresh(self.expires_at)
&& !self
.token_response
.0
.access_token()
.secret()
.trim()
.is_empty()
}
}
/// OAuth credentials paired with the concrete store selected for their client lifecycle.
#[derive(Debug, Clone)]
pub struct StoredOAuthCredentialSnapshot {
credentials: StoredOAuthTokens,
store: ResolvedOAuthCredentialStore,
store_was_contended: bool,
}
impl PartialEq for StoredOAuthCredentialSnapshot {
fn eq(&self, other: &Self) -> bool {
self.credentials == other.credentials && self.store == other.store
}
}
impl StoredOAuthCredentialSnapshot {
pub(crate) fn new(
mut credentials: StoredOAuthTokens,
store: ResolvedOAuthCredentialStore,
) -> Self {
credentials.token_response.0.set_expires_in(None);
Self {
credentials,
store,
store_was_contended: false,
}
}
/// Returns the normalized credentials originally read from the selected store.
pub fn credentials(&self) -> &StoredOAuthTokens {
&self.credentials
}
/// Returns whether this snapshot was retained because its store could not be read.
pub fn store_was_contended(&self) -> bool {
self.store_was_contended
}
/// Refreshes a runtime snapshot without waiting or discarding its last known authority.
pub fn for_runtime_refresh(
previous: Option<&Self>,
server_name: &str,
url: &str,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<Option<Self>> {
match try_resolve_oauth_tokens_from_store_policy(
&DefaultKeyringStore,
server_name,
url,
store_mode,
keyring_backend_kind,
) {
Ok(Some(mut resolved)) => {
resolved.tokens.token_response.0.set_expires_in(None);
Ok(Some(Self {
credentials: resolved.tokens,
store: resolved.store,
store_was_contended: false,
}))
}
Ok(None) => Ok(None),
Err(error) if oauth_store_is_contended(&error) => Ok(previous
.filter(|previous| {
previous.credentials.server_name == server_name
&& previous.credentials.url == url
})
.map(|previous| Self {
store_was_contended: true,
..previous.clone()
})),
Err(error) => Err(error),
}
}
/// Rereads the selected authority without waiting for a contended credential-store lock.
pub fn reload(
&self,
server_name: &str,
url: &str,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<Option<StoredOAuthTokens>> {
if self.store == ResolvedOAuthCredentialStore::file()
&& store_mode == OAuthCredentialsStoreMode::Auto
{
return Self::for_runtime_refresh(
/*previous*/ None,
server_name,
url,
store_mode,
keyring_backend_kind,
)
.map(|snapshot| snapshot.map(|snapshot| snapshot.credentials));
}
let mut observation = telemetry::resolved(self.store, Operation::Load);
let result = self.store.try_load(&DefaultKeyringStore, server_name, url);
observation.record_load_attempt(telemetry::store(self.store), &result);
if matches!(self.store.backend, resolved_store::Backend::Keyring(_))
&& let Err(error) = &result
{
telemetry::record_secure_error(&mut observation, error.as_ref());
}
let credentials = match result {
Ok(credentials) => credentials,
Err(error) if oauth_store_is_contended(&error) => return Ok(None),
Err(error) => return Err(error),
};
Ok(normalized_oauth_credentials(credentials.as_ref()))
}
}
/// Wrap OAuthTokenResponse to allow for partial equality comparison.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WrappedOAuthTokenResponse(pub OAuthTokenResponse);
impl PartialEq for WrappedOAuthTokenResponse {
fn eq(&self, other: &Self) -> bool {
match (serde_json::to_value(self), serde_json::to_value(other)) {
(Ok(s1), Ok(s2)) => s1 == s2,
_ => false,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum StoredOAuthTokenStatus {
Missing,
Usable,
AuthorizationRequired,
}
pub(crate) fn oauth_token_status(
server_name: &str,
url: &str,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<StoredOAuthTokenStatus> {
let resolved = resolve_oauth_tokens_from_store_policy(
&DefaultKeyringStore,
server_name,
url,
store_mode,
keyring_backend_kind,
)?;
Ok(match resolved.as_ref().map(|resolved| &resolved.tokens) {
None => StoredOAuthTokenStatus::Missing,
Some(tokens) if oauth_tokens_are_usable(tokens) => StoredOAuthTokenStatus::Usable,
Some(_) => StoredOAuthTokenStatus::AuthorizationRequired,
})
}
/// Returns stored OAuth credentials without their derived expiration interval.
pub fn stored_oauth_credentials(
server_name: &str,
url: &str,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<Option<StoredOAuthTokens>> {
Ok(
stored_oauth_credential_snapshot(server_name, url, store_mode, keyring_backend_kind)?
.map(|snapshot| snapshot.credentials),
)
}
/// Loads OAuth credentials together with the concrete authority selected by store policy.
pub fn stored_oauth_credential_snapshot(
server_name: &str,
url: &str,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<Option<StoredOAuthCredentialSnapshot>> {
let Some(resolved) = resolve_oauth_tokens_from_store_policy(
&DefaultKeyringStore,
server_name,
url,
store_mode,
keyring_backend_kind,
)?
else {
return Ok(None);
};
Ok(Some(StoredOAuthCredentialSnapshot::new(
resolved.tokens,
resolved.store,
)))
}
fn oauth_store_is_contended(error: &Error) -> bool {
matches!(
error.downcast_ref::<OAuthStoreLockFailure>(),
Some(OAuthStoreLockFailure::Timeout { acquire_timeout, .. })
if acquire_timeout.is_zero()
)
}
fn normalized_oauth_credentials(tokens: Option<&StoredOAuthTokens>) -> Option<StoredOAuthTokens> {
tokens.map(|tokens| {
let mut tokens = tokens.clone();
tokens.token_response.0.set_expires_in(None);
tokens
})
}
fn normalized_file_oauth_credentials(
tokens: Option<&StoredOAuthTokens>,
) -> Option<StoredOAuthTokens> {
normalized_oauth_credentials(tokens).map(|mut tokens| {
// Compare the representation File can persist, not vendor-only response fields.
let response = &mut tokens.token_response.0;
response.set_extra_fields(VendorExtraTokenFields::default());
response.set_token_type(BasicTokenType::Bearer);
if response.scopes().is_some_and(Vec::is_empty) {
response.set_scopes(None);
}
tokens
})
}
fn oauth_tokens_are_usable(tokens: &StoredOAuthTokens) -> bool {
if tokens.client_id.trim().is_empty() {
return false;
}
if token_needs_refresh(tokens.expires_at) {
return tokens.bound_issuer().is_some() && tokens.has_refresh_token();
}
tokens.access_token_is_usable_without_refresh()
}
fn refresh_expires_in_from_timestamp(tokens: &mut StoredOAuthTokens) {
let Some(expires_at) = tokens.expires_at else {
return;
};
match expires_in_from_timestamp(expires_at) {
Some(seconds) => {
let duration = Duration::from_secs(seconds);
tokens.token_response.0.set_expires_in(Some(&duration));
}
None => {
// RMCP treats a missing expiry as unknown and uses the access token
// as-is. Treat a known-expired timestamp as an explicit zero so
// startup refreshes the token before the first request.
tokens
.token_response
.0
.set_expires_in(Some(&Duration::ZERO));
}
}
}
fn load_oauth_tokens_from_keyring<K: KeyringStore + Clone + 'static>(
keyring_store: &K,
keyring_backend_kind: AuthKeyringBackendKind,
server_name: &str,
url: &str,
) -> std::result::Result<Option<StoredOAuthTokens>, OAuthKeyringLoadError> {
match keyring_backend_kind {
AuthKeyringBackendKind::Direct => {
load_oauth_tokens_from_direct_keyring(keyring_store, server_name, url)
.map_err(OAuthKeyringLoadError::Backend)
}
AuthKeyringBackendKind::Secrets => {
load_oauth_tokens_from_secrets_keyring(keyring_store, server_name, url)
}
}
}
fn load_oauth_tokens_from_direct_keyring<K: KeyringStore>(
keyring_store: &K,
server_name: &str,
url: &str,
) -> Result<Option<StoredOAuthTokens>> {
let key = compute_store_key(server_name, url)?;
match keyring_store.load(KEYRING_SERVICE, &key) {
Ok(Some(serialized)) => {
let mut tokens: StoredOAuthTokens = serde_json::from_str(&serialized)
.context("failed to deserialize OAuth tokens from keyring")?;
refresh_expires_in_from_timestamp(&mut tokens);
Ok(Some(tokens))
}
Ok(None) => Ok(None),
Err(error) => Err(Error::new(std::io::Error::from(error))),
}
}
fn load_oauth_tokens_from_secrets_keyring<K: KeyringStore + Clone + 'static>(
keyring_store: &K,
server_name: &str,
url: &str,
) -> std::result::Result<Option<StoredOAuthTokens>, OAuthKeyringLoadError> {
let _store_lock = OAuthStoreLock::acquire_for_read(OAuthStore::Secrets)?;
load_oauth_tokens_from_secrets_keyring_with_lock_held(keyring_store, server_name, url)
}
fn load_oauth_tokens_from_secrets_keyring_with_lock_held<K: KeyringStore + Clone + 'static>(
keyring_store: &K,
server_name: &str,
url: &str,
) -> std::result::Result<Option<StoredOAuthTokens>, OAuthKeyringLoadError> {
let codex_home = find_codex_home().map_err(anyhow::Error::from)?;
let manager = SecretsManager::new_with_keyring_store_and_namespace(
codex_home.to_path_buf(),
SecretsBackendKind::Local,
Arc::new(keyring_store.clone()),
LocalSecretsNamespace::McpOAuth,
);
let secret_name = compute_secret_name(server_name, url)?;
match manager
.get(&SecretScope::Global, &secret_name)
.context("failed to load MCP OAuth tokens from encrypted storage")?
{
Some(serialized) => {
let mut tokens: StoredOAuthTokens = serde_json::from_str(&serialized)
.context("failed to deserialize OAuth tokens from encrypted storage")?;
refresh_expires_in_from_timestamp(&mut tokens);
Ok(Some(tokens))
}
None => Ok(None),
}
}
/// Classifies keyring load failures that affect Auto fallback policy.
#[derive(Debug, thiserror::Error)]
enum OAuthKeyringLoadError {
/// Store coordination failed, so consulting another authority would be unsafe.
#[error(transparent)]
StoreLock(#[from] OAuthStoreLockFailure),
/// The selected keyring backend itself was unavailable or its data was invalid.
#[error("{0}")]
Backend(#[from] anyhow::Error),
}
pub async fn save_oauth_tokens(
server_name: &str,
tokens: &StoredOAuthTokens,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<()> {
let lock = RefreshCredentialLock::acquire_for_server(server_name, &tokens.url).await?;
save_oauth_tokens_with_lock_held(&lock, server_name, tokens, store_mode, keyring_backend_kind)
}
/// Save while retaining the matching credential lock acquired by the caller.
pub(crate) fn save_oauth_tokens_with_lock_held(
lock: &RefreshCredentialLock,
server_name: &str,
tokens: &StoredOAuthTokens,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<()> {
invalidate_enterprise_credential_version(server_name, &tokens.url, lock)?;
let keyring_store = DefaultKeyringStore;
let mut observation = telemetry::policy(store_mode, keyring_backend_kind, Operation::Save);
match store_mode {
OAuthCredentialsStoreMode::Auto => save_oauth_tokens_with_keyring_with_fallback_to_file(
&keyring_store,
keyring_backend_kind,
server_name,
tokens,
),
OAuthCredentialsStoreMode::File => {
let result = save_oauth_tokens_to_file(tokens);
observation.record_save_attempt(Store::File, &result);
result
}
OAuthCredentialsStoreMode::Keyring => {
let result = save_oauth_tokens_with_keyring_and_cleanup_file(
&keyring_store,
store_mode,
keyring_backend_kind,
server_name,
tokens,
);
observation.record_save_attempt(telemetry::keyring(keyring_backend_kind), &result);
if let Err(error) = &result {
telemetry::record_secure_error(&mut observation, error.as_ref());
}
result
}
}
}
fn save_oauth_tokens_with_keyring<K: KeyringStore + Clone + 'static>(
keyring_store: &K,
keyring_backend_kind: AuthKeyringBackendKind,
server_name: &str,
tokens: &StoredOAuthTokens,
) -> Result<()> {
// This exact-store writer is used after a client resolves its authority. Only login-time
// policy resolution may clean up or update the non-selected store.
match keyring_backend_kind {
AuthKeyringBackendKind::Direct => {
save_oauth_tokens_to_direct_keyring(keyring_store, server_name, tokens)
}
AuthKeyringBackendKind::Secrets => {
save_oauth_tokens_to_secrets_keyring(keyring_store, server_name, tokens)
}
}
}
fn save_oauth_tokens_to_direct_keyring<K: KeyringStore>(
keyring_store: &K,
server_name: &str,
tokens: &StoredOAuthTokens,
) -> Result<()> {
let serialized = serde_json::to_string(tokens).context("failed to serialize OAuth tokens")?;
let key = compute_store_key(server_name, &tokens.url)?;
keyring_store
.save(KEYRING_SERVICE, &key, &serialized)
.map_err(std::io::Error::from)
.context("failed to write OAuth tokens to keyring")
}
/// Saves one credential while holding the Secrets aggregate-store lock across the mutation.
fn save_oauth_tokens_to_secrets_keyring<K: KeyringStore + Clone + 'static>(
keyring_store: &K,
server_name: &str,
tokens: &StoredOAuthTokens,
) -> Result<()> {
let serialized = serde_json::to_string(tokens).context("failed to serialize OAuth tokens")?;
let _store_lock = OAuthStoreLock::acquire_for_write(OAuthStore::Secrets)?;
save_oauth_tokens_to_secrets_keyring_with_lock_held(
keyring_store,
server_name,
tokens,
&serialized,
)
}
/// Writes one credential to Secrets. The caller must hold the Secrets aggregate-store lock.
fn save_oauth_tokens_to_secrets_keyring_with_lock_held<K: KeyringStore + Clone + 'static>(
keyring_store: &K,
server_name: &str,
tokens: &StoredOAuthTokens,
serialized: &str,
) -> Result<()> {
let codex_home = find_codex_home()?;
let manager = SecretsManager::new_with_keyring_store_and_namespace(
codex_home.to_path_buf(),
SecretsBackendKind::Local,
Arc::new(keyring_store.clone()),
LocalSecretsNamespace::McpOAuth,
);
let secret_name = compute_secret_name(server_name, &tokens.url)?;
manager
.set(&SecretScope::Global, &secret_name, serialized)
.context("failed to write OAuth tokens to encrypted storage")
}
/// Saves to the selected keyring backend, then best-effort removes the fallback File entry.
fn save_oauth_tokens_with_keyring_and_cleanup_file<K: KeyringStore + Clone + 'static>(
keyring_store: &K,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
server_name: &str,
tokens: &StoredOAuthTokens,
) -> Result<()> {
save_oauth_tokens_with_keyring(keyring_store, keyring_backend_kind, server_name, tokens)?;
let key = compute_store_key(server_name, &tokens.url)?;
let mut observation = telemetry::policy(store_mode, keyring_backend_kind, Operation::Cleanup);
let result = delete_oauth_tokens_from_file(server_name, &tokens.url, &key);
observation.record_delete_attempt(Store::File, &result);
if result.is_err() {
warn!("failed to remove OAuth tokens from fallback storage");
}
drop(observation);
Ok(())
}
fn save_oauth_tokens_with_keyring_with_fallback_to_file<K: KeyringStore + Clone + 'static>(
keyring_store: &K,
keyring_backend_kind: AuthKeyringBackendKind,
server_name: &str,
tokens: &StoredOAuthTokens,
) -> Result<()> {
let mut observation = telemetry::policy(
OAuthCredentialsStoreMode::Auto,
keyring_backend_kind,
Operation::Save,
);
let result = save_oauth_tokens_with_keyring_and_cleanup_file(
keyring_store,
OAuthCredentialsStoreMode::Auto,
keyring_backend_kind,
server_name,
tokens,
);
observation.record_save_attempt(telemetry::keyring(keyring_backend_kind), &result);
match result {
Ok(()) => Ok(()),
// As on load, a store lock failure is a coordination failure rather than evidence that
// the keyring backend is unavailable. Falling back could leave a newer File token hidden
// behind a stale Secrets entry.
Err(error) if error.downcast_ref::<OAuthStoreLockFailure>().is_some() => {
telemetry::record_secure_error(&mut observation, error.as_ref());
Err(error)
}
Err(error) => {
let message = error.to_string();
telemetry::record_secure_error(&mut observation, error.as_ref());
let result = save_oauth_tokens_to_file(tokens);
observation.record_save_attempt(Store::File, &result);
result.with_context(|| format!("failed to write OAuth tokens to keyring: {message}"))
}
}
}
pub async fn delete_oauth_tokens(
server_name: &str,
url: &str,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<bool> {
let lock = RefreshCredentialLock::acquire_for_server(server_name, url).await?;
delete_oauth_tokens_with_lock_held(&lock, server_name, url, store_mode, keyring_backend_kind)
}
/// Delete while retaining the matching credential lock acquired by the caller.
pub(crate) fn delete_oauth_tokens_with_lock_held(
lock: &RefreshCredentialLock,
server_name: &str,
url: &str,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<bool> {
invalidate_enterprise_credential_version(server_name, url, lock)?;
let keyring_store = DefaultKeyringStore;
delete_oauth_tokens_from_keyring_and_file(
&keyring_store,
store_mode,
keyring_backend_kind,
server_name,
url,
)
}
fn delete_oauth_tokens_from_keyring_and_file<K: KeyringStore + Clone + 'static>(
keyring_store: &K,
store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
server_name: &str,
url: &str,
) -> Result<bool> {
let mut observation = telemetry::policy(store_mode, keyring_backend_kind, Operation::Delete);
let result = compute_store_key(server_name, url).and_then(|key| {
let keyring_result =
delete_oauth_tokens_from_keyring(keyring_store, keyring_backend_kind, server_name, url);
let keyring_removed = match keyring_result {
Ok(removed) => removed,
Err(error) => {
warn!("failed to delete OAuth tokens from keyring");
match store_mode {
OAuthCredentialsStoreMode::Auto | OAuthCredentialsStoreMode::Keyring => {
return Err(error).context("failed to delete OAuth tokens from keyring");
}
OAuthCredentialsStoreMode::File => false,
}
}
};
let file_removed = delete_oauth_tokens_from_file(server_name, url, &key)?;
Ok(keyring_removed || file_removed)
});
observation.record_delete_attempt(Store::Multiple, &result);
result
}
fn delete_oauth_tokens_from_keyring<K: KeyringStore + Clone + 'static>(
keyring_store: &K,
keyring_backend_kind: AuthKeyringBackendKind,
server_name: &str,
url: &str,
) -> Result<bool> {
match keyring_backend_kind {
AuthKeyringBackendKind::Direct => {
delete_oauth_tokens_from_direct_keyring(keyring_store, server_name, url)
}
AuthKeyringBackendKind::Secrets => {
let direct_removed =
delete_oauth_tokens_from_direct_keyring(keyring_store, server_name, url)?;
let secrets_removed =
delete_oauth_tokens_from_secrets_keyring(keyring_store, server_name, url)?;
Ok(direct_removed || secrets_removed)
}
}
}
fn delete_oauth_tokens_from_direct_keyring<K: KeyringStore>(
keyring_store: &K,
server_name: &str,
url: &str,
) -> Result<bool> {
let key = compute_store_key(server_name, url)?;
keyring_store
.delete(KEYRING_SERVICE, &key)
.map_err(|error| Error::new(std::io::Error::from(error)))
}
fn delete_oauth_tokens_from_secrets_keyring<K: KeyringStore + Clone + 'static>(
keyring_store: &K,
server_name: &str,
url: &str,
) -> Result<bool> {
let _store_lock = OAuthStoreLock::acquire_for_write(OAuthStore::Secrets)?;
let codex_home = find_codex_home()?;
let manager = SecretsManager::new_with_keyring_store_and_namespace(
codex_home.to_path_buf(),
SecretsBackendKind::Local,
Arc::new(keyring_store.clone()),
LocalSecretsNamespace::McpOAuth,
);
let secret_name = compute_secret_name(server_name, url)?;
let secrets_removed = manager
.delete(&SecretScope::Global, &secret_name)
.context("failed to delete OAuth tokens from encrypted storage")?;
Ok(secrets_removed)
}
#[derive(Clone)]
pub(crate) struct OAuthPersistor {
inner: Arc<OAuthPersistorInner>,
}
struct OAuthPersistorInner {
server_name: String,
url: String,
authorization_manager: Arc<Mutex<AuthorizationManager>>,
credential_store: ResolvedOAuthCredentialStore,
oauth_config: Option<McpServerOAuthConfig>,
last_credentials: Mutex<Option<StoredOAuthTokens>>,
}
impl OAuthPersistorInner {
fn refreshed_server_name(&self, previous: Option<&StoredOAuthTokens>) -> String {
// File refreshes update the matched row in place, including its original server name.
match (self.credential_store.backend, previous) {
(resolved_store::Backend::File, Some(previous)) => previous.server_name.clone(),
_ => self.server_name.clone(),
}
}
}
impl OAuthPersistor {
pub(crate) fn new(
server_name: String,
url: String,
authorization_manager: Arc<Mutex<AuthorizationManager>>,
credential_store: ResolvedOAuthCredentialStore,
initial_credentials: Option<StoredOAuthTokens>,
oauth_config: Option<McpServerOAuthConfig>,
) -> Self {
Self {
inner: Arc::new(OAuthPersistorInner {
server_name,
url,
authorization_manager,
credential_store,
last_credentials: Mutex::new(initial_credentials),
oauth_config,
}),
}
}
pub(crate) async fn stored_credentials(&self) -> Option<StoredOAuthTokens> {
let credentials = self.inner.last_credentials.lock().await;
normalized_oauth_credentials(credentials.as_ref())
}
/// Persists RMCP-managed credential changes back to this client's resolved authority.
#[expect(
clippy::await_holding_invalid_type,
reason = "AuthorizationManager async access must be serialized through its mutex"
)]
pub(crate) async fn persist_if_needed(&self) -> Result<()> {
{
let manager = self.inner.authorization_manager.lock().await;
if !self.credentials_changed(&manager).await? {
return Ok(());
}
}
let credential_guard = self.acquire_persistence_guard().await?;
let manager = self.inner.authorization_manager.lock().await;
self.persist_with_manager(&manager, &credential_guard).await
}
pub(super) async fn acquire_persistence_guard(
&self,
) -> std::result::Result<RefreshCredentialLock, AuthError> {
let guard =
RefreshCredentialLock::acquire_for_server(&self.inner.server_name, &self.inner.url)
.await
.map_err(|_| {
AuthError::CredentialStoreError(
"failed to lock MCP OAuth credentials".to_string(),
)
})?;
let inner = Arc::clone(&self.inner);
let current = tokio::task::spawn_blocking(move || {
inner
.credential_store
.load(&DefaultKeyringStore, &inner.server_name, &inner.url)
})
.await
.map_err(|_| {
AuthError::CredentialStoreError(
"MCP OAuth credential validation task failed".to_string(),
)
})?
.map_err(|_| {
AuthError::CredentialStoreError("failed to validate MCP OAuth credentials".to_string())
})?;
let expected = self.stored_credentials().await;
let expected = match self.inner.credential_store.backend {
resolved_store::Backend::File => normalized_file_oauth_credentials(expected.as_ref()),
resolved_store::Backend::Keyring(_) => expected,
};
if normalized_oauth_credentials(current.as_ref()) != expected {
warn!(
server_name = %self.inner.server_name,
"stored MCP OAuth credentials changed; authorization must be rebuilt"
);
return Err(AuthError::AuthorizationRequired);
}
Ok(guard)
}
pub(super) async fn fresh_access_token(
&self,
manager: &AuthorizationManager,
) -> std::result::Result<Option<String>, AuthError> {
let (client_id, maybe_credentials) = manager.get_credentials().await?;
let Some(credentials) = maybe_credentials else {
return Ok(None);
};
let last_credentials = self.inner.last_credentials.lock().await;
let Some(last_credentials) = last_credentials.as_ref() else {
return Ok(None);
};
if last_credentials.client_id != client_id
|| last_credentials.token_response != WrappedOAuthTokenResponse(credentials.clone())
|| token_needs_refresh(last_credentials.expires_at)
|| (last_credentials.expires_at.is_none() && credentials.expires_in().is_some())
{
return Ok(None);
}
Ok(Some(credentials.access_token().secret().to_string()))
}
async fn credentials_changed(&self, manager: &AuthorizationManager) -> Result<bool> {
let (client_id, maybe_credentials) = manager.get_credentials().await?;
let last_credentials = self.inner.last_credentials.lock().await;
Ok(match maybe_credentials {
Some(credentials) => last_credentials.as_ref().is_none_or(|previous| {
previous.client_id != client_id
|| previous.token_response != WrappedOAuthTokenResponse(credentials)
}),
None => last_credentials.is_some(),
})
}
pub(super) async fn persist_with_manager(
&self,
manager: &AuthorizationManager,
_credential_guard: &RefreshCredentialLock,
) -> Result<()> {
let (client_id, maybe_credentials) = manager.get_credentials().await?;
match maybe_credentials {
Some(credentials) => {
let mut last_credentials = self.inner.last_credentials.lock().await;
let new_token_response = WrappedOAuthTokenResponse(credentials.clone());
let same_token = last_credentials
.as_ref()
.map(|previous| previous.token_response == new_token_response)
.unwrap_or(false);
let expires_at = if same_token {
last_credentials
.as_ref()
.and_then(|previous| previous.expires_at)
} else {
compute_expires_at_millis(&credentials)
};
let stored = StoredOAuthTokens {
server_name: self.inner.refreshed_server_name(last_credentials.as_ref()),
url: self.inner.url.clone(),
issuer: last_credentials
.as_ref()
.and_then(|previous| previous.issuer.clone()),
client_id,
token_response: new_token_response,
expires_at,
};
if last_credentials.as_ref() != Some(&stored) {
self.inner.credential_store.save_with_refresh_telemetry(
&DefaultKeyringStore,
&self.inner.server_name,
last_credentials.as_ref(),
&stored,
)?;
*last_credentials = Some(stored);
}
}
None => {
let mut last_credentials = self.inner.last_credentials.lock().await;
if last_credentials.take().is_some()
&& let Err(error) = self.inner.credential_store.delete(
&DefaultKeyringStore,
&self.inner.server_name,
&self.inner.url,
)
{
warn!(
server_name = %self.inner.server_name,
error = %error,
"failed to remove MCP OAuth credentials from the resolved store"
);
}
}
}
Ok(())
}
}
const FALLBACK_FILENAME: &str = ".credentials.json";
const MCP_SERVER_TYPE: &str = "http";
type FallbackFile = BTreeMap<String, FallbackTokenEntry>;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct FallbackTokenEntry {
server_name: String,
server_url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
issuer: Option<String>,
client_id: String,
access_token: String,
#[serde(default)]
expires_at: Option<u64>,
#[serde(default)]
refresh_token: Option<String>,
#[serde(default)]
scopes: Vec<String>,
// Legacy host entries omit this marker, so executor lookups fail closed.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
executor_owned: bool,
}
impl FallbackTokenEntry {
fn matches_legacy_key(&self, server_name: &str, url: &str) -> bool {
!server_name.starts_with("executor:")
&& !server_name.starts_with("local:")
&& !self.executor_owned
&& self.server_name == server_name
&& self.server_url == url
}
}
fn load_oauth_tokens_from_file(server_name: &str, url: &str) -> Result<Option<StoredOAuthTokens>> {
let _store_lock = OAuthStoreLock::acquire_for_read(OAuthStore::File)?;
load_oauth_tokens_from_file_with_lock_held(server_name, url)
.map(|entry| entry.map(|(_, tokens)| tokens))
}
/// Retains the matched entry's key. The caller must hold the File aggregate-store lock.
fn load_oauth_tokens_from_file_with_lock_held(
server_name: &str,
url: &str,
) -> Result<Option<(String, StoredOAuthTokens)>> {
let Some(store) = read_fallback_file_unlocked()? else {
return Ok(None);
};
let key = compute_store_key(server_name, url)?;
let local_server_name = server_name.strip_prefix("local:").unwrap_or(server_name);
for (stored_key, entry) in &store {
let matches_credential = if stored_key != &key {
entry.matches_legacy_key(server_name, url)
} else if server_name.starts_with("executor:") {
entry.executor_owned && entry.server_name == server_name && entry.server_url == url
} else {
!entry.executor_owned
&& entry.server_url == url
&& (entry.server_name == local_server_name || entry.server_name == server_name)
};
if !matches_credential {
continue;
}
let mut token_response = OAuthTokenResponse::new(
AccessToken::new(entry.access_token.clone()),
BasicTokenType::Bearer,
VendorExtraTokenFields::default(),
);
if let Some(refresh) = entry.refresh_token.clone() {
token_response.set_refresh_token(Some(RefreshToken::new(refresh)));
}
let scopes = entry.scopes.clone();
if !scopes.is_empty() {
token_response.set_scopes(Some(scopes.into_iter().map(Scope::new).collect()));
}
let mut stored = StoredOAuthTokens {
server_name: entry.server_name.clone(),
url: entry.server_url.clone(),
issuer: entry.issuer.clone(),
client_id: entry.client_id.clone(),
token_response: WrappedOAuthTokenResponse(token_response),
expires_at: entry.expires_at,
};
refresh_expires_in_from_timestamp(&mut stored);
return Ok(Some((stored_key.clone(), stored)));
}
Ok(None)
}
/// Saves one credential while holding the File aggregate-store lock across the full
/// read-modify-write operation.
fn save_oauth_tokens_to_file(tokens: &StoredOAuthTokens) -> Result<()> {
let _store_lock = OAuthStoreLock::acquire_for_write(OAuthStore::File)?;
save_oauth_tokens_to_file_with_lock_held(tokens)
}
/// Updates the fallback File. The caller must hold the File aggregate-store lock.
fn save_oauth_tokens_to_file_with_lock_held(tokens: &StoredOAuthTokens) -> Result<()> {
let key = compute_store_key(&tokens.server_name, &tokens.url)?;
let mut store = read_fallback_file_unlocked()?.unwrap_or_default();
let executor_owned = tokens.server_name.starts_with("executor:");
if executor_owned && store.get(&key).is_some_and(|entry| !entry.executor_owned) {
anyhow::bail!("executor OAuth credential key conflicts with a host-owned credential");
}
remove_file_credential(&mut store, &tokens.server_name, &tokens.url, &key);
store.insert(key, FallbackTokenEntry::from(tokens));
write_fallback_file(&store)
}
impl From<&StoredOAuthTokens> for FallbackTokenEntry {
fn from(tokens: &StoredOAuthTokens) -> Self {
let token_response = &tokens.token_response.0;
Self {
server_name: tokens.server_name.clone(),
server_url: tokens.url.clone(),
issuer: tokens.issuer.clone(),
client_id: tokens.client_id.clone(),
access_token: token_response.access_token().secret().to_string(),
expires_at: tokens
.expires_at
.or_else(|| compute_expires_at_millis(token_response)),
refresh_token: token_response
.refresh_token()
.map(|token| token.secret().to_string()),
scopes: token_response
.scopes()
.map(|scopes| scopes.iter().map(|scope| scope.to_string()).collect())
.unwrap_or_default(),
executor_owned: tokens.server_name.starts_with("executor:"),
}
}
}
fn delete_oauth_tokens_from_file(server_name: &str, url: &str, key: &str) -> Result<bool> {
let _store_lock = OAuthStoreLock::acquire_for_write(OAuthStore::File)?;
let mut store = match read_fallback_file_unlocked()? {
Some(store) => store,
None => return Ok(false),
};
if key.starts_with("executor:")
&& !key.contains('|')
&& store.get(key).is_some_and(|entry| !entry.executor_owned)
{
anyhow::bail!("executor OAuth credential key conflicts with a host-owned credential");
}
let removed = remove_file_credential(&mut store, server_name, url, key);
if removed {
write_fallback_file(&store)?;
}
Ok(removed)
}
fn remove_file_credential(
store: &mut FallbackFile,
server_name: &str,
url: &str,
key: &str,
) -> bool {
let original_len = store.len();
// Enterprise keys are profile-scoped. A legacy ordinary row may share the name and URL.
let enterprise_scoped = server_name.starts_with("ema-idp:");
store.retain(|stored_key, entry| {
stored_key != key && (enterprise_scoped || !entry.matches_legacy_key(server_name, url))
});
store.len() != original_len
}
pub(crate) fn compute_expires_at_millis(response: &OAuthTokenResponse) -> Option<u64> {
let expires_in = response.expires_in()?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0));
let expiry = now.checked_add(expires_in)?;
let millis = expiry.as_millis();
if millis > u128::from(u64::MAX) {
Some(u64::MAX)
} else {
Some(millis as u64)
}
}
fn expires_in_from_timestamp(expires_at: u64) -> Option<u64> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0));
let now_ms = now.as_millis() as u64;
if expires_at <= now_ms {
None
} else {
Some((expires_at - now_ms) / 1000)
}
}
fn token_needs_refresh(expires_at: Option<u64>) -> bool {
let Some(expires_at) = expires_at else {
return false;
};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_millis() as u64;
now.saturating_add(REFRESH_SKEW_MILLIS) >= expires_at
}
fn compute_store_key(server_name: &str, server_url: &str) -> Result<String> {
let executor_owned = server_name.starts_with("executor:");
let enterprise_owned = server_name.starts_with("ema-idp:");
let server_name = server_name.strip_prefix("local:").unwrap_or(server_name);
let mut payload = JsonMap::new();
payload.insert(
"type".to_string(),
Value::String(MCP_SERVER_TYPE.to_string()),
);
payload.insert("url".to_string(), Value::String(server_url.to_string()));
payload.insert("headers".to_string(), Value::Object(JsonMap::new()));
let payload = if enterprise_owned {
// The OS keyring is shared across homes. Keep enterprise sessions
// isolated by Codex profile as well as authenticated user and workspace.
let codex_home = find_codex_home()?;
fs::create_dir_all(&codex_home)?;
payload.insert(
"codex_home".to_string(),
serde_json::to_value(codex_home.as_path().canonicalize()?)?,
);
// Different binaries can enable different serde_json ordering features.
serde_json::to_value(payload.into_iter().collect::<BTreeMap<_, _>>())?
} else {
Value::Object(payload)
};
let truncated = sha_256_prefix(&payload)?;
let separator = if executor_owned { ':' } else { '|' };
Ok(format!("{server_name}{separator}{truncated}"))
}
/// Derive a valid secret-store name from the MCP OAuth store key.
///
/// `compute_store_key` intentionally includes readable identity components and
/// punctuation, but `SecretName` only allows `A-Z`, `0-9`, and `_`.
/// Re-hashing keeps the secret key deterministic while satisfying that
/// restricted alphabet.
fn compute_secret_name(server_name: &str, server_url: &str) -> Result<SecretName> {
let key = compute_store_key(server_name, server_url)?;
let mut hasher = Sha256::new();
hasher.update(key.as_bytes());
let digest = hasher.finalize();
let hex = format!("{digest:X}");
SecretName::new(&format!("{MCP_OAUTH_SECRET_PREFIX}_{}", &hex[..32]))
}
fn fallback_file_path() -> Result<PathBuf> {
Ok(find_codex_home()?.join(FALLBACK_FILENAME).to_path_buf())
}
fn read_fallback_file_unlocked() -> Result<Option<FallbackFile>> {
let path = fallback_file_path()?;
let contents = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(err).context(format!(
"failed to read credentials file at {}",
path.display()
));
}
};
match serde_json::from_str::<FallbackFile>(&contents) {
Ok(store) => Ok(Some(store)),
Err(e) => Err(e).context(format!(
"failed to parse credentials file at {}",
path.display()
)),
}
}
fn open_fallback_file_for_write(path: &std::path::Path) -> Result<fs::File> {
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(false);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
}
let file = options.open(path)?;
anyhow::ensure!(
file.metadata()?.is_file(),
"credentials path is not a regular file"
);
Ok(file)
}
fn write_fallback_file(store: &FallbackFile) -> Result<()> {
let path = fallback_file_path()?;
if store.is_empty() {
if path.exists() {
fs::remove_file(path)?;
}
return Ok(());
}
let parent = path
.parent()
.context("credentials file path has no parent directory")?;
fs::create_dir_all(parent)?;
let serialized = serde_json::to_string(store)?;
let mut file = open_fallback_file_for_write(&path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.set_permissions(fs::Permissions::from_mode(0o600))?;
}
file.set_len(/*size*/ 0)?;
file.write_all(serialized.as_bytes())?;
Ok(())
}
fn sha_256_prefix(value: &Value) -> Result<String> {
let serialized =
serde_json::to_string(&value).context("failed to serialize MCP OAuth key payload")?;
let mut hasher = Sha256::new();
hasher.update(serialized.as_bytes());
let digest = hasher.finalize();
let hex = format!("{digest:x}");
let truncated = &hex[..16];
Ok(truncated.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use codex_keyring_store::tests::MockKeyringStore;
use codex_secrets::compute_keyring_account;
use keyring::Error as KeyringError;
use pretty_assertions::assert_eq;
use std::sync::Arc;
#[path = "credential_store_tests.rs"]
mod credential_store_tests;
#[path = "persistor_tests.rs"]
mod persistor_tests;
#[path = "telemetry_tests.rs"]
mod telemetry_tests;
use super::test_support::TempCodexHome;
#[test]
fn stored_oauth_credentials_ignore_derived_expiration_and_track_token_changes() -> Result<()> {
let _env = TempCodexHome::new();
let mut tokens = sample_tokens();
let credentials = super::normalized_oauth_credentials(Some(&tokens));
tokens
.token_response
.0
.set_expires_in(Some(&Duration::from_secs(1)));
assert_eq!(
credentials,
super::normalized_oauth_credentials(Some(&tokens))
);
super::save_oauth_tokens_to_file(&tokens)?;
assert_eq!(
credentials,
super::stored_oauth_credentials(
&tokens.server_name,
&tokens.url,
OAuthCredentialsStoreMode::File,
AuthKeyringBackendKind::Direct,
)?
);
tokens
.token_response
.0
.set_access_token(AccessToken::new("new-access-token".to_string()));
super::save_oauth_tokens_to_file(&tokens)?;
assert_ne!(
credentials,
super::stored_oauth_credentials(
&tokens.server_name,
&tokens.url,
OAuthCredentialsStoreMode::File,
AuthKeyringBackendKind::Direct,
)?
);
Ok(())
}
#[test]
fn resolve_oauth_tokens_from_store_policy_uses_keyring_when_available() -> Result<()> {
let _env = TempCodexHome::new();
let store = MockKeyringStore::default();
let tokens = sample_tokens();
let expected = tokens.clone();
let serialized = serde_json::to_string(&tokens)?;
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
store.save(KEYRING_SERVICE, &key, &serialized)?;
let resolved = super::resolve_oauth_tokens_from_store_policy(
&store,
&tokens.server_name,
&tokens.url,
OAuthCredentialsStoreMode::Auto,
AuthKeyringBackendKind::Direct,
)?
.expect("tokens should load from keyring");
assert_eq!(
resolved.store,
ResolvedOAuthCredentialStore::keyring(AuthKeyringBackendKind::Direct)
);
assert_tokens_match_without_expiry(&resolved.tokens, &expected);
Ok(())
}
#[test]
fn load_oauth_tokens_falls_back_when_missing_in_keyring() -> Result<()> {
let _env = TempCodexHome::new();
let store = MockKeyringStore::default();
let tokens = sample_tokens();
let expected = tokens.clone();
super::save_oauth_tokens_to_file(&tokens)?;
let resolved = super::resolve_oauth_tokens_from_store_policy(
&store,
&tokens.server_name,
&tokens.url,
OAuthCredentialsStoreMode::Auto,
AuthKeyringBackendKind::Direct,
)?
.expect("tokens should load from fallback");
assert_eq!(resolved.store, ResolvedOAuthCredentialStore::file());
assert_tokens_match_without_expiry(&resolved.tokens, &expected);
Ok(())
}
#[test]
fn load_oauth_tokens_falls_back_when_keyring_errors() -> Result<()> {
let _env = TempCodexHome::new();
let store = MockKeyringStore::default();
let tokens = sample_tokens();
let expected = tokens.clone();
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
store.set_error(&key, KeyringError::Invalid("error".into(), "load".into()));
super::save_oauth_tokens_to_file(&tokens)?;
let resolved = super::resolve_oauth_tokens_from_store_policy(
&store,
&tokens.server_name,
&tokens.url,
OAuthCredentialsStoreMode::Auto,
AuthKeyringBackendKind::Direct,
)?
.expect("tokens should load from fallback");
assert_eq!(resolved.store, ResolvedOAuthCredentialStore::file());
assert_tokens_match_without_expiry(&resolved.tokens, &expected);
Ok(())
}
#[test]
fn exact_store_operations_do_not_adopt_or_mutate_the_other_store() -> Result<()> {
let _env = TempCodexHome::new();
let store = MockKeyringStore::default();
let file_tokens = sample_tokens();
let mut keyring_tokens = file_tokens.clone();
keyring_tokens
.token_response
.0
.set_access_token(AccessToken::new("keyring-access-token".to_string()));
super::save_oauth_tokens_to_file(&file_tokens)?;
let fallback_path = super::fallback_file_path()?;
let fallback_before = fs::read(&fallback_path)?;
super::save_oauth_tokens_with_keyring(
&store,
AuthKeyringBackendKind::Direct,
&keyring_tokens.server_name,
&keyring_tokens,
)?;
assert_eq!(fs::read(fallback_path)?, fallback_before);
let loaded = ResolvedOAuthCredentialStore::keyring(AuthKeyringBackendKind::Direct)
.load(&store, &keyring_tokens.server_name, &keyring_tokens.url)?
.expect("tokens should load from the selected keyring store");
assert_tokens_match_without_expiry(&loaded, &keyring_tokens);
Ok(())
}
#[test]
fn save_oauth_tokens_prefers_keyring_when_available() -> Result<()> {
let _env = TempCodexHome::new();
let store = MockKeyringStore::default();
let tokens = sample_tokens();
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
super::save_oauth_tokens_to_file(&tokens)?;
super::save_oauth_tokens_with_keyring_with_fallback_to_file(
&store,
AuthKeyringBackendKind::Direct,
&tokens.server_name,
&tokens,
)?;
let fallback_path = super::fallback_file_path()?;
assert!(!fallback_path.exists(), "fallback file should be removed");
let stored = store.saved_value(&key).expect("value saved to keyring");
assert_eq!(serde_json::from_str::<StoredOAuthTokens>(&stored)?, tokens);
Ok(())
}
#[test]
fn save_oauth_tokens_writes_fallback_when_keyring_fails() -> Result<()> {
let _env = TempCodexHome::new();
let store = MockKeyringStore::default();
let tokens = sample_tokens();
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
store.set_error(&key, KeyringError::Invalid("error".into(), "save".into()));
super::save_oauth_tokens_with_keyring_with_fallback_to_file(
&store,
AuthKeyringBackendKind::Direct,
&tokens.server_name,
&tokens,
)?;
let fallback_path = super::fallback_file_path()?;
assert!(fallback_path.exists(), "fallback file should be created");
let saved = super::read_fallback_file_unlocked()?.expect("fallback file should load");
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
let entry = saved.get(&key).expect("entry for key");
assert_eq!(entry.server_name, tokens.server_name);
assert_eq!(entry.server_url, tokens.url);
assert_eq!(entry.client_id, tokens.client_id);
assert_eq!(
entry.access_token,
tokens.token_response.0.access_token().secret().as_str()
);
assert!(store.saved_value(&key).is_none());
Ok(())
}
#[cfg(unix)]
#[test]
fn fallback_file_is_private_at_creation() -> Result<()> {
use std::os::unix::fs::PermissionsExt;
const CHILD: &str = "CODEX_TEST_OAUTH_PERMISSIVE_UMASK";
if std::env::var_os(CHILD).is_none() {
// Change umask only in the child running this one test.
let status = std::process::Command::new("/bin/sh")
.args(["-c", "umask 000; exec \"$@\"", "sh"])
.arg(std::env::current_exe()?)
.args([
"--exact",
"oauth::tests::fallback_file_is_private_at_creation",
])
.env(CHILD, "1")
.status()?;
anyhow::ensure!(status.success(), "creation-permissions test failed");
return Ok(());
}
let _env = TempCodexHome::new();
let path = fallback_file_path()?;
let file = open_fallback_file_for_write(&path)?;
assert_eq!(file.metadata()?.permissions().mode() & 0o777, 0o600);
Ok(())
}
#[test]
fn fallback_file_updates_the_existing_file() -> Result<()> {
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
let env = TempCodexHome::new();
save_oauth_tokens_to_file(&sample_tokens())?;
let path = fallback_file_path()?;
let original = env.path().join("original-file");
fs::hard_link(&path, &original)?;
#[cfg(unix)]
fs::set_permissions(&path, fs::Permissions::from_mode(0o644))?;
let mut store = read_fallback_file_unlocked()?.expect("saved credentials");
store.values_mut().next().unwrap().access_token = "new".to_string();
write_fallback_file(&store)?;
let expected = serde_json::to_vec(&store)?;
assert_eq!(
[fs::read(original)?, fs::read(&path)?],
[expected.clone(), expected]
);
#[cfg(unix)]
assert_eq!(fs::metadata(&path)?.permissions().mode() & 0o777, 0o600);
Ok(())
}
#[cfg(any(unix, windows))]
#[test]
fn fallback_file_write_does_not_follow_symlinks() -> Result<()> {
#[cfg(unix)]
use std::os::unix::fs::symlink;
#[cfg(windows)]
use std::os::windows::fs::symlink_file as symlink;
let env = TempCodexHome::new();
let path = fallback_file_path()?;
let target = env.path().join("symlink-target");
fs::write(&target, "synthetic credentials")?;
let linked = symlink(&target, &path);
#[cfg(windows)]
if linked
.as_ref()
.is_err_and(|error| error.raw_os_error() == Some(1314))
{
eprintln!("Skipping symlink test: Windows symlink privilege unavailable");
return Ok(());
}
linked?;
assert!(open_fallback_file_for_write(&path).is_err());
assert_eq!(fs::read_to_string(target)?, "synthetic credentials");
assert!(fs::symlink_metadata(path)?.file_type().is_symlink());
Ok(())
}
#[test]
fn save_oauth_tokens_with_secrets_backend_writes_encrypted_storage() -> Result<()> {
let env = TempCodexHome::new();
let store = MockKeyringStore::default();
let tokens = sample_tokens();
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
let serialized = serde_json::to_string(&tokens)?;
store.save(KEYRING_SERVICE, &key, &serialized)?;
super::save_oauth_tokens_to_file(&tokens)?;
super::save_oauth_tokens_with_keyring_with_fallback_to_file(
&store,
AuthKeyringBackendKind::Secrets,
&tokens.server_name,
&tokens,
)?;
let manager = SecretsManager::new_with_keyring_store_and_namespace(
env.path().to_path_buf(),
SecretsBackendKind::Local,
Arc::new(store.clone()),
LocalSecretsNamespace::McpOAuth,
);
let secret_name = super::compute_secret_name(&tokens.server_name, &tokens.url)?;
let stored = manager
.get(&SecretScope::Global, &secret_name)?
.expect("tokens should be saved to encrypted storage");
assert_eq!(serde_json::from_str::<StoredOAuthTokens>(&stored)?, tokens);
assert_eq!(store.saved_value(&key), Some(serialized));
assert!(env.path().join("secrets").join("mcp_oauth.age").exists());
assert!(!env.path().join("secrets").join("local.age").exists());
assert!(!super::fallback_file_path()?.exists());
Ok(())
}
#[test]
fn load_oauth_tokens_with_secrets_backend_reads_encrypted_storage() -> Result<()> {
let _env = TempCodexHome::new();
let store = MockKeyringStore::default();
let tokens = sample_tokens();
let expected = tokens.clone();
super::save_oauth_tokens_with_keyring(
&store,
AuthKeyringBackendKind::Secrets,
&tokens.server_name,
&tokens,
)?;
let loaded = super::load_oauth_tokens_from_keyring(
&store,
AuthKeyringBackendKind::Secrets,
&tokens.server_name,
&tokens.url,
)?
.expect("tokens should load from encrypted storage");
assert_tokens_match_without_expiry(&loaded, &expected);
Ok(())
}
#[test]
fn load_oauth_tokens_with_secrets_backend_ignores_direct_entry() -> Result<()> {
let _env = TempCodexHome::new();
let store = MockKeyringStore::default();
let tokens = sample_tokens();
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
let serialized = serde_json::to_string(&tokens)?;
store.save(KEYRING_SERVICE, &key, &serialized)?;
let loaded = super::load_oauth_tokens_from_keyring(
&store,
AuthKeyringBackendKind::Secrets,
&tokens.server_name,
&tokens.url,
)?;
assert!(loaded.is_none());
Ok(())
}
#[test]
fn save_oauth_tokens_with_secrets_backend_falls_back_to_file_when_keyring_fails() -> Result<()>
{
let env = TempCodexHome::new();
let store = MockKeyringStore::default();
store.set_error(
&compute_keyring_account(env.path(), LocalSecretsNamespace::McpOAuth),
KeyringError::Invalid("error".into(), "save".into()),
);
let tokens = sample_tokens();
super::save_oauth_tokens_with_keyring_with_fallback_to_file(
&store,
AuthKeyringBackendKind::Secrets,
&tokens.server_name,
&tokens,
)?;
let saved = super::read_fallback_file_unlocked()?.expect("fallback file should load");
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
assert!(saved.contains_key(&key));
Ok(())
}
#[test]
fn delete_oauth_tokens_with_secrets_backend_removes_secrets_and_file() -> Result<()> {
let env = TempCodexHome::new();
let store = MockKeyringStore::default();
let tokens = sample_tokens();
let serialized = serde_json::to_string(&tokens)?;
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
store.save(KEYRING_SERVICE, &key, &serialized)?;
super::save_oauth_tokens_with_keyring(
&store,
AuthKeyringBackendKind::Secrets,
&tokens.server_name,
&tokens,
)?;
store.save(KEYRING_SERVICE, &key, &serialized)?;
super::save_oauth_tokens_to_file(&tokens)?;
let removed = super::delete_oauth_tokens_from_keyring_and_file(
&store,
OAuthCredentialsStoreMode::Auto,
AuthKeyringBackendKind::Secrets,
&tokens.server_name,
&tokens.url,
)?;
let manager = SecretsManager::new_with_keyring_store_and_namespace(
env.path().to_path_buf(),
SecretsBackendKind::Local,
Arc::new(store.clone()),
LocalSecretsNamespace::McpOAuth,
);
let secret_name = super::compute_secret_name(&tokens.server_name, &tokens.url)?;
assert!(removed);
assert!(manager.get(&SecretScope::Global, &secret_name)?.is_none());
assert!(store.saved_value(&key).is_none());
assert!(!super::fallback_file_path()?.exists());
Ok(())
}
#[test]
fn delete_oauth_tokens_removes_all_storage() -> Result<()> {
let _env = TempCodexHome::new();
let store = MockKeyringStore::default();
let tokens = sample_tokens();
let serialized = serde_json::to_string(&tokens)?;
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
store.save(KEYRING_SERVICE, &key, &serialized)?;
super::save_oauth_tokens_to_file(&tokens)?;
let mut entries = read_fallback_file_unlocked()?.expect("stored credentials");
entries.insert(
"older-build-key".to_string(),
FallbackTokenEntry::from(&tokens),
);
let other = StoredOAuthTokens {
server_name: "other".into(),
..tokens.clone()
};
let other_key = compute_store_key(&other.server_name, &other.url)?;
entries.insert(other_key.clone(), FallbackTokenEntry::from(&other));
write_fallback_file(&entries)?;
let removed = super::delete_oauth_tokens_from_keyring_and_file(
&store,
OAuthCredentialsStoreMode::Auto,
AuthKeyringBackendKind::Direct,
&tokens.server_name,
&tokens.url,
)?;
assert!(removed);
assert!(!store.contains(&key));
let remaining = read_fallback_file_unlocked()?.expect("other credential");
assert_eq!(remaining.into_keys().collect::<Vec<_>>(), vec![other_key]);
Ok(())
}
#[test]
fn enterprise_file_cleanup_preserves_legacy_local_credentials() -> Result<()> {
let _env = TempCodexHome::new();
let mut tokens = sample_tokens();
tokens.server_name = "ema-idp:demo".to_string();
let key = compute_store_key(&tokens.server_name, &tokens.url)?;
let local_key = compute_store_key("local:ema-idp:demo", &tokens.url)?;
let entry = FallbackTokenEntry::from(&tokens);
write_fallback_file(&FallbackFile::from([
(key.clone(), entry.clone()),
(local_key.clone(), entry),
]))?;
assert!(delete_oauth_tokens_from_file(
&tokens.server_name,
&tokens.url,
&key
)?);
let remaining = read_fallback_file_unlocked()?.expect("legacy local credential");
assert_eq!(remaining.into_keys().collect::<Vec<_>>(), vec![local_key]);
Ok(())
}
#[test]
fn delete_oauth_tokens_file_mode_removes_keyring_only_entry() -> Result<()> {
let _env = TempCodexHome::new();
let store = MockKeyringStore::default();
let tokens = sample_tokens();
let serialized = serde_json::to_string(&tokens)?;
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
store.save(KEYRING_SERVICE, &key, &serialized)?;
assert!(store.contains(&key));
let removed = super::delete_oauth_tokens_from_keyring_and_file(
&store,
OAuthCredentialsStoreMode::Auto,
AuthKeyringBackendKind::Direct,
&tokens.server_name,
&tokens.url,
)?;
assert!(removed);
assert!(!store.contains(&key));
assert!(!super::fallback_file_path()?.exists());
Ok(())
}
#[test]
fn delete_oauth_tokens_propagates_keyring_errors() -> Result<()> {
let _env = TempCodexHome::new();
let store = MockKeyringStore::default();
let tokens = sample_tokens();
let key = super::compute_store_key(&tokens.server_name, &tokens.url)?;
store.set_error(&key, KeyringError::Invalid("error".into(), "delete".into()));
super::save_oauth_tokens_to_file(&tokens).unwrap();
let result = super::delete_oauth_tokens_from_keyring_and_file(
&store,
OAuthCredentialsStoreMode::Auto,
AuthKeyringBackendKind::Direct,
&tokens.server_name,
&tokens.url,
);
assert!(result.is_err());
assert!(super::fallback_file_path().unwrap().exists());
Ok(())
}
#[test]
fn refresh_expires_in_from_timestamp_restores_future_durations() {
let mut tokens = sample_tokens();
let expires_at = tokens.expires_at.expect("expires_at should be set");
tokens.token_response.0.set_expires_in(None);
super::refresh_expires_in_from_timestamp(&mut tokens);
let actual = tokens
.token_response
.0
.expires_in()
.expect("expires_in should be restored")
.as_secs();
let expected = super::expires_in_from_timestamp(expires_at)
.expect("expires_at should still be in the future");
let diff = actual.abs_diff(expected);
assert!(diff <= 1, "expires_in drift too large: diff={diff}");
}
#[test]
fn refresh_expires_in_from_timestamp_marks_expired_tokens() {
let mut tokens = sample_tokens();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0));
let expired_at = now.as_millis() as u64;
tokens.expires_at = Some(expired_at.saturating_sub(1000));
let duration = Duration::from_secs(600);
tokens.token_response.0.set_expires_in(Some(&duration));
super::refresh_expires_in_from_timestamp(&mut tokens);
assert_eq!(tokens.token_response.0.expires_in(), Some(Duration::ZERO));
}
#[test]
fn oauth_tokens_are_usable_when_expiry_is_unknown() {
let mut tokens = sample_tokens();
tokens.expires_at = None;
tokens.token_response.0.set_refresh_token(None);
assert!(super::oauth_tokens_are_usable(&tokens));
}
#[test]
fn oauth_tokens_are_usable_when_unexpired_without_refresh_token() {
let mut tokens = sample_tokens();
tokens.token_response.0.set_refresh_token(None);
assert!(super::oauth_tokens_are_usable(&tokens));
}
#[test]
fn oauth_tokens_are_usable_when_expired_but_refreshable() {
let mut tokens = sample_tokens();
tokens.expires_at = Some(0);
assert!(super::oauth_tokens_are_usable(&tokens));
}
#[test]
fn oauth_tokens_are_not_usable_when_expired_and_unrefreshable() {
let mut tokens = sample_tokens();
tokens.expires_at = Some(0);
tokens.token_response.0.set_refresh_token(None);
assert!(!super::oauth_tokens_are_usable(&tokens));
}
#[test]
fn oauth_tokens_are_not_usable_when_near_expiry_and_unrefreshable() {
let mut tokens = sample_tokens();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_else(|_| Duration::from_secs(0))
.as_millis() as u64;
tokens.expires_at = Some(now.saturating_add(REFRESH_SKEW_MILLIS - 1));
tokens.token_response.0.set_refresh_token(None);
assert!(!super::oauth_tokens_are_usable(&tokens));
}
#[test]
fn oauth_tokens_are_not_usable_when_client_id_is_blank() {
let mut tokens = sample_tokens();
tokens.client_id = " ".to_string();
assert!(!super::oauth_tokens_are_usable(&tokens));
}
#[test]
fn oauth_tokens_are_not_usable_when_access_token_is_blank() {
let mut tokens = sample_tokens();
tokens
.token_response
.0
.set_access_token(AccessToken::new(" ".to_string()));
assert!(!super::oauth_tokens_are_usable(&tokens));
}
#[test]
fn oauth_tokens_are_not_usable_when_required_refresh_token_is_blank() {
let mut tokens = sample_tokens();
tokens.expires_at = Some(0);
tokens
.token_response
.0
.set_refresh_token(Some(RefreshToken::new(" ".to_string())));
assert!(!super::oauth_tokens_are_usable(&tokens));
}
fn assert_tokens_match_without_expiry(
actual: &StoredOAuthTokens,
expected: &StoredOAuthTokens,
) {
assert_eq!(actual.server_name, expected.server_name);
assert_eq!(actual.url, expected.url);
assert_eq!(actual.issuer, expected.issuer);
assert_eq!(actual.client_id, expected.client_id);
assert_eq!(actual.expires_at, expected.expires_at);
assert_token_response_match_without_expiry(
&actual.token_response,
&expected.token_response,
);
}
fn assert_token_response_match_without_expiry(
actual: &WrappedOAuthTokenResponse,
expected: &WrappedOAuthTokenResponse,
) {
let actual_response = &actual.0;
let expected_response = &expected.0;
assert_eq!(
actual_response.access_token().secret(),
expected_response.access_token().secret()
);
assert_eq!(actual_response.token_type(), expected_response.token_type());
assert_eq!(
actual_response.refresh_token().map(RefreshToken::secret),
expected_response.refresh_token().map(RefreshToken::secret),
);
assert_eq!(actual_response.scopes(), expected_response.scopes());
assert_eq!(
actual_response.extra_fields().0,
expected_response.extra_fields().0
);
assert_eq!(
actual_response.expires_in().is_some(),
expected_response.expires_in().is_some()
);
}
fn sample_tokens() -> StoredOAuthTokens {
let mut response = OAuthTokenResponse::new(
AccessToken::new("access-token".to_string()),
BasicTokenType::Bearer,
VendorExtraTokenFields::default(),
);
response.set_refresh_token(Some(RefreshToken::new("refresh-token".to_string())));
response.set_scopes(Some(vec![
Scope::new("scope-a".to_string()),
Scope::new("scope-b".to_string()),
]));
let expires_in = Duration::from_secs(3600);
response.set_expires_in(Some(&expires_in));
let expires_at = super::compute_expires_at_millis(&response);
StoredOAuthTokens {
server_name: "test-server".to_string(),
url: "https://example.test".to_string(),
issuer: Some("https://issuer.example.test".to_string()),
client_id: "client-id".to_string(),
token_response: WrappedOAuthTokenResponse(response),
expires_at,
}
}
}