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 = 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(¤t, session)
114 {
115 return Ok(current);
116 }
117 refresh_openai_chatgpt_session_without_lock(session, storage_mode).await
118}
119
120async 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(¤t.refresh_token),
140 ))
141 .send()
142 .await
143 .context("failed to refresh openai chatgpt token")?;
144
145 if !response.status().is_success() {
149 let status = response.status();
150 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 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
170pub(crate) async fn merge_refresh_response(
174 current: &OpenAIChatGptSession,
175 resp: OpenAIRefreshResponse,
176) -> Result<OpenAIChatGptSession> {
177 let now = now_secs();
178 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 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 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 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 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 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#[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
383async 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}