Skip to main content

volga_oauth_client/
token.rs

1//! Token models
2//!
3//! [`TokenResponse`] is the wire shape of a successful token endpoint
4//! response (RFC 6749 Section 5.1); [`TokenSet`] is what the application holds
5//! on to - the same fields with `expires_in` resolved into an absolute
6//! [`SystemTime`] captured when the response was received.
7
8use serde::{Deserialize, Serialize};
9use std::time::{Duration, SystemTime};
10
11/// A successful token endpoint response (RFC 6749 Section 5.1)
12#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct TokenResponse {
14    /// The issued access token
15    pub access_token: String,
16
17    /// The token type, almost always `Bearer` (case-insensitive)
18    pub token_type: String,
19
20    /// Access token lifetime in seconds
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub expires_in: Option<u64>,
23
24    /// Refresh token, when the server issued one
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub refresh_token: Option<String>,
27
28    /// The granted scope, when it differs from the requested one
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub scope: Option<String>,
31
32    /// OpenID Connect ID token; passed through as-is, not validated
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub id_token: Option<String>,
35}
36
37/// Tokens held by the application
38///
39/// Produced from a [`TokenResponse`] via `From`, which resolves the
40/// relative `expires_in` into an absolute [`expires_at`](Self::expires_at).
41/// Serializable so a [`TokenStore`](crate::TokenStore) implementation can
42/// persist it.
43#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct TokenSet {
45    /// The access token
46    pub access_token: String,
47
48    /// The token type, almost always `Bearer` (case-insensitive)
49    pub token_type: String,
50
51    /// Refresh token, when the server issued one
52    pub refresh_token: Option<String>,
53
54    /// The granted scope, when the server reported it
55    pub scope: Option<String>,
56
57    /// OpenID Connect ID token; passed through as-is, not validated
58    pub id_token: Option<String>,
59
60    /// Absolute access token expiration; `None` when the server did not
61    /// report a lifetime (or reported one too large to represent)
62    pub expires_at: Option<SystemTime>,
63}
64
65impl TokenSet {
66    /// Returns `true` when the access token has expired
67    ///
68    /// A token without a known lifetime never reports as expired.
69    #[inline]
70    pub fn is_expired(&self) -> bool {
71        self.expires_within(Duration::ZERO)
72    }
73
74    /// Returns `true` when the access token expires within `leeway` from
75    /// now (or already has)
76    ///
77    /// A `leeway` too large to represent covers any expiration.
78    pub fn expires_within(&self, leeway: Duration) -> bool {
79        self.expires_at.is_some_and(|expires_at| {
80            SystemTime::now()
81                .checked_add(leeway)
82                .is_none_or(|deadline| deadline >= expires_at)
83        })
84    }
85}
86
87impl From<TokenResponse> for TokenSet {
88    fn from(response: TokenResponse) -> Self {
89        Self {
90            access_token: response.access_token,
91            token_type: response.token_type,
92            refresh_token: response.refresh_token,
93            scope: response.scope,
94            id_token: response.id_token,
95            // an `expires_in` too large to represent as a `SystemTime`
96            // (a buggy or malicious server) is treated as no reported
97            // lifetime rather than panicking
98            expires_at: response
99                .expires_in
100                .and_then(|secs| SystemTime::now().checked_add(Duration::from_secs(secs))),
101        }
102    }
103}
104
105impl std::fmt::Debug for TokenResponse {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        // tokens are credentials - never expose them in debug output
108        f.debug_struct("TokenResponse")
109            .field("access_token", &"[redacted]")
110            .field("token_type", &self.token_type)
111            .field("expires_in", &self.expires_in)
112            .field(
113                "refresh_token",
114                &self.refresh_token.as_ref().map(|_| "[redacted]"),
115            )
116            .field("scope", &self.scope)
117            .field("id_token", &self.id_token.as_ref().map(|_| "[redacted]"))
118            .finish()
119    }
120}
121
122impl std::fmt::Debug for TokenSet {
123    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124        f.debug_struct("TokenSet")
125            .field("access_token", &"[redacted]")
126            .field("token_type", &self.token_type)
127            .field(
128                "refresh_token",
129                &self.refresh_token.as_ref().map(|_| "[redacted]"),
130            )
131            .field("scope", &self.scope)
132            .field("id_token", &self.id_token.as_ref().map(|_| "[redacted]"))
133            .field("expires_at", &self.expires_at)
134            .finish()
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    fn response(expires_in: Option<u64>) -> TokenResponse {
143        TokenResponse {
144            access_token: "at".into(),
145            token_type: "Bearer".into(),
146            expires_in,
147            refresh_token: Some("rt".into()),
148            scope: Some("read".into()),
149            id_token: None,
150        }
151    }
152
153    #[test]
154    fn it_deserializes_a_minimal_response() {
155        let response: TokenResponse =
156            serde_json::from_str(r#"{"access_token": "at", "token_type": "Bearer"}"#).unwrap();
157        assert_eq!(response.access_token, "at");
158        assert_eq!(response.expires_in, None);
159        assert_eq!(response.refresh_token, None);
160    }
161
162    #[test]
163    fn it_resolves_expiration_into_absolute_time() {
164        let tokens = TokenSet::from(response(Some(3600)));
165        let expires_at = tokens.expires_at.unwrap();
166        let lifetime = expires_at.duration_since(SystemTime::now()).unwrap();
167        assert!(lifetime > Duration::from_secs(3590) && lifetime <= Duration::from_secs(3600));
168
169        assert!(!tokens.is_expired());
170        assert!(tokens.expires_within(Duration::from_secs(3601)));
171
172        // no reported lifetime - never expired
173        let tokens = TokenSet::from(response(None));
174        assert!(!tokens.is_expired());
175        assert!(!tokens.expires_within(Duration::from_secs(3600)));
176
177        let tokens = TokenSet::from(response(Some(0)));
178        assert!(tokens.is_expired());
179    }
180
181    #[test]
182    fn it_survives_unrepresentable_lifetimes() {
183        // an overflowing `expires_in` must not panic - it degrades to
184        // "no reported lifetime"
185        let tokens = TokenSet::from(response(Some(u64::MAX)));
186        assert_eq!(tokens.expires_at, None);
187        assert!(!tokens.is_expired());
188
189        // an overflowing leeway covers any expiration
190        let tokens = TokenSet::from(response(Some(3600)));
191        assert!(tokens.expires_within(Duration::MAX));
192        let tokens = TokenSet::from(response(None));
193        assert!(!tokens.expires_within(Duration::MAX));
194    }
195
196    #[test]
197    fn it_redacts_tokens_in_debug_output() {
198        let debug = format!("{:?}", TokenSet::from(response(Some(60))));
199        assert!(!debug.contains("at") || debug.contains("[redacted]"));
200        assert!(!debug.contains("\"rt\""));
201        let debug = format!("{:?}", response(Some(60)));
202        assert!(debug.contains("[redacted]"));
203    }
204}