Skip to content

[rmcp-client] Coordinate MCP OAuth refresh across clients - #28647

Closed
stevenlee-oai wants to merge 13 commits into
mainfrom
dev/stevenlee/mcp-oauth-refresh-transaction
Closed

[rmcp-client] Coordinate MCP OAuth refresh across clients#28647
stevenlee-oai wants to merge 13 commits into
mainfrom
dev/stevenlee/mcp-oauth-refresh-transaction

Conversation

@stevenlee-oai

@stevenlee-oai stevenlee-oai commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Codex Thread 019ed376-876c-7793-8da7-74eabb2ae263

Why

Multiple Codex clients can start from the same persisted MCP OAuth refresh token. If they refresh concurrently, a provider that rotates refresh tokens can accept one request and reject the other, leaving one client with stale credentials.

This race existed because RMCP could refresh from its in-memory credential independently of Codex's persistent store. A timing buffer alone cannot close that gap, especially when a provider omits expires_in or an access token is rejected before its recorded expiry.

File-backed stores have a second concurrency problem: .credentials.json and secrets/mcp_oauth.age each contain credentials for multiple MCP servers. Concurrent read-modify-write operations for different servers can overwrite each other's entries.

This PR gives Codex sole ownership of MCP OAuth refresh and persistence, and coordinates both levels without holding a store-wide lock across browser or provider network requests.

Ownership Model

  • Persistent storage is the source of truth for the refresh token and absolute expires_at.
  • RMCP receives only the access token and request scopes. It does not receive refresh_token, expires_in, or token_received_at, so it cannot independently refresh or persist a rotated credential.
  • OAuthPersistor owns proactive refresh, 401 recovery, refresh-token rotation, and persistence.
  • Full refresh-capable credentials are staged in the AuthorizationManager only while Codex holds both the manager mutex and the per-credential transaction lock. The manager is restored to an access-only credential before the mutex is released.

Lock Model

There are two lock scopes:

  1. Per-credential transaction lock

    • Keyed by compute_store_key(server_name, url).
    • Serializes refresh, final login persistence, and logout for the same MCP credential.
    • Held from the authoritative backend reread through provider refresh and persistence, so only one client can use a rotating refresh token.
  2. Short store-wide file lock

    • One lock for .credentials.json and one for secrets/mcp_oauth.age.
    • Held only while reading or performing a complete read-modify-write on that shared store.
    • Preserves unrelated server entries by rereading the latest map, replacing/removing one entry, and writing the merged map.
    • Never held across browser authorization or provider refresh HTTP requests.

Direct keyring storage remains per credential because each credential is a separate keyring item. Its fallback-file cleanup uses the File store lock.

Operation Per-credential lock File/Secrets store lock
Proactive or 401 refresh Held from authoritative reread through persistence Briefly held for backend reread and persistence; released during provider request
Login Acquired only after callback/token exchange Briefly held for final persistence
Logout Held through deletion Briefly held for each shared-store deletion

Lock ordering is always per-credential first, then one store lock. The File and Secrets store locks are never held together.

Refresh And Recovery

Before an operation, Codex proactively refreshes credentials whose absolute expires_at is within 60 seconds:

  1. acquire the per-credential lock;
  2. reread the configured persistent backend;
  3. adopt and use a latest credential that is already valid;
  4. otherwise refresh with the latest persisted refresh token;
  5. persist the full rotated credential before releasing the lock;
  6. expose only the resulting access token to RMCP.

Loading may recompute relative expires_in, but refresh decisions use the authoritative absolute expires_at, so elapsed-time drift does not incorrectly skip a required refresh.

If initialization or an MCP operation returns 401, Codex runs the same locked transaction and retries that request once. If another client already persisted a newer usable access token, the loser adopts it without another provider request. If the latest access token is still the rejected token, Codex forces a provider refresh even when expiry metadata is absent.

Missing persistent credentials clear the live manager and return authorization-required behavior instead of refreshing stale in-memory state. Lock, reread, adoption, persistence, and provider-refresh errors propagate; RMCP does not issue an independent refresh or retry.

In Auto mode, a keyring read failure is distinct from a confirmed missing credential. Codex still uses an available fallback-file credential, but if the keyring read fails and no fallback exists, the refresh returns the storage error without clearing the live manager or using its stale refresh token.

