volga_oauth_client/
token.rs1use serde::{Deserialize, Serialize};
9use std::time::{Duration, SystemTime};
10
11#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct TokenResponse {
14 pub access_token: String,
16
17 pub token_type: String,
19
20 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub expires_in: Option<u64>,
23
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub refresh_token: Option<String>,
27
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub scope: Option<String>,
31
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub id_token: Option<String>,
35}
36
37#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct TokenSet {
45 pub access_token: String,
47
48 pub token_type: String,
50
51 pub refresh_token: Option<String>,
53
54 pub scope: Option<String>,
56
57 pub id_token: Option<String>,
59
60 pub expires_at: Option<SystemTime>,
63}
64
65impl TokenSet {
66 #[inline]
70 pub fn is_expired(&self) -> bool {
71 self.expires_within(Duration::ZERO)
72 }
73
74 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 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 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 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 let tokens = TokenSet::from(response(Some(u64::MAX)));
186 assert_eq!(tokens.expires_at, None);
187 assert!(!tokens.is_expired());
188
189 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}