Skip to main content

cloud_sdk/authentication/
expiry.rs

1/// Caller-provided monotonic timestamp in seconds.
2///
3/// This value deliberately has no relationship to provider wall-clock time.
4/// Callers must obtain every timestamp in one credential lifetime from the
5/// same monotonic clock.
6#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
7pub struct CredentialTimestamp(u64);
8
9impl CredentialTimestamp {
10    /// Wraps caller-provided monotonic seconds without acquiring a clock.
11    #[must_use]
12    pub const fn from_seconds(seconds: u64) -> Self {
13        Self(seconds)
14    }
15
16    /// Returns the caller-provided monotonic seconds.
17    #[must_use]
18    pub const fn as_seconds(self) -> u64 {
19        self.0
20    }
21}
22
23/// Invalid OAuth-style credential lifetime.
24#[derive(Clone, Copy, Debug, Eq, PartialEq)]
25pub enum CredentialLifetimeError {
26    /// `expires_in` must be nonzero.
27    ZeroExpiresIn,
28    /// Refresh lead time must be nonzero so a refresh window exists.
29    ZeroRefreshWindow,
30    /// Refresh lead time must leave a nonempty fresh interval.
31    RefreshWindowTooLarge,
32    /// The caller timestamp and `expires_in` overflow the representation.
33    TimestampOverflow,
34}
35
36impl_static_error!(CredentialLifetimeError,
37    Self::ZeroExpiresIn => "credential expires_in must be nonzero",
38    Self::ZeroRefreshWindow => "credential refresh window must be nonzero",
39    Self::RefreshWindowTooLarge => "credential refresh window consumes the complete lifetime",
40    Self::TimestampOverflow => "credential expiry timestamp overflows",
41);
42
43/// State of an expiring credential at one caller-provided timestamp.
44#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
45pub enum CredentialLifetimeState {
46    /// The supplied time precedes the acquisition observation.
47    ClockRollback,
48    /// The credential is before its refresh window.
49    Fresh,
50    /// The credential is inside its refresh window but not expired.
51    RefreshRequired,
52    /// The credential has reached or passed its exclusive expiry.
53    Expired,
54}
55
56/// Bounded OAuth-style lifetime derived from `expires_in` and caller time.
57///
58/// Expiry is exclusive: a credential is expired when `now >= expires_at`.
59/// The provider duration is never interpreted as provider wall-clock time.
60#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
61pub struct CredentialLifetime {
62    observed_at: CredentialTimestamp,
63    refresh_at: CredentialTimestamp,
64    expires_at: CredentialTimestamp,
65    expires_in: u32,
66    refresh_before: u32,
67}
68
69impl CredentialLifetime {
70    /// Converts a provider `expires_in` duration through explicit caller time.
71    pub fn from_expires_in(
72        observed_at: CredentialTimestamp,
73        expires_in: u32,
74        refresh_before: u32,
75    ) -> Result<Self, CredentialLifetimeError> {
76        if expires_in == 0 {
77            return Err(CredentialLifetimeError::ZeroExpiresIn);
78        }
79        if refresh_before == 0 {
80            return Err(CredentialLifetimeError::ZeroRefreshWindow);
81        }
82        if refresh_before >= expires_in {
83            return Err(CredentialLifetimeError::RefreshWindowTooLarge);
84        }
85        let expires_at = observed_at
86            .0
87            .checked_add(u64::from(expires_in))
88            .ok_or(CredentialLifetimeError::TimestampOverflow)?;
89        let refresh_at = expires_at
90            .checked_sub(u64::from(refresh_before))
91            .ok_or(CredentialLifetimeError::TimestampOverflow)?;
92        Ok(Self {
93            observed_at,
94            refresh_at: CredentialTimestamp(refresh_at),
95            expires_at: CredentialTimestamp(expires_at),
96            expires_in,
97            refresh_before,
98        })
99    }
100
101    /// Returns when the provider duration was observed.
102    #[must_use]
103    pub const fn observed_at(self) -> CredentialTimestamp {
104        self.observed_at
105    }
106
107    /// Returns the first timestamp at which refresh is required.
108    #[must_use]
109    pub const fn refresh_at(self) -> CredentialTimestamp {
110        self.refresh_at
111    }
112
113    /// Returns the exclusive expiry timestamp.
114    #[must_use]
115    pub const fn expires_at(self) -> CredentialTimestamp {
116        self.expires_at
117    }
118
119    /// Returns the admitted provider duration.
120    #[must_use]
121    pub const fn expires_in(self) -> u32 {
122        self.expires_in
123    }
124
125    /// Returns the caller-selected refresh lead time.
126    #[must_use]
127    pub const fn refresh_before(self) -> u32 {
128        self.refresh_before
129    }
130
131    /// Classifies the lifetime at one observation from the same caller clock.
132    #[must_use]
133    pub const fn state_at(self, now: CredentialTimestamp) -> CredentialLifetimeState {
134        if now.0 < self.observed_at.0 {
135            CredentialLifetimeState::ClockRollback
136        } else if now.0 >= self.expires_at.0 {
137            CredentialLifetimeState::Expired
138        } else if now.0 >= self.refresh_at.0 {
139            CredentialLifetimeState::RefreshRequired
140        } else {
141            CredentialLifetimeState::Fresh
142        }
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::{
149        CredentialLifetime, CredentialLifetimeError, CredentialLifetimeState, CredentialTimestamp,
150    };
151
152    #[test]
153    fn expires_in_uses_explicit_caller_time_and_exclusive_expiry() {
154        let lifetime = CredentialLifetime::from_expires_in(
155            CredentialTimestamp::from_seconds(1_000),
156            3_599,
157            300,
158        );
159        assert!(lifetime.is_ok());
160        let Ok(lifetime) = lifetime else {
161            unreachable!("credential lifetime fixture construction failed");
162        };
163        assert_eq!(lifetime.refresh_at().as_seconds(), 4_299);
164        assert_eq!(lifetime.expires_at().as_seconds(), 4_599);
165        assert_eq!(
166            lifetime.state_at(CredentialTimestamp::from_seconds(999)),
167            CredentialLifetimeState::ClockRollback
168        );
169        assert_eq!(
170            lifetime.state_at(CredentialTimestamp::from_seconds(4_298)),
171            CredentialLifetimeState::Fresh
172        );
173        assert_eq!(
174            lifetime.state_at(CredentialTimestamp::from_seconds(4_299)),
175            CredentialLifetimeState::RefreshRequired
176        );
177        assert_eq!(
178            lifetime.state_at(CredentialTimestamp::from_seconds(4_599)),
179            CredentialLifetimeState::Expired
180        );
181    }
182
183    #[test]
184    fn invalid_or_overflowing_lifetimes_fail_closed() {
185        let now = CredentialTimestamp::from_seconds(10);
186        assert_eq!(
187            CredentialLifetime::from_expires_in(now, 0, 0),
188            Err(CredentialLifetimeError::ZeroExpiresIn)
189        );
190        assert_eq!(
191            CredentialLifetime::from_expires_in(now, 60, 0),
192            Err(CredentialLifetimeError::ZeroRefreshWindow)
193        );
194        assert_eq!(
195            CredentialLifetime::from_expires_in(now, 60, 60),
196            Err(CredentialLifetimeError::RefreshWindowTooLarge)
197        );
198        assert_eq!(
199            CredentialLifetime::from_expires_in(CredentialTimestamp::from_seconds(u64::MAX), 2, 1,),
200            Err(CredentialLifetimeError::TimestampOverflow)
201        );
202    }
203}