1use serde::{Deserialize, Serialize};
7
8pub const DEFAULT_REFRESH_MARGIN_SECS: u64 = 60;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub struct StoredToken {
22 pub access_token: String,
24 pub refresh_token: String,
26 pub expires_at: u64,
28}
29
30impl StoredToken {
31 pub fn to_json(&self) -> Result<String, serde_json::Error> {
38 serde_json::to_string_pretty(self)
39 }
40
41 pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
48 serde_json::from_str(s)
49 }
50
51 #[must_use]
56 pub const fn is_expired(&self, now_unix: u64) -> bool {
57 now_unix >= self.expires_at
58 }
59
60 #[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#[derive(Debug, Clone, Deserialize)]
79pub struct TokenResponse {
80 pub access_token: String,
82 pub refresh_token: Option<String>,
84 pub expires_in: Option<u64>,
86 pub token_type: Option<String>,
88}
89
90impl TokenResponse {
91 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 #[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#[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 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 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 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 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 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 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 let token = StoredToken {
397 access_token: "a".into(),
398 refresh_token: "r".into(),
399 expires_at: u64::MAX,
400 };
401 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}