Skip to main content

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}