Skip to main content

codex_login/auth/
manager.rs

1use chrono::Utc;
2use http::StatusCode;
3use serde::Deserialize;
4use serde::Serialize;
5#[cfg(test)]
6use serial_test::serial;
7use std::env;
8use std::fmt::Debug;
9use std::future::Future;
10use std::path::Path;
11use std::path::PathBuf;
12use std::pin::Pin;
13use std::sync::Arc;
14use std::sync::Mutex;
15use std::sync::RwLock;
16use std::sync::atomic::AtomicU64;
17use std::sync::atomic::Ordering;
18use std::time::Duration;
19use std::time::Instant;
20use tokio::sync::Semaphore;
21use tokio::sync::watch;
22use tracing::instrument;
23
24use codex_agent_identity::ChatGptEnvironment;
25use codex_protocol::auth::AuthMode;
26use codex_protocol::config_types::ForcedLoginMethod;
27use codex_protocol::config_types::ModelProviderAuthInfo;
28
29use super::access_token::CodexAccessToken;
30use super::access_token::classify_codex_access_token;
31use super::agent_identity::ManagedChatGptAgentIdentityBinding;
32use super::agent_identity::agent_identity_authapi_base_url;
33use super::agent_identity::classify_bootstrap_error;
34use super::agent_identity::record_matches_managed_chatgpt_binding;
35use super::agent_identity::record_needs_task_registration;
36use super::agent_identity::register_managed_chatgpt_agent_identity;
37use super::agent_identity::require_agent_identity_authapi_base_url;
38use super::agent_identity::verified_record_from_jwt;
39use super::external_bearer::BearerTokenRefresher;
40use super::revoke::revoke_auth_tokens;
41use crate::auth::AuthHeaders;
42pub use crate::auth::agent_identity::AgentIdentityAuth;
43pub use crate::auth::agent_identity::AgentIdentityAuthError;
44pub use crate::auth::bedrock_api_key::BedrockApiKeyAuth;
45pub use crate::auth::personal_access_token::PersonalAccessTokenAuth;
46pub use crate::auth::storage::AgentIdentityAuthRecord;
47pub use crate::auth::storage::AgentIdentityStorage;
48pub use crate::auth::storage::AuthDotJson;
49pub use crate::auth::storage::AuthKeyringBackendKind;
50use crate::auth::storage::AuthStorageBackend;
51use crate::auth::storage::create_auth_storage;
52use crate::auth::util::try_parse_error_message;
53use crate::default_client::create_client;
54use crate::default_client::create_default_auth_client;
55use crate::outbound_proxy::AuthRouteConfig;
56use crate::token_data::TokenData;
57use crate::token_data::parse_chatgpt_jwt_claims;
58use crate::token_data::parse_jwt_expiration;
59use codex_config::types::AuthCredentialsStoreMode;
60use codex_http_client::HttpClient;
61use codex_http_client::HttpClientFactory;
62use codex_http_client::OutboundProxyPolicy;
63use codex_protocol::account::PlanType as AccountPlanType;
64use codex_protocol::auth::PlanType as InternalPlanType;
65use codex_protocol::auth::RefreshTokenFailedError;
66use codex_protocol::auth::RefreshTokenFailedReason;
67use codex_protocol::protocol::SessionSource;
68use serde_json::Value;
69use thiserror::Error;
70
71/// Authentication mechanism used by the current user.
72#[derive(Debug, Clone)]
73pub enum CodexAuth {
74    ApiKey(ApiKeyAuth),
75    Chatgpt(ChatgptAuth),
76    ChatgptAuthTokens(ChatgptAuthTokens),
77    Headers(AuthHeaders),
78    AgentIdentity(AgentIdentityAuth),
79    PersonalAccessToken(PersonalAccessTokenAuth),
80    BedrockApiKey(BedrockApiKeyAuth),
81}
82
83/// Policy for resolving Agent Identity auth from a broader Codex auth snapshot.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum AgentIdentityAuthPolicy {
86    /// Use Agent Identity auth only when the current auth is already Agent Identity.
87    JwtOnly,
88    /// Allow managed ChatGPT auth to register or reuse Agent Identity auth.
89    ChatGptAuth,
90}
91
92const AGENT_IDENTITY_BOOTSTRAP_FAILURE_COOLDOWN: Duration = Duration::from_secs(60 * 60);
93
94#[derive(Debug)]
95struct CachedAgentIdentityBootstrapFailure {
96    account_id: String,
97    authapi_base_url: String,
98    retry_at: Instant,
99    error: AgentIdentityAuthError,
100}
101
102#[derive(Debug, Default)]
103struct AgentIdentityBootstrapCooldown {
104    failure: Option<CachedAgentIdentityBootstrapFailure>,
105}
106
107impl AgentIdentityBootstrapCooldown {
108    fn error_for(
109        &mut self,
110        account_id: &str,
111        authapi_base_url: &str,
112        now: Instant,
113    ) -> Option<AgentIdentityAuthError> {
114        let error = self
115            .failure
116            .as_ref()
117            .filter(|failure| {
118                failure.account_id == account_id
119                    && failure.authapi_base_url == authapi_base_url
120                    && failure.retry_at > now
121            })
122            .map(|failure| failure.error.clone());
123        if error.is_none() {
124            self.clear();
125        }
126        error
127    }
128
129    fn record_failure(
130        &mut self,
131        account_id: String,
132        authapi_base_url: String,
133        error: AgentIdentityAuthError,
134        now: Instant,
135    ) {
136        self.failure = Some(CachedAgentIdentityBootstrapFailure {
137            account_id,
138            authapi_base_url,
139            retry_at: now + AGENT_IDENTITY_BOOTSTRAP_FAILURE_COOLDOWN,
140            error,
141        });
142    }
143
144    fn clear(&mut self) {
145        self.failure = None;
146    }
147}
148
149impl PartialEq for CodexAuth {
150    fn eq(&self, other: &Self) -> bool {
151        match (self, other) {
152            (Self::Headers(a), Self::Headers(b)) => a == b,
153            (Self::PersonalAccessToken(a), Self::PersonalAccessToken(b)) => a == b,
154            (Self::BedrockApiKey(a), Self::BedrockApiKey(b)) => a == b,
155            _ => self.api_auth_mode() == other.api_auth_mode(),
156        }
157    }
158}
159
160#[derive(Debug, Clone)]
161pub struct ApiKeyAuth {
162    api_key: String,
163}
164
165#[derive(Debug, Clone)]
166pub struct ChatgptAuth {
167    state: ChatgptAuthState,
168    storage: Arc<dyn AuthStorageBackend>,
169}
170
171#[derive(Debug, Clone)]
172pub struct ChatgptAuthTokens {
173    state: ChatgptAuthState,
174}
175
176#[derive(Debug, Clone)]
177struct ChatgptAuthState {
178    auth_dot_json: Arc<Mutex<Option<AuthDotJson>>>,
179    client: HttpClient,
180}
181
182const TOKEN_REFRESH_INTERVAL: i64 = 8;
183const CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES: i64 = 5;
184
185const REFRESH_TOKEN_EXPIRED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again.";
186const REFRESH_TOKEN_REUSED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.";
187const REFRESH_TOKEN_INVALIDATED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.";
188const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str =
189    "Your access token could not be refreshed. Please log out and sign in again.";
190const REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE: &str = "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.";
191const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
192pub(super) const REVOKE_TOKEN_URL: &str = "https://auth.openai.com/oauth/revoke";
193pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE";
194pub const REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REVOKE_TOKEN_URL_OVERRIDE";
195pub const CLIENT_ID_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_CLIENT_ID";
196static NEXT_DUMMY_AUTH_ID: AtomicU64 = AtomicU64::new(1);
197
198#[derive(Debug, Error)]
199pub enum RefreshTokenError {
200    #[error("{0}")]
201    Permanent(#[from] RefreshTokenFailedError),
202    #[error(transparent)]
203    Transient(#[from] std::io::Error),
204}
205
206#[derive(Clone, Copy, Debug, PartialEq, Eq)]
207pub enum ExternalAuthRefreshReason {
208    Unauthorized,
209}
210
211#[derive(Clone, Debug, PartialEq, Eq)]
212pub struct ExternalAuthRefreshContext {
213    pub reason: ExternalAuthRefreshReason,
214    pub previous_account_id: Option<String>,
215}
216
217/// Pluggable auth provider used by `AuthManager` for externally managed auth flows.
218///
219/// Implementations own the current auth value and any source-specific refresh mechanism.
220pub trait ExternalAuth: Send + Sync {
221    /// Returns the provider's current auth value.
222    fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth>;
223
224    /// Refreshes auth and makes the returned value current for future `resolve()` calls.
225    fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth>;
226}
227
228pub type ExternalAuthFuture<'a, T> = Pin<Box<dyn Future<Output = std::io::Result<T>> + Send + 'a>>;
229
230impl RefreshTokenError {
231    pub fn failed_reason(&self) -> Option<RefreshTokenFailedReason> {
232        match self {
233            Self::Permanent(error) => Some(error.reason),
234            Self::Transient(_) => None,
235        }
236    }
237}
238
239impl From<RefreshTokenError> for std::io::Error {
240    fn from(err: RefreshTokenError) -> Self {
241        match err {
242            RefreshTokenError::Permanent(failed) => std::io::Error::other(failed),
243            RefreshTokenError::Transient(inner) => inner,
244        }
245    }
246}
247
248impl CodexAuth {
249    async fn from_auth_dot_json(
250        codex_home: &Path,
251        auth_dot_json: AuthDotJson,
252        auth_credentials_store_mode: AuthCredentialsStoreMode,
253        chatgpt_base_url: Option<&str>,
254        keyring_backend_kind: AuthKeyringBackendKind,
255        agent_identity_authapi_base_url: Option<&str>,
256        auth_route_config: Option<&AuthRouteConfig>,
257    ) -> std::io::Result<Self> {
258        let auth_mode = auth_dot_json.resolved_mode();
259        if auth_mode == AuthMode::ApiKey {
260            let Some(api_key) = auth_dot_json.openai_api_key.as_deref() else {
261                return Err(std::io::Error::other("API key auth is missing a key."));
262            };
263            return Ok(Self::from_api_key(api_key));
264        }
265        if auth_mode == AuthMode::AgentIdentity {
266            let Some(agent_identity) = auth_dot_json.agent_identity.clone() else {
267                return Err(std::io::Error::other(
268                    "agent identity auth is missing agent identity auth material.",
269                ));
270            };
271            let base_url = chatgpt_base_url
272                .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
273                .trim_end_matches('/')
274                .to_string();
275            let agent_identity_authapi_base_url =
276                require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?;
277            match agent_identity {
278                AgentIdentityStorage::Jwt(jwt) => {
279                    let auth = AgentIdentityAuth::from_jwt(
280                        &jwt,
281                        &base_url,
282                        agent_identity_authapi_base_url,
283                        auth_route_config,
284                    )
285                    .await?;
286                    return Ok(Self::AgentIdentity(auth));
287                }
288                AgentIdentityStorage::Record(record) => {
289                    let auth = AgentIdentityAuth::from_record(
290                        record,
291                        agent_identity_authapi_base_url,
292                        auth_route_config,
293                    )
294                    .await?;
295                    return Ok(Self::AgentIdentity(auth));
296                }
297            }
298        }
299        if auth_mode == AuthMode::PersonalAccessToken {
300            let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else {
301                return Err(std::io::Error::other(
302                    "personal access token auth is missing a personal access token.",
303                ));
304            };
305            return Self::from_personal_access_token(personal_access_token, auth_route_config)
306                .await;
307        }
308        if auth_mode == AuthMode::BedrockApiKey {
309            let Some(auth) = auth_dot_json.bedrock_api_key else {
310                return Err(std::io::Error::other(
311                    "Bedrock API key auth is missing a Bedrock API key.",
312                ));
313            };
314            return Ok(Self::BedrockApiKey(auth));
315        }
316        if auth_mode == AuthMode::Headers {
317            return Err(std::io::Error::other(
318                "externally provided auth cannot be loaded from auth storage.",
319            ));
320        }
321
322        let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode);
323        let client = create_default_auth_client(&refresh_token_endpoint(), auth_route_config)?;
324        let state = ChatgptAuthState {
325            auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
326            client,
327        };
328
329        match auth_mode {
330            AuthMode::Chatgpt => {
331                let storage = create_auth_storage(
332                    codex_home.to_path_buf(),
333                    storage_mode,
334                    keyring_backend_kind,
335                );
336                Ok(Self::Chatgpt(ChatgptAuth { state, storage }))
337            }
338            AuthMode::ChatgptAuthTokens => Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state })),
339            AuthMode::ApiKey => unreachable!("api key mode is handled above"),
340            AuthMode::Headers => {
341                unreachable!("externally provided auth is never loaded from auth storage")
342            }
343            AuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"),
344            AuthMode::PersonalAccessToken => {
345                unreachable!("personal access token mode is handled above")
346            }
347            AuthMode::BedrockApiKey => unreachable!("bedrock api key mode is handled above"),
348        }
349    }
350
351    pub async fn from_auth_storage(
352        codex_home: &Path,
353        auth_credentials_store_mode: AuthCredentialsStoreMode,
354        chatgpt_base_url: Option<&str>,
355        keyring_backend_kind: AuthKeyringBackendKind,
356        auth_route_config: Option<&AuthRouteConfig>,
357    ) -> std::io::Result<Option<Self>> {
358        let agent_identity_authapi_base_url =
359            agent_identity_authapi_base_url(chatgpt_base_url).ok();
360        load_auth(
361            codex_home,
362            /*enable_codex_api_key_env*/ false,
363            auth_credentials_store_mode,
364            /*forced_chatgpt_workspace_id*/ None,
365            chatgpt_base_url,
366            keyring_backend_kind,
367            agent_identity_authapi_base_url.as_deref(),
368            auth_route_config,
369        )
370        .await
371    }
372
373    pub async fn from_agent_identity_jwt(
374        jwt: &str,
375        chatgpt_base_url: Option<&str>,
376        auth_route_config: Option<&AuthRouteConfig>,
377    ) -> std::io::Result<Self> {
378        let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url)?;
379        Self::from_agent_identity_jwt_with_authapi_base_url(
380            jwt,
381            chatgpt_base_url,
382            &agent_identity_authapi_base_url,
383            auth_route_config,
384        )
385        .await
386    }
387
388    async fn from_agent_identity_jwt_with_authapi_base_url(
389        jwt: &str,
390        chatgpt_base_url: Option<&str>,
391        agent_identity_authapi_base_url: &str,
392        auth_route_config: Option<&AuthRouteConfig>,
393    ) -> std::io::Result<Self> {
394        let base_url = chatgpt_base_url
395            .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
396            .trim_end_matches('/')
397            .to_string();
398        Ok(Self::AgentIdentity(
399            AgentIdentityAuth::from_jwt(
400                jwt,
401                &base_url,
402                agent_identity_authapi_base_url,
403                auth_route_config,
404            )
405            .await?,
406        ))
407    }
408
409    pub async fn from_personal_access_token(
410        access_token: &str,
411        auth_route_config: Option<&AuthRouteConfig>,
412    ) -> std::io::Result<Self> {
413        Ok(Self::PersonalAccessToken(
414            PersonalAccessTokenAuth::load(access_token, auth_route_config).await?,
415        ))
416    }
417
418    /// Returns the effective backend auth mode.
419    ///
420    /// Externally managed ChatGPT tokens are normalized to [`AuthMode::Chatgpt`].
421    pub fn auth_mode(&self) -> AuthMode {
422        match self {
423            Self::ApiKey(_) => AuthMode::ApiKey,
424            Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt,
425            Self::Headers(_) => AuthMode::Headers,
426            Self::AgentIdentity(_) => AuthMode::AgentIdentity,
427            Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken,
428            Self::BedrockApiKey(_) => AuthMode::BedrockApiKey,
429        }
430    }
431
432    /// Returns the precise kind of credentials backing this authentication.
433    pub fn api_auth_mode(&self) -> AuthMode {
434        match self {
435            Self::ApiKey(_) => AuthMode::ApiKey,
436            Self::Chatgpt(_) => AuthMode::Chatgpt,
437            Self::ChatgptAuthTokens(_) => AuthMode::ChatgptAuthTokens,
438            Self::Headers(_) => AuthMode::Headers,
439            Self::AgentIdentity(_) => AuthMode::AgentIdentity,
440            Self::PersonalAccessToken(_) => AuthMode::PersonalAccessToken,
441            Self::BedrockApiKey(_) => AuthMode::BedrockApiKey,
442        }
443    }
444
445    pub fn is_api_key_auth(&self) -> bool {
446        self.auth_mode() == AuthMode::ApiKey
447    }
448
449    pub fn is_personal_access_token_auth(&self) -> bool {
450        self.auth_mode() == AuthMode::PersonalAccessToken
451    }
452
453    pub fn is_chatgpt_auth(&self) -> bool {
454        self.api_auth_mode().has_chatgpt_account()
455    }
456
457    pub fn uses_codex_backend(&self) -> bool {
458        self.api_auth_mode().uses_codex_backend()
459    }
460
461    pub fn is_external_chatgpt_tokens(&self) -> bool {
462        matches!(self, Self::ChatgptAuthTokens(_))
463    }
464
465    fn supports_unauthorized_recovery(&self) -> bool {
466        matches!(
467            self,
468            Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::Headers(_)
469        )
470    }
471
472    /// Returns `None` if `auth_mode() != AuthMode::ApiKey`.
473    pub fn api_key(&self) -> Option<&str> {
474        match self {
475            Self::ApiKey(auth) => Some(auth.api_key.as_str()),
476            Self::Chatgpt(_)
477            | Self::ChatgptAuthTokens(_)
478            | Self::Headers(_)
479            | Self::AgentIdentity(_)
480            | Self::PersonalAccessToken(_)
481            | Self::BedrockApiKey(_) => None,
482        }
483    }
484
485    /// Returns `Err` if token-backed ChatGPT auth is unavailable.
486    pub fn get_token_data(&self) -> Result<TokenData, std::io::Error> {
487        let auth_dot_json: Option<AuthDotJson> = self.get_current_auth_json();
488        match auth_dot_json {
489            Some(AuthDotJson {
490                tokens: Some(tokens),
491                last_refresh: Some(_),
492                ..
493            }) => Ok(tokens),
494            _ => Err(std::io::Error::other("Token data is not available.")),
495        }
496    }
497
498    /// Returns the token string used for bearer authentication.
499    pub fn get_token(&self) -> Result<String, std::io::Error> {
500        match self {
501            Self::ApiKey(auth) => Ok(auth.api_key.clone()),
502            Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => {
503                let access_token = self.get_token_data()?.access_token;
504                Ok(access_token)
505            }
506            Self::AgentIdentity(_) => Err(std::io::Error::other(
507                "agent identity auth does not expose a bearer token",
508            )),
509            Self::Headers(_) => Err(std::io::Error::other(
510                "header auth does not expose a bearer token",
511            )),
512            Self::PersonalAccessToken(auth) => Ok(auth.access_token().to_string()),
513            Self::BedrockApiKey(_) => Err(std::io::Error::other(
514                "Bedrock API key auth does not expose a Codex bearer token",
515            )),
516        }
517    }
518
519    /// Returns `None` if Codex backend auth does not expose an account id.
520    pub fn get_account_id(&self) -> Option<String> {
521        match self {
522            Self::Headers(_) => None,
523            Self::AgentIdentity(auth) => Some(auth.account_id().to_string()),
524            Self::PersonalAccessToken(auth) => Some(auth.account_id().to_string()),
525            _ => self.get_current_token_data().and_then(|t| t.account_id),
526        }
527    }
528
529    /// Returns false if Codex backend auth omits the FedRAMP claim.
530    pub fn is_fedramp_account(&self) -> bool {
531        match self {
532            Self::Headers(_) => false,
533            Self::AgentIdentity(auth) => auth.is_fedramp_account(),
534            Self::PersonalAccessToken(auth) => auth.is_fedramp_account(),
535            _ => self
536                .get_current_token_data()
537                .is_some_and(|t| t.id_token.is_fedramp_account()),
538        }
539    }
540
541    /// Returns `None` if Codex backend auth does not expose an account email.
542    pub fn get_account_email(&self) -> Option<String> {
543        match self {
544            Self::Headers(_) => None,
545            Self::AgentIdentity(auth) => auth.email().map(str::to_string),
546            Self::PersonalAccessToken(auth) => auth.email().map(str::to_string),
547            _ => self.get_current_token_data().and_then(|t| t.id_token.email),
548        }
549    }
550
551    /// Returns `None` if Codex backend auth does not expose a ChatGPT user id.
552    pub fn get_chatgpt_user_id(&self) -> Option<String> {
553        match self {
554            Self::Headers(_) => None,
555            Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()),
556            Self::PersonalAccessToken(auth) => Some(auth.chatgpt_user_id().to_string()),
557            _ => self
558                .get_current_token_data()
559                .and_then(|t| t.id_token.chatgpt_user_id),
560        }
561    }
562
563    /// Account-facing plan classification derived from the current auth.
564    /// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…)
565    /// for UI or product decisions based on the user's subscription.
566    pub fn account_plan_type(&self) -> Option<AccountPlanType> {
567        if matches!(self, Self::Headers(_)) {
568            return None;
569        }
570        if let Self::AgentIdentity(auth) = self {
571            return Some(auth.plan_type());
572        }
573        if let Self::PersonalAccessToken(auth) = self {
574            return Some(auth.plan_type());
575        }
576
577        self.get_current_token_data().map(|t| {
578            t.id_token
579                .chatgpt_plan_type
580                .map(AccountPlanType::from)
581                .unwrap_or(AccountPlanType::Unknown)
582        })
583    }
584
585    pub fn is_workspace_account(&self) -> bool {
586        self.account_plan_type()
587            .is_some_and(AccountPlanType::is_workspace_account)
588    }
589
590    /// Returns `None` if token-backed ChatGPT auth is unavailable.
591    fn get_current_auth_json(&self) -> Option<AuthDotJson> {
592        let state = match self {
593            Self::Chatgpt(auth) => &auth.state,
594            Self::ChatgptAuthTokens(auth) => &auth.state,
595            Self::ApiKey(_)
596            | Self::Headers(_)
597            | Self::AgentIdentity(_)
598            | Self::PersonalAccessToken(_)
599            | Self::BedrockApiKey(_) => return None,
600        };
601        #[expect(clippy::unwrap_used)]
602        state.auth_dot_json.lock().unwrap().clone()
603    }
604
605    /// Returns `None` if token-backed ChatGPT auth is unavailable.
606    fn get_current_token_data(&self) -> Option<TokenData> {
607        self.get_current_auth_json().and_then(|t| t.tokens)
608    }
609
610    fn stored_managed_chatgpt_agent_identity_record(
611        &self,
612        account_id: &str,
613    ) -> Option<AgentIdentityAuthRecord> {
614        self.get_current_auth_json()
615            .and_then(|auth| auth.agent_identity)
616            .and_then(|identity| identity.as_record().cloned())
617            .filter(|identity| identity.account_id == account_id)
618    }
619
620    fn persist_managed_chatgpt_agent_identity_record(
621        &self,
622        record: AgentIdentityAuthRecord,
623    ) -> std::io::Result<()> {
624        if let Self::Chatgpt(chatgpt_auth) = self {
625            chatgpt_auth.persist_agent_identity_record(record)?;
626        }
627        Ok(())
628    }
629
630    async fn agent_identity_auth(
631        &self,
632        policy: AgentIdentityAuthPolicy,
633        agent_identity_authapi_base_url: Option<&str>,
634        forced_chatgpt_workspace_id: Option<Vec<String>>,
635        auth_route_config: Option<&AuthRouteConfig>,
636        session_source: SessionSource,
637    ) -> std::io::Result<Option<AgentIdentityAuth>> {
638        match self {
639            Self::AgentIdentity(auth) => Ok(Some(auth.clone())),
640            Self::ApiKey(_)
641            | Self::ChatgptAuthTokens(_)
642            | Self::Headers(_)
643            | Self::PersonalAccessToken(_)
644            | Self::BedrockApiKey(_) => Ok(None),
645            Self::Chatgpt(_) => {
646                if policy == AgentIdentityAuthPolicy::JwtOnly {
647                    return Ok(None);
648                }
649                self.ensure_managed_chatgpt_agent_identity(
650                    require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?,
651                    forced_chatgpt_workspace_id,
652                    auth_route_config,
653                    session_source,
654                )
655                .await
656                .map(Some)
657            }
658        }
659    }
660
661    async fn ensure_managed_chatgpt_agent_identity(
662        &self,
663        agent_identity_authapi_base_url: &str,
664        forced_chatgpt_workspace_id: Option<Vec<String>>,
665        auth_route_config: Option<&AuthRouteConfig>,
666        session_source: SessionSource,
667    ) -> std::io::Result<AgentIdentityAuth> {
668        let binding =
669            ManagedChatGptAgentIdentityBinding::from_auth(self, forced_chatgpt_workspace_id)
670                .ok_or_else(|| std::io::Error::other("ChatGPT auth is unavailable"))?;
671
672        // JWT auth is loaded as CodexAuth::AgentIdentity; this path only reuses
673        // records created by the managed ChatGPT Agent Identity bootstrap.
674        if let Some(record) = self.stored_managed_chatgpt_agent_identity_record(&binding.account_id)
675            && record_matches_managed_chatgpt_binding(&record, &binding)
676        {
677            let should_persist = record_needs_task_registration(&record);
678            let auth = AgentIdentityAuth::from_record(
679                record,
680                agent_identity_authapi_base_url,
681                auth_route_config,
682            )
683            .await
684            .map_err(|err| classify_bootstrap_error("agent task registration", err))?;
685            if should_persist {
686                self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?;
687            }
688            return Ok(auth);
689        }
690
691        let auth = register_managed_chatgpt_agent_identity(
692            binding,
693            agent_identity_authapi_base_url,
694            session_source,
695            auth_route_config,
696        )
697        .await?;
698        self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?;
699        Ok(auth)
700    }
701
702    /// Consider this private to integration tests.
703    pub fn create_dummy_chatgpt_auth_for_testing() -> Self {
704        let auth_dot_json = AuthDotJson {
705            auth_mode: Some(AuthMode::Chatgpt),
706            openai_api_key: None,
707            tokens: Some(TokenData {
708                id_token: Default::default(),
709                access_token: "Access Token".to_string(),
710                refresh_token: "test".to_string(),
711                account_id: Some("account_id".to_string()),
712            }),
713            last_refresh: Some(Utc::now()),
714            agent_identity: None,
715            personal_access_token: None,
716            bedrock_api_key: None,
717        };
718
719        let state = ChatgptAuthState {
720            auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
721            client: create_client(),
722        };
723        let dummy_auth_id = NEXT_DUMMY_AUTH_ID.fetch_add(1, Ordering::Relaxed);
724        let storage = create_auth_storage(
725            PathBuf::from(format!("dummy-chatgpt-auth-{dummy_auth_id}")),
726            AuthCredentialsStoreMode::Ephemeral,
727            AuthKeyringBackendKind::default(),
728        );
729        Self::Chatgpt(ChatgptAuth { state, storage })
730    }
731
732    /// Constructs in-memory ChatGPT auth from externally managed tokens.
733    pub fn from_external_chatgpt_tokens(
734        access_token: &str,
735        chatgpt_account_id: &str,
736        chatgpt_plan_type: Option<&str>,
737    ) -> std::io::Result<Self> {
738        let auth_dot_json = AuthDotJson::from_external_access_token(
739            access_token,
740            chatgpt_account_id,
741            chatgpt_plan_type,
742        )?;
743        let state = ChatgptAuthState {
744            auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))),
745            client: create_client(),
746        };
747        Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state }))
748    }
749
750    pub fn from_api_key(api_key: &str) -> Self {
751        Self::ApiKey(ApiKeyAuth {
752            api_key: api_key.to_owned(),
753        })
754    }
755}
756
757impl ManagedChatGptAgentIdentityBinding {
758    fn from_auth(auth: &CodexAuth, forced_workspace_id: Option<Vec<String>>) -> Option<Self> {
759        if !auth.is_chatgpt_auth() {
760            return None;
761        }
762
763        let token_data = auth.get_token_data().ok()?;
764        let forced_workspace_id =
765            forced_workspace_id
766                .as_deref()
767                .and_then(|workspace_ids| match workspace_ids {
768                    [workspace_id] if !workspace_id.is_empty() => Some(workspace_id.clone()),
769                    _ => None,
770                });
771        let account_id = forced_workspace_id
772            .or(token_data
773                .account_id
774                .clone()
775                .filter(|value| !value.is_empty()))
776            .or(token_data.id_token.chatgpt_account_id.clone())?;
777        let chatgpt_user_id = token_data
778            .id_token
779            .chatgpt_user_id
780            .clone()
781            .filter(|value| !value.is_empty())?;
782
783        Some(Self {
784            account_id,
785            chatgpt_user_id,
786            email: token_data.id_token.email.clone(),
787            plan_type: auth.account_plan_type().unwrap_or(AccountPlanType::Unknown),
788            chatgpt_account_is_fedramp: auth.is_fedramp_account(),
789            access_token: token_data.access_token,
790        })
791    }
792}
793
794impl ChatgptAuth {
795    fn current_auth_json(&self) -> Option<AuthDotJson> {
796        #[expect(clippy::unwrap_used)]
797        self.state.auth_dot_json.lock().unwrap().clone()
798    }
799
800    fn current_token_data(&self) -> Option<TokenData> {
801        self.current_auth_json().and_then(|auth| auth.tokens)
802    }
803
804    fn storage(&self) -> &Arc<dyn AuthStorageBackend> {
805        &self.storage
806    }
807
808    fn client(&self) -> &HttpClient {
809        &self.state.client
810    }
811
812    fn persist_agent_identity_record(
813        &self,
814        record: AgentIdentityAuthRecord,
815    ) -> std::io::Result<()> {
816        persist_agent_identity_record(&self.state.auth_dot_json, &self.storage, record)
817    }
818}
819
820fn persist_agent_identity_record(
821    auth_dot_json: &Arc<Mutex<Option<AuthDotJson>>>,
822    storage: &Arc<dyn AuthStorageBackend>,
823    record: AgentIdentityAuthRecord,
824) -> std::io::Result<()> {
825    let mut guard = auth_dot_json
826        .lock()
827        .map_err(|_| std::io::Error::other("failed to lock auth state"))?;
828    let mut auth = storage
829        .load()?
830        .or_else(|| guard.clone())
831        .ok_or_else(|| std::io::Error::other("auth data is not available"))?;
832    auth.agent_identity = Some(AgentIdentityStorage::Record(record));
833    storage.save(&auth)?;
834    *guard = Some(auth);
835    Ok(())
836}
837
838pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY";
839pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY";
840pub const CODEX_ACCESS_TOKEN_ENV_VAR: &str = "CODEX_ACCESS_TOKEN";
841
842pub fn read_openai_api_key_from_env() -> Option<String> {
843    env::var(OPENAI_API_KEY_ENV_VAR)
844        .ok()
845        .map(|value| value.trim().to_string())
846        .filter(|value| !value.is_empty())
847}
848
849pub fn read_codex_api_key_from_env() -> Option<String> {
850    read_non_empty_env_var(CODEX_API_KEY_ENV_VAR)
851}
852
853pub fn read_codex_access_token_from_env() -> Option<String> {
854    read_non_empty_env_var(CODEX_ACCESS_TOKEN_ENV_VAR)
855}
856
857fn read_non_empty_env_var(key: &str) -> Option<String> {
858    env::var(key)
859        .ok()
860        .map(|value| value.trim().to_string())
861        .filter(|value| !value.is_empty())
862}
863
864/// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)`
865/// if a file was removed, `Ok(false)` if no auth file was present.
866pub fn logout(
867    codex_home: &Path,
868    auth_credentials_store_mode: AuthCredentialsStoreMode,
869    keyring_backend_kind: AuthKeyringBackendKind,
870) -> std::io::Result<bool> {
871    let storage = create_auth_storage(
872        codex_home.to_path_buf(),
873        auth_credentials_store_mode,
874        keyring_backend_kind,
875    );
876    storage.delete()
877}
878
879pub async fn logout_with_revoke(
880    codex_home: &Path,
881    auth_credentials_store_mode: AuthCredentialsStoreMode,
882    keyring_backend_kind: AuthKeyringBackendKind,
883    auth_route_config: Option<&AuthRouteConfig>,
884) -> std::io::Result<bool> {
885    let auth_dot_json = match load_auth_dot_json(
886        codex_home,
887        auth_credentials_store_mode,
888        keyring_backend_kind,
889    ) {
890        Ok(auth_dot_json) => auth_dot_json,
891        Err(err) => {
892            tracing::warn!("failed to load stored auth during logout: {err}");
893            None
894        }
895    };
896    if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref(), auth_route_config).await {
897        tracing::warn!("failed to revoke auth tokens during logout: {err}");
898    }
899    logout_all_stores(
900        codex_home,
901        auth_credentials_store_mode,
902        keyring_backend_kind,
903    )
904}
905
906/// Writes an `auth.json` that contains only the API key.
907pub fn login_with_api_key(
908    codex_home: &Path,
909    api_key: &str,
910    auth_credentials_store_mode: AuthCredentialsStoreMode,
911    keyring_backend_kind: AuthKeyringBackendKind,
912) -> std::io::Result<()> {
913    let auth_dot_json = AuthDotJson {
914        auth_mode: Some(AuthMode::ApiKey),
915        openai_api_key: Some(api_key.to_string()),
916        tokens: None,
917        last_refresh: None,
918        agent_identity: None,
919        personal_access_token: None,
920        bedrock_api_key: None,
921    };
922    save_auth(
923        codex_home,
924        &auth_dot_json,
925        auth_credentials_store_mode,
926        keyring_backend_kind,
927    )
928}
929
930/// Writes an `auth.json` that contains only the access token.
931pub async fn login_with_access_token(
932    codex_home: &Path,
933    access_token: &str,
934    auth_credentials_store_mode: AuthCredentialsStoreMode,
935    forced_chatgpt_workspace_id: Option<&[String]>,
936    chatgpt_base_url: Option<&str>,
937    keyring_backend_kind: AuthKeyringBackendKind,
938    auth_route_config: Option<&AuthRouteConfig>,
939) -> std::io::Result<()> {
940    let auth_dot_json = match classify_codex_access_token(access_token) {
941        CodexAccessToken::PersonalAccessToken(access_token) => {
942            let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?;
943            ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?;
944            AuthDotJson {
945                // Infer PAT auth from the credential field so older Codex builds can still
946                // deserialize auth.json after a rollback.
947                auth_mode: None,
948                openai_api_key: None,
949                tokens: None,
950                last_refresh: None,
951                agent_identity: None,
952                personal_access_token: Some(access_token.to_string()),
953                bedrock_api_key: None,
954            }
955        }
956        CodexAccessToken::AgentIdentityJwt(jwt) => {
957            let base_url = chatgpt_base_url
958                .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url())
959                .trim_end_matches('/')
960                .to_string();
961            verified_record_from_jwt(jwt, &base_url, auth_route_config).await?;
962            AuthDotJson {
963                auth_mode: Some(AuthMode::AgentIdentity),
964                openai_api_key: None,
965                tokens: None,
966                last_refresh: None,
967                agent_identity: Some(AgentIdentityStorage::Jwt(jwt.to_string())),
968                personal_access_token: None,
969                bedrock_api_key: None,
970            }
971        }
972    };
973    save_auth(
974        codex_home,
975        &auth_dot_json,
976        auth_credentials_store_mode,
977        keyring_backend_kind,
978    )
979}
980
981fn ensure_personal_access_token_workspace_allowed(
982    expected_workspace_ids: Option<&[String]>,
983    auth: &PersonalAccessTokenAuth,
984) -> std::io::Result<()> {
985    crate::server::ensure_workspace_account_allowed(expected_workspace_ids, auth.account_id())
986        .map_err(|message| std::io::Error::new(std::io::ErrorKind::PermissionDenied, message))
987}
988
989/// Writes an in-memory auth payload for externally managed ChatGPT tokens.
990pub fn login_with_chatgpt_auth_tokens(
991    codex_home: &Path,
992    access_token: &str,
993    chatgpt_account_id: &str,
994    chatgpt_plan_type: Option<&str>,
995) -> std::io::Result<()> {
996    let auth_dot_json = AuthDotJson::from_external_access_token(
997        access_token,
998        chatgpt_account_id,
999        chatgpt_plan_type,
1000    )?;
1001    save_auth(
1002        codex_home,
1003        &auth_dot_json,
1004        AuthCredentialsStoreMode::Ephemeral,
1005        AuthKeyringBackendKind::default(),
1006    )
1007}
1008
1009/// Persist the provided auth payload using the specified backend.
1010pub fn save_auth(
1011    codex_home: &Path,
1012    auth: &AuthDotJson,
1013    auth_credentials_store_mode: AuthCredentialsStoreMode,
1014    keyring_backend_kind: AuthKeyringBackendKind,
1015) -> std::io::Result<()> {
1016    let storage = create_auth_storage(
1017        codex_home.to_path_buf(),
1018        auth_credentials_store_mode,
1019        keyring_backend_kind,
1020    );
1021    storage.save(auth)
1022}
1023
1024/// Load the raw stored auth payload without applying environment overrides.
1025///
1026/// Returns `None` when no credentials are stored. Prefer `AuthManager` for
1027/// ordinary production reads; this helper is for tests and write-side
1028/// maintenance that must inspect the exact payload in storage.
1029pub fn load_auth_dot_json(
1030    codex_home: &Path,
1031    auth_credentials_store_mode: AuthCredentialsStoreMode,
1032    keyring_backend_kind: AuthKeyringBackendKind,
1033) -> std::io::Result<Option<AuthDotJson>> {
1034    let storage = create_auth_storage(
1035        codex_home.to_path_buf(),
1036        auth_credentials_store_mode,
1037        keyring_backend_kind,
1038    );
1039    storage.load()
1040}
1041
1042#[derive(Debug, Clone, PartialEq, Eq)]
1043pub struct AuthConfig {
1044    pub codex_home: PathBuf,
1045    pub auth_credentials_store_mode: AuthCredentialsStoreMode,
1046    pub keyring_backend_kind: AuthKeyringBackendKind,
1047    pub forced_login_method: Option<ForcedLoginMethod>,
1048    pub chatgpt_base_url: Option<String>,
1049    pub forced_chatgpt_workspace_id: Option<Vec<String>>,
1050    pub auth_route_config: AuthRouteConfig,
1051}
1052
1053/// Enforces configured login restrictions using auth-owned HTTP settings.
1054pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> {
1055    let agent_identity_authapi_base_url =
1056        agent_identity_authapi_base_url(config.chatgpt_base_url.as_deref()).ok();
1057    enforce_login_restrictions_with_agent_identity_authapi_base_url(
1058        config,
1059        agent_identity_authapi_base_url.as_deref(),
1060    )
1061    .await
1062}
1063
1064async fn enforce_login_restrictions_with_agent_identity_authapi_base_url(
1065    config: &AuthConfig,
1066    agent_identity_authapi_base_url: Option<&str>,
1067) -> std::io::Result<()> {
1068    let Some(auth) = load_auth(
1069        &config.codex_home,
1070        /*enable_codex_api_key_env*/ true,
1071        config.auth_credentials_store_mode,
1072        /*forced_chatgpt_workspace_id*/ None,
1073        config.chatgpt_base_url.as_deref(),
1074        config.keyring_backend_kind,
1075        agent_identity_authapi_base_url,
1076        Some(&config.auth_route_config),
1077    )
1078    .await?
1079    else {
1080        return Ok(());
1081    };
1082
1083    if let Some(required_method) = config.forced_login_method {
1084        let method_violation = match (required_method, auth.auth_mode()) {
1085            (ForcedLoginMethod::Api, AuthMode::ApiKey)
1086            | (ForcedLoginMethod::Api, AuthMode::BedrockApiKey) => None,
1087            (ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt)
1088            | (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens)
1089            | (ForcedLoginMethod::Chatgpt, AuthMode::Headers)
1090            | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity)
1091            | (ForcedLoginMethod::Chatgpt, AuthMode::PersonalAccessToken) => None,
1092            (ForcedLoginMethod::Api, AuthMode::Chatgpt)
1093            | (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens)
1094            | (ForcedLoginMethod::Api, AuthMode::Headers)
1095            | (ForcedLoginMethod::Api, AuthMode::AgentIdentity)
1096            | (ForcedLoginMethod::Api, AuthMode::PersonalAccessToken) => Some(
1097                "API key login is required, but ChatGPT is currently being used. Logging out."
1098                    .to_string(),
1099            ),
1100            (ForcedLoginMethod::Chatgpt, AuthMode::ApiKey)
1101            | (ForcedLoginMethod::Chatgpt, AuthMode::BedrockApiKey) => Some(
1102                "ChatGPT login is required, but an API key is currently being used. Logging out."
1103                    .to_string(),
1104            ),
1105        };
1106
1107        if let Some(message) = method_violation {
1108            return logout_with_message(
1109                &config.codex_home,
1110                message,
1111                config.auth_credentials_store_mode,
1112                config.keyring_backend_kind,
1113            );
1114        }
1115    }
1116
1117    if let Some(expected_account_ids) = config.forced_chatgpt_workspace_id.as_deref() {
1118        let chatgpt_account_id = match &auth {
1119            CodexAuth::ApiKey(_) | CodexAuth::Headers(_) | CodexAuth::BedrockApiKey(_) => {
1120                return Ok(());
1121            }
1122            CodexAuth::AgentIdentity(_) | CodexAuth::PersonalAccessToken(_) => {
1123                auth.get_account_id()
1124            }
1125            CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => {
1126                let token_data = match auth.get_token_data() {
1127                    Ok(data) => data,
1128                    Err(err) => {
1129                        return logout_with_message(
1130                            &config.codex_home,
1131                            format!(
1132                                "Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out."
1133                            ),
1134                            config.auth_credentials_store_mode,
1135                            config.keyring_backend_kind,
1136                        );
1137                    }
1138                };
1139                token_data.id_token.chatgpt_account_id
1140            }
1141        };
1142
1143        // workspace is the external identifier for account id.
1144        let chatgpt_account_id = chatgpt_account_id.as_deref();
1145        if !chatgpt_account_id.is_some_and(|actual| {
1146            expected_account_ids
1147                .iter()
1148                .any(|expected| expected == actual)
1149        }) {
1150            let expected_workspaces = expected_account_ids.join(", ");
1151            let message = match chatgpt_account_id {
1152                Some(actual) => format!(
1153                    "Login is restricted to workspace(s) {expected_workspaces}, but current credentials belong to {actual}. Logging out."
1154                ),
1155                None => format!(
1156                    "Login is restricted to workspace(s) {expected_workspaces}, but current credentials lack a workspace identifier. Logging out."
1157                ),
1158            };
1159            return logout_with_message(
1160                &config.codex_home,
1161                message,
1162                config.auth_credentials_store_mode,
1163                config.keyring_backend_kind,
1164            );
1165        }
1166    }
1167
1168    Ok(())
1169}
1170
1171fn logout_with_message(
1172    codex_home: &Path,
1173    message: String,
1174    auth_credentials_store_mode: AuthCredentialsStoreMode,
1175    keyring_backend_kind: AuthKeyringBackendKind,
1176) -> std::io::Result<()> {
1177    // External auth tokens live in the ephemeral store, but persistent auth may still exist
1178    // from earlier logins. Clear both so a forced logout truly removes all active auth.
1179    let removal_result = logout_all_stores(
1180        codex_home,
1181        auth_credentials_store_mode,
1182        keyring_backend_kind,
1183    );
1184    let error_message = match removal_result {
1185        Ok(_) => message,
1186        Err(err) => format!("{message}. Failed to remove auth.json: {err}"),
1187    };
1188    Err(std::io::Error::other(error_message))
1189}
1190
1191fn logout_all_stores(
1192    codex_home: &Path,
1193    auth_credentials_store_mode: AuthCredentialsStoreMode,
1194    keyring_backend_kind: AuthKeyringBackendKind,
1195) -> std::io::Result<bool> {
1196    if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral {
1197        return logout(
1198            codex_home,
1199            AuthCredentialsStoreMode::Ephemeral,
1200            AuthKeyringBackendKind::default(),
1201        );
1202    }
1203    let removed_ephemeral = logout(
1204        codex_home,
1205        AuthCredentialsStoreMode::Ephemeral,
1206        AuthKeyringBackendKind::default(),
1207    )?;
1208    let removed_managed = logout(
1209        codex_home,
1210        auth_credentials_store_mode,
1211        keyring_backend_kind,
1212    )?;
1213    Ok(removed_ephemeral || removed_managed)
1214}
1215
1216#[allow(clippy::too_many_arguments)]
1217async fn load_auth(
1218    codex_home: &Path,
1219    enable_codex_api_key_env: bool,
1220    auth_credentials_store_mode: AuthCredentialsStoreMode,
1221    forced_chatgpt_workspace_id: Option<&[String]>,
1222    chatgpt_base_url: Option<&str>,
1223    keyring_backend_kind: AuthKeyringBackendKind,
1224    agent_identity_authapi_base_url: Option<&str>,
1225    auth_route_config: Option<&AuthRouteConfig>,
1226) -> std::io::Result<Option<CodexAuth>> {
1227    // API key via env var takes precedence over any other auth method.
1228    if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() {
1229        return Ok(Some(CodexAuth::from_api_key(api_key.as_str())));
1230    }
1231
1232    // External ChatGPT auth tokens live in the in-memory (ephemeral) store. Always check this
1233    // first so external auth takes precedence over any persisted credentials.
1234    let ephemeral_storage = create_auth_storage(
1235        codex_home.to_path_buf(),
1236        AuthCredentialsStoreMode::Ephemeral,
1237        AuthKeyringBackendKind::default(),
1238    );
1239    if let Some(auth_dot_json) = ephemeral_storage.load()? {
1240        let auth = CodexAuth::from_auth_dot_json(
1241            codex_home,
1242            auth_dot_json,
1243            AuthCredentialsStoreMode::Ephemeral,
1244            chatgpt_base_url,
1245            keyring_backend_kind,
1246            agent_identity_authapi_base_url,
1247            auth_route_config,
1248        )
1249        .await?;
1250        if let CodexAuth::PersonalAccessToken(auth) = &auth {
1251            ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, auth)?;
1252        }
1253        return Ok(Some(auth));
1254    }
1255
1256    if let Some(access_token) = read_codex_access_token_from_env() {
1257        return match classify_codex_access_token(&access_token) {
1258            CodexAccessToken::PersonalAccessToken(access_token) => {
1259                let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?;
1260                ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?;
1261                Ok(Some(CodexAuth::PersonalAccessToken(auth)))
1262            }
1263            CodexAccessToken::AgentIdentityJwt(jwt) => {
1264                CodexAuth::from_agent_identity_jwt_with_authapi_base_url(
1265                    jwt,
1266                    chatgpt_base_url,
1267                    require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?,
1268                    auth_route_config,
1269                )
1270            }
1271            .await
1272            .map(Some),
1273        };
1274    }
1275
1276    // If the caller explicitly requested ephemeral auth, there is no persisted fallback.
1277    if auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral {
1278        return Ok(None);
1279    }
1280
1281    // Fall back to the configured persistent store (file/keyring/auto) for managed auth.
1282    let storage = create_auth_storage(
1283        codex_home.to_path_buf(),
1284        auth_credentials_store_mode,
1285        keyring_backend_kind,
1286    );
1287    let auth_dot_json = match storage.load()? {
1288        Some(auth) => auth,
1289        None => return Ok(None),
1290    };
1291
1292    let auth = CodexAuth::from_auth_dot_json(
1293        codex_home,
1294        auth_dot_json,
1295        auth_credentials_store_mode,
1296        chatgpt_base_url,
1297        keyring_backend_kind,
1298        agent_identity_authapi_base_url,
1299        auth_route_config,
1300    )
1301    .await?;
1302    if let CodexAuth::PersonalAccessToken(auth) = &auth {
1303        ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, auth)?;
1304    }
1305    Ok(Some(auth))
1306}
1307
1308// Persist refreshed tokens into auth storage and update last_refresh.
1309fn persist_tokens(
1310    storage: &Arc<dyn AuthStorageBackend>,
1311    id_token: Option<String>,
1312    access_token: Option<String>,
1313    refresh_token: Option<String>,
1314) -> std::io::Result<AuthDotJson> {
1315    let mut auth_dot_json = storage
1316        .load()?
1317        .ok_or(std::io::Error::other("Token data is not available."))?;
1318
1319    let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default);
1320    if let Some(id_token) = id_token {
1321        tokens.id_token = parse_chatgpt_jwt_claims(&id_token).map_err(std::io::Error::other)?;
1322    }
1323    if let Some(access_token) = access_token {
1324        tokens.access_token = access_token;
1325    }
1326    if let Some(refresh_token) = refresh_token {
1327        tokens.refresh_token = refresh_token;
1328    }
1329    auth_dot_json.last_refresh = Some(Utc::now());
1330    storage.save(&auth_dot_json)?;
1331    Ok(auth_dot_json)
1332}
1333
1334// Requests refreshed ChatGPT OAuth tokens from the auth service using a refresh token.
1335// The caller is responsible for persisting any returned tokens.
1336async fn request_chatgpt_token_refresh(
1337    refresh_token: String,
1338    client: &HttpClient,
1339) -> Result<RefreshResponse, RefreshTokenError> {
1340    let refresh_request = RefreshRequest {
1341        client_id: oauth_client_id(),
1342        grant_type: "refresh_token",
1343        refresh_token,
1344    };
1345    let endpoint = refresh_token_endpoint();
1346
1347    // Use shared client factory to include standard headers
1348    let response = client
1349        .post(endpoint.as_str())
1350        .header("Content-Type", "application/json")
1351        .json(&refresh_request)
1352        .send()
1353        .await
1354        .map_err(|err| RefreshTokenError::Transient(std::io::Error::other(err)))?;
1355
1356    let status = response.status();
1357    if status.is_success() {
1358        let refresh_response = response
1359            .json::<RefreshResponse>()
1360            .await
1361            .map_err(|err| RefreshTokenError::Transient(std::io::Error::other(err)))?;
1362        Ok(refresh_response)
1363    } else {
1364        let body = response.text().await.unwrap_or_default();
1365        tracing::error!("Failed to refresh token: {status}: {body}");
1366        let failed = classify_refresh_token_failure(&body);
1367        if status == StatusCode::UNAUTHORIZED || failed.reason != RefreshTokenFailedReason::Other {
1368            Err(RefreshTokenError::Permanent(failed))
1369        } else {
1370            let message = try_parse_error_message(&body);
1371            Err(RefreshTokenError::Transient(std::io::Error::other(
1372                format!("Failed to refresh token: {status}: {message}"),
1373            )))
1374        }
1375    }
1376}
1377
1378fn classify_refresh_token_failure(body: &str) -> RefreshTokenFailedError {
1379    let code = extract_refresh_token_error_code(body);
1380
1381    let normalized_code = code.as_deref().map(str::to_ascii_lowercase);
1382    let reason = match normalized_code.as_deref() {
1383        Some("refresh_token_expired") => RefreshTokenFailedReason::Expired,
1384        Some("refresh_token_reused") => RefreshTokenFailedReason::Exhausted,
1385        Some("refresh_token_invalidated") => RefreshTokenFailedReason::Revoked,
1386        _ => RefreshTokenFailedReason::Other,
1387    };
1388
1389    if reason == RefreshTokenFailedReason::Other {
1390        tracing::warn!(
1391            backend_code = normalized_code.as_deref(),
1392            backend_body = body,
1393            "Encountered unknown response while refreshing token"
1394        );
1395    }
1396
1397    let message = match reason {
1398        RefreshTokenFailedReason::Expired => REFRESH_TOKEN_EXPIRED_MESSAGE.to_string(),
1399        RefreshTokenFailedReason::Exhausted => REFRESH_TOKEN_REUSED_MESSAGE.to_string(),
1400        RefreshTokenFailedReason::Revoked => REFRESH_TOKEN_INVALIDATED_MESSAGE.to_string(),
1401        RefreshTokenFailedReason::Other => REFRESH_TOKEN_UNKNOWN_MESSAGE.to_string(),
1402    };
1403
1404    RefreshTokenFailedError::new(reason, message)
1405}
1406
1407fn extract_refresh_token_error_code(body: &str) -> Option<String> {
1408    if body.trim().is_empty() {
1409        return None;
1410    }
1411
1412    let Value::Object(map) = serde_json::from_str::<Value>(body).ok()? else {
1413        return None;
1414    };
1415
1416    if let Some(error_value) = map.get("error") {
1417        match error_value {
1418            Value::Object(obj) => {
1419                if let Some(code) = obj.get("code").and_then(Value::as_str) {
1420                    return Some(code.to_string());
1421                }
1422            }
1423            Value::String(code) => {
1424                return Some(code.to_string());
1425            }
1426            _ => {}
1427        }
1428    }
1429
1430    map.get("code").and_then(Value::as_str).map(str::to_string)
1431}
1432
1433#[derive(Serialize)]
1434struct RefreshRequest {
1435    client_id: String,
1436    grant_type: &'static str,
1437    refresh_token: String,
1438}
1439
1440#[derive(Deserialize, Clone)]
1441struct RefreshResponse {
1442    id_token: Option<String>,
1443    access_token: Option<String>,
1444    refresh_token: Option<String>,
1445}
1446
1447// Shared constant for token refresh (client id used for oauth token refresh flow)
1448pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
1449
1450pub fn oauth_client_id() -> String {
1451    std::env::var(CLIENT_ID_OVERRIDE_ENV_VAR)
1452        .ok()
1453        .filter(|client_id| !client_id.trim().is_empty())
1454        .unwrap_or_else(|| CLIENT_ID.to_string())
1455}
1456
1457fn refresh_token_endpoint() -> String {
1458    std::env::var(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR)
1459        .unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string())
1460}
1461
1462impl AuthDotJson {
1463    fn from_external_access_token(
1464        access_token: &str,
1465        chatgpt_account_id: &str,
1466        chatgpt_plan_type: Option<&str>,
1467    ) -> std::io::Result<Self> {
1468        let mut token_info =
1469            parse_chatgpt_jwt_claims(access_token).map_err(std::io::Error::other)?;
1470        token_info.chatgpt_account_id = Some(chatgpt_account_id.to_string());
1471        token_info.chatgpt_plan_type = chatgpt_plan_type
1472            .map(InternalPlanType::from_raw_value)
1473            .or(token_info.chatgpt_plan_type)
1474            .or(Some(InternalPlanType::Unknown("unknown".to_string())));
1475        let tokens = TokenData {
1476            id_token: token_info,
1477            access_token: access_token.to_string(),
1478            refresh_token: String::new(),
1479            account_id: Some(chatgpt_account_id.to_string()),
1480        };
1481
1482        Ok(Self {
1483            auth_mode: Some(AuthMode::ChatgptAuthTokens),
1484            openai_api_key: None,
1485            tokens: Some(tokens),
1486            last_refresh: Some(Utc::now()),
1487            agent_identity: None,
1488            personal_access_token: None,
1489            bedrock_api_key: None,
1490        })
1491    }
1492
1493    pub(super) fn resolved_mode(&self) -> AuthMode {
1494        if let Some(mode) = self.auth_mode {
1495            return mode;
1496        }
1497        if self.personal_access_token.is_some() {
1498            return AuthMode::PersonalAccessToken;
1499        }
1500        if self.bedrock_api_key.is_some() {
1501            return AuthMode::BedrockApiKey;
1502        }
1503        if self.openai_api_key.is_some() {
1504            return AuthMode::ApiKey;
1505        }
1506        AuthMode::Chatgpt
1507    }
1508
1509    fn storage_mode(
1510        &self,
1511        auth_credentials_store_mode: AuthCredentialsStoreMode,
1512    ) -> AuthCredentialsStoreMode {
1513        if self.resolved_mode() == AuthMode::ChatgptAuthTokens {
1514            AuthCredentialsStoreMode::Ephemeral
1515        } else {
1516            auth_credentials_store_mode
1517        }
1518    }
1519}
1520
1521/// Internal cached auth state.
1522#[derive(Clone)]
1523struct CachedAuth {
1524    auth: Option<CodexAuth>,
1525    /// Permanent refresh failure cached for the current auth snapshot so
1526    /// later refresh attempts for the same credentials fail fast without network.
1527    permanent_refresh_failure: Option<AuthScopedRefreshFailure>,
1528}
1529
1530#[derive(Clone)]
1531struct AuthScopedRefreshFailure {
1532    auth: CodexAuth,
1533    error: RefreshTokenFailedError,
1534}
1535
1536impl Debug for CachedAuth {
1537    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1538        f.debug_struct("CachedAuth")
1539            .field(
1540                "auth_mode",
1541                &self.auth.as_ref().map(CodexAuth::api_auth_mode),
1542            )
1543            .field(
1544                "permanent_refresh_failure",
1545                &self
1546                    .permanent_refresh_failure
1547                    .as_ref()
1548                    .map(|failure| failure.error.reason),
1549            )
1550            .finish()
1551    }
1552}
1553
1554enum UnauthorizedRecoveryStep {
1555    Reload,
1556    RefreshToken,
1557    ExternalRefresh,
1558    Done,
1559}
1560
1561enum ReloadOutcome {
1562    /// Reload was performed and the cached auth changed
1563    ReloadedChanged,
1564    /// Reload was performed and the cached auth remained the same
1565    ReloadedNoChange,
1566    /// Reload was skipped (missing or mismatched account id)
1567    Skipped,
1568}
1569
1570#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1571enum UnauthorizedRecoveryMode {
1572    Managed,
1573    External,
1574}
1575
1576// UnauthorizedRecovery is a state machine that handles an attempt to refresh the authentication when requests
1577// to API fail with 401 status code.
1578// The client calls next() every time it encounters a 401 error, one time per retry.
1579// For API key based authentication, we don't do anything and let the error bubble to the user.
1580//
1581// For ChatGPT based authentication, we:
1582// 1. Attempt to reload the auth data from disk. We only reload if the account id matches the one the current process is running as.
1583// 2. Attempt to refresh the token using OAuth token refresh flow.
1584// If after both steps the server still responds with 401 we let the error bubble to the user.
1585//
1586// For external auth sources, UnauthorizedRecovery retries once by asking the
1587// configured provider to refresh and caching the returned auth through the same
1588// path used by other auth sources.
1589pub struct UnauthorizedRecovery {
1590    manager: Arc<AuthManager>,
1591    step: UnauthorizedRecoveryStep,
1592    expected_account_id: Option<String>,
1593    mode: UnauthorizedRecoveryMode,
1594}
1595
1596#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1597pub struct UnauthorizedRecoveryStepResult {
1598    auth_state_changed: Option<bool>,
1599}
1600
1601impl UnauthorizedRecoveryStepResult {
1602    pub fn auth_state_changed(&self) -> Option<bool> {
1603        self.auth_state_changed
1604    }
1605}
1606
1607impl UnauthorizedRecovery {
1608    fn new(manager: Arc<AuthManager>) -> Self {
1609        let cached_auth = manager.auth_cached();
1610        let expected_account_id = cached_auth.as_ref().and_then(CodexAuth::get_account_id);
1611        let mode = if manager.has_external_auth() {
1612            UnauthorizedRecoveryMode::External
1613        } else {
1614            UnauthorizedRecoveryMode::Managed
1615        };
1616        let step = match mode {
1617            UnauthorizedRecoveryMode::Managed => UnauthorizedRecoveryStep::Reload,
1618            UnauthorizedRecoveryMode::External => UnauthorizedRecoveryStep::ExternalRefresh,
1619        };
1620        Self {
1621            manager,
1622            step,
1623            expected_account_id,
1624            mode,
1625        }
1626    }
1627
1628    pub fn has_next(&self) -> bool {
1629        if self.manager.has_external_api_key_auth() {
1630            return !matches!(self.step, UnauthorizedRecoveryStep::Done);
1631        }
1632
1633        if !self
1634            .manager
1635            .auth_cached()
1636            .as_ref()
1637            .is_some_and(CodexAuth::supports_unauthorized_recovery)
1638        {
1639            return false;
1640        }
1641
1642        if self.mode == UnauthorizedRecoveryMode::External && !self.manager.has_external_auth() {
1643            return false;
1644        }
1645
1646        !matches!(self.step, UnauthorizedRecoveryStep::Done)
1647    }
1648
1649    pub fn unavailable_reason(&self) -> &'static str {
1650        if self.manager.has_external_api_key_auth() {
1651            return if matches!(self.step, UnauthorizedRecoveryStep::Done) {
1652                "recovery_exhausted"
1653            } else {
1654                "ready"
1655            };
1656        }
1657
1658        if self
1659            .manager
1660            .auth_cached()
1661            .as_ref()
1662            .is_some_and(CodexAuth::is_personal_access_token_auth)
1663        {
1664            return "not_refreshable_auth";
1665        }
1666
1667        if !self
1668            .manager
1669            .auth_cached()
1670            .as_ref()
1671            .is_some_and(CodexAuth::supports_unauthorized_recovery)
1672        {
1673            return "not_chatgpt_auth";
1674        }
1675
1676        if self.mode == UnauthorizedRecoveryMode::External && !self.manager.has_external_auth() {
1677            return "no_external_auth";
1678        }
1679
1680        if matches!(self.step, UnauthorizedRecoveryStep::Done) {
1681            return "recovery_exhausted";
1682        }
1683
1684        "ready"
1685    }
1686
1687    pub fn mode_name(&self) -> &'static str {
1688        match self.mode {
1689            UnauthorizedRecoveryMode::Managed => "managed",
1690            UnauthorizedRecoveryMode::External => "external",
1691        }
1692    }
1693
1694    pub fn step_name(&self) -> &'static str {
1695        match self.step {
1696            UnauthorizedRecoveryStep::Reload => "reload",
1697            UnauthorizedRecoveryStep::RefreshToken => "refresh_token",
1698            UnauthorizedRecoveryStep::ExternalRefresh => "external_refresh",
1699            UnauthorizedRecoveryStep::Done => "done",
1700        }
1701    }
1702
1703    pub async fn next(&mut self) -> Result<UnauthorizedRecoveryStepResult, RefreshTokenError> {
1704        if !self.has_next() {
1705            return Err(RefreshTokenError::Permanent(RefreshTokenFailedError::new(
1706                RefreshTokenFailedReason::Other,
1707                "No more recovery steps available.",
1708            )));
1709        }
1710
1711        match self.step {
1712            UnauthorizedRecoveryStep::Reload => {
1713                match self
1714                    .manager
1715                    .reload_if_account_id_matches(self.expected_account_id.as_deref())
1716                    .await
1717                {
1718                    ReloadOutcome::ReloadedChanged => {
1719                        self.step = UnauthorizedRecoveryStep::RefreshToken;
1720                        return Ok(UnauthorizedRecoveryStepResult {
1721                            auth_state_changed: Some(true),
1722                        });
1723                    }
1724                    ReloadOutcome::ReloadedNoChange => {
1725                        self.step = UnauthorizedRecoveryStep::RefreshToken;
1726                        return Ok(UnauthorizedRecoveryStepResult {
1727                            auth_state_changed: Some(false),
1728                        });
1729                    }
1730                    ReloadOutcome::Skipped => {
1731                        self.step = UnauthorizedRecoveryStep::Done;
1732                        return Err(RefreshTokenError::Permanent(RefreshTokenFailedError::new(
1733                            RefreshTokenFailedReason::Other,
1734                            REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE.to_string(),
1735                        )));
1736                    }
1737                }
1738            }
1739            UnauthorizedRecoveryStep::RefreshToken => {
1740                self.manager.refresh_token_from_authority().await?;
1741                self.step = UnauthorizedRecoveryStep::Done;
1742                return Ok(UnauthorizedRecoveryStepResult {
1743                    auth_state_changed: Some(true),
1744                });
1745            }
1746            UnauthorizedRecoveryStep::ExternalRefresh => {
1747                self.manager.refresh_token_from_authority().await?;
1748                self.step = UnauthorizedRecoveryStep::Done;
1749                return Ok(UnauthorizedRecoveryStepResult {
1750                    auth_state_changed: Some(true),
1751                });
1752            }
1753            UnauthorizedRecoveryStep::Done => {}
1754        }
1755        Ok(UnauthorizedRecoveryStepResult {
1756            auth_state_changed: None,
1757        })
1758    }
1759}
1760
1761/// Central manager providing a single source of truth for auth.json derived
1762/// authentication data. It loads once (or on preference change) and then
1763/// hands out cloned `CodexAuth` values so the rest of the program has a
1764/// consistent snapshot.
1765///
1766/// External modifications to `auth.json` will NOT be observed until
1767/// `reload()` is called explicitly. This matches the design goal of avoiding
1768/// different parts of the program seeing inconsistent auth data mid‑run.
1769pub struct AuthManager {
1770    codex_home: PathBuf,
1771    inner: RwLock<CachedAuth>,
1772    auth_change_tx: watch::Sender<u64>,
1773    enable_codex_api_key_env: bool,
1774    auth_credentials_store_mode: AuthCredentialsStoreMode,
1775    keyring_backend_kind: AuthKeyringBackendKind,
1776    forced_chatgpt_workspace_id: RwLock<Option<Vec<String>>>,
1777    chatgpt_base_url: Option<String>,
1778    agent_identity_authapi_base_url: Option<String>,
1779    refresh_lock: Semaphore,
1780    agent_identity_lock: Semaphore,
1781    agent_identity_bootstrap_cooldown: Mutex<AgentIdentityBootstrapCooldown>,
1782    external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>,
1783    auth_route_config: AuthRouteConfig,
1784}
1785
1786/// Configuration view required to construct a shared [`AuthManager`].
1787///
1788/// Implementations should return the auth-related config values for the
1789/// already-resolved runtime configuration. The primary implementation is
1790/// `codex_core::config::Config`, but this trait keeps `codex-login` independent
1791/// from `codex-core`.
1792pub trait AuthManagerConfig {
1793    /// Returns the Codex home directory used for auth storage.
1794    fn codex_home(&self) -> PathBuf;
1795
1796    /// Returns the CLI auth credential storage mode for auth loading.
1797    fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode;
1798
1799    /// Returns the backend to use when CLI auth keyring storage is selected.
1800    fn auth_keyring_backend_kind(&self) -> AuthKeyringBackendKind;
1801
1802    /// Returns the workspace IDs that ChatGPT auth should be restricted to, if any.
1803    fn forced_chatgpt_workspace_id(&self) -> Option<Vec<String>>;
1804
1805    /// Returns the ChatGPT backend base URL used for first-party backend authorization.
1806    fn chatgpt_base_url(&self) -> String;
1807
1808    /// Returns route-selection settings for auth-owned clients.
1809    fn auth_route_config(&self) -> AuthRouteConfig;
1810}
1811
1812impl Debug for AuthManager {
1813    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1814        f.debug_struct("AuthManager")
1815            .field("codex_home", &self.codex_home)
1816            .field("inner", &self.inner)
1817            .field("enable_codex_api_key_env", &self.enable_codex_api_key_env)
1818            .field(
1819                "auth_credentials_store_mode",
1820                &self.auth_credentials_store_mode,
1821            )
1822            .field("keyring_backend_kind", &self.keyring_backend_kind)
1823            .field(
1824                "forced_chatgpt_workspace_id",
1825                &self.forced_chatgpt_workspace_id,
1826            )
1827            .field("chatgpt_base_url", &self.chatgpt_base_url)
1828            .field("auth_route_config", &self.auth_route_config)
1829            .field("has_external_auth", &self.has_external_auth())
1830            .finish_non_exhaustive()
1831    }
1832}
1833
1834fn default_agent_identity_authapi_base_url() -> Option<String> {
1835    agent_identity_authapi_base_url(/*chatgpt_base_url*/ None).ok()
1836}
1837
1838impl AuthManager {
1839    /// Create a new manager loading the initial auth using the provided
1840    /// preferred auth method. Errors loading auth are swallowed; `auth()` will
1841    /// simply return `None` in that case so callers can treat it as an
1842    /// unauthenticated state.
1843    pub async fn new(
1844        codex_home: PathBuf,
1845        enable_codex_api_key_env: bool,
1846        auth_credentials_store_mode: AuthCredentialsStoreMode,
1847        forced_chatgpt_workspace_id: Option<Vec<String>>,
1848        chatgpt_base_url: Option<String>,
1849        keyring_backend_kind: AuthKeyringBackendKind,
1850        auth_route_config: AuthRouteConfig,
1851    ) -> Self {
1852        let agent_identity_authapi_base_url =
1853            agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok();
1854        let managed_auth = load_auth(
1855            &codex_home,
1856            enable_codex_api_key_env,
1857            auth_credentials_store_mode,
1858            forced_chatgpt_workspace_id.as_deref(),
1859            chatgpt_base_url.as_deref(),
1860            keyring_backend_kind,
1861            agent_identity_authapi_base_url.as_deref(),
1862            Some(&auth_route_config),
1863        )
1864        .await
1865        .ok()
1866        .flatten();
1867        let (auth_change_tx, _auth_change_rx) = watch::channel(0);
1868        Self {
1869            codex_home,
1870            inner: RwLock::new(CachedAuth {
1871                auth: managed_auth,
1872                permanent_refresh_failure: None,
1873            }),
1874            auth_change_tx,
1875            enable_codex_api_key_env,
1876            auth_credentials_store_mode,
1877            keyring_backend_kind,
1878            forced_chatgpt_workspace_id: RwLock::new(forced_chatgpt_workspace_id),
1879            chatgpt_base_url,
1880            agent_identity_authapi_base_url,
1881            refresh_lock: Semaphore::new(/*permits*/ 1),
1882            agent_identity_lock: Semaphore::new(/*permits*/ 1),
1883            agent_identity_bootstrap_cooldown: Mutex::default(),
1884            external_auth: RwLock::new(None),
1885            auth_route_config,
1886        }
1887    }
1888
1889    /// Create an AuthManager with a specific CodexAuth, for testing only.
1890    pub fn from_auth_for_testing(auth: CodexAuth) -> Arc<Self> {
1891        let cached = CachedAuth {
1892            auth: Some(auth),
1893            permanent_refresh_failure: None,
1894        };
1895        let (auth_change_tx, _auth_change_rx) = watch::channel(0);
1896
1897        Arc::new(Self {
1898            codex_home: PathBuf::from("non-existent"),
1899            inner: RwLock::new(cached),
1900            auth_change_tx,
1901            enable_codex_api_key_env: false,
1902            auth_credentials_store_mode: AuthCredentialsStoreMode::File,
1903            keyring_backend_kind: AuthKeyringBackendKind::default(),
1904            forced_chatgpt_workspace_id: RwLock::new(None),
1905            chatgpt_base_url: None,
1906            agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
1907            refresh_lock: Semaphore::new(/*permits*/ 1),
1908            agent_identity_lock: Semaphore::new(/*permits*/ 1),
1909            agent_identity_bootstrap_cooldown: Mutex::default(),
1910            external_auth: RwLock::new(None),
1911            auth_route_config: crate::test_support::transport_default_auth_route_config(),
1912        })
1913    }
1914
1915    /// Create an AuthManager with a specific CodexAuth and codex home, for testing only.
1916    pub fn from_auth_for_testing_with_home(auth: CodexAuth, codex_home: PathBuf) -> Arc<Self> {
1917        let cached = CachedAuth {
1918            auth: Some(auth),
1919            permanent_refresh_failure: None,
1920        };
1921        let (auth_change_tx, _auth_change_rx) = watch::channel(0);
1922        Arc::new(Self {
1923            codex_home,
1924            inner: RwLock::new(cached),
1925            auth_change_tx,
1926            enable_codex_api_key_env: false,
1927            auth_credentials_store_mode: AuthCredentialsStoreMode::File,
1928            keyring_backend_kind: AuthKeyringBackendKind::default(),
1929            forced_chatgpt_workspace_id: RwLock::new(None),
1930            chatgpt_base_url: None,
1931            agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
1932            refresh_lock: Semaphore::new(/*permits*/ 1),
1933            agent_identity_lock: Semaphore::new(/*permits*/ 1),
1934            agent_identity_bootstrap_cooldown: Mutex::default(),
1935            external_auth: RwLock::new(None),
1936            auth_route_config: crate::test_support::transport_default_auth_route_config(),
1937        })
1938    }
1939
1940    /// Create an AuthManager with a specific CodexAuth and Agent Identity AuthAPI base URL, for testing only.
1941    #[doc(hidden)]
1942    pub fn from_auth_for_testing_with_agent_identity_authapi_base_url(
1943        auth: CodexAuth,
1944        agent_identity_authapi_base_url: String,
1945    ) -> Arc<Self> {
1946        let cached = CachedAuth {
1947            auth: Some(auth),
1948            permanent_refresh_failure: None,
1949        };
1950        let (auth_change_tx, _auth_change_rx) = watch::channel(0);
1951        Arc::new(Self {
1952            codex_home: PathBuf::from("non-existent"),
1953            inner: RwLock::new(cached),
1954            auth_change_tx,
1955            enable_codex_api_key_env: false,
1956            auth_credentials_store_mode: AuthCredentialsStoreMode::File,
1957            keyring_backend_kind: AuthKeyringBackendKind::default(),
1958            forced_chatgpt_workspace_id: RwLock::new(None),
1959            chatgpt_base_url: None,
1960            agent_identity_authapi_base_url: Some(
1961                agent_identity_authapi_base_url
1962                    .trim_end_matches('/')
1963                    .to_string(),
1964            ),
1965            refresh_lock: Semaphore::new(/*permits*/ 1),
1966            agent_identity_lock: Semaphore::new(/*permits*/ 1),
1967            agent_identity_bootstrap_cooldown: Mutex::default(),
1968            external_auth: RwLock::new(None),
1969            auth_route_config: crate::test_support::transport_default_auth_route_config(),
1970        })
1971    }
1972
1973    pub fn external_bearer_only(config: ModelProviderAuthInfo) -> Arc<Self> {
1974        let (auth_change_tx, _auth_change_rx) = watch::channel(0);
1975        Arc::new(Self {
1976            codex_home: PathBuf::from("non-existent"),
1977            inner: RwLock::new(CachedAuth {
1978                auth: None,
1979                permanent_refresh_failure: None,
1980            }),
1981            auth_change_tx,
1982            enable_codex_api_key_env: false,
1983            auth_credentials_store_mode: AuthCredentialsStoreMode::File,
1984            keyring_backend_kind: AuthKeyringBackendKind::default(),
1985            forced_chatgpt_workspace_id: RwLock::new(None),
1986            chatgpt_base_url: None,
1987            agent_identity_authapi_base_url: default_agent_identity_authapi_base_url(),
1988            refresh_lock: Semaphore::new(/*permits*/ 1),
1989            agent_identity_lock: Semaphore::new(/*permits*/ 1),
1990            agent_identity_bootstrap_cooldown: Mutex::default(),
1991            external_auth: RwLock::new(Some(
1992                Arc::new(BearerTokenRefresher::new(config)) as Arc<dyn ExternalAuth>
1993            )),
1994            // External bearer auth refreshes by running the provider's command and never makes
1995            // auth-owned HTTP requests, so this route is intentionally inert.
1996            auth_route_config: AuthRouteConfig::from_http_client_factory(HttpClientFactory::new(
1997                OutboundProxyPolicy::ReqwestDefault,
1998            )),
1999        })
2000    }
2001
2002    /// Current cached auth (clone) without attempting a refresh.
2003    pub fn auth_cached(&self) -> Option<CodexAuth> {
2004        self.inner
2005            .read()
2006            .ok()
2007            .and_then(|cached| cached.auth.clone())
2008    }
2009
2010    /// Subscribes to cached auth changes that can affect request recovery.
2011    pub fn auth_change_receiver(&self) -> watch::Receiver<u64> {
2012        self.auth_change_tx.subscribe()
2013    }
2014
2015    pub fn refresh_failure_for_auth(&self, auth: &CodexAuth) -> Option<RefreshTokenFailedError> {
2016        self.inner.read().ok().and_then(|cached| {
2017            cached
2018                .permanent_refresh_failure
2019                .as_ref()
2020                .filter(|failure| Self::auths_equal_for_refresh(Some(auth), Some(&failure.auth)))
2021                .map(|failure| failure.error.clone())
2022        })
2023    }
2024
2025    /// Current cached auth (clone). May be `None` if not logged in or load failed.
2026    /// For managed ChatGPT auth that needs a proactive refresh, first performs
2027    /// a guarded reload and then refreshes only if the on-disk auth is unchanged.
2028    #[instrument(level = "trace", skip_all)]
2029    pub async fn auth(&self) -> Option<CodexAuth> {
2030        if self.has_external_auth() {
2031            self.reload().await;
2032            return self.auth_cached();
2033        }
2034
2035        let auth = self.auth_cached()?;
2036        if Self::should_refresh_proactively(&auth)
2037            && let Err(err) = self.refresh_token().await
2038        {
2039            tracing::error!("Failed to refresh token: {}", err);
2040            return Some(auth);
2041        }
2042        self.auth_cached()
2043    }
2044
2045    pub async fn agent_identity_auth(
2046        &self,
2047        policy: AgentIdentityAuthPolicy,
2048        session_source: SessionSource,
2049    ) -> std::io::Result<Option<AgentIdentityAuth>> {
2050        let Some(auth) = self.auth().await else {
2051            return Ok(None);
2052        };
2053        if policy == AgentIdentityAuthPolicy::ChatGptAuth && matches!(auth, CodexAuth::Chatgpt(_)) {
2054            let _bootstrap_permit = self
2055                .agent_identity_lock
2056                .acquire()
2057                .await
2058                .map_err(std::io::Error::other)?;
2059            let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id();
2060            let cooldown_key = ManagedChatGptAgentIdentityBinding::from_auth(
2061                &auth,
2062                forced_chatgpt_workspace_id.clone(),
2063            )
2064            .and_then(|binding| {
2065                self.agent_identity_authapi_base_url
2066                    .as_ref()
2067                    .map(|base_url| (binding.account_id, base_url.clone()))
2068            });
2069            if let Some((account_id, authapi_base_url)) = cooldown_key.as_ref()
2070                && let Ok(mut cooldown) = self.agent_identity_bootstrap_cooldown.lock()
2071                && let Some(error) =
2072                    cooldown.error_for(account_id, authapi_base_url, Instant::now())
2073            {
2074                tracing::warn!("agent identity bootstrap retry suppressed during shared cooldown");
2075                return Err(std::io::Error::other(error));
2076            }
2077
2078            let result = auth
2079                .agent_identity_auth(
2080                    policy,
2081                    self.agent_identity_authapi_base_url.as_deref(),
2082                    forced_chatgpt_workspace_id,
2083                    Some(&self.auth_route_config),
2084                    session_source,
2085                )
2086                .await;
2087            if let Ok(mut cooldown) = self.agent_identity_bootstrap_cooldown.lock() {
2088                if let (Err(err), Some((account_id, authapi_base_url))) = (&result, cooldown_key)
2089                    && let Some(error) = AgentIdentityAuthError::bootstrap_unavailable(err).cloned()
2090                {
2091                    cooldown.record_failure(account_id, authapi_base_url, error, Instant::now());
2092                } else {
2093                    cooldown.clear();
2094                }
2095            }
2096            return result;
2097        }
2098        auth.agent_identity_auth(
2099            policy,
2100            self.agent_identity_authapi_base_url.as_deref(),
2101            self.forced_chatgpt_workspace_id(),
2102            Some(&self.auth_route_config),
2103            session_source,
2104        )
2105        .await
2106    }
2107
2108    /// Reloads auth from the active source. Returns whether the auth value changed.
2109    pub async fn reload(&self) -> bool {
2110        tracing::info!("Reloading auth");
2111        let new_auth = self.load_auth().await;
2112        self.set_cached_auth(new_auth)
2113    }
2114
2115    async fn reload_if_account_id_matches(
2116        &self,
2117        expected_account_id: Option<&str>,
2118    ) -> ReloadOutcome {
2119        let expected_account_id = match expected_account_id {
2120            Some(account_id) => account_id,
2121            None => {
2122                tracing::info!("Skipping auth reload because no account id is available.");
2123                return ReloadOutcome::Skipped;
2124            }
2125        };
2126
2127        let new_auth = self.load_auth().await;
2128        let new_account_id = new_auth.as_ref().and_then(CodexAuth::get_account_id);
2129
2130        if new_account_id.as_deref() != Some(expected_account_id) {
2131            let found_account_id = new_account_id.as_deref().unwrap_or("unknown");
2132            tracing::info!(
2133                "Skipping auth reload due to account id mismatch (expected: {expected_account_id}, found: {found_account_id})"
2134            );
2135            return ReloadOutcome::Skipped;
2136        }
2137
2138        tracing::info!("Reloading auth for account {expected_account_id}");
2139        let cached_before_reload = self.auth_cached();
2140        let auth_changed =
2141            !Self::auths_equal_for_refresh(cached_before_reload.as_ref(), new_auth.as_ref());
2142        self.set_cached_auth(new_auth);
2143        if auth_changed {
2144            ReloadOutcome::ReloadedChanged
2145        } else {
2146            ReloadOutcome::ReloadedNoChange
2147        }
2148    }
2149
2150    fn auths_equal_for_refresh(a: Option<&CodexAuth>, b: Option<&CodexAuth>) -> bool {
2151        match (a, b) {
2152            (None, None) => true,
2153            (Some(a), Some(b)) => match (a.api_auth_mode(), b.api_auth_mode()) {
2154                (AuthMode::ApiKey, AuthMode::ApiKey) => a.api_key() == b.api_key(),
2155                (AuthMode::Chatgpt, AuthMode::Chatgpt)
2156                | (AuthMode::ChatgptAuthTokens, AuthMode::ChatgptAuthTokens) => {
2157                    a.get_current_auth_json() == b.get_current_auth_json()
2158                }
2159                (AuthMode::Headers, AuthMode::Headers) => a == b,
2160                (AuthMode::AgentIdentity, AuthMode::AgentIdentity) => match (a, b) {
2161                    (CodexAuth::AgentIdentity(a), CodexAuth::AgentIdentity(b)) => {
2162                        a.record() == b.record()
2163                    }
2164                    _ => false,
2165                },
2166                (AuthMode::PersonalAccessToken, AuthMode::PersonalAccessToken) => a == b,
2167                (AuthMode::BedrockApiKey, AuthMode::BedrockApiKey) => a == b,
2168                _ => false,
2169            },
2170            _ => false,
2171        }
2172    }
2173
2174    fn auths_equal(a: Option<&CodexAuth>, b: Option<&CodexAuth>) -> bool {
2175        match (a, b) {
2176            (None, None) => true,
2177            (Some(a), Some(b)) => a == b,
2178            _ => false,
2179        }
2180    }
2181
2182    /// Records a permanent refresh failure only if the failed refresh was
2183    /// attempted against the auth snapshot that is still cached.
2184    fn record_permanent_refresh_failure_if_unchanged(
2185        &self,
2186        attempted_auth: &CodexAuth,
2187        error: &RefreshTokenFailedError,
2188    ) {
2189        if let Ok(mut guard) = self.inner.write() {
2190            let current_auth_matches =
2191                Self::auths_equal_for_refresh(Some(attempted_auth), guard.auth.as_ref());
2192            if current_auth_matches {
2193                guard.permanent_refresh_failure = Some(AuthScopedRefreshFailure {
2194                    auth: attempted_auth.clone(),
2195                    error: error.clone(),
2196                });
2197            }
2198        }
2199    }
2200
2201    async fn load_auth(&self) -> Option<CodexAuth> {
2202        if let Some(external_auth) = self.external_auth() {
2203            return match self.resolve_external_auth(&external_auth).await {
2204                Ok(auth) => Some(auth),
2205                Err(err) => {
2206                    tracing::error!("Failed to resolve external auth: {err}");
2207                    None
2208                }
2209            };
2210        }
2211
2212        let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id();
2213        load_auth(
2214            &self.codex_home,
2215            self.enable_codex_api_key_env,
2216            self.auth_credentials_store_mode,
2217            forced_chatgpt_workspace_id.as_deref(),
2218            self.chatgpt_base_url.as_deref(),
2219            self.keyring_backend_kind,
2220            self.agent_identity_authapi_base_url.as_deref(),
2221            Some(&self.auth_route_config),
2222        )
2223        .await
2224        .ok()
2225        .flatten()
2226    }
2227
2228    fn set_cached_auth(&self, new_auth: Option<CodexAuth>) -> bool {
2229        if let Ok(mut guard) = self.inner.write() {
2230            let previous = guard.auth.as_ref();
2231            let changed = !AuthManager::auths_equal(previous, new_auth.as_ref());
2232            let auth_changed_for_refresh =
2233                !Self::auths_equal_for_refresh(previous, new_auth.as_ref());
2234            if auth_changed_for_refresh {
2235                guard.permanent_refresh_failure = None;
2236            }
2237            tracing::info!("Reloaded auth, changed: {changed}");
2238            guard.auth = new_auth;
2239            if auth_changed_for_refresh {
2240                self.auth_change_tx.send_modify(|revision| *revision += 1);
2241            }
2242            changed
2243        } else {
2244            false
2245        }
2246    }
2247
2248    pub async fn set_external_auth(
2249        &self,
2250        external_auth: Arc<dyn ExternalAuth>,
2251    ) -> Result<(), RefreshTokenError> {
2252        let auth = self.resolve_external_auth(&external_auth).await?;
2253        *self.external_auth.write().map_err(|_| {
2254            RefreshTokenError::Transient(std::io::Error::other("external auth lock is poisoned"))
2255        })? = Some(external_auth);
2256        self.commit_external_auth(auth)
2257    }
2258
2259    pub fn clear_external_auth(&self) {
2260        if let Ok(mut external_auth) = self.external_auth.write()
2261            && external_auth.take().is_some()
2262        {
2263            self.set_cached_auth(/*new_auth*/ None);
2264        }
2265    }
2266
2267    pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option<Vec<String>>) {
2268        if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write()
2269            && *guard != workspace_id
2270        {
2271            *guard = workspace_id;
2272        }
2273    }
2274
2275    pub fn forced_chatgpt_workspace_id(&self) -> Option<Vec<String>> {
2276        self.forced_chatgpt_workspace_id
2277            .read()
2278            .ok()
2279            .and_then(|guard| guard.clone())
2280    }
2281
2282    pub fn has_external_auth(&self) -> bool {
2283        self.external_auth().is_some()
2284    }
2285
2286    pub fn is_external_chatgpt_auth_active(&self) -> bool {
2287        self.auth_cached()
2288            .as_ref()
2289            .is_some_and(CodexAuth::is_external_chatgpt_tokens)
2290    }
2291
2292    pub fn codex_api_key_env_enabled(&self) -> bool {
2293        self.enable_codex_api_key_env
2294    }
2295
2296    /// Convenience constructor returning an `Arc` wrapper.
2297    pub async fn shared(
2298        codex_home: PathBuf,
2299        enable_codex_api_key_env: bool,
2300        auth_credentials_store_mode: AuthCredentialsStoreMode,
2301        forced_chatgpt_workspace_id: Option<Vec<String>>,
2302        chatgpt_base_url: Option<String>,
2303        keyring_backend_kind: AuthKeyringBackendKind,
2304        auth_route_config: AuthRouteConfig,
2305    ) -> Arc<Self> {
2306        Arc::new(
2307            Self::new(
2308                codex_home,
2309                enable_codex_api_key_env,
2310                auth_credentials_store_mode,
2311                forced_chatgpt_workspace_id,
2312                chatgpt_base_url,
2313                keyring_backend_kind,
2314                auth_route_config,
2315            )
2316            .await,
2317        )
2318    }
2319
2320    /// Convenience constructor returning an `Arc` wrapper from resolved config.
2321    pub async fn shared_from_config(
2322        config: &impl AuthManagerConfig,
2323        enable_codex_api_key_env: bool,
2324    ) -> Arc<Self> {
2325        Self::shared(
2326            config.codex_home(),
2327            enable_codex_api_key_env,
2328            config.cli_auth_credentials_store_mode(),
2329            config.forced_chatgpt_workspace_id(),
2330            Some(config.chatgpt_base_url()),
2331            config.auth_keyring_backend_kind(),
2332            config.auth_route_config(),
2333        )
2334        .await
2335    }
2336
2337    pub fn unauthorized_recovery(self: &Arc<Self>) -> UnauthorizedRecovery {
2338        UnauthorizedRecovery::new(Arc::clone(self))
2339    }
2340
2341    fn external_auth(&self) -> Option<Arc<dyn ExternalAuth>> {
2342        self.external_auth
2343            .read()
2344            .ok()
2345            .and_then(|external_auth| external_auth.as_ref().map(Arc::clone))
2346    }
2347
2348    fn has_external_api_key_auth(&self) -> bool {
2349        self.has_external_auth()
2350            && self
2351                .auth_cached()
2352                .as_ref()
2353                .is_some_and(CodexAuth::is_api_key_auth)
2354    }
2355
2356    async fn resolve_external_auth(
2357        &self,
2358        external_auth: &Arc<dyn ExternalAuth>,
2359    ) -> Result<CodexAuth, RefreshTokenError> {
2360        let auth = external_auth
2361            .resolve()
2362            .await
2363            .map_err(RefreshTokenError::Transient)?;
2364        self.validate_external_auth(&auth)?;
2365        Ok(auth)
2366    }
2367
2368    /// Attempt to refresh the token by first performing a guarded reload from
2369    /// the active auth source. If the loaded token differs from the cached token,
2370    /// we can assume that the source already refreshed it. Otherwise, ask the
2371    /// token authority to refresh.
2372    pub async fn refresh_token(&self) -> Result<(), RefreshTokenError> {
2373        let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| {
2374            RefreshTokenError::Permanent(RefreshTokenFailedError::new(
2375                RefreshTokenFailedReason::Other,
2376                REFRESH_TOKEN_UNKNOWN_MESSAGE.to_string(),
2377            ))
2378        })?;
2379        let auth_before_reload = self.auth_cached();
2380        if auth_before_reload
2381            .as_ref()
2382            .is_some_and(|auth| auth.is_api_key_auth() || auth.is_personal_access_token_auth())
2383        {
2384            return Ok(());
2385        }
2386        let expected_account_id = auth_before_reload
2387            .as_ref()
2388            .and_then(CodexAuth::get_account_id);
2389
2390        match self
2391            .reload_if_account_id_matches(expected_account_id.as_deref())
2392            .await
2393        {
2394            ReloadOutcome::ReloadedChanged => {
2395                tracing::info!("Skipping token refresh because auth changed after guarded reload.");
2396                Ok(())
2397            }
2398            ReloadOutcome::ReloadedNoChange => self.refresh_token_from_authority_impl().await,
2399            ReloadOutcome::Skipped => {
2400                Err(RefreshTokenError::Permanent(RefreshTokenFailedError::new(
2401                    RefreshTokenFailedReason::Other,
2402                    REFRESH_TOKEN_ACCOUNT_MISMATCH_MESSAGE.to_string(),
2403                )))
2404            }
2405        }
2406    }
2407
2408    /// Attempt to refresh the current auth token from the authority that issued
2409    /// it and update the shared cache. If the token refresh fails, returns the
2410    /// error to the caller.
2411    pub async fn refresh_token_from_authority(&self) -> Result<(), RefreshTokenError> {
2412        let _refresh_guard = self.refresh_lock.acquire().await.map_err(|_| {
2413            RefreshTokenError::Permanent(RefreshTokenFailedError::new(
2414                RefreshTokenFailedReason::Other,
2415                REFRESH_TOKEN_UNKNOWN_MESSAGE.to_string(),
2416            ))
2417        })?;
2418        self.refresh_token_from_authority_impl().await
2419    }
2420
2421    async fn refresh_token_from_authority_impl(&self) -> Result<(), RefreshTokenError> {
2422        tracing::info!("Refreshing token");
2423
2424        let auth = match self.auth_cached() {
2425            Some(auth) => auth,
2426            None => return Ok(()),
2427        };
2428        if let Some(error) = self.refresh_failure_for_auth(&auth) {
2429            return Err(RefreshTokenError::Permanent(error));
2430        }
2431
2432        let attempted_auth = auth.clone();
2433        let result = if self.has_external_auth() {
2434            self.refresh_external_auth(ExternalAuthRefreshReason::Unauthorized)
2435                .await
2436        } else {
2437            match auth {
2438                CodexAuth::Chatgpt(chatgpt_auth) => {
2439                    let token_data = chatgpt_auth.current_token_data().ok_or_else(|| {
2440                        RefreshTokenError::Transient(std::io::Error::other(
2441                            "Token data is not available.",
2442                        ))
2443                    })?;
2444                    self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token)
2445                        .await
2446                }
2447                CodexAuth::ApiKey(_)
2448                | CodexAuth::ChatgptAuthTokens(_)
2449                | CodexAuth::Headers(_)
2450                | CodexAuth::AgentIdentity(_)
2451                | CodexAuth::PersonalAccessToken(_)
2452                | CodexAuth::BedrockApiKey(_) => Ok(()),
2453            }
2454        };
2455        if let Err(RefreshTokenError::Permanent(error)) = &result {
2456            self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error);
2457        }
2458        result
2459    }
2460
2461    /// Log out by deleting the on‑disk auth.json (if present). Returns Ok(true)
2462    /// if a file was removed, Ok(false) if no auth file existed. On success,
2463    /// reloads the in‑memory auth cache so callers immediately observe the
2464    /// unauthenticated state.
2465    pub async fn logout(&self) -> std::io::Result<bool> {
2466        let removed = logout_all_stores(
2467            &self.codex_home,
2468            self.auth_credentials_store_mode,
2469            self.keyring_backend_kind,
2470        )?;
2471        // Always reload to clear any cached auth (even if file absent).
2472        self.clear_external_auth();
2473        self.reload().await;
2474        Ok(removed)
2475    }
2476
2477    pub async fn logout_with_revoke(&self) -> std::io::Result<bool> {
2478        let auth_dot_json = self
2479            .auth_cached()
2480            .and_then(|auth| auth.get_current_auth_json());
2481        if let Err(err) =
2482            revoke_auth_tokens(auth_dot_json.as_ref(), Some(&self.auth_route_config)).await
2483        {
2484            tracing::warn!("failed to revoke auth tokens during logout: {err}");
2485        }
2486        let result = logout_all_stores(
2487            &self.codex_home,
2488            self.auth_credentials_store_mode,
2489            self.keyring_backend_kind,
2490        )?;
2491        // Always reload to clear any cached auth (even if file absent).
2492        self.clear_external_auth();
2493        self.reload().await;
2494        Ok(result)
2495    }
2496
2497    /// Returns the precise kind of credentials backing the current authentication.
2498    pub fn get_api_auth_mode(&self) -> Option<AuthMode> {
2499        self.auth_cached().as_ref().map(CodexAuth::api_auth_mode)
2500    }
2501
2502    /// Returns the effective backend auth mode for the current authentication.
2503    pub fn auth_mode(&self) -> Option<AuthMode> {
2504        self.auth_cached().as_ref().map(CodexAuth::auth_mode)
2505    }
2506
2507    pub fn current_auth_uses_codex_backend(&self) -> bool {
2508        self.get_api_auth_mode()
2509            .is_some_and(AuthMode::uses_codex_backend)
2510    }
2511
2512    fn should_refresh_proactively(auth: &CodexAuth) -> bool {
2513        let chatgpt_auth = match auth {
2514            CodexAuth::Chatgpt(chatgpt_auth) => chatgpt_auth,
2515            _ => return false,
2516        };
2517
2518        let auth_dot_json = match chatgpt_auth.current_auth_json() {
2519            Some(auth_dot_json) => auth_dot_json,
2520            None => return false,
2521        };
2522        if let Some(tokens) = auth_dot_json.tokens.as_ref()
2523            && let Ok(Some(expires_at)) = parse_jwt_expiration(&tokens.access_token)
2524        {
2525            return expires_at
2526                <= Utc::now()
2527                    + chrono::Duration::minutes(CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES);
2528        }
2529        let last_refresh = match auth_dot_json.last_refresh {
2530            Some(last_refresh) => last_refresh,
2531            None => return false,
2532        };
2533        last_refresh < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL)
2534    }
2535
2536    async fn refresh_external_auth(
2537        &self,
2538        reason: ExternalAuthRefreshReason,
2539    ) -> Result<(), RefreshTokenError> {
2540        let Some(external_auth) = self.external_auth() else {
2541            return Err(RefreshTokenError::Transient(std::io::Error::other(
2542                "external auth is not configured",
2543            )));
2544        };
2545        let previous_account_id = self
2546            .auth_cached()
2547            .as_ref()
2548            .and_then(CodexAuth::get_account_id);
2549        let context = ExternalAuthRefreshContext {
2550            reason,
2551            previous_account_id,
2552        };
2553
2554        let refreshed = external_auth
2555            .refresh(context)
2556            .await
2557            .map_err(RefreshTokenError::Transient)?;
2558        self.validate_external_auth(&refreshed)?;
2559        self.commit_external_auth(refreshed)?;
2560        Ok(())
2561    }
2562
2563    fn commit_external_auth(&self, auth: CodexAuth) -> Result<(), RefreshTokenError> {
2564        if auth.is_external_chatgpt_tokens() {
2565            let auth_dot_json = auth.get_current_auth_json().ok_or_else(|| {
2566                RefreshTokenError::Transient(std::io::Error::other(
2567                    "external ChatGPT auth tokens are missing auth state",
2568                ))
2569            })?;
2570            // App/connectors paths still construct independent AuthManagers from Config. Mirror
2571            // external ChatGPT auth into the process-local store so those managers see it too.
2572            save_auth(
2573                &self.codex_home,
2574                &auth_dot_json,
2575                AuthCredentialsStoreMode::Ephemeral,
2576                AuthKeyringBackendKind::default(),
2577            )
2578            .map_err(RefreshTokenError::Transient)?;
2579        }
2580
2581        self.set_cached_auth(Some(auth));
2582        Ok(())
2583    }
2584
2585    fn validate_external_auth(&self, auth: &CodexAuth) -> Result<(), RefreshTokenError> {
2586        if let Some(account_id) = auth.get_account_id()
2587            && let Some(expected_workspace_ids) = self.forced_chatgpt_workspace_id()
2588            && !expected_workspace_ids.contains(&account_id)
2589        {
2590            return Err(RefreshTokenError::Transient(std::io::Error::other(
2591                format!(
2592                    "external auth returned workspace {account_id:?}, expected one of {expected_workspace_ids:?}"
2593                ),
2594            )));
2595        }
2596        Ok(())
2597    }
2598
2599    // Refreshes ChatGPT OAuth tokens, persists the updated auth state, and
2600    // reloads the in-memory cache so callers immediately observe new tokens.
2601    async fn refresh_and_persist_chatgpt_token(
2602        &self,
2603        auth: &ChatgptAuth,
2604        refresh_token: String,
2605    ) -> Result<(), RefreshTokenError> {
2606        let refresh_response = request_chatgpt_token_refresh(refresh_token, auth.client()).await?;
2607
2608        persist_tokens(
2609            auth.storage(),
2610            refresh_response.id_token,
2611            refresh_response.access_token,
2612            refresh_response.refresh_token,
2613        )
2614        .map_err(RefreshTokenError::from)?;
2615        self.reload().await;
2616
2617        Ok(())
2618    }
2619}
2620
2621#[cfg(test)]
2622#[path = "auth_tests.rs"]
2623mod tests;