1use anyhow::{Context, Result, anyhow, bail};
10use base64::{Engine, engine::general_purpose::STANDARD, engine::general_purpose::URL_SAFE_NO_PAD};
11use fs2::FileExt;
12use reqwest::Client;
13use ring::aead::{self, Aad, LessSafeKey, NONCE_LEN, Nonce, UnboundKey};
14use ring::rand::{SecureRandom, SystemRandom};
15use serde::{Deserialize, Serialize};
16use std::fmt;
17use std::fs;
18use std::fs::OpenOptions;
19use std::path::PathBuf;
20use std::sync::{Arc, Mutex};
21
22use crate::storage_paths::auth_storage_dir;
23use crate::{OpenAIAuthConfig, OpenAIPreferredMethod};
24
25pub use super::credentials::AuthCredentialsStoreMode;
26use super::pkce::PkceChallenge;
27
28const OPENAI_AUTH_URL: &str = "https://auth.openai.com/oauth/authorize";
29const OPENAI_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
30const OPENAI_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
31const OPENAI_ORIGINATOR: &str = "codex_cli_rs";
32const OPENAI_CALLBACK_PATH: &str = "/auth/callback";
33const OPENAI_STORAGE_SERVICE: &str = "vtcode";
34const OPENAI_STORAGE_USER: &str = "openai_chatgpt_session";
35const OPENAI_SESSION_FILE: &str = "openai_chatgpt.json";
36const OPENAI_REFRESH_LOCK_FILE: &str = "openai_chatgpt.refresh.lock";
37const REFRESH_INTERVAL_SECS: u64 = 8 * 60;
38const REFRESH_SKEW_SECS: u64 = 60;
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct OpenAIChatGptSession {
43 pub openai_api_key: String,
46 pub id_token: String,
48 pub access_token: String,
50 pub refresh_token: String,
52 pub account_id: Option<String>,
54 pub email: Option<String>,
56 pub plan: Option<String>,
58 pub obtained_at: u64,
60 pub refreshed_at: u64,
62 pub expires_at: Option<u64>,
64}
65
66impl OpenAIChatGptSession {
67 pub fn is_refresh_due(&self) -> bool {
68 let now = now_secs();
69 if let Some(expires_at) = self.expires_at
70 && now.saturating_add(REFRESH_SKEW_SECS) >= expires_at
71 {
72 return true;
73 }
74 now.saturating_sub(self.refreshed_at) >= REFRESH_INTERVAL_SECS
75 }
76}
77
78#[derive(Clone)]
80pub struct OpenAIChatGptAuthHandle {
81 session: Arc<Mutex<OpenAIChatGptSession>>,
82 auth_config: OpenAIAuthConfig,
83 storage_mode: AuthCredentialsStoreMode,
84}
85
86impl fmt::Debug for OpenAIChatGptAuthHandle {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 f.debug_struct("OpenAIChatGptAuthHandle")
89 .field("auth_config", &self.auth_config)
90 .field("storage_mode", &self.storage_mode)
91 .finish()
92 }
93}
94
95impl OpenAIChatGptAuthHandle {
96 pub fn new(
97 session: OpenAIChatGptSession,
98 auth_config: OpenAIAuthConfig,
99 storage_mode: AuthCredentialsStoreMode,
100 ) -> Self {
101 Self {
102 session: Arc::new(Mutex::new(session)),
103 auth_config,
104 storage_mode,
105 }
106 }
107
108 pub fn snapshot(&self) -> Result<OpenAIChatGptSession> {
109 self.session
110 .lock()
111 .map(|guard| guard.clone())
112 .map_err(|_| anyhow!("openai chatgpt auth mutex poisoned"))
113 }
114
115 pub fn current_api_key(&self) -> Result<String> {
116 self.snapshot()
117 .map(|session| active_api_bearer_token(&session).to_string())
118 }
119
120 pub fn provider_label(&self) -> &'static str {
121 "OpenAI (ChatGPT)"
122 }
123
124 pub async fn refresh_if_needed(&self) -> Result<()> {
125 if !self.auth_config.auto_refresh {
126 return Ok(());
127 }
128
129 let needs_refresh = self.snapshot()?.is_refresh_due();
130 if needs_refresh {
131 self.force_refresh().await?;
132 }
133 Ok(())
134 }
135
136 pub async fn force_refresh(&self) -> Result<()> {
137 let session = self.snapshot()?;
138 let refreshed =
139 refresh_openai_chatgpt_session_from_snapshot(&session, self.storage_mode).await?;
140 self.replace_session(refreshed)
141 }
142
143 fn replace_session(&self, session: OpenAIChatGptSession) -> Result<()> {
144 let mut guard = self
145 .session
146 .lock()
147 .map_err(|_| anyhow!("openai chatgpt auth mutex poisoned"))?;
148 *guard = session;
149 Ok(())
150 }
151}
152
153#[derive(Debug, Clone)]
155pub enum OpenAIResolvedAuth {
156 ApiKey {
157 api_key: String,
158 },
159 ChatGpt {
160 api_key: String,
161 handle: OpenAIChatGptAuthHandle,
162 },
163}
164
165impl OpenAIResolvedAuth {
166 pub fn api_key(&self) -> &str {
167 match self {
168 Self::ApiKey { api_key } => api_key,
169 Self::ChatGpt { api_key, .. } => api_key,
170 }
171 }
172
173 pub fn handle(&self) -> Option<OpenAIChatGptAuthHandle> {
174 match self {
175 Self::ApiKey { .. } => None,
176 Self::ChatGpt { handle, .. } => Some(handle.clone()),
177 }
178 }
179
180 pub fn using_chatgpt(&self) -> bool {
181 matches!(self, Self::ChatGpt { .. })
182 }
183}
184
185fn active_api_bearer_token(session: &OpenAIChatGptSession) -> &str {
186 if session.openai_api_key.trim().is_empty() {
187 session.access_token.as_str()
188 } else {
189 session.openai_api_key.as_str()
190 }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum OpenAIResolvedAuthSource {
195 ApiKey,
196 ChatGpt,
197}
198
199#[derive(Debug, Clone)]
200pub struct OpenAICredentialOverview {
201 pub api_key_available: bool,
202 pub chatgpt_session: Option<OpenAIChatGptSession>,
203 pub active_source: Option<OpenAIResolvedAuthSource>,
204 pub preferred_method: OpenAIPreferredMethod,
205 pub notice: Option<String>,
206 pub recommendation: Option<String>,
207}
208
209#[derive(Debug, Clone)]
211pub enum OpenAIChatGptAuthStatus {
212 Authenticated {
213 label: Option<String>,
214 age_seconds: u64,
215 expires_in: Option<u64>,
216 },
217 NotAuthenticated,
218}
219
220pub fn get_openai_chatgpt_auth_url(
222 challenge: &PkceChallenge,
223 callback_port: u16,
224 state: &str,
225) -> String {
226 let redirect_uri = format!("http://localhost:{callback_port}{OPENAI_CALLBACK_PATH}");
227 let query = [
228 ("response_type", "code".to_string()),
229 ("client_id", OPENAI_CLIENT_ID.to_string()),
230 ("redirect_uri", redirect_uri),
231 (
232 "scope",
233 "openid profile email offline_access api.connectors.read api.connectors.invoke"
234 .to_string(),
235 ),
236 ("code_challenge", challenge.code_challenge.clone()),
237 (
238 "code_challenge_method",
239 challenge.code_challenge_method.clone(),
240 ),
241 ("id_token_add_organizations", "true".to_string()),
242 ("codex_cli_simplified_flow", "true".to_string()),
243 ("state", state.to_string()),
244 ("originator", OPENAI_ORIGINATOR.to_string()),
245 ];
246
247 let encoded = query
248 .iter()
249 .map(|(key, value)| format!("{key}={}", urlencoding::encode(value)))
250 .collect::<Vec<_>>()
251 .join("&");
252 format!("{OPENAI_AUTH_URL}?{encoded}")
253}
254
255pub fn generate_openai_oauth_state() -> Result<String> {
256 let mut state_bytes = [0_u8; 32];
257 SystemRandom::new()
258 .fill(&mut state_bytes)
259 .map_err(|_| anyhow!("failed to generate openai oauth state"))?;
260 Ok(URL_SAFE_NO_PAD.encode(state_bytes))
261}
262
263pub fn parse_openai_chatgpt_manual_callback_input(
264 input: &str,
265 expected_state: &str,
266) -> Result<String> {
267 let trimmed = input.trim();
268 if trimmed.is_empty() {
269 bail!("missing authorization callback input");
270 }
271
272 let query = if trimmed.contains("://") {
273 let url = reqwest::Url::parse(trimmed).context("invalid callback url")?;
274 url.query()
275 .ok_or_else(|| anyhow!("callback url did not include a query string"))?
276 .to_string()
277 } else if trimmed.contains('=') {
278 trimmed.trim_start_matches('?').to_string()
279 } else {
280 bail!("paste the full redirect url or query string containing code and state");
281 };
282
283 let code = extract_query_value(&query, "code")
284 .ok_or_else(|| anyhow!("callback input did not include an authorization code"))?;
285 let state = extract_query_value(&query, "state")
286 .ok_or_else(|| anyhow!("callback input did not include state"))?;
287 if state != expected_state {
288 bail!("OAuth error: state mismatch");
289 }
290 Ok(code)
291}
292
293pub async fn exchange_openai_chatgpt_code_for_tokens(
295 code: &str,
296 challenge: &PkceChallenge,
297 callback_port: u16,
298) -> Result<OpenAIChatGptSession> {
299 let redirect_uri = format!("http://localhost:{callback_port}{OPENAI_CALLBACK_PATH}");
300 let body = format!(
301 "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}",
302 urlencoding::encode(code),
303 urlencoding::encode(&redirect_uri),
304 urlencoding::encode(OPENAI_CLIENT_ID),
305 urlencoding::encode(&challenge.code_verifier),
306 );
307
308 let token_response: OpenAITokenResponse = Client::new()
309 .post(OPENAI_TOKEN_URL)
310 .header("Content-Type", "application/x-www-form-urlencoded")
311 .body(body)
312 .send()
313 .await
314 .context("failed to exchange openai authorization code")?
315 .error_for_status()
316 .context("openai authorization-code exchange failed")?
317 .json()
318 .await
319 .context("failed to parse openai authorization-code response")?;
320
321 build_session_from_token_response(token_response).await
322}
323
324pub fn resolve_openai_auth(
326 auth_config: &OpenAIAuthConfig,
327 storage_mode: AuthCredentialsStoreMode,
328 api_key: Option<String>,
329) -> Result<OpenAIResolvedAuth> {
330 let session = load_openai_chatgpt_session_with_mode(storage_mode)?;
331 match auth_config.preferred_method {
332 OpenAIPreferredMethod::Chatgpt => {
333 let session = session.ok_or_else(|| anyhow!("Run vtcode login openai"))?;
334 let handle =
335 OpenAIChatGptAuthHandle::new(session.clone(), auth_config.clone(), storage_mode);
336 Ok(OpenAIResolvedAuth::ChatGpt {
337 api_key: active_api_bearer_token(&session).to_string(),
338 handle,
339 })
340 }
341 OpenAIPreferredMethod::ApiKey => {
342 let api_key = api_key.ok_or_else(|| anyhow!("OpenAI API key not found"))?;
343 Ok(OpenAIResolvedAuth::ApiKey { api_key })
344 }
345 OpenAIPreferredMethod::Auto => {
346 if let Some(session) = session {
347 let handle = OpenAIChatGptAuthHandle::new(
348 session.clone(),
349 auth_config.clone(),
350 storage_mode,
351 );
352 Ok(OpenAIResolvedAuth::ChatGpt {
353 api_key: active_api_bearer_token(&session).to_string(),
354 handle,
355 })
356 } else {
357 let api_key = api_key.ok_or_else(|| anyhow!("OpenAI API key not found"))?;
358 Ok(OpenAIResolvedAuth::ApiKey { api_key })
359 }
360 }
361 }
362}
363
364pub fn summarize_openai_credentials(
365 auth_config: &OpenAIAuthConfig,
366 storage_mode: AuthCredentialsStoreMode,
367 api_key: Option<String>,
368) -> Result<OpenAICredentialOverview> {
369 let chatgpt_session = load_openai_chatgpt_session_with_mode(storage_mode)?;
370 let api_key_available = api_key
371 .as_ref()
372 .is_some_and(|value| !value.trim().is_empty());
373 let active_source = match auth_config.preferred_method {
374 OpenAIPreferredMethod::Chatgpt => chatgpt_session
375 .as_ref()
376 .map(|_| OpenAIResolvedAuthSource::ChatGpt),
377 OpenAIPreferredMethod::ApiKey => {
378 api_key_available.then_some(OpenAIResolvedAuthSource::ApiKey)
379 }
380 OpenAIPreferredMethod::Auto => {
381 if chatgpt_session.is_some() {
382 Some(OpenAIResolvedAuthSource::ChatGpt)
383 } else if api_key_available {
384 Some(OpenAIResolvedAuthSource::ApiKey)
385 } else {
386 None
387 }
388 }
389 };
390
391 let (notice, recommendation) = if api_key_available && chatgpt_session.is_some() {
392 let active_label = match active_source {
393 Some(OpenAIResolvedAuthSource::ChatGpt) => "ChatGPT subscription",
394 Some(OpenAIResolvedAuthSource::ApiKey) => "OPENAI_API_KEY",
395 None => "neither credential",
396 };
397 let recommendation = match active_source {
398 Some(OpenAIResolvedAuthSource::ChatGpt) => {
399 "Next step: keep the current priority, run /logout openai to rely on API-key auth only, or set [auth.openai].preferred_method = \"api_key\"."
400 }
401 Some(OpenAIResolvedAuthSource::ApiKey) => {
402 "Next step: keep the current priority, remove OPENAI_API_KEY if ChatGPT should win, or set [auth.openai].preferred_method = \"chatgpt\"."
403 }
404 None => {
405 "Next step: choose a single preferred source or set [auth.openai].preferred_method explicitly."
406 }
407 };
408 (
409 Some(format!(
410 "Both ChatGPT subscription auth and OPENAI_API_KEY are available. VT Code is using {active_label} because auth.openai.preferred_method = {}.",
411 auth_config.preferred_method.as_str()
412 )),
413 Some(recommendation.to_string()),
414 )
415 } else {
416 (None, None)
417 };
418
419 Ok(OpenAICredentialOverview {
420 api_key_available,
421 chatgpt_session,
422 active_source,
423 preferred_method: auth_config.preferred_method,
424 notice,
425 recommendation,
426 })
427}
428
429pub fn save_openai_chatgpt_session(session: &OpenAIChatGptSession) -> Result<()> {
430 save_openai_chatgpt_session_with_mode(session, AuthCredentialsStoreMode::default())
431}
432
433pub fn save_openai_chatgpt_session_with_mode(
434 session: &OpenAIChatGptSession,
435 mode: AuthCredentialsStoreMode,
436) -> Result<()> {
437 let serialized =
438 serde_json::to_string(session).context("failed to serialize openai session")?;
439 match mode.effective_mode() {
440 AuthCredentialsStoreMode::Keyring => {
441 persist_session_to_keyring_or_file(session, &serialized)?
442 }
443 AuthCredentialsStoreMode::File => save_session_to_file(session)?,
444 AuthCredentialsStoreMode::Auto => unreachable!(),
445 }
446 Ok(())
447}
448
449pub fn load_openai_chatgpt_session() -> Result<Option<OpenAIChatGptSession>> {
450 load_preferred_openai_chatgpt_session(AuthCredentialsStoreMode::Keyring)
451}
452
453pub fn load_openai_chatgpt_session_with_mode(
454 mode: AuthCredentialsStoreMode,
455) -> Result<Option<OpenAIChatGptSession>> {
456 load_preferred_openai_chatgpt_session(mode.effective_mode())
457}
458
459pub fn clear_openai_chatgpt_session() -> Result<()> {
460 let _ = clear_session_from_keyring();
461 let _ = clear_session_from_file();
462 Ok(())
463}
464
465pub fn clear_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
466 match mode.effective_mode() {
467 AuthCredentialsStoreMode::Keyring => clear_session_from_keyring(),
468 AuthCredentialsStoreMode::File => clear_session_from_file(),
469 AuthCredentialsStoreMode::Auto => unreachable!(),
470 }
471}
472
473pub fn get_openai_chatgpt_auth_status() -> Result<OpenAIChatGptAuthStatus> {
474 get_openai_chatgpt_auth_status_with_mode(AuthCredentialsStoreMode::default())
475}
476
477pub fn get_openai_chatgpt_auth_status_with_mode(
478 mode: AuthCredentialsStoreMode,
479) -> Result<OpenAIChatGptAuthStatus> {
480 let Some(session) = load_openai_chatgpt_session_with_mode(mode)? else {
481 return Ok(OpenAIChatGptAuthStatus::NotAuthenticated);
482 };
483 let now = now_secs();
484 Ok(OpenAIChatGptAuthStatus::Authenticated {
485 label: session
486 .email
487 .clone()
488 .or_else(|| session.plan.clone())
489 .or_else(|| session.account_id.clone()),
490 age_seconds: now.saturating_sub(session.obtained_at),
491 expires_in: session
492 .expires_at
493 .map(|expires_at| expires_at.saturating_sub(now)),
494 })
495}
496
497pub async fn refresh_openai_chatgpt_session_from_refresh_token(
498 refresh_token: &str,
499 storage_mode: AuthCredentialsStoreMode,
500) -> Result<OpenAIChatGptSession> {
501 let _lock = acquire_refresh_lock().await?;
502 refresh_openai_chatgpt_session_without_lock(refresh_token, storage_mode).await
503}
504
505pub async fn refresh_openai_chatgpt_session_with_mode(
506 mode: AuthCredentialsStoreMode,
507) -> Result<OpenAIChatGptSession> {
508 let session = load_openai_chatgpt_session_with_mode(mode)?
509 .ok_or_else(|| anyhow!("Run vtcode login openai"))?;
510 refresh_openai_chatgpt_session_from_snapshot(&session, mode).await
511}
512
513async fn refresh_openai_chatgpt_session_from_snapshot(
514 session: &OpenAIChatGptSession,
515 storage_mode: AuthCredentialsStoreMode,
516) -> Result<OpenAIChatGptSession> {
517 let _lock = acquire_refresh_lock().await?;
518 if let Some(current) = load_openai_chatgpt_session_with_mode(storage_mode)?
519 && session_has_newer_refresh_state(¤t, session)
520 {
521 return Ok(current);
522 }
523 refresh_openai_chatgpt_session_without_lock(&session.refresh_token, storage_mode).await
524}
525
526async fn refresh_openai_chatgpt_session_without_lock(
527 refresh_token: &str,
528 storage_mode: AuthCredentialsStoreMode,
529) -> Result<OpenAIChatGptSession> {
530 let response = Client::new()
531 .post(OPENAI_TOKEN_URL)
532 .header("Content-Type", "application/x-www-form-urlencoded")
533 .body(format!(
534 "grant_type=refresh_token&client_id={}&refresh_token={}",
535 urlencoding::encode(OPENAI_CLIENT_ID),
536 urlencoding::encode(refresh_token),
537 ))
538 .send()
539 .await
540 .context("failed to refresh openai chatgpt token")?;
541 response
542 .error_for_status_ref()
543 .map_err(|err| classify_refresh_error(err, storage_mode))?;
544 let token_response: OpenAITokenResponse = response
545 .json()
546 .await
547 .context("failed to parse openai refresh response")?;
548
549 let session = build_session_from_token_response(token_response).await?;
550 save_openai_chatgpt_session_with_mode(&session, storage_mode)?;
551 Ok(session)
552}
553
554async fn build_session_from_token_response(
555 token_response: OpenAITokenResponse,
556) -> Result<OpenAIChatGptSession> {
557 let id_claims = parse_jwt_claims(&token_response.id_token)?;
558 let access_claims = parse_jwt_claims(&token_response.access_token).ok();
559 let api_key = match exchange_openai_chatgpt_api_key(&token_response.id_token).await {
560 Ok(api_key) => api_key,
561 Err(err) => {
562 tracing::warn!(
563 "openai api-key exchange unavailable, falling back to oauth access token: {err}"
564 );
565 String::new()
566 }
567 };
568 let now = now_secs();
569 Ok(OpenAIChatGptSession {
570 openai_api_key: api_key,
571 id_token: token_response.id_token,
572 access_token: token_response.access_token,
573 refresh_token: token_response.refresh_token,
574 account_id: access_claims
575 .as_ref()
576 .and_then(|claims| claims.account_id.clone())
577 .or(id_claims.account_id),
578 email: id_claims.email.or_else(|| {
579 access_claims
580 .as_ref()
581 .and_then(|claims| claims.email.clone())
582 }),
583 plan: access_claims
584 .as_ref()
585 .and_then(|claims| claims.plan.clone())
586 .or(id_claims.plan),
587 obtained_at: now,
588 refreshed_at: now,
589 expires_at: token_response
590 .expires_in
591 .map(|secs| now.saturating_add(secs)),
592 })
593}
594
595async fn exchange_openai_chatgpt_api_key(id_token: &str) -> Result<String> {
596 #[derive(Deserialize)]
597 struct ExchangeResponse {
598 access_token: String,
599 }
600
601 let exchange: ExchangeResponse = Client::new()
602 .post(OPENAI_TOKEN_URL)
603 .header("Content-Type", "application/x-www-form-urlencoded")
604 .body(format!(
605 "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}",
606 urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
607 urlencoding::encode(OPENAI_CLIENT_ID),
608 urlencoding::encode("openai-api-key"),
609 urlencoding::encode(id_token),
610 urlencoding::encode("urn:ietf:params:oauth:token-type:id_token"),
611 ))
612 .send()
613 .await
614 .context("failed to exchange openai id token for api key")?
615 .error_for_status()
616 .context("openai api-key exchange failed")?
617 .json()
618 .await
619 .context("failed to parse openai api-key exchange response")?;
620
621 Ok(exchange.access_token)
622}
623
624#[derive(Debug, Deserialize)]
625struct OpenAITokenResponse {
626 id_token: String,
627 access_token: String,
628 refresh_token: String,
629 #[serde(default)]
630 expires_in: Option<u64>,
631}
632
633#[derive(Debug, Deserialize)]
634struct IdTokenClaims {
635 #[serde(default)]
636 email: Option<String>,
637 #[serde(rename = "https://api.openai.com/profile", default)]
638 profile: Option<ProfileClaims>,
639 #[serde(rename = "https://api.openai.com/auth", default)]
640 auth: Option<AuthClaims>,
641}
642
643#[derive(Debug, Deserialize)]
644struct ProfileClaims {
645 #[serde(default)]
646 email: Option<String>,
647}
648
649#[derive(Debug, Deserialize)]
650struct AuthClaims {
651 #[serde(default)]
652 chatgpt_plan_type: Option<String>,
653 #[serde(default)]
654 chatgpt_account_id: Option<String>,
655}
656
657#[derive(Debug)]
658struct ParsedIdTokenClaims {
659 email: Option<String>,
660 account_id: Option<String>,
661 plan: Option<String>,
662}
663
664fn parse_jwt_claims(jwt: &str) -> Result<ParsedIdTokenClaims> {
665 let mut parts = jwt.split('.');
666 let (_, payload_b64, _) = match (parts.next(), parts.next(), parts.next()) {
667 (Some(header), Some(payload), Some(signature))
668 if !header.is_empty() && !payload.is_empty() && !signature.is_empty() =>
669 {
670 (header, payload, signature)
671 }
672 _ => bail!("invalid openai id token"),
673 };
674
675 let payload = URL_SAFE_NO_PAD
676 .decode(payload_b64)
677 .context("failed to decode openai id token payload")?;
678 let claims: IdTokenClaims =
679 serde_json::from_slice(&payload).context("failed to parse openai id token payload")?;
680
681 Ok(ParsedIdTokenClaims {
682 email: claims
683 .email
684 .or_else(|| claims.profile.and_then(|profile| profile.email)),
685 account_id: claims
686 .auth
687 .as_ref()
688 .and_then(|auth| auth.chatgpt_account_id.clone()),
689 plan: claims.auth.and_then(|auth| auth.chatgpt_plan_type),
690 })
691}
692
693fn extract_query_value(query: &str, key: &str) -> Option<String> {
694 query
695 .trim_start_matches('?')
696 .split('&')
697 .filter_map(|pair| {
698 let (pair_key, pair_value) = pair.split_once('=')?;
699 (pair_key == key)
700 .then(|| {
701 urlencoding::decode(pair_value)
702 .ok()
703 .map(|value| value.into_owned())
704 })
705 .flatten()
706 })
707 .find(|value| !value.is_empty())
708}
709
710fn session_has_newer_refresh_state(
711 current: &OpenAIChatGptSession,
712 previous: &OpenAIChatGptSession,
713) -> bool {
714 current.refresh_token != previous.refresh_token
715 || current.refreshed_at > previous.refreshed_at
716 || current.obtained_at > previous.obtained_at
717}
718
719struct RefreshLockGuard {
720 file: fs::File,
721}
722
723impl Drop for RefreshLockGuard {
724 fn drop(&mut self) {
725 let _ = FileExt::unlock(&self.file);
726 }
727}
728
729async fn acquire_refresh_lock() -> Result<RefreshLockGuard> {
730 let path = auth_storage_dir()?.join(OPENAI_REFRESH_LOCK_FILE);
731 let file = OpenOptions::new()
732 .create(true)
733 .read(true)
734 .write(true)
735 .truncate(false)
736 .open(&path)
737 .with_context(|| format!("failed to open openai refresh lock {}", path.display()))?;
738 let file = tokio::task::spawn_blocking(move || {
739 file.lock_exclusive()
740 .context("failed to acquire openai refresh lock")?;
741 Ok::<_, anyhow::Error>(file)
742 })
743 .await
744 .context("openai refresh lock task failed")??;
745 Ok(RefreshLockGuard { file })
746}
747
748fn classify_refresh_error(
749 err: reqwest::Error,
750 storage_mode: AuthCredentialsStoreMode,
751) -> anyhow::Error {
752 let status = err.status();
753 let message = err.to_string();
754 if status.is_some_and(|status| status == reqwest::StatusCode::BAD_REQUEST)
755 && (message.contains("invalid_grant") || message.contains("refresh_token"))
756 {
757 let _ = clear_openai_chatgpt_session_with_mode(storage_mode);
758 anyhow!("Your ChatGPT session expired. Run `vtcode login openai` again.")
759 } else {
760 anyhow!(message)
761 }
762}
763
764fn save_session_to_keyring(serialized: &str) -> Result<()> {
765 let entry = keyring::Entry::new(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER)
766 .context("failed to access keyring for openai session")?;
767 entry
768 .set_password(serialized)
769 .context("failed to store openai session in keyring")?;
770 Ok(())
771}
772
773fn persist_session_to_keyring_or_file(
774 session: &OpenAIChatGptSession,
775 serialized: &str,
776) -> Result<()> {
777 match save_session_to_keyring(serialized) {
778 Ok(()) => match load_session_from_keyring_decoded() {
779 Ok(Some(_)) => Ok(()),
780 Ok(None) => {
781 tracing::warn!(
782 "openai session keyring write did not round-trip; falling back to encrypted file storage"
783 );
784 save_session_to_file(session)
785 }
786 Err(err) => {
787 tracing::warn!(
788 "openai session keyring verification failed, falling back to encrypted file storage: {err}"
789 );
790 save_session_to_file(session)
791 }
792 },
793 Err(err) => {
794 tracing::warn!(
795 "failed to persist openai session in keyring, falling back to encrypted file storage: {err}"
796 );
797 save_session_to_file(session)
798 .context("failed to persist openai session after keyring fallback")
799 }
800 }
801}
802
803fn decode_session_from_keyring(serialized: String) -> Result<OpenAIChatGptSession> {
804 serde_json::from_str(&serialized).context("failed to decode openai session")
805}
806
807fn load_session_from_keyring_decoded() -> Result<Option<OpenAIChatGptSession>> {
808 load_session_from_keyring()?
809 .map(decode_session_from_keyring)
810 .transpose()
811}
812
813fn load_preferred_openai_chatgpt_session(
814 mode: AuthCredentialsStoreMode,
815) -> Result<Option<OpenAIChatGptSession>> {
816 match mode {
817 AuthCredentialsStoreMode::Keyring => match load_session_from_keyring_decoded() {
818 Ok(Some(session)) => Ok(Some(session)),
819 Ok(None) => load_session_from_file(),
820 Err(err) => {
821 tracing::warn!(
822 "failed to load openai session from keyring, falling back to encrypted file: {err}"
823 );
824 load_session_from_file()
825 }
826 },
827 AuthCredentialsStoreMode::File => {
828 if let Some(session) = load_session_from_file()? {
829 return Ok(Some(session));
830 }
831 load_session_from_keyring_decoded()
832 }
833 AuthCredentialsStoreMode::Auto => unreachable!(),
834 }
835}
836
837fn load_session_from_keyring() -> Result<Option<String>> {
838 let entry = match keyring::Entry::new(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
839 Ok(entry) => entry,
840 Err(_) => return Ok(None),
841 };
842
843 match entry.get_password() {
844 Ok(value) => Ok(Some(value)),
845 Err(keyring::Error::NoEntry) => Ok(None),
846 Err(err) => Err(anyhow!("failed to read openai session from keyring: {err}")),
847 }
848}
849
850fn clear_session_from_keyring() -> Result<()> {
851 let entry = match keyring::Entry::new(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
852 Ok(entry) => entry,
853 Err(_) => return Ok(()),
854 };
855
856 match entry.delete_credential() {
857 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
858 Err(err) => Err(anyhow!(
859 "failed to clear openai session keyring entry: {err}"
860 )),
861 }
862}
863
864fn save_session_to_file(session: &OpenAIChatGptSession) -> Result<()> {
865 let encrypted = encrypt_session(session)?;
866 let path = get_session_path()?;
867 fs::write(&path, serde_json::to_vec_pretty(&encrypted)?)
868 .context("failed to persist openai session file")?;
869 #[cfg(unix)]
870 {
871 use std::os::unix::fs::PermissionsExt;
872 fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
873 .context("failed to set openai session file permissions")?;
874 }
875 Ok(())
876}
877
878fn load_session_from_file() -> Result<Option<OpenAIChatGptSession>> {
879 let path = get_session_path()?;
880 let data = match fs::read(path) {
881 Ok(data) => data,
882 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
883 Err(err) => return Err(anyhow!("failed to read openai session file: {err}")),
884 };
885
886 let encrypted: EncryptedSession =
887 serde_json::from_slice(&data).context("failed to decode openai session file")?;
888 Ok(Some(decrypt_session(&encrypted)?))
889}
890
891fn clear_session_from_file() -> Result<()> {
892 let path = get_session_path()?;
893 match fs::remove_file(path) {
894 Ok(()) => Ok(()),
895 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
896 Err(err) => Err(anyhow!("failed to delete openai session file: {err}")),
897 }
898}
899
900fn get_session_path() -> Result<PathBuf> {
901 Ok(auth_storage_dir()?.join(OPENAI_SESSION_FILE))
902}
903
904#[derive(Debug, Serialize, Deserialize)]
905struct EncryptedSession {
906 nonce: String,
907 ciphertext: String,
908 version: u8,
909}
910
911fn encrypt_session(session: &OpenAIChatGptSession) -> Result<EncryptedSession> {
912 let key = derive_encryption_key()?;
913 let rng = SystemRandom::new();
914 let mut nonce_bytes = [0u8; NONCE_LEN];
915 rng.fill(&mut nonce_bytes)
916 .map_err(|_| anyhow!("failed to generate nonce"))?;
917
918 let mut ciphertext =
919 serde_json::to_vec(session).context("failed to serialize openai session for encryption")?;
920 let nonce = Nonce::assume_unique_for_key(nonce_bytes);
921 key.seal_in_place_append_tag(nonce, Aad::empty(), &mut ciphertext)
922 .map_err(|_| anyhow!("failed to encrypt openai session"))?;
923
924 Ok(EncryptedSession {
925 nonce: STANDARD.encode(nonce_bytes),
926 ciphertext: STANDARD.encode(ciphertext),
927 version: 1,
928 })
929}
930
931fn decrypt_session(encrypted: &EncryptedSession) -> Result<OpenAIChatGptSession> {
932 if encrypted.version != 1 {
933 bail!("unsupported openai session encryption format");
934 }
935
936 let nonce_bytes = STANDARD
937 .decode(&encrypted.nonce)
938 .context("failed to decode openai session nonce")?;
939 let nonce_array: [u8; NONCE_LEN] = nonce_bytes
940 .try_into()
941 .map_err(|_| anyhow!("invalid openai session nonce length"))?;
942 let mut ciphertext = STANDARD
943 .decode(&encrypted.ciphertext)
944 .context("failed to decode openai session ciphertext")?;
945
946 let key = derive_encryption_key()?;
947 let plaintext = key
948 .open_in_place(
949 Nonce::assume_unique_for_key(nonce_array),
950 Aad::empty(),
951 &mut ciphertext,
952 )
953 .map_err(|_| anyhow!("failed to decrypt openai session"))?;
954 serde_json::from_slice(plaintext).context("failed to parse decrypted openai session")
955}
956
957fn derive_encryption_key() -> Result<LessSafeKey> {
958 use ring::digest::{SHA256, digest};
959
960 let mut key_material = Vec::new();
961 if let Ok(hostname) = hostname::get() {
962 key_material.extend_from_slice(hostname.as_encoded_bytes());
963 }
964
965 #[cfg(unix)]
966 {
967 key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
968 }
969 #[cfg(not(unix))]
970 {
971 if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
972 key_material.extend_from_slice(user.as_bytes());
973 }
974 }
975
976 key_material.extend_from_slice(b"vtcode-openai-chatgpt-oauth-v1");
977 let hash = digest(&SHA256, &key_material);
978 let key_bytes: &[u8; 32] = hash.as_ref()[..32]
979 .try_into()
980 .context("openai session encryption key was too short")?;
981 let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
982 .map_err(|_| anyhow!("invalid openai session encryption key"))?;
983 Ok(LessSafeKey::new(unbound))
984}
985
986fn now_secs() -> u64 {
987 std::time::SystemTime::now()
988 .duration_since(std::time::UNIX_EPOCH)
989 .map(|duration| duration.as_secs())
990 .unwrap_or(0)
991}
992
993#[cfg(test)]
994mod tests {
995 use super::*;
996 use assert_fs::TempDir;
997 use serial_test::serial;
998
999 struct TestAuthDirGuard {
1000 temp_dir: Option<TempDir>,
1001 previous: Option<PathBuf>,
1002 }
1003
1004 impl TestAuthDirGuard {
1005 fn new() -> Self {
1006 let temp_dir = TempDir::new().expect("create temp auth dir");
1007 let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
1008 .expect("read auth dir override");
1009 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
1010 temp_dir.path().to_path_buf(),
1011 ))
1012 .expect("set temp auth dir override");
1013 Self {
1014 temp_dir: Some(temp_dir),
1015 previous,
1016 }
1017 }
1018 }
1019
1020 impl Drop for TestAuthDirGuard {
1021 fn drop(&mut self) {
1022 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
1023 .expect("restore auth dir override");
1024 if let Some(temp_dir) = self.temp_dir.take() {
1025 temp_dir.close().expect("remove temp auth dir");
1026 }
1027 }
1028 }
1029
1030 fn sample_session() -> OpenAIChatGptSession {
1031 OpenAIChatGptSession {
1032 openai_api_key: "api-key".to_string(),
1033 id_token: "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig".to_string(),
1034 access_token: "oauth-access".to_string(),
1035 refresh_token: "refresh-token".to_string(),
1036 account_id: Some("acc_123".to_string()),
1037 email: Some("test@example.com".to_string()),
1038 plan: Some("plus".to_string()),
1039 obtained_at: 10,
1040 refreshed_at: 10,
1041 expires_at: Some(now_secs() + 3600),
1042 }
1043 }
1044
1045 #[test]
1046 fn auth_url_contains_expected_openai_parameters() {
1047 let challenge = PkceChallenge {
1048 code_verifier: "verifier".to_string(),
1049 code_challenge: "challenge".to_string(),
1050 code_challenge_method: "S256".to_string(),
1051 };
1052
1053 let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state");
1054 assert!(url.starts_with(OPENAI_AUTH_URL));
1055 assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
1056 assert!(url.contains("code_challenge=challenge"));
1057 assert!(url.contains("codex_cli_simplified_flow=true"));
1058 assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
1059 assert!(url.contains("state=test-state"));
1060 }
1061
1062 #[test]
1063 fn parse_jwt_claims_extracts_openai_claims() {
1064 let claims = parse_jwt_claims(
1065 "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
1066 )
1067 .expect("claims");
1068 assert_eq!(claims.email.as_deref(), Some("test@example.com"));
1069 assert_eq!(claims.account_id.as_deref(), Some("acc_123"));
1070 assert_eq!(claims.plan.as_deref(), Some("plus"));
1071 }
1072
1073 #[test]
1074 fn session_refresh_due_uses_expiry_and_age() {
1075 let mut session = sample_session();
1076 let now = now_secs();
1077 session.obtained_at = now;
1078 session.refreshed_at = now;
1079 session.expires_at = Some(now + 3600);
1080 assert!(!session.is_refresh_due());
1081 session.expires_at = Some(now);
1082 assert!(session.is_refresh_due());
1083 }
1084
1085 #[test]
1086 #[serial]
1087 fn resolve_openai_auth_prefers_chatgpt_in_auto_mode() {
1088 let _guard = TestAuthDirGuard::new();
1089 let session = sample_session();
1090 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1091 .expect("save session");
1092 let resolved = resolve_openai_auth(
1093 &OpenAIAuthConfig::default(),
1094 AuthCredentialsStoreMode::File,
1095 Some("api-key".to_string()),
1096 )
1097 .expect("resolved auth");
1098 assert!(resolved.using_chatgpt());
1099 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1100 .expect("clear session");
1101 }
1102
1103 #[test]
1104 #[serial]
1105 fn resolve_openai_auth_auto_falls_back_to_api_key_without_session() {
1106 let _guard = TestAuthDirGuard::new();
1107 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1108 .expect("clear session");
1109 let resolved = resolve_openai_auth(
1110 &OpenAIAuthConfig::default(),
1111 AuthCredentialsStoreMode::File,
1112 Some("api-key".to_string()),
1113 )
1114 .expect("resolved auth");
1115 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1116 }
1117
1118 #[test]
1119 #[serial]
1120 fn resolve_openai_auth_api_key_mode_ignores_stored_chatgpt_session() {
1121 let _guard = TestAuthDirGuard::new();
1122 let session = sample_session();
1123 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1124 .expect("save session");
1125 let resolved = resolve_openai_auth(
1126 &OpenAIAuthConfig {
1127 preferred_method: OpenAIPreferredMethod::ApiKey,
1128 ..OpenAIAuthConfig::default()
1129 },
1130 AuthCredentialsStoreMode::File,
1131 Some("api-key".to_string()),
1132 )
1133 .expect("resolved auth");
1134 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1135 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1136 .expect("clear session");
1137 }
1138
1139 #[test]
1140 #[serial]
1141 fn resolve_openai_auth_chatgpt_mode_requires_stored_session() {
1142 let _guard = TestAuthDirGuard::new();
1143 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1144 .expect("clear session");
1145 let error = resolve_openai_auth(
1146 &OpenAIAuthConfig {
1147 preferred_method: OpenAIPreferredMethod::Chatgpt,
1148 ..OpenAIAuthConfig::default()
1149 },
1150 AuthCredentialsStoreMode::File,
1151 Some("api-key".to_string()),
1152 )
1153 .expect_err("chatgpt mode should require a stored session");
1154 assert!(error.to_string().contains("vtcode login openai"));
1155 }
1156
1157 #[test]
1158 #[serial]
1159 fn summarize_openai_credentials_reports_dual_source_notice() {
1160 let _guard = TestAuthDirGuard::new();
1161 let session = sample_session();
1162 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1163 .expect("save session");
1164 let overview = summarize_openai_credentials(
1165 &OpenAIAuthConfig::default(),
1166 AuthCredentialsStoreMode::File,
1167 Some("api-key".to_string()),
1168 )
1169 .expect("overview");
1170 assert_eq!(
1171 overview.active_source,
1172 Some(OpenAIResolvedAuthSource::ChatGpt)
1173 );
1174 assert!(overview.notice.is_some());
1175 assert!(overview.recommendation.is_some());
1176 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1177 .expect("clear session");
1178 }
1179
1180 #[test]
1181 #[serial]
1182 fn summarize_openai_credentials_respects_api_key_preference() {
1183 let _guard = TestAuthDirGuard::new();
1184 let session = sample_session();
1185 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1186 .expect("save session");
1187 let overview = summarize_openai_credentials(
1188 &OpenAIAuthConfig {
1189 preferred_method: OpenAIPreferredMethod::ApiKey,
1190 ..OpenAIAuthConfig::default()
1191 },
1192 AuthCredentialsStoreMode::File,
1193 Some("api-key".to_string()),
1194 )
1195 .expect("overview");
1196 assert_eq!(
1197 overview.active_source,
1198 Some(OpenAIResolvedAuthSource::ApiKey)
1199 );
1200 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1201 .expect("clear session");
1202 }
1203
1204 #[test]
1205 fn encrypted_file_round_trip_restores_session() {
1206 let session = sample_session();
1207 let encrypted = encrypt_session(&session).expect("encrypt");
1208 let decrypted = decrypt_session(&encrypted).expect("decrypt");
1209 assert_eq!(decrypted.account_id, session.account_id);
1210 assert_eq!(decrypted.email, session.email);
1211 assert_eq!(decrypted.plan, session.plan);
1212 }
1213
1214 #[test]
1215 #[serial]
1216 fn default_loader_falls_back_to_file_session() {
1217 let _guard = TestAuthDirGuard::new();
1218 let session = sample_session();
1219 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1220 .expect("save session");
1221
1222 let loaded = load_openai_chatgpt_session()
1223 .expect("load session")
1224 .expect("stored session should be found");
1225
1226 assert_eq!(loaded.account_id, session.account_id);
1227 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1228 .expect("clear session");
1229 }
1230
1231 #[test]
1232 #[serial]
1233 fn keyring_mode_loader_falls_back_to_file_session() {
1234 let _guard = TestAuthDirGuard::new();
1235 let session = sample_session();
1236 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1237 .expect("save session");
1238
1239 let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1240 .expect("load session")
1241 .expect("stored session should be found");
1242
1243 assert_eq!(loaded.email, session.email);
1244 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1245 .expect("clear session");
1246 }
1247
1248 #[test]
1249 fn active_api_bearer_token_falls_back_to_access_token() {
1250 let mut session = sample_session();
1251 session.openai_api_key.clear();
1252
1253 assert_eq!(active_api_bearer_token(&session), "oauth-access");
1254 }
1255
1256 #[test]
1257 fn parse_manual_callback_input_accepts_full_redirect_url() {
1258 let code = parse_openai_chatgpt_manual_callback_input(
1259 "http://localhost:1455/auth/callback?code=auth-code&state=test-state",
1260 "test-state",
1261 )
1262 .expect("manual input should parse");
1263 assert_eq!(code, "auth-code");
1264 }
1265
1266 #[test]
1267 fn parse_manual_callback_input_accepts_query_string() {
1268 let code = parse_openai_chatgpt_manual_callback_input(
1269 "code=auth-code&state=test-state",
1270 "test-state",
1271 )
1272 .expect("manual input should parse");
1273 assert_eq!(code, "auth-code");
1274 }
1275
1276 #[test]
1277 fn parse_manual_callback_input_rejects_bare_code() {
1278 let error = parse_openai_chatgpt_manual_callback_input("auth-code", "test-state")
1279 .expect_err("bare code should be rejected");
1280 assert!(
1281 error
1282 .to_string()
1283 .contains("full redirect url or query string")
1284 );
1285 }
1286
1287 #[test]
1288 fn parse_manual_callback_input_rejects_state_mismatch() {
1289 let error = parse_openai_chatgpt_manual_callback_input(
1290 "code=auth-code&state=wrong-state",
1291 "test-state",
1292 )
1293 .expect_err("state mismatch should fail");
1294 assert!(error.to_string().contains("state mismatch"));
1295 }
1296
1297 #[tokio::test]
1298 #[serial]
1299 async fn refresh_lock_serializes_parallel_acquisition() {
1300 let _guard = TestAuthDirGuard::new();
1301 let first = tokio::spawn(async {
1302 let _lock = acquire_refresh_lock().await.expect("first lock");
1303 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1304 });
1305 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1306
1307 let start = std::time::Instant::now();
1308 let second = tokio::spawn(async {
1309 let _lock = acquire_refresh_lock().await.expect("second lock");
1310 });
1311
1312 first.await.expect("first task");
1313 second.await.expect("second task");
1314 assert!(start.elapsed() >= std::time::Duration::from_millis(100));
1315 }
1316}