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;