Skip to main content

stoat_core/
token.rs

1//! Token types for stoat.
2//!
3//! Defines the stored token format and the OAuth token endpoint response
4//! format. These are pure data types with serialization support — no I/O.
5
6use serde::{Deserialize, Serialize};
7
8/// Default refresh margin in seconds.
9///
10/// The token is considered to need refreshing when the current time is
11/// within this many seconds of `expires_at`. This allows proactive refresh
12/// before the token actually expires, avoiding 401 errors on forwarded
13/// requests.
14pub const DEFAULT_REFRESH_MARGIN_SECS: u64 = 60;
15
16/// Stored token data, persisted to the token file.
17///
18/// This is the format written by `stoat login` and read/updated by
19/// `stoat serve`. The `expires_at` field is a Unix timestamp (seconds).
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct StoredToken {
22    /// The current OAuth access token.
23    pub access_token: String,
24    /// The refresh token for obtaining new access tokens.
25    pub refresh_token: String,
26    /// Unix timestamp (seconds) when the access token expires.
27    pub expires_at: u64,
28}
29
30impl StoredToken {
31    /// Serialize to a JSON string.
32    ///
33    /// # Errors
34    ///
35    /// Returns a [`serde_json::Error`] if serialization fails (should not
36    /// happen for this type).
37    pub fn to_json(&self) -> Result<String, serde_json::Error> {
38        serde_json::to_string_pretty(self)
39    }
40
41    /// Deserialize from a JSON string.
42    ///
43    /// # Errors
44    ///
45    /// Returns a [`serde_json::Error`] if the input is not valid JSON or
46    /// does not match the expected schema.
47    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
48        serde_json::from_str(s)
49    }
50
51    /// Check whether the access token has expired.
52    ///
53    /// `now_unix` is the current Unix timestamp in seconds. Returns `true`
54    /// if the current time is at or past `expires_at`.
55    #[must_use]
56    pub const fn is_expired(&self, now_unix: u64) -> bool {
57        now_unix >= self.expires_at
58    }
59
60    /// Check whether the access token needs refreshing.
61    ///
62    /// Returns `true` if the token is expired or will expire within
63    /// `margin_secs` seconds. This allows proactive refresh before the
64    /// token actually expires, avoiding 401 errors on forwarded requests.
65    ///
66    /// Use [`DEFAULT_REFRESH_MARGIN_SECS`] for the standard margin.
67    #[must_use]
68    pub const fn needs_refresh(&self, now_unix: u64, margin_secs: u64) -> bool {
69        now_unix.saturating_add(margin_secs) >= self.expires_at
70    }
71}
72
73/// Response from the OAuth token endpoint.
74///
75/// This is the standard OAuth 2.0 token response format. The `expires_in`
76/// field is seconds from now, which must be converted to an absolute
77/// `expires_at` timestamp for storage.
78#[derive(Debug, Clone, Deserialize)]
79pub struct TokenResponse {
80    /// The access token issued by the authorization server.
81    pub access_token: String,
82    /// The refresh token (may not always be present, but required for stoat).
83    pub refresh_token: Option<String>,
84    /// The lifetime of the access token in seconds.
85    pub expires_in: Option<u64>,
86    /// The token type (typically "Bearer").
87    pub token_type: Option<String>,
88}
89
90impl TokenResponse {
91    /// Convert to a [`StoredToken`] using the given current time.
92    ///
93    /// `now_unix` is the current Unix timestamp in seconds. If `expires_in`
94    /// is not present, the token is assumed to expire in 3600 seconds (1 hour).
95    ///
96    /// # Errors
97    ///
98    /// Returns an error if `refresh_token` is `None`, since stoat requires
99    /// a refresh token for automatic token renewal.
100    pub fn into_stored_token(self, now_unix: u64) -> Result<StoredToken, MissingRefreshToken> {
101        let refresh_token = self.refresh_token.ok_or(MissingRefreshToken)?;
102        let expires_in = self.expires_in.unwrap_or(3600);
103        Ok(StoredToken {
104            access_token: self.access_token,
105            refresh_token,
106            expires_at: now_unix + expires_in,
107        })
108    }
109
110    /// Convert a refresh response to a [`StoredToken`], using the existing
111    /// refresh token as a fallback.
112    ///
113    /// During a token refresh, the authorization server may or may not issue
114    /// a new refresh token. If the response does not include one, the
115    /// existing `fallback_refresh_token` is preserved.
116    ///
117    /// `now_unix` is the current Unix timestamp in seconds. If `expires_in`
118    /// is not present, the token is assumed to expire in 3600 seconds (1 hour).
119    #[must_use]
120    pub fn into_refreshed_token(self, fallback_refresh_token: &str, now_unix: u64) -> StoredToken {
121        let refresh_token = self
122            .refresh_token
123            .unwrap_or_else(|| fallback_refresh_token.to_owned());
124        let expires_in = self.expires_in.unwrap_or(3600);
125        StoredToken {
126            access_token: self.access_token,
127            refresh_token,
128            expires_at: now_unix + expires_in,
129        }
130    }
131}
132
133/// Error returned when the token response does not include a refresh token.
134#[derive(Debug, Clone, thiserror::Error)]
135#[error("token response did not include a refresh_token")]
136pub struct MissingRefreshToken;
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn stored_token_roundtrip() {
144        let token = StoredToken {
145            access_token: "access-123".into(),
146            refresh_token: "refresh-456".into(),
147            expires_at: 1_710_000_000,
148        };
149
150        let json = token.to_json().unwrap();
151        let parsed = StoredToken::from_json(&json).unwrap();
152        assert_eq!(token, parsed);
153    }
154
155    #[test]
156    fn stored_token_json_format() {
157        let token = StoredToken {
158            access_token: "eyJ...".into(),
159            refresh_token: "eyJ...".into(),
160            expires_at: 1_710_000_000,
161        };
162
163        let json = token.to_json().unwrap();
164        // Verify the JSON contains the expected fields.
165        assert!(json.contains("\"access_token\""));
166        assert!(json.contains("\"refresh_token\""));
167        assert!(json.contains("\"expires_at\""));
168    }
169
170    #[test]
171    fn token_response_into_stored_token() {
172        let response = TokenResponse {
173            access_token: "access-abc".into(),
174            refresh_token: Some("refresh-xyz".into()),
175            expires_in: Some(7200),
176            token_type: Some("Bearer".into()),
177        };
178
179        let now = 1_700_000_000;
180        let stored = response.into_stored_token(now).unwrap();
181        assert_eq!(stored.access_token, "access-abc");
182        assert_eq!(stored.refresh_token, "refresh-xyz");
183        assert_eq!(stored.expires_at, now + 7200);
184    }
185
186    #[test]
187    fn token_response_default_expiry() {
188        let response = TokenResponse {
189            access_token: "access".into(),
190            refresh_token: Some("refresh".into()),
191            expires_in: None,
192            token_type: None,
193        };
194
195        let now = 1_700_000_000;
196        let stored = response.into_stored_token(now).unwrap();
197        assert_eq!(stored.expires_at, now + 3600, "should default to 1 hour");
198    }
199
200    #[test]
201    fn token_response_missing_refresh_token() {
202        let response = TokenResponse {
203            access_token: "access".into(),
204            refresh_token: None,
205            expires_in: Some(3600),
206            token_type: Some("Bearer".into()),
207        };
208
209        let result = response.into_stored_token(1_700_000_000);
210        assert!(result.is_err());
211        assert_eq!(
212            result.unwrap_err().to_string(),
213            "token response did not include a refresh_token"
214        );
215    }
216
217    #[test]
218    fn deserialize_stored_token_from_doc_example() {
219        let json = r#"{
220  "access_token": "eyJ...",
221  "refresh_token": "eyJ...",
222  "expires_at": 1710000000
223}"#;
224        let token = StoredToken::from_json(json).unwrap();
225        assert_eq!(token.access_token, "eyJ...");
226        assert_eq!(token.refresh_token, "eyJ...");
227        assert_eq!(token.expires_at, 1_710_000_000);
228    }
229
230    #[test]
231    fn deserialize_token_response() {
232        let json = r#"{
233  "access_token": "abc",
234  "refresh_token": "def",
235  "expires_in": 3600,
236  "token_type": "Bearer"
237}"#;
238        let response: TokenResponse = serde_json::from_str(json).unwrap();
239        assert_eq!(response.access_token, "abc");
240        assert_eq!(response.refresh_token.unwrap(), "def");
241        assert_eq!(response.expires_in.unwrap(), 3600);
242        assert_eq!(response.token_type.unwrap(), "Bearer");
243    }
244
245    #[test]
246    fn is_expired_before_expiry() {
247        let token = StoredToken {
248            access_token: "a".into(),
249            refresh_token: "r".into(),
250            expires_at: 1_000,
251        };
252        assert!(!token.is_expired(999));
253    }
254
255    #[test]
256    fn is_expired_at_expiry() {
257        let token = StoredToken {
258            access_token: "a".into(),
259            refresh_token: "r".into(),
260            expires_at: 1_000,
261        };
262        assert!(token.is_expired(1_000));
263    }
264
265    #[test]
266    fn is_expired_after_expiry() {
267        let token = StoredToken {
268            access_token: "a".into(),
269            refresh_token: "r".into(),
270            expires_at: 1_000,
271        };
272        assert!(token.is_expired(1_001));
273    }
274
275    #[test]
276    fn needs_refresh_well_before_margin() {
277        let token = StoredToken {
278            access_token: "a".into(),
279            refresh_token: "r".into(),
280            expires_at: 1_000,
281        };
282        // 800 + 60 = 860 < 1000, no refresh needed
283        assert!(!token.needs_refresh(800, 60));
284    }
285
286    #[test]
287    fn needs_refresh_within_margin() {
288        let token = StoredToken {
289            access_token: "a".into(),
290            refresh_token: "r".into(),
291            expires_at: 1_000,
292        };
293        // 950 + 60 = 1010 >= 1000, needs refresh
294        assert!(token.needs_refresh(950, 60));
295    }
296
297    #[test]
298    fn needs_refresh_at_boundary() {
299        let token = StoredToken {
300            access_token: "a".into(),
301            refresh_token: "r".into(),
302            expires_at: 1_000,
303        };
304        // 940 + 60 = 1000 >= 1000, needs refresh (at exact boundary)
305        assert!(token.needs_refresh(940, 60));
306    }
307
308    #[test]
309    fn needs_refresh_just_before_boundary() {
310        let token = StoredToken {
311            access_token: "a".into(),
312            refresh_token: "r".into(),
313            expires_at: 1_000,
314        };
315        // 939 + 60 = 999 < 1000, no refresh needed
316        assert!(!token.needs_refresh(939, 60));
317    }
318
319    #[test]
320    fn needs_refresh_with_zero_margin_same_as_expired() {
321        let token = StoredToken {
322            access_token: "a".into(),
323            refresh_token: "r".into(),
324            expires_at: 1_000,
325        };
326        assert_eq!(token.needs_refresh(999, 0), token.is_expired(999));
327        assert_eq!(token.needs_refresh(1_000, 0), token.is_expired(1_000));
328        assert_eq!(token.needs_refresh(1_001, 0), token.is_expired(1_001));
329    }
330
331    #[test]
332    fn needs_refresh_already_expired() {
333        let token = StoredToken {
334            access_token: "a".into(),
335            refresh_token: "r".into(),
336            expires_at: 1_000,
337        };
338        assert!(token.needs_refresh(2_000, 60));
339    }
340
341    #[test]
342    fn into_refreshed_token_with_new_refresh_token() {
343        let response = TokenResponse {
344            access_token: "new-access".into(),
345            refresh_token: Some("new-refresh".into()),
346            expires_in: Some(7200),
347            token_type: Some("Bearer".into()),
348        };
349
350        let stored = response.into_refreshed_token("old-refresh", 1_700_000_000);
351        assert_eq!(stored.access_token, "new-access");
352        assert_eq!(stored.refresh_token, "new-refresh");
353        assert_eq!(stored.expires_at, 1_700_000_000 + 7200);
354    }
355
356    #[test]
357    fn into_refreshed_token_preserves_old_refresh_token() {
358        let response = TokenResponse {
359            access_token: "new-access".into(),
360            refresh_token: None,
361            expires_in: Some(3600),
362            token_type: Some("Bearer".into()),
363        };
364
365        let stored = response.into_refreshed_token("old-refresh", 1_700_000_000);
366        assert_eq!(stored.access_token, "new-access");
367        assert_eq!(
368            stored.refresh_token, "old-refresh",
369            "should preserve the existing refresh token when none is returned"
370        );
371        assert_eq!(stored.expires_at, 1_700_000_000 + 3600);
372    }
373
374    #[test]
375    fn from_json_ignores_extra_fields() {
376        // Forward compatibility: extra fields in the JSON should be silently ignored.
377        let json = r#"{
378  "access_token": "tok",
379  "refresh_token": "ref",
380  "expires_at": 1000,
381  "some_future_field": "value"
382}"#;
383        let token = StoredToken::from_json(json).unwrap();
384        assert_eq!(token.access_token, "tok");
385    }
386
387    #[test]
388    fn from_json_missing_field_is_error() {
389        let json = r#"{ "access_token": "tok" }"#;
390        assert!(StoredToken::from_json(json).is_err());
391    }
392
393    #[test]
394    fn needs_refresh_no_overflow_at_max() {
395        // When now_unix + margin would overflow u64, should still work.
396        let token = StoredToken {
397            access_token: "a".into(),
398            refresh_token: "r".into(),
399            expires_at: u64::MAX,
400        };
401        // now + margin wraps, but token should not need refresh.
402        // This tests that extremely large values don't panic.
403        assert!(token.needs_refresh(u64::MAX, 60));
404    }
405
406    #[test]
407    fn is_expired_at_zero() {
408        let token = StoredToken {
409            access_token: "a".into(),
410            refresh_token: "r".into(),
411            expires_at: 0,
412        };
413        assert!(token.is_expired(0));
414        assert!(token.is_expired(1));
415    }
416
417    #[test]
418    fn into_refreshed_token_default_expiry() {
419        let response = TokenResponse {
420            access_token: "new-access".into(),
421            refresh_token: None,
422            expires_in: None,
423            token_type: None,
424        };
425
426        let stored = response.into_refreshed_token("old-refresh", 1_700_000_000);
427        assert_eq!(
428            stored.expires_at,
429            1_700_000_000 + 3600,
430            "should default to 1 hour"
431        );
432    }
433}