Skip to main content

vtcode_auth/
auth_service.rs

1//! Internal auth service contracts used by VT Code.
2
3use anyhow::{Result, anyhow};
4use std::sync::Arc;
5
6use crate::AuthCredentialsStoreMode;
7use crate::codex_auth_import::{codex_auth_json_refresher, is_session_expired, try_load_codex_chatgpt_session};
8use crate::config::{OpenAIAuthConfig, OpenAIPreferredMethod};
9use crate::openai_chatgpt_oauth::{
10    OpenAIChatGptAuthHandle, OpenAIChatGptSession, OpenAIChatGptSessionProvenance, OpenAIChatGptSessionRefresher,
11    OpenAICredentialOverview, OpenAIResolvedAuth, OpenAIResolvedAuthSource, load_openai_chatgpt_session_with_mode,
12};
13
14/// Service contract for resolving VT Code's OpenAI account auth state.
15#[derive(Debug, Clone)]
16pub struct OpenAIAccountAuthService {
17    auth_config: OpenAIAuthConfig,
18    storage_mode: AuthCredentialsStoreMode,
19}
20
21impl OpenAIAccountAuthService {
22    #[must_use]
23    pub(crate) fn new(auth_config: OpenAIAuthConfig, storage_mode: AuthCredentialsStoreMode) -> Self {
24        Self { auth_config, storage_mode }
25    }
26
27    /// Resolve the active OpenAI auth source for the current configuration.
28    pub(crate) fn resolve_runtime_auth(&self, api_key: Option<String>) -> Result<OpenAIResolvedAuth> {
29        let session = load_openai_chatgpt_session_with_mode(self.storage_mode)?;
30        match self.auth_config.preferred_method {
31            OpenAIPreferredMethod::Chatgpt => match session {
32                Some(native) => {
33                    let handle = self.handle_from_session(native);
34                    let api_key = handle.current_api_key()?;
35                    Ok(OpenAIResolvedAuth::ChatGpt { api_key, handle })
36                }
37                None => match self.try_codex_fallback()? {
38                    Some(codex_session) => {
39                        // Reject expired Codex sessions so runtime selection
40                        // matches status display (summarize_credentials also
41                        // filters expired Codex out). This prevents status
42                        // from reporting no active credential while runtime
43                        // silently selects an expired one.
44                        if is_session_expired(&codex_session) {
45                            Err(anyhow!(
46                                "Codex's ChatGPT session is expired. \
47                                 Run `codex login` to refresh it, or `vtcode login openai` for a VT Code session."
48                            ))
49                        } else {
50                            // Codex sessions must use the external refresher —
51                            // Codex-owned tokens are not rotated by VT Code
52                            // (ownership/race-avoidance: rotating them could
53                            // race Codex's refresh cycle or invalidate
54                            // Codex-maintained credentials).
55                            let handle = OpenAIChatGptAuthHandle::new_external(
56                                codex_session,
57                                self.auth_config.auto_refresh,
58                                codex_auth_json_refresher(),
59                            );
60                            let api_key = handle.current_api_key()?;
61                            Ok(OpenAIResolvedAuth::ChatGpt { api_key, handle })
62                        }
63                    }
64                    None => Err(anyhow!("Run vtcode login openai")),
65                },
66            },
67            OpenAIPreferredMethod::ApiKey => {
68                let api_key = require_api_key(api_key)?;
69                Ok(OpenAIResolvedAuth::ApiKey { api_key })
70            }
71            OpenAIPreferredMethod::Auto => {
72                if let Some(session) = session {
73                    let handle = self.handle_from_session(session);
74                    let api_key = handle.current_api_key()?;
75                    Ok(OpenAIResolvedAuth::ChatGpt { api_key, handle })
76                } else if let Some(codex_session) = self.try_codex_fallback()? {
77                    // Skip expired Codex sessions in auto mode — fall through to
78                    // API key so the user isn't blocked by stale Codex tokens.
79                    if is_session_expired(&codex_session) {
80                        tracing::info!("codex auth.json session is expired; falling back to API key");
81                        let api_key = require_api_key(api_key)?;
82                        Ok(OpenAIResolvedAuth::ApiKey { api_key })
83                    } else {
84                        let handle = OpenAIChatGptAuthHandle::new_external(
85                            codex_session,
86                            self.auth_config.auto_refresh,
87                            codex_auth_json_refresher(),
88                        );
89                        let api_key = handle.current_api_key()?;
90                        Ok(OpenAIResolvedAuth::ChatGpt { api_key, handle })
91                    }
92                } else {
93                    let api_key = require_api_key(api_key)?;
94                    Ok(OpenAIResolvedAuth::ApiKey { api_key })
95                }
96            }
97        }
98    }
99
100    /// Build a stored-session auth handle from a VT Code-managed session.
101    fn handle_from_session(&self, session: OpenAIChatGptSession) -> OpenAIChatGptAuthHandle {
102        OpenAIChatGptAuthHandle::new(session, self.auth_config.clone(), self.storage_mode)
103    }
104
105    /// Try to load a ChatGPT session from Codex's `~/.codex/auth.json` as a
106    /// fallback when VT Code has no stored session of its own.
107    ///
108    /// Errors are logged and swallowed so a malformed Codex auth file never
109    /// breaks the normal auth resolution path.
110    fn try_codex_fallback(&self) -> Result<Option<OpenAIChatGptSession>> {
111        match try_load_codex_chatgpt_session() {
112            Ok(session) => Ok(session),
113            Err(err) => {
114                tracing::warn!("failed to load codex auth.json fallback: {err}");
115                Ok(None)
116            }
117        }
118    }
119
120    /// Resolve a non-persistent OpenAI auth session backed by externally managed tokens.
121    pub fn resolve_external_session_auth(
122        &self,
123        session: OpenAIChatGptSession,
124        refresher: Arc<dyn OpenAIChatGptSessionRefresher>,
125    ) -> Result<OpenAIResolvedAuth> {
126        let handle = OpenAIChatGptAuthHandle::new_external(session, self.auth_config.auto_refresh, refresher);
127        let api_key = handle.current_api_key()?;
128        Ok(OpenAIResolvedAuth::ChatGpt { api_key, handle })
129    }
130
131    /// Summarize the available OpenAI credentials without mutating storage.
132    pub(crate) fn summarize_credentials(&self, api_key: Option<String>) -> Result<OpenAICredentialOverview> {
133        let vtcode_session = load_openai_chatgpt_session_with_mode(self.storage_mode)?;
134        // Try Codex fallback independently so we can report its availability
135        // even when a VT Code-managed session exists (for status display purposes).
136        // Expired Codex sessions are not reported as available — they would
137        // produce 401s at runtime.
138        let codex_session = self.try_codex_fallback()?.filter(|session| !is_session_expired(session));
139        let codex_fallback_available = codex_session.is_some();
140
141        // Prefer the VT Code-managed session; fall back to Codex.
142        let (chatgpt_session, chatgpt_session_provenance) = match vtcode_session {
143            Some(session) => (Some(session), Some(OpenAIChatGptSessionProvenance::Native)),
144            None => match codex_session {
145                Some(session) => (Some(session), Some(OpenAIChatGptSessionProvenance::CodexFallback)),
146                None => (None, None),
147            },
148        };
149
150        // Extract redacted metadata for display — never expose the full session.
151        let (chatgpt_email, chatgpt_plan, chatgpt_session_present) = match &chatgpt_session {
152            Some(session) => (session.email.clone(), session.plan.clone(), true),
153            None => (None, None, false),
154        };
155
156        let api_key_available = api_key.as_ref().is_some_and(|value| !value.trim().is_empty());
157        let active_source = match self.auth_config.preferred_method {
158            OpenAIPreferredMethod::Chatgpt => chatgpt_session_present.then_some(OpenAIResolvedAuthSource::ChatGpt),
159            OpenAIPreferredMethod::ApiKey => api_key_available.then_some(OpenAIResolvedAuthSource::ApiKey),
160            OpenAIPreferredMethod::Auto => {
161                if chatgpt_session_present {
162                    Some(OpenAIResolvedAuthSource::ChatGpt)
163                } else if api_key_available {
164                    Some(OpenAIResolvedAuthSource::ApiKey)
165                } else {
166                    None
167                }
168            }
169        };
170
171        let (notice, recommendation) = if api_key_available && chatgpt_session_present {
172            let active_label = match active_source {
173                Some(OpenAIResolvedAuthSource::ChatGpt) => "ChatGPT subscription",
174                Some(OpenAIResolvedAuthSource::ApiKey) => "OPENAI_API_KEY",
175                None => "neither credential",
176            };
177            let recommendation = match active_source {
178                Some(OpenAIResolvedAuthSource::ChatGpt) => {
179                    "Next step: keep the current priority, run /logout openai to rely on API-key auth only, or set [auth.openai].preferred_method = \"api_key\"."
180                }
181                Some(OpenAIResolvedAuthSource::ApiKey) => {
182                    "Next step: keep the current priority, remove OPENAI_API_KEY if ChatGPT should win, or set [auth.openai].preferred_method = \"chatgpt\"."
183                }
184                None => "Next step: choose a single preferred source or set [auth.openai].preferred_method explicitly.",
185            };
186            (
187                Some(format!(
188                    "Both ChatGPT subscription auth and OPENAI_API_KEY are available. VT Code is using {active_label} because auth.openai.preferred_method = {}.",
189                    self.auth_config.preferred_method.as_str()
190                )),
191                Some(recommendation.to_string()),
192            )
193        } else {
194            (None, None)
195        };
196
197        Ok(OpenAICredentialOverview {
198            api_key_available,
199            chatgpt_email,
200            chatgpt_plan,
201            chatgpt_session_present,
202            chatgpt_session_provenance,
203            codex_fallback_available,
204            active_source,
205            preferred_method: self.auth_config.preferred_method,
206            notice,
207            recommendation,
208        })
209    }
210}
211
212fn require_api_key(api_key: Option<String>) -> Result<String> {
213    api_key
214        .map(|value| value.trim().to_string())
215        .filter(|value| !value.is_empty())
216        .ok_or_else(|| anyhow!("OpenAI API key not found"))
217}