vtcode_auth/openai_chatgpt_oauth/
refresh.rs1use 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
22pub 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(¤t, session)
117 {
118 return Ok(current);
119 }
120 refresh_openai_chatgpt_session_without_lock(session, storage_mode).await
121}
122
123async 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(¤t.refresh_token),
144 ))
145 .send()
146 })
147 .await
148 .context("failed to refresh openai chatgpt token")?;
149
150 if !response.status().is_success() {
154 let status = response.status();
155 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 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
175pub(crate) async fn merge_refresh_response(
179 current: &OpenAIChatGptSession,
180 resp: OpenAIRefreshResponse,
181) -> Result<OpenAIChatGptSession> {
182 let now = now_secs();
183 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 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 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 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 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 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#[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
391async 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}