From ae110493973d5b72f60d42efe2fae91d51ca9c0e Mon Sep 17 00:00:00 2001 From: smurching-oai Date: Wed, 14 Oct 2026 19:00:45 +0000 Subject: [PATCH] Expose gateway authentication as a reusable OAuth session API (#54070) ## What changed Rename the grant manager and supporting modules in `codex-rs/login` to expose `OAuthSessionManager`, its configuration and status types, `OAuthLoginControl`, and `subscribe_oauth_session_status`. Keep the existing gateway API names as compatibility aliases and retain the credential storage paths and namespace so saved credentials remain usable. Update the existing authentication tests to use the OAuth session API. GitOrigin-RevId: 72cea12be606dde28cf42809e7f8b8807cbc4a2a --- codex-rs/login/src/lib.rs | 25 ++-- .../src/{gateway_auth.rs => oauth_session.rs} | 94 +++++++-------- ..._callback.rs => oauth_session_callback.rs} | 0 ...y_auth_login.rs => oauth_session_login.rs} | 107 +++++++++--------- ..._tests.rs => oauth_session_login_tests.rs} | 94 +++++++++------ ...th_storage.rs => oauth_session_storage.rs} | 7 +- ...ests.rs => oauth_session_storage_tests.rs} | 16 +-- ...y_auth_tests.rs => oauth_session_tests.rs} | 40 +++---- ...y_auth_token.rs => oauth_session_token.rs} | 4 +- 9 files changed, 212 insertions(+), 175 deletions(-) rename codex-rs/login/src/{gateway_auth.rs => oauth_session.rs} (88%) rename codex-rs/login/src/{gateway_auth_callback.rs => oauth_session_callback.rs} (100%) rename codex-rs/login/src/{gateway_auth_login.rs => oauth_session_login.rs} (79%) rename codex-rs/login/src/{gateway_auth_login_tests.rs => oauth_session_login_tests.rs} (88%) rename codex-rs/login/src/{gateway_auth_storage.rs => oauth_session_storage.rs} (91%) rename codex-rs/login/src/{gateway_auth_storage_tests.rs => oauth_session_storage_tests.rs} (93%) rename codex-rs/login/src/{gateway_auth_tests.rs => oauth_session_tests.rs} (97%) rename codex-rs/login/src/{gateway_auth_token.rs => oauth_session_token.rs} (98%) diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index abbbf1d897..ea82416d62 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -9,8 +9,8 @@ pub use auth::WorkspaceRoutingSession; mod callback_params; mod device_code_auth; -mod gateway_auth; mod oauth; +mod oauth_session; mod outbound_proxy; mod pkce; mod server; @@ -75,10 +75,19 @@ pub use auth_env_telemetry::collect_auth_env_telemetry; pub use outbound_proxy::AuthRouteConfig; pub use token_data::TokenData; -pub use gateway_auth::GatewayAuthConfig; -pub use gateway_auth::GatewayAuthError; -pub use gateway_auth::GatewayAuthManager; -pub use gateway_auth::GatewayAuthStatus; -pub use gateway_auth::GatewayAuthStatusChange; -pub use gateway_auth::GatewayLoginControl; -pub use gateway_auth::subscribe_gateway_auth_status; +// Existing inference-gateway names continue to refer to the same grant manager. +pub use oauth_session::OAuthLoginControl as GatewayLoginControl; +pub use oauth_session::OAuthSessionConfig as GatewayAuthConfig; +pub use oauth_session::OAuthSessionError as GatewayAuthError; +pub use oauth_session::OAuthSessionManager as GatewayAuthManager; +pub use oauth_session::OAuthSessionStatus as GatewayAuthStatus; +pub use oauth_session::OAuthSessionStatusChange as GatewayAuthStatusChange; +pub use oauth_session::subscribe_oauth_session_status as subscribe_gateway_auth_status; + +pub use oauth_session::OAuthLoginControl; +pub use oauth_session::OAuthSessionConfig; +pub use oauth_session::OAuthSessionError; +pub use oauth_session::OAuthSessionManager; +pub use oauth_session::OAuthSessionStatus; +pub use oauth_session::OAuthSessionStatusChange; +pub use oauth_session::subscribe_oauth_session_status; diff --git a/codex-rs/login/src/gateway_auth.rs b/codex-rs/login/src/oauth_session.rs similarity index 88% rename from codex-rs/login/src/gateway_auth.rs rename to codex-rs/login/src/oauth_session.rs index 8ceb769e01..4119aab553 100644 --- a/codex-rs/login/src/gateway_auth.rs +++ b/codex-rs/login/src/oauth_session.rs @@ -32,40 +32,40 @@ use tokio::sync::Mutex; use url::Host; use url::Url; -#[path = "gateway_auth_callback.rs"] +#[path = "oauth_session_callback.rs"] mod callback; -#[path = "gateway_auth_login.rs"] +#[path = "oauth_session_login.rs"] mod login; -#[path = "gateway_auth_storage.rs"] +#[path = "oauth_session_storage.rs"] mod storage; -#[path = "gateway_auth_token.rs"] +#[path = "oauth_session_token.rs"] mod token; -pub use login::GatewayAuthStatus; -pub use login::GatewayAuthStatusChange; -pub use login::GatewayLoginControl; -pub use login::subscribe_gateway_auth_status; +pub use login::OAuthLoginControl; +pub use login::OAuthSessionStatus; +pub use login::OAuthSessionStatusChange; +pub use login::subscribe_oauth_session_status; use callback::CallbackListener; -use storage::GatewayAuthStorage; +use storage::OAuthSessionStorage; use token::StoredToken; use token::TokenResponse; const REFRESH_SKEW_SECONDS: i64 = 30; const HTTP_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 20); -/// Gateway authentication states that require user action rather than request retries. +/// Credential states that require user action rather than request retries. #[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] -pub enum GatewayAuthError { +pub enum OAuthSessionError { #[error("Gateway sign-in required. Choose Sign in or Reconnect, then retry your request")] LoginRequired, #[error("Gateway authentication is in progress; retry after sign-in")] LoginInProgress, } -/// Public-client OAuth settings for a model provider. +/// Public-client OAuth settings that identify one configured authorization grant. #[derive(Clone, PartialEq, Eq)] -pub struct GatewayAuthConfig { +pub struct OAuthSessionConfig { pub authorization_url: String, pub token_url: String, pub client_id: String, @@ -74,11 +74,11 @@ pub struct GatewayAuthConfig { pub redirect_port: Option, } -/// Resolves and persists an OAuth access token for a model provider. +/// Resolves and persists an OAuth access token without knowing how a consumer uses it. #[derive(Clone)] -pub struct GatewayAuthManager { - control: Arc, - state: Arc, +pub struct OAuthSessionManager { + control: Arc, + state: Arc, } enum RefreshPolicy { @@ -103,20 +103,20 @@ enum RefreshOutcome { LoginRequired, } -struct GatewayAuthState { - config: GatewayAuthConfig, +struct OAuthSessionState { + config: OAuthSessionConfig, codex_home: PathBuf, - storage: GatewayAuthStorage, + storage: OAuthSessionStorage, http_client: HttpClient, - cached_token: Arc>, + cached_token: Arc>, // Reserve explicit login independently of background credential reads and refreshes. login_attempt: Arc>, - status: tokio::sync::watch::Sender>, - status_tx: tokio::sync::broadcast::Sender, + status: tokio::sync::watch::Sender>, + status_tx: tokio::sync::broadcast::Sender, } #[derive(Clone, Default, PartialEq, Eq)] -struct GatewayAuthCache { +struct OAuthSessionCache { token: Option, // Retain rotated credentials across a failed save. The prior persisted token remains // available for comparison so retrying cannot overwrite a newer external login. @@ -125,11 +125,11 @@ struct GatewayAuthCache { login_required: Option, } -impl GatewayAuthManager { - /// Creates independent gateway credentials in their encrypted store using the caller's HTTP policy. +impl OAuthSessionManager { + /// Creates a configured OAuth session using the caller's encrypted store and HTTP policy. /// Token grants never follow redirects or include primary-provider credentials or request logs. pub fn new( - config: GatewayAuthConfig, + config: OAuthSessionConfig, codex_home: PathBuf, http_client_factory: &HttpClientFactory, keyring: Arc, @@ -144,14 +144,14 @@ impl GatewayAuthManager { ) .map_err(|_| io::Error::other("failed to create provider OAuth HTTP client"))?; Ok(Self { - control: GatewayLoginControl::for_host(&codex_home, http_client_factory), - state: Arc::new(GatewayAuthState { + control: OAuthLoginControl::for_host(&codex_home, http_client_factory), + state: Arc::new(OAuthSessionState { config, status_tx: login::status_sender_for_host(&codex_home, http_client_factory), - storage: GatewayAuthStorage::new(codex_home.clone(), keyring), + storage: OAuthSessionStorage::new(codex_home.clone(), keyring), codex_home, http_client, - cached_token: Arc::new(Mutex::new(GatewayAuthCache::default())), + cached_token: Arc::new(Mutex::new(OAuthSessionCache::default())), login_attempt: Arc::new(Mutex::new(())), status: tokio::sync::watch::channel(/*init*/ None).0, }), @@ -186,7 +186,7 @@ impl GatewayAuthManager { let terminal_status = self.state.status.borrow().as_ref().is_some_and(|snapshot| { matches!( snapshot.status, - GatewayAuthStatus::NotReady | GatewayAuthStatus::Failed { .. } + OAuthSessionStatus::NotReady | OAuthSessionStatus::Failed { .. } ) }); // A cached token may predate the failed login. Only persisted replacements can clear it. @@ -216,11 +216,11 @@ impl GatewayAuthManager { let terminal_matches = self.state.status.borrow().as_ref().is_some_and(|snapshot| { matches!( snapshot.status, - GatewayAuthStatus::NotReady | GatewayAuthStatus::Failed { .. } + OAuthSessionStatus::NotReady | OAuthSessionStatus::Failed { .. } ) && snapshot.credential.as_ref() == Some(token) }); if !terminal_matches { - self.publish_status(GatewayAuthStatus::Succeeded, Some(token)); + self.publish_status(OAuthSessionStatus::Succeeded, Some(token)); } let access_token = token.access_token.clone(); cached.login_required = None; @@ -234,7 +234,7 @@ impl GatewayAuthManager { Err(error) => Err(error), Ok(RefreshOutcome::AccessToken(access_token)) => { cached.login_required = None; - manager.publish_status(GatewayAuthStatus::Succeeded, cached.token.as_ref()); + manager.publish_status(OAuthSessionStatus::Succeeded, cached.token.as_ref()); Ok(access_token) } Ok(RefreshOutcome::LoginRequired) => { @@ -249,8 +249,8 @@ impl GatewayAuthManager { if result.as_ref().is_err_and(|error| { error .get_ref() - .and_then(|source| source.downcast_ref::()) - == Some(&GatewayAuthError::LoginRequired) + .and_then(|source| source.downcast_ref::()) + == Some(&OAuthSessionError::LoginRequired) }) && !self .control .explicit_login @@ -263,7 +263,7 @@ impl GatewayAuthManager { async fn persist_pending( &self, - cached: &mut GatewayAuthCache, + cached: &mut OAuthSessionCache, credential_lock: &Arc, ) -> io::Result { let token = cached @@ -289,7 +289,7 @@ impl GatewayAuthManager { async fn refresh( &self, - cached: &mut GatewayAuthCache, + cached: &mut OAuthSessionCache, policy: &RefreshPolicy, ) -> io::Result { let credential_lock = Arc::new(storage::lock_credentials(&self.state.codex_home).await?); @@ -418,7 +418,7 @@ impl GatewayAuthManager { async fn authorize_with_browser( &self, - cached: &mut GatewayAuthCache, + cached: &mut OAuthSessionCache, open_browser: impl FnOnce(&Url), ) -> io::Result { let pkce = generate_pkce(); @@ -470,7 +470,7 @@ impl GatewayAuthManager { })?; cached.pending = Some(response.into_stored(/*previous_refresh_token*/ None)?); let access_token = self.persist_pending(cached, &credential_lock).await?; - self.publish_status(GatewayAuthStatus::Succeeded, cached.token.as_ref()); + self.publish_status(OAuthSessionStatus::Succeeded, cached.token.as_ref()); Ok(access_token) } @@ -488,11 +488,11 @@ impl GatewayAuthManager { } } -impl fmt::Debug for GatewayAuthConfig { +impl fmt::Debug for OAuthSessionConfig { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { // Configured URLs may contain issuer-specific credentials in arbitrary query keys. formatter - .debug_struct("GatewayAuthConfig") + .debug_struct("OAuthSessionConfig") .field("client_id", &self.client_id) .field("scopes", &self.scopes) .field("redirect_port", &self.redirect_port) @@ -500,10 +500,10 @@ impl fmt::Debug for GatewayAuthConfig { } } -impl fmt::Debug for GatewayAuthManager { +impl fmt::Debug for OAuthSessionManager { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter - .debug_struct("GatewayAuthManager") + .debug_struct("OAuthSessionManager") .field("config", &self.state.config) .finish_non_exhaustive() } @@ -515,7 +515,7 @@ fn token_is_usable(token: &StoredToken) -> bool { .is_none_or(|expires_at| expires_at > Utc::now().timestamp() + REFRESH_SKEW_SECONDS) } -fn validate_config(config: &GatewayAuthConfig) -> io::Result<()> { +fn validate_config(config: &OAuthSessionConfig) -> io::Result<()> { let authorization = validate_oauth_url( &config.authorization_url, "provider OAuth authorization endpoint", @@ -573,5 +573,5 @@ fn validate_oauth_url(value: &str, description: &str) -> io::Result { } #[cfg(test)] -#[path = "gateway_auth_tests.rs"] +#[path = "oauth_session_tests.rs"] mod tests; diff --git a/codex-rs/login/src/gateway_auth_callback.rs b/codex-rs/login/src/oauth_session_callback.rs similarity index 100% rename from codex-rs/login/src/gateway_auth_callback.rs rename to codex-rs/login/src/oauth_session_callback.rs diff --git a/codex-rs/login/src/gateway_auth_login.rs b/codex-rs/login/src/oauth_session_login.rs similarity index 79% rename from codex-rs/login/src/gateway_auth_login.rs rename to codex-rs/login/src/oauth_session_login.rs index d6840c3f44..443366d2d4 100644 --- a/codex-rs/login/src/gateway_auth_login.rs +++ b/codex-rs/login/src/oauth_session_login.rs @@ -12,7 +12,7 @@ use tokio::sync::broadcast; /// Readiness and browser authorization progress for a gateway credential. #[derive(Clone, Debug, PartialEq, Eq)] -pub enum GatewayAuthStatus { +pub enum OAuthSessionStatus { NotReady, Started, Succeeded, @@ -22,26 +22,26 @@ pub enum GatewayAuthStatus { // Keep terminal status tied to its credential and retain successful credential expiry for busy reads. // Deliberately omit Debug: this snapshot contains credentials. #[derive(Clone)] -pub(super) struct GatewayAuthStatusSnapshot { - pub(super) status: GatewayAuthStatus, +pub(super) struct OAuthSessionStatusSnapshot { + pub(super) status: OAuthSessionStatus, pub(super) credential: Option, } /// A credential status change scoped to the configured OAuth account. #[derive(Clone, Debug)] -pub struct GatewayAuthStatusChange { - pub config: GatewayAuthConfig, - pub status: GatewayAuthStatus, +pub struct OAuthSessionStatusChange { + pub config: OAuthSessionConfig, + pub status: OAuthSessionStatus, } /// Browser policy shared by gateway managers with the same home and network configuration. #[derive(Debug)] -pub struct GatewayLoginControl { +pub struct OAuthLoginControl { pub(super) explicit_login: AtomicBool, - status_tx: broadcast::Sender, + status_tx: broadcast::Sender, } -impl GatewayLoginControl { +impl OAuthLoginControl { /// Returns the control shared across this runtime's provider configurations. pub fn for_runtime(runtime: &crate::AuthRuntimeConfig) -> Arc { Self::for_host( @@ -51,7 +51,7 @@ impl GatewayLoginControl { } pub(super) fn for_host(codex_home: &std::path::Path, factory: &HttpClientFactory) -> Arc { - type Controls = Vec<(PathBuf, HttpClientFactory, Arc)>; + type Controls = Vec<(PathBuf, HttpClientFactory, Arc)>; static CONTROLS: OnceLock> = OnceLock::new(); let mut controls = CONTROLS .get_or_init(std::sync::Mutex::default) @@ -94,10 +94,10 @@ impl GatewayLoginControl { /// Subscribes to gateway status changes for this home and network configuration. /// The subscription also covers managers created after configuration changes. -pub fn subscribe_gateway_auth_status( +pub fn subscribe_oauth_session_status( runtime: &crate::AuthRuntimeConfig, -) -> broadcast::Receiver { - GatewayLoginControl::for_runtime(runtime) +) -> broadcast::Receiver { + OAuthLoginControl::for_runtime(runtime) .status_tx .subscribe() } @@ -105,29 +105,29 @@ pub fn subscribe_gateway_auth_status( pub(super) fn status_sender_for_host( codex_home: &std::path::Path, factory: &HttpClientFactory, -) -> broadcast::Sender { - GatewayLoginControl::for_host(codex_home, factory) +) -> broadcast::Sender { + OAuthLoginControl::for_host(codex_home, factory) .status_tx .clone() } -impl GatewayAuthManager { +impl OAuthSessionManager { /// Returns the OAuth account configuration used to scope status notifications. - pub fn config(&self) -> &GatewayAuthConfig { + pub fn config(&self) -> &OAuthSessionConfig { &self.state.config } /// Returns whether the status changed and a notification was published. pub(super) fn publish_status( &self, - status: GatewayAuthStatus, + status: OAuthSessionStatus, credential: Option<&StoredToken>, ) -> bool { let credential = match status { - GatewayAuthStatus::NotReady - | GatewayAuthStatus::Failed { .. } - | GatewayAuthStatus::Succeeded => credential.cloned(), - GatewayAuthStatus::Started => None, + OAuthSessionStatus::NotReady + | OAuthSessionStatus::Failed { .. } + | OAuthSessionStatus::Succeeded => credential.cloned(), + OAuthSessionStatus::Started => None, }; let mut changed = false; self.state.status.send_if_modified(|previous| { @@ -141,12 +141,12 @@ impl GatewayAuthManager { { return false; } - *previous = Some(GatewayAuthStatusSnapshot { + *previous = Some(OAuthSessionStatusSnapshot { status: status.clone(), credential, }); if changed { - let _ = self.state.status_tx.send(GatewayAuthStatusChange { + let _ = self.state.status_tx.send(OAuthSessionStatusChange { config: self.state.config.clone(), status: status.clone(), }); @@ -159,19 +159,19 @@ impl GatewayAuthManager { pub(super) fn login_required_error(&self, credential: Option<&StoredToken>) -> io::Error { // Requests must re-prompt even after an earlier NotReady notification was dismissed. // Passive readiness reads continue to use the deduplicated publisher above. - if !self.publish_status(GatewayAuthStatus::NotReady, credential) { - let _ = self.state.status_tx.send(GatewayAuthStatusChange { + if !self.publish_status(OAuthSessionStatus::NotReady, credential) { + let _ = self.state.status_tx.send(OAuthSessionStatusChange { config: self.state.config.clone(), - status: GatewayAuthStatus::NotReady, + status: OAuthSessionStatus::NotReady, }); } io::Error::new( io::ErrorKind::PermissionDenied, - GatewayAuthError::LoginRequired, + OAuthSessionError::LoginRequired, ) } - pub(super) async fn lock_cached_token(&self) -> io::Result> { + pub(super) async fn lock_cached_token(&self) -> io::Result> { let mut status = self.state.status.subscribe(); let lock = Arc::clone(&self.state.cached_token).lock_owned(); tokio::pin!(lock); @@ -179,11 +179,11 @@ impl GatewayAuthManager { if status .borrow_and_update() .as_ref() - .is_some_and(|snapshot| snapshot.status == GatewayAuthStatus::Started) + .is_some_and(|snapshot| snapshot.status == OAuthSessionStatus::Started) { return Err(io::Error::new( io::ErrorKind::WouldBlock, - GatewayAuthError::LoginInProgress, + OAuthSessionError::LoginInProgress, )); } tokio::select! { @@ -195,14 +195,14 @@ impl GatewayAuthManager { } /// Reads readiness without refreshing credentials, opening a browser, or waiting for login. - pub async fn status(&self) -> io::Result { + pub async fn status(&self) -> io::Result { let manager = self.clone(); tokio::task::spawn_blocking(move || manager.status_blocking()) .await .map_err(|_| io::Error::other("provider OAuth readiness task failed"))? } - fn status_blocking(&self) -> io::Result { + fn status_blocking(&self) -> io::Result { let mut status_changes = self.state.status.subscribe(); let previous_cache = { let Ok(cached) = self.state.cached_token.try_lock() else { @@ -211,9 +211,9 @@ impl GatewayAuthManager { if status_changes .borrow_and_update() .as_ref() - .is_some_and(|snapshot| snapshot.status == GatewayAuthStatus::Started) + .is_some_and(|snapshot| snapshot.status == OAuthSessionStatus::Started) { - return Ok(GatewayAuthStatus::Started); + return Ok(OAuthSessionStatus::Started); } cached.clone() }; @@ -233,7 +233,7 @@ impl GatewayAuthManager { let terminal = snapshot.as_ref().filter(|snapshot| { matches!( snapshot.status, - GatewayAuthStatus::NotReady | GatewayAuthStatus::Failed { .. } + OAuthSessionStatus::NotReady | OAuthSessionStatus::Failed { .. } ) }); if stored.as_ref().is_some_and(token_is_usable) @@ -245,31 +245,31 @@ impl GatewayAuthManager { cached.token = stored; cached.pending = None; cached.login_required = None; - self.publish_status(GatewayAuthStatus::Succeeded, cached.token.as_ref()); - return Ok(GatewayAuthStatus::Succeeded); + self.publish_status(OAuthSessionStatus::Succeeded, cached.token.as_ref()); + return Ok(OAuthSessionStatus::Succeeded); } if let Some(snapshot) = terminal { return Ok(snapshot.status.clone()); } - self.publish_status(GatewayAuthStatus::NotReady, stored.as_ref()); - Ok(GatewayAuthStatus::NotReady) + self.publish_status(OAuthSessionStatus::NotReady, stored.as_ref()); + Ok(OAuthSessionStatus::NotReady) } - fn snapshot_status(&self) -> GatewayAuthStatus { - let mut status = GatewayAuthStatus::NotReady; + fn snapshot_status(&self) -> OAuthSessionStatus { + let mut status = OAuthSessionStatus::NotReady; // Check and downgrade atomically so an expired observation cannot overwrite // a completed refresh or newly started login. self.state.status.send_if_modified(|snapshot| { let Some(snapshot) = snapshot else { return false; }; - let expired = snapshot.status == GatewayAuthStatus::Succeeded + let expired = snapshot.status == OAuthSessionStatus::Succeeded && !snapshot.credential.as_ref().is_some_and(token_is_usable); if expired { - snapshot.status = GatewayAuthStatus::NotReady; - let _ = self.state.status_tx.send(GatewayAuthStatusChange { + snapshot.status = OAuthSessionStatus::NotReady; + let _ = self.state.status_tx.send(OAuthSessionStatusChange { config: self.state.config.clone(), - status: GatewayAuthStatus::NotReady, + status: OAuthSessionStatus::NotReady, }); } status = snapshot.status.clone(); @@ -289,7 +289,10 @@ impl GatewayAuthManager { let _login_attempt = Arc::clone(&self.state.login_attempt) .try_lock_owned() .map_err(|_| { - io::Error::new(io::ErrorKind::WouldBlock, GatewayAuthError::LoginInProgress) + io::Error::new( + io::ErrorKind::WouldBlock, + OAuthSessionError::LoginInProgress, + ) })?; tokio::pin!(cancel); let mut cached = tokio::select! { @@ -305,7 +308,7 @@ impl GatewayAuthManager { credential: cached.token.clone(), completed: false, }; - self.publish_status(GatewayAuthStatus::Started, /*credential*/ None); + self.publish_status(OAuthSessionStatus::Started, /*credential*/ None); let result = tokio::select! { biased; _ = &mut cancel => Err(io::Error::new(io::ErrorKind::Interrupted, "Gateway sign-in was canceled")), @@ -335,7 +338,7 @@ impl GatewayAuthManager { } struct LoginStatusGuard<'a> { - manager: &'a GatewayAuthManager, + manager: &'a OAuthSessionManager, credential: Option, completed: bool, } @@ -344,8 +347,8 @@ impl LoginStatusGuard<'_> { fn finish(&mut self, result: &io::Result) { self.manager.publish_status( match result { - Ok(_) => GatewayAuthStatus::Succeeded, - Err(error) => GatewayAuthStatus::Failed { + Ok(_) => OAuthSessionStatus::Succeeded, + Err(error) => OAuthSessionStatus::Failed { message: error.to_string(), }, }, @@ -359,7 +362,7 @@ impl Drop for LoginStatusGuard<'_> { fn drop(&mut self) { if !self.completed { self.manager.publish_status( - GatewayAuthStatus::Failed { + OAuthSessionStatus::Failed { message: "Gateway sign-in was canceled".to_string(), }, self.credential.as_ref(), diff --git a/codex-rs/login/src/gateway_auth_login_tests.rs b/codex-rs/login/src/oauth_session_login_tests.rs similarity index 88% rename from codex-rs/login/src/gateway_auth_login_tests.rs rename to codex-rs/login/src/oauth_session_login_tests.rs index bb1e5a1319..95eb6cb565 100644 --- a/codex-rs/login/src/gateway_auth_login_tests.rs +++ b/codex-rs/login/src/oauth_session_login_tests.rs @@ -1,8 +1,8 @@ //! Explicit browser login shares credentials with request consumers and remains cancellable. use super::*; -use crate::GatewayAuthError; -use crate::GatewayAuthStatus; +use crate::OAuthSessionError; +use crate::OAuthSessionStatus; use pretty_assertions::assert_eq; use tokio::sync::Notify; @@ -15,8 +15,8 @@ async fn status_subscriptions_survive_gateway_changes_and_are_isolated_by_home() auth_route_config: transport_default_auth_route_config(), }; // Subscribe before any manager exists, as app-server does without gateway configuration. - let mut events = crate::subscribe_gateway_auth_status(&runtime); - crate::GatewayLoginControl::for_runtime(&runtime).require_explicit_login(); + let mut events = crate::subscribe_oauth_session_status(&runtime); + crate::OAuthLoginControl::for_runtime(&runtime).require_explicit_login(); let keyring = Arc::new(MockKeyringStore::default()); let (other_manager, _other_home) = client(config(&server), keyring.clone()); other_manager.resolve_access_token().await.unwrap_err(); @@ -28,7 +28,7 @@ async fn status_subscriptions_survive_gateway_changes_and_are_isolated_by_home() for client_id in ["first-gateway", "replacement-gateway"] { let mut gateway = config(&server); gateway.client_id = client_id.to_string(); - let manager = GatewayAuthManager::new( + let manager = OAuthSessionManager::new( gateway.clone(), home.path().to_path_buf(), runtime.auth_route_config.http_client_factory(), @@ -42,7 +42,7 @@ async fn status_subscriptions_survive_gateway_changes_and_are_isolated_by_home() .unwrap(); assert_eq!( (event.config, event.status), - (gateway, GatewayAuthStatus::NotReady) + (gateway, OAuthSessionStatus::NotReady) ); // The next gateway must reach the same subscriber after the last manager is dropped. drop(manager); @@ -69,7 +69,10 @@ async fn cancel_releases_callback_while_reads_and_requests_stay_responsive() { }); let check = async { let callback = url_rx.await.unwrap(); - assert_eq!(observer.status().await.unwrap(), GatewayAuthStatus::Started); + assert_eq!( + observer.status().await.unwrap(), + OAuthSessionStatus::Started + ); assert_eq!( observer.resolve_access_token().await.unwrap_err().kind(), std::io::ErrorKind::WouldBlock @@ -79,13 +82,13 @@ async fn cancel_releases_callback_while_reads_and_requests_stay_responsive() { }; let (result, callback) = tokio::join!(login, check); assert_eq!(result.unwrap_err().kind(), std::io::ErrorKind::Interrupted); - let failed = GatewayAuthStatus::Failed { + let failed = OAuthSessionStatus::Failed { message: "Gateway sign-in was canceled".to_string(), }; assert_eq!(observer.status().await.unwrap(), failed); assert_eq!( events.recv().await.unwrap().status, - GatewayAuthStatus::Started + OAuthSessionStatus::Started ); assert_eq!(events.recv().await.unwrap().status, failed); tokio::time::timeout(Duration::from_secs(/*secs*/ 2), async { @@ -115,9 +118,9 @@ async fn credential_read_failure_finishes_login_status() { assert_eq!(error.to_string(), message); assert_eq!( events.try_recv().unwrap().status, - GatewayAuthStatus::Started + OAuthSessionStatus::Started ); - let failed = GatewayAuthStatus::Failed { + let failed = OAuthSessionStatus::Failed { message: message.into(), }; assert_eq!(events.try_recv().unwrap().status, failed); @@ -160,10 +163,13 @@ async fn rejected_credentials_stay_not_ready_until_replaced() { .kind(), std::io::ErrorKind::PermissionDenied ); - assert_eq!(manager.status().await.unwrap(), GatewayAuthStatus::NotReady); + assert_eq!( + manager.status().await.unwrap(), + OAuthSessionStatus::NotReady + ); assert_eq!( events.try_recv().unwrap().status, - GatewayAuthStatus::NotReady + OAuthSessionStatus::NotReady ); for _ in 0..2 { assert_eq!( @@ -172,9 +178,12 @@ async fn rejected_credentials_stay_not_ready_until_replaced() { ); assert_eq!( events.try_recv().unwrap().status, - GatewayAuthStatus::NotReady + OAuthSessionStatus::NotReady + ); + assert_eq!( + manager.status().await.unwrap(), + OAuthSessionStatus::NotReady ); - assert_eq!(manager.status().await.unwrap(), GatewayAuthStatus::NotReady); assert!(matches!( events.try_recv(), Err(tokio::sync::broadcast::error::TryRecvError::Empty) @@ -197,7 +206,7 @@ async fn rejected_credentials_stay_not_ready_until_replaced() { ); assert_eq!( manager.status().await.unwrap(), - GatewayAuthStatus::Succeeded + OAuthSessionStatus::Succeeded ); assert_eq!(manager.resolve_access_token().await.unwrap(), "replacement"); @@ -236,7 +245,7 @@ async fn rejected_credentials_stay_not_ready_until_replaced() { let cache_lock = Arc::clone(&manager.state.cached_token).lock_owned().await; assert_eq!( manager.status().await.unwrap(), - GatewayAuthStatus::Succeeded + OAuthSessionStatus::Succeeded ); drop(cache_lock); assert_eq!(manager.resolve_access_token().await.unwrap(), "signed-in"); @@ -256,12 +265,15 @@ async fn dropping_login_clears_in_progress_status() { .await }); started_rx.await.unwrap(); - assert_eq!(observer.status().await.unwrap(), GatewayAuthStatus::Started); + assert_eq!( + observer.status().await.unwrap(), + OAuthSessionStatus::Started + ); login.abort(); assert!(login.await.unwrap_err().is_cancelled()); assert_eq!( observer.status().await.unwrap(), - GatewayAuthStatus::Failed { + OAuthSessionStatus::Failed { message: "Gateway sign-in was canceled".to_string() } ); @@ -279,7 +291,7 @@ async fn expired_readiness_stays_not_ready_while_refresh_is_blocked() { }; manager.save_token(&expired).unwrap(); // Seed the last successful observation with a credential that has since expired. - manager.publish_status(GatewayAuthStatus::Succeeded, Some(&expired)); + manager.publish_status(OAuthSessionStatus::Succeeded, Some(&expired)); let mut events = manager.state.status_tx.subscribe(); Mock::given(method("POST")) .and(path("/token")) @@ -291,7 +303,10 @@ async fn expired_readiness_stays_not_ready_while_refresh_is_blocked() { .mount(&server) .await; if read_before_refresh { - assert_eq!(manager.status().await.unwrap(), GatewayAuthStatus::NotReady); + assert_eq!( + manager.status().await.unwrap(), + OAuthSessionStatus::NotReady + ); } // Block the real refresh on the cross-process store lock after it takes the cache lock. let store_lock = super::super::storage::lock_credentials(home.path()) @@ -307,11 +322,17 @@ async fn expired_readiness_stays_not_ready_while_refresh_is_blocked() { } }) => ready.expect("refresh holds cache lock"), } - assert_eq!(manager.status().await.unwrap(), GatewayAuthStatus::NotReady); - assert_eq!(manager.status().await.unwrap(), GatewayAuthStatus::NotReady); + assert_eq!( + manager.status().await.unwrap(), + OAuthSessionStatus::NotReady + ); + assert_eq!( + manager.status().await.unwrap(), + OAuthSessionStatus::NotReady + ); assert_eq!( events.try_recv().unwrap().status, - GatewayAuthStatus::NotReady + OAuthSessionStatus::NotReady ); assert!(matches!( events.try_recv(), @@ -324,16 +345,16 @@ async fn expired_readiness_stays_not_ready_while_refresh_is_blocked() { let cache_lock = Arc::clone(&manager.state.cached_token).lock_owned().await; assert_eq!( manager.status().await.unwrap(), - GatewayAuthStatus::Succeeded + OAuthSessionStatus::Succeeded ); drop(cache_lock); assert_eq!( events.try_recv().unwrap().status, - GatewayAuthStatus::Succeeded + OAuthSessionStatus::Succeeded ); assert_eq!( manager.status().await.unwrap(), - GatewayAuthStatus::Succeeded + OAuthSessionStatus::Succeeded ); } } @@ -365,12 +386,12 @@ async fn external_credentials_recover_missing_and_failed_readiness() { .login_with_browser(cancel.notified(), |_| cancel.notify_one()) .await .unwrap_err(); - GatewayAuthStatus::Failed { + OAuthSessionStatus::Failed { message: "Gateway sign-in was canceled".into(), } } else { manager.resolve_access_token().await.unwrap_err(); - GatewayAuthStatus::NotReady + OAuthSessionStatus::NotReady }; assert_eq!(manager.status().await.unwrap(), expected); if failed_login { @@ -397,7 +418,7 @@ async fn external_credentials_recover_missing_and_failed_readiness() { } assert_eq!( manager.status().await.unwrap(), - GatewayAuthStatus::Succeeded + OAuthSessionStatus::Succeeded ); assert_eq!(manager.resolve_access_token().await.unwrap(), "replacement"); } @@ -433,9 +454,9 @@ async fn login_waits_for_background_credentials_and_can_cancel_while_queued() { assert_eq!( duplicate .get_ref() - .and_then(|error| error.downcast_ref::()) + .and_then(|error| error.downcast_ref::()) .copied(), - Some(GatewayAuthError::LoginInProgress), + Some(OAuthSessionError::LoginInProgress), ); if cancel_while_queued { cancel.notify_one(); @@ -444,13 +465,16 @@ async fn login_waits_for_background_credentials_and_can_cancel_while_queued() { .unwrap() .unwrap_err(); assert_eq!(error.kind(), std::io::ErrorKind::Interrupted); - assert_eq!(manager.status().await.unwrap(), GatewayAuthStatus::NotReady); + assert_eq!( + manager.status().await.unwrap(), + OAuthSessionStatus::NotReady + ); drop(cache_lock); } else { drop(cache_lock); let check = async { url_rx.await.unwrap(); - assert_eq!(manager.status().await.unwrap(), GatewayAuthStatus::Started); + assert_eq!(manager.status().await.unwrap(), OAuthSessionStatus::Started); cancel.notify_one(); }; let (result, ()) = tokio::join!(login, check); @@ -514,7 +538,7 @@ async fn legacy_policy_authorizes_missing_and_rejected_credentials() { ); assert_eq!( manager.status().await.unwrap(), - GatewayAuthStatus::Succeeded + OAuthSessionStatus::Succeeded ); } } diff --git a/codex-rs/login/src/gateway_auth_storage.rs b/codex-rs/login/src/oauth_session_storage.rs similarity index 91% rename from codex-rs/login/src/gateway_auth_storage.rs rename to codex-rs/login/src/oauth_session_storage.rs index 9ef7ececd2..a82b53fff5 100644 --- a/codex-rs/login/src/gateway_auth_storage.rs +++ b/codex-rs/login/src/oauth_session_storage.rs @@ -1,4 +1,5 @@ -//! Persists gateway credentials and serializes exchanges across all configurations in one store. +//! Persists configured OAuth grants and serializes exchanges in one encrypted store. +//! Gateway storage paths and namespace are retained so existing credentials remain usable. use std::fs::File; use std::fs::OpenOptions; @@ -49,9 +50,9 @@ pub(super) async fn lock_credentials(codex_home: &Path) -> io::Result { Ok(file) } -pub(super) struct GatewayAuthStorage(SecretsManager); +pub(super) struct OAuthSessionStorage(SecretsManager); -impl GatewayAuthStorage { +impl OAuthSessionStorage { pub(super) fn new(codex_home: PathBuf, keyring: Arc) -> Self { Self(SecretsManager::new_with_keyring_store_and_namespace( codex_home, diff --git a/codex-rs/login/src/gateway_auth_storage_tests.rs b/codex-rs/login/src/oauth_session_storage_tests.rs similarity index 93% rename from codex-rs/login/src/gateway_auth_storage_tests.rs rename to codex-rs/login/src/oauth_session_storage_tests.rs index 0548940fb4..88ec677d8c 100644 --- a/codex-rs/login/src/gateway_auth_storage_tests.rs +++ b/codex-rs/login/src/oauth_session_storage_tests.rs @@ -53,7 +53,7 @@ impl KeyringStore for BlockingKeyring { } } -fn blocking_client() -> (GatewayAuthManager, tempfile::TempDir, Arc) { +fn blocking_client() -> (OAuthSessionManager, tempfile::TempDir, Arc) { let home = tempfile::tempdir().unwrap(); let keyring = Arc::new(BlockingKeyring { inner: MockKeyringStore::default(), @@ -61,7 +61,7 @@ fn blocking_client() -> (GatewayAuthManager, tempfile::TempDir, Arc()), - Some(&crate::GatewayAuthError::LoginInProgress), + .and_then(|error| error.downcast_ref::()), + Some(&crate::OAuthSessionError::LoginInProgress), ); assert_eq!( manager.status().await.unwrap(), - crate::GatewayAuthStatus::Started + crate::OAuthSessionStatus::Started ); assert_eq!( events.try_recv().unwrap().status, - crate::GatewayAuthStatus::Started + crate::OAuthSessionStatus::Started ); cancel.notify_one(); let error = tokio::time::timeout(Duration::from_secs(/*secs*/ 2), login) @@ -179,7 +179,7 @@ async fn cancel_during_keyring_read_releases_login_and_cached_credentials() { assert_eq!(error.kind(), std::io::ErrorKind::Interrupted); assert_eq!( events.try_recv().unwrap().status, - crate::GatewayAuthStatus::Failed { + crate::OAuthSessionStatus::Failed { message: "Gateway sign-in was canceled".into(), } ); diff --git a/codex-rs/login/src/gateway_auth_tests.rs b/codex-rs/login/src/oauth_session_tests.rs similarity index 97% rename from codex-rs/login/src/gateway_auth_tests.rs rename to codex-rs/login/src/oauth_session_tests.rs index 53a68e8383..7007c0f606 100644 --- a/codex-rs/login/src/gateway_auth_tests.rs +++ b/codex-rs/login/src/oauth_session_tests.rs @@ -17,24 +17,24 @@ use wiremock::matchers::body_string_contains; use wiremock::matchers::method; use wiremock::matchers::path; -use super::GatewayAuthConfig; -use super::GatewayAuthManager; +use super::OAuthSessionConfig; +use super::OAuthSessionManager; use super::StoredToken; use super::callback::CallbackListener; use crate::test_support::transport_default_auth_route_config; -#[path = "gateway_auth_login_tests.rs"] +#[path = "oauth_session_login_tests.rs"] mod login_tests; -#[path = "gateway_auth_storage_tests.rs"] +#[path = "oauth_session_storage_tests.rs"] mod storage_tests; fn client( - config: GatewayAuthConfig, + config: OAuthSessionConfig, keyring: Arc, -) -> (GatewayAuthManager, tempfile::TempDir) { +) -> (OAuthSessionManager, tempfile::TempDir) { let home = tempfile::tempdir().expect("Codex home"); - let manager = GatewayAuthManager::new( + let manager = OAuthSessionManager::new( config, home.path().to_path_buf(), transport_default_auth_route_config().http_client_factory(), @@ -45,8 +45,8 @@ fn client( (manager, home) } -fn config(server: &MockServer) -> GatewayAuthConfig { - GatewayAuthConfig { +fn config(server: &MockServer) -> OAuthSessionConfig { + OAuthSessionConfig { authorization_url: format!("{}/authorize", server.uri()), token_url: format!("{}/token", server.uri()), client_id: "codex-test".to_string(), @@ -56,8 +56,8 @@ fn config(server: &MockServer) -> GatewayAuthConfig { } } -fn loopback_config() -> GatewayAuthConfig { - GatewayAuthConfig { +fn loopback_config() -> OAuthSessionConfig { + OAuthSessionConfig { authorization_url: "http://127.0.0.1:18080/authorize".to_string(), token_url: "http://127.0.0.1:18080/token".to_string(), client_id: "codex-test".to_string(), @@ -68,7 +68,7 @@ fn loopback_config() -> GatewayAuthConfig { } fn save_token( - client: &GatewayAuthManager, + client: &OAuthSessionManager, access_token: &str, refresh_token: Option<&str>, expires_at: i64, @@ -158,7 +158,7 @@ async fn expired_tokens_refresh_once_when_the_response_omits_rotation_and_expiry Utc::now().timestamp() - 1, ); - let other = GatewayAuthManager::new( + let other = OAuthSessionManager::new( client.state.config.clone(), client.state.codex_home.clone(), transport_default_auth_route_config().http_client_factory(), @@ -193,7 +193,7 @@ async fn different_configurations_refresh_under_the_same_store_lock() { let (first, home) = client(config(&server), keyring.clone()); let mut other_config = first.state.config.clone(); other_config.client_id = "other-client".to_string(); - let second = GatewayAuthManager::new( + let second = OAuthSessionManager::new( other_config, home.path().to_path_buf(), transport_default_auth_route_config().http_client_factory(), @@ -462,13 +462,13 @@ async fn query_credentials_are_not_exposed_by_echoed_errors_or_truncated_request let message = "Gateway sign-in failed; check the gateway configuration and credential store."; assert_eq!(error.to_string(), message); - let failed = crate::GatewayAuthStatus::Failed { + let failed = crate::OAuthSessionStatus::Failed { message: message.into(), }; assert_eq!(manager.status().await.unwrap(), failed); assert_eq!( events.try_recv().unwrap().status, - crate::GatewayAuthStatus::Started + crate::OAuthSessionStatus::Started ); let event = events.try_recv().unwrap(); assert_eq!(event.status, failed); @@ -483,7 +483,7 @@ async fn gateway_credentials_use_a_dedicated_encrypted_file_and_reload_large_tok let keyring = Arc::new(MockKeyringStore::default()); let access_token = "gateway-token-".repeat(/*n*/ 512); let config = loopback_config(); - let client = GatewayAuthManager::new( + let client = OAuthSessionManager::new( config.clone(), codex_home.path().to_path_buf(), transport_default_auth_route_config().http_client_factory(), @@ -506,7 +506,7 @@ async fn gateway_credentials_use_a_dedicated_encrypted_file_and_reload_large_tok ); assert!(!codex_home.path().join("secrets/codex_auth.age").exists()); - let reloaded = GatewayAuthManager::new( + let reloaded = OAuthSessionManager::new( config, codex_home.path().to_path_buf(), transport_default_auth_route_config().http_client_factory(), @@ -796,7 +796,7 @@ async fn cancelled_refresh_still_records_rotated_or_rejected_credentials() { drop(cached); assert_eq!( manager.status().await.unwrap(), - crate::GatewayAuthStatus::NotReady + crate::OAuthSessionStatus::NotReady ); assert_eq!( manager.resolve_access_token().await.unwrap_err().kind(), @@ -815,7 +815,7 @@ async fn cancelled_refresh_still_records_rotated_or_rejected_credentials() { ); assert_eq!( manager.status().await.unwrap(), - crate::GatewayAuthStatus::Succeeded + crate::OAuthSessionStatus::Succeeded ); } } diff --git a/codex-rs/login/src/gateway_auth_token.rs b/codex-rs/login/src/oauth_session_token.rs similarity index 98% rename from codex-rs/login/src/gateway_auth_token.rs rename to codex-rs/login/src/oauth_session_token.rs index e175fc6358..ecf0086bb2 100644 --- a/codex-rs/login/src/gateway_auth_token.rs +++ b/codex-rs/login/src/oauth_session_token.rs @@ -9,7 +9,7 @@ use codex_secrets::redact_secrets; use serde::Deserialize; use serde::Serialize; -use super::GatewayAuthConfig; +use super::OAuthSessionConfig; #[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] pub(super) struct StoredToken { @@ -78,7 +78,7 @@ impl TokenResponse { pub(super) fn endpoint_error( error: OAuthError, - config: &GatewayAuthConfig, + config: &OAuthSessionConfig, grant_type: &str, redirect_uri: Option<&str>, ) -> io::Error {