ai_usagebar/openai/
oauth.rs1use 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}