Skip to main content

vtcode_auth/openai_chatgpt_oauth/
refresh.rs

1//! Token exchange, refresh, storage wrappers, and refresh-lock helpers.
2
3use anyhow::{Context, Result, anyhow, bail};
4use fs2::FileExt;
5use serde::Deserialize;
6use std::fs;
7use std::fs::OpenOptions;
8
9use crate::openai_refresh_policy::{RefreshFailureAction, classify_refresh_failure};
10use crate::openai_session_storage::OpenAiSessionStorage;
11use crate::storage_paths::auth_storage_dir;
12
13pub use super::super::credentials::AuthCredentialsStoreMode;
14use super::super::pkce::PkceChallenge;
15use super::jwt::{parse_jwt_claims, parse_jwt_exp};
16use super::session::{OpenAIChatGptAuthStatus, OpenAIChatGptSession, active_api_bearer_token};
17use super::{
18    MAX_ERROR_BODY_BYTES, OPENAI_CALLBACK_PATH, OPENAI_REFRESH_LOCK_FILE, OPENAI_TOKEN_URL, now_secs,
19    resolve_oauth_client_identity,
20};
21
22/// Exchange an authorization code for OAuth tokens.
23pub async fn exchange_openai_chatgpt_code_for_tokens(
24    code: &str,
25    challenge: &PkceChallenge,
26    callback_port: u16,
27) -> Result<OpenAIChatGptSession> {
28    let redirect_uri = format!("http://localhost:{callback_port}{OPENAI_CALLBACK_PATH}");
29    let identity = resolve_oauth_client_identity()?;
30    let body = format!(
31        "grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}",
32        urlencoding::encode(code),
33        urlencoding::encode(&redirect_uri),
34        urlencoding::encode(&identity.client_id),
35        urlencoding::encode(&challenge.code_verifier),
36    );
37
38    let token_client = vtcode_commons::http::create_default_client_without_redirects()
39        .context("failed to build openai token client")?;
40    let token_response: OpenAITokenResponse =
41        vtcode_commons::http::send_with_retry_transport_only(&crate::oauth_token_retry_policy(), || {
42            token_client
43                .post(OPENAI_TOKEN_URL)
44                .header("Content-Type", "application/x-www-form-urlencoded")
45                .body(body.clone())
46                .send()
47        })
48        .await
49        .context("failed to exchange openai authorization code")?
50        .error_for_status()
51        .context("openai authorization-code exchange failed")?
52        .json()
53        .await
54        .context("failed to parse openai authorization-code response")?;
55
56    build_session_from_token_response(token_response).await
57}
58pub fn save_openai_chatgpt_session(session: &OpenAIChatGptSession) -> Result<()> {
59    save_openai_chatgpt_session_with_mode(session, AuthCredentialsStoreMode::default())
60}
61
62pub fn save_openai_chatgpt_session_with_mode(
63    session: &OpenAIChatGptSession,
64    mode: AuthCredentialsStoreMode,
65) -> Result<()> {
66    OpenAiSessionStorage::new().save(session, mode)
67}
68
69pub fn load_openai_chatgpt_session() -> Result<Option<OpenAIChatGptSession>> {
70    OpenAiSessionStorage::new().load(AuthCredentialsStoreMode::Keyring)
71}
72
73pub fn load_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<Option<OpenAIChatGptSession>> {
74    OpenAiSessionStorage::new().load(mode)
75}
76
77pub fn clear_openai_chatgpt_session() -> Result<()> {
78    OpenAiSessionStorage::new().clear_all()
79}
80
81pub fn clear_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<()> {
82    OpenAiSessionStorage::new().clear(mode)
83}
84
85pub fn get_openai_chatgpt_auth_status() -> Result<OpenAIChatGptAuthStatus> {
86    get_openai_chatgpt_auth_status_with_mode(AuthCredentialsStoreMode::default())
87}
88
89pub fn get_openai_chatgpt_auth_status_with_mode(mode: AuthCredentialsStoreMode) -> Result<OpenAIChatGptAuthStatus> {
90    let Some(session) = load_openai_chatgpt_session_with_mode(mode)? else {
91        return Ok(OpenAIChatGptAuthStatus::NotAuthenticated);
92    };
93    let now = now_secs();
94    Ok(OpenAIChatGptAuthStatus::Authenticated {
95        label: session
96            .email
97            .clone()
98            .or_else(|| session.plan.clone())
99            .or_else(|| session.account_id.clone()),
100        age_seconds: now.saturating_sub(session.obtained_at),
101        expires_in: session.expires_at.map(|expires_at| expires_at.saturating_sub(now)),
102    })
103}
104
105pub async fn refresh_openai_chatgpt_session_with_mode(mode: AuthCredentialsStoreMode) -> Result<OpenAIChatGptSession> {
106    let session = load_openai_chatgpt_session_with_mode(mode)?.ok_or_else(|| anyhow!("Run vtcode login openai"))?;
107    refresh_openai_chatgpt_session_from_snapshot(&session, mode).await
108}
109
110pub(super) async fn refresh_openai_chatgpt_session_from_snapshot(
111    session: &OpenAIChatGptSession,
112    storage_mode: AuthCredentialsStoreMode,
113) -> Result<OpenAIChatGptSession> {
114    let _lock = acquire_refresh_lock().await?;
115    if let Some(current) = load_openai_chatgpt_session_with_mode(storage_mode)?
116        && session_has_newer_refresh_state(&current, session)
117    {
118        return Ok(current);
119    }
120    refresh_openai_chatgpt_session_without_lock(session, storage_mode).await
121}
122
123/// Refresh the ChatGPT session using the stored refresh token.
124///
125/// The response is parsed as [`OpenAIRefreshResponse`] with independently
126/// optional fields — OpenAI's token endpoint may omit unchanged fields.
127/// Omitted fields preserve the current session's values. This matches the
128/// behavior of `openai/codex`'s `RefreshResponse` + `persist_tokens`.
129async fn refresh_openai_chatgpt_session_without_lock(
130    current: &OpenAIChatGptSession,
131    storage_mode: AuthCredentialsStoreMode,
132) -> Result<OpenAIChatGptSession> {
133    let identity = resolve_oauth_client_identity()?;
134    let token_client = vtcode_commons::http::create_default_client_without_redirects()
135        .context("failed to build openai token client")?;
136    let response = vtcode_commons::http::send_with_retry_transport_only(&crate::oauth_token_retry_policy(), || {
137        token_client
138            .post(OPENAI_TOKEN_URL)
139            .header("Content-Type", "application/x-www-form-urlencoded")
140            .body(format!(
141                "grant_type=refresh_token&client_id={}&refresh_token={}",
142                urlencoding::encode(&identity.client_id),
143                urlencoding::encode(&current.refresh_token),
144            ))
145            .send()
146    })
147    .await
148    .context("failed to refresh openai chatgpt token")?;
149
150    // Check for HTTP errors. Unlike error_for_status_ref(), we capture the
151    // response body to classify token-endpoint errors (e.g. invalid_grant,
152    // refresh_token_expired) that reqwest's status-only error would miss.
153    if !response.status().is_success() {
154        let status = response.status();
155        // Read a bounded body for error classification — never log it raw.
156        let body_text = read_bounded_text(response, MAX_ERROR_BODY_BYTES).await;
157        return Err(classify_refresh_status_error(status, &body_text));
158    }
159
160    let refresh_response: OpenAIRefreshResponse =
161        response.json().await.context("failed to parse openai refresh response")?;
162
163    let session = merge_refresh_response(current, refresh_response).await?;
164    // Guard against a blank access_token — the primary bearer credential.
165    // This protects the minimal-session refresh helper (which starts with
166    // blank token fields) from persisting a session with blank tokens when
167    // the token endpoint returns a partial response that omits access_token.
168    if session.access_token.trim().is_empty() {
169        bail!("openai token refresh returned no access token — the session cannot be used");
170    }
171    save_openai_chatgpt_session_with_mode(&session, storage_mode)?;
172    Ok(session)
173}
174
175/// Merge a partial refresh response into the current session, preserving
176/// omitted fields. Only re-exchanges the API key when a new `id_token` is
177/// present; otherwise keeps the previous exchanged key.
178pub(crate) async fn merge_refresh_response(
179    current: &OpenAIChatGptSession,
180    resp: OpenAIRefreshResponse,
181) -> Result<OpenAIChatGptSession> {
182    let now = now_secs();
183    // Track which fields were present before moving them out of resp.
184    // Treat blank-string values as absent — some token endpoints return
185    // empty strings for omitted fields rather than leaving them out.
186    let has_new_id_token = resp.id_token.as_deref().is_some_and(|v| !v.trim().is_empty());
187    let has_new_access_token = resp.access_token.as_deref().is_some_and(|v| !v.trim().is_empty());
188    let new_id_token = resp
189        .id_token
190        .filter(|v| !v.trim().is_empty())
191        .unwrap_or_else(|| current.id_token.clone());
192    let new_access_token = resp
193        .access_token
194        .filter(|v| !v.trim().is_empty())
195        .unwrap_or_else(|| current.access_token.clone());
196    let new_refresh_token = resp
197        .refresh_token
198        .filter(|v| !v.trim().is_empty())
199        .unwrap_or_else(|| current.refresh_token.clone());
200
201    // Re-exchange the API key only when a new id_token was provided.
202    let openai_api_key = if has_new_id_token {
203        match exchange_openai_chatgpt_api_key(&new_id_token).await {
204            Ok(api_key) => api_key,
205            Err(err) => {
206                tracing::warn!("openai api-key exchange unavailable, falling back to previous key: {err}");
207                current.openai_api_key.clone()
208            }
209        }
210    } else {
211        current.openai_api_key.clone()
212    };
213
214    // Recompute expiry: prefer expires_in from the response, then try to parse
215    // exp from the new access_token JWT. For a **changed** access token without
216    // expires_in or a parseable exp, set None — do NOT inherit the previous
217    // token's expiry, which belongs to a different token.
218    //
219    // Key distinction: `has_new_access_token` means the field was present and
220    // non-blank, NOT that the token value changed. If the endpoint repeats the
221    // same opaque access token without expires_in, the old expiry is still
222    // valid and must be preserved.
223    let access_token_changed = has_new_access_token && new_access_token != current.access_token;
224    let expires_at = if let Some(secs) = resp.expires_in {
225        Some(now.saturating_add(secs))
226    } else if access_token_changed {
227        parse_jwt_exp(&new_access_token)
228    } else {
229        current.expires_at
230    };
231
232    // Update email/plan/account_id only when a new id_token was provided.
233    let (email, plan, account_id) = if has_new_id_token {
234        let id_claims = parse_jwt_claims(&new_id_token)?;
235        let access_claims = parse_jwt_claims(&new_access_token).ok();
236        let email = id_claims.email.clone();
237        let plan = access_claims.as_ref().and_then(|c| c.plan.clone()).or(id_claims.plan);
238        let account_id = access_claims
239            .as_ref()
240            .and_then(|c| c.account_id.clone())
241            .or(id_claims.account_id);
242        (email, plan, account_id)
243    } else {
244        (current.email.clone(), current.plan.clone(), current.account_id.clone())
245    };
246
247    Ok(OpenAIChatGptSession {
248        openai_api_key,
249        id_token: new_id_token,
250        access_token: new_access_token,
251        refresh_token: new_refresh_token,
252        account_id,
253        email,
254        plan,
255        // Preserve the original obtained_at — only refreshed_at advances.
256        obtained_at: current.obtained_at,
257        refreshed_at: now,
258        expires_at,
259    })
260}
261
262async fn build_session_from_token_response(token_response: OpenAITokenResponse) -> Result<OpenAIChatGptSession> {
263    // Validate that the token response contains usable credentials.
264    if token_response.access_token.trim().is_empty() {
265        bail!("openai authorization-code response did not include a usable access token");
266    }
267    if token_response.refresh_token.trim().is_empty() {
268        bail!("openai authorization-code response did not include a usable refresh token");
269    }
270    let id_claims = parse_jwt_claims(&token_response.id_token)?;
271    let access_claims = parse_jwt_claims(&token_response.access_token).ok();
272    let api_key = match exchange_openai_chatgpt_api_key(&token_response.id_token).await {
273        Ok(api_key) => api_key,
274        Err(err) => {
275            tracing::warn!("openai api-key exchange unavailable, falling back to oauth access token: {err}");
276            String::new()
277        }
278    };
279    let now = now_secs();
280    Ok(OpenAIChatGptSession {
281        openai_api_key: api_key,
282        id_token: token_response.id_token,
283        access_token: token_response.access_token,
284        refresh_token: token_response.refresh_token,
285        account_id: access_claims
286            .as_ref()
287            .and_then(|claims| claims.account_id.clone())
288            .or(id_claims.account_id),
289        email: id_claims
290            .email
291            .or_else(|| access_claims.as_ref().and_then(|claims| claims.email.clone())),
292        plan: access_claims.as_ref().and_then(|claims| claims.plan.clone()).or(id_claims.plan),
293        obtained_at: now,
294        refreshed_at: now,
295        expires_at: token_response.expires_in.map(|secs| now.saturating_add(secs)),
296    })
297}
298
299async fn exchange_openai_chatgpt_api_key(id_token: &str) -> Result<String> {
300    #[derive(Deserialize)]
301    struct ExchangeResponse {
302        access_token: String,
303    }
304
305    let identity = resolve_oauth_client_identity()?;
306    let token_client = vtcode_commons::http::create_default_client_without_redirects()
307        .context("failed to build openai token client")?;
308    let exchange: ExchangeResponse =
309        vtcode_commons::http::send_with_retry_transport_only(&crate::oauth_token_retry_policy(), || {
310            token_client
311                .post(OPENAI_TOKEN_URL)
312                .header("Content-Type", "application/x-www-form-urlencoded")
313                .body(format!(
314                    "grant_type={}&client_id={}&requested_token={}&subject_token={}&subject_token_type={}",
315                    urlencoding::encode("urn:ietf:params:oauth:grant-type:token-exchange"),
316                    urlencoding::encode(&identity.client_id),
317                    urlencoding::encode("openai-api-key"),
318                    urlencoding::encode(id_token),
319                    urlencoding::encode("urn:ietf:params:oauth:token-type:id_token"),
320                ))
321                .send()
322        })
323        .await
324        .context("failed to exchange openai id token for api key")?
325        .error_for_status()
326        .context("openai api-key exchange failed")?
327        .json()
328        .await
329        .context("failed to parse openai api-key exchange response")?;
330
331    Ok(exchange.access_token)
332}
333#[derive(Deserialize)]
334struct OpenAITokenResponse {
335    id_token: String,
336    access_token: String,
337    refresh_token: String,
338    #[serde(default)]
339    expires_in: Option<u64>,
340}
341
342/// Refresh-token grant response — all token fields are independently optional
343/// because OpenAI's token endpoint may omit unchanged fields (matching the
344/// behavior observed in `openai/codex`'s `RefreshResponse`). Omitted fields
345/// preserve the previous session's values during merge.
346#[derive(Deserialize)]
347pub(crate) struct OpenAIRefreshResponse {
348    #[serde(default)]
349    pub(crate) id_token: Option<String>,
350    #[serde(default)]
351    pub(crate) access_token: Option<String>,
352    #[serde(default)]
353    pub(crate) refresh_token: Option<String>,
354    #[serde(default)]
355    pub(crate) expires_in: Option<u64>,
356}
357fn session_has_newer_refresh_state(current: &OpenAIChatGptSession, previous: &OpenAIChatGptSession) -> bool {
358    current.refresh_token != previous.refresh_token
359        || current.refreshed_at > previous.refreshed_at
360        || current.obtained_at > previous.obtained_at
361}
362
363pub(crate) struct RefreshLockGuard {
364    file: fs::File,
365}
366
367impl Drop for RefreshLockGuard {
368    fn drop(&mut self) {
369        drop(FileExt::unlock(&self.file));
370    }
371}
372
373pub(crate) async fn acquire_refresh_lock() -> Result<RefreshLockGuard> {
374    let path = auth_storage_dir()?.join(OPENAI_REFRESH_LOCK_FILE);
375    let file = OpenOptions::new()
376        .create(true)
377        .read(true)
378        .write(true)
379        .truncate(false)
380        .open(&path)
381        .context("failed to open openai refresh lock")?;
382    let file = tokio::task::spawn_blocking(move || {
383        file.lock_exclusive().context("failed to acquire openai refresh lock")?;
384        Ok::<_, anyhow::Error>(file)
385    })
386    .await
387    .context("openai refresh lock task failed")??;
388    Ok(RefreshLockGuard { file })
389}
390
391/// Read at most `max_bytes` from an HTTP response body as a string.
392///
393/// Reads the response in chunks and stops once `max_bytes` have been
394/// accumulated, preventing unbounded memory allocation from a misbehaving or
395/// hostile endpoint. Invalid UTF-8 sequences are replaced (lossy) since we
396/// only use the text for best-effort error classification, never for display
397/// or logging.
398async fn read_bounded_text(mut response: reqwest::Response, max_bytes: usize) -> String {
399    let mut buf = Vec::with_capacity(max_bytes.min(8 * 1024));
400    while buf.len() < max_bytes {
401        match response.chunk().await {
402            Ok(Some(chunk)) => {
403                let remaining = max_bytes - buf.len();
404                if chunk.len() <= remaining {
405                    buf.extend_from_slice(&chunk);
406                } else {
407                    buf.extend_from_slice(chunk.get(..remaining).unwrap_or_default());
408                    break;
409                }
410            }
411            Ok(None) => break,
412            Err(_) => break,
413        }
414    }
415    String::from_utf8_lossy(&buf).into_owned()
416}
417
418pub(crate) fn classify_refresh_status_error(status: reqwest::StatusCode, body: &str) -> anyhow::Error {
419    let failure = classify_refresh_failure(status, body);
420    if failure.action() == RefreshFailureAction::ClearStoredSession {
421        if let Err(clear_err) = clear_session_from_all_stores() {
422            tracing::warn!("failed to clear expired openai chatgpt session across all stores: {clear_err}");
423        }
424    }
425    failure.into_error()
426}
427
428fn clear_session_from_all_stores() -> Result<()> {
429    OpenAiSessionStorage::new().clear_all()
430}