Skip to main content

axess_core/session/
data.rs

1//! Session payload: the typed data stored in the session store per session.
2
3use crate::authn::{
4    factor::FactorKind,
5    ids::{DeviceId, TenantId, UserId},
6};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::sync::Arc;
10
11/// Current session data schema version.
12///
13/// Increment this when the `SessionData` structure changes in a way that
14/// requires migration. The session layer checks this on load and calls
15/// [`SessionData::migrate`] if the stored version is older.
16///
17/// # History
18///
19/// - `v1`: initial schema (`auth_state`, `fingerprint`, `custom`).
20/// - `v2`: added `device_id: Option<DeviceId>`. Existing v1
21///   sessions deserialise with `device_id = None` via `serde(default)`;
22///   the migration step bumps the version field so a re-save reflects
23///   the new schema.
24pub const SESSION_DATA_VERSION: u8 = 2;
25
26/// The complete session payload stored in the session store.
27///
28/// All authentication state is captured here in a flat, serializable form.
29/// Session data is serialized as JSON once per request, not per field access.
30///
31/// # Schema versioning
32///
33/// The `version` field is persisted with each session. When the library evolves
34/// and `SessionData` gains or removes fields, bump [`SESSION_DATA_VERSION`] and
35/// add a migration step in [`SessionData::migrate`]. The session layer calls
36/// `migrate` automatically on load, so existing sessions are upgraded in-place
37/// without requiring a coordinated session wipe.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SessionData {
40    /// Schema version of this session data. Used for forward-compatible
41    /// deserialization: if a newer library version adds fields, older sessions
42    /// are migrated transparently on load.
43    #[serde(default = "default_version")]
44    pub version: u8,
45    /// Authentication state of the session principal.
46    pub auth_state: AuthState,
47    /// Fingerprint identifying the expected client for this session.
48    ///
49    /// Set automatically by [`SessionBinding`](super::binding::SessionBinding)
50    /// when the session first becomes authenticated. Checked on every subsequent
51    /// request: a mismatch invalidates the session (possible hijacking).
52    pub fingerprint: Option<String>,
53    /// Opaque [`DeviceId`] resolved for the request that owns this session.
54    ///
55    /// `None` when the device subsystem is disabled (the `device` feature is
56    /// off) or when the request was handled before the device resolver had
57    /// a chance to stamp it (the very first request on a brand-new browser,
58    /// before any [`Device`](crate::device::Device) row exists).
59    ///
60    /// Independent of [`fingerprint`](Self::fingerprint): fingerprint is a
61    /// short-lived hijack guard recomputed on every request, whereas
62    /// `device_id` references a long-lived row in the device store that may
63    /// outlive any single session.
64    ///
65    /// Carried ungated so the on-the-wire shape of `SessionData` does not
66    /// diverge across feature configurations. See
67    /// [`docs/identity/device.md`](../../../docs/identity/device.md).
68    #[serde(default)]
69    pub device_id: Option<DeviceId>,
70    /// Escape hatch for application-specific data stored alongside the session.
71    pub custom: serde_json::Value,
72}
73
74fn default_version() -> u8 {
75    1
76}
77
78impl Default for SessionData {
79    fn default() -> Self {
80        Self {
81            version: SESSION_DATA_VERSION,
82            auth_state: AuthState::default(),
83            fingerprint: None,
84            device_id: None,
85            custom: serde_json::Value::default(),
86        }
87    }
88}
89
90impl SessionData {
91    /// Migrate session data from an older schema version to the current version.
92    ///
93    /// Called automatically by the session layer on load. Each version bump
94    /// should add a migration step here. Returns `true` if a migration was
95    /// applied (session should be re-saved).
96    pub fn migrate(&mut self) -> bool {
97        if self.version >= SESSION_DATA_VERSION {
98            return false;
99        }
100
101        // v1 → v2: added `device_id: Option<DeviceId>`. The
102        // serde `default` attribute on the field already supplied `None`
103        // during deserialisation, so the in-memory representation is
104        // already correct; the only work left is bumping the persisted
105        // version field so a re-save reflects the current schema.
106        //
107        // Each per-version step is responsible for bumping `self.version`
108        // to the next number. No unconditional trailing assignment to
109        // `SESSION_DATA_VERSION`; that hid the per-step bump from
110        // observation (mutation tests couldn't distinguish "this branch
111        // ran" from "the trailing line ran"). Future steps follow the
112        // same shape: `if self.version == 2 { ...; self.version = 3; }`.
113        if self.version == 1 {
114            self.version = 2;
115        }
116        true
117    }
118}
119
120/// Authentication state machine: flat enum, typed [`UserId`] / [`TenantId`] IDs.
121///
122/// The state machine follows a strict forward progression with
123/// `PendingWorkflow` as a post-authentication holding state.
124///
125/// # State transition diagram
126///
127/// ```text
128///                     ┌───────────────────────────┐
129///                     │ (app-level, optional)      │
130///  Guest ─┬──────────►│ Identifying ──► Authenticating ──► Authenticated
131///         │           └───────────────────────────┘          ▲
132///         │                                                  │
133///         └──► Authenticating ──► Authenticated              │
134///         │         (begin_login default path)               │
135///         │                                                  │
136///         └──► PendingWorkflow(Signup) ──────────────────────┘
137///    ▲              (begin_signup)           (complete_signup)
138///    │                                                       │
139///    │              (logout / session clear)                  │
140///    └───────────────────────────────────────────────────────-┘
141/// ```
142///
143/// - **Guest**: anonymous visitor, no authentication started.
144/// - **Identifying**: optional state for username-first flows. The built-in
145///   `AuthnService::begin_login()` skips this and goes directly to
146///   `Authenticating`. Available via `session.set_identifying()` for apps
147///   that need a two-step identify-then-authenticate UX.
148/// - **Authenticating**: progressing through a multi-factor method; `remaining`
149///   tracks which factor kinds are still to be verified.
150/// - **Authenticated**: all required factors verified; full access granted.
151/// - **PendingWorkflow**: blocked on a post-auth step (signup, KYC, password
152///   reset, etc.). Reached via `begin_signup()` or app-level workflow initiation.
153///
154/// Calling [`AuthSession::clear`](super::extractor::AuthSession::clear) from
155/// any state resets the session back to `Guest` (logout).
156#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
157#[serde(tag = "kind")]
158pub enum AuthState {
159    /// No authentication started; anonymous visitor.
160    #[default]
161    Guest,
162
163    /// User identified (username entered), first factor not yet verified.
164    Identifying {
165        /// Identifier of the user being authenticated.
166        user_id: UserId,
167        /// Tenant the user belongs to.
168        tenant_id: TenantId,
169    },
170
171    /// Progressing through a multi-factor method.
172    Authenticating {
173        /// Identifier of the user being authenticated.
174        user_id: UserId,
175        /// Tenant the user belongs to.
176        tenant_id: TenantId,
177        /// Human-readable method name, e.g. `"password+totp"`.
178        method_name: Arc<str>,
179        /// Ordered list of factor kinds still to be verified.
180        remaining: Vec<FactorKind>,
181        /// Factor kinds that have already been verified in this flow,
182        /// in the order they were completed. Carried into
183        /// [`AuthState::Authenticated::factors_completed`] on transition
184        /// so audit events can record the full list.
185        #[serde(default)]
186        completed: Vec<FactorKind>,
187        /// Number of failed attempts on the current factor step.
188        attempt_count: u32,
189        /// Timestamp of last attempt (for rate limiting / lockout).
190        last_attempt: Option<DateTime<Utc>>,
191    },
192
193    /// All factors verified: user is fully authenticated.
194    Authenticated {
195        /// Identifier of the authenticated user.
196        user_id: UserId,
197        /// Tenant the user belongs to.
198        tenant_id: TenantId,
199        /// Wall-clock time when authentication completed (from injectable Clock).
200        authn_time: DateTime<Utc>,
201        /// Factor kinds that were verified to reach this state, in
202        /// completion order. Used by the audit log to distinguish
203        /// "user logged in with password only because the tenant
204        /// disables TOTP" (legitimate single-factor) from "user
205        /// reached Authenticated without verifying TOTP" (bypass
206        /// attack). Empty for sessions whose state predates this
207        /// field; `serde(default)` keeps existing serialized
208        /// sessions readable.
209        #[serde(default)]
210        factors_completed: Vec<FactorKind>,
211    },
212
213    /// Authenticated but blocked on a post-auth workflow (e.g. signup, KYC, password reset).
214    PendingWorkflow {
215        /// Identifier of the user awaiting workflow completion.
216        user_id: UserId,
217        /// Tenant the user belongs to.
218        tenant_id: TenantId,
219        /// Workflow currently blocking full authentication (e.g. signup, KYC).
220        workflow: WorkflowState,
221    },
222}
223
224impl AuthState {
225    /// Return the authenticated user ID if one is associated with this state.
226    pub fn user_id(&self) -> Option<&UserId> {
227        match self {
228            AuthState::Guest => None,
229            AuthState::Identifying { user_id, .. } => Some(user_id),
230            AuthState::Authenticating { user_id, .. } => Some(user_id),
231            AuthState::Authenticated { user_id, .. } => Some(user_id),
232            AuthState::PendingWorkflow { user_id, .. } => Some(user_id),
233        }
234    }
235
236    /// Return the tenant ID if one is associated with this state.
237    pub fn tenant_id(&self) -> Option<&TenantId> {
238        match self {
239            AuthState::Guest => None,
240            AuthState::Identifying { tenant_id, .. } => Some(tenant_id),
241            AuthState::Authenticating { tenant_id, .. } => Some(tenant_id),
242            AuthState::Authenticated { tenant_id, .. } => Some(tenant_id),
243            AuthState::PendingWorkflow { tenant_id, .. } => Some(tenant_id),
244        }
245    }
246
247    /// Return `true` if this state represents a fully authenticated session.
248    pub fn is_authenticated(&self) -> bool {
249        matches!(self, AuthState::Authenticated { .. })
250    }
251
252    /// Return `true` if this is an unauthenticated guest session.
253    pub fn is_guest(&self) -> bool {
254        matches!(self, AuthState::Guest)
255    }
256
257    /// Return `true` if the session is in progress through a multi-factor flow.
258    ///
259    /// Use this to guard MFA factor-verification routes.
260    pub fn is_authenticating(&self) -> bool {
261        matches!(self, AuthState::Authenticating { .. })
262    }
263
264    // ── Pure state transitions ────────────────────────────────────
265    //
266    // These methods own the pure mutation logic for the state machine.
267    // They never call out to stores, audit, metrics, or session-level
268    // orchestration (RwLock, dirty flag, id rotation, fingerprint
269    // binding); those concerns stay on `AuthSession` which delegates
270    // here. Visibility is `pub(crate)` because external callers receive
271    // `&AuthState` (read-only) via `AuthSession::auth_state` and cannot
272    // construct a `&mut AuthState`; the session is the only mutation
273    // path and that's enforced at the type level.
274
275    /// Replace the state with `Identifying { user_id, tenant_id }`.
276    ///
277    /// Valid from any prior state; this is the "user has typed their
278    /// username, factor not yet selected" entry point and may interrupt
279    /// an in-flight ceremony if the application chooses to re-identify.
280    pub(crate) fn set_identifying(&mut self, user_id: UserId, tenant_id: TenantId) {
281        *self = AuthState::Identifying { user_id, tenant_id };
282    }
283
284    /// Replace the state with `Authenticating { … }`: begin a multi-factor flow.
285    pub(crate) fn begin_authenticating(
286        &mut self,
287        user_id: UserId,
288        tenant_id: TenantId,
289        method_name: Arc<str>,
290        factors: Vec<FactorKind>,
291    ) {
292        *self = AuthState::Authenticating {
293            user_id,
294            tenant_id,
295            method_name,
296            remaining: factors,
297            completed: Vec::new(),
298            attempt_count: 0,
299            last_attempt: None,
300        };
301    }
302
303    /// Replace the state with `Authenticated { … }`.
304    ///
305    /// Used for direct-to-authenticated transitions where there isn't a
306    /// factor sequence to record (e.g. impersonation, OAuth callback
307    /// where the IdP already enforced MFA). For MFA flows that complete
308    /// through `advance_factor`, the `factors_completed` list is built
309    /// from the prior `Authenticating` state's `completed` field.
310    pub(crate) fn set_authenticated(
311        &mut self,
312        user_id: UserId,
313        tenant_id: TenantId,
314        authn_time: DateTime<Utc>,
315    ) {
316        *self = AuthState::Authenticated {
317            user_id,
318            tenant_id,
319            authn_time,
320            factors_completed: Vec::new(),
321        };
322    }
323
324    /// Replace the state with `PendingWorkflow { … }`.
325    pub(crate) fn set_pending_workflow(
326        &mut self,
327        user_id: UserId,
328        tenant_id: TenantId,
329        workflow: WorkflowState,
330    ) {
331        *self = AuthState::PendingWorkflow {
332            user_id,
333            tenant_id,
334            workflow,
335        };
336    }
337
338    /// Advance through a multi-factor flow.
339    ///
340    /// Removes `kind` from `remaining` (if present) and pushes it to
341    /// `completed`. When `remaining` becomes empty, transitions to
342    /// `Authenticated` carrying the `completed` list as
343    /// `factors_completed`. Returns the [`AdvanceOutcome`] describing
344    /// what happened so the caller (`AuthSession::advance_factor`) can
345    /// run the right post-mutation orchestration (id rotation,
346    /// fingerprint binding, dirty flag).
347    ///
348    /// `NotApplicable` from any non-`Authenticating` state: the state
349    /// machine is defensive: calling `advance_factor` on `Guest` is a
350    /// no-op rather than an error so partial-attribution audit emits
351    /// can fire before reaching here without a panic risk.
352    pub(crate) fn advance_factor(
353        &mut self,
354        kind: &FactorKind,
355        authn_time: DateTime<Utc>,
356    ) -> AdvanceOutcome {
357        match self {
358            AuthState::Authenticating {
359                user_id,
360                tenant_id,
361                remaining,
362                completed,
363                ..
364            } => {
365                if let Some(pos) = remaining.iter().position(|k| k == kind) {
366                    let removed = remaining.remove(pos);
367                    completed.push(removed);
368                }
369                if remaining.is_empty() {
370                    let uid = *user_id;
371                    let tid = *tenant_id;
372                    let factors = std::mem::take(completed);
373                    *self = AuthState::Authenticated {
374                        user_id: uid,
375                        tenant_id: tid,
376                        authn_time,
377                        factors_completed: factors,
378                    };
379                    AdvanceOutcome::Completed
380                } else {
381                    AdvanceOutcome::StillAuthenticating
382                }
383            }
384            _ => AdvanceOutcome::NotApplicable,
385        }
386    }
387
388    /// Record a failed factor attempt against the `Authenticating`
389    /// state: increment counter, capture timestamp.
390    ///
391    /// No-op on any other variant. **The session-level dirty flag is
392    /// not the caller's concern here**: we deliberately do NOT
393    /// persist on every wrong attempt because that scales DB writes
394    /// with brute-force traffic. `AuthSession::record_attempt_at`
395    /// preserves that property by not setting `modified = true`.
396    pub(crate) fn record_attempt_at(&mut self, now: DateTime<Utc>) {
397        if let AuthState::Authenticating {
398            attempt_count,
399            last_attempt,
400            ..
401        } = self
402        {
403            *attempt_count += 1;
404            *last_attempt = Some(now);
405        }
406    }
407}
408
409/// Result of [`AuthState::advance_factor`].
410///
411/// The session-level orchestration in `AuthSession::advance_factor`
412/// branches on this to run the right side effects: `Completed` requires
413/// fingerprint binding + id rotation + dirty flag; `StillAuthenticating`
414/// requires only the dirty flag; `NotApplicable` is a no-op.
415#[derive(Debug, PartialEq, Eq)]
416pub(crate) enum AdvanceOutcome {
417    /// The state was not `Authenticating`; nothing was changed.
418    NotApplicable,
419    /// The factor was applied (or absent: defensive no-op on the
420    /// kind itself); the flow still has remaining factors.
421    StillAuthenticating,
422    /// The last remaining factor was applied; transitioned to
423    /// `Authenticated`.
424    Completed,
425}
426
427/// Post-authentication workflow tracking.
428///
429/// Used when the user is fully authenticated but must complete an additional
430/// workflow step before being granted full access (e.g. KYC, password reset).
431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
432pub struct WorkflowState {
433    /// The kind of workflow that must be completed.
434    pub kind: WorkflowKind,
435    /// Zero-based index of the current step.
436    pub current_step: u32,
437    /// Total number of steps in this workflow.
438    pub total_steps: u32,
439    /// When this workflow was initiated.
440    pub initiated_at: DateTime<Utc>,
441}
442
443impl WorkflowState {
444    /// Create a new workflow. Pass `clock.now()` as `now` for DST compatibility.
445    pub fn new(kind: WorkflowKind, total_steps: u32, now: DateTime<Utc>) -> Self {
446        Self {
447            kind,
448            current_step: 0,
449            total_steps,
450            initiated_at: now,
451        }
452    }
453}
454
455/// The classification of a post-authentication workflow.
456#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
457pub enum WorkflowKind {
458    /// New-user registration workflow.
459    Signup,
460    /// Password reset flow initiated by the user or an admin.
461    PasswordReset,
462    /// Email verification after account creation or address change.
463    EmailVerification,
464    /// Application-defined workflow with a custom name.
465    Custom(Arc<str>),
466}
467
468#[cfg(test)]
469mod tests;