Login, Logout, And Active Threads

  • OAuth login completes the browser/provider flow without a lock, then locks and persists the new credential. A completed login therefore wins over an earlier refresh.
  • Existing codex mcp logout locks before deleting credentials. A completed logout cannot be overwritten or resurrected by refresh.
  • App-server OAuth login preserves its existing completion-notification behavior and does not rebuild active threads' MCP managers.

Bounded Waits

  • Per-credential lock acquisition retries every 500ms; short File/Secrets store lock acquisition retries every 50ms. Both return a clear error after 60 seconds.
  • Provider refresh is limited to 45 seconds.
  • Locks are OS advisory locks, not TTL leases. Process exit releases ownership even though reusable lockfiles remain on disk; a live owner is never displaced.

Non-Goals / Known Limits

  • No app-server API contract changes or schema regeneration.
  • There is no targeted per-server refresh after app-server OAuth login. Existing sessions need config/mcpServer/reload or restart to load the newly authorized server and tools. A targeted refresh is intentionally separate from this credential-concurrency change so unrelated MCP sessions are not reconnected by a full-manager reload.
  • There is no app-server MCP OAuth logout RPC. codex mcp logout is a separate process and cannot notify active app-server threads. Existing sessions likewise need config/mcpServer/reload or restart to immediately discard loaded clients and tools after CLI logout.
  • Different-CODEX_HOME direct-keyring coordination remains out of scope. File and Secrets locks naturally coordinate processes using the same backing store under one CODEX_HOME.
  • OAuth UI/state and provider metadata/scope behavior are unchanged.
  • No lockfile TTL, startup lock-directory cleanup, or lock stealing.

Verification

  • just test -p codex-rmcp-client (104 passed, 6 skipped)
  • just fix -p codex-rmcp-client

