From b2aa07db8ac892a865de888dd08726680fcf873c Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 20 Oct 2026 11:36:58 +0000 Subject: [PATCH] Add scoped leases for persisted account OAuth grants (#55546) ## What changed - Expose `AccountOAuthGrantLease` to prevent this auth manager's token refreshes from rotating account credentials during an identity-provider exchange. Releasing the lease returns an `AccountOAuthGrant` that callers can recheck before accepting asynchronous results. - Validate grants against cached login ownership and persisted credentials, requiring OAuth provenance and complete account identity. Reject stale grants and propagate storage failures, including unavailable keyrings in `Auto` mode. - Track `login_generation` separately from account ownership and use it to invalidate enterprise MCP authentication. Losing login metadata invalidates the login while preserving same-account network policy; a new login to the same account advances both generations. ## Testing Extend auth refresh coverage to verify refresh blocking and wakeup on lease release, invalidation after token rotation or persisted login replacement, metadata loss and restoration, and logout while a lease is held. GitOrigin-RevId: f51b3cd526d7ee94d5dffa16681f8ca332245ce3 --- .../src/account_notifications_tests.rs | 1 + codex-rs/codex-mcp/src/ema_auth.rs | 8 +- codex-rs/login/src/auth/account_grant.rs | 87 ++++++++++ codex-rs/login/src/auth/change_state.rs | 16 +- codex-rs/login/src/auth/change_state_tests.rs | 3 + codex-rs/login/src/auth/manager.rs | 111 ++++++++++++- codex-rs/login/src/lib.rs | 2 + codex-rs/login/tests/suite/auth_refresh.rs | 155 +++++++++++++++++- 8 files changed, 368 insertions(+), 15 deletions(-) create mode 100644 codex-rs/login/src/auth/account_grant.rs diff --git a/codex-rs/app-server/src/account_notifications_tests.rs b/codex-rs/app-server/src/account_notifications_tests.rs index 744acf03ab..df55eaa451 100644 --- a/codex-rs/app-server/src/account_notifications_tests.rs +++ b/codex-rs/app-server/src/account_notifications_tests.rs @@ -51,6 +51,7 @@ async fn queued_notifications_follow_auth_owner_changes(owner_generation: u64) { generation: 2, owner_generation, source_generation: owner_generation, + login_generation: owner_generation, }); rx.recv().await.unwrap(); send.await; diff --git a/codex-rs/codex-mcp/src/ema_auth.rs b/codex-rs/codex-mcp/src/ema_auth.rs index 1c5e32b082..5caa12b50f 100644 --- a/codex-rs/codex-mcp/src/ema_auth.rs +++ b/codex-rs/codex-mcp/src/ema_auth.rs @@ -73,10 +73,10 @@ impl EmaAuth { let scope = ema_auth_scope(auth).ok_or(EmaAuthFailure::ReauthenticationRequired)?; let manager = auth_manager.context("enterprise MCP requires an account owner")?; let mut changes = manager.auth_change_state_receiver(); - let owner_generation = changes.borrow().owner_generation; + let login_generation = changes.borrow().login_generation; // Never hold the watch guard while reading cached auth: writers take the auth lock first. if ema_auth_scope(manager.auth_cached().as_ref()).as_ref() != Some(&scope) - || changes.borrow().owner_generation != owner_generation + || changes.borrow().login_generation != login_generation { bail!(EmaAuthFailure::ReauthenticationRequired); } @@ -86,7 +86,7 @@ impl EmaAuth { let changes = changes.clone(); let policy = policy.clone(); move || { - changes.borrow().owner_generation == owner_generation + changes.borrow().login_generation == login_generation && policy.as_ref().is_none_or(|binding| { binding.read().status == CloudConfigBundleBindingStatus::Current }) @@ -105,7 +105,7 @@ impl EmaAuth { } => {} () = async { while changes.changed().await.is_ok() { - if changes.borrow().owner_generation != owner_generation { + if changes.borrow().login_generation != login_generation { break; } } diff --git a/codex-rs/login/src/auth/account_grant.rs b/codex-rs/login/src/auth/account_grant.rs new file mode 100644 index 0000000000..56f240b8d6 --- /dev/null +++ b/codex-rs/login/src/auth/account_grant.rs @@ -0,0 +1,87 @@ +//! Scoped access to a persisted login grant. Refresh exclusion lasts only through the IdP +//! exchange; callers must recheck ownership before publishing its result. Storage reads +//! observe other processes at these boundaries, not continuously. + +use std::fmt; +use std::io; +use std::sync::Arc; +use tokio::sync::OwnedSemaphorePermit; + +use super::AuthManager; +use crate::token_data::TokenData; + +/// A snapshot of one account grant, checked against both cached ownership and storage. +/// This does not authorize a destination; consumers must validate the recorded issuer/client. +pub struct AccountOAuthGrant { + pub(super) manager: Arc, + pub(super) tokens: TokenData, + pub(super) login_generation: u64, +} + +/// Keeps ordinary refresh from rotating the account token during an IdP exchange. +/// Login and logout remain available and invalidate the grant independently of this lease. +pub struct AccountOAuthGrantLease { + pub(super) grant: AccountOAuthGrant, + pub(super) permit: OwnedSemaphorePermit, +} + +impl fmt::Debug for AccountOAuthGrant { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AccountOAuthGrant").finish_non_exhaustive() + } +} + +impl fmt::Debug for AccountOAuthGrantLease { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AccountOAuthGrantLease") + .finish_non_exhaustive() + } +} + +impl AccountOAuthGrantLease { + pub fn tokens(&self) -> &TokenData { + &self.grant.tokens + } + + /// Release refresh exclusion before contacting a resource authorization server. + pub fn release(self) -> AccountOAuthGrant { + drop(self.permit); + self.grant + } +} + +impl AccountOAuthGrant { + pub(super) fn owner_is_current(&self) -> bool { + let Ok(cached) = self.manager.inner.read() else { + return false; + }; + self.manager.auth_change_state_tx.borrow().login_generation == self.login_generation + && cached + .auth + .as_ref() + .filter(|auth| matches!(auth, super::CodexAuth::Chatgpt(_))) + .and_then(|auth| auth.get_token_data().ok()) + .is_some_and(|tokens| same_account_grant(&self.tokens, &tokens)) + } + + /// Check after asynchronous work, before accepting a token or publishing a connection. + /// A storage failure is an error, never proof that the saved grant is still valid. + pub async fn check_current(&self) -> io::Result { + if !self.owner_is_current() { + return Ok(false); + } + let stored = self.manager.read_account_grant_tokens().await?; + Ok(self.owner_is_current() + && stored.as_ref().is_some_and(|tokens| { + same_account_grant(&self.tokens, tokens) + && self.tokens.refresh_token == tokens.refresh_token + })) + } +} + +pub(super) fn same_account_grant(a: &TokenData, b: &TokenData) -> bool { + a.oauth_provenance == b.oauth_provenance + && a.login_owner_id == b.login_owner_id + && a.account_id == b.account_id + && a.id_token.chatgpt_user_id == b.id_token.chatgpt_user_id +} diff --git a/codex-rs/login/src/auth/change_state.rs b/codex-rs/login/src/auth/change_state.rs index 168572bcf8..d461a418da 100644 --- a/codex-rs/login/src/auth/change_state.rs +++ b/codex-rs/login/src/auth/change_state.rs @@ -10,10 +10,13 @@ pub struct AuthChangeState { /// Advances on login, logout, or changes of user, workspace, or auth mode. /// A verified legacy-to-OAuth credential migration preserves the same owner. /// Credential changes with incomplete owner identity also advance this revision. + /// Losing login metadata alone preserves the account owner. pub owner_generation: u64, /// Advances on an underlying actor credential/source change, including a new OAuth login. /// Ordinary refresh within an identifiable OAuth session preserves this revision. pub source_generation: u64, + /// Preserves strict login ownership for EMA, including loss of login metadata. + pub login_generation: u64, } pub(super) fn same_owner(previous: Option<&CodexAuth>, current: Option<&CodexAuth>) -> bool { @@ -27,8 +30,19 @@ pub(super) fn same_owner(previous: Option<&CodexAuth>, current: Option<&CodexAut else { return false; }; - !user.trim().is_empty() + let current_login = login_owner_id(Some(current)); + (current_login.is_none() || login_owner_id(Some(previous)) == current_login) + && !user.trim().is_empty() && !workspace.trim().is_empty() && current.get_chatgpt_user_id().as_ref() == Some(&user) && current.get_account_id().as_ref() == Some(&workspace) } + +pub(super) fn same_login(previous: Option<&CodexAuth>, current: Option<&CodexAuth>) -> bool { + same_owner(previous, current) && login_owner_id(previous) == login_owner_id(current) +} + +fn login_owner_id(auth: Option<&CodexAuth>) -> Option { + auth.and_then(|auth| auth.get_token_data().ok()) + .and_then(|tokens| tokens.login_owner_id) +} diff --git a/codex-rs/login/src/auth/change_state_tests.rs b/codex-rs/login/src/auth/change_state_tests.rs index ffc355a729..7d1098faec 100644 --- a/codex-rs/login/src/auth/change_state_tests.rs +++ b/codex-rs/login/src/auth/change_state_tests.rs @@ -59,6 +59,7 @@ fn auth_owner_generation_distinguishes_refreshes_from_identity_changes() { generation, owner_generation, source_generation: generation, + login_generation: owner_generation, }, ); assert_eq!(*legacy_changes.borrow_and_update(), generation); @@ -81,6 +82,7 @@ async fn auth_owner_generation_preserves_logout_and_switches_when_coalesced() { generation: 2, owner_generation: 2, source_generation: 2, + login_generation: 2, }, ); @@ -92,6 +94,7 @@ async fn auth_owner_generation_preserves_logout_and_switches_when_coalesced() { generation: 4, owner_generation: 4, source_generation: 4, + login_generation: 4, }, ); } diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 2d676069b1..c91436e771 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -1,5 +1,11 @@ mod workspace_routing; +#[path = "account_grant.rs"] +mod account_grant; +pub use account_grant::AccountOAuthGrant; +pub use account_grant::AccountOAuthGrantLease; +use account_grant::same_account_grant; + use chrono::Utc; use http::StatusCode; use serde::Deserialize; @@ -40,6 +46,7 @@ use super::agent_identity::register_managed_chatgpt_agent_identity; use super::agent_identity::require_agent_identity_authapi_base_url; use super::agent_identity::verified_record_from_jwt; use super::change_state::AuthChangeState; +use super::change_state::same_login; use super::change_state::same_owner; use super::external_bearer::BearerTokenRefresher; use super::revoke::revoke_auth_tokens; @@ -2079,7 +2086,7 @@ pub struct AuthManager { managed_auth_policy: ManagedAuthPolicy, chatgpt_base_url: Option, agent_identity_authapi_base_url: Option, - refresh_lock: Semaphore, + refresh_lock: Arc, agent_identity_lock: Semaphore, agent_identity_bootstrap_cooldown: Mutex, external_auth: RwLock>>, @@ -2236,7 +2243,7 @@ impl AuthManager { managed_auth_policy, chatgpt_base_url, agent_identity_authapi_base_url, - refresh_lock: Semaphore::new(/*permits*/ 1), + refresh_lock: Arc::new(Semaphore::new(/*permits*/ 1)), agent_identity_lock: Semaphore::new(/*permits*/ 1), agent_identity_bootstrap_cooldown: Mutex::default(), external_auth: RwLock::new(None), @@ -2272,7 +2279,7 @@ impl AuthManager { managed_auth_policy: ManagedAuthPolicy::default(), chatgpt_base_url: None, agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(), - refresh_lock: Semaphore::new(/*permits*/ 1), + refresh_lock: Arc::new(Semaphore::new(/*permits*/ 1)), agent_identity_lock: Semaphore::new(/*permits*/ 1), agent_identity_bootstrap_cooldown: Mutex::default(), external_auth: RwLock::new(None), @@ -2302,7 +2309,7 @@ impl AuthManager { managed_auth_policy: ManagedAuthPolicy::default(), chatgpt_base_url: None, agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(), - refresh_lock: Semaphore::new(/*permits*/ 1), + refresh_lock: Arc::new(Semaphore::new(/*permits*/ 1)), agent_identity_lock: Semaphore::new(/*permits*/ 1), agent_identity_bootstrap_cooldown: Mutex::default(), external_auth: RwLock::new(None), @@ -2340,7 +2347,7 @@ impl AuthManager { .trim_end_matches('/') .to_string(), ), - refresh_lock: Semaphore::new(/*permits*/ 1), + refresh_lock: Arc::new(Semaphore::new(/*permits*/ 1)), agent_identity_lock: Semaphore::new(/*permits*/ 1), agent_identity_bootstrap_cooldown: Mutex::default(), external_auth: RwLock::new(None), @@ -2368,7 +2375,7 @@ impl AuthManager { managed_auth_policy: ManagedAuthPolicy::default(), chatgpt_base_url: None, agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(), - refresh_lock: Semaphore::new(/*permits*/ 1), + refresh_lock: Arc::new(Semaphore::new(/*permits*/ 1)), agent_identity_lock: Semaphore::new(/*permits*/ 1), agent_identity_bootstrap_cooldown: Mutex::default(), external_auth: RwLock::new(Some(Arc::new(BearerTokenRefresher::new(config)))), @@ -2509,6 +2516,93 @@ impl AuthManager { .await } + /// Borrows a persisted account grant while excluding this manager's token refreshes. + /// Release the lease after the IdP exchange, then check the grant before using its result. + pub async fn lease_account_oauth_grant( + self: &Arc, + ) -> std::io::Result> { + let permit = Arc::clone(&self.refresh_lock) + .acquire_owned() + .await + .map_err(|_| std::io::Error::other("Account refresh coordination is unavailable."))?; + let login_generation = self.auth_change_state_tx.borrow().login_generation; + let Some(tokens) = self.read_account_grant_tokens().await? else { + return Ok(None); + }; + let grant = AccountOAuthGrant { + manager: Arc::clone(self), + tokens, + login_generation, + }; + if !grant.owner_is_current() { + return Ok(None); + } + Ok(Some(AccountOAuthGrantLease { grant, permit })) + } + + async fn read_account_grant_tokens(&self) -> std::io::Result> { + if self.has_external_auth() + || self.auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral + { + return Ok(None); + } + let Some(auth @ CodexAuth::Chatgpt(_)) = self.auth_cached() else { + return Ok(None); + }; + let allowed = self.allowed_login_methods(); + if validate_auth_restrictions( + Some(&allowed), + self.effective_chatgpt_workspaces().as_deref(), + &auth, + ) + .is_err() + { + return Ok(None); + } + let cached = auth.get_token_data()?; + let home = self.codex_home.clone(); + let mode = self.auth_credentials_store_mode; + let backend = self.keyring_backend_kind; + let stored = tokio::task::spawn_blocking(move || { + // Unlike ordinary login loading, an unavailable keyring must not authorize + // account exchange using an older file fallback. + if mode == AuthCredentialsStoreMode::Auto { + let keyring = + create_auth_storage(home.clone(), AuthCredentialsStoreMode::Keyring, backend); + if let Some(auth) = keyring.load()? { + return Ok(Some(auth)); + } + create_auth_storage(home, AuthCredentialsStoreMode::File, backend).load() + } else { + create_auth_storage(home, mode, backend).load() + } + }) + .await + .map_err(|_| std::io::Error::other("Account credential read failed."))? + .map_err(|_: std::io::Error| { + std::io::Error::other("Account credential storage is unavailable.") + })?; + let Some(stored) = stored.filter(|auth| auth.resolved_mode() == AuthMode::Chatgpt) else { + return Ok(None); + }; + Ok(stored.tokens.filter(|tokens| { + tokens.oauth_provenance.as_ref().is_some_and(|provenance| { + !provenance.issuer.trim().is_empty() && !provenance.client_id.trim().is_empty() + }) && tokens + .login_owner_id + .as_ref() + .is_some_and(|id| !id.is_empty()) + && !tokens.refresh_token.is_empty() + && tokens + .id_token + .chatgpt_user_id + .as_ref() + .is_some_and(|id| !id.is_empty()) + && tokens.account_id.as_ref().is_some_and(|id| !id.is_empty()) + && same_account_grant(tokens, &cached) + })) + } + /// Reloads auth from the active source. Returns whether the auth value changed. pub async fn reload(&self) -> bool { tracing::info!("Reloading auth"); @@ -2668,6 +2762,8 @@ impl AuthManager { auth_changed_for_refresh && !same_owner(previous, new_auth.as_ref()); let source_changed = auth_changed_for_refresh && !super::source::same_source(previous, new_auth.as_ref()); + let login_changed = + auth_changed_for_refresh && !same_login(previous, new_auth.as_ref()); if owner_changed { self.auth_route_config .application_network_policy() @@ -2687,6 +2783,9 @@ impl AuthManager { if source_changed { state.source_generation += 1; } + if login_changed { + state.login_generation += 1; + } }); self.auth_change_tx.send_modify(|revision| *revision += 1); } diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index ea82416d62..4698c66ff3 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -32,6 +32,8 @@ pub use success_page::CODEX_OPEN_APP_URL; pub use success_page::LoginSuccessPage; pub use success_page::LoginSuccessPageBrand; +pub use auth::AccountOAuthGrant; +pub use auth::AccountOAuthGrantLease; pub use auth::AgentIdentityAuthPolicy; pub use auth::AuthChangeState; pub use auth::AuthConfig; diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index 11fb2d29a0..a5b0f7c368 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -4,7 +4,9 @@ use base64::Engine; use chrono::Duration; use chrono::Utc; use codex_config::types::AuthCredentialsStoreMode; +use codex_http_client::DestinationPolicy; use codex_http_client::HttpClientFactory; +use codex_http_client::NetworkPolicyController; use codex_http_client::OutboundProxyPolicy; use codex_http_client::cache_system_proxy_route_for_test; use codex_login::AuthDotJson; @@ -28,6 +30,10 @@ use std::ffi::OsString; use std::net::TcpListener; use std::process::Command; use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::task::Wake; +use std::task::Waker; use std::time::Duration as StdDuration; use tempfile::TempDir; use wiremock::Mock; @@ -200,7 +206,19 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> { .mount(&server) .await; - let ctx = RefreshTokenTestContext::new(&server).await?; + let mut ctx = RefreshTokenTestContext::new(&server).await?; + let policy_controller = NetworkPolicyController::default(); + ctx.auth_manager = AuthManager::shared( + ctx.codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*forced_chatgpt_workspace_id*/ None, + /*chatgpt_base_url*/ None, + AuthKeyringBackendKind::default(), + codex_login::test_support::transport_default_auth_route_config() + .with_application_network_policy(policy_controller.policy()), + ) + .await; // Exercise the recorded issuer instead of the test endpoint override. // SAFETY: auth_env serializes these tests; ctx restores the environment on drop. unsafe { @@ -209,6 +227,12 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> { let initial_last_refresh = Utc::now() - Duration::days(1); let mut initial_tokens = build_tokens(INITIAL_ACCESS_TOKEN, INITIAL_REFRESH_TOKEN); initial_tokens.login_owner_id = Some("completed-login".into()); + initial_tokens.id_token = codex_login::token_data::parse_chatgpt_jwt_claims( + &jwt_with_payload(json!({ + "sub": "user-123", + "https://api.openai.com/auth": { "chatgpt_user_id": "user-123", "chatgpt_account_id": "account-id" } + })), + )?; initial_tokens.oauth_provenance = Some(AccountOAuthProvenance { issuer: server.uri(), client_id: "recorded-client".into(), @@ -225,10 +249,56 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> { }; ctx.write_auth(&initial_auth).await?; - ctx.auth_manager - .refresh_token_from_authority() + let changes = ctx.auth_manager.auth_change_state_receiver(); + let before = *changes.borrow(); + let lease = ctx + .auth_manager + .lease_account_oauth_grant() + .await? + .context("eligible account")?; + assert_eq!(lease.tokens(), &initial_tokens); + let refresh = ctx.auth_manager.refresh_token_from_authority(); + tokio::pin!(refresh); + struct RefreshWake(AtomicBool); + impl Wake for RefreshWake { + fn wake(self: Arc) { + self.0.store(true, Ordering::SeqCst); + } + } + let refresh_wake = Arc::new(RefreshWake(AtomicBool::new(false))); + let waker = Waker::from(Arc::clone(&refresh_wake)); + // Register the refresh waiter so lease release must wake it, not just return Pending. + assert!( + refresh + .as_mut() + .poll(&mut std::task::Context::from_waker(&waker)) + .is_pending() + ); + assert!( + server + .received_requests() + .await + .unwrap_or_default() + .is_empty() + ); + // No await between clearing the flag and release: this wake must come from the lease. + refresh_wake.0.store(false, Ordering::SeqCst); + let grant = lease.release(); + assert!( + refresh_wake.0.load(Ordering::SeqCst), + "releasing the account grant must wake the blocked refresh" + ); + assert!(grant.check_current().await?); + refresh .await - .context("refresh should succeed")?; + .context("refresh should succeed once the IdP lease is released")?; + assert!( + !grant.check_current().await?, + "the old credential was rotated" + ); + let after = *changes.borrow(); + assert_eq!(after.owner_generation, before.owner_generation); + assert_eq!(after.login_generation, before.login_generation); let requests = server.received_requests().await.unwrap_or_default(); assert_eq!( @@ -268,6 +338,83 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> { .context("token data should be cached")?; assert_eq!(cached, refreshed_tokens); + let lease = ctx + .auth_manager + .lease_account_oauth_grant() + .await? + .context("rotated account grant")?; + assert_eq!(lease.tokens(), &refreshed_tokens); + let grant = lease.release(); + let mut replacement = stored.clone(); + replacement + .tokens + .as_mut() + .context("replacement tokens")? + .login_owner_id = Some("new-login-same-account".into()); + // A persisted replacement is visible even before the ordinary auth cache reloads. + save_auth( + ctx.codex_home.path(), + &replacement, + AuthCredentialsStoreMode::File, + AuthKeyringBackendKind::default(), + )?; + assert!(!grant.check_current().await?); + ctx.auth_manager.reload().await; + let after = *changes.borrow(); + assert_eq!(after.owner_generation, before.owner_generation + 1); + assert_eq!(after.login_generation, before.login_generation + 1); + let lease = ctx + .auth_manager + .lease_account_oauth_grant() + .await? + .context("new login grant")?; + let grant = lease.release(); + let before = *changes.borrow(); + let policy = ctx.auth_manager.application_network_policy(); + assert!(policy_controller.publish(policy.revision(), DestinationPolicy::Unrestricted)); + let policy = policy.for_current_account(); + let endpoint = server.uri().parse()?; + assert!(policy.acquire(&endpoint).is_ok()); + // Older clients drop unknown login metadata when saving a same-account refresh. + let mut legacy = replacement.clone(); + let tokens = legacy.tokens.as_mut().context("legacy tokens")?; + tokens.login_owner_id = None; + tokens.oauth_provenance = None; + ctx.write_auth(&legacy).await?; + let after = *changes.borrow(); + assert_eq!(after.owner_generation, before.owner_generation); + assert!(policy.acquire(&endpoint).is_ok()); + assert_eq!(after.login_generation, before.login_generation + 1); + assert!(!grant.check_current().await?); + assert!( + ctx.auth_manager + .lease_account_oauth_grant() + .await? + .is_none() + ); + + // Restoring identical credentials must not revive the old EMA grant. + ctx.write_auth(&replacement).await?; + assert_eq!( + changes.borrow().login_generation, + before.login_generation + 2 + ); + assert!(!grant.check_current().await?); + let lease = ctx + .auth_manager + .lease_account_oauth_grant() + .await? + .context("restored login grant")?; + // Logout must complete even while an IdP exchange holds the refresh lease. + ctx.auth_manager.logout().await?; + assert!(!lease.release().check_current().await?); + assert!( + ctx.auth_manager + .lease_account_oauth_grant() + .await? + .is_none() + ); + server.verify().await; Ok(()) }