vtcode_auth/openai_chatgpt_oauth/
refresh.rs1use 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
23pub 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(¤t, session)
113 {
114 return Ok(current);
115 }
116 refresh_openai_chatgpt_session_without_lock(session, storage_mode).await
117}
118
119async 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(¤t.refresh_token),
137 ))
138 .send()
139 .await
140 .context("failed to refresh openai chatgpt token")?;
141
142 if !response.status().is_success() {
146 let status = response.status();
147 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 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
167pub(crate) async fn merge_refresh_response(
171 current: &OpenAIChatGptSession,
172 resp: OpenAIRefreshResponse,
173) -> Result<OpenAIChatGptSession> {
174 let now = now_secs();
175 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 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 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 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 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 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#[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
378async 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}