Allow provider selection for gateway OAuth reads and logins (#59819)

## Why

Gateway OAuth reads and logins previously used only the current provider, preventing clients from authorizing another configured provider without changing the default.

## What changed

- Add optional `modelProvider` params to `account/gatewayOAuth/read` and `account/gatewayOAuth/login`, preserving current-provider behavior for omitted or null params and selectors.
- Validate explicit providers against current managed policy without changing the default or any thread's provider.
- Retain readiness state across providers and send `account/gatewayOAuth/changed` notifications for every configured provider sharing the changed credential.
- Update protocol schemas, TypeScript types, Python models, and documentation.

## Testing

Add coverage for non-default provider login, retained readiness when alternating providers, notifications for shared credentials without starting login, and legacy TypeScript request compatibility.

GitOrigin-RevId: cfd472c322b821fe36b8c133a8de774f4ba48534
This commit is contained in:
Abhinav
2026-11-06 04:02:11 +00:00
committed by copyberry
parent a142c90e26
commit aece559654
22 changed files with 561 additions and 71 deletions
@@ -1,4 +1,4 @@
//! Forward OAuth progress for the app's current gateway.
//! Forward OAuth progress for every configured provider sharing the changed grant.
use crate::config_manager::ConfigManager;
use crate::outgoing_message::OutgoingMessageSender;
@@ -39,27 +39,30 @@ pub(crate) fn spawn(
else {
continue;
};
let Ok(Some(manager)) = codex_model_provider::create_model_provider(
config.model_provider.clone(),
Some(Arc::clone(&auth_manager)),
)
.gateway_auth_manager() else {
continue;
};
if !matches!(manager.oauth_session(), Some(oauth) if oauth.config() == &change.config) {
continue;
for (provider_id, provider) in &config.model_providers {
let Ok(Some(manager)) = codex_model_provider::create_model_provider(
provider.clone(),
Some(Arc::clone(&auth_manager)),
)
.gateway_auth_manager() else {
continue;
};
if !matches!(manager.oauth_session(), Some(oauth) if oauth.config() == &change.config)
{
continue;
}
let (status, error) = status_fields(change.status.clone());
outgoing
.send_server_notification(ServerNotification::GatewayOAuthChanged(
GatewayOAuthChangedNotification {
auth_url: None,
provider_id: provider_id.clone(),
status,
error,
},
))
.await;
}
let (status, error) = status_fields(change.status);
outgoing
.send_server_notification(ServerNotification::GatewayOAuthChanged(
GatewayOAuthChangedNotification {
auth_url: None,
provider_id: config.model_provider_id,
status,
error,
},
))
.await;
}
}))
}
+12 -10
View File
@@ -1890,12 +1890,13 @@ impl MessageProcessor {
.bedrock_check_gov_cloud_requirements()
.await
}
ClientRequest::GatewayOAuthRead { .. } => {
Box::pin(self.account_processor.gateway_oauth_read())
.await
.map(|response| Some(response.into()))
}
ClientRequest::GatewayOAuthLogin { .. } => {
ClientRequest::GatewayOAuthRead { params, .. } => Box::pin(
self.account_processor
.gateway_oauth_read(params.unwrap_or_default()),
)
.await
.map(|response| Some(response.into())),
ClientRequest::GatewayOAuthLogin { params, .. } => {
if session
.opted_out_notification_methods()
.contains("account/gatewayOAuth/changed")
@@ -1904,10 +1905,11 @@ impl MessageProcessor {
"Gateway login requires account/gatewayOAuth/changed notifications",
))
} else {
Box::pin(
self.account_processor
.gateway_oauth_login(connection_id, &session.rpc_gate),
)
Box::pin(self.account_processor.gateway_oauth_login(
params.unwrap_or_default(),
connection_id,
&session.rpc_gate,
))
.await
.map(|response| Some(response.into()))
}
@@ -24,6 +24,7 @@ use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
use tempfile::TempDir;
use test_case::test_case;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
@@ -47,8 +48,10 @@ async fn send_request(
.await;
}
#[test_case(false; "default provider")]
#[test_case(true; "explicit non-default provider")]
#[tokio::test]
async fn gateway_oauth_login_updates_existing_requests() -> Result<()> {
async fn gateway_oauth_login_updates_existing_requests(selected: bool) -> Result<()> {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/token"))
@@ -121,10 +124,23 @@ delivery = {{ kind = "header", name = "X-Gateway-Authorization" }}
)
.await;
let _: InitializeResponse = read_response(&mut outgoing, /*request_id*/ 1).await;
// Existing request consumers retain the gateway when the default changes.
if selected {
let path = home.path().join("config.toml");
let text = std::fs::read_to_string(&path)?;
std::fs::write(
path,
text.replace(
"model_provider = \"gateway\"",
"model_provider = \"openai\"",
),
)?;
}
let params = selected.then(|| json!({"modelProvider": "gateway"}));
send_request(
&processor,
&session,
json!({"id": 2, "method": "account/gatewayOAuth/login"}),
json!({"id": 2, "method": "account/gatewayOAuth/login", "params": params}),
)
.await;
let authorization_url = timeout(Duration::from_secs(/*secs*/ 10), async {
@@ -166,7 +182,7 @@ delivery = {{ kind = "header", name = "X-Gateway-Authorization" }}
send_request(
&processor,
&session,
json!({"id": 3, "method": "account/gatewayOAuth/read"}),
json!({"id": 3, "method": "account/gatewayOAuth/read", "params": params}),
)
.await;
let response: GatewayOAuthReadResponse = read_response(&mut outgoing, /*request_id*/ 3).await;
@@ -288,3 +304,61 @@ delivery = {{ kind = "header", name = "X-Gateway-Authorization" }}
processor.drain_background_tasks().await;
Ok(())
}
/// Checking another provider must not recreate the first provider's readiness state.
#[tokio::test]
async fn gateway_oauth_read_retains_each_provider_status() -> Result<()> {
let home = TempDir::new()?;
let mut text = String::from("[features]\nsecret_auth_storage = true\n");
for id in ["first", "second"] {
text.push_str(&format!(
r#"
[model_providers.{id}]
name = "{id}"
base_url = "https://{id}.example/v1"
wire_api = "responses"
[model_providers.{id}.gateway_oauth]
authorization_url = "https://{id}.example/authorize"
token_url = "https://{id}.example/token"
client_id = "{id}"
delivery = {{ kind = "header", name = "X-Gateway-Authorization" }}
"#
));
}
std::fs::write(home.path().join("config.toml"), text)?;
let config = Arc::new(
ConfigBuilder::default()
.codex_home(home.path().to_path_buf())
.build()
.await?,
);
let auth = AuthManager::from_auth_for_testing_with_home(
CodexAuth::from_api_key("primary"),
home.path().to_path_buf(),
);
let mut changes = codex_login::subscribe_gateway_auth_status(&auth.runtime_config());
let (processor, _outgoing) = build_test_processor(config, auth).await;
for (id, changed) in [("first", true), ("second", true), ("first", false)] {
let state = processor
.account_processor
.gateway_oauth_read(codex_app_server_protocol::GatewayOAuthReadParams {
model_provider: Some(id.into()),
})
.await
.map_err(|err| anyhow::anyhow!("{}", err.message))?;
assert_eq!(state.provider_id, id);
assert_eq!(state.status, Some(GatewayOAuthStatus::NotReady));
if changed {
assert_eq!(
changes.try_recv()?.status,
codex_login::GatewayAuthStatus::NotReady
);
} else {
assert!(matches!(
changes.try_recv(),
Err(tokio::sync::broadcast::error::TryRecvError::Empty)
));
}
}
Ok(())
}
@@ -117,7 +117,8 @@ pub(crate) struct AccountRequestProcessor {
workspace_routing_fetches: Arc<Mutex<workspace_routing::WorkspaceRoutingFetches>>,
workspace_routing_shutdown: CancellationToken,
gateway_login: Arc<std::sync::Mutex<Option<gateway_oauth::ActiveGatewayLogin>>>,
gateway_client: Arc<std::sync::Mutex<Option<Arc<codex_model_provider::GatewayAuthManager>>>>,
gateway_clients:
Arc<std::sync::Mutex<HashMap<String, Arc<codex_model_provider::GatewayAuthManager>>>>,
_gateway_notifications: Arc<tokio_util::task::AbortOnDropHandle<()>>,
pub(super) enterprise_login: Arc<enterprise_login::EnterpriseLoginState>,
}
@@ -151,7 +152,7 @@ impl AccountRequestProcessor {
model_catalog,
active_login: Arc::new(Mutex::new(None)),
gateway_login: Arc::new(std::sync::Mutex::new(/*t*/ None)),
gateway_client: Arc::new(std::sync::Mutex::new(/*t*/ None)),
gateway_clients: Arc::new(std::sync::Mutex::new(HashMap::new())),
workspace_routing: Arc::new(Mutex::new(None)),
workspace_routing_fetches: Arc::new(Mutex::new(HashMap::new())),
workspace_routing_shutdown: CancellationToken::new(),
@@ -30,14 +30,25 @@ impl Drop for LoginGuard {
impl AccountRequestProcessor {
async fn gateway_client(
&self,
) -> Result<(Config, Option<Arc<OAuthSessionManager>>), JSONRPCErrorError> {
let config = self
.config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
.map_err(|err| {
internal_error(format!("failed to load gateway configuration: {err}"))
})?;
model_provider: Option<String>,
) -> Result<(Arc<Config>, Option<Arc<OAuthSessionManager>>), JSONRPCErrorError> {
let config = match model_provider.as_deref() {
Some(provider_id) => {
let config = config_for_provider(&self.config, provider_id)?;
// Explicit selectors use startup definitions, so validate against current policy.
self.config_manager
.check_thread_model_provider(&config)
.await
.map_err(|err| config_load_error(&err))?;
config
}
None => Arc::new(
self.config_manager
.load_latest_config(/*fallback_cwd*/ None)
.await
.map_err(|err| config_load_error(&err))?,
),
};
let manager = codex_model_provider::create_model_provider(
config.model_provider.clone(),
Some(Arc::clone(&self.auth_manager)),
@@ -47,17 +58,21 @@ impl AccountRequestProcessor {
let session = manager
.as_deref()
.and_then(GatewayAuthManager::oauth_session);
*self
.gateway_client
.lock()
.unwrap_or_else(PoisonError::into_inner) = manager;
// Keep sign-in status alive when clients alternate between providers.
if let Some(manager) = &manager {
self.gateway_clients
.lock()
.unwrap_or_else(PoisonError::into_inner)
.insert(config.model_provider_id.clone(), Arc::clone(manager));
}
Ok((config, session))
}
pub(crate) async fn gateway_oauth_read(
&self,
params: codex_app_server_protocol::GatewayOAuthReadParams,
) -> Result<GatewayOAuthReadResponse, JSONRPCErrorError> {
let (config, client) = self.gateway_client().await?;
let (config, client) = self.gateway_client(params.model_provider).await?;
let state = if let Some(client) = client {
Some(status_fields(
client
@@ -69,8 +84,8 @@ impl AccountRequestProcessor {
None
};
Ok(GatewayOAuthReadResponse {
provider_id: config.model_provider_id,
provider_name: config.model_provider.name,
provider_id: config.model_provider_id.clone(),
provider_name: config.model_provider.name.clone(),
required: state.is_some(),
status: state.as_ref().map(|state| state.0),
error: state.and_then(|state| state.1),
@@ -79,6 +94,7 @@ impl AccountRequestProcessor {
pub(crate) async fn gateway_oauth_login(
&self,
params: codex_app_server_protocol::GatewayOAuthLoginParams,
owner: ConnectionId,
connection_gate: &crate::connection_rpc_gate::ConnectionRpcGate,
) -> Result<GatewayOAuthLoginResponse, JSONRPCErrorError> {
@@ -101,9 +117,9 @@ impl AccountRequestProcessor {
});
LoginGuard(Arc::clone(&self.gateway_login))
};
let (config, client) = self.gateway_client().await?;
let (config, client) = self.gateway_client(params.model_provider).await?;
let client = client
.ok_or_else(|| invalid_request("The current provider does not use gateway OAuth"))?;
.ok_or_else(|| invalid_request("The selected provider does not use gateway OAuth"))?;
if config.model_provider.requires_openai_auth
&& config.model_provider.env_key.is_none()
&& config.model_provider.experimental_bearer_token.is_none()