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
This commit is contained in:
smurching-oai
2026-10-14 19:07:44 +00:00
committed by copyberry
parent 0fd85b0752
commit ae11049397
9 changed files with 212 additions and 175 deletions
+17 -8
View File
@@ -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;
@@ -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<u16>,
}
/// 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<GatewayLoginControl>,
state: Arc<GatewayAuthState>,
pub struct OAuthSessionManager {
control: Arc<OAuthLoginControl>,
state: Arc<OAuthSessionState>,
}
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<Mutex<GatewayAuthCache>>,
cached_token: Arc<Mutex<OAuthSessionCache>>,
// Reserve explicit login independently of background credential reads and refreshes.
login_attempt: Arc<Mutex<()>>,
status: tokio::sync::watch::Sender<Option<login::GatewayAuthStatusSnapshot>>,
status_tx: tokio::sync::broadcast::Sender<GatewayAuthStatusChange>,
status: tokio::sync::watch::Sender<Option<login::OAuthSessionStatusSnapshot>>,
status_tx: tokio::sync::broadcast::Sender<OAuthSessionStatusChange>,
}
#[derive(Clone, Default, PartialEq, Eq)]
struct GatewayAuthCache {
struct OAuthSessionCache {
token: Option<StoredToken>,
// 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<StoredToken>,
}
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<dyn KeyringStore>,
@@ -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::<GatewayAuthError>())
== Some(&GatewayAuthError::LoginRequired)
.and_then(|source| source.downcast_ref::<OAuthSessionError>())
== 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<std::fs::File>,
) -> io::Result<String> {
let token = cached
@@ -289,7 +289,7 @@ impl GatewayAuthManager {
async fn refresh(
&self,
cached: &mut GatewayAuthCache,
cached: &mut OAuthSessionCache,
policy: &RefreshPolicy,
) -> io::Result<RefreshOutcome> {
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<String> {
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<Url> {
}
#[cfg(test)]
#[path = "gateway_auth_tests.rs"]
#[path = "oauth_session_tests.rs"]
mod tests;
@@ -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<StoredToken>,
}
/// 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<GatewayAuthStatusChange>,
status_tx: broadcast::Sender<OAuthSessionStatusChange>,
}
impl GatewayLoginControl {
impl OAuthLoginControl {
/// Returns the control shared across this runtime's provider configurations.
pub fn for_runtime(runtime: &crate::AuthRuntimeConfig) -> Arc<Self> {
Self::for_host(
@@ -51,7 +51,7 @@ impl GatewayLoginControl {
}
pub(super) fn for_host(codex_home: &std::path::Path, factory: &HttpClientFactory) -> Arc<Self> {
type Controls = Vec<(PathBuf, HttpClientFactory, Arc<GatewayLoginControl>)>;
type Controls = Vec<(PathBuf, HttpClientFactory, Arc<OAuthLoginControl>)>;
static CONTROLS: OnceLock<std::sync::Mutex<Controls>> = 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<GatewayAuthStatusChange> {
GatewayLoginControl::for_runtime(runtime)
) -> broadcast::Receiver<OAuthSessionStatusChange> {
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<GatewayAuthStatusChange> {
GatewayLoginControl::for_host(codex_home, factory)
) -> broadcast::Sender<OAuthSessionStatusChange> {
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<OwnedMutexGuard<GatewayAuthCache>> {
pub(super) async fn lock_cached_token(&self) -> io::Result<OwnedMutexGuard<OAuthSessionCache>> {
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<GatewayAuthStatus> {
pub async fn status(&self) -> io::Result<OAuthSessionStatus> {
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<GatewayAuthStatus> {
fn status_blocking(&self) -> io::Result<OAuthSessionStatus> {
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<StoredToken>,
completed: bool,
}
@@ -344,8 +347,8 @@ impl LoginStatusGuard<'_> {
fn finish<T>(&mut self, result: &io::Result<T>) {
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(),
@@ -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::<GatewayAuthError>())
.and_then(|error| error.downcast_ref::<OAuthSessionError>())
.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
);
}
}
@@ -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<File> {
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<dyn KeyringStore>) -> Self {
Self(SecretsManager::new_with_keyring_store_and_namespace(
codex_home,
@@ -53,7 +53,7 @@ impl KeyringStore for BlockingKeyring {
}
}
fn blocking_client() -> (GatewayAuthManager, tempfile::TempDir, Arc<BlockingKeyring>) {
fn blocking_client() -> (OAuthSessionManager, tempfile::TempDir, Arc<BlockingKeyring>) {
let home = tempfile::tempdir().unwrap();
let keyring = Arc::new(BlockingKeyring {
inner: MockKeyringStore::default(),
@@ -61,7 +61,7 @@ fn blocking_client() -> (GatewayAuthManager, tempfile::TempDir, Arc<BlockingKeyr
check_thread: AtomicBool::new(/*v*/ false),
gate: Mutex::new(/*t*/ None),
});
let manager = GatewayAuthManager::new(
let manager = OAuthSessionManager::new(
loopback_config(),
home.path().to_path_buf(),
transport_default_auth_route_config().http_client_factory(),
@@ -122,7 +122,7 @@ async fn readiness_does_not_block_cached_requests_or_overwrite_newer_credentials
release_tx.send(()).unwrap();
assert_eq!(
readiness.await.unwrap().unwrap(),
crate::GatewayAuthStatus::Succeeded
crate::OAuthSessionStatus::Succeeded
);
assert_eq!(manager.resolve_access_token().await.unwrap(), "replacement");
}
@@ -159,16 +159,16 @@ async fn cancel_during_keyring_read_releases_login_and_cached_credentials() {
assert_eq!(
error
.get_ref()
.and_then(|error| error.downcast_ref::<crate::GatewayAuthError>()),
Some(&crate::GatewayAuthError::LoginInProgress),
.and_then(|error| error.downcast_ref::<crate::OAuthSessionError>()),
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(),
}
);
@@ -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<MockKeyringStore>,
) -> (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
);
}
}
@@ -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 {