1use serde::{Deserialize, Serialize};
5
6#[derive(Debug, thiserror::Error)]
8pub enum TokenError {
9 #[error("token endpoint: {0}")]
11 Http(String),
12 #[error("token endpoint returned {0}")]
14 Status(reqwest::StatusCode),
15 #[error("token JSON: {0}")]
17 Json(String),
18}
19
20#[derive(Clone, Debug, Serialize, Deserialize)]
22pub struct TokenResponse {
23 pub access_token: String,
25 #[serde(default)]
27 pub token_type: Option<String>,
28 #[serde(default)]
30 pub expires_in: Option<u64>,
31 #[serde(default)]
33 pub refresh_token: Option<String>,
34}
35
36#[derive(Clone, Debug)]
39pub struct TokenSet {
40 pub access_token: String,
42 pub refresh_token: Option<String>,
44 pub expires_at: i64,
46}
47
48pub async fn exchange_code(
53 http: &reqwest::Client,
54 token_url: &str,
55 client_id: &str,
56 client_secret: Option<&str>,
57 code: &str,
58 redirect_uri: &str,
59 code_verifier: &str,
60) -> Result<TokenSet, TokenError> {
61 let mut form = vec![
62 ("grant_type", "authorization_code".to_string()),
63 ("code", code.to_string()),
64 ("redirect_uri", redirect_uri.to_string()),
65 ("client_id", client_id.to_string()),
66 ("code_verifier", code_verifier.to_string()),
67 ];
68 if let Some(secret) = client_secret {
69 form.push(("client_secret", secret.to_owned()));
70 }
71 token_request(http, token_url, &form).await
72}
73
74pub async fn refresh(
79 http: &reqwest::Client,
80 token_url: &str,
81 client_id: &str,
82 client_secret: Option<&str>,
83 refresh_token: &str,
84) -> Result<TokenSet, TokenError> {
85 let mut form = vec![
86 ("grant_type", "refresh_token".to_string()),
87 ("refresh_token", refresh_token.to_string()),
88 ("client_id", client_id.to_string()),
89 ];
90 if let Some(secret) = client_secret {
91 form.push(("client_secret", secret.to_owned()));
92 }
93 token_request(http, token_url, &form).await
94}
95
96pub async fn refresh_access_token(
102 http: &reqwest::Client,
103 token_url: &str,
104 client_id: &str,
105 refresh_token: &str,
106) -> Result<TokenResponse, TokenError> {
107 let form = vec![
108 ("grant_type", "refresh_token".to_string()),
109 ("refresh_token", refresh_token.to_string()),
110 ("client_id", client_id.to_string()),
111 ];
112 token_request_raw(http, token_url, &form).await
113}
114
115async fn token_request(
116 http: &reqwest::Client,
117 url: &str,
118 form: &[(&str, String)],
119) -> Result<TokenSet, TokenError> {
120 let parsed = token_request_raw(http, url, form).await?;
121 let expires_in = i64::try_from(parsed.expires_in.unwrap_or(3600)).unwrap_or(i64::MAX);
122 let expires_at = now_unix_ms().saturating_add(expires_in.saturating_mul(1000));
123 Ok(TokenSet {
124 access_token: parsed.access_token,
125 refresh_token: parsed.refresh_token,
126 expires_at,
127 })
128}
129
130async fn token_request_raw(
131 http: &reqwest::Client,
132 url: &str,
133 form: &[(&str, String)],
134) -> Result<TokenResponse, TokenError> {
135 let response = http
136 .post(url)
137 .form(form)
138 .send()
139 .await
140 .map_err(|e| TokenError::Http(e.to_string()))?;
141 let status = response.status();
142 let text = response
143 .text()
144 .await
145 .map_err(|e| TokenError::Http(e.to_string()))?;
146 if !status.is_success() {
147 return Err(TokenError::Status(status));
148 }
149 serde_json::from_str(&text).map_err(|e| TokenError::Json(e.to_string()))
150}
151
152fn now_unix_ms() -> i64 {
155 #[allow(clippy::disallowed_methods)]
156 let now = std::time::SystemTime::now();
157 now.duration_since(std::time::UNIX_EPOCH)
158 .map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(0))
159}
160
161#[cfg(test)]
162mod tests {
163 #![allow(clippy::unwrap_used, clippy::expect_used)]
164
165 use super::*;
166
167 async fn spawn_mock_token_server() -> (String, tokio::task::JoinHandle<()>) {
169 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
170 .await
171 .expect("bind");
172 let port = listener.local_addr().expect("addr").port();
173 let base = format!("http://127.0.0.1:{port}");
174 let handle = tokio::spawn(async move {
175 let (mut stream, _) = listener.accept().await.expect("accept");
176 use tokio::io::{AsyncReadExt, AsyncWriteExt};
177 let mut buf = vec![0u8; 4096];
178 let n = stream.read(&mut buf).await.expect("read");
179 let request = String::from_utf8_lossy(&buf[..n]).into_owned();
180 let body = if request.contains("refresh_token=bad") {
182 r#"{"error":"invalid_grant","error_description":"token is invalid"}"#
183 } else if request.contains("grant_type=authorization_code") {
184 r#"{"access_token":"exch_at","token_type":"Bearer","expires_in":1800,"refresh_token":"exch_rt"}"#
185 } else if request.contains("grant_type=refresh_token") {
186 r#"{"access_token":"new_at","token_type":"Bearer","expires_in":3600,"refresh_token":"new_rt"}"#
187 } else {
188 r#"{"error":"unsupported_grant_type"}"#
189 };
190 let status = if request.contains("bad") || request.contains("unsupported") {
191 "400"
192 } else {
193 "200"
194 };
195 let response = format!(
196 "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
197 body.len(),
198 );
199 stream.write_all(response.as_bytes()).await.expect("write");
200 });
201 (base, handle)
202 }
203
204 #[tokio::test]
205 async fn exchange_code_sends_pkce_form() {
206 let (base, handle) = spawn_mock_token_server().await;
207 let http = reqwest::Client::new();
208 let set = exchange_code(
209 &http,
210 &format!("{base}/token"),
211 "test-cid",
212 None,
213 "the-code",
214 "http://127.0.0.1:53123/cb",
215 "the-verifier",
216 )
217 .await
218 .expect("success");
219 assert_eq!(set.access_token, "exch_at");
220 assert_eq!(set.refresh_token.as_deref(), Some("exch_rt"));
221 assert!(set.expires_at > 0, "expiry computed from expires_in");
222 handle.await.unwrap();
223 }
224
225 #[tokio::test]
226 async fn refresh_access_token_sends_correct_form() {
227 let (base, handle) = spawn_mock_token_server().await;
228 let http = reqwest::Client::new();
229 let resp = refresh_access_token(
230 &http,
231 &format!("{base}/token"),
232 "test-client-id",
233 "test-refresh-token",
234 )
235 .await
236 .expect("success");
237 assert_eq!(resp.access_token, "new_at");
238 assert_eq!(resp.token_type.as_deref(), Some("Bearer"));
239 assert_eq!(resp.expires_in, Some(3600));
240 assert_eq!(resp.refresh_token.as_deref(), Some("new_rt"));
241 handle.await.unwrap();
242 }
243
244 #[tokio::test]
245 async fn refresh_access_token_handles_rotation() {
246 let (base, handle) = spawn_mock_token_server().await;
247 let http = reqwest::Client::new();
248 let resp = refresh_access_token(&http, &format!("{base}/token"), "client", "old-rt")
249 .await
250 .expect("ok");
251 assert_eq!(resp.refresh_token.as_deref(), Some("new_rt"));
253 assert_ne!(resp.refresh_token.as_deref(), Some("old-rt"));
254 handle.await.unwrap();
255 }
256
257 #[tokio::test]
258 async fn refresh_access_token_rejects_invalid_token() {
259 let (base, handle) = spawn_mock_token_server().await;
260 let http = reqwest::Client::new();
261 let err = refresh_access_token(&http, &format!("{base}/token"), "client", "bad")
262 .await
263 .expect_err("should fail");
264 match err {
265 TokenError::Status(status) => assert_eq!(status.as_u16(), 400),
266 other => panic!("unexpected: {other}"),
267 }
268 handle.await.unwrap();
269 }
270
271 #[tokio::test]
272 async fn refresh_access_token_network_error() {
273 let http = reqwest::Client::new();
274 let err = refresh_access_token(&http, "http://127.0.0.1:1/nope", "client", "rt")
275 .await
276 .expect_err("should fail");
277 assert!(matches!(err, TokenError::Http(_)));
278 }
279
280 #[tokio::test]
281 async fn refresh_access_token_url_construction() {
282 let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
285 .await
286 .expect("bind");
287 let port = listener.local_addr().expect("addr").port();
288 let base = format!("http://127.0.0.1:{port}");
289 let server = tokio::spawn(async move {
290 let (mut stream, _) = listener.accept().await.expect("accept");
291 use tokio::io::{AsyncReadExt, AsyncWriteExt};
292 let mut buf = vec![0u8; 4096];
293 let n = stream.read(&mut buf).await.expect("read");
294 let raw = String::from_utf8_lossy(&buf[..n]).into_owned();
295 let body = raw.split_once("\r\n\r\n").map_or("", |(_, b)| b);
297 assert!(body.contains("grant_type=refresh_token"), "body: {body}");
298 assert!(body.contains("refresh_token=test-rt"), "body: {body}");
299 assert!(body.contains("client_id=test-cid"), "body: {body}");
300 let resp = r#"{"access_token":"at","token_type":"Bearer","expires_in":3600,"refresh_token":"new-rt"}"#;
301 let http = format!(
302 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{resp}",
303 resp.len()
304 );
305 stream.write_all(http.as_bytes()).await.expect("write");
306 });
307 let http = reqwest::Client::new();
308 let result = refresh_access_token(&http, &format!("{base}/token"), "test-cid", "test-rt")
309 .await
310 .expect("ok");
311 assert_eq!(result.access_token, "at");
312 server.await.unwrap();
313 }
314
315 #[test]
316 fn token_response_deserialize_minimal() {
317 let json = r#"{"access_token":"at","token_type":"Bearer","expires_in":300}"#;
318 let resp: TokenResponse = serde_json::from_str(json).expect("parse");
319 assert_eq!(resp.access_token, "at");
320 assert_eq!(resp.token_type.as_deref(), Some("Bearer"));
321 assert_eq!(resp.expires_in, Some(300));
322 assert!(resp.refresh_token.is_none());
323 }
324
325 #[test]
326 fn token_response_deserialize_full() {
327 let json =
328 r#"{"access_token":"at","token_type":"Bearer","expires_in":3600,"refresh_token":"rt"}"#;
329 let resp: TokenResponse = serde_json::from_str(json).expect("parse");
330 assert_eq!(resp.refresh_token.as_deref(), Some("rt"));
331 }
332}