1use super::{
4 AuthContext, AuthError, Authenticator, BrowserSignInPrompt, DeviceCodePrompt, STORE_KEY,
5 SignInMethod, SignInPrompt, SignedOut,
6};
7use crate::http_client::HttpClientExt;
8use crate::json_utils::Lenient;
9use crate::providers::internal::auth::{
10 load_record, request, save_record, send_bytes, send_json, token_expired,
11};
12use crate::wasm_compat::WasmCompatSend;
13use base64::Engine;
14use base64::prelude::BASE64_URL_SAFE_NO_PAD;
15use bytes::Bytes;
16use http::Method;
17use serde::{Deserialize, Serialize};
18
19mod browser;
20
21const CHATGPT_AUTH_BASE: &str = "https://auth.openai.com";
22const CHATGPT_DEVICE_CODE_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/usercode";
23const CHATGPT_DEVICE_TOKEN_URL: &str = "https://auth.openai.com/api/accounts/deviceauth/token";
24const CHATGPT_OAUTH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
25const CHATGPT_OAUTH_REVOKE_URL: &str = "https://auth.openai.com/api/accounts/oauth/revoke";
29const CHATGPT_DEVICE_VERIFY_URL: &str = "https://auth.openai.com/codex/device";
30const CHATGPT_CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
31const TOKEN_EXPIRY_SKEW_SECONDS: i64 = 60;
32const DEVICE_CODE_TIMEOUT_SECONDS: i64 = 15 * 60;
33const BROWSER_SIGN_IN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60);
34const DEVICE_CODE_POLL_SLEEP_SECONDS: u64 = 5;
35
36#[derive(Debug, Clone, Deserialize, Serialize, Default)]
37struct AuthRecord {
38 access_token: Option<String>,
39 refresh_token: Option<String>,
40 id_token: Option<String>,
41 expires_at: Option<i64>,
42 account_id: Option<String>,
43}
44
45#[derive(Debug, Deserialize)]
46struct DeviceCodeResponse {
47 device_auth_id: String,
48 #[serde(alias = "usercode")]
49 user_code: String,
50 #[serde(default)]
52 interval: Option<serde_json::Value>,
53}
54
55#[derive(Debug, Deserialize)]
56struct DeviceTokenResponse {
57 authorization_code: String,
58 code_verifier: String,
59}
60
61#[derive(Debug, Deserialize)]
62struct OAuthTokenResponse {
63 access_token: String,
64 refresh_token: Option<String>,
65 id_token: Option<String>,
66}
67
68#[derive(Debug, Deserialize)]
69struct OAuthErrorResponse {
70 error: Option<String>,
71 error_description: Option<String>,
72}
73
74enum RefreshTokensError {
75 Reauthenticate,
76 Auth(AuthError),
77}
78
79impl SignInMethod {
80 pub fn detect(device_asked: bool) -> Self {
85 let set = |name: &str| std::env::var_os(name).is_some_and(|value| !value.is_empty());
86 let graphical = !(set("SSH_CONNECTION") || set("SSH_TTY"))
87 && (cfg!(any(target_os = "macos", windows))
88 || set("DISPLAY")
89 || set("WAYLAND_DISPLAY"));
90 if device_asked || !graphical {
91 Self::DeviceCode
92 } else {
93 Self::Browser
94 }
95 }
96}
97
98impl Authenticator {
99 pub(super) async fn auth_context_oauth<H>(&self, http: &H) -> Result<AuthContext, AuthError>
100 where
101 H: HttpClientExt,
102 {
103 if let Some(context) = self.0.remembered.live(TOKEN_EXPIRY_SKEW_SECONDS) {
104 return Ok(context);
105 }
106 let _held = self.0.hold(STORE_KEY).await?;
107 let mut record: AuthRecord = load_record(&*self.0.store, STORE_KEY).await?;
108
109 if let Some(access_token) = record.access_token.clone()
110 && !token_expired(record.expires_at, TOKEN_EXPIRY_SKEW_SECONDS)
111 {
112 let account_id = record
113 .account_id
114 .clone()
115 .or_else(|| extract_account_id(record.id_token.as_deref()))
116 .or_else(|| extract_account_id(Some(&access_token)));
117 if account_id != record.account_id {
118 record.account_id = account_id;
119 save_record(&*self.0.store, STORE_KEY, &record).await?;
120 }
121 return Ok(self.remember(record));
122 }
123
124 if let Some(refresh_token) = record.refresh_token.clone() {
125 match self.refresh_tokens(http, &refresh_token).await {
126 Ok(refreshed) => {
127 save_record(&*self.0.store, STORE_KEY, &refreshed).await?;
128 return Ok(self.remember(refreshed));
129 }
130 Err(RefreshTokensError::Reauthenticate) => {
131 let kept: AuthRecord = load_record(&*self.0.store, STORE_KEY).await?;
134 if kept.refresh_token != record.refresh_token
135 && kept.access_token.is_some()
136 && !token_expired(kept.expires_at, TOKEN_EXPIRY_SKEW_SECONDS)
137 {
138 return Ok(self.remember(kept));
139 }
140 }
141 Err(RefreshTokensError::Auth(err)) => return Err(err),
142 }
143 }
144
145 if !self.0.allow_device_flow {
146 return Err(AuthError::Message(
147 "ChatGPT sign-in required. Reconnect ChatGPT in Settings before using this provider."
148 .into(),
149 ));
150 }
151
152 let fresh = self.login_device_flow(http).await?;
153 self.keep(fresh).await
154 }
155
156 pub async fn sign_in<H, F>(
161 &self,
162 http: &H,
163 method: SignInMethod,
164 mut prompt: F,
165 ) -> Result<AuthContext, AuthError>
166 where
167 H: HttpClientExt,
168 F: FnMut(SignInPrompt) + WasmCompatSend,
169 {
170 if method == SignInMethod::Browser {
171 let browser = self
172 .sign_in_with_browser(http, |page| prompt(SignInPrompt::Browser(page)))
173 .await;
174 match browser {
175 Err(AuthError::Io(error)) if error.kind() == std::io::ErrorKind::AddrInUse => {
176 prompt(SignInPrompt::BrowserUnavailable(error.to_string()));
177 }
178 other => return other,
179 }
180 }
181 self.sign_in_with_device_code(http).await
182 }
183
184 pub async fn sign_in_with_device_code<H>(&self, http: &H) -> Result<AuthContext, AuthError>
188 where
189 H: HttpClientExt,
190 {
191 let fresh = self.login_device_flow(http).await?;
192 let _held = self.0.hold(STORE_KEY).await?;
193 self.keep(fresh).await
194 }
195
196 pub async fn sign_in_with_browser<H, F>(
209 &self,
210 http: &H,
211 prompt: F,
212 ) -> Result<AuthContext, AuthError>
213 where
214 H: HttpClientExt,
215 F: FnOnce(BrowserSignInPrompt) + WasmCompatSend,
216 {
217 let (listener, port) = browser::bind()?;
218 let redirect_uri = browser::redirect_uri(port);
219 let pkce = browser::Pkce::generate();
220 let state = browser::random_token(2);
221 let authorize_url =
222 browser::authorize_url(&redirect_uri, &pkce.challenge, &state, &originator());
223 let callback = browser::spawn_listener(
224 listener,
225 state,
226 std::time::Instant::now() + BROWSER_SIGN_IN_TIMEOUT,
227 )?;
228 let browser_launched = browser::open_browser(&authorize_url);
229 prompt(BrowserSignInPrompt {
230 authorize_url,
231 browser_launched,
232 });
233
234 let received = callback.await.map_err(|_| {
235 AuthError::Message("the sign-in listener stopped unexpectedly".into())
236 })??;
237 let exchanged =
238 exchange_authorization_code(http, &received.code, &pkce.verifier, &redirect_uri).await;
239 received.finish(
240 exchanged
241 .as_ref()
242 .map(|_| ())
243 .map_err(|error| error.to_string()),
244 );
245 let fresh = exchanged?;
246 let _held = self.0.hold(STORE_KEY).await?;
247 self.keep(fresh).await
248 }
249
250 pub async fn sign_out<H>(&self, http: &H) -> Result<SignedOut, AuthError>
260 where
261 H: HttpClientExt,
262 {
263 let _held = self.0.hold(STORE_KEY).await?;
264 let record: AuthRecord = load_record(&*self.0.store, STORE_KEY).await?;
265 let tokens = [
266 (record.refresh_token.as_deref(), "refresh_token"),
267 (record.access_token.as_deref(), "access_token"),
268 ];
269 let mut revoked = None;
270 for (token, hint) in tokens {
271 let Some(token) = token else { continue };
272 let result = revoke(http, token, hint).await;
273 let result = result.map_err(|error| format!("revoking the {hint} failed: {error}"));
274 revoked = Some(match revoked {
275 Some(Err(earlier)) => Err(earlier),
276 _ => result,
277 });
278 }
279 self.0
280 .store
281 .delete(STORE_KEY)
282 .await
283 .map_err(AuthError::Store)?;
284 self.0.remembered.forget();
285 Ok(match revoked {
286 Some(revoked) => SignedOut::Forgotten { revoked },
287 None => SignedOut::NotSignedIn,
288 })
289 }
290
291 async fn keep(&self, fresh: AuthRecord) -> Result<AuthContext, AuthError> {
293 save_record(&*self.0.store, STORE_KEY, &fresh).await?;
294 Ok(self.remember(fresh))
295 }
296
297 fn remember(&self, record: AuthRecord) -> AuthContext {
299 let context = AuthContext {
300 api_key: record.access_token.unwrap_or_default().into(),
301 account_id: record.account_id,
302 api_base: None,
303 };
304 self.0.remembered.keep(&context, record.expires_at);
305 context
306 }
307
308 async fn login_device_flow<H>(&self, http: &H) -> Result<AuthRecord, AuthError>
309 where
310 H: HttpClientExt,
311 {
312 let device: DeviceCodeResponse = send_json(
313 http,
314 request(Method::POST, CHATGPT_DEVICE_CODE_URL)
315 .header(http::header::CONTENT_TYPE, "application/json")
316 .body(Bytes::from(serde_json::to_vec(
317 &serde_json::json!({ "client_id": CHATGPT_CLIENT_ID }),
318 )?)),
319 )
320 .await?;
321
322 self.0.device_code_handler.show(
323 DeviceCodePrompt {
324 verification_uri: CHATGPT_DEVICE_VERIFY_URL.to_string(),
325 user_code: device.user_code.clone(),
326 },
327 &format!(
328 "Sign in with ChatGPT:\n1) Visit {CHATGPT_DEVICE_VERIFY_URL}\n2) Enter code: {}\nDo not share this device code.",
329 device.user_code
330 ),
331 );
332
333 let interval = device
334 .interval
335 .as_ref()
336 .and_then(Lenient::as_u64_lenient)
337 .unwrap_or(DEVICE_CODE_POLL_SLEEP_SECONDS);
338 let start = std::time::Instant::now();
339 let code = loop {
340 if start.elapsed().as_secs() as i64 >= DEVICE_CODE_TIMEOUT_SECONDS {
341 return Err(AuthError::Message(
342 "Timed out waiting for ChatGPT device authorization".into(),
343 ));
344 }
345
346 let poll = send_json::<_, DeviceTokenResponse>(
347 http,
348 request(Method::POST, CHATGPT_DEVICE_TOKEN_URL)
349 .header(http::header::CONTENT_TYPE, "application/json")
350 .body(Bytes::from(serde_json::to_vec(&serde_json::json!({
351 "device_auth_id": device.device_auth_id,
352 "user_code": device.user_code,
353 }))?)),
354 )
355 .await;
356
357 match poll {
358 Ok(token_response) => break token_response,
359 Err(AuthError::Http(err))
362 if matches!(
363 err.non_success_status().map(|status| status.as_u16()),
364 Some(403 | 404)
365 ) =>
366 {
367 crate::wasm_compat::sleep(std::time::Duration::from_secs(interval)).await;
368 continue;
369 }
370 Err(AuthError::Http(err)) if err.non_success_status().is_some() => {
371 let status = err.non_success_status().unwrap_or_default();
372 let text = err.non_success_body().unwrap_or_default();
373 return Err(AuthError::Message(format!(
374 "ChatGPT device authorization failed: {status} {text}"
375 )));
376 }
377 Err(err) => return Err(err),
378 }
379 };
380
381 exchange_authorization_code(
382 http,
383 &code.authorization_code,
384 &code.code_verifier,
385 &format!("{CHATGPT_AUTH_BASE}/deviceauth/callback"),
386 )
387 .await
388 }
389
390 async fn refresh_tokens<H>(
391 &self,
392 http: &H,
393 refresh_token: &str,
394 ) -> Result<AuthRecord, RefreshTokensError>
395 where
396 H: HttpClientExt,
397 {
398 let form = [
399 ("client_id", CHATGPT_CLIENT_ID),
400 ("grant_type", "refresh_token"),
401 ("refresh_token", refresh_token),
402 ("scope", "openid profile email"),
403 ];
404
405 let body = url::form_urlencoded::Serializer::new(String::new())
406 .extend_pairs(form)
407 .finish();
408
409 let response = send_json::<_, OAuthTokenResponse>(
410 http,
411 request(Method::POST, CHATGPT_OAUTH_TOKEN_URL)
412 .header(
413 http::header::CONTENT_TYPE,
414 "application/x-www-form-urlencoded",
415 )
416 .body(Bytes::from(body)),
417 )
418 .await;
419
420 let (status, body) = match response {
421 Ok(tokens) => {
422 return Ok(build_auth_record(tokens, Some(refresh_token.to_owned())));
423 }
424 Err(AuthError::Http(err)) if err.non_success_status().is_some() => (
425 err.non_success_status().unwrap_or_default(),
426 err.non_success_body().unwrap_or_default().to_owned(),
427 ),
428 Err(err) => return Err(RefreshTokensError::Auth(err)),
429 };
430 let oauth_error = serde_json::from_str::<OAuthErrorResponse>(&body).ok();
431 if should_reauthenticate_after_refresh(
432 status,
433 oauth_error
434 .as_ref()
435 .and_then(|error| error.error.as_deref()),
436 ) {
437 return Err(RefreshTokensError::Reauthenticate);
438 }
439
440 Err(RefreshTokensError::Auth(AuthError::Message(
441 format_refresh_error(status, oauth_error.as_ref(), &body),
442 )))
443 }
444}
445
446async fn revoke<H>(http: &H, token: &str, hint: &str) -> Result<(), AuthError>
449where
450 H: HttpClientExt,
451{
452 let form = [
453 ("token", token),
454 ("token_type_hint", hint),
455 ("client_id", CHATGPT_CLIENT_ID),
456 ];
457 let body = url::form_urlencoded::Serializer::new(String::new())
458 .extend_pairs(form)
459 .finish();
460 send_bytes(
461 http,
462 request(Method::POST, CHATGPT_OAUTH_REVOKE_URL)
463 .header(
464 http::header::CONTENT_TYPE,
465 "application/x-www-form-urlencoded",
466 )
467 .body(Bytes::from(body)),
468 )
469 .await
470 .map(drop)
471}
472
473async fn exchange_authorization_code<H>(
475 http: &H,
476 code: &str,
477 code_verifier: &str,
478 redirect_uri: &str,
479) -> Result<AuthRecord, AuthError>
480where
481 H: HttpClientExt,
482{
483 let form = [
484 ("grant_type", "authorization_code"),
485 ("code", code),
486 ("redirect_uri", redirect_uri),
487 ("client_id", CHATGPT_CLIENT_ID),
488 ("code_verifier", code_verifier),
489 ];
490 let body = url::form_urlencoded::Serializer::new(String::new())
491 .extend_pairs(form)
492 .finish();
493
494 let tokens: OAuthTokenResponse = send_json(
495 http,
496 request(Method::POST, CHATGPT_OAUTH_TOKEN_URL)
497 .header(
498 http::header::CONTENT_TYPE,
499 "application/x-www-form-urlencoded",
500 )
501 .body(Bytes::from(body)),
502 )
503 .await?;
504
505 Ok(build_auth_record(tokens, None))
506}
507
508fn originator() -> String {
510 let identity = crate::providers::chatgpt::DIALECT.quirks.identity;
511 identity
512 .and_then(|identity| std::env::var(identity.originator_env).ok())
513 .filter(|value| !value.is_empty())
514 .unwrap_or_else(|| {
515 identity
516 .map_or(crate::providers::chatgpt::DEFAULT_ORIGINATOR, |identity| {
517 identity.originator
518 })
519 .to_owned()
520 })
521}
522
523fn build_auth_record(
524 tokens: OAuthTokenResponse,
525 previous_refresh_token: Option<String>,
526) -> AuthRecord {
527 let access_token = Some(tokens.access_token);
528 let id_token = tokens.id_token;
529 AuthRecord {
530 expires_at: access_token
531 .as_deref()
532 .and_then(extract_expiration_timestamp),
533 account_id: extract_account_id(id_token.as_deref()).or_else(|| {
534 access_token
535 .as_deref()
536 .and_then(|token| extract_account_id(Some(token)))
537 }),
538 access_token,
539 refresh_token: tokens.refresh_token.or(previous_refresh_token),
540 id_token,
541 }
542}
543
544fn extract_expiration_timestamp(token: &str) -> Option<i64> {
545 decode_jwt_claims(token)
546 .get("exp")
547 .and_then(|value| value.as_i64().or_else(|| value.as_u64().map(|v| v as i64)))
548}
549
550fn extract_account_id(token: Option<&str>) -> Option<String> {
551 let claims = decode_jwt_claims(token?);
552 claims
553 .get("https://api.openai.com/auth")
554 .and_then(|value| value.as_object())
555 .and_then(|map| map.get("chatgpt_account_id"))
556 .and_then(|value| value.as_str())
557 .map(ToOwned::to_owned)
558}
559
560fn decode_jwt_claims(token: &str) -> serde_json::Value {
561 let payload = token.split('.').nth(1).unwrap_or_default();
562 let decoded = BASE64_URL_SAFE_NO_PAD.decode(payload.as_bytes());
563 decoded
564 .ok()
565 .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok())
566 .unwrap_or(serde_json::Value::Null)
567}
568
569fn should_reauthenticate_after_refresh(status: http::StatusCode, error_code: Option<&str>) -> bool {
570 matches!(
571 status,
572 http::StatusCode::BAD_REQUEST | http::StatusCode::UNAUTHORIZED
573 ) && matches!(error_code, Some("invalid_grant"))
574}
575
576fn format_refresh_error(
577 status: http::StatusCode,
578 oauth_error: Option<&OAuthErrorResponse>,
579 body: &str,
580) -> String {
581 let error_code = oauth_error.and_then(|error| error.error.as_deref());
582 let description = oauth_error.and_then(|error| error.error_description.as_deref());
583
584 if let Some(description) = description
585 .map(str::trim)
586 .filter(|description| !description.is_empty())
587 {
588 return format!(
589 "ChatGPT token refresh failed: {status} {} ({description})",
590 error_code.unwrap_or("unknown_error")
591 );
592 }
593
594 if let Some(error_code) = error_code {
595 return format!("ChatGPT token refresh failed: {status} {error_code}");
596 }
597
598 if !body.trim().is_empty() {
599 return format!("ChatGPT token refresh failed: {status} {body}");
600 }
601
602 format!("ChatGPT token refresh failed: {status}")
603}
604
605#[cfg(test)]
606mod tests;