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 {