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(¤t_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}