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    /// The scopes Microsoft granted (its token response's `scope`), so a
13    /// refresh asks for no more than that; `None` on tokens stored before
14    /// pidge recorded it. See [`crate::auth::config::refresh_scope_string`].
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub scope: Option<String>,
17}
18
19impl TokenSet {
20    /// True if the access token is within 60 seconds of expiring (or already expired).
21    /// We refresh before this threshold to absorb clock skew.
22    pub fn needs_refresh(&self) -> bool {
23        Utc::now() + Duration::seconds(60) >= self.expires_at
24    }
25}
26
27/// Hand-written so `access_token`/`refresh_token` are never printed by an
28/// incidental `{:?}` (a log line, a test failure message, …); see
29/// `crate::mcp::McpTokens`'s matching `Debug` impl.
30impl 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}