Skip to main content

rig_core/providers/chatgpt/auth/
native.rs

1//! Native ChatGPT OAuth and token cache implementation.
2
3use 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";
25/// The `revocation_endpoint` of the provider's discovery document,
26/// `https://auth.openai.com/.well-known/openid-configuration` (RFC 7009,
27/// no client secret).
28const 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    /// Seconds between polls, a number or a numeric string.
51    #[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    /// The browser when one opened here would reach the user and
81    /// `device_asked` is false: not over SSH, and on Linux and the BSDs
82    /// only inside an X11 or Wayland session. Otherwise the device code.
83    /// Native only.
84    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                    // Another client may have spent this refresh token and
132                    // kept what it got.
133                    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    /// Sign in again by `method`, whatever is cached, telling `prompt` what
157    /// the user must do. A browser sign-in whose callback ports are taken
158    /// says so and falls back to the device code. The credential is kept in
159    /// the token store. Native only.
160    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    /// Sign in again with the device-code flow, whatever is cached: the
185    /// handler shows the code to enter, and the credential is kept in the
186    /// token store. Native only.
187    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    /// Sign in again in the browser, whatever is cached: an authorization
197    /// code with PKCE, returned to a one-shot listener on `127.0.0.1` port
198    /// 1455, or 1457 when that is taken. The default browser is opened on
199    /// the sign-in page and `prompt` receives its URL, for the user to open
200    /// when the browser did not. The credential is kept in the token store.
201    /// Native only.
202    ///
203    /// When both ports are taken, fails with [`AuthError::Io`] of kind
204    /// [`std::io::ErrorKind::AddrInUse`], before prompting, so the caller can
205    /// fall back to [`Self::sign_in_with_device_code`]. Gives up after 15
206    /// minutes. Dropping the future stops the listener within a fraction of
207    /// a second and frees its port.
208    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    /// Sign out: the provider revokes the stored refresh token and access
251    /// token, then the record is deleted from the store and forgotten here,
252    /// whether or not the provider confirmed. Native only.
253    ///
254    /// # Errors
255    ///
256    /// When the store cannot be read, or the record cannot be deleted. A
257    /// revocation that failed is not an error: [`SignedOut::Forgotten`]
258    /// says why.
259    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    /// Keep a fresh sign-in and return its context.
292    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    /// `record`'s context, remembered until the record expires.
298    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                // Still pending: the endpoint answers 403/404 until the user
360                // completes authorization.
361                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
446/// Asks the provider to revoke `token`, of the kind `hint` names
447/// (`refresh_token` or `access_token`).
448async 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
473/// Exchange an authorization code and its PKCE verifier for tokens.
474async 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
508/// The originator the ChatGPT dialect sends, named to the sign-in page too.
509fn 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;