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