Focused regressions cover:

  • two clients starting from one rotating refresh token, with only one provider refresh;
  • no-expiry credentials receiving 401, including loser adoption of the winner's persisted token;
  • one-shot 401 recovery during initialization and normal operations;
  • persistence of the final rotated credential, verified by a newly constructed client;
  • adoption of a valid persisted winner without another refresh;
  • derived expires_in drift without incorrectly skipping a required refresh;
  • use of the latest persisted refresh token;
  • Auto-mode keyring read failure with no fallback returning an error without clearing live credentials;
  • preflight failure blocking the MCP operation and any RMCP retry;
  • both login/logout orderings;
  • concurrent different-server updates preserving both entries in File and Secrets stores;
  • lock and provider timeout behavior.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6cafc8a930

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread codex-rs/rmcp-client/src/oauth.rs Outdated
Comment on lines +638 to +642
if latest != snapshot {
if let Some(latest) = latest {
self.adopt_credentials(latest).await?;
}
return Ok(());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh despite transient expires_in differences

When a persisted token is inside the refresh skew but not yet expired, load_oauth_tokens_* recomputes expires_in from expires_at on every read, so rereading under the lock can make latest != snapshot solely because a second elapsed. This branch then assumes another client refreshed, adopts the same near-expiry credential, and returns without calling refresh_token; once the token expires, the transport can still auto-refresh outside this lock and reintroduce the rotating-refresh-token race this change is trying to prevent. Compare only stable credential fields or re-check token_needs_refresh(latest.expires_at) before skipping the refresh.

Useful? React with 👍 / 👎.

Some(oauth_persistor),
),
} => {
oauth_persistor.refresh_if_needed().await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep startup refresh inside the initialize timeout

For OAuth-backed streamable HTTP clients with expired persisted credentials, this await can perform provider discovery/token refresh I/O and can also wait indefinitely for another process to release the refresh lock, but it runs before service::serve_client(...) is wrapped in the caller's handshake timeout below. In that scenario initialize(..., Some(timeout)) no longer bounds startup, so a hung token endpoint or lock holder can hang the client instead of timing out; the refresh needs to share the same timeout/deadline as the initialize attempt.

Useful? React with 👍 / 👎.

Comment thread codex-rs/rmcp-client/src/oauth.rs Outdated
Comment on lines +628 to +631
if latest.is_none() && snapshot.is_some() {
self.clear_manager_credentials().await;
let mut last_credentials = self.inner.last_credentials.lock().await;
*last_credentials = None;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't treat Auto keyring read failures as logout

In OAuthCredentialsStoreMode::Auto, load_oauth_tokens_from_keyring_with_fallback_to_file converts a keyring read error into a fallback-file lookup; if the credential was only in the keyring and that lookup returns None, this new branch treats the token as deliberately removed, clears the in-memory manager, and aborts instead of refreshing the still-available snapshot. A transient keyring outage during startup/proactive refresh can therefore force reauthorization even though the client already has the refresh token; preserve the keyring error or allow the snapshot refresh when Auto fallback is empty due to a keyring failure.

Useful? React with 👍 / 👎.

Comment on lines +936 to +938
Ok(find_codex_home()?
.join(REFRESH_LOCK_DIR)
.join(format!("{digest:x}.lock"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Share the lock wherever the credential is shared

For the default direct keyring backend, the OAuth credential is stored in the OS keyring by compute_store_key(server_name, url) and is not scoped by CODEX_HOME, but this new lock path is under find_codex_home(). Two Codex processes that use different CODEX_HOME values while sharing the same OS keyring entry will therefore take different lock files and can still refresh the same rotating refresh token concurrently; put the direct-keyring lock in a location scoped like the keyring credential, or include the home/profile in the credential key as well.

Useful? React with 👍 / 👎.

@stevenlee-oai

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c3cff7d5cb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines 694 to +697
return Ok(());
}

let _lock =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Acquire the refresh lock before the skew check

When a stored token is just outside this 30s skew, or crosses it between this check and the actual HTTP request, this returns before taking the cross-process lock. RMCP also refreshes tokens inside the request path when they fall under its 30s buffer, and the later persist_if_needed save is not protected by this lock, so two clients starting with the same near-expiry rotating refresh token can still refresh concurrently and leave one client with stale credentials. Consider acquiring the coordination lock before deciding to skip, or using a wider/authoritative preflight window that prevents RMCP's internal refresh path from running unlocked.

Useful? React with 👍 / 👎.

Some(oauth_persistor),
),
} => {
oauth_persistor.refresh_if_needed().await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep startup OAuth refresh within startup timeout

For OAuth-backed streamable HTTP servers with expired or near-expired saved credentials, this refresh runs before the transport handshake is wrapped in the caller's startup_timeout_sec. If another Codex process holds the refresh lock or the token endpoint stalls, startup can now wait for the fixed OAuth lock/provider limits (60s/45s) even when the server is configured with a much smaller startup timeout, so bound this preflight by the remaining startup deadline too.

Useful? React with 👍 / 👎.

timeout: Option<Duration>,
) -> Result<ListToolsResult> {
self.refresh_oauth_if_needed().await;
self.refresh_oauth_if_needed().await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep operation OAuth refresh within tool timeouts

When a tool/resource operation supplies a timeout, the OAuth preflight happens before run_service_operation starts applying that timeout. With near-expiry credentials and either a contended refresh lock or a slow token endpoint, a call can wait for the fixed OAuth refresh waits before the configured tool_timeout_sec begins, so the per-tool limit is bypassed; pass the operation deadline through the refresh path as well.

Useful? React with 👍 / 👎.

store_mode: OAuthCredentialsStoreMode,
keyring_backend_kind: AuthKeyringBackendKind,
) -> Result<()> {
let _lock = RefreshCredentialLock::acquire_for_server(server_name, &tokens.url).await?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize fallback-file writes with a file-wide lock

In File mode (and Auto when falling back), this lock is keyed per server, but the subsequent save/delete rewrites the single shared .credentials.json map. Two processes updating different MCP servers therefore take different locks, both read the same old file, and the later write can drop the other server's refreshed/login/logout update; use a lock that covers the fallback credentials file whenever that backend is written.

Useful? React with 👍 / 👎.

@stevenlee-oai
stevenlee-oai force-pushed the dev/stevenlee/mcp-oauth-refresh-transaction branch from 878649a to aca2273 Compare June 18, 2026 05:14
@stevenlee-oai

Copy link
Copy Markdown
Contributor Author

@codex review

@stevenlee-oai
stevenlee-oai force-pushed the dev/stevenlee/mcp-oauth-refresh-transaction branch from aca2273 to e37efba Compare June 18, 2026 17:55
Comment thread codex-rs/rmcp-client/src/oauth.rs
Comment thread codex-rs/app-server/src/request_processors/mcp_processor.rs Outdated
@stevenlee-oai
stevenlee-oai force-pushed the dev/stevenlee/mcp-oauth-refresh-transaction branch from efd9577 to a180e13 Compare June 18, 2026 20:11
@stevenlee-oai
stevenlee-oai force-pushed the dev/stevenlee/mcp-oauth-refresh-transaction branch from a180e13 to e1f541b Compare June 18, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants