aster_forge_auth/refresh.rs
1//! Refresh state-machine decisions and a transaction-aware persistence port.
2
3use crate::{
4 AuthError, AuthTokenPair, AuthTokenType, Clock, JwtSigner, JwtVerifier, RefreshCoordinator,
5 RefreshJti, Restriction, SessionId, SessionVersion, Subject,
6};
7use async_trait::async_trait;
8use std::{fmt, sync::Arc};
9use tokio_util::sync::CancellationToken;
10
11/// Untrusted client metadata used only for stale-retry classification, never device authentication.
12#[derive(Debug, Clone, Default, PartialEq, Eq)]
13pub struct ClientContext {
14 /// IP already normalized by the product's trusted-proxy boundary.
15 pub ip_address: Option<String>,
16 /// User agent supplied by the client; matching it is not proof of identity.
17 pub user_agent: Option<String>,
18}
19
20/// Explicit replay policy. Both variants reject old credentials; neither grants a second refresh.
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
22pub enum RefreshPolicy {
23 /// Every identifiable old credential is treated as possible theft.
24 #[default]
25 Strict,
26 /// Classify only the immediately previous JTI with matching nonempty UA and compatible IP as
27 /// stale, within this inclusive window. The product explicitly accepts spoofable fingerprint
28 /// risk; missing/mismatched evidence still triggers reuse handling.
29 SameClientWithin {
30 /// Maximum age of the previous rotation, in seconds.
31 seconds: u64,
32 },
33}
34
35/// Authoritative product session snapshot, read under the transaction used for the decision.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct RefreshSession {
38 /// Account owning this session.
39 pub subject: Subject,
40 /// Stable session identity used to identify replays older than the previous JTI.
41 pub session_id: SessionId,
42 /// Currently usable refresh identity.
43 pub current_refresh_jti: RefreshJti,
44 /// Immediately preceding identity, if this session has rotated.
45 pub previous_refresh_jti: Option<RefreshJti>,
46 /// Last committed successful rotation time.
47 pub last_seen_at: i64,
48 /// Exclusive server-side session expiry.
49 pub refresh_expires_at: i64,
50 /// Whether this session has been revoked.
51 pub revoked: bool,
52 /// Stored client evidence from the last successful request.
53 pub client: ClientContext,
54}
55
56/// Candidate mutation submitted to the product's writer transaction. It contains IDs and expiry,
57/// never token strings. Stores must recheck current account state, version, restriction and session
58/// ownership, then use [`classify_refresh`] before conditional rotation or atomic reuse revocation.
59#[derive(Debug, Clone)]
60pub struct RefreshRotationRequest {
61 /// Verified account subject.
62 pub subject: Subject,
63 /// Verified session identity.
64 pub session_id: SessionId,
65 /// Verified account generation.
66 pub session_version: SessionVersion,
67 /// Verified login restriction.
68 pub restriction: Restriction,
69 /// Expected usable refresh identity.
70 pub current_refresh_jti: RefreshJti,
71 /// Exclusive credential deadline (`exp` plus verifier leeway), widened to avoid overflow.
72 /// Stores must refresh `now` after waiting for database locks before classification.
73 pub current_refresh_valid_until: i128,
74 /// Candidate next identity; uniqueness must be enforced by persistence.
75 pub next_refresh_jti: RefreshJti,
76 /// Candidate next refresh expiry.
77 pub next_refresh_expires_at: i64,
78 /// Current client evidence. Do not substitute stored evidence before replay classification.
79 pub client: ClientContext,
80 /// Decision timestamp supplied by the injected clock.
81 pub now: i64,
82 /// Explicit product-selected stale/reuse policy.
83 pub policy: RefreshPolicy,
84}
85
86/// Pure state-machine decision; products apply its effects atomically in their own transaction.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum RefreshDecision {
89 /// Current JTI matches; attempt the conditional update.
90 Rotate,
91 /// Reject with the indicated typed fact. Reuse requires atomic product-owned revocation.
92 Reject(RefreshRejection),
93}
94
95/// Authentication rejection, distinct from persistence/commit failure and never containing tokens.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97pub enum RefreshRejection {
98 /// Missing, revoked, wrong-owner, or generation-invalid session.
99 Unavailable,
100 /// The credential deadline or authoritative server-side session expiry has passed.
101 Expired,
102 /// Product account is disabled or deleted.
103 AccountDisabled,
104 /// Product disallows refreshing this restricted login.
105 Restricted,
106 /// Identifiable immediate previous token matches the chosen retry policy; always rejected.
107 Stale,
108 /// Identifiable replay; account/session revocation has committed before returning this fact.
109 ReuseDetected,
110}
111
112/// Classifies authoritative session state without accessing product entities or performing effects.
113///
114/// Same-client stale is allowed only for the previous JTI of this exact account/session, with a
115/// nonnegative rotation age inside the explicit window. Older replays remain identifiable by `sid`;
116/// foreign or absent sessions never cause another account's revocation.
117#[must_use]
118pub fn classify_refresh(
119 request: &RefreshRotationRequest,
120 session: Option<&RefreshSession>,
121) -> RefreshDecision {
122 let reject = RefreshDecision::Reject;
123 // Database-lock waits can outlast JWT validity even after the coordinator recheck.
124 if i128::from(request.now) >= request.current_refresh_valid_until {
125 return reject(RefreshRejection::Expired);
126 }
127 let Some(session) = session else {
128 return reject(RefreshRejection::Unavailable);
129 };
130 if session.subject != request.subject
131 || session.session_id != request.session_id
132 || session.revoked
133 {
134 return reject(RefreshRejection::Unavailable);
135 }
136 if request.now >= session.refresh_expires_at {
137 return reject(RefreshRejection::Expired);
138 }
139 if session.current_refresh_jti == request.current_refresh_jti {
140 return RefreshDecision::Rotate;
141 }
142 if let RefreshPolicy::SameClientWithin { seconds } = request.policy {
143 let recent = request
144 .now
145 .checked_sub(session.last_seen_at)
146 .and_then(|age| u64::try_from(age).ok())
147 .is_some_and(|age| age <= seconds);
148 let same_client = session
149 .client
150 .user_agent
151 .as_deref()
152 .is_some_and(|ua| !ua.trim().is_empty())
153 && session.client.user_agent == request.client.user_agent
154 && (session.client.ip_address.is_none()
155 || request.client.ip_address.is_none()
156 || session.client.ip_address == request.client.ip_address);
157 if session.previous_refresh_jti.as_ref() == Some(&request.current_refresh_jti)
158 && recent
159 && same_client
160 {
161 return reject(RefreshRejection::Stale);
162 }
163 }
164 reject(RefreshRejection::ReuseDetected)
165}
166
167/// Transaction result. A store must not return success or reuse until all required writes commit.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum RefreshStoreOutcome {
170 /// Conditional rotation and its transaction committed.
171 Rotated,
172 /// CAS lost; leave this transaction and re-read the committed winner using `resolve_conflict`.
173 Conflict,
174 /// Authentication rejection. Security effects required for reuse have already committed.
175 Rejected(RefreshRejection),
176}
177
178/// Product persistence boundary. Forge never opens a database transaction or maps product errors.
179#[async_trait]
180pub trait RefreshSessionStore: Send + Sync {
181 /// Product error preserved through the coordinator, including database/audit/commit failures.
182 type Error: Send;
183
184 /// Checks current account/version/restriction state and applies a conditional JTI rotation or
185 /// rejection. Reuse classification, version bump, session purge and required security audit
186 /// must use the same transaction. Refresh `request.now` from the product's trusted clock after
187 /// acquiring database locks, so waiting cannot extend the verified credential deadline.
188 /// Authentication rejection is an `Ok(Rejected(..))`, so it
189 /// cannot accidentally roll back already-required revocation.
190 ///
191 /// # Errors
192 /// Returns the original product error if any required operation or transaction commit fails.
193 async fn rotate_refresh(
194 &self,
195 request: &RefreshRotationRequest,
196 ) -> std::result::Result<RefreshStoreOutcome, Self::Error>;
197
198 /// Resolves a lost CAS in a fresh writer transaction after the winner becomes visible.
199 /// Recheck account/version/session state, classify, and atomically revoke on reuse. This method
200 /// may only reject; it must never rotate or release the losing request's candidate credentials.
201 ///
202 /// # Errors
203 /// Returns the product's database, audit, or commit error without claiming security success.
204 async fn resolve_conflict(
205 &self,
206 request: &RefreshRotationRequest,
207 ) -> std::result::Result<RefreshRejection, Self::Error>;
208}
209
210/// Refresh failure separates mechanism failures from the product's unmodified persistence error.
211pub enum RefreshError<E> {
212 /// Signature, policy, resource-budget, or cancellation failure.
213 Auth(AuthError),
214 /// Product database/transaction/audit failure. Debug/Display do not expose its contents.
215 Store(E),
216}
217impl<E> fmt::Debug for RefreshError<E> {
218 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
219 match self {
220 Self::Auth(error) => f.debug_tuple("Auth").field(error).finish(),
221 Self::Store(_) => f.write_str("Store(<redacted>)"),
222 }
223 }
224}
225impl<E> fmt::Display for RefreshError<E> {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 match self {
228 Self::Auth(error) => error.fmt(f),
229 Self::Store(_) => f.write_str("refresh persistence operation failed"),
230 }
231 }
232}
233impl<E> std::error::Error for RefreshError<E> {}
234impl<E> From<AuthError> for RefreshError<E> {
235 fn from(error: AuthError) -> Self {
236 Self::Auth(error)
237 }
238}
239
240/// Post-commit result. Only the successful branch contains usable replacement credentials.
241#[derive(Debug, Clone, PartialEq, Eq)]
242pub enum RefreshOutcome {
243 /// Candidate identity and all required writes have committed.
244 Rotated(AuthTokenPair),
245 /// Authentication was rejected; the typed security fact is safe to map at the product boundary.
246 Rejected(RefreshRejection),
247}
248
249/// Refresh workflow combining strict JWT checks, a shared instance coordinator, and product CAS.
250/// The coordinator can be reused while products rebuild signers for dynamic lifetime configuration.
251pub struct RefreshTokenService<S: ?Sized> {
252 signer: JwtSigner,
253 verifier: JwtVerifier,
254 store: Arc<S>,
255 clock: Arc<dyn Clock>,
256 policy: RefreshPolicy,
257 coordinator: RefreshCoordinator,
258}
259impl<S: ?Sized> fmt::Debug for RefreshTokenService<S> {
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 f.debug_struct("RefreshTokenService")
262 .field("policy", &self.policy)
263 .finish_non_exhaustive()
264 }
265}
266impl<S: RefreshSessionStore + ?Sized> RefreshTokenService<S> {
267 /// Creates a workflow using the product-owned, cloneable coordinator resource.
268 #[must_use]
269 pub fn new(
270 signer: JwtSigner,
271 verifier: JwtVerifier,
272 store: Arc<S>,
273 clock: Arc<dyn Clock>,
274 policy: RefreshPolicy,
275 coordinator: RefreshCoordinator,
276 ) -> Self {
277 Self {
278 signer,
279 verifier,
280 store,
281 clock,
282 policy,
283 coordinator,
284 }
285 }
286 /// Rotates or rejects a refresh credential. Dropping the future releases coordination resources.
287 ///
288 /// # Errors
289 /// Returns classified mechanism failures or the original store error; a failed commit never
290 /// returns credentials. If the response is lost after commit, retrying cannot mint another pair.
291 pub async fn refresh(
292 &self,
293 token: &str,
294 client: ClientContext,
295 ) -> std::result::Result<RefreshOutcome, RefreshError<S::Error>> {
296 self.refresh_with_cancellation(token, client, &CancellationToken::new())
297 .await
298 }
299 /// Refreshes with cancellation while queued or before entering persistence. Once a store
300 /// transaction starts, it is allowed to finish, preserving explicit commit semantics rather
301 /// than returning cancellation with an unknown mutation outcome. Shutdown rejects new/queued
302 /// work; products drain already-started transactions before closing the writer database.
303 ///
304 /// # Errors
305 /// Returns cancellation/resource/auth failures or the original store/commit error.
306 pub async fn refresh_with_cancellation(
307 &self,
308 token: &str,
309 client: ClientContext,
310 cancel: &CancellationToken,
311 ) -> std::result::Result<RefreshOutcome, RefreshError<S::Error>> {
312 let claims = self
313 .verifier
314 .verify(token, AuthTokenType::Refresh)?
315 .into_claims();
316 let current = claims.jti.ok_or(AuthError::MissingClaim)?;
317 let _guard = self.coordinator.acquire(current.clone(), cancel).await?;
318 // Queued credentials may expire while awaiting the previous request's transaction.
319 self.verifier.verify(token, AuthTokenType::Refresh)?;
320 let now = self.clock.now_unix_seconds()?;
321 let candidate = self.signer.issue(
322 claims.sub.clone(),
323 claims.sid.clone(),
324 claims.session_version,
325 claims.restriction,
326 )?;
327 if candidate.refresh_jti == current {
328 return Err(AuthError::InvalidPolicy.into());
329 }
330 if cancel.is_cancelled() || self.coordinator.is_closed() {
331 return Err(AuthError::Cancelled.into());
332 }
333 let request = RefreshRotationRequest {
334 subject: claims.sub,
335 session_id: claims.sid,
336 session_version: claims.session_version,
337 restriction: claims.restriction,
338 current_refresh_jti: current,
339 current_refresh_valid_until: i128::from(claims.exp)
340 + i128::from(self.verifier.leeway_secs()),
341 next_refresh_jti: candidate.refresh_jti.clone(),
342 next_refresh_expires_at: candidate.refresh_expires_at,
343 client,
344 now,
345 policy: self.policy,
346 };
347 match self
348 .store
349 .rotate_refresh(&request)
350 .await
351 .map_err(RefreshError::Store)?
352 {
353 RefreshStoreOutcome::Rotated => Ok(RefreshOutcome::Rotated(candidate)),
354 RefreshStoreOutcome::Rejected(reason) => Ok(RefreshOutcome::Rejected(reason)),
355 RefreshStoreOutcome::Conflict => {
356 let reason = self
357 .store
358 .resolve_conflict(&request)
359 .await
360 .map_err(RefreshError::Store)?;
361 Ok(RefreshOutcome::Rejected(reason))
362 }
363 }
364 }
365}