Skip to main content

axess_core/session/
refresh.rs

1//! Refresh token support for mobile/SPA clients.
2//!
3//! Provides long-lived refresh tokens that can be exchanged for new sessions.
4//! Designed for clients that cannot use HTTP-only cookies (mobile apps, SPAs).
5//!
6//! # Security model
7//!
8//! - **Hash-only storage:** Only the SHA-256 hash of the token is persisted;
9//!   the plaintext is returned exactly once at issuance.
10//! - **Token rotation:** Each use of a refresh token invalidates the old one
11//!   and issues a new one, making stolen tokens single-use.
12//! - **Device binding:** Optional device fingerprint is stored with the token
13//!   and checked on refresh to detect cross-device replay.
14//! - **Per-user cap:** A configurable maximum number of active tokens per user
15//!   prevents unbounded accumulation; the oldest tokens are auto-revoked.
16
17use crate::authn::ids::{DeviceId, TenantId, UserId};
18use crate::session::data::{AuthState, SessionData};
19use axess_identity::define_id;
20use axess_rng::SecureRng;
21use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
22use chrono::{DateTime, Duration, Utc};
23use sha2::{Digest, Sha256};
24use subtle::ConstantTimeEq;
25
26// ── Types ────────────────────────────────────────────────────────────────────
27
28define_id! {
29    /// Opaque identifier for a single [`RefreshToken`] record. Uuid v4
30    /// newtype: 16 bytes, `Copy`, hyphenated string on the wire. Mint
31    /// via [`Self::new`]`(&mut rng)`; validate user-supplied strings via
32    /// [`Self::try_new`].
33    pub RefreshTokenId
34}
35
36define_id! {
37    /// Opaque identifier for a token rotation family. All tokens in a single
38    /// rotation chain share the same family id (the original token's id), so
39    /// reuse of any rotated-out member can revoke the entire chain. Same
40    /// Uuid-newtype shape as [`RefreshTokenId`].
41    pub TokenFamilyId
42}
43
44/// A refresh token record. Long-lived, stored server-side, used to
45/// obtain new access/session tokens.
46#[derive(Debug, Clone)]
47pub struct RefreshToken {
48    /// Unique token ID.
49    pub id: RefreshTokenId,
50    /// The user this token belongs to.
51    pub user_id: UserId,
52    /// The tenant context for the session.
53    pub tenant_id: TenantId,
54    /// SHA-256 hash of the actual token (never store plaintext).
55    pub token_hash: String,
56    /// When this token was created.
57    pub issued_at: DateTime<Utc>,
58    /// When this token expires.
59    pub expires_at: DateTime<Utc>,
60    /// Whether this token has been revoked.
61    pub revoked: bool,
62    /// Optional device fingerprint (User-Agent or custom identifier).
63    pub device_info: Option<String>,
64    /// Family ID for token rotation tracking.
65    ///
66    /// All tokens in a rotation chain share the same family ID (the original
67    /// token's ID). If a revoked token is reused, the entire family can be
68    /// revoked as a compromise signal.
69    pub family_id: Option<TokenFamilyId>,
70    /// Opaque [`DeviceId`] this token was issued to.
71    ///
72    /// Carried through rotation so every member of a rotation family
73    /// retains the link back to the originating
74    /// [`Device`](crate::device::Device). When family revocation
75    /// fires (rotated-out token reused, a compromise signal), the set of
76    /// `device_id` values across the family identifies which devices to
77    /// transition to [`DeviceTrustLevel::Revoked`] via
78    /// [`cascade_revoke_devices`].
79    ///
80    /// `None` for tokens issued before device support landed or for clients that
81    /// chose not to associate the token with a device.
82    ///
83    /// [`DeviceTrustLevel::Revoked`]: crate::device::DeviceTrustLevel::Revoked
84    /// [`cascade_revoke_devices`]: crate::device::cascade_revoke_devices
85    pub device_id: Option<DeviceId>,
86}
87
88/// Configuration for refresh token behavior.
89#[derive(Debug, Clone)]
90pub struct RefreshTokenConfig {
91    /// Token time-to-live. Default: 30 days.
92    pub ttl: Duration,
93    /// Maximum active tokens per user. Default: 10.
94    /// When exceeded, the oldest token is auto-revoked.
95    pub max_per_user: usize,
96    /// Whether to rotate tokens on each use. Default: true.
97    /// When enabled, each refresh invalidates the old token and issues a new one.
98    pub rotation: bool,
99    /// HMAC pepper used to derive the stored token hash. When
100    /// set, the on-disk hash is `HMAC-SHA256(pepper, plaintext)` rather
101    /// than the unsalted `SHA-256(plaintext)` fallback. The pepper is a
102    /// per-deployment secret stored in application config (NOT in the
103    /// database); a leaked refresh-token-hash table cannot be pre-image
104    /// scanned without it.
105    ///
106    /// **Migration:** changing the pepper (including switching from
107    /// `None` to `Some`) invalidates every existing refresh token. Plan
108    /// the rollout accordingly. Default `None` for backward compat.
109    pub hash_pepper: Option<Vec<u8>>,
110}
111
112impl Default for RefreshTokenConfig {
113    fn default() -> Self {
114        Self {
115            ttl: Duration::days(30),
116            max_per_user: 10,
117            rotation: true,
118            hash_pepper: None,
119        }
120    }
121}
122
123// ── Errors ───────────────────────────────────────────────────────────────────
124
125/// Errors from refresh token operations.
126#[derive(Debug, thiserror::Error)]
127pub enum RefreshError<E: std::error::Error + Send + Sync + 'static> {
128    /// The token was not found in the store.
129    #[error("refresh token not found")]
130    NotFound,
131
132    /// The token has expired.
133    #[error("refresh token expired")]
134    Expired,
135
136    /// The token has been revoked.
137    #[error("refresh token revoked")]
138    Revoked,
139
140    /// The device fingerprint does not match.
141    #[error("device mismatch")]
142    DeviceMismatch,
143
144    /// The caller-supplied account-status check returned `false`
145    /// for the user the token belongs to (suspended, terminated, etc.).
146    /// Returned only by [`refresh_session_with_status_check`]; the bare
147    /// [`refresh_session`] never produces it because it has no
148    /// [`IdentityStore`](crate::authn::store::IdentityStore) reference.
149    #[error("account is not active")]
150    AccountInactive,
151
152    /// An error from the underlying store.
153    #[error("store error: {0}")]
154    Store(#[source] E),
155}
156
157// ── Store trait ──────────────────────────────────────────────────────────────
158
159/// Async storage backend for refresh tokens.
160///
161/// All methods accept `&self`; implementations use interior mutability.
162pub trait RefreshTokenStore: Send + Sync {
163    /// The error type returned by storage operations.
164    type Error: std::error::Error + Send + Sync + 'static;
165
166    /// Persist a new refresh token record.
167    fn store_token(
168        &self,
169        token: &RefreshToken,
170    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
171
172    /// Look up a token by its hash. Returns `None` if not found.
173    fn find_token(
174        &self,
175        token_hash: &str,
176    ) -> impl std::future::Future<Output = Result<Option<RefreshToken>, Self::Error>> + Send;
177
178    /// Mark a token as revoked by its ID.
179    fn revoke_token(
180        &self,
181        token_id: &RefreshTokenId,
182    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
183
184    /// Revoke all tokens for a user (e.g. global logout, credential rotation).
185    fn revoke_user_tokens(
186        &self,
187        user_id: &UserId,
188    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
189
190    /// Return all non-revoked tokens for a user, ordered by `issued_at` ascending.
191    fn active_tokens(
192        &self,
193        user_id: &UserId,
194    ) -> impl std::future::Future<Output = Result<Vec<RefreshToken>, Self::Error>> + Send;
195
196    /// Revoke all tokens in a family (same `family_id`).
197    ///
198    /// Called when a rotated-out (already revoked) token is reused. This is
199    /// a compromise signal indicating the original token was stolen. All
200    /// tokens descending from the original issuance must be revoked
201    /// immediately so that neither the attacker nor the legitimate user can
202    /// continue refreshing.
203    ///
204    /// # Contract
205    ///
206    /// Implementations MUST perform the revocation as a **single atomic
207    /// statement** scoped to `family_id`, e.g.:
208    ///
209    /// ```sql
210    /// UPDATE refresh_tokens
211    ///    SET revoked_at = NOW()
212    ///  WHERE user_id = $1
213    ///    AND family_id = $2
214    ///    AND revoked_at IS NULL
215    /// ```
216    ///
217    /// Two failure modes the contract is meant to prevent:
218    ///
219    /// 1. **Non-atomic load+update**: a backend that lists active tokens
220    ///    and then revokes them one-by-one races with a parallel
221    ///    `refresh_session` call from the attacker; the attacker may
222    ///    succeed in rotating an unrevoked sibling between the list and
223    ///    the per-row revoke, escaping the family invalidation.
224    /// 2. **Family scope ignored**: delegating to `revoke_user_tokens`
225    ///    would over-revoke (safe, but boots the legitimate user out of
226    ///    every other concurrent device). With per-family scoping, only
227    ///    the compromised device chain is killed.
228    fn revoke_family(
229        &self,
230        user_id: &UserId,
231        family_id: &TokenFamilyId,
232    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
233
234    /// Atomically issue a new refresh token while evicting the
235    /// per-user cap-overflow set in a **single transaction**.
236    ///
237    /// The naive sequence (`revoke_token(evict[i])` ... then
238    /// `store_token(new)`) is not atomic. If a `store_token` failure
239    /// arrives after the evictions, the user has just lost N legitimate
240    /// active tokens for nothing; if it arrives before, an in-flight
241    /// concurrent issue could push the active set above the cap.
242    ///
243    /// # Requirement
244    ///
245    /// This method is **required**: implementations MUST wrap the
246    /// per-id revocations and the new-token insert in a single transaction
247    /// (or equivalent atomic primitive) so partial-failure semantics
248    /// match; either all evictions land and the new token is stored, or
249    /// nothing changes.
250    fn issue_with_eviction(
251        &self,
252        evict_ids: &[RefreshTokenId],
253        new_token: &RefreshToken,
254    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
255
256    /// Atomically rotate a refresh token: revoke `parent_id` and insert
257    /// `new_token` in a **single transaction**.
258    ///
259    /// # Why this exists
260    ///
261    /// The naive sequence (`revoke_token(parent)` followed by
262    /// `store_token(new)`) is not atomic. If the second call fails (network
263    /// blip, DB write error, process crash between the two), the parent is
264    /// already revoked and no replacement was issued. The user is silently
265    /// logged out across every device using that token family, with no
266    /// recovery path short of re-authentication.
267    ///
268    /// # Contract
269    ///
270    /// Implementations MUST wrap the parent-revoke and the new-token
271    /// insert in a single transaction (or equivalent atomic primitive),
272    /// e.g.:
273    ///
274    /// 1. A single SQL transaction wrapping `UPDATE … SET revoked_at` +
275    ///    `INSERT INTO refresh_tokens`.
276    /// 2. A Lua/Redis MULTI/EXEC block on Valkey-class stores.
277    /// 3. Any equivalent atomic primitive provided by the backend.
278    ///
279    /// A naive serial revoke + store can leave a user silently logged out
280    /// across every device in the family if the second call fails
281    /// mid-flight.
282    fn rotate_token(
283        &self,
284        parent_id: &RefreshTokenId,
285        new_token: &RefreshToken,
286    ) -> impl std::future::Future<Output = Result<(), Self::Error>> + Send;
287
288    /// Called when a rotated-out token is reused (a token compromise signal).
289    ///
290    /// Override this to alert operators (e.g. send to a SIEM, page on-call,
291    /// or log at a higher severity). The default implementation logs a warning.
292    ///
293    /// This is called *after* `revoke_family` has already revoked the
294    /// compromised token family.
295    ///
296    /// # Device cascade
297    ///
298    /// `compromised_devices` carries every `(TenantId, DeviceId)` pair
299    /// linked to refresh tokens in the compromised family: the
300    /// already-revoked token under reuse plus any sibling members that
301    /// were still active when reuse was detected. Production overrides
302    /// SHOULD pass these to
303    /// [`cascade_revoke_devices`](crate::device::cascade_revoke_devices)
304    /// so every device that participated in the family transitions to
305    /// [`DeviceTrustLevel::Revoked`](crate::device::DeviceTrustLevel::Revoked).
306    /// The default implementation logs the device list alongside the
307    /// family id; it does NOT perform the cascade itself because the
308    /// trait has no access to a [`DeviceStore`](crate::device::DeviceStore).
309    /// The cascade primitive lives outside the refresh path so a single
310    /// application can wire its own DeviceStore once and reuse it from
311    /// any number of compromise hooks.
312    fn on_token_compromise(
313        &self,
314        user_id: &UserId,
315        family_id: &TokenFamilyId,
316        compromised_devices: &[(TenantId, DeviceId)],
317    ) -> impl std::future::Future<Output = ()> + Send {
318        tracing::warn!(
319            %user_id,
320            %family_id,
321            device_count = compromised_devices.len(),
322            "refresh token compromise detected (rotated-out token was reused); \
323             override on_token_compromise + call cascade_revoke_devices to revoke linked devices"
324        );
325        async {}
326    }
327}
328
329// ── Core functions ───────────────────────────────────────────────────────────
330
331/// Compute the storage-side hash of a plaintext refresh token, returned
332/// as URL-safe base64.
333///
334/// When `pepper` is `Some`, computes `HMAC-SHA256(pepper, plaintext)`,
335/// so a pre-image scan of the resulting hash table cannot be carried
336/// out without knowing the pepper (which lives in application config
337/// rather than the DB). When `None`, falls back to the previous
338/// unsalted `SHA-256(plaintext)` for backward compat.
339fn hash_token(plaintext: &str, pepper: Option<&[u8]>) -> String {
340    match pepper {
341        Some(pep) if !pep.is_empty() => {
342            use hmac::Mac;
343            let mut mac = crate::hmac::new_signer(pep);
344            mac.update(plaintext.as_bytes());
345            URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())
346        }
347        _ => {
348            let digest = Sha256::digest(plaintext.as_bytes());
349            URL_SAFE_NO_PAD.encode(digest)
350        }
351    }
352}
353
354/// Generate a cryptographically random plaintext token (32 bytes, URL-safe base64).
355fn generate_token_value(rng: &impl SecureRng) -> String {
356    let mut bytes = [0u8; 32];
357    rng.fill_bytes(&mut bytes);
358    URL_SAFE_NO_PAD.encode(bytes)
359}
360
361/// Per-issuance parameters for [`issue_refresh_token`].
362pub struct IssueRequest<'a> {
363    /// The user receiving the token.
364    pub user_id: &'a UserId,
365    /// The tenant context.
366    pub tenant_id: &'a TenantId,
367    /// Optional device/client identifier for token binding.
368    pub device_info: Option<String>,
369    /// Token family for rotation tracking. `None` starts a new family;
370    /// `Some(parent_family_id)` inherits from the rotated-out token.
371    pub family_id: Option<TokenFamilyId>,
372    /// Opaque [`DeviceId`] to associate with this token.
373    ///
374    /// Threaded into the persisted record so that family revocation can
375    /// drive a cascade revoke of the linked devices via
376    /// [`cascade_revoke_devices`](crate::device::cascade_revoke_devices).
377    /// Inherited unchanged across rotation.
378    pub device_id: Option<DeviceId>,
379}
380
381/// Issue a new refresh token for the given user/tenant.
382///
383/// Returns `(plaintext_token, stored_record)`. The plaintext is
384/// returned exactly once; only the hash is persisted. Callers must
385/// transmit the plaintext to the client over a secure channel.
386///
387/// If the user already has `config.max_per_user` active tokens, the oldest ones
388/// are revoked to make room.
389pub async fn issue_refresh_token<S: RefreshTokenStore>(
390    req: IssueRequest<'_>,
391    config: &RefreshTokenConfig,
392    store: &S,
393    rng: &impl SecureRng,
394    now: DateTime<Utc>,
395) -> Result<(String, RefreshToken), RefreshError<S::Error>> {
396    // Enforce per-user cap by revoking oldest tokens.
397    let active = store
398        .active_tokens(req.user_id)
399        .await
400        .map_err(RefreshError::Store)?;
401
402    let evict_ids: Vec<RefreshTokenId> = if active.len() >= config.max_per_user {
403        let to_revoke = active.len() - config.max_per_user + 1;
404        active.iter().take(to_revoke).map(|t| t.id).collect()
405    } else {
406        Vec::new()
407    };
408
409    let (plaintext, record) = build_refresh_token(req, config, rng, now);
410
411    // Cap-eviction + insert in one trait call so production
412    // backends can wrap them in a single transaction. The naive
413    // `for revoke; … store` had a window where a mid-sequence failure
414    // could leave the user under-revoked or with no replacement token.
415    store
416        .issue_with_eviction(&evict_ids, &record)
417        .await
418        .map_err(RefreshError::Store)?;
419
420    Ok((plaintext, record))
421}
422
423/// Validate a refresh token and produce a new session.
424///
425/// On success returns `(SessionData, Option<(new_plaintext, new_record)>)`.
426/// The optional second element is present when token rotation is
427/// enabled: the old token is revoked and a fresh one issued.
428///
429/// # Security: account status and registry registration
430///
431/// This function performs **token CRUD only**. It does NOT consult any
432/// [`IdentityStore`](crate::authn::store::IdentityStore) or
433/// [`SessionRegistry`](crate::session::store::SessionRegistry). Two
434/// caller responsibilities the library cannot enforce here:
435///
436/// 1. **Account status**: a refresh token from a user who has been
437///    suspended/terminated since issuance is still valid for rotation.
438///    Use [`refresh_session_with_status_check`] to thread an
439///    `account_status` predicate through the validation; on `false`
440///    the helper returns [`RefreshError::AccountInactive`] WITHOUT
441///    rotating, so the legitimate user's family stays intact for
442///    later un-suspension.
443///
444/// 2. **Registry registration**: the returned `SessionData` is
445///    `Authenticated` but is NOT registered with any
446///    [`crate::session::SessionRegistry`]. A refresh-rotated session that the
447///    application doesn't `register` is the same anti-pattern in
448///    miniature: `invalidate_user` cannot reach it. Callers who use
449///    a `SessionRegistry` MUST register the new session id before
450///    returning the response.
451pub async fn refresh_session<S: RefreshTokenStore>(
452    plaintext: &str,
453    store: &S,
454    config: &RefreshTokenConfig,
455    rng: &impl SecureRng,
456    now: DateTime<Utc>,
457    device_info: Option<&str>,
458) -> Result<(SessionData, Option<(String, RefreshToken)>), RefreshError<S::Error>> {
459    let token_hash = hash_token(plaintext, config.hash_pepper.as_deref());
460
461    let record = store
462        .find_token(&token_hash)
463        .await
464        .map_err(RefreshError::Store)?
465        .ok_or(RefreshError::NotFound)?;
466
467    if record.revoked {
468        // Reuse of a revoked token is a compromise signal; revoke the
469        // entire token family so the attacker's stolen token (and any
470        // rotated descendant) becomes useless.
471        if let Some(ref fid) = record.family_id {
472            tracing::warn!(
473                family_id = %fid,
474                user_id = %record.user_id,
475                "revoked refresh token reused; revoking entire family (compromise signal)"
476            );
477
478            // Collect the (tenant, device) pairs linked to every
479            // member of the family BEFORE revoke_family runs. Backends
480            // that implement the contract atomically (single SQL UPDATE)
481            // would otherwise leave the device set inferable only from
482            // their own audit trail; gathering here keeps the cascade
483            // primitive backend-agnostic.
484            let compromised_devices = collect_family_device_targets(store, fid, &record).await;
485
486            store
487                .revoke_family(&record.user_id, fid)
488                .await
489                .map_err(RefreshError::Store)?;
490
491            store
492                .on_token_compromise(&record.user_id, fid, &compromised_devices)
493                .await;
494        }
495        return Err(RefreshError::Revoked);
496    }
497
498    if now >= record.expires_at {
499        return Err(RefreshError::Expired);
500    }
501
502    // Device binding check: if the stored record has device info, the caller
503    // must present a matching fingerprint.
504    //
505    // Both sides are first hashed with SHA-256 before comparison. Plain
506    // `ct_eq(a.as_bytes(), b.as_bytes())` returns `Choice(0)` immediately
507    // when the byte lengths differ; that early return is itself a timing
508    // signal that leaks the stored fingerprint's length, and (over many
509    // requests) lets an attacker brute-force a plausible User-Agent prefix.
510    // Hashing first puts both inputs at a fixed 32 bytes so the comparison
511    // is uniformly constant-time regardless of the original input lengths.
512    if let Some(ref stored_device) = record.device_info {
513        let Some(current) = device_info else {
514            return Err(RefreshError::DeviceMismatch);
515        };
516        let stored_hash = Sha256::digest(stored_device.as_bytes());
517        let current_hash = Sha256::digest(current.as_bytes());
518        if !bool::from(stored_hash.ct_eq(&current_hash)) {
519            return Err(RefreshError::DeviceMismatch);
520        }
521    }
522
523    // Build session data from the token's user/tenant. user_id/tenant_id
524    // are now typed at the trait surface, so the previous `try_new` parsing
525    // (and `RefreshError::InvalidToken`) are no longer needed; the
526    // RefreshTokenStore trait promises validated values come back.
527    let session = SessionData {
528        version: crate::session::data::SESSION_DATA_VERSION,
529        auth_state: AuthState::Authenticated {
530            user_id: record.user_id,
531            tenant_id: record.tenant_id,
532            authn_time: now,
533            // Refresh-token rotation re-establishes the same authn level
534            // implicitly; no per-factor record carried across rotation.
535            factors_completed: Vec::new(),
536        },
537        fingerprint: None,
538        // Rotated session inherits the device link from the token
539        // record. Without this, refresh-driven session resumption would
540        // appear as a "device-less" session and the layer would re-resolve
541        // it from request headers; typically yielding the same id, but
542        // an unnecessary round-trip and a lost association on the gap.
543        device_id: record.device_id,
544        custom: serde_json::Value::default(),
545    };
546
547    // Token rotation: atomically revoke old + insert new in a single
548    // backend transaction so a mid-rotation crash never leaves the user
549    // both un-rotated and un-authenticated.
550    let new_token = if config.rotation {
551        let (new_plaintext, new_record) = build_refresh_token(
552            IssueRequest {
553                user_id: &record.user_id,
554                tenant_id: &record.tenant_id,
555                device_info: record.device_info.clone(),
556                family_id: record.family_id, // Inherit family from parent.
557                device_id: record.device_id, // Inherit device link.
558            },
559            config,
560            rng,
561            now,
562        );
563
564        store
565            .rotate_token(&record.id, &new_record)
566            .await
567            .map_err(RefreshError::Store)?;
568
569        Some((new_plaintext, new_record))
570    } else {
571        None
572    };
573
574    Ok((session, new_token))
575}
576
577/// Refresh a session, threading a caller-supplied
578/// `account_status` predicate through the validation.
579///
580/// Same contract as [`refresh_session`] except the refresh is gated by
581/// `status_check(&user_id)`: if the predicate returns `false`, the
582/// helper returns [`RefreshError::AccountInactive`] WITHOUT calling
583/// [`RefreshTokenStore::rotate_token`]; the legitimate user's token
584/// family is preserved unchanged so a later un-suspension restores
585/// access without forcing a full re-login.
586///
587/// The status check runs AFTER the token is found-and-not-expired-not-
588/// revoked-not-device-mismatched but BEFORE the rotation: same
589/// ordering rationale as the cascade in
590/// `complete_factor_step`: a mid-flight suspension lands its
591/// `invalidate_user` against an empty registry slot (the new session
592/// id has not been registered yet) and the post-check refusal closes
593/// the race that would otherwise produce an authenticated-but-
594/// unregistered session.
595///
596/// # When to use this vs [`refresh_session`]
597///
598/// Pick this whenever the application carries an
599/// [`IdentityStore`](crate::authn::store::IdentityStore) (or any
600/// equivalent source of truth for "is this account allowed to
601/// authenticate right now"). The bare [`refresh_session`] is for
602/// applications that have already gated the refresh route by their
603/// own status check upstream.
604///
605/// # TOCTOU window
606///
607/// Between `status_check` and `rotate_token` a concurrent suspend
608/// could land. The window is closed by the
609/// [`SessionRegistry`](crate::session::store::SessionRegistry)
610/// invalidation cascade: the suspend's `invalidate_user` evicts
611/// every registered session, and the next request through
612/// [`AuthnService::check_session`](crate::authn::service::AuthnService::check_session)
613/// observes the rotated session as invalid. Same trade-off as
614/// `complete_factor_step`'s post-register re-check covers for
615/// the factor flow.
616///
617/// # Example
618///
619/// ```ignore
620/// use axess_core::session::refresh::{refresh_session_with_status_check, RefreshError};
621/// use axess_core::authn::store::IdentityStore;
622///
623/// let result = refresh_session_with_status_check(
624///     &plaintext, &refresh_store, &config, &mut rng, now, device_info,
625///     |user_id| async move {
626///         // `identity` here is the application's IdentityStore.
627///         identity
628///             .account_status(user_id)
629///             .await
630///             .map(|status| status.allows_login())
631///             .unwrap_or(false) // fail closed on store errors
632///     },
633/// ).await;
634/// ```
635pub async fn refresh_session_with_status_check<S, FStat, Fut>(
636    plaintext: &str,
637    store: &S,
638    config: &RefreshTokenConfig,
639    rng: &impl SecureRng,
640    now: DateTime<Utc>,
641    device_info: Option<&str>,
642    status_check: FStat,
643) -> Result<(SessionData, Option<(String, RefreshToken)>), RefreshError<S::Error>>
644where
645    S: RefreshTokenStore,
646    FStat: FnOnce(&UserId) -> Fut,
647    Fut: std::future::Future<Output = bool>,
648{
649    let token_hash = hash_token(plaintext, config.hash_pepper.as_deref());
650
651    // Resolve the user_id from the token without touching rotation
652    // state. Reusing `find_token` rather than threading the result into
653    // `refresh_session` keeps the rotation atomicity owned by
654    // `refresh_session`'s own `rotate_token` call.
655    let record = store
656        .find_token(&token_hash)
657        .await
658        .map_err(RefreshError::Store)?
659        .ok_or(RefreshError::NotFound)?;
660
661    // Mirror the early-return ordering of `refresh_session` so a
662    // suspended-user check on a revoked token still surfaces the
663    // compromise signal (Revoked) rather than masking it as
664    // AccountInactive. Pre-rotation refusals only.
665    if record.revoked {
666        // Defer to refresh_session so its family-revocation cascade
667        // (and the device cascade) runs unchanged. The status
668        // check is intentionally skipped on this branch; the priority
669        // is the compromise response, not the account state.
670        return refresh_session(plaintext, store, config, rng, now, device_info).await;
671    }
672    if now >= record.expires_at {
673        return Err(RefreshError::Expired);
674    }
675
676    if !status_check(&record.user_id).await {
677        tracing::warn!(
678            user_id = %record.user_id,
679            "refresh refused; caller-supplied status check returned false"
680        );
681        return Err(RefreshError::AccountInactive);
682    }
683
684    // Status OK: continue with the standard validate-and-rotate path.
685    // `find_token` will run again here, but it's a hash lookup;
686    // amortized cost is negligible compared to the network round-trip
687    // the rotation itself implies, and the duplicated read keeps the
688    // rotation atomicity owned by `refresh_session` rather than
689    // re-implementing it inline here.
690    refresh_session(plaintext, store, config, rng, now, device_info).await
691}
692
693/// Gather the unique `(tenant, device)` pairs participating in a
694/// compromised refresh-token family.
695///
696/// Combines the just-loaded already-revoked record (the token under reuse)
697/// with currently-active siblings returned by
698/// [`RefreshTokenStore::active_tokens`]. The active list excludes the
699/// reused token itself by definition; `record` carries that one's
700/// device link explicitly.
701///
702/// Errors from the active-tokens query are swallowed to a `tracing::warn!`:
703/// the cascade is best-effort, and the family revocation that called
704/// this helper is the request-failing operation. We never let device-
705/// gathering bubble up and mask the actual compromise signal.
706async fn collect_family_device_targets<S: RefreshTokenStore>(
707    store: &S,
708    family_id: &TokenFamilyId,
709    seen_record: &RefreshToken,
710) -> Vec<(TenantId, DeviceId)> {
711    let mut targets: Vec<(TenantId, DeviceId)> = Vec::new();
712    if let Some(did) = seen_record.device_id.as_ref() {
713        targets.push((seen_record.tenant_id, *did));
714    }
715    match store.active_tokens(&seen_record.user_id).await {
716        Ok(active) => {
717            for token in active {
718                if token.family_id.as_ref() != Some(family_id) {
719                    continue;
720                }
721                let Some(did) = token.device_id else { continue };
722                let pair = (token.tenant_id, did);
723                if !targets.contains(&pair) {
724                    targets.push(pair);
725                }
726            }
727        }
728        Err(e) => {
729            tracing::warn!(
730                error = %e,
731                family_id = %family_id,
732                user_id = %seen_record.user_id,
733                "failed to enumerate active siblings for device cascade; \
734                 family revocation will proceed with the seen-record device only"
735            );
736        }
737    }
738    targets
739}
740
741/// Pure (no-I/O) builder for a refresh token record.
742///
743/// Splits the random/clock-dependent record construction out of
744/// [`issue_refresh_token`] so callers that need to swap one token for
745/// another atomically (see [`RefreshTokenStore::rotate_token`]) can build
746/// the new record up-front and persist it inside the rotation transaction.
747fn build_refresh_token(
748    req: IssueRequest<'_>,
749    config: &RefreshTokenConfig,
750    rng: &impl SecureRng,
751    now: DateTime<Utc>,
752) -> (String, RefreshToken) {
753    let plaintext = generate_token_value(rng);
754    let token_hash = hash_token(&plaintext, config.hash_pepper.as_deref());
755
756    let mut id_bytes = [0u8; 16];
757    rng.fill_bytes(&mut id_bytes);
758    let id = RefreshTokenId::try_new(uuid::Uuid::from_bytes(id_bytes).to_string())
759        .expect("UUID is non-empty");
760
761    let effective_family = req.family_id.unwrap_or_else(|| {
762        TokenFamilyId::try_new(id.to_string()).expect("UUID-derived family id is non-empty")
763    });
764
765    let record = RefreshToken {
766        id,
767        user_id: *req.user_id,
768        tenant_id: *req.tenant_id,
769        token_hash,
770        issued_at: now,
771        expires_at: now + config.ttl,
772        revoked: false,
773        device_info: req.device_info,
774        family_id: Some(effective_family),
775        device_id: req.device_id,
776    };
777
778    (plaintext, record)
779}
780
781/// Revoke a single refresh token by its plaintext value.
782///
783/// Takes the [`RefreshTokenConfig`] so the lookup hash is
784/// computed with the same pepper that issuance used.
785pub async fn revoke_refresh_token<S: RefreshTokenStore>(
786    plaintext: &str,
787    store: &S,
788    config: &RefreshTokenConfig,
789) -> Result<(), RefreshError<S::Error>> {
790    let token_hash = hash_token(plaintext, config.hash_pepper.as_deref());
791
792    let record = store
793        .find_token(&token_hash)
794        .await
795        .map_err(RefreshError::Store)?
796        .ok_or(RefreshError::NotFound)?;
797
798    store
799        .revoke_token(&record.id)
800        .await
801        .map_err(RefreshError::Store)?;
802
803    Ok(())
804}
805
806#[cfg(test)]
807mod atomic_rotation;
808#[cfg(test)]
809mod basics;
810#[cfg(all(test, feature = "device"))]
811mod device_cascade;
812#[cfg(test)]
813mod status_check;
814#[cfg(test)]
815mod test_support;
816
817#[cfg(test)]
818mod refresh_unit_tests {
819    use super::*;
820    use crate::testing::mock_random::MockRng;
821
822    // ── hash_token ──────────────────────────────────────────────────────
823
824    /// Pin: hash_token without pepper produces a SHA-256 hash.
825    #[test]
826    fn hash_token_no_pepper_is_sha256() {
827        let h1 = hash_token("test-token-value", None);
828        let h2 = hash_token("test-token-value", None);
829        assert_eq!(h1, h2, "hash must be deterministic");
830        assert!(!h1.is_empty());
831    }
832
833    /// Pin: hash_token with pepper produces a different hash than without.
834    #[test]
835    fn hash_token_with_pepper_differs_from_without() {
836        let without = hash_token("test-token", None);
837        let with = hash_token("test-token", Some(b"my-secret-pepper"));
838        assert_ne!(without, with, "peppered hash must differ from unpeppered");
839    }
840
841    /// Pin: hash_token with empty pepper falls back to SHA-256 (same as None).
842    #[test]
843    fn hash_token_empty_pepper_falls_back_to_sha256() {
844        let none_pepper = hash_token("token", None);
845        let empty_pepper = hash_token("token", Some(b""));
846        assert_eq!(
847            none_pepper, empty_pepper,
848            "empty pepper must fall back to SHA-256 path"
849        );
850    }
851
852    /// Pin: different inputs produce different hashes (collision resistance).
853    #[test]
854    fn hash_token_different_inputs_different_hashes() {
855        let h1 = hash_token("token-a", None);
856        let h2 = hash_token("token-b", None);
857        assert_ne!(h1, h2);
858    }
859
860    /// Pin: different peppers produce different hashes.
861    #[test]
862    fn hash_token_different_peppers_different_hashes() {
863        let h1 = hash_token("same-token", Some(b"pepper-1"));
864        let h2 = hash_token("same-token", Some(b"pepper-2"));
865        assert_ne!(h1, h2);
866    }
867
868    // ── generate_token_value ─────────────────────────────────────────────
869
870    /// Pin: generate_token_value produces a non-empty base64 string.
871    #[test]
872    fn generate_token_value_produces_nonempty_base64() {
873        let rng = MockRng::new(42);
874        let token = generate_token_value(&rng);
875        assert!(!token.is_empty());
876        // Should be URL-safe base64 of 32 bytes = 43 chars.
877        assert_eq!(token.len(), 43, "32 bytes → 43 base64url chars");
878    }
879
880    /// Pin: generate_token_value is deterministic with MockRng.
881    #[test]
882    fn generate_token_value_is_deterministic() {
883        let t1 = generate_token_value(&MockRng::new(99));
884        let t2 = generate_token_value(&MockRng::new(99));
885        assert_eq!(t1, t2);
886    }
887
888    /// Pin: different seeds produce different tokens.
889    #[test]
890    fn generate_token_value_different_seeds_differ() {
891        let t1 = generate_token_value(&MockRng::new(1));
892        let t2 = generate_token_value(&MockRng::new(2));
893        assert_ne!(t1, t2);
894    }
895
896    // ── RefreshTokenConfig::default ──────────────────────────────────────
897
898    /// Pin: default config has production-safe values.
899    #[test]
900    fn default_config_has_safe_defaults() {
901        let cfg = RefreshTokenConfig::default();
902        assert_eq!(cfg.ttl, Duration::days(30));
903        assert_eq!(cfg.max_per_user, 10);
904        assert!(cfg.rotation, "rotation must be on by default");
905        assert!(cfg.hash_pepper.is_none(), "no pepper by default");
906    }
907
908    // ── RefreshError Display ─────────────────────────────────────────────
909
910    /// Pin: each RefreshError variant produces a distinct non-empty message.
911    #[test]
912    fn refresh_error_display_per_variant() {
913        use std::io;
914        let variants: Vec<(RefreshError<io::Error>, &str)> = vec![
915            (RefreshError::NotFound, "not found"),
916            (RefreshError::Expired, "expired"),
917            (RefreshError::Revoked, "revoked"),
918            (RefreshError::DeviceMismatch, "device mismatch"),
919            (RefreshError::AccountInactive, "not active"),
920            (RefreshError::Store(io::Error::other("boom")), "store error"),
921        ];
922        for (err, expected_substr) in variants {
923            let msg = err.to_string();
924            assert!(
925                msg.contains(expected_substr),
926                "RefreshError display for {:?} must contain {expected_substr:?}, got {msg:?}",
927                std::mem::discriminant(&err)
928            );
929        }
930    }
931}