oauth2_broker/auth/token/
record.rs

1//! Immutable token record structs, lifecycle helpers, and builders.
2
3// self
4use crate::{
5	_prelude::*,
6	auth::{
7		ScopeSet,
8		token::{family::TokenFamily, secret::TokenSecret},
9	},
10};
11
12/// Current lifecycle status for a token record.
13#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
14pub enum TokenStatus {
15	/// Token is not yet valid because the issued-at instant is in the future.
16	Pending,
17	/// Token is currently valid.
18	Active,
19	/// Token exceeded its expiry instant.
20	Expired,
21	/// Token has been revoked locally or by the provider.
22	Revoked,
23}
24
25/// Errors produced by [`TokenRecordBuilder`].
26#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ThisError)]
27pub enum TokenRecordBuilderError {
28	/// Issued when no access token value was provided.
29	#[error("Access token is required.")]
30	MissingAccessToken,
31	/// Issued when no expiry (absolute or relative) was configured.
32	#[error("Expiry must be supplied via expires_at or expires_in.")]
33	MissingExpiry,
34}
35
36/// Immutable record describing issued OAuth tokens.
37#[derive(Serialize, Deserialize, Clone)]
38pub struct TokenRecord {
39	/// Logical token grouping (tenant/principal/provider).
40	pub family: TokenFamily,
41	/// Normalized scopes granted to this record.
42	pub scope: ScopeSet,
43	/// Access token secret; callers must avoid logging it.
44	pub access_token: TokenSecret,
45	/// Refresh token secret, if the provider issued one.
46	pub refresh_token: Option<TokenSecret>,
47	/// Issued-at instant recorded from the provider response.
48	pub issued_at: OffsetDateTime,
49	/// Expiry instant derived from issued_at plus expires_in or absolute expiry.
50	pub expires_at: OffsetDateTime,
51	/// Revocation instant if the record has been revoked.
52	pub revoked_at: Option<OffsetDateTime>,
53}
54impl TokenRecord {
55	/// Returns a builder for constructing rotation-friendly records.
56	pub fn builder(family: TokenFamily, scope: ScopeSet) -> TokenRecordBuilder {
57		TokenRecordBuilder::new(family, scope)
58	}
59
60	/// Computes the lifecycle status at a given instant.
61	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	/// Convenience helper that checks the status using the current UTC instant.
76	pub fn status(&self) -> TokenStatus {
77		self.status_at(OffsetDateTime::now_utc())
78	}
79
80	/// Returns `true` if the record is considered pending at the provided instant.
81	pub fn is_pending_at(&self, instant: OffsetDateTime) -> bool {
82		matches!(self.status_at(instant), TokenStatus::Pending)
83	}
84
85	/// Returns `true` if the record is currently pending (issued_at in the future).
86	pub fn is_pending(&self) -> bool {
87		matches!(self.status(), TokenStatus::Pending)
88	}
89
90	/// Returns `true` if the record is currently active (not pending/expired/revoked).
91	pub fn is_active(&self) -> bool {
92		matches!(self.status(), TokenStatus::Active)
93	}
94
95	/// Returns `true` if the record has expired at the provided instant.
96	pub fn is_expired_at(&self, instant: OffsetDateTime) -> bool {
97		matches!(self.status_at(instant), TokenStatus::Expired)
98	}
99
100	/// Returns `true` if the record is expired relative to the current clock.
101	pub fn is_expired(&self) -> bool {
102		matches!(self.status(), TokenStatus::Expired)
103	}
104
105	/// Returns `true` if the record has been revoked.
106	pub fn is_revoked(&self) -> bool {
107		self.revoked_at.is_some()
108	}
109
110	/// Marks the record as revoked.
111	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/// Builder for [`TokenRecord`].
130#[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	/// Sets the issued-at instant.
154	pub fn issued_at(mut self, instant: OffsetDateTime) -> Self {
155		self.issued_at = Some(instant);
156
157		self
158	}
159
160	/// Convenience helper that stamps `issued_at` with the current clock.
161	pub fn issued_now(self) -> Self {
162		self.issued_at(OffsetDateTime::now_utc())
163	}
164
165	/// Sets an absolute expiry instant.
166	pub fn expires_at(mut self, instant: OffsetDateTime) -> Self {
167		self.expires_at = Some(instant);
168
169		self
170	}
171
172	/// Sets a relative expiry duration from the issued instant.
173	pub fn expires_in(mut self, duration: Duration) -> Self {
174		self.expires_in = Some(duration);
175
176		self
177	}
178
179	/// Provides the access token value.
180	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	/// Provides the refresh token value.
187	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	/// Consumes the builder and produces a [`TokenRecord`].
194	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	// crates.io
218	use time::macros;
219	// self
220	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}