oauth2_broker/auth/token/
record.rs1use crate::{
5 _prelude::*,
6 auth::{
7 ScopeSet,
8 token::{family::TokenFamily, secret::TokenSecret},
9 },
10};
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
14pub enum TokenStatus {
15 Pending,
17 Active,
19 Expired,
21 Revoked,
23}
24
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ThisError)]
27pub enum TokenRecordBuilderError {
28 #[error("Access token is required.")]
30 MissingAccessToken,
31 #[error("Expiry must be supplied via expires_at or expires_in.")]
33 MissingExpiry,
34}
35
36#[derive(Serialize, Deserialize, Clone)]
38pub struct TokenRecord {
39 pub family: TokenFamily,
41 pub scope: ScopeSet,
43 pub access_token: TokenSecret,
45 pub refresh_token: Option<TokenSecret>,
47 pub issued_at: OffsetDateTime,
49 pub expires_at: OffsetDateTime,
51 pub revoked_at: Option<OffsetDateTime>,
53}
54impl TokenRecord {
55 pub fn builder(family: TokenFamily, scope: ScopeSet) -> TokenRecordBuilder {
57 TokenRecordBuilder::new(family, scope)
58 }
59
60 pub fn status_at(&self, instant: OffsetDateTime) -> TokenStatus {
62 if self.revoked_at.is_some() {
63 return TokenStatus::Revoked;
64 }
65 if instant < self.issued_at {
66 return TokenStatus::Pending;
67 }
68 if instant >= self.expires_at {
69 return TokenStatus::Expired;
70 }
71
72 TokenStatus::Active
73 }
74
75 pub fn status(&self) -> TokenStatus {
77 self.status_at(OffsetDateTime::now_utc())
78 }
79
80 pub fn is_pending_at(&self, instant: OffsetDateTime) -> bool {
82 matches!(self.status_at(instant), TokenStatus::Pending)
83 }
84
85 pub fn is_pending(&self) -> bool {
87 matches!(self.status(), TokenStatus::Pending)
88 }
89
90 pub fn is_active(&self) -> bool {
92 matches!(self.status(), TokenStatus::Active)
93 }
94
95 pub fn is_expired_at(&self, instant: OffsetDateTime) -> bool {
97 matches!(self.status_at(instant), TokenStatus::Expired)
98 }
99
100 pub fn is_expired(&self) -> bool {
102 matches!(self.status(), TokenStatus::Expired)
103 }
104
105 pub fn is_revoked(&self) -> bool {
107 self.revoked_at.is_some()
108 }
109
110 pub fn revoke(&mut self, instant: OffsetDateTime) {
112 self.revoked_at = Some(instant);
113 }
114}
115impl Debug for TokenRecord {
116 fn fmt(&self, f: &mut Formatter) -> FmtResult {
117 f.debug_struct("TokenRecord")
118 .field("family", &self.family)
119 .field("scope", &self.scope)
120 .field("access_token", &"<redacted>")
121 .field("refresh_token", &self.refresh_token.as_ref().map(|_| "<redacted>"))
122 .field("issued_at", &self.issued_at)
123 .field("expires_at", &self.expires_at)
124 .field("revoked_at", &self.revoked_at)
125 .finish()
126 }
127}
128
129#[derive(Clone, Debug)]
131pub struct TokenRecordBuilder {
132 family: TokenFamily,
133 scope: ScopeSet,
134 access_token: Option<TokenSecret>,
135 refresh_token: Option<TokenSecret>,
136 issued_at: Option<OffsetDateTime>,
137 expires_at: Option<OffsetDateTime>,
138 expires_in: Option<Duration>,
139}
140impl TokenRecordBuilder {
141 fn new(family: TokenFamily, scope: ScopeSet) -> Self {
142 Self {
143 family,
144 scope,
145 access_token: None,
146 refresh_token: None,
147 issued_at: None,
148 expires_at: None,
149 expires_in: None,
150 }
151 }
152
153 pub fn issued_at(mut self, instant: OffsetDateTime) -> Self {
155 self.issued_at = Some(instant);
156
157 self
158 }
159
160 pub fn issued_now(self) -> Self {
162 self.issued_at(OffsetDateTime::now_utc())
163 }
164
165 pub fn expires_at(mut self, instant: OffsetDateTime) -> Self {
167 self.expires_at = Some(instant);
168
169 self
170 }
171
172 pub fn expires_in(mut self, duration: Duration) -> Self {
174 self.expires_in = Some(duration);
175
176 self
177 }
178
179 pub fn access_token(mut self, token: impl Into<String>) -> Self {
181 self.access_token = Some(TokenSecret::new(token));
182
183 self
184 }
185
186 pub fn refresh_token(mut self, token: impl Into<String>) -> Self {
188 self.refresh_token = Some(TokenSecret::new(token));
189
190 self
191 }
192
193 pub fn build(self) -> Result<TokenRecord, TokenRecordBuilderError> {
195 let access_token = self.access_token.ok_or(TokenRecordBuilderError::MissingAccessToken)?;
196 let issued_at = self.issued_at.unwrap_or_else(OffsetDateTime::now_utc);
197 let expires_at = match (self.expires_at, self.expires_in) {
198 (Some(instant), _) => instant,
199 (None, Some(delta)) => issued_at + delta,
200 (None, None) => return Err(TokenRecordBuilderError::MissingExpiry),
201 };
202
203 Ok(TokenRecord {
204 family: self.family,
205 scope: self.scope,
206 access_token,
207 refresh_token: self.refresh_token,
208 issued_at,
209 expires_at,
210 revoked_at: None,
211 })
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use time::macros;
219 use super::*;
221 use crate::auth::{PrincipalId, TenantId};
222
223 #[test]
224 fn status_transitions_cover_all_states() {
225 let tenant = TenantId::new("t-1").expect("Tenant fixture should be valid.");
226 let principal = PrincipalId::new("p-1").expect("Principal fixture should be valid.");
227 let family = TokenFamily::new(tenant, principal);
228 let scope = ScopeSet::new(["email", "profile"])
229 .expect("Scope fixture should be valid for token record tests.");
230 let issued = macros::datetime!(2025-01-01 00:00 UTC);
231 let expires = macros::datetime!(2025-01-01 01:00 UTC);
232 let mut record = TokenRecord::builder(family.clone(), scope)
233 .access_token("access")
234 .refresh_token("refresh")
235 .issued_at(issued)
236 .expires_at(expires)
237 .build()
238 .expect("Token record builder should succeed for status transitions.");
239
240 assert_eq!(record.status_at(macros::datetime!(2024-12-31 23:59 UTC)), TokenStatus::Pending);
241 assert_eq!(record.status_at(macros::datetime!(2025-01-01 00:30 UTC)), TokenStatus::Active);
242 assert_eq!(record.status_at(macros::datetime!(2025-01-01 01:00 UTC)), TokenStatus::Expired);
243
244 record.revoke(macros::datetime!(2025-01-01 00:10 UTC));
245
246 assert_eq!(record.status_at(macros::datetime!(2025-01-01 00:30 UTC)), TokenStatus::Revoked);
247 }
248
249 #[test]
250 fn builder_handles_relative_expiry() {
251 let tenant = TenantId::new("tenant").expect("Tenant fixture should be valid.");
252 let principal = PrincipalId::new("principal").expect("Principal fixture should be valid.");
253 let family = TokenFamily::new(tenant, principal);
254 let scope = ScopeSet::new(["email"])
255 .expect("Scope fixture should be valid for relative expiry test.");
256 let record = TokenRecord::builder(family, scope)
257 .access_token("secret")
258 .issued_at(macros::datetime!(2025-01-01 00:00 UTC))
259 .expires_in(Duration::minutes(30))
260 .build()
261 .expect("Token record builder should support relative expiry calculations.");
262
263 assert_eq!(record.expires_at, macros::datetime!(2025-01-01 00:30 UTC));
264 }
265
266 #[test]
267 fn helper_methods_match_statuses() {
268 let tenant = TenantId::new("t").expect("Tenant fixture should be valid.");
269 let principal = PrincipalId::new("p").expect("Principal fixture should be valid.");
270 let scope = ScopeSet::new(["email"])
271 .expect("Scope fixture should be valid for helper method coverage.");
272 let now = OffsetDateTime::now_utc();
273 let pending = TokenRecord::builder(
274 TokenFamily::new(tenant.clone(), principal.clone()),
275 scope.clone(),
276 )
277 .access_token("pending")
278 .issued_at(now + Duration::minutes(5))
279 .expires_at(now + Duration::hours(1))
280 .build()
281 .expect("Pending record builder should succeed.");
282
283 assert!(pending.is_pending());
284 assert!(pending.is_pending_at(now));
285 assert!(!pending.is_active());
286
287 let mut active = TokenRecord::builder(
288 TokenFamily::new(tenant.clone(), principal.clone()),
289 scope.clone(),
290 )
291 .access_token("active")
292 .issued_at(now - Duration::minutes(1))
293 .expires_at(now + Duration::minutes(1))
294 .build()
295 .expect("Active record builder should succeed.");
296
297 assert!(active.is_active());
298 assert!(!active.is_revoked());
299
300 active.revoke(now);
301
302 assert!(active.is_revoked());
303
304 let expired = TokenRecord::builder(TokenFamily::new(tenant, principal), scope)
305 .access_token("expired")
306 .issued_at(now - Duration::hours(2))
307 .expires_at(now - Duration::minutes(1))
308 .build()
309 .expect("Expired record builder should succeed.");
310
311 assert!(expired.is_expired());
312 assert!(expired.is_expired_at(now));
313 }
314}