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: None, // optional column; reader lands when re-auth wall ships
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                s.expires_at, s.token_hash IS NOT NULL AS hashed \
535           FROM rustio_sessions s \
536           JOIN rustio_users u ON u.id = s.user_id \
537          WHERE s.token_hash = $1 AND s.revoked_at IS NULL",
538    )
539    .bind(&token_hash)
540    .fetch_optional(db.pool())
541    .await?;
542
543    let row = match row {
544        Some(r) => Some(r),
545        // Slow path / transition fallback: pre-0.4.0 sessions have
546        // NULL `token_hash` and were keyed by plaintext `token` PK.
547        // Look those up so existing logged-in users aren't kicked out
548        // when 0.4.0 deploys. The fallback can be removed in a follow-
549        // up release once SESSION_LENGTH_DAYS (14d) has elapsed since
550        // 0.4.0 publish — every legacy session will have expired by
551        // then.
552        None => {
553            sqlx::query(
554                "SELECT u.id, u.email, u.role, u.is_active, u.is_demo, u.demo_label, \
555                    s.expires_at, FALSE AS hashed \
556               FROM rustio_sessions s \
557               JOIN rustio_users u ON u.id = s.user_id \
558              WHERE s.token = $1 AND s.token_hash IS NULL AND s.revoked_at IS NULL",
559            )
560            .bind(token)
561            .fetch_optional(db.pool())
562            .await?
563        }
564    };
565    let row = match row {
566        Some(r) => r,
567        None => return Ok(None),
568    };
569    let r = Row::from_pg(&row);
570    let expires_at = r.get_datetime("expires_at")?;
571    if expires_at < Utc::now() {
572        // Don't bother keeping the stale row around. Fire-and-forget;
573        // the central invalidate_sessions API lands in the next
574        // commit and replaces this DELETE with a soft revoke. Until
575        // then a hard delete is consistent with prior behavior for
576        // expired rows (purge_expired_sessions also DELETEs).
577        let _ = delete_session(db, token).await;
578        return Ok(None);
579    }
580
581    // Touch last_seen without holding the request back. Updates by
582    // token_hash on the fast path, falls back to token for legacy
583    // sessions so the activity timestamp lands on the right row.
584    let db_clone = db.clone();
585    let token_owned = token.to_string();
586    let token_hash_owned = token_hash.clone();
587    tokio::spawn(async move {
588        let _ = sqlx::query(
589            "UPDATE rustio_sessions SET last_seen = NOW() \
590              WHERE (token_hash = $1 OR (token_hash IS NULL AND token = $2)) \
591                AND revoked_at IS NULL",
592        )
593        .bind(&token_hash_owned)
594        .bind(&token_owned)
595        .execute(db_clone.pool())
596        .await;
597    });
598
599    Ok(Some(Identity {
600        user_id: r.get_i64("id")?,
601        email: r.get_string("email")?,
602        role: Role::parse(&r.get_string("role")?)?,
603        is_active: r.get_bool("is_active")?,
604        is_demo: r.get_bool("is_demo")?,
605        demo_label: r.get_optional_string("demo_label")?,
606    }))
607}
608
609/// Delete all expired sessions. Intended to be called periodically
610/// from a background task (see `background::spawn_session_sweeper`).
611pub async fn purge_expired_sessions(db: &Db) -> Result<u64> {
612    let result = sqlx::query("DELETE FROM rustio_sessions WHERE expires_at < NOW()")
613        .execute(db.pool())
614        .await?;
615    Ok(result.rows_affected())
616}
617
618pub fn session_token_from_cookie(cookie_header: &str) -> Option<String> {
619    let prefix = format!("{SESSION_COOKIE}=");
620    for part in cookie_header.split(';') {
621        let part = part.trim();
622        if let Some(v) = part.strip_prefix(&prefix) {
623            return Some(v.to_string());
624        }
625    }
626    None
627}
628
629/// Generate a 256-bit cryptographically-random URL-safe-base64
630/// token. Shared between session cookies and password-reset tokens
631/// (R1) — both want the same "random enough that brute force is
632/// infeasible regardless of any hash function's work factor"
633/// shape. `pub(crate)` so `auth::recovery` can call it without
634/// duplicating the helper; not in the public API.
635pub(crate) fn random_token() -> String {
636    let mut bytes = [0u8; 32];
637    rand::thread_rng().fill_bytes(&mut bytes);
638    URL_SAFE_NO_PAD.encode(bytes)
639}
640
641/// Hash a session-cookie token for at-rest storage in
642/// `rustio_sessions.token_hash`. SHA-256 of the URL-safe-base64
643/// plaintext, re-encoded as URL-safe-base64 (no padding) so the
644/// column accepts ASCII text.
645///
646/// SHA-256 is the right choice here (not Argon2): the input is a
647/// 256-bit random token, so brute force is infeasible regardless of
648/// the hash function's work factor; SHA-256 is fast enough to keep
649/// the session-lookup path under 1ms even at high RPS. Argon2 would
650/// add latency without security benefit for this input distribution.
651pub(crate) fn hash_token_for_storage(token: &str) -> String {
652    let digest = Sha256::digest(token.as_bytes());
653    URL_SAFE_NO_PAD.encode(digest)
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659
660    #[test]
661    fn extracts_token_from_cookie_header() {
662        let h = "foo=bar; rustio_session=abc123; other=x";
663        assert_eq!(session_token_from_cookie(h), Some("abc123".into()));
664    }
665
666    #[test]
667    fn returns_none_when_cookie_missing() {
668        let h = "foo=bar; other=x";
669        assert!(session_token_from_cookie(h).is_none());
670    }
671
672    #[test]
673    fn random_token_has_reasonable_entropy() {
674        // Rough sanity check — two consecutive tokens should differ.
675        assert_ne!(random_token(), random_token());
676    }
677
678    #[test]
679    fn hash_token_is_deterministic() {
680        // Same input → same hash, every call. Required for the
681        // identity_from_session lookup to find the row.
682        let token = random_token();
683        assert_eq!(
684            hash_token_for_storage(&token),
685            hash_token_for_storage(&token)
686        );
687    }
688
689    #[test]
690    fn hash_token_differs_per_token() {
691        // Different inputs → different hashes (collision-resistance is
692        // the point).
693        let a = hash_token_for_storage("aaaa");
694        let b = hash_token_for_storage("aaab");
695        assert_ne!(a, b);
696    }
697
698    #[test]
699    fn hash_token_output_is_url_safe_base64() {
700        let h = hash_token_for_storage("anything");
701        // 256 bits → 43 url-safe-no-pad base64 chars.
702        assert_eq!(h.len(), 43);
703        assert!(h
704            .chars()
705            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
706    }
707
708    #[test]
709    fn hash_token_does_not_leak_plaintext() {
710        // Property check — the hash output should bear no obvious
711        // resemblance to the plaintext, including substrings.
712        let plaintext = "secret-cookie-value-12345";
713        let h = hash_token_for_storage(plaintext);
714        assert!(!h.contains("secret"));
715        assert!(!h.contains("12345"));
716    }
717
718    // ---- typed session model ----
719
720    #[test]
721    fn session_trust_orders_correctly() {
722        assert!(SessionTrust::Authenticated.rank() < SessionTrust::Elevated.rank());
723        assert!(SessionTrust::Elevated.rank() < SessionTrust::MfaVerified.rank());
724        assert!(SessionTrust::MfaVerified.satisfies(SessionTrust::Elevated));
725        assert!(SessionTrust::MfaVerified.satisfies(SessionTrust::Authenticated));
726        assert!(SessionTrust::Authenticated.satisfies(SessionTrust::Authenticated));
727        assert!(!SessionTrust::Authenticated.satisfies(SessionTrust::Elevated));
728        assert!(!SessionTrust::Elevated.satisfies(SessionTrust::MfaVerified));
729    }
730
731    #[test]
732    fn session_trust_round_trips_through_sql() {
733        for tier in [
734            SessionTrust::Authenticated,
735            SessionTrust::Elevated,
736            SessionTrust::MfaVerified,
737        ] {
738            assert_eq!(SessionTrust::parse(tier.as_str()), tier);
739        }
740    }
741
742    #[test]
743    fn session_trust_parse_defaults_safely_on_unknown() {
744        // Unknown / malformed trust_level column → fall back to the
745        // weakest tier so a bad row can't accidentally elevate.
746        assert_eq!(SessionTrust::parse("garbage"), SessionTrust::Authenticated);
747        assert_eq!(SessionTrust::parse(""), SessionTrust::Authenticated);
748    }
749
750    #[test]
751    fn invalidation_reason_strings_are_distinct() {
752        // Property: as_str() values must be globally unique so audit
753        // rows are unambiguous.
754        let reasons = [
755            SessionInvalidationReason::Logout,
756            SessionInvalidationReason::Expired,
757            SessionInvalidationReason::UserRequested,
758            SessionInvalidationReason::AdministrativeRevoke,
759            SessionInvalidationReason::PasswordReset,
760            SessionInvalidationReason::PasswordResetByOther,
761            SessionInvalidationReason::MfaEnabled,
762            SessionInvalidationReason::MfaDisabled,
763            SessionInvalidationReason::MfaDisabledByOther,
764            SessionInvalidationReason::AuthorityEscalation,
765            SessionInvalidationReason::EmergencyRecovery,
766            SessionInvalidationReason::TrustEscalation,
767        ];
768        let mut set = std::collections::HashSet::new();
769        for r in reasons {
770            assert!(set.insert(r.as_str()), "duplicate as_str() for {r:?}");
771        }
772        assert_eq!(set.len(), reasons.len());
773    }
774}