Skip to main content

rustio_admin/auth/
sessions.rs

1//! DB-backed sessions.
2//!
3//! See `DESIGN_SESSIONS.md` for the canonical lifecycle, trust-level
4//! model, and invalidation reasons. Briefly:
5//!
6//! - A **session** is a device/browser context with a stable
7//!   [`SessionId`], a current [`SessionTrust`], and an issuance chain
8//!   tracked through `parent_session_id`.
9//! - Cookie tokens are sha-256-hashed at rest in `token_hash`; the
10//!   plaintext only exists in the user's cookie.
11//! - Trust escalation rotates the cookie (mints a new row, sets the
12//!   parent's `revoked_at` with reason `trust_escalation`).
13//! - All revocations go through [`invalidate_sessions`] — no other
14//!   code path writes `revoked_at`. Grep for `revoked_at` to verify.
15
16use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
17use chrono::{DateTime, Duration, Utc};
18use rand::RngCore;
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21
22use crate::error::Result;
23use crate::orm::{Db, Row};
24
25use super::role::Role;
26use super::users::Identity;
27
28/// The cookie name we look for and set. Constant so middleware and
29/// handlers stay in sync.
30pub const SESSION_COOKIE: &str = "rustio_session";
31
32const SESSION_LENGTH_DAYS: i64 = 14;
33
34/// Trust level a session has acquired. The login flow mints
35/// [`SessionTrust::Authenticated`]; the future re-auth wall promotes
36/// to [`SessionTrust::Elevated`]; a successful TOTP step on this
37/// session lifts to [`SessionTrust::MfaVerified`].
38///
39/// The variants are ordered: `Authenticated < Elevated <
40/// MfaVerified`. Compare via [`SessionTrust::satisfies`].
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum SessionTrust {
44    Authenticated,
45    Elevated,
46    MfaVerified,
47}
48
49impl SessionTrust {
50    /// Stable lowercase identifier matching the SQL `trust_level`
51    /// column's CHECK constraint.
52    pub const fn as_str(self) -> &'static str {
53        match self {
54            Self::Authenticated => "authenticated",
55            Self::Elevated => "elevated",
56            Self::MfaVerified => "mfa_verified",
57        }
58    }
59
60    /// Numeric ladder for partial-order comparisons.
61    pub const fn rank(self) -> u8 {
62        match self {
63            Self::Authenticated => 1,
64            Self::Elevated => 2,
65            Self::MfaVerified => 3,
66        }
67    }
68
69    /// `self` is at least as trusted as `other`.
70    pub const fn satisfies(self, other: SessionTrust) -> bool {
71        self.rank() >= other.rank()
72    }
73
74    /// Parse from the SQL `trust_level` column. Defaults to
75    /// `Authenticated` on unknown input so a malformed migration
76    /// can't lock anyone out.
77    pub fn parse(s: &str) -> Self {
78        match s {
79            "elevated" => Self::Elevated,
80            "mfa_verified" => Self::MfaVerified,
81            _ => Self::Authenticated,
82        }
83    }
84}
85
86/// Why a session is being invalidated. Drives both the audit
87/// `action_type` and decisions about whether to clear remembered MFA
88/// or mint a replacement session.
89///
90/// All [`invalidate_sessions`] callers pass one of these — the engine
91/// is the single writer of `revoked_at`. Free-form reasons are not
92/// allowed; doctrine 22 ("centralized invalidation") in
93/// `DESIGN_SYSTEM.md`.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum SessionInvalidationReason {
96    Logout,
97    Expired,
98    UserRequested,
99    AdministrativeRevoke,
100    PasswordReset,
101    PasswordResetByOther,
102    MfaEnabled,
103    MfaDisabled,
104    MfaDisabledByOther,
105    AuthorityEscalation,
106    EmergencyRecovery,
107    /// Token rotation that accompanies a trust escalation
108    /// (`Authenticated → Elevated`, etc.). The replacement session is
109    /// minted as the parent's child; this revokes the old token.
110    TrustEscalation,
111}
112
113impl SessionInvalidationReason {
114    /// Stable lowercase identifier persisted in
115    /// `rustio_sessions.revoked_reason` and used as the audit
116    /// `action_type` suffix.
117    pub const fn as_str(self) -> &'static str {
118        match self {
119            Self::Logout => "logout",
120            Self::Expired => "expired",
121            Self::UserRequested => "user_requested",
122            Self::AdministrativeRevoke => "administrative_revoke",
123            Self::PasswordReset => "password_reset",
124            Self::PasswordResetByOther => "password_reset_by_other",
125            Self::MfaEnabled => "mfa_enabled",
126            Self::MfaDisabled => "mfa_disabled",
127            Self::MfaDisabledByOther => "mfa_disabled_by_other",
128            Self::AuthorityEscalation => "authority_escalation",
129            Self::EmergencyRecovery => "emergency_recovery",
130            Self::TrustEscalation => "trust_escalation",
131        }
132    }
133}
134
135/// Which sessions an [`invalidate_sessions`] call targets.
136#[derive(Debug, Clone, Copy)]
137pub enum SessionTarget {
138    /// Every active session belonging to `user_id`.
139    User { user_id: i64 },
140    /// Every active session belonging to `user_id` except the one
141    /// identified by `current_session_id`. Used by "log me out
142    /// everywhere else" and by post-password-reset flows that want to
143    /// keep the current device alive.
144    UserExceptCurrent {
145        user_id: i64,
146        current_session_id: i64,
147    },
148    /// One specific session row.
149    Single { session_id: i64 },
150}
151
152/// One session row, reconstructed from `rustio_sessions`. Returned
153/// by [`list_active_for_user`] for the active-sessions UI.
154#[derive(Debug, Clone, Serialize)]
155pub struct Session {
156    pub session_id: i64,
157    pub user_id: i64,
158    pub trust_level: SessionTrust,
159    pub created_at: DateTime<Utc>,
160    pub last_seen: DateTime<Utc>,
161    pub expires_at: DateTime<Utc>,
162    pub elevated_until: Option<DateTime<Utc>>,
163    pub ip: Option<String>,
164    pub user_agent: Option<String>,
165}
166
167/// Outcome of an [`invalidate_sessions`] call. Used by the audit
168/// pipeline to write one row per affected session and by the caller
169/// to decide whether to clear the user's cookie.
170#[derive(Debug, Clone, Default)]
171pub struct InvalidationOutcome {
172    /// `session_id`s that were transitioned from active to revoked.
173    pub revoked_session_ids: Vec<i64>,
174    /// Reason recorded for the audit pipeline.
175    pub reason: Option<SessionInvalidationReason>,
176}
177
178pub async fn init_session_tables(db: &Db) -> Result<()> {
179    sqlx::query(
180        "CREATE TABLE IF NOT EXISTS rustio_sessions (
181            token      TEXT PRIMARY KEY,
182            user_id    BIGINT NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE,
183            expires_at TIMESTAMPTZ NOT NULL,
184            created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
185            last_seen  TIMESTAMPTZ NOT NULL DEFAULT NOW()
186        )",
187    )
188    .execute(db.pool())
189    .await?;
190
191    sqlx::query("CREATE INDEX IF NOT EXISTS rustio_sessions_user_idx ON rustio_sessions (user_id)")
192        .execute(db.pool())
193        .await?;
194
195    sqlx::query(
196        "CREATE INDEX IF NOT EXISTS rustio_sessions_expires_idx ON rustio_sessions (expires_at)",
197    )
198    .execute(db.pool())
199    .await?;
200
201    Ok(())
202}
203
204/// Additive schema upgrade for session-level metadata (ip, user_agent).
205/// Idempotent; safe to call on every boot. Reads are consumed by the
206/// built-in user profile page; the auth path itself never reads these.
207pub(crate) async fn migrate_session_schema(db: &Db) -> Result<()> {
208    sqlx::query("ALTER TABLE rustio_sessions ADD COLUMN IF NOT EXISTS ip TEXT")
209        .execute(db.pool())
210        .await?;
211    sqlx::query("ALTER TABLE rustio_sessions ADD COLUMN IF NOT EXISTS user_agent TEXT")
212        .execute(db.pool())
213        .await?;
214    Ok(())
215}
216
217/// Additive lifecycle migration introduced in 0.4.0 (`feat/session-token-hashing`).
218/// Adds:
219///
220/// - `session_id` — a stable BIGINT identifier separate from the
221///   token, so trust escalation can rotate the cookie without losing
222///   the session's identity. Backed by a sequence; existing rows are
223///   assigned ids on the ALTER.
224/// - `token_hash` — sha256 of the cookie token, URL-safe base64.
225///   Reads will prefer this over the plaintext `token` PK during a
226///   14-day transition window; new sessions populate it at insert.
227/// - `device_id` — nullable, reserved for future device-recognition
228///   work. R0 leaves it empty.
229/// - `trust_level` — `authenticated | elevated | mfa_verified`.
230///   Defaults to `authenticated` for existing rows.
231/// - `elevated_until` — re-auth wall expiry; populated by the future
232///   `/admin/reauth` endpoint.
233/// - `parent_session_id` — lineage anchor for trust-escalation
234///   rotation; future invalidations use it to revoke ancestor
235///   sessions when a child elevates.
236/// - `revoked_at` / `revoked_reason` — soft-delete with a typed
237///   reason. Replaces the old DELETE-on-logout flow (the row stays
238///   for audit retention until `purge_expired_sessions` reaps it).
239///
240/// Idempotent; safe to call on every boot.
241pub(crate) async fn migrate_session_lifecycle(db: &Db) -> Result<()> {
242    sqlx::query("CREATE SEQUENCE IF NOT EXISTS rustio_sessions_session_id_seq")
243        .execute(db.pool())
244        .await?;
245    sqlx::query(
246        "ALTER TABLE rustio_sessions \
247         ADD COLUMN IF NOT EXISTS session_id BIGINT NOT NULL DEFAULT \
248             nextval('rustio_sessions_session_id_seq')",
249    )
250    .execute(db.pool())
251    .await?;
252    sqlx::query(
253        "ALTER SEQUENCE rustio_sessions_session_id_seq OWNED BY rustio_sessions.session_id",
254    )
255    .execute(db.pool())
256    .await?;
257    sqlx::query("ALTER TABLE rustio_sessions ADD COLUMN IF NOT EXISTS token_hash TEXT")
258        .execute(db.pool())
259        .await?;
260    sqlx::query("ALTER TABLE rustio_sessions ADD COLUMN IF NOT EXISTS device_id TEXT")
261        .execute(db.pool())
262        .await?;
263    sqlx::query(
264        "ALTER TABLE rustio_sessions ADD COLUMN IF NOT EXISTS trust_level TEXT \
265         NOT NULL DEFAULT 'authenticated'",
266    )
267    .execute(db.pool())
268    .await?;
269    sqlx::query("ALTER TABLE rustio_sessions ADD COLUMN IF NOT EXISTS elevated_until TIMESTAMPTZ")
270        .execute(db.pool())
271        .await?;
272    sqlx::query("ALTER TABLE rustio_sessions ADD COLUMN IF NOT EXISTS parent_session_id BIGINT")
273        .execute(db.pool())
274        .await?;
275    sqlx::query("ALTER TABLE rustio_sessions ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ")
276        .execute(db.pool())
277        .await?;
278    sqlx::query("ALTER TABLE rustio_sessions ADD COLUMN IF NOT EXISTS revoked_reason TEXT")
279        .execute(db.pool())
280        .await?;
281
282    // CHECK constraint guarded via pg_constraint, since `IF NOT EXISTS`
283    // doesn't apply to constraints.
284    sqlx::query(
285        "DO $$ BEGIN \
286            IF NOT EXISTS ( \
287                SELECT 1 FROM pg_constraint \
288                WHERE conname = 'rustio_sessions_trust_level_check' \
289            ) THEN \
290                ALTER TABLE rustio_sessions \
291                ADD CONSTRAINT rustio_sessions_trust_level_check \
292                CHECK (trust_level IN ('authenticated', 'elevated', 'mfa_verified')); \
293            END IF; \
294        END $$",
295    )
296    .execute(db.pool())
297    .await?;
298
299    sqlx::query(
300        "CREATE UNIQUE INDEX IF NOT EXISTS rustio_sessions_session_id_uq \
301         ON rustio_sessions (session_id)",
302    )
303    .execute(db.pool())
304    .await?;
305    sqlx::query(
306        "CREATE UNIQUE INDEX IF NOT EXISTS rustio_sessions_token_hash_uq \
307         ON rustio_sessions (token_hash) \
308         WHERE revoked_at IS NULL AND token_hash IS NOT NULL",
309    )
310    .execute(db.pool())
311    .await?;
312    sqlx::query(
313        "CREATE INDEX IF NOT EXISTS rustio_sessions_user_active_idx \
314         ON rustio_sessions (user_id) WHERE revoked_at IS NULL",
315    )
316    .execute(db.pool())
317    .await?;
318    sqlx::query(
319        "CREATE INDEX IF NOT EXISTS rustio_sessions_parent_idx \
320         ON rustio_sessions (parent_session_id) WHERE parent_session_id IS NOT NULL",
321    )
322    .execute(db.pool())
323    .await?;
324
325    Ok(())
326}
327
328pub async fn create_session(db: &Db, user_id: i64) -> Result<String> {
329    let token = random_token();
330    let token_hash = hash_token_for_storage(&token);
331    let expires = Utc::now() + Duration::days(SESSION_LENGTH_DAYS);
332    // Both `token` (PRIMARY KEY) and `token_hash` are stored. The
333    // plaintext column is preserved so the 0.3.x fallback read path
334    // keeps working for sessions created before this commit; new
335    // sessions write both values so a future migration can drop the
336    // plaintext column without a data backfill.
337    sqlx::query(
338        "INSERT INTO rustio_sessions (token, token_hash, user_id, expires_at) \
339         VALUES ($1, $2, $3, $4)",
340    )
341    .bind(&token)
342    .bind(&token_hash)
343    .bind(user_id)
344    .bind(expires)
345    .execute(db.pool())
346    .await?;
347    Ok(token)
348}
349
350/// Hard-delete a session row by cookie token. Retained as a
351/// pre-0.4.0 compatibility shim — internal callers are migrating to
352/// [`invalidate_sessions`], which soft-revokes via `revoked_at` and
353/// keeps the row available for the audit trail. New code MUST NOT
354/// call this directly; only the expired-row sweeper and the read-path
355/// stale-cleanup branch are allowed callers, both of which are
356/// inside this module.
357pub async fn delete_session(db: &Db, token: &str) -> Result<()> {
358    sqlx::query("DELETE FROM rustio_sessions WHERE token = $1 OR token_hash = $2")
359        .bind(token)
360        .bind(hash_token_for_storage(token))
361        .execute(db.pool())
362        .await?;
363    Ok(())
364}
365
366/// Centralised session invalidation — the single legitimate writer of
367/// `rustio_sessions.revoked_at`.
368///
369/// Doctrine 22 (centralized invalidation) makes every revoke decision
370/// pass through here. Handlers MUST NOT issue raw `UPDATE … SET
371/// revoked_at` statements; a grep for that string in the source tree
372/// must return only this module. PR review enforces it.
373///
374/// What this function does:
375///
376/// - Resolves the [`SessionTarget`] into the set of session ids that
377///   are currently active and match.
378/// - Marks each row `revoked_at = NOW()` and `revoked_reason =
379///   reason.as_str()`.
380/// - Returns the affected ids in the [`InvalidationOutcome`] so the
381///   caller can write one audit row per revoked session, all sharing
382///   the supplied `correlation_id`.
383///
384/// Audit row writes are the caller's job (the audit module owns the
385/// `rustio_admin_actions` table; sessions own `rustio_sessions`). The
386/// reason is returned so the caller can render a typed `action_type`
387/// without re-deriving it.
388pub async fn invalidate_sessions(
389    db: &Db,
390    target: SessionTarget,
391    reason: SessionInvalidationReason,
392) -> Result<InvalidationOutcome> {
393    let reason_str = reason.as_str();
394    let revoked_ids: Vec<i64> = match target {
395        SessionTarget::User { user_id } => {
396            sqlx::query_scalar::<_, i64>(
397                "UPDATE rustio_sessions \
398                SET revoked_at = NOW(), revoked_reason = $2 \
399              WHERE user_id = $1 AND revoked_at IS NULL \
400            RETURNING session_id",
401            )
402            .bind(user_id)
403            .bind(reason_str)
404            .fetch_all(db.pool())
405            .await?
406        }
407        SessionTarget::UserExceptCurrent {
408            user_id,
409            current_session_id,
410        } => {
411            sqlx::query_scalar::<_, i64>(
412                "UPDATE rustio_sessions \
413                SET revoked_at = NOW(), revoked_reason = $3 \
414              WHERE user_id = $1 AND session_id <> $2 AND revoked_at IS NULL \
415            RETURNING session_id",
416            )
417            .bind(user_id)
418            .bind(current_session_id)
419            .bind(reason_str)
420            .fetch_all(db.pool())
421            .await?
422        }
423        SessionTarget::Single { session_id } => {
424            sqlx::query_scalar::<_, i64>(
425                "UPDATE rustio_sessions \
426                SET revoked_at = NOW(), revoked_reason = $2 \
427              WHERE session_id = $1 AND revoked_at IS NULL \
428            RETURNING session_id",
429            )
430            .bind(session_id)
431            .bind(reason_str)
432            .fetch_all(db.pool())
433            .await?
434        }
435    };
436
437    Ok(InvalidationOutcome {
438        revoked_session_ids: revoked_ids,
439        reason: Some(reason),
440    })
441}
442
443/// Convenience wrapper for the existing logout flow. Routes through
444/// [`invalidate_sessions`] with `SessionTarget::Single` and
445/// `SessionInvalidationReason::Logout`.
446///
447/// Looks up the session by the cookie token (fast path: token_hash;
448/// fallback: plaintext for legacy 0.3.x sessions). Returns `Ok(())`
449/// even when no row matches — logout is idempotent.
450pub async fn logout_session(db: &Db, token: &str) -> Result<()> {
451    let token_hash = hash_token_for_storage(token);
452    let session_id: Option<i64> = sqlx::query_scalar::<_, i64>(
453        "SELECT session_id FROM rustio_sessions \
454          WHERE (token_hash = $1 OR (token_hash IS NULL AND token = $2)) \
455            AND revoked_at IS NULL \
456          LIMIT 1",
457    )
458    .bind(&token_hash)
459    .bind(token)
460    .fetch_optional(db.pool())
461    .await?;
462
463    if let Some(sid) = session_id {
464        invalidate_sessions(
465            db,
466            SessionTarget::Single { session_id: sid },
467            SessionInvalidationReason::Logout,
468        )
469        .await?;
470    }
471    Ok(())
472}
473
474/// List a user's currently-active sessions, ordered by `last_seen`
475/// descending so the active-sessions UI surfaces the most recently
476/// used row first. Excludes revoked + expired rows.
477pub async fn list_active_for_user(db: &Db, user_id: i64) -> Result<Vec<Session>> {
478    let rows = sqlx::query(
479        "SELECT session_id, user_id, trust_level, created_at, last_seen, expires_at, \
480                elevated_until, ip, user_agent \
481           FROM rustio_sessions \
482          WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW() \
483          ORDER BY last_seen DESC",
484    )
485    .bind(user_id)
486    .fetch_all(db.pool())
487    .await?;
488
489    rows.iter()
490        .map(|r| {
491            let r = Row::from_pg(r);
492            Ok(Session {
493                session_id: r.get_i64("session_id")?,
494                user_id: r.get_i64("user_id")?,
495                trust_level: SessionTrust::parse(&r.get_string("trust_level")?),
496                created_at: r.get_datetime("created_at")?,
497                last_seen: r.get_datetime("last_seen")?,
498                expires_at: r.get_datetime("expires_at")?,
499                elevated_until: r.get_optional_datetime("elevated_until")?,
500                ip: r.get_optional_string("ip")?,
501                user_agent: r.get_optional_string("user_agent")?,
502            })
503        })
504        .collect()
505}
506
507/// Resolve the cookie token to its `session_id` (active sessions
508/// only). Used by the active-sessions UI to mark which row is the
509/// current device, and by `UserExceptCurrent` callers.
510pub async fn current_session_id(db: &Db, token: &str) -> Result<Option<i64>> {
511    let token_hash = hash_token_for_storage(token);
512    let id: Option<i64> = sqlx::query_scalar::<_, i64>(
513        "SELECT session_id FROM rustio_sessions \
514          WHERE (token_hash = $1 OR (token_hash IS NULL AND token = $2)) \
515            AND revoked_at IS NULL AND expires_at > NOW() \
516          LIMIT 1",
517    )
518    .bind(&token_hash)
519    .bind(token)
520    .fetch_optional(db.pool())
521    .await?;
522    Ok(id)
523}
524
525pub async fn identity_from_session(db: &Db, token: &str) -> Result<Option<Identity>> {
526    // Fast path: lookup by sha-256 of the cookie token. Every session
527    // created in 0.4.0+ has `token_hash` populated, and the unique
528    // partial index `rustio_sessions_token_hash_uq` makes this an
529    // index seek. Revoked sessions (`revoked_at IS NOT NULL`) are
530    // excluded so a logged-out cookie never re-authenticates.
531    let token_hash = hash_token_for_storage(token);
532    let row = sqlx::query(
533        "SELECT u.id, u.email, u.role, u.is_active, u.is_demo, u.demo_label, \
534                u.must_change_password, \
535                s.expires_at, s.token_hash IS NOT NULL AS hashed \
536           FROM rustio_sessions s \
537           JOIN rustio_users u ON u.id = s.user_id \
538          WHERE s.token_hash = $1 AND s.revoked_at IS NULL",
539    )
540    .bind(&token_hash)
541    .fetch_optional(db.pool())
542    .await?;
543
544    let row = match row {
545        Some(r) => Some(r),
546        // Slow path / transition fallback: pre-0.4.0 sessions have
547        // NULL `token_hash` and were keyed by plaintext `token` PK.
548        // Look those up so existing logged-in users aren't kicked out
549        // when 0.4.0 deploys. The fallback can be removed in a follow-
550        // up release once SESSION_LENGTH_DAYS (14d) has elapsed since
551        // 0.4.0 publish — every legacy session will have expired by
552        // then.
553        None => {
554            sqlx::query(
555                "SELECT u.id, u.email, u.role, u.is_active, u.is_demo, u.demo_label, \
556                    u.must_change_password, \
557                    s.expires_at, FALSE AS hashed \
558               FROM rustio_sessions s \
559               JOIN rustio_users u ON u.id = s.user_id \
560              WHERE s.token = $1 AND s.token_hash IS NULL AND s.revoked_at IS NULL",
561            )
562            .bind(token)
563            .fetch_optional(db.pool())
564            .await?
565        }
566    };
567    let row = match row {
568        Some(r) => r,
569        None => return Ok(None),
570    };
571    let r = Row::from_pg(&row);
572    let expires_at = r.get_datetime("expires_at")?;
573    if expires_at < Utc::now() {
574        // Don't bother keeping the stale row around. Fire-and-forget;
575        // the central invalidate_sessions API lands in the next
576        // commit and replaces this DELETE with a soft revoke. Until
577        // then a hard delete is consistent with prior behavior for
578        // expired rows (purge_expired_sessions also DELETEs).
579        let _ = delete_session(db, token).await;
580        return Ok(None);
581    }
582
583    // Touch last_seen without holding the request back. Updates by
584    // token_hash on the fast path, falls back to token for legacy
585    // sessions so the activity timestamp lands on the right row.
586    let db_clone = db.clone();
587    let token_owned = token.to_string();
588    let token_hash_owned = token_hash.clone();
589    tokio::spawn(async move {
590        let _ = sqlx::query(
591            "UPDATE rustio_sessions SET last_seen = NOW() \
592              WHERE (token_hash = $1 OR (token_hash IS NULL AND token = $2)) \
593                AND revoked_at IS NULL",
594        )
595        .bind(&token_hash_owned)
596        .bind(&token_owned)
597        .execute(db_clone.pool())
598        .await;
599    });
600
601    Ok(Some(Identity {
602        user_id: r.get_i64("id")?,
603        email: r.get_string("email")?,
604        role: Role::parse(&r.get_string("role")?)?,
605        is_active: r.get_bool("is_active")?,
606        is_demo: r.get_bool("is_demo")?,
607        demo_label: r.get_optional_string("demo_label")?,
608        must_change_password: r.get_bool("must_change_password")?,
609    }))
610}
611
612/// Delete all expired sessions. Intended to be called periodically
613/// from a background task (see `background::spawn_session_sweeper`).
614pub async fn purge_expired_sessions(db: &Db) -> Result<u64> {
615    let result = sqlx::query("DELETE FROM rustio_sessions WHERE expires_at < NOW()")
616        .execute(db.pool())
617        .await?;
618    Ok(result.rows_affected())
619}
620
621pub fn session_token_from_cookie(cookie_header: &str) -> Option<String> {
622    let prefix = format!("{SESSION_COOKIE}=");
623    for part in cookie_header.split(';') {
624        let part = part.trim();
625        if let Some(v) = part.strip_prefix(&prefix) {
626            return Some(v.to_string());
627        }
628    }
629    None
630}
631
632/// Generate a 256-bit cryptographically-random URL-safe-base64
633/// token. Shared between session cookies and password-reset tokens
634/// (R1) — both want the same "random enough that brute force is
635/// infeasible regardless of any hash function's work factor"
636/// shape. `pub(crate)` so `auth::recovery` can call it without
637/// duplicating the helper; not in the public API.
638pub(crate) fn random_token() -> String {
639    let mut bytes = [0u8; 32];
640    rand::thread_rng().fill_bytes(&mut bytes);
641    URL_SAFE_NO_PAD.encode(bytes)
642}
643
644/// Hash a session-cookie token for at-rest storage in
645/// `rustio_sessions.token_hash`. SHA-256 of the URL-safe-base64
646/// plaintext, re-encoded as URL-safe-base64 (no padding) so the
647/// column accepts ASCII text.
648///
649/// SHA-256 is the right choice here (not Argon2): the input is a
650/// 256-bit random token, so brute force is infeasible regardless of
651/// the hash function's work factor; SHA-256 is fast enough to keep
652/// the session-lookup path under 1ms even at high RPS. Argon2 would
653/// add latency without security benefit for this input distribution.
654pub(crate) fn hash_token_for_storage(token: &str) -> String {
655    let digest = Sha256::digest(token.as_bytes());
656    URL_SAFE_NO_PAD.encode(digest)
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662
663    #[test]
664    fn extracts_token_from_cookie_header() {
665        let h = "foo=bar; rustio_session=abc123; other=x";
666        assert_eq!(session_token_from_cookie(h), Some("abc123".into()));
667    }
668
669    #[test]
670    fn returns_none_when_cookie_missing() {
671        let h = "foo=bar; other=x";
672        assert!(session_token_from_cookie(h).is_none());
673    }
674
675    #[test]
676    fn random_token_has_reasonable_entropy() {
677        // Rough sanity check — two consecutive tokens should differ.
678        assert_ne!(random_token(), random_token());
679    }
680
681    #[test]
682    fn hash_token_is_deterministic() {
683        // Same input → same hash, every call. Required for the
684        // identity_from_session lookup to find the row.
685        let token = random_token();
686        assert_eq!(
687            hash_token_for_storage(&token),
688            hash_token_for_storage(&token)
689        );
690    }
691
692    #[test]
693    fn hash_token_differs_per_token() {
694        // Different inputs → different hashes (collision-resistance is
695        // the point).
696        let a = hash_token_for_storage("aaaa");
697        let b = hash_token_for_storage("aaab");
698        assert_ne!(a, b);
699    }
700
701    #[test]
702    fn hash_token_output_is_url_safe_base64() {
703        let h = hash_token_for_storage("anything");
704        // 256 bits → 43 url-safe-no-pad base64 chars.
705        assert_eq!(h.len(), 43);
706        assert!(h
707            .chars()
708            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
709    }
710
711    #[test]
712    fn hash_token_does_not_leak_plaintext() {
713        // Property check — the hash output should bear no obvious
714        // resemblance to the plaintext, including substrings.
715        let plaintext = "secret-cookie-value-12345";
716        let h = hash_token_for_storage(plaintext);
717        assert!(!h.contains("secret"));
718        assert!(!h.contains("12345"));
719    }
720
721    // ---- typed session model ----
722
723    #[test]
724    fn session_trust_orders_correctly() {
725        assert!(SessionTrust::Authenticated.rank() < SessionTrust::Elevated.rank());
726        assert!(SessionTrust::Elevated.rank() < SessionTrust::MfaVerified.rank());
727        assert!(SessionTrust::MfaVerified.satisfies(SessionTrust::Elevated));
728        assert!(SessionTrust::MfaVerified.satisfies(SessionTrust::Authenticated));
729        assert!(SessionTrust::Authenticated.satisfies(SessionTrust::Authenticated));
730        assert!(!SessionTrust::Authenticated.satisfies(SessionTrust::Elevated));
731        assert!(!SessionTrust::Elevated.satisfies(SessionTrust::MfaVerified));
732    }
733
734    #[test]
735    fn session_trust_round_trips_through_sql() {
736        for tier in [
737            SessionTrust::Authenticated,
738            SessionTrust::Elevated,
739            SessionTrust::MfaVerified,
740        ] {
741            assert_eq!(SessionTrust::parse(tier.as_str()), tier);
742        }
743    }
744
745    #[test]
746    fn session_trust_parse_defaults_safely_on_unknown() {
747        // Unknown / malformed trust_level column → fall back to the
748        // weakest tier so a bad row can't accidentally elevate.
749        assert_eq!(SessionTrust::parse("garbage"), SessionTrust::Authenticated);
750        assert_eq!(SessionTrust::parse(""), SessionTrust::Authenticated);
751    }
752
753    #[test]
754    fn invalidation_reason_strings_are_distinct() {
755        // Property: as_str() values must be globally unique so audit
756        // rows are unambiguous.
757        let reasons = [
758            SessionInvalidationReason::Logout,
759            SessionInvalidationReason::Expired,
760            SessionInvalidationReason::UserRequested,
761            SessionInvalidationReason::AdministrativeRevoke,
762            SessionInvalidationReason::PasswordReset,
763            SessionInvalidationReason::PasswordResetByOther,
764            SessionInvalidationReason::MfaEnabled,
765            SessionInvalidationReason::MfaDisabled,
766            SessionInvalidationReason::MfaDisabledByOther,
767            SessionInvalidationReason::AuthorityEscalation,
768            SessionInvalidationReason::EmergencyRecovery,
769            SessionInvalidationReason::TrustEscalation,
770        ];
771        let mut set = std::collections::HashSet::new();
772        for r in reasons {
773            assert!(set.insert(r.as_str()), "duplicate as_str() for {r:?}");
774        }
775        assert_eq!(set.len(), reasons.len());
776    }
777}