pidge_client/auth/
tokens.rs1use chrono::{DateTime, Duration, Utc};
4use serde::{Deserialize, Serialize};
5
6#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
8pub struct TokenSet {
9 pub access_token: String,
10 pub refresh_token: String,
11 pub expires_at: DateTime<Utc>,
12 #[serde(default, skip_serializing_if = "Option::is_none")]
16 pub scope: Option<String>,
17}
18
19impl TokenSet {
20 pub fn needs_refresh(&self) -> bool {
23 Utc::now() + Duration::seconds(60) >= self.expires_at
24 }
25}
26
27impl std::fmt::Debug for TokenSet {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 f.debug_struct("TokenSet")
33 .field("access_token", &"<redacted>")
34 .field("refresh_token", &"<redacted>")
35 .field("expires_at", &self.expires_at)
36 .field("scope", &self.scope)
37 .finish()
38 }
39}
40
41#[cfg(test)]
42mod tests {
43 use super::*;
44
45 #[test]
46 fn fresh_token_does_not_need_refresh() {
47 let t = TokenSet {
48 access_token: "a".into(),
49 refresh_token: "r".into(),
50 expires_at: Utc::now() + Duration::seconds(3600),
51 scope: None,
52 };
53 assert!(!t.needs_refresh());
54 }
55
56 #[test]
57 fn token_expiring_within_60s_needs_refresh() {
58 let t = TokenSet {
59 access_token: "a".into(),
60 refresh_token: "r".into(),
61 expires_at: Utc::now() + Duration::seconds(30),
62 scope: None,
63 };
64 assert!(t.needs_refresh());
65 }
66
67 #[test]
68 fn already_expired_token_needs_refresh() {
69 let t = TokenSet {
70 access_token: "a".into(),
71 refresh_token: "r".into(),
72 expires_at: Utc::now() - Duration::seconds(10),
73 scope: None,
74 };
75 assert!(t.needs_refresh());
76 }
77
78 #[test]
79 fn tokens_roundtrip_through_json() {
80 let t = TokenSet {
81 access_token: "ey…".into(),
82 refresh_token: "M.C5…".into(),
83 expires_at: DateTime::parse_from_rfc3339("2026-05-13T23:00:00Z")
84 .unwrap()
85 .to_utc(),
86 scope: Some("openid Mail.ReadWrite".into()),
87 };
88 let json = serde_json::to_string(&t).unwrap();
89 let t2: TokenSet = serde_json::from_str(&json).unwrap();
90 assert_eq!(t, t2);
91 }
92
93 #[test]
94 fn debug_redacts_both_tokens() {
95 let t = TokenSet {
96 access_token: "super-secret-access".into(),
97 refresh_token: "super-secret-refresh".into(),
98 expires_at: Utc::now(),
99 scope: None,
100 };
101
102 let debug = format!("{t:?}");
103
104 assert!(!debug.contains("super-secret-access"));
105 assert!(!debug.contains("super-secret-refresh"));
106 assert!(debug.contains("<redacted>"));
107 }
108}