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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum AgentIdentityAuthPolicy {
86 JwtOnly,
88 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
217pub trait ExternalAuth: Send + Sync {
221 fn resolve(&self) -> ExternalAuthFuture<'_, CodexAuth>;
223
224 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 false,
363 auth_credentials_store_mode,
364 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
864pub 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
906pub 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
930pub 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 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
989pub 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
1009pub 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
1024pub 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
1053pub 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 true,
1071 config.auth_credentials_store_mode,
1072 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 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 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 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 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 auth_credentials_store_mode == AuthCredentialsStoreMode::Ephemeral {
1278 return Ok(None);
1279 }
1280
1281 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
1308fn 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
1334async 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 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
1447pub 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#[derive(Clone)]
1523struct CachedAuth {
1524 auth: Option<CodexAuth>,
1525 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 ReloadedChanged,
1564 ReloadedNoChange,
1566 Skipped,
1568}
1569
1570#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1571enum UnauthorizedRecoveryMode {
1572 Managed,
1573 External,
1574}
1575
1576pub 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
1761pub 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
1786pub trait AuthManagerConfig {
1793 fn codex_home(&self) -> PathBuf;
1795
1796 fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode;
1798
1799 fn auth_keyring_backend_kind(&self) -> AuthKeyringBackendKind;
1801
1802 fn forced_chatgpt_workspace_id(&self) -> Option<Vec<String>>;
1804
1805 fn chatgpt_base_url(&self) -> String;
1807
1808 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(None).ok()
1836}
1837
1838impl AuthManager {
1839 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(1),
1882 agent_identity_lock: Semaphore::new(1),
1883 agent_identity_bootstrap_cooldown: Mutex::default(),
1884 external_auth: RwLock::new(None),
1885 auth_route_config,
1886 }
1887 }
1888
1889 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(1),
1908 agent_identity_lock: Semaphore::new(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 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(1),
1933 agent_identity_lock: Semaphore::new(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 #[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(1),
1966 agent_identity_lock: Semaphore::new(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(1),
1989 agent_identity_lock: Semaphore::new(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 auth_route_config: AuthRouteConfig::from_http_client_factory(HttpClientFactory::new(
1997 OutboundProxyPolicy::ReqwestDefault,
1998 )),
1999 })
2000 }
2001
2002 pub fn auth_cached(&self) -> Option<CodexAuth> {
2004 self.inner
2005 .read()
2006 .ok()
2007 .and_then(|cached| cached.auth.clone())
2008 }
2009
2010 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 #[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 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 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(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 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 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 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 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 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 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 self.clear_external_auth();
2493 self.reload().await;
2494 Ok(result)
2495 }
2496
2497 pub fn get_api_auth_mode(&self) -> Option<AuthMode> {
2499 self.auth_cached().as_ref().map(CodexAuth::api_auth_mode)
2500 }
2501
2502 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 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 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;