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