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 clear_session_from_all_stores()
461}
462
463pub fn clear_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
464 match mode.effective_mode() {
465 AuthCredentialsStoreMode::Keyring => clear_session_from_keyring(),
466 AuthCredentialsStoreMode::File => clear_session_from_file(),
467 AuthCredentialsStoreMode::Auto => unreachable!(),
468 }
469}
470
471pub fn get_openai_chatgpt_auth_status() -> Result<OpenAIChatGptAuthStatus> {
472 get_openai_chatgpt_auth_status_with_mode(AuthCredentialsStoreMode::default())
473}
474
475pub fn get_openai_chatgpt_auth_status_with_mode(
476 mode: AuthCredentialsStoreMode,
477) -> Result<OpenAIChatGptAuthStatus> {
478 let Some(session) = load_openai_chatgpt_session_with_mode(mode)? else {
479 return Ok(OpenAIChatGptAuthStatus::NotAuthenticated);
480 };
481 let now = now_secs();
482 Ok(OpenAIChatGptAuthStatus::Authenticated {
483 label: session
484 .email
485 .clone()
486 .or_else(|| session.plan.clone())
487 .or_else(|| session.account_id.clone()),
488 age_seconds: now.saturating_sub(session.obtained_at),
489 expires_in: session
490 .expires_at
491 .map(|expires_at| expires_at.saturating_sub(now)),
492 })
493}
494
495pub async fn refresh_openai_chatgpt_session_from_refresh_token(
496 refresh_token: &str,
497 storage_mode: AuthCredentialsStoreMode,
498) -> Result<OpenAIChatGptSession> {
499 let _lock = acquire_refresh_lock().await?;
500 refresh_openai_chatgpt_session_without_lock(refresh_token, storage_mode).await
501}
502
503pub async fn refresh_openai_chatgpt_session_with_mode(
504 mode: AuthCredentialsStoreMode,
505) -> Result<OpenAIChatGptSession> {
506 let session = load_openai_chatgpt_session_with_mode(mode)?
507 .ok_or_else(|| anyhow!("Run vtcode login openai"))?;
508 refresh_openai_chatgpt_session_from_snapshot(&session, mode).await
509}
510
511async fn refresh_openai_chatgpt_session_from_snapshot(
512 session: &OpenAIChatGptSession,
513 storage_mode: AuthCredentialsStoreMode,
514) -> Result<OpenAIChatGptSession> {
515 let _lock = acquire_refresh_lock().await?;
516 if let Some(current) = load_openai_chatgpt_session_with_mode(storage_mode)?
517 && session_has_newer_refresh_state(¤t, session)
518 {
519 return Ok(current);
520 }
521 refresh_openai_chatgpt_session_without_lock(&session.refresh_token, storage_mode).await
522}
523
524async fn refresh_openai_chatgpt_session_without_lock(
525 refresh_token: &str,
526 storage_mode: AuthCredentialsStoreMode,
527) -> Result<OpenAIChatGptSession> {
528 let response = Client::new()
529 .post(OPENAI_TOKEN_URL)
530 .header("Content-Type", "application/x-www-form-urlencoded")
531 .body(format!(
532 "grant_type=refresh_token&client_id={}&refresh_token={}",
533 urlencoding::encode(OPENAI_CLIENT_ID),
534 urlencoding::encode(refresh_token),
535 ))
536 .send()
537 .await
538 .context("failed to refresh openai chatgpt token")?;
539 response
540 .error_for_status_ref()
541 .map_err(classify_refresh_error)?;
542 let token_response: OpenAITokenResponse = response
543 .json()
544 .await
545 .context("failed to parse openai refresh response")?;
546
547 let session = build_session_from_token_response(token_response).await?;
548 save_openai_chatgpt_session_with_mode(&session, storage_mode)?;
549 Ok(session)
550}
551
552async fn build_session_from_token_response(
553 token_response: OpenAITokenResponse,
554) -> Result<OpenAIChatGptSession> {
555 let id_claims = parse_jwt_claims(&token_response.id_token)?;
556 let access_claims = parse_jwt_claims(&token_response.access_token).ok();
557 let api_key = match exchange_openai_chatgpt_api_key(&token_response.id_token).await {
558 Ok(api_key) => api_key,
559 Err(err) => {
560 tracing::warn!(
561 "openai api-key exchange unavailable, falling back to oauth access token: {err}"
562 );
563 String::new()
564 }
565 };
566 let now = now_secs();
567 Ok(OpenAIChatGptSession {
568 openai_api_key: api_key,
569 id_token: token_response.id_token,
570 access_token: token_response.access_token,
571 refresh_token: token_response.refresh_token,
572 account_id: access_claims
573 .as_ref()
574 .and_then(|claims| claims.account_id.clone())
575 .or(id_claims.account_id),
576 email: id_claims.email.or_else(|| {
577 access_claims
578 .as_ref()
579 .and_then(|claims| claims.email.clone())
580 }),
581 plan: access_claims
582 .as_ref()
583 .and_then(|claims| claims.plan.clone())
584 .or(id_claims.plan),
585 obtained_at: now,
586 refreshed_at: now,
587 expires_at: token_response
588 .expires_in
589 .map(|secs| now.saturating_add(secs)),
590 })
591}
592
593async fn exchange_openai_chatgpt_api_key(id_token: &str) -> Result<String> {
594 #[derive(Deserialize)]
595 struct ExchangeResponse {
596 access_token: String,
597 }
598
599 let exchange: ExchangeResponse = Client::new()
600 .post(OPENAI_TOKEN_URL)
601 .header("Content-Type", "application/x-www-form-urlencoded")
602 .body(format!(
603 "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}",
604 urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
605 urlencoding::encode(OPENAI_CLIENT_ID),
606 urlencoding::encode("openai-api-key"),
607 urlencoding::encode(id_token),
608 urlencoding::encode("urn:ietf:params:oauth:token-type:id_token"),
609 ))
610 .send()
611 .await
612 .context("failed to exchange openai id token for api key")?
613 .error_for_status()
614 .context("openai api-key exchange failed")?
615 .json()
616 .await
617 .context("failed to parse openai api-key exchange response")?;
618
619 Ok(exchange.access_token)
620}
621
622#[derive(Debug, Deserialize)]
623struct OpenAITokenResponse {
624 id_token: String,
625 access_token: String,
626 refresh_token: String,
627 #[serde(default)]
628 expires_in: Option<u64>,
629}
630
631#[derive(Debug, Deserialize)]
632struct IdTokenClaims {
633 #[serde(default)]
634 email: Option<String>,
635 #[serde(rename = "https://api.openai.com/profile", default)]
636 profile: Option<ProfileClaims>,
637 #[serde(rename = "https://api.openai.com/auth", default)]
638 auth: Option<AuthClaims>,
639}
640
641#[derive(Debug, Deserialize)]
642struct ProfileClaims {
643 #[serde(default)]
644 email: Option<String>,
645}
646
647#[derive(Debug, Deserialize)]
648struct AuthClaims {
649 #[serde(default)]
650 chatgpt_plan_type: Option<String>,
651 #[serde(default)]
652 chatgpt_account_id: Option<String>,
653}
654
655#[derive(Debug)]
656struct ParsedIdTokenClaims {
657 email: Option<String>,
658 account_id: Option<String>,
659 plan: Option<String>,
660}
661
662fn parse_jwt_claims(jwt: &str) -> Result<ParsedIdTokenClaims> {
663 let mut parts = jwt.split('.');
664 let (_, payload_b64, _) = match (parts.next(), parts.next(), parts.next()) {
665 (Some(header), Some(payload), Some(signature))
666 if !header.is_empty() && !payload.is_empty() && !signature.is_empty() =>
667 {
668 (header, payload, signature)
669 }
670 _ => bail!("invalid openai id token"),
671 };
672
673 let payload = URL_SAFE_NO_PAD
674 .decode(payload_b64)
675 .context("failed to decode openai id token payload")?;
676 let claims: IdTokenClaims =
677 serde_json::from_slice(&payload).context("failed to parse openai id token payload")?;
678
679 Ok(ParsedIdTokenClaims {
680 email: claims
681 .email
682 .or_else(|| claims.profile.and_then(|profile| profile.email)),
683 account_id: claims
684 .auth
685 .as_ref()
686 .and_then(|auth| auth.chatgpt_account_id.clone()),
687 plan: claims.auth.and_then(|auth| auth.chatgpt_plan_type),
688 })
689}
690
691fn extract_query_value(query: &str, key: &str) -> Option<String> {
692 query
693 .trim_start_matches('?')
694 .split('&')
695 .filter_map(|pair| {
696 let (pair_key, pair_value) = pair.split_once('=')?;
697 (pair_key == key)
698 .then(|| {
699 urlencoding::decode(pair_value)
700 .ok()
701 .map(|value| value.into_owned())
702 })
703 .flatten()
704 })
705 .find(|value| !value.is_empty())
706}
707
708fn session_has_newer_refresh_state(
709 current: &OpenAIChatGptSession,
710 previous: &OpenAIChatGptSession,
711) -> bool {
712 current.refresh_token != previous.refresh_token
713 || current.refreshed_at > previous.refreshed_at
714 || current.obtained_at > previous.obtained_at
715}
716
717struct RefreshLockGuard {
718 file: fs::File,
719}
720
721impl Drop for RefreshLockGuard {
722 fn drop(&mut self) {
723 let _ = FileExt::unlock(&self.file);
724 }
725}
726
727async fn acquire_refresh_lock() -> Result<RefreshLockGuard> {
728 let path = auth_storage_dir()?.join(OPENAI_REFRESH_LOCK_FILE);
729 let file = OpenOptions::new()
730 .create(true)
731 .read(true)
732 .write(true)
733 .truncate(false)
734 .open(&path)
735 .with_context(|| format!("failed to open openai refresh lock {}", path.display()))?;
736 let file = tokio::task::spawn_blocking(move || {
737 file.lock_exclusive()
738 .context("failed to acquire openai refresh lock")?;
739 Ok::<_, anyhow::Error>(file)
740 })
741 .await
742 .context("openai refresh lock task failed")??;
743 Ok(RefreshLockGuard { file })
744}
745
746fn classify_refresh_error(err: reqwest::Error) -> anyhow::Error {
747 let status = err.status();
748 let message = err.to_string();
749 if status.is_some_and(|status| status == reqwest::StatusCode::BAD_REQUEST)
750 && (message.contains("invalid_grant") || message.contains("refresh_token"))
751 {
752 if let Err(clear_err) = clear_session_from_all_stores() {
753 tracing::warn!(
754 "failed to clear expired openai chatgpt session across all stores: {clear_err}"
755 );
756 }
757 anyhow!("Your ChatGPT session expired. Run `vtcode login openai` again.")
758 } else {
759 anyhow!(message)
760 }
761}
762
763fn clear_session_from_all_stores() -> Result<()> {
764 let mut errors = Vec::new();
765
766 if let Err(err) = clear_session_from_keyring() {
767 errors.push(err.to_string());
768 }
769 if let Err(err) = clear_session_from_file() {
770 errors.push(err.to_string());
771 }
772
773 if errors.is_empty() {
774 Ok(())
775 } else {
776 Err(anyhow!(
777 "failed to clear openai session from all stores: {}",
778 errors.join("; ")
779 ))
780 }
781}
782
783fn save_session_to_keyring(serialized: &str) -> Result<()> {
784 let entry = keyring::Entry::new(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER)
785 .context("failed to access keyring for openai session")?;
786 entry
787 .set_password(serialized)
788 .context("failed to store openai session in keyring")?;
789 Ok(())
790}
791
792fn persist_session_to_keyring_or_file(
793 session: &OpenAIChatGptSession,
794 serialized: &str,
795) -> Result<()> {
796 match save_session_to_keyring(serialized) {
797 Ok(()) => match load_session_from_keyring_decoded() {
798 Ok(Some(_)) => Ok(()),
799 Ok(None) => {
800 tracing::warn!(
801 "openai session keyring write did not round-trip; falling back to encrypted file storage"
802 );
803 save_session_to_file(session)
804 }
805 Err(err) => {
806 tracing::warn!(
807 "openai session keyring verification failed, falling back to encrypted file storage: {err}"
808 );
809 save_session_to_file(session)
810 }
811 },
812 Err(err) => {
813 tracing::warn!(
814 "failed to persist openai session in keyring, falling back to encrypted file storage: {err}"
815 );
816 save_session_to_file(session)
817 .context("failed to persist openai session after keyring fallback")
818 }
819 }
820}
821
822fn decode_session_from_keyring(serialized: String) -> Result<OpenAIChatGptSession> {
823 serde_json::from_str(&serialized).context("failed to decode openai session")
824}
825
826fn load_session_from_keyring_decoded() -> Result<Option<OpenAIChatGptSession>> {
827 load_session_from_keyring()?
828 .map(decode_session_from_keyring)
829 .transpose()
830}
831
832fn load_preferred_openai_chatgpt_session(
833 mode: AuthCredentialsStoreMode,
834) -> Result<Option<OpenAIChatGptSession>> {
835 match mode {
836 AuthCredentialsStoreMode::Keyring => match load_session_from_keyring_decoded() {
837 Ok(Some(session)) => Ok(Some(session)),
838 Ok(None) => load_session_from_file(),
839 Err(err) => {
840 tracing::warn!(
841 "failed to load openai session from keyring, falling back to encrypted file: {err}"
842 );
843 load_session_from_file()
844 }
845 },
846 AuthCredentialsStoreMode::File => {
847 if let Some(session) = load_session_from_file()? {
848 return Ok(Some(session));
849 }
850 load_session_from_keyring_decoded()
851 }
852 AuthCredentialsStoreMode::Auto => unreachable!(),
853 }
854}
855
856fn load_session_from_keyring() -> Result<Option<String>> {
857 let entry = match keyring::Entry::new(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
858 Ok(entry) => entry,
859 Err(_) => return Ok(None),
860 };
861
862 match entry.get_password() {
863 Ok(value) => Ok(Some(value)),
864 Err(keyring::Error::NoEntry) => Ok(None),
865 Err(err) => Err(anyhow!("failed to read openai session from keyring: {err}")),
866 }
867}
868
869fn clear_session_from_keyring() -> Result<()> {
870 let entry = match keyring::Entry::new(OPENAI_STORAGE_SERVICE, OPENAI_STORAGE_USER) {
871 Ok(entry) => entry,
872 Err(_) => return Ok(()),
873 };
874
875 match entry.delete_credential() {
876 Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
877 Err(err) => Err(anyhow!(
878 "failed to clear openai session keyring entry: {err}"
879 )),
880 }
881}
882
883fn save_session_to_file(session: &OpenAIChatGptSession) -> Result<()> {
884 let encrypted = encrypt_session(session)?;
885 let path = get_session_path()?;
886 fs::write(&path, serde_json::to_vec_pretty(&encrypted)?)
887 .context("failed to persist openai session file")?;
888 #[cfg(unix)]
889 {
890 use std::os::unix::fs::PermissionsExt;
891 fs::set_permissions(&path, fs::Permissions::from_mode(0o600))
892 .context("failed to set openai session file permissions")?;
893 }
894 Ok(())
895}
896
897fn load_session_from_file() -> Result<Option<OpenAIChatGptSession>> {
898 let path = get_session_path()?;
899 let data = match fs::read(path) {
900 Ok(data) => data,
901 Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
902 Err(err) => return Err(anyhow!("failed to read openai session file: {err}")),
903 };
904
905 let encrypted: EncryptedSession =
906 serde_json::from_slice(&data).context("failed to decode openai session file")?;
907 Ok(Some(decrypt_session(&encrypted)?))
908}
909
910fn clear_session_from_file() -> Result<()> {
911 let path = get_session_path()?;
912 match fs::remove_file(path) {
913 Ok(()) => Ok(()),
914 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
915 Err(err) => Err(anyhow!("failed to delete openai session file: {err}")),
916 }
917}
918
919fn get_session_path() -> Result<PathBuf> {
920 Ok(auth_storage_dir()?.join(OPENAI_SESSION_FILE))
921}
922
923#[derive(Debug, Serialize, Deserialize)]
924struct EncryptedSession {
925 nonce: String,
926 ciphertext: String,
927 version: u8,
928}
929
930fn encrypt_session(session: &OpenAIChatGptSession) -> Result<EncryptedSession> {
931 let key = derive_encryption_key()?;
932 let rng = SystemRandom::new();
933 let mut nonce_bytes = [0u8; NONCE_LEN];
934 rng.fill(&mut nonce_bytes)
935 .map_err(|_| anyhow!("failed to generate nonce"))?;
936
937 let mut ciphertext =
938 serde_json::to_vec(session).context("failed to serialize openai session for encryption")?;
939 let nonce = Nonce::assume_unique_for_key(nonce_bytes);
940 key.seal_in_place_append_tag(nonce, Aad::empty(), &mut ciphertext)
941 .map_err(|_| anyhow!("failed to encrypt openai session"))?;
942
943 Ok(EncryptedSession {
944 nonce: STANDARD.encode(nonce_bytes),
945 ciphertext: STANDARD.encode(ciphertext),
946 version: 1,
947 })
948}
949
950fn decrypt_session(encrypted: &EncryptedSession) -> Result<OpenAIChatGptSession> {
951 if encrypted.version != 1 {
952 bail!("unsupported openai session encryption format");
953 }
954
955 let nonce_bytes = STANDARD
956 .decode(&encrypted.nonce)
957 .context("failed to decode openai session nonce")?;
958 let nonce_array: [u8; NONCE_LEN] = nonce_bytes
959 .try_into()
960 .map_err(|_| anyhow!("invalid openai session nonce length"))?;
961 let mut ciphertext = STANDARD
962 .decode(&encrypted.ciphertext)
963 .context("failed to decode openai session ciphertext")?;
964
965 let key = derive_encryption_key()?;
966 let plaintext = key
967 .open_in_place(
968 Nonce::assume_unique_for_key(nonce_array),
969 Aad::empty(),
970 &mut ciphertext,
971 )
972 .map_err(|_| anyhow!("failed to decrypt openai session"))?;
973 serde_json::from_slice(plaintext).context("failed to parse decrypted openai session")
974}
975
976fn derive_encryption_key() -> Result<LessSafeKey> {
977 use ring::digest::{SHA256, digest};
978
979 let mut key_material = Vec::new();
980 if let Ok(hostname) = hostname::get() {
981 key_material.extend_from_slice(hostname.as_encoded_bytes());
982 }
983
984 #[cfg(unix)]
985 {
986 key_material.extend_from_slice(&nix::unistd::getuid().as_raw().to_le_bytes());
987 }
988 #[cfg(not(unix))]
989 {
990 if let Ok(user) = std::env::var("USER").or_else(|_| std::env::var("USERNAME")) {
991 key_material.extend_from_slice(user.as_bytes());
992 }
993 }
994
995 key_material.extend_from_slice(b"vtcode-openai-chatgpt-oauth-v1");
996 let hash = digest(&SHA256, &key_material);
997 let key_bytes: &[u8; 32] = hash.as_ref()[..32]
998 .try_into()
999 .context("openai session encryption key was too short")?;
1000 let unbound = UnboundKey::new(&aead::AES_256_GCM, key_bytes)
1001 .map_err(|_| anyhow!("invalid openai session encryption key"))?;
1002 Ok(LessSafeKey::new(unbound))
1003}
1004
1005fn now_secs() -> u64 {
1006 std::time::SystemTime::now()
1007 .duration_since(std::time::UNIX_EPOCH)
1008 .map(|duration| duration.as_secs())
1009 .unwrap_or(0)
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014 use super::*;
1015 use assert_fs::TempDir;
1016 use serial_test::serial;
1017
1018 struct TestAuthDirGuard {
1019 temp_dir: Option<TempDir>,
1020 previous: Option<PathBuf>,
1021 }
1022
1023 impl TestAuthDirGuard {
1024 fn new() -> Self {
1025 let temp_dir = TempDir::new().expect("create temp auth dir");
1026 let previous = crate::storage_paths::auth_storage_dir_override_for_tests()
1027 .expect("read auth dir override");
1028 crate::storage_paths::set_auth_storage_dir_override_for_tests(Some(
1029 temp_dir.path().to_path_buf(),
1030 ))
1031 .expect("set temp auth dir override");
1032 Self {
1033 temp_dir: Some(temp_dir),
1034 previous,
1035 }
1036 }
1037 }
1038
1039 impl Drop for TestAuthDirGuard {
1040 fn drop(&mut self) {
1041 crate::storage_paths::set_auth_storage_dir_override_for_tests(self.previous.clone())
1042 .expect("restore auth dir override");
1043 if let Some(temp_dir) = self.temp_dir.take() {
1044 temp_dir.close().expect("remove temp auth dir");
1045 }
1046 }
1047 }
1048
1049 fn sample_session() -> OpenAIChatGptSession {
1050 OpenAIChatGptSession {
1051 openai_api_key: "api-key".to_string(),
1052 id_token: "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig".to_string(),
1053 access_token: "oauth-access".to_string(),
1054 refresh_token: "refresh-token".to_string(),
1055 account_id: Some("acc_123".to_string()),
1056 email: Some("test@example.com".to_string()),
1057 plan: Some("plus".to_string()),
1058 obtained_at: 10,
1059 refreshed_at: 10,
1060 expires_at: Some(now_secs() + 3600),
1061 }
1062 }
1063
1064 #[test]
1065 fn auth_url_contains_expected_openai_parameters() {
1066 let challenge = PkceChallenge {
1067 code_verifier: "verifier".to_string(),
1068 code_challenge: "challenge".to_string(),
1069 code_challenge_method: "S256".to_string(),
1070 };
1071
1072 let url = get_openai_chatgpt_auth_url(&challenge, 1455, "test-state");
1073 assert!(url.starts_with(OPENAI_AUTH_URL));
1074 assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
1075 assert!(url.contains("code_challenge=challenge"));
1076 assert!(url.contains("codex_cli_simplified_flow=true"));
1077 assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
1078 assert!(url.contains("state=test-state"));
1079 }
1080
1081 #[test]
1082 fn parse_jwt_claims_extracts_openai_claims() {
1083 let claims = parse_jwt_claims(
1084 "aGVhZGVy.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJodHRwczovL2FwaS5vcGVuYWkuY29tL2F1dGgiOnsiY2hhdGdwdF9hY2NvdW50X2lkIjoiYWNjXzEyMyIsImNoYXRncHRfcGxhbl90eXBlIjoicGx1cyJ9fQ.sig",
1085 )
1086 .expect("claims");
1087 assert_eq!(claims.email.as_deref(), Some("test@example.com"));
1088 assert_eq!(claims.account_id.as_deref(), Some("acc_123"));
1089 assert_eq!(claims.plan.as_deref(), Some("plus"));
1090 }
1091
1092 #[test]
1093 fn session_refresh_due_uses_expiry_and_age() {
1094 let mut session = sample_session();
1095 let now = now_secs();
1096 session.obtained_at = now;
1097 session.refreshed_at = now;
1098 session.expires_at = Some(now + 3600);
1099 assert!(!session.is_refresh_due());
1100 session.expires_at = Some(now);
1101 assert!(session.is_refresh_due());
1102 }
1103
1104 #[test]
1105 #[serial]
1106 fn resolve_openai_auth_prefers_chatgpt_in_auto_mode() {
1107 let _guard = TestAuthDirGuard::new();
1108 let session = sample_session();
1109 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1110 .expect("save session");
1111 let resolved = resolve_openai_auth(
1112 &OpenAIAuthConfig::default(),
1113 AuthCredentialsStoreMode::File,
1114 Some("api-key".to_string()),
1115 )
1116 .expect("resolved auth");
1117 assert!(resolved.using_chatgpt());
1118 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1119 .expect("clear session");
1120 }
1121
1122 #[test]
1123 #[serial]
1124 fn resolve_openai_auth_auto_falls_back_to_api_key_without_session() {
1125 let _guard = TestAuthDirGuard::new();
1126 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1127 .expect("clear session");
1128 let resolved = resolve_openai_auth(
1129 &OpenAIAuthConfig::default(),
1130 AuthCredentialsStoreMode::File,
1131 Some("api-key".to_string()),
1132 )
1133 .expect("resolved auth");
1134 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1135 }
1136
1137 #[test]
1138 #[serial]
1139 fn resolve_openai_auth_api_key_mode_ignores_stored_chatgpt_session() {
1140 let _guard = TestAuthDirGuard::new();
1141 let session = sample_session();
1142 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1143 .expect("save session");
1144 let resolved = resolve_openai_auth(
1145 &OpenAIAuthConfig {
1146 preferred_method: OpenAIPreferredMethod::ApiKey,
1147 ..OpenAIAuthConfig::default()
1148 },
1149 AuthCredentialsStoreMode::File,
1150 Some("api-key".to_string()),
1151 )
1152 .expect("resolved auth");
1153 assert!(matches!(resolved, OpenAIResolvedAuth::ApiKey { .. }));
1154 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1155 .expect("clear session");
1156 }
1157
1158 #[test]
1159 #[serial]
1160 fn resolve_openai_auth_chatgpt_mode_requires_stored_session() {
1161 let _guard = TestAuthDirGuard::new();
1162 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1163 .expect("clear session");
1164 let error = resolve_openai_auth(
1165 &OpenAIAuthConfig {
1166 preferred_method: OpenAIPreferredMethod::Chatgpt,
1167 ..OpenAIAuthConfig::default()
1168 },
1169 AuthCredentialsStoreMode::File,
1170 Some("api-key".to_string()),
1171 )
1172 .expect_err("chatgpt mode should require a stored session");
1173 assert!(error.to_string().contains("vtcode login openai"));
1174 }
1175
1176 #[test]
1177 #[serial]
1178 fn summarize_openai_credentials_reports_dual_source_notice() {
1179 let _guard = TestAuthDirGuard::new();
1180 let session = sample_session();
1181 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1182 .expect("save session");
1183 let overview = summarize_openai_credentials(
1184 &OpenAIAuthConfig::default(),
1185 AuthCredentialsStoreMode::File,
1186 Some("api-key".to_string()),
1187 )
1188 .expect("overview");
1189 assert_eq!(
1190 overview.active_source,
1191 Some(OpenAIResolvedAuthSource::ChatGpt)
1192 );
1193 assert!(overview.notice.is_some());
1194 assert!(overview.recommendation.is_some());
1195 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1196 .expect("clear session");
1197 }
1198
1199 #[test]
1200 #[serial]
1201 fn summarize_openai_credentials_respects_api_key_preference() {
1202 let _guard = TestAuthDirGuard::new();
1203 let session = sample_session();
1204 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1205 .expect("save session");
1206 let overview = summarize_openai_credentials(
1207 &OpenAIAuthConfig {
1208 preferred_method: OpenAIPreferredMethod::ApiKey,
1209 ..OpenAIAuthConfig::default()
1210 },
1211 AuthCredentialsStoreMode::File,
1212 Some("api-key".to_string()),
1213 )
1214 .expect("overview");
1215 assert_eq!(
1216 overview.active_source,
1217 Some(OpenAIResolvedAuthSource::ApiKey)
1218 );
1219 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1220 .expect("clear session");
1221 }
1222
1223 #[test]
1224 fn encrypted_file_round_trip_restores_session() {
1225 let session = sample_session();
1226 let encrypted = encrypt_session(&session).expect("encrypt");
1227 let decrypted = decrypt_session(&encrypted).expect("decrypt");
1228 assert_eq!(decrypted.account_id, session.account_id);
1229 assert_eq!(decrypted.email, session.email);
1230 assert_eq!(decrypted.plan, session.plan);
1231 }
1232
1233 #[test]
1234 #[serial]
1235 fn default_loader_falls_back_to_file_session() {
1236 let _guard = TestAuthDirGuard::new();
1237 let session = sample_session();
1238 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1239 .expect("save session");
1240
1241 let loaded = load_openai_chatgpt_session()
1242 .expect("load session")
1243 .expect("stored session should be found");
1244
1245 assert_eq!(loaded.account_id, session.account_id);
1246 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1247 .expect("clear session");
1248 }
1249
1250 #[test]
1251 #[serial]
1252 fn keyring_mode_loader_falls_back_to_file_session() {
1253 let _guard = TestAuthDirGuard::new();
1254 let session = sample_session();
1255 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1256 .expect("save session");
1257
1258 let loaded = load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1259 .expect("load session")
1260 .expect("stored session should be found");
1261
1262 assert_eq!(loaded.email, session.email);
1263 clear_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1264 .expect("clear session");
1265 }
1266
1267 #[test]
1268 #[serial]
1269 fn clear_openai_chatgpt_session_removes_file_and_keyring_sessions() {
1270 let _guard = TestAuthDirGuard::new();
1271 let session = sample_session();
1272 save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::File)
1273 .expect("save file session");
1274
1275 if save_openai_chatgpt_session_with_mode(&session, AuthCredentialsStoreMode::Keyring)
1276 .is_err()
1277 {
1278 clear_openai_chatgpt_session().expect("clear session");
1279 assert!(
1280 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1281 .expect("load file session")
1282 .is_none()
1283 );
1284 return;
1285 }
1286
1287 clear_openai_chatgpt_session().expect("clear session");
1288 assert!(
1289 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::File)
1290 .expect("load file session")
1291 .is_none()
1292 );
1293 assert!(
1294 load_openai_chatgpt_session_with_mode(AuthCredentialsStoreMode::Keyring)
1295 .expect("load keyring session")
1296 .is_none()
1297 );
1298 }
1299
1300 #[test]
1301 fn active_api_bearer_token_falls_back_to_access_token() {
1302 let mut session = sample_session();
1303 session.openai_api_key.clear();
1304
1305 assert_eq!(active_api_bearer_token(&session), "oauth-access");
1306 }
1307
1308 #[test]
1309 fn parse_manual_callback_input_accepts_full_redirect_url() {
1310 let code = parse_openai_chatgpt_manual_callback_input(
1311 "http://localhost:1455/auth/callback?code=auth-code&state=test-state",
1312 "test-state",
1313 )
1314 .expect("manual input should parse");
1315 assert_eq!(code, "auth-code");
1316 }
1317
1318 #[test]
1319 fn parse_manual_callback_input_accepts_query_string() {
1320 let code = parse_openai_chatgpt_manual_callback_input(
1321 "code=auth-code&state=test-state",
1322 "test-state",
1323 )
1324 .expect("manual input should parse");
1325 assert_eq!(code, "auth-code");
1326 }
1327
1328 #[test]
1329 fn parse_manual_callback_input_rejects_bare_code() {
1330 let error = parse_openai_chatgpt_manual_callback_input("auth-code", "test-state")
1331 .expect_err("bare code should be rejected");
1332 assert!(
1333 error
1334 .to_string()
1335 .contains("full redirect url or query string")
1336 );
1337 }
1338
1339 #[test]
1340 fn parse_manual_callback_input_rejects_state_mismatch() {
1341 let error = parse_openai_chatgpt_manual_callback_input(
1342 "code=auth-code&state=wrong-state",
1343 "test-state",
1344 )
1345 .expect_err("state mismatch should fail");
1346 assert!(error.to_string().contains("state mismatch"));
1347 }
1348
1349 #[tokio::test]
1350 #[serial]
1351 async fn refresh_lock_serializes_parallel_acquisition() {
1352 let _guard = TestAuthDirGuard::new();
1353 let first = tokio::spawn(async {
1354 let _lock = acquire_refresh_lock().await.expect("first lock");
1355 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1356 });
1357 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1358
1359 let start = std::time::Instant::now();
1360 let second = tokio::spawn(async {
1361 let _lock = acquire_refresh_lock().await.expect("second lock");
1362 });
1363
1364 first.await.expect("first task");
1365 second.await.expect("second task");
1366 assert!(start.elapsed() >= std::time::Duration::from_millis(100));
1367 }
1368}