Skip to main content

ai_usagebar/openai/
oauth.rs

1//! OAuth refresh — POST `https://auth.openai.com/oauth/token`.
2//!
3//! Mirrors codexbar's refresh flow and `openai/codex`'s `auth/manager.rs`.
4//! Notable differences from the Anthropic flow:
5//!   - URL is `auth.openai.com` (not `platform.claude.com`)
6//!   - `client_id` is the Codex CLI's public OAuth client ID
7//!   - The body must include `scope: "openid profile email"`
8//!   - The response includes a fresh `id_token` too (we persist all three).
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{AppError, Result};
13
14pub const TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
15pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
16pub const SCOPE: &str = "openid profile email";
17pub const REFRESH_BUFFER_SECS: i64 = 300;
18
19#[derive(Debug, Serialize)]
20struct RefreshRequest<'a> {
21    client_id: &'a str,
22    grant_type: &'a str,
23    refresh_token: &'a str,
24    scope: &'a str,
25}
26
27#[derive(Debug, Deserialize)]
28pub struct RefreshResponse {
29    #[serde(deserialize_with = "de_nonempty_string")]
30    pub access_token: String,
31    #[serde(default, deserialize_with = "de_opt_nonempty_string")]
32    pub refresh_token: Option<String>,
33    #[serde(default, deserialize_with = "de_opt_nonempty_string")]
34    pub id_token: Option<String>,
35    #[serde(default, deserialize_with = "de_expires_in")]
36    pub expires_in: Option<u64>,
37}
38
39fn de_nonempty_string<'de, D>(d: D) -> std::result::Result<String, D::Error>
40where
41    D: serde::Deserializer<'de>,
42{
43    let value = String::deserialize(d)?;
44    if value.trim().is_empty() {
45        Err(serde::de::Error::custom("token cannot be empty"))
46    } else {
47        Ok(value)
48    }
49}
50
51fn de_opt_nonempty_string<'de, D>(d: D) -> std::result::Result<Option<String>, D::Error>
52where
53    D: serde::Deserializer<'de>,
54{
55    Option::<String>::deserialize(d)?
56        .map(|value| {
57            if value.trim().is_empty() {
58                Err(serde::de::Error::custom("token cannot be empty"))
59            } else {
60                Ok(value)
61            }
62        })
63        .transpose()
64}
65
66fn de_expires_in<'de, D>(d: D) -> std::result::Result<Option<u64>, D::Error>
67where
68    D: serde::Deserializer<'de>,
69{
70    let v = serde_json::Value::deserialize(d)?;
71    match v {
72        serde_json::Value::Null => Ok(None),
73        serde_json::Value::Number(n) => {
74            const MAX_SAFE_EXPIRES_IN: u64 = (i64::MAX as u64) / 2;
75            if let Some(value) = n.as_u64().filter(|value| *value <= MAX_SAFE_EXPIRES_IN) {
76                Ok(Some(value))
77            } else if let Some(value) = n.as_f64()
78                && value.is_finite()
79                && value.fract() == 0.0
80                && (0.0..=MAX_SAFE_EXPIRES_IN as f64).contains(&value)
81            {
82                Ok(Some(value as u64))
83            } else {
84                Err(serde::de::Error::custom(
85                    "expires_in must be a non-negative integer in range",
86                ))
87            }
88        }
89        other => Err(serde::de::Error::custom(format!(
90            "expires_in must be a number or null, got {other:?}"
91        ))),
92    }
93}
94
95pub async fn refresh(
96    client: &reqwest::Client,
97    endpoint: &str,
98    refresh_token: &str,
99) -> Result<RefreshResponse> {
100    let req = RefreshRequest {
101        client_id: CLIENT_ID,
102        grant_type: "refresh_token",
103        refresh_token,
104        scope: SCOPE,
105    };
106
107    let resp = client
108        .post(endpoint)
109        .header("Content-Type", "application/json")
110        .json(&req)
111        .send()
112        .await?;
113
114    let status = resp.status();
115    let body = crate::vendor::read_body_capped(resp, crate::vendor::MAX_BODY_BYTES).await?;
116    let body = String::from_utf8_lossy(&body).into_owned();
117    if !status.is_success() {
118        let msg = crate::anthropic::oauth::parse_error_body(&body)
119            .unwrap_or_else(|| "Refresh failed".into());
120        return Err(AppError::Http {
121            status: status.as_u16(),
122            body: msg,
123        });
124    }
125    serde_json::from_str(&body)
126        .map_err(|e| AppError::Schema(format!("openai token response: {e}; body: {body}")))
127}
128
129pub fn needs_refresh(expires_at_secs: i64, now_secs: i64) -> bool {
130    expires_at_secs < now_secs + REFRESH_BUFFER_SECS
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn needs_refresh_threshold() {
139        let now = 1_000_000;
140        assert!(needs_refresh(now + 100, now));
141        assert!(!needs_refresh(now + 1000, now));
142    }
143
144    #[test]
145    fn malformed_optional_expires_in_is_not_treated_as_absent() {
146        for value in [
147            "3600.5",
148            "-1",
149            "1e300",
150            "18446744073709551615",
151            "true",
152            r#""3600""#,
153        ] {
154            let body = format!(r#"{{"access_token":"new","expires_in":{value}}}"#);
155            assert!(
156                serde_json::from_str::<RefreshResponse>(&body).is_err(),
157                "{body}"
158            );
159        }
160        let response: RefreshResponse =
161            serde_json::from_str(r#"{"access_token":"new","expires_in":null}"#).unwrap();
162        assert_eq!(response.expires_in, None);
163    }
164
165    #[test]
166    fn empty_refresh_tokens_are_schema_drift_not_credentials_to_persist() {
167        for body in [
168            r#"{"access_token":""}"#,
169            r#"{"access_token":"new","refresh_token":"   "}"#,
170            r#"{"access_token":"new","id_token":""}"#,
171        ] {
172            assert!(
173                serde_json::from_str::<RefreshResponse>(body).is_err(),
174                "{body}"
175            );
176        }
177    }
178
179    #[tokio::test]
180    async fn refresh_success_parses_three_tokens() {
181        let mut server = mockito::Server::new_async().await;
182        server
183            .mock("POST", "/oauth/token")
184            .with_status(200)
185            .with_body(
186                r#"{"access_token":"new-at","refresh_token":"new-rt","id_token":"new-id","expires_in":3600}"#,
187            )
188            .create_async()
189            .await;
190        let client = reqwest::Client::new();
191        let r = refresh(&client, &format!("{}/oauth/token", server.url()), "old")
192            .await
193            .unwrap();
194        assert_eq!(r.access_token, "new-at");
195        assert_eq!(r.refresh_token.as_deref(), Some("new-rt"));
196        assert_eq!(r.id_token.as_deref(), Some("new-id"));
197        assert_eq!(r.expires_in, Some(3600));
198    }
199
200    #[tokio::test]
201    async fn refresh_400_returns_http_with_description() {
202        let mut server = mockito::Server::new_async().await;
203        server
204            .mock("POST", "/oauth/token")
205            .with_status(400)
206            .with_body(r#"{"error":"invalid_grant","error_description":"Refresh expired"}"#)
207            .create_async()
208            .await;
209        let client = reqwest::Client::new();
210        let err = refresh(&client, &format!("{}/oauth/token", server.url()), "x")
211            .await
212            .unwrap_err();
213        match err {
214            AppError::Http { status, body } => {
215                assert_eq!(status, 400);
216                assert_eq!(body, "Refresh expired");
217            }
218            other => panic!("expected Http error, got {other:?}"),
219        }
220    }
221}