Skip to main content

oauth_toolkit/
token.rs

1//! `OAuth2` token endpoint operations: authorization-code exchange (with
2//! PKCE verifier) and refresh-token rotation.
3
4use serde::{Deserialize, Serialize};
5
6/// Errors from token endpoint operations.
7#[derive(Debug, thiserror::Error)]
8pub enum TokenError {
9    /// HTTP request to the token endpoint failed.
10    #[error("token endpoint: {0}")]
11    Http(String),
12    /// The endpoint returned a non-success status.
13    #[error("token endpoint returned {0}")]
14    Status(reqwest::StatusCode),
15    /// The response body was not valid token-endpoint JSON.
16    #[error("token JSON: {0}")]
17    Json(String),
18}
19
20/// Parsed response from an `OAuth2` token endpoint.
21#[derive(Clone, Debug, Serialize, Deserialize)]
22pub struct TokenResponse {
23    /// The access token string.
24    pub access_token: String,
25    /// Token type (e.g. `"Bearer"`).
26    #[serde(default)]
27    pub token_type: Option<String>,
28    /// Lifetime in seconds.
29    #[serde(default)]
30    pub expires_in: Option<u64>,
31    /// Rotated refresh token (if the provider issues one).
32    #[serde(default)]
33    pub refresh_token: Option<String>,
34}
35
36/// Outcome of a completed token request, with access-token expiry
37/// computed in unix milliseconds.
38#[derive(Clone, Debug)]
39pub struct TokenSet {
40    /// Access token (short-lived).
41    pub access_token: String,
42    /// Refresh token (persist and treat as a secret).
43    pub refresh_token: Option<String>,
44    /// Access-token expiry in unix ms.
45    pub expires_at: i64,
46}
47
48/// Exchanges an authorization code for tokens (PKCE verifier required).
49///
50/// # Errors
51/// [`TokenError`] on HTTP/protocol failure.
52pub 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
74/// Refreshes an access token with a stored refresh token.
75///
76/// # Errors
77/// [`TokenError::Status`] when the refresh is rejected (revoked/expired).
78pub 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
96/// Refreshes an access token and returns the raw [`TokenResponse`]
97/// (rotation-aware: callers read `refresh_token` off the response).
98///
99/// # Errors
100/// [`TokenError::Status`] when the refresh is rejected (revoked/expired).
101pub 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
152/// Wall-clock read for token-expiry math (inherently wall-clock: the token
153/// endpoint defines lifetimes).
154fn 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    /// Spawns a minimal mock token endpoint and returns its base URL.
168    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            // Determine response based on request body.
181            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        // The mock returns a new refresh token.
252        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        // Verify the form fields are sent correctly by reading the raw POST
283        // body from a mock server that captures it.
284        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            // Extract the POST body (after double CRLF).
296            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}