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