Skip to main content

vti_common/auth/
session.rs

1use crate::error::AppError;
2use crate::store::KeyspaceHandle;
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::time::{SystemTime, UNIX_EPOCH};
6use tracing::debug;
7
8/// Session lifecycle state.
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
11pub enum SessionState {
12    ChallengeSent,
13    Authenticated,
14}
15
16/// A session record stored in fjall under `session:{session_id}`.
17///
18/// `Debug` is hand-written below to redact the `refresh_token`. The raw
19/// derive would render it inline — any `tracing::debug!("{session:?}")`,
20/// panic backtrace, or `dbg!()` call holding a `Session` would otherwise
21/// exfiltrate a bearer-equivalent secret to logs.
22#[derive(Clone, Serialize, Deserialize)]
23pub struct Session {
24    pub session_id: String,
25    pub did: String,
26    pub challenge: String,
27    pub state: SessionState,
28    pub created_at: u64,
29    /// Wall-clock epoch seconds of the most recent authenticated request on
30    /// this session. Intrinsic-sender (DIDComm/TSP) sessions carry no refresh
31    /// token, so this drives their idle-TTL expiry in
32    /// [`cleanup_expired_sessions`]. REST sessions set it too but are bounded
33    /// by `refresh_expires_at`. `#[serde(default)]` so rows written before this
34    /// field existed deserialise with `0`; the sweeper falls back to
35    /// `created_at` in that case.
36    #[serde(default)]
37    pub last_seen: u64,
38    pub refresh_token: Option<String>,
39    pub refresh_expires_at: Option<u64>,
40    /// Whether the **challenge issued for this session** was accompanied
41    /// by a successful TEE attestation. Distinct from "this VTA was built
42    /// with the TEE feature": a TEE binary running in `TeeMode::Optional`
43    /// can serve unattested challenges when the provider errors out, and
44    /// the resulting JWT must reflect that.
45    ///
46    /// `#[serde(default)]` so older session records (written before this
47    /// field existed) deserialize as `false` — the conservative default.
48    #[serde(default)]
49    pub tee_attested: bool,
50    /// AAL claims persisted across token rotation. Mirrors the JWT's
51    /// `amr` / `acr` so [`/auth/refresh`] mints a new access token at
52    /// the same authentication-method-references and assurance level
53    /// the session was last issued at. Without this, a session that
54    /// was step-upped to `aal2` would be silently dropped back to
55    /// `aal1` on every 15-minute refresh.
56    ///
57    /// `#[serde(default)]` on both: a session row written before this
58    /// field landed deserialises with empty vectors / empty string,
59    /// which the refresh handler treats as "unknown AAL — fall back
60    /// to `aal1`". Same behaviour as pre-migration; the holder can
61    /// re-step-up if needed.
62    #[serde(default)]
63    pub amr: Vec<String>,
64    #[serde(default)]
65    pub acr: String,
66    /// Epoch-seconds deadline after which a step-up-elevated `acr` lapses back
67    /// to `aal1`. Set when a step-up elevates the session; read by the
68    /// intrinsic-sender resolver ([`resolve_did_session`]), which downgrades on
69    /// read once the window closes. `None` for an un-elevated session — and, in
70    /// this phase, for REST sessions, whose short access-token TTL already
71    /// bounds elevation (a later phase wires REST into the same read-time
72    /// downgrade). `#[serde(default)]` for back-compat with pre-existing rows.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub acr_expires_at: Option<u64>,
75    /// JWT `jti` rotation pin. Set per-token-issue so old JWTs are
76    /// immediately invalidated when a new token is minted for the
77    /// same session — the `AuthClaims` extractor compares the JWT's
78    /// `jti` against this field and rejects mismatches.
79    ///
80    /// Optional because not every consumer uses per-token-issue
81    /// rotation; the canonical extractor checks this only when
82    /// `Some(_)`. `#[skip_serializing_if = "Option::is_none"]`
83    /// keeps the field out of the serialised form when unused so
84    /// existing storage rows do not gain a `token_id: null` column.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub token_id: Option<String>,
87    /// Ephemeral session pubkey for Data Integrity proof binding
88    /// (`eddsa-jcs-2022`). Ed25519 multikey, base58btc with the
89    /// `z` prefix (e.g. `z6MkfBwQrx…`). The corresponding
90    /// `did:key:<this>` is the verificationMethod the holder uses
91    /// when signing trust-task envelopes for this session.
92    ///
93    /// `None` for clients that did not register a session pubkey;
94    /// REQUIRED-spec dispatch then rejects proofless envelopes per
95    /// the trust-task framework's IS_PROOF_REQUIRED gate.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub session_pubkey_b58btc: Option<String>,
98}
99
100impl std::fmt::Debug for Session {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("Session")
103            .field("session_id", &self.session_id)
104            .field("did", &self.did)
105            .field("challenge", &"<redacted>")
106            .field("state", &self.state)
107            .field("created_at", &self.created_at)
108            .field("last_seen", &self.last_seen)
109            .field(
110                "refresh_token",
111                &self.refresh_token.as_ref().map(|_| "<redacted>"),
112            )
113            .field("refresh_expires_at", &self.refresh_expires_at)
114            .field("tee_attested", &self.tee_attested)
115            .field("amr", &self.amr)
116            .field("acr", &self.acr)
117            .field("acr_expires_at", &self.acr_expires_at)
118            .field("token_id", &self.token_id.as_ref().map(|_| "<redacted>"))
119            .field("session_pubkey_b58btc", &self.session_pubkey_b58btc)
120            .finish()
121    }
122}
123
124fn session_key(session_id: &str) -> String {
125    format!("session:{session_id}")
126}
127
128/// Key the refresh-token reverse-index by SHA-256 of the token rather
129/// than the token itself. An attacker with raw read access to the
130/// sessions keyspace (storage dump, vsock proxy compromise) sees only
131/// hashes, not live tokens. The lookup path hashes the presented token
132/// before probing the store.
133///
134/// Hash length (32 bytes → 64 hex chars) is fine for collision
135/// resistance; UUIDv4 refresh tokens have 122 bits of entropy, so
136/// pre-image resistance is what we rely on here, not second-preimage.
137fn refresh_key(token: &str) -> String {
138    let digest = Sha256::digest(token.as_bytes());
139    format!("refresh:{}", hex_lower(&digest))
140}
141
142fn hex_lower(bytes: &[u8]) -> String {
143    const TABLE: &[u8; 16] = b"0123456789abcdef";
144    let mut out = String::with_capacity(bytes.len() * 2);
145    for &b in bytes {
146        out.push(TABLE[(b >> 4) as usize] as char);
147        out.push(TABLE[(b & 0x0f) as usize] as char);
148    }
149    out
150}
151
152/// Store a new session in the `sessions` keyspace.
153pub async fn store_session(sessions: &KeyspaceHandle, session: &Session) -> Result<(), AppError> {
154    sessions
155        .insert(session_key(&session.session_id), session)
156        .await?;
157    debug!(session_id = %session.session_id, did = %session.did, "session stored");
158    Ok(())
159}
160
161/// Load a session by session_id.
162pub async fn get_session(
163    sessions: &KeyspaceHandle,
164    session_id: &str,
165) -> Result<Option<Session>, AppError> {
166    sessions.get(session_key(session_id)).await
167}
168
169/// Update an existing session (overwrites).
170pub async fn update_session(sessions: &KeyspaceHandle, session: &Session) -> Result<(), AppError> {
171    sessions
172        .insert(session_key(&session.session_id), session)
173        .await
174}
175
176/// Idle lifetime for an intrinsic-sender (DIDComm/TSP) session. Such a session
177/// carries no refresh token, so it is reaped this many seconds after its last
178/// authenticated request rather than at a refresh-token deadline. REST sessions
179/// are bounded by `refresh_expires_at` and ignore this.
180pub const INTRINSIC_SESSION_IDLE_TTL_SECS: u64 = 86_400; // 24h
181
182/// Resolve the canonical session for an intrinsic-sender (DIDComm/TSP) caller,
183/// creating it on first sight. Keyed on the authenticated `did` so the same
184/// identity resolves **one** persistent session across messages and transports.
185/// That persistence is what lets a step-up elevation performed while handling
186/// one message be observed by the caller's subsequent messages — the whole
187/// point of a transport-agnostic session.
188///
189/// Semantics:
190/// - **Absent** → create an `Authenticated`, `aal1` session (single `did`
191///   factor), stamped `created_at = last_seen = now`, no refresh token.
192/// - **Present** → bump `last_seen`; if a step-up elevation has lapsed
193///   (`acr_expires_at` now in the past) downgrade `acr` back to `aal1` and drop
194///   the elevated factors, so the caller must re-step-up.
195///
196/// Returns the session as the caller should be seen *now* (post-downgrade) and
197/// persists any mutation. The returned `acr`/`amr` are what the AAL-gating
198/// handlers must trust — not a hardcoded `aal1`.
199pub async fn resolve_did_session(
200    sessions: &KeyspaceHandle,
201    did: &str,
202    now: u64,
203) -> Result<Session, AppError> {
204    if let Some(mut session) = get_session(sessions, did).await? {
205        session.last_seen = now;
206        if let Some(deadline) = session.acr_expires_at
207            && now >= deadline
208        {
209            session.acr = "aal1".to_string();
210            session.acr_expires_at = None;
211            session.amr = vec!["did".to_string()];
212        }
213        update_session(sessions, &session).await?;
214        Ok(session)
215    } else {
216        let session = Session {
217            session_id: did.to_string(),
218            did: did.to_string(),
219            challenge: String::new(),
220            state: SessionState::Authenticated,
221            created_at: now,
222            last_seen: now,
223            refresh_token: None,
224            refresh_expires_at: None,
225            tee_attested: false,
226            amr: vec!["did".to_string()],
227            acr: "aal1".to_string(),
228            acr_expires_at: None,
229            token_id: None,
230            session_pubkey_b58btc: None,
231        };
232        store_session(sessions, &session).await?;
233        Ok(session)
234    }
235}
236
237/// Store a reverse index from refresh token to session_id.
238pub async fn store_refresh_index(
239    sessions: &KeyspaceHandle,
240    token: &str,
241    session_id: &str,
242) -> Result<(), AppError> {
243    sessions
244        .insert_raw(refresh_key(token), session_id.as_bytes().to_vec())
245        .await
246}
247
248/// Look up a session_id by refresh token.
249pub async fn get_session_by_refresh(
250    sessions: &KeyspaceHandle,
251    token: &str,
252) -> Result<Option<String>, AppError> {
253    match sessions.get_raw(refresh_key(token)).await? {
254        Some(bytes) => {
255            let session_id = String::from_utf8(bytes)
256                .map_err(|e| AppError::Internal(format!("invalid session_id bytes: {e}")))?;
257            Ok(Some(session_id))
258        }
259        None => Ok(None),
260    }
261}
262
263/// Delete a refresh-token reverse index entry. Used by the rotation
264/// path on `/auth/refresh` so a presented refresh token works exactly
265/// once — replay returns "refresh token not found", same as a stolen-
266/// then-revoked token.
267pub async fn delete_refresh_index(sessions: &KeyspaceHandle, token: &str) -> Result<(), AppError> {
268    sessions.remove(refresh_key(token)).await
269}
270
271/// Atomically claim-and-delete the `refresh_token → session_id`
272/// reverse index. The classic Redis-`GETDEL` shape — exactly one
273/// concurrent caller observes `Some`, even under retries.
274///
275/// Used by the canonical `/auth/refresh` handler to close the
276/// rotation TOCTOU: a leaked refresh token cannot be presented
277/// twice. On single-process fjall the atomicity comes from
278/// running both ops in one `blocking_with_timeout` closure; on
279/// the vsock backend the fallback is non-atomic
280/// (see [`crate::store::KeyspaceHandle::take_raw`]).
281pub async fn take_session_id_by_refresh(
282    sessions: &KeyspaceHandle,
283    token: &str,
284) -> Result<Option<String>, AppError> {
285    match sessions.take_raw(refresh_key(token)).await? {
286        Some(bytes) => {
287            let session_id = String::from_utf8(bytes)
288                .map_err(|e| AppError::Internal(format!("invalid session_id bytes: {e}")))?;
289            Ok(Some(session_id))
290        }
291        None => Ok(None),
292    }
293}
294
295/// Count `ChallengeSent` sessions belonging to `did`. The
296/// canonical `/auth/challenge` handler invokes this to enforce
297/// `AuthBackend::max_pending_challenges_per_did` and reject
298/// callers that try to exhaust the keyspace with a churn of
299/// pending challenges.
300///
301/// Default implementation is an O(N) prefix scan over `session:`.
302/// Backends with a per-DID tracker keyspace (like did-hosting's
303/// `pending_challenges:` index) can override the corresponding
304/// `SessionStore::count_pending_challenges` method to return O(1).
305/// Suitable for the current keyspace sizes vti-common consumers
306/// operate at; revisit when sessions cross five-figure cardinality.
307pub async fn count_pending_challenges(
308    sessions: &KeyspaceHandle,
309    did: &str,
310) -> Result<usize, AppError> {
311    let entries = sessions.prefix_iter_raw("session:").await?;
312    let mut count = 0usize;
313    for (_key, value) in entries {
314        if let Ok(s) = serde_json::from_slice::<Session>(&value)
315            && s.did == did
316            && s.state == SessionState::ChallengeSent
317        {
318            count += 1;
319        }
320    }
321    Ok(count)
322}
323
324/// Returns the current UNIX epoch timestamp in seconds.
325pub fn now_epoch() -> u64 {
326    SystemTime::now()
327        .duration_since(UNIX_EPOCH)
328        .unwrap()
329        .as_secs()
330}
331
332/// Delete a single session and its refresh index.
333pub async fn delete_session(sessions: &KeyspaceHandle, session_id: &str) -> Result<(), AppError> {
334    let session: Option<Session> = sessions.get(session_key(session_id)).await?;
335    if let Some(session) = session {
336        if let Some(ref token) = session.refresh_token {
337            sessions.remove(refresh_key(token)).await?;
338        }
339        sessions.remove(session_key(session_id)).await?;
340        debug!(session_id, "session deleted");
341    }
342    Ok(())
343}
344
345/// List all active sessions.
346pub async fn list_sessions(sessions: &KeyspaceHandle) -> Result<Vec<Session>, AppError> {
347    let raw = sessions.prefix_iter_raw("session:").await?;
348    let mut result = Vec::with_capacity(raw.len());
349    for (_key, value) in raw {
350        if let Ok(session) = serde_json::from_slice::<Session>(&value) {
351            result.push(session);
352        }
353    }
354    Ok(result)
355}
356
357/// Remove expired sessions from the store.
358///
359/// - `ChallengeSent` sessions expire after `challenge_ttl` seconds from `created_at`.
360/// - `Authenticated` **REST** sessions (UUID `session_id`) expire when
361///   `refresh_expires_at` has passed — unchanged.
362/// - `Authenticated` **intrinsic-sender** sessions (DIDComm/TSP), identified by
363///   `session_id == did`, expire after [`INTRINSIC_SESSION_IDLE_TTL_SECS`] of
364///   idle since `last_seen` (falling back to `created_at` for rows written
365///   before the `last_seen` field existed). Without this branch such a session
366///   — which has `refresh_expires_at == None` — would hit the REST rule and be
367///   swept on the very next pass.
368pub async fn cleanup_expired_sessions(
369    sessions: &KeyspaceHandle,
370    challenge_ttl: u64,
371) -> Result<(), AppError> {
372    let entries = sessions.prefix_iter_raw("session:").await?;
373    let now = now_epoch();
374    let mut removed = 0u64;
375    let mut live_sessions: std::collections::HashSet<String> =
376        std::collections::HashSet::with_capacity(entries.len());
377
378    for (key, value) in entries {
379        let session: Session = match serde_json::from_slice(&value) {
380            Ok(s) => s,
381            Err(_) => continue,
382        };
383
384        let expired = match session.state {
385            SessionState::ChallengeSent => now.saturating_sub(session.created_at) > challenge_ttl,
386            SessionState::Authenticated => {
387                if session.refresh_token.is_some() {
388                    // REST/JWT session (has a refresh token) — bounded by its
389                    // refresh-token deadline. Now that REST sessions are also
390                    // DID-keyed, the presence of a refresh token, not the key
391                    // shape, is what marks a JWT session.
392                    session
393                        .refresh_expires_at
394                        .is_none_or(|expires| now > expires)
395                } else if session.session_id == session.did {
396                    // Intrinsic-sender (DIDComm/TSP) canonical session — keyed on
397                    // the DID, no refresh token, reaped on idle. Fall back to
398                    // created_at for pre-migration rows whose last_seen is 0.
399                    let last = session.last_seen.max(session.created_at);
400                    now.saturating_sub(last) > INTRINSIC_SESSION_IDLE_TTL_SECS
401                } else {
402                    // Transient authenticated rows with neither a refresh token
403                    // nor a DID key (e.g. VTC cross-community recognise sessions)
404                    // — unchanged: expire on the next pass.
405                    session
406                        .refresh_expires_at
407                        .is_none_or(|expires| now > expires)
408                }
409            }
410        };
411
412        if expired {
413            sessions.remove(key).await?;
414            if let Some(ref token) = session.refresh_token {
415                sessions.remove(refresh_key(token)).await?;
416            }
417            removed += 1;
418        } else {
419            live_sessions.insert(session.session_id);
420        }
421    }
422
423    // GC orphan `nonce:{challenge}` index entries. `auth::challenge` writes
424    // these on every challenge issue but never deletes them, so without
425    // this sweep the keyspace grows unbounded over a long-running TEE.
426    // A nonce is orphan if its session record is gone (either expired-in-
427    // this-pass or already cleaned up). Decoding the value is safe because
428    // it's UTF-8 ASCII (the session_id) by construction.
429    let nonce_entries = sessions.prefix_iter_raw("nonce:").await?;
430    let mut nonce_removed = 0u64;
431    for (key, value) in nonce_entries {
432        let session_id = match std::str::from_utf8(&value) {
433            Ok(s) => s,
434            Err(_) => {
435                // Malformed; treat as orphan and clean up.
436                sessions.remove(key).await?;
437                nonce_removed += 1;
438                continue;
439            }
440        };
441        if !live_sessions.contains(session_id) {
442            sessions.remove(key).await?;
443            nonce_removed += 1;
444        }
445    }
446
447    debug!(
448        removed,
449        nonces_removed = nonce_removed,
450        "session cleanup complete"
451    );
452
453    Ok(())
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use crate::config::StoreConfig;
460    use crate::store::Store;
461
462    fn temp_sessions_ks() -> (KeyspaceHandle, tempfile::TempDir) {
463        let dir = tempfile::tempdir().expect("tempdir");
464        let config = StoreConfig {
465            data_dir: dir.path().to_path_buf(),
466        };
467        let store = Store::open(&config).expect("open store");
468        let ks = store.keyspace("sessions").expect("keyspace");
469        (ks, dir)
470    }
471
472    fn sample_session(session_id: &str, did: &str, state: SessionState) -> Session {
473        Session {
474            session_id: session_id.to_string(),
475            did: did.to_string(),
476            challenge: "test-challenge-hex".into(),
477            state,
478            created_at: now_epoch(),
479            last_seen: now_epoch(),
480            refresh_token: None,
481            refresh_expires_at: None,
482            tee_attested: false,
483            amr: Vec::new(),
484            acr: String::new(),
485            acr_expires_at: None,
486            token_id: None,
487            session_pubkey_b58btc: None,
488        }
489    }
490
491    #[test]
492    fn debug_redacts_refresh_token() {
493        // Regression: Session derives a manual Debug that must hide
494        // refresh_token. A `tracing::debug!("{session:?}")` in any code
495        // path holding a Session must not exfiltrate the bearer-
496        // equivalent secret.
497        let mut s = sample_session("sess-1", "did:key:zA", SessionState::Authenticated);
498        s.refresh_token = Some("super-secret-refresh-uuid".into());
499        let rendered = format!("{s:?}");
500        assert!(
501            !rendered.contains("super-secret-refresh-uuid"),
502            "raw refresh token must not appear in Debug output, got: {rendered}"
503        );
504        assert!(
505            rendered.contains("<redacted>"),
506            "expected redaction marker, got: {rendered}"
507        );
508    }
509
510    // ── Session key helpers ─────────────────────────────────────────
511
512    #[test]
513    fn session_key_is_prefixed_for_scan() {
514        assert_eq!(session_key("abc"), "session:abc");
515    }
516
517    #[test]
518    fn refresh_key_hashes_token_not_stores_raw() {
519        // S-7 invariant: a storage dump must not yield live refresh
520        // tokens. The reverse-index key is keyed by SHA-256 hex, not
521        // the raw token. Regressions that revert to raw-token keying
522        // leak credentials on any backup / memory dump.
523        let key = refresh_key("very-secret-uuid-v4-12345");
524        assert!(
525            key.starts_with("refresh:"),
526            "prefix must survive for prefix scans"
527        );
528        let hash_part = key.strip_prefix("refresh:").unwrap();
529        assert_eq!(
530            hash_part.len(),
531            64,
532            "SHA-256 as hex is 64 chars; got {hash_part}"
533        );
534        assert!(
535            !hash_part.contains("very-secret"),
536            "raw token must not appear in the index key — got {key}"
537        );
538
539        // Same input → same hash (deterministic lookup).
540        assert_eq!(refresh_key("very-secret-uuid-v4-12345"), key);
541        // Different input → different hash.
542        assert_ne!(refresh_key("other-token"), key);
543    }
544
545    // ── Store round-trip ────────────────────────────────────────────
546
547    #[tokio::test]
548    async fn store_and_load_session() {
549        let (ks, _dir) = temp_sessions_ks();
550        let session = sample_session("sess-1", "did:key:zA", SessionState::ChallengeSent);
551        store_session(&ks, &session).await.unwrap();
552
553        let loaded = get_session(&ks, "sess-1")
554            .await
555            .unwrap()
556            .expect("session must be present");
557        assert_eq!(loaded.session_id, "sess-1");
558        assert_eq!(loaded.did, "did:key:zA");
559        assert_eq!(loaded.state, SessionState::ChallengeSent);
560    }
561
562    #[tokio::test]
563    async fn get_session_returns_none_for_missing() {
564        let (ks, _dir) = temp_sessions_ks();
565        let result = get_session(&ks, "never-existed").await.unwrap();
566        assert!(result.is_none());
567    }
568
569    #[tokio::test]
570    async fn update_session_overwrites_state() {
571        let (ks, _dir) = temp_sessions_ks();
572        let mut session = sample_session("sess-1", "did:key:zA", SessionState::ChallengeSent);
573        store_session(&ks, &session).await.unwrap();
574
575        session.state = SessionState::Authenticated;
576        update_session(&ks, &session).await.unwrap();
577
578        let loaded = get_session(&ks, "sess-1").await.unwrap().unwrap();
579        assert_eq!(loaded.state, SessionState::Authenticated);
580    }
581
582    // ── Refresh-token index ─────────────────────────────────────────
583
584    #[tokio::test]
585    async fn refresh_index_lookup_round_trip() {
586        let (ks, _dir) = temp_sessions_ks();
587        store_refresh_index(&ks, "refresh-token-abc", "sess-1")
588            .await
589            .unwrap();
590
591        let session_id = get_session_by_refresh(&ks, "refresh-token-abc")
592            .await
593            .unwrap()
594            .expect("refresh token must resolve to session id");
595        assert_eq!(session_id, "sess-1");
596    }
597
598    #[tokio::test]
599    async fn refresh_index_returns_none_for_unknown_token() {
600        let (ks, _dir) = temp_sessions_ks();
601        let result = get_session_by_refresh(&ks, "bogus-token").await.unwrap();
602        assert!(result.is_none());
603    }
604
605    #[tokio::test]
606    async fn delete_refresh_index_removes_only_the_named_token() {
607        // Rotation invariant: deleting a presented refresh token's
608        // index must not affect any other live tokens. Two sessions
609        // with separate tokens — deleting one leaves the other usable.
610        let (ks, _dir) = temp_sessions_ks();
611        store_refresh_index(&ks, "token-a", "sess-a").await.unwrap();
612        store_refresh_index(&ks, "token-b", "sess-b").await.unwrap();
613
614        delete_refresh_index(&ks, "token-a").await.unwrap();
615
616        assert!(
617            get_session_by_refresh(&ks, "token-a")
618                .await
619                .unwrap()
620                .is_none(),
621            "deleted token must no longer resolve"
622        );
623        assert_eq!(
624            get_session_by_refresh(&ks, "token-b")
625                .await
626                .unwrap()
627                .as_deref(),
628            Some("sess-b"),
629            "untouched token must still resolve"
630        );
631    }
632
633    #[tokio::test]
634    async fn delete_refresh_index_is_idempotent() {
635        // Deleting a token that was never stored — and deleting twice —
636        // must succeed silently. The rotation path calls delete on the
637        // presented token after writing the new index; a double-call
638        // (e.g. retry after partial failure) must not error.
639        let (ks, _dir) = temp_sessions_ks();
640        delete_refresh_index(&ks, "never-existed").await.unwrap();
641
642        store_refresh_index(&ks, "once", "sess-x").await.unwrap();
643        delete_refresh_index(&ks, "once").await.unwrap();
644        delete_refresh_index(&ks, "once").await.unwrap();
645    }
646
647    #[tokio::test]
648    async fn refresh_index_is_keyed_by_hash_not_raw_token() {
649        // Integration-level assertion of S-7: the stored key contains
650        // the hash, not the raw token. A `prefix_iter_raw("refresh:")`
651        // on a compromised store must not yield a usable token.
652        let (ks, _dir) = temp_sessions_ks();
653        store_refresh_index(&ks, "super-secret-token-value", "sess-xyz")
654            .await
655            .unwrap();
656
657        let all: Vec<_> = ks.prefix_iter_raw("refresh:").await.unwrap();
658        assert_eq!(all.len(), 1, "exactly one refresh index entry");
659        let (key_bytes, _value_bytes) = &all[0];
660        let key = String::from_utf8_lossy(key_bytes);
661        assert!(
662            !key.contains("super-secret-token-value"),
663            "raw token must not appear in stored key — got {key}"
664        );
665    }
666
667    // ── Delete ──────────────────────────────────────────────────────
668
669    #[tokio::test]
670    async fn delete_session_removes_session_and_refresh_index() {
671        let (ks, _dir) = temp_sessions_ks();
672        let mut session = sample_session("sess-1", "did:key:zA", SessionState::Authenticated);
673        session.refresh_token = Some("refresh-token-abc".into());
674        session.refresh_expires_at = Some(now_epoch() + 86400);
675        store_session(&ks, &session).await.unwrap();
676        store_refresh_index(&ks, "refresh-token-abc", "sess-1")
677            .await
678            .unwrap();
679
680        delete_session(&ks, "sess-1").await.unwrap();
681
682        assert!(get_session(&ks, "sess-1").await.unwrap().is_none());
683        assert!(
684            get_session_by_refresh(&ks, "refresh-token-abc")
685                .await
686                .unwrap()
687                .is_none(),
688            "refresh-index entry must be removed alongside the session"
689        );
690    }
691
692    #[tokio::test]
693    async fn delete_missing_session_is_a_noop() {
694        let (ks, _dir) = temp_sessions_ks();
695        // No session with this id; delete must succeed silently.
696        delete_session(&ks, "never-existed")
697            .await
698            .expect("delete of missing session must not error");
699    }
700
701    // ── List ────────────────────────────────────────────────────────
702
703    #[tokio::test]
704    async fn list_sessions_returns_all_records() {
705        let (ks, _dir) = temp_sessions_ks();
706        for i in 0..3 {
707            let session = sample_session(
708                &format!("sess-{i}"),
709                &format!("did:key:z{i}"),
710                SessionState::Authenticated,
711            );
712            store_session(&ks, &session).await.unwrap();
713        }
714
715        let listed = list_sessions(&ks).await.unwrap();
716        assert_eq!(listed.len(), 3);
717    }
718
719    #[tokio::test]
720    async fn list_sessions_ignores_refresh_index_entries() {
721        // Both session:... and refresh:... share the keyspace. The
722        // "session:" prefix scan must not pull refresh entries into
723        // the listing, or the JSON decode would silently skip them
724        // (fine) but an off-by-one in the prefix would break the scan.
725        let (ks, _dir) = temp_sessions_ks();
726        store_session(
727            &ks,
728            &sample_session("sess-1", "did:key:zA", SessionState::Authenticated),
729        )
730        .await
731        .unwrap();
732        store_refresh_index(&ks, "refresh-token-1", "sess-1")
733            .await
734            .unwrap();
735
736        let listed = list_sessions(&ks).await.unwrap();
737        assert_eq!(listed.len(), 1, "only the session entry should appear");
738        assert_eq!(listed[0].session_id, "sess-1");
739    }
740
741    // ── Cleanup ─────────────────────────────────────────────────────
742
743    #[tokio::test]
744    async fn cleanup_removes_challenge_sent_past_ttl() {
745        let (ks, _dir) = temp_sessions_ks();
746        let challenge_ttl = 300u64;
747
748        let mut expired = sample_session("sess-stale", "did:key:zA", SessionState::ChallengeSent);
749        expired.created_at = now_epoch().saturating_sub(challenge_ttl + 60);
750        store_session(&ks, &expired).await.unwrap();
751
752        let mut fresh = sample_session("sess-fresh", "did:key:zB", SessionState::ChallengeSent);
753        fresh.created_at = now_epoch();
754        store_session(&ks, &fresh).await.unwrap();
755
756        cleanup_expired_sessions(&ks, challenge_ttl).await.unwrap();
757
758        assert!(
759            get_session(&ks, "sess-stale").await.unwrap().is_none(),
760            "stale ChallengeSent session must be removed"
761        );
762        assert!(
763            get_session(&ks, "sess-fresh").await.unwrap().is_some(),
764            "fresh ChallengeSent session must remain"
765        );
766    }
767
768    #[tokio::test]
769    async fn cleanup_removes_authenticated_past_refresh_expiry() {
770        let (ks, _dir) = temp_sessions_ks();
771
772        let mut expired = sample_session("sess-expired", "did:key:zA", SessionState::Authenticated);
773        expired.refresh_token = Some("expired-token".into());
774        expired.refresh_expires_at = Some(now_epoch().saturating_sub(10));
775        store_session(&ks, &expired).await.unwrap();
776        store_refresh_index(&ks, "expired-token", "sess-expired")
777            .await
778            .unwrap();
779
780        cleanup_expired_sessions(&ks, 300).await.unwrap();
781
782        assert!(
783            get_session(&ks, "sess-expired").await.unwrap().is_none(),
784            "expired Authenticated session must be removed"
785        );
786        assert!(
787            get_session_by_refresh(&ks, "expired-token")
788                .await
789                .unwrap()
790                .is_none(),
791            "refresh index must be cleaned up alongside the session"
792        );
793    }
794
795    #[tokio::test]
796    async fn cleanup_removes_authenticated_with_no_refresh_expiry() {
797        // A defensive invariant: Authenticated sessions without a
798        // refresh_expires_at should be treated as expired (the None
799        // branch uses `is_none_or` which returns true). This prevents
800        // a buggy code path from leaving sessions that never expire.
801        let (ks, _dir) = temp_sessions_ks();
802        let mut odd = sample_session("sess-odd", "did:key:zA", SessionState::Authenticated);
803        odd.refresh_token = Some("odd-token".into());
804        odd.refresh_expires_at = None;
805        store_session(&ks, &odd).await.unwrap();
806
807        cleanup_expired_sessions(&ks, 300).await.unwrap();
808
809        assert!(
810            get_session(&ks, "sess-odd").await.unwrap().is_none(),
811            "Authenticated session with no expiry must be garbage-collected"
812        );
813    }
814
815    #[tokio::test]
816    async fn cleanup_gc_orphan_nonce_indices() {
817        // Regression: `auth::challenge` writes `nonce:{challenge}` →
818        // `session_id` reverse indexes but never deletes them. Without
819        // this sweep, the keyspace grows linearly with every challenge
820        // ever issued — significant in a long-running TEE.
821        let (ks, _dir) = temp_sessions_ks();
822
823        // Live session: `nonce:` index for it must survive.
824        let live = sample_session("sess-live", "did:key:zA", SessionState::ChallengeSent);
825        store_session(&ks, &live).await.unwrap();
826        ks.insert_raw("nonce:live-challenge".to_string(), b"sess-live".to_vec())
827            .await
828            .unwrap();
829
830        // Orphan: nonce points at a session_id that doesn't exist.
831        ks.insert_raw(
832            "nonce:orphan-challenge".to_string(),
833            b"sess-vanished".to_vec(),
834        )
835        .await
836        .unwrap();
837
838        // Stale challenge: session past TTL — its nonce should be cleaned
839        // up alongside the session itself.
840        let mut stale = sample_session("sess-stale", "did:key:zB", SessionState::ChallengeSent);
841        stale.created_at = now_epoch().saturating_sub(3600);
842        store_session(&ks, &stale).await.unwrap();
843        ks.insert_raw("nonce:stale-challenge".to_string(), b"sess-stale".to_vec())
844            .await
845            .unwrap();
846
847        cleanup_expired_sessions(&ks, 300).await.unwrap();
848
849        let nonces = ks.prefix_iter_raw("nonce:").await.unwrap();
850        let nonce_keys: Vec<String> = nonces
851            .iter()
852            .map(|(k, _)| String::from_utf8_lossy(k).into_owned())
853            .collect();
854
855        assert!(
856            nonce_keys.iter().any(|k| k == "nonce:live-challenge"),
857            "live session's nonce must survive — got {nonce_keys:?}"
858        );
859        assert!(
860            !nonce_keys.iter().any(|k| k == "nonce:orphan-challenge"),
861            "orphan nonce must be removed — got {nonce_keys:?}"
862        );
863        assert!(
864            !nonce_keys.iter().any(|k| k == "nonce:stale-challenge"),
865            "stale-session nonce must be removed — got {nonce_keys:?}"
866        );
867    }
868
869    #[tokio::test]
870    async fn cleanup_preserves_active_authenticated_session() {
871        let (ks, _dir) = temp_sessions_ks();
872        let mut active = sample_session("sess-live", "did:key:zA", SessionState::Authenticated);
873        active.refresh_token = Some("live-token".into());
874        active.refresh_expires_at = Some(now_epoch() + 86400);
875        store_session(&ks, &active).await.unwrap();
876        store_refresh_index(&ks, "live-token", "sess-live")
877            .await
878            .unwrap();
879
880        cleanup_expired_sessions(&ks, 300).await.unwrap();
881
882        let loaded = get_session(&ks, "sess-live").await.unwrap();
883        assert!(loaded.is_some(), "live session must not be cleaned up");
884    }
885
886    // ── resolve_did_session (intrinsic-sender / DIDComm-TSP) ─────────
887
888    #[tokio::test]
889    async fn resolve_did_session_creates_aal1_keyed_on_did() {
890        let (ks, _dir) = temp_sessions_ks();
891        let did = "did:key:zResolveNew";
892        let now = now_epoch();
893        let s = resolve_did_session(&ks, did, now).await.unwrap();
894        assert_eq!(s.session_id, did, "canonical session_id is the DID itself");
895        assert_eq!(s.did, did);
896        assert_eq!(s.state, SessionState::Authenticated);
897        assert_eq!(s.acr, "aal1");
898        assert_eq!(s.amr, vec!["did".to_string()]);
899        assert!(
900            s.refresh_token.is_none(),
901            "intrinsic session has no refresh token"
902        );
903        assert_eq!(s.last_seen, now);
904        // Persisted under session:{did}: a second resolve reads the same row.
905        assert!(get_session(&ks, did).await.unwrap().is_some());
906    }
907
908    #[tokio::test]
909    async fn resolve_did_session_reports_elevation_within_window() {
910        let (ks, _dir) = temp_sessions_ks();
911        let did = "did:key:zResolveElevated";
912        let now = now_epoch();
913        // Create, then elevate the row exactly as the step-up handler does.
914        let mut s = resolve_did_session(&ks, did, now).await.unwrap();
915        s.acr = "aal2".into();
916        s.amr.push("did-signed".into());
917        s.acr_expires_at = Some(now + 900);
918        update_session(&ks, &s).await.unwrap();
919        // A later message (within the window) observes the elevation — the
920        // property that makes intrinsic-sender step-up take effect at all.
921        let seen = resolve_did_session(&ks, did, now + 60).await.unwrap();
922        assert_eq!(seen.acr, "aal2");
923        assert!(seen.amr.iter().any(|m| m == "did-signed"));
924    }
925
926    #[tokio::test]
927    async fn resolve_did_session_downgrades_after_window() {
928        let (ks, _dir) = temp_sessions_ks();
929        let did = "did:key:zResolveLapsed";
930        let now = now_epoch();
931        let mut s = resolve_did_session(&ks, did, now).await.unwrap();
932        s.acr = "aal2".into();
933        s.amr = vec!["did".into(), "did-signed".into()];
934        s.acr_expires_at = Some(now + 900);
935        update_session(&ks, &s).await.unwrap();
936        // Past the deadline → downgraded to aal1, factors reset, downgrade
937        // persisted so a single approval can't grant permanent aal2.
938        let seen = resolve_did_session(&ks, did, now + 901).await.unwrap();
939        assert_eq!(seen.acr, "aal1");
940        assert_eq!(seen.acr_expires_at, None);
941        assert_eq!(seen.amr, vec!["did".to_string()]);
942        let stored = get_session(&ks, did).await.unwrap().unwrap();
943        assert_eq!(stored.acr, "aal1");
944        assert_eq!(stored.acr_expires_at, None);
945    }
946
947    #[tokio::test]
948    async fn intrinsic_session_reaped_only_after_idle_ttl() {
949        let (ks, _dir) = temp_sessions_ks();
950        let did = "did:key:zIdle";
951        let mut s = resolve_did_session(&ks, did, now_epoch()).await.unwrap();
952        // Fresh: survives a sweep (session_id == did → idle-TTL branch).
953        cleanup_expired_sessions(&ks, 60).await.unwrap();
954        assert!(
955            get_session(&ks, did).await.unwrap().is_some(),
956            "a just-seen intrinsic session must not be reaped"
957        );
958        // Idle beyond the TTL: reaped.
959        s.last_seen = now_epoch().saturating_sub(INTRINSIC_SESSION_IDLE_TTL_SECS + 10);
960        s.created_at = s.last_seen;
961        update_session(&ks, &s).await.unwrap();
962        cleanup_expired_sessions(&ks, 60).await.unwrap();
963        assert!(
964            get_session(&ks, did).await.unwrap().is_none(),
965            "an idle intrinsic session must be reaped"
966        );
967    }
968
969    #[tokio::test]
970    async fn rest_session_sweep_rule_unchanged_for_uuid_sessions() {
971        let (ks, _dir) = temp_sessions_ks();
972        // UUID session_id != did → REST branch, bounded by refresh deadline.
973        let mut live = sample_session("uuid-live", "did:key:zRest", SessionState::Authenticated);
974        live.refresh_token = Some("rt-live".into());
975        live.refresh_expires_at = Some(now_epoch() + 3600);
976        store_session(&ks, &live).await.unwrap();
977        let mut dead = sample_session("uuid-dead", "did:key:zRest", SessionState::Authenticated);
978        dead.refresh_token = Some("rt-dead".into());
979        dead.refresh_expires_at = Some(now_epoch().saturating_sub(10));
980        store_session(&ks, &dead).await.unwrap();
981        cleanup_expired_sessions(&ks, 60).await.unwrap();
982        assert!(get_session(&ks, "uuid-live").await.unwrap().is_some());
983        assert!(get_session(&ks, "uuid-dead").await.unwrap().is_none());
984    }
985
986    // ── now_epoch ───────────────────────────────────────────────────
987
988    #[test]
989    fn now_epoch_is_monotonic() {
990        // Guard against the fallback path (0 on clock < UNIX_EPOCH)
991        // silently returning without the test noticing. If this test
992        // fires on a machine with a broken clock, the fallback is
993        // doing its job — rerun on a sane host.
994        let t = now_epoch();
995        assert!(t > 1_700_000_000, "epoch should be post-2023; got {t}");
996    }
997}