Skip to main content

pidge_client/auth/
tokens.rs

1//! Token storage shape: what gets serialized into the keychain.
2
3use chrono::{DateTime, Duration, Utc};
4use serde::{Deserialize, Serialize};
5
6/// A user's OAuth tokens for one account.
7#[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}
13
14impl TokenSet {
15    /// True if the access token is within 60 seconds of expiring (or already expired).
16    /// We refresh before this threshold to absorb clock skew.
17    pub fn needs_refresh(&self) -> bool {
18        Utc::now() + Duration::seconds(60) >= self.expires_at
19    }
20}
21
22/// Hand-written so `access_token`/`refresh_token` are never printed by an
23/// incidental `{:?}` (a log line, a test failure message, …); see
24/// `crate::mcp::McpTokens`'s matching `Debug` impl.
25impl std::fmt::Debug for TokenSet {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("TokenSet")
28            .field("access_token", &"<redacted>")
29            .field("refresh_token", &"<redacted>")
30            .field("expires_at", &self.expires_at)
31            .finish()
32    }
33}
34
35#[cfg(test)]
36mod tests {
37    use super::*;
38
39    #[test]
40    fn fresh_token_does_not_need_refresh() {
41        let t = TokenSet {
42            access_token: "a".into(),
43            refresh_token: "r".into(),
44            expires_at: Utc::now() + Duration::seconds(3600),
45        };
46        assert!(!t.needs_refresh());
47    }
48
49    #[test]
50    fn token_expiring_within_60s_needs_refresh() {
51        let t = TokenSet {
52            access_token: "a".into(),
53            refresh_token: "r".into(),
54            expires_at: Utc::now() + Duration::seconds(30),
55        };
56        assert!(t.needs_refresh());
57    }
58
59    #[test]
60    fn already_expired_token_needs_refresh() {
61        let t = TokenSet {
62            access_token: "a".into(),
63            refresh_token: "r".into(),
64            expires_at: Utc::now() - Duration::seconds(10),
65        };
66        assert!(t.needs_refresh());
67    }
68
69    #[test]
70    fn tokens_roundtrip_through_json() {
71        let t = TokenSet {
72            access_token: "ey…".into(),
73            refresh_token: "M.C5…".into(),
74            expires_at: DateTime::parse_from_rfc3339("2026-05-13T23:00:00Z")
75                .unwrap()
76                .to_utc(),
77        };
78        let json = serde_json::to_string(&t).unwrap();
79        let t2: TokenSet = serde_json::from_str(&json).unwrap();
80        assert_eq!(t, t2);
81    }
82
83    #[test]
84    fn debug_redacts_both_tokens() {
85        let t = TokenSet {
86            access_token: "super-secret-access".into(),
87            refresh_token: "super-secret-refresh".into(),
88            expires_at: Utc::now(),
89        };
90
91        let debug = format!("{t:?}");
92
93        assert!(!debug.contains("super-secret-access"));
94        assert!(!debug.contains("super-secret-refresh"));
95        assert!(debug.contains("<redacted>"));
96    }
97}