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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
11pub enum SessionState {
12 ChallengeSent,
13 Authenticated,
14}
15
16#[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 #[serde(default)]
37 pub last_seen: u64,
38 pub refresh_token: Option<String>,
39 pub refresh_expires_at: Option<u64>,
40 #[serde(default)]
49 pub tee_attested: bool,
50 #[serde(default)]
63 pub amr: Vec<String>,
64 #[serde(default)]
65 pub acr: String,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub acr_expires_at: Option<u64>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub token_id: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub session_pubkey_b58btc: Option<String>,
112}
113
114impl Session {
115 pub fn elevation_active(&self, now: u64) -> bool {
127 self.acr_expires_at.is_some_and(|deadline| now < deadline)
128 }
129
130 pub fn downgrade_lapsed_elevation(&mut self, now: u64) -> bool {
142 match self.acr_expires_at {
143 Some(deadline) if now >= deadline => {
144 self.acr = "aal1".to_string();
145 self.acr_expires_at = None;
146 self.amr = vec!["did".to_string()];
147 true
148 }
149 _ => false,
150 }
151 }
152}
153
154impl std::fmt::Debug for Session {
155 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156 f.debug_struct("Session")
157 .field("session_id", &self.session_id)
158 .field("did", &self.did)
159 .field("challenge", &"<redacted>")
160 .field("state", &self.state)
161 .field("created_at", &self.created_at)
162 .field("last_seen", &self.last_seen)
163 .field(
164 "refresh_token",
165 &self.refresh_token.as_ref().map(|_| "<redacted>"),
166 )
167 .field("refresh_expires_at", &self.refresh_expires_at)
168 .field("tee_attested", &self.tee_attested)
169 .field("amr", &self.amr)
170 .field("acr", &self.acr)
171 .field("acr_expires_at", &self.acr_expires_at)
172 .field("token_id", &self.token_id.as_ref().map(|_| "<redacted>"))
173 .field("session_pubkey_b58btc", &self.session_pubkey_b58btc)
174 .finish()
175 }
176}
177
178fn session_key(session_id: &str) -> String {
179 format!("session:{session_id}")
180}
181
182fn refresh_key(token: &str) -> String {
192 let digest = Sha256::digest(token.as_bytes());
193 format!("refresh:{}", hex_lower(&digest))
194}
195
196fn hex_lower(bytes: &[u8]) -> String {
197 const TABLE: &[u8; 16] = b"0123456789abcdef";
198 let mut out = String::with_capacity(bytes.len() * 2);
199 for &b in bytes {
200 out.push(TABLE[(b >> 4) as usize] as char);
201 out.push(TABLE[(b & 0x0f) as usize] as char);
202 }
203 out
204}
205
206pub async fn store_session(sessions: &KeyspaceHandle, session: &Session) -> Result<(), AppError> {
208 sessions
209 .insert(session_key(&session.session_id), session)
210 .await?;
211 debug!(session_id = %session.session_id, did = %session.did, "session stored");
212 Ok(())
213}
214
215pub async fn get_session(
217 sessions: &KeyspaceHandle,
218 session_id: &str,
219) -> Result<Option<Session>, AppError> {
220 sessions.get(session_key(session_id)).await
221}
222
223pub async fn update_session(sessions: &KeyspaceHandle, session: &Session) -> Result<(), AppError> {
225 sessions
226 .insert(session_key(&session.session_id), session)
227 .await
228}
229
230pub const INTRINSIC_SESSION_IDLE_TTL_SECS: u64 = 86_400; pub async fn resolve_did_session(
254 sessions: &KeyspaceHandle,
255 did: &str,
256 now: u64,
257) -> Result<Session, AppError> {
258 if let Some(mut session) = get_session(sessions, did).await? {
259 session.last_seen = now;
260 session.downgrade_lapsed_elevation(now);
261 update_session(sessions, &session).await?;
262 Ok(session)
263 } else {
264 let session = Session {
265 session_id: did.to_string(),
266 did: did.to_string(),
267 challenge: String::new(),
268 state: SessionState::Authenticated,
269 created_at: now,
270 last_seen: now,
271 refresh_token: None,
272 refresh_expires_at: None,
273 tee_attested: false,
274 amr: vec!["did".to_string()],
275 acr: "aal1".to_string(),
276 acr_expires_at: None,
277 token_id: None,
278 session_pubkey_b58btc: None,
279 };
280 store_session(sessions, &session).await?;
281 Ok(session)
282 }
283}
284
285pub async fn store_refresh_index(
287 sessions: &KeyspaceHandle,
288 token: &str,
289 session_id: &str,
290) -> Result<(), AppError> {
291 sessions
292 .insert_raw(refresh_key(token), session_id.as_bytes().to_vec())
293 .await
294}
295
296pub async fn get_session_by_refresh(
298 sessions: &KeyspaceHandle,
299 token: &str,
300) -> Result<Option<String>, AppError> {
301 match sessions.get_raw(refresh_key(token)).await? {
302 Some(bytes) => {
303 let session_id = String::from_utf8(bytes)
304 .map_err(|e| AppError::Internal(format!("invalid session_id bytes: {e}")))?;
305 Ok(Some(session_id))
306 }
307 None => Ok(None),
308 }
309}
310
311pub async fn delete_refresh_index(sessions: &KeyspaceHandle, token: &str) -> Result<(), AppError> {
316 sessions.remove(refresh_key(token)).await
317}
318
319pub async fn take_session_id_by_refresh(
330 sessions: &KeyspaceHandle,
331 token: &str,
332) -> Result<Option<String>, AppError> {
333 match sessions.take_raw(refresh_key(token)).await? {
334 Some(bytes) => {
335 let session_id = String::from_utf8(bytes)
336 .map_err(|e| AppError::Internal(format!("invalid session_id bytes: {e}")))?;
337 Ok(Some(session_id))
338 }
339 None => Ok(None),
340 }
341}
342
343pub async fn count_pending_challenges(
356 sessions: &KeyspaceHandle,
357 did: &str,
358) -> Result<usize, AppError> {
359 let entries = sessions.prefix_iter_raw("session:").await?;
360 let mut count = 0usize;
361 for (_key, value) in entries {
362 if let Ok(s) = serde_json::from_slice::<Session>(&value)
363 && s.did == did
364 && s.state == SessionState::ChallengeSent
365 {
366 count += 1;
367 }
368 }
369 Ok(count)
370}
371
372pub fn now_epoch() -> u64 {
374 SystemTime::now()
375 .duration_since(UNIX_EPOCH)
376 .unwrap()
377 .as_secs()
378}
379
380pub async fn delete_session(sessions: &KeyspaceHandle, session_id: &str) -> Result<(), AppError> {
382 let session: Option<Session> = sessions.get(session_key(session_id)).await?;
383 if let Some(session) = session {
384 if let Some(ref token) = session.refresh_token {
385 sessions.remove(refresh_key(token)).await?;
386 }
387 sessions.remove(session_key(session_id)).await?;
388 debug!(session_id, "session deleted");
389 }
390 Ok(())
391}
392
393pub async fn list_sessions(sessions: &KeyspaceHandle) -> Result<Vec<Session>, AppError> {
395 let raw = sessions.prefix_iter_raw("session:").await?;
396 let mut result = Vec::with_capacity(raw.len());
397 for (_key, value) in raw {
398 if let Ok(session) = serde_json::from_slice::<Session>(&value) {
399 result.push(session);
400 }
401 }
402 Ok(result)
403}
404
405pub async fn cleanup_expired_sessions(
417 sessions: &KeyspaceHandle,
418 challenge_ttl: u64,
419) -> Result<(), AppError> {
420 let entries = sessions.prefix_iter_raw("session:").await?;
421 let now = now_epoch();
422 let mut removed = 0u64;
423 let mut live_sessions: std::collections::HashSet<String> =
424 std::collections::HashSet::with_capacity(entries.len());
425
426 for (key, value) in entries {
427 let session: Session = match serde_json::from_slice(&value) {
428 Ok(s) => s,
429 Err(_) => continue,
430 };
431
432 let expired = match session.state {
433 SessionState::ChallengeSent => now.saturating_sub(session.created_at) > challenge_ttl,
434 SessionState::Authenticated => {
435 if session.refresh_token.is_some() {
436 session
441 .refresh_expires_at
442 .is_none_or(|expires| now > expires)
443 } else if session.session_id == session.did {
444 let last = session.last_seen.max(session.created_at);
448 now.saturating_sub(last) > INTRINSIC_SESSION_IDLE_TTL_SECS
449 } else {
450 session
454 .refresh_expires_at
455 .is_none_or(|expires| now > expires)
456 }
457 }
458 };
459
460 if expired {
461 sessions.remove(key).await?;
462 if let Some(ref token) = session.refresh_token {
463 sessions.remove(refresh_key(token)).await?;
464 }
465 removed += 1;
466 } else {
467 live_sessions.insert(session.session_id);
468 }
469 }
470
471 let nonce_entries = sessions.prefix_iter_raw("nonce:").await?;
478 let mut nonce_removed = 0u64;
479 for (key, value) in nonce_entries {
480 let session_id = match std::str::from_utf8(&value) {
481 Ok(s) => s,
482 Err(_) => {
483 sessions.remove(key).await?;
485 nonce_removed += 1;
486 continue;
487 }
488 };
489 if !live_sessions.contains(session_id) {
490 sessions.remove(key).await?;
491 nonce_removed += 1;
492 }
493 }
494
495 debug!(
496 removed,
497 nonces_removed = nonce_removed,
498 "session cleanup complete"
499 );
500
501 Ok(())
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::config::StoreConfig;
508 use crate::store::Store;
509
510 fn temp_sessions_ks() -> (KeyspaceHandle, tempfile::TempDir) {
511 let dir = tempfile::tempdir().expect("tempdir");
512 let config = StoreConfig {
513 data_dir: dir.path().to_path_buf(),
514 };
515 let store = Store::open(&config).expect("open store");
516 let ks = store.keyspace("sessions").expect("keyspace");
517 (ks, dir)
518 }
519
520 fn sample_session(session_id: &str, did: &str, state: SessionState) -> Session {
521 Session {
522 session_id: session_id.to_string(),
523 did: did.to_string(),
524 challenge: "test-challenge-hex".into(),
525 state,
526 created_at: now_epoch(),
527 last_seen: now_epoch(),
528 refresh_token: None,
529 refresh_expires_at: None,
530 tee_attested: false,
531 amr: Vec::new(),
532 acr: String::new(),
533 acr_expires_at: None,
534 token_id: None,
535 session_pubkey_b58btc: None,
536 }
537 }
538
539 #[test]
540 fn debug_redacts_refresh_token() {
541 let mut s = sample_session("sess-1", "did:key:zA", SessionState::Authenticated);
546 s.refresh_token = Some("super-secret-refresh-uuid".into());
547 let rendered = format!("{s:?}");
548 assert!(
549 !rendered.contains("super-secret-refresh-uuid"),
550 "raw refresh token must not appear in Debug output, got: {rendered}"
551 );
552 assert!(
553 rendered.contains("<redacted>"),
554 "expected redaction marker, got: {rendered}"
555 );
556 }
557
558 #[test]
561 fn elevation_is_active_only_inside_the_window() {
562 let now = now_epoch();
563 let mut s = sample_session("sess-1", "did:key:zA", SessionState::Authenticated);
564
565 s.acr = "aal2".into();
569 assert!(!s.elevation_active(now), "absent deadline is not elevation");
570
571 s.acr_expires_at = Some(now + 900);
572 assert!(s.elevation_active(now));
573 assert!(s.elevation_active(now + 899));
574
575 assert!(!s.elevation_active(now + 900));
578 assert!(!s.elevation_active(now + 901));
579 }
580
581 #[test]
582 fn downgrade_lapsed_elevation_resets_only_once_lapsed() {
583 let now = now_epoch();
584 let mut s = sample_session("sess-1", "did:key:zA", SessionState::Authenticated);
585 s.acr = "aal2".into();
586 s.amr = vec!["did".into(), "passkey".into()];
587 s.acr_expires_at = Some(now + 900);
588
589 assert!(
590 !s.downgrade_lapsed_elevation(now),
591 "still inside the window"
592 );
593 assert_eq!(s.acr, "aal2");
594
595 assert!(s.downgrade_lapsed_elevation(now + 900), "window closed");
596 assert_eq!(s.acr, "aal1");
597 assert_eq!(s.acr_expires_at, None);
598 assert_eq!(s.amr, vec!["did".to_string()]);
599
600 assert!(!s.downgrade_lapsed_elevation(now + 1800));
602 }
603
604 #[test]
607 fn session_key_is_prefixed_for_scan() {
608 assert_eq!(session_key("abc"), "session:abc");
609 }
610
611 #[test]
612 fn refresh_key_hashes_token_not_stores_raw() {
613 let key = refresh_key("very-secret-uuid-v4-12345");
618 assert!(
619 key.starts_with("refresh:"),
620 "prefix must survive for prefix scans"
621 );
622 let hash_part = key.strip_prefix("refresh:").unwrap();
623 assert_eq!(
624 hash_part.len(),
625 64,
626 "SHA-256 as hex is 64 chars; got {hash_part}"
627 );
628 assert!(
629 !hash_part.contains("very-secret"),
630 "raw token must not appear in the index key — got {key}"
631 );
632
633 assert_eq!(refresh_key("very-secret-uuid-v4-12345"), key);
635 assert_ne!(refresh_key("other-token"), key);
637 }
638
639 #[tokio::test]
642 async fn store_and_load_session() {
643 let (ks, _dir) = temp_sessions_ks();
644 let session = sample_session("sess-1", "did:key:zA", SessionState::ChallengeSent);
645 store_session(&ks, &session).await.unwrap();
646
647 let loaded = get_session(&ks, "sess-1")
648 .await
649 .unwrap()
650 .expect("session must be present");
651 assert_eq!(loaded.session_id, "sess-1");
652 assert_eq!(loaded.did, "did:key:zA");
653 assert_eq!(loaded.state, SessionState::ChallengeSent);
654 }
655
656 #[tokio::test]
657 async fn get_session_returns_none_for_missing() {
658 let (ks, _dir) = temp_sessions_ks();
659 let result = get_session(&ks, "never-existed").await.unwrap();
660 assert!(result.is_none());
661 }
662
663 #[tokio::test]
664 async fn update_session_overwrites_state() {
665 let (ks, _dir) = temp_sessions_ks();
666 let mut session = sample_session("sess-1", "did:key:zA", SessionState::ChallengeSent);
667 store_session(&ks, &session).await.unwrap();
668
669 session.state = SessionState::Authenticated;
670 update_session(&ks, &session).await.unwrap();
671
672 let loaded = get_session(&ks, "sess-1").await.unwrap().unwrap();
673 assert_eq!(loaded.state, SessionState::Authenticated);
674 }
675
676 #[tokio::test]
679 async fn refresh_index_lookup_round_trip() {
680 let (ks, _dir) = temp_sessions_ks();
681 store_refresh_index(&ks, "refresh-token-abc", "sess-1")
682 .await
683 .unwrap();
684
685 let session_id = get_session_by_refresh(&ks, "refresh-token-abc")
686 .await
687 .unwrap()
688 .expect("refresh token must resolve to session id");
689 assert_eq!(session_id, "sess-1");
690 }
691
692 #[tokio::test]
693 async fn refresh_index_returns_none_for_unknown_token() {
694 let (ks, _dir) = temp_sessions_ks();
695 let result = get_session_by_refresh(&ks, "bogus-token").await.unwrap();
696 assert!(result.is_none());
697 }
698
699 #[tokio::test]
700 async fn delete_refresh_index_removes_only_the_named_token() {
701 let (ks, _dir) = temp_sessions_ks();
705 store_refresh_index(&ks, "token-a", "sess-a").await.unwrap();
706 store_refresh_index(&ks, "token-b", "sess-b").await.unwrap();
707
708 delete_refresh_index(&ks, "token-a").await.unwrap();
709
710 assert!(
711 get_session_by_refresh(&ks, "token-a")
712 .await
713 .unwrap()
714 .is_none(),
715 "deleted token must no longer resolve"
716 );
717 assert_eq!(
718 get_session_by_refresh(&ks, "token-b")
719 .await
720 .unwrap()
721 .as_deref(),
722 Some("sess-b"),
723 "untouched token must still resolve"
724 );
725 }
726
727 #[tokio::test]
728 async fn delete_refresh_index_is_idempotent() {
729 let (ks, _dir) = temp_sessions_ks();
734 delete_refresh_index(&ks, "never-existed").await.unwrap();
735
736 store_refresh_index(&ks, "once", "sess-x").await.unwrap();
737 delete_refresh_index(&ks, "once").await.unwrap();
738 delete_refresh_index(&ks, "once").await.unwrap();
739 }
740
741 #[tokio::test]
742 async fn refresh_index_is_keyed_by_hash_not_raw_token() {
743 let (ks, _dir) = temp_sessions_ks();
747 store_refresh_index(&ks, "super-secret-token-value", "sess-xyz")
748 .await
749 .unwrap();
750
751 let all: Vec<_> = ks.prefix_iter_raw("refresh:").await.unwrap();
752 assert_eq!(all.len(), 1, "exactly one refresh index entry");
753 let (key_bytes, _value_bytes) = &all[0];
754 let key = String::from_utf8_lossy(key_bytes);
755 assert!(
756 !key.contains("super-secret-token-value"),
757 "raw token must not appear in stored key — got {key}"
758 );
759 }
760
761 #[tokio::test]
764 async fn delete_session_removes_session_and_refresh_index() {
765 let (ks, _dir) = temp_sessions_ks();
766 let mut session = sample_session("sess-1", "did:key:zA", SessionState::Authenticated);
767 session.refresh_token = Some("refresh-token-abc".into());
768 session.refresh_expires_at = Some(now_epoch() + 86400);
769 store_session(&ks, &session).await.unwrap();
770 store_refresh_index(&ks, "refresh-token-abc", "sess-1")
771 .await
772 .unwrap();
773
774 delete_session(&ks, "sess-1").await.unwrap();
775
776 assert!(get_session(&ks, "sess-1").await.unwrap().is_none());
777 assert!(
778 get_session_by_refresh(&ks, "refresh-token-abc")
779 .await
780 .unwrap()
781 .is_none(),
782 "refresh-index entry must be removed alongside the session"
783 );
784 }
785
786 #[tokio::test]
787 async fn delete_missing_session_is_a_noop() {
788 let (ks, _dir) = temp_sessions_ks();
789 delete_session(&ks, "never-existed")
791 .await
792 .expect("delete of missing session must not error");
793 }
794
795 #[tokio::test]
798 async fn list_sessions_returns_all_records() {
799 let (ks, _dir) = temp_sessions_ks();
800 for i in 0..3 {
801 let session = sample_session(
802 &format!("sess-{i}"),
803 &format!("did:key:z{i}"),
804 SessionState::Authenticated,
805 );
806 store_session(&ks, &session).await.unwrap();
807 }
808
809 let listed = list_sessions(&ks).await.unwrap();
810 assert_eq!(listed.len(), 3);
811 }
812
813 #[tokio::test]
814 async fn list_sessions_ignores_refresh_index_entries() {
815 let (ks, _dir) = temp_sessions_ks();
820 store_session(
821 &ks,
822 &sample_session("sess-1", "did:key:zA", SessionState::Authenticated),
823 )
824 .await
825 .unwrap();
826 store_refresh_index(&ks, "refresh-token-1", "sess-1")
827 .await
828 .unwrap();
829
830 let listed = list_sessions(&ks).await.unwrap();
831 assert_eq!(listed.len(), 1, "only the session entry should appear");
832 assert_eq!(listed[0].session_id, "sess-1");
833 }
834
835 #[tokio::test]
838 async fn cleanup_removes_challenge_sent_past_ttl() {
839 let (ks, _dir) = temp_sessions_ks();
840 let challenge_ttl = 300u64;
841
842 let mut expired = sample_session("sess-stale", "did:key:zA", SessionState::ChallengeSent);
843 expired.created_at = now_epoch().saturating_sub(challenge_ttl + 60);
844 store_session(&ks, &expired).await.unwrap();
845
846 let mut fresh = sample_session("sess-fresh", "did:key:zB", SessionState::ChallengeSent);
847 fresh.created_at = now_epoch();
848 store_session(&ks, &fresh).await.unwrap();
849
850 cleanup_expired_sessions(&ks, challenge_ttl).await.unwrap();
851
852 assert!(
853 get_session(&ks, "sess-stale").await.unwrap().is_none(),
854 "stale ChallengeSent session must be removed"
855 );
856 assert!(
857 get_session(&ks, "sess-fresh").await.unwrap().is_some(),
858 "fresh ChallengeSent session must remain"
859 );
860 }
861
862 #[tokio::test]
863 async fn cleanup_removes_authenticated_past_refresh_expiry() {
864 let (ks, _dir) = temp_sessions_ks();
865
866 let mut expired = sample_session("sess-expired", "did:key:zA", SessionState::Authenticated);
867 expired.refresh_token = Some("expired-token".into());
868 expired.refresh_expires_at = Some(now_epoch().saturating_sub(10));
869 store_session(&ks, &expired).await.unwrap();
870 store_refresh_index(&ks, "expired-token", "sess-expired")
871 .await
872 .unwrap();
873
874 cleanup_expired_sessions(&ks, 300).await.unwrap();
875
876 assert!(
877 get_session(&ks, "sess-expired").await.unwrap().is_none(),
878 "expired Authenticated session must be removed"
879 );
880 assert!(
881 get_session_by_refresh(&ks, "expired-token")
882 .await
883 .unwrap()
884 .is_none(),
885 "refresh index must be cleaned up alongside the session"
886 );
887 }
888
889 #[tokio::test]
890 async fn cleanup_removes_authenticated_with_no_refresh_expiry() {
891 let (ks, _dir) = temp_sessions_ks();
896 let mut odd = sample_session("sess-odd", "did:key:zA", SessionState::Authenticated);
897 odd.refresh_token = Some("odd-token".into());
898 odd.refresh_expires_at = None;
899 store_session(&ks, &odd).await.unwrap();
900
901 cleanup_expired_sessions(&ks, 300).await.unwrap();
902
903 assert!(
904 get_session(&ks, "sess-odd").await.unwrap().is_none(),
905 "Authenticated session with no expiry must be garbage-collected"
906 );
907 }
908
909 #[tokio::test]
910 async fn cleanup_gc_orphan_nonce_indices() {
911 let (ks, _dir) = temp_sessions_ks();
916
917 let live = sample_session("sess-live", "did:key:zA", SessionState::ChallengeSent);
919 store_session(&ks, &live).await.unwrap();
920 ks.insert_raw("nonce:live-challenge".to_string(), b"sess-live".to_vec())
921 .await
922 .unwrap();
923
924 ks.insert_raw(
926 "nonce:orphan-challenge".to_string(),
927 b"sess-vanished".to_vec(),
928 )
929 .await
930 .unwrap();
931
932 let mut stale = sample_session("sess-stale", "did:key:zB", SessionState::ChallengeSent);
935 stale.created_at = now_epoch().saturating_sub(3600);
936 store_session(&ks, &stale).await.unwrap();
937 ks.insert_raw("nonce:stale-challenge".to_string(), b"sess-stale".to_vec())
938 .await
939 .unwrap();
940
941 cleanup_expired_sessions(&ks, 300).await.unwrap();
942
943 let nonces = ks.prefix_iter_raw("nonce:").await.unwrap();
944 let nonce_keys: Vec<String> = nonces
945 .iter()
946 .map(|(k, _)| String::from_utf8_lossy(k).into_owned())
947 .collect();
948
949 assert!(
950 nonce_keys.iter().any(|k| k == "nonce:live-challenge"),
951 "live session's nonce must survive — got {nonce_keys:?}"
952 );
953 assert!(
954 !nonce_keys.iter().any(|k| k == "nonce:orphan-challenge"),
955 "orphan nonce must be removed — got {nonce_keys:?}"
956 );
957 assert!(
958 !nonce_keys.iter().any(|k| k == "nonce:stale-challenge"),
959 "stale-session nonce must be removed — got {nonce_keys:?}"
960 );
961 }
962
963 #[tokio::test]
964 async fn cleanup_preserves_active_authenticated_session() {
965 let (ks, _dir) = temp_sessions_ks();
966 let mut active = sample_session("sess-live", "did:key:zA", SessionState::Authenticated);
967 active.refresh_token = Some("live-token".into());
968 active.refresh_expires_at = Some(now_epoch() + 86400);
969 store_session(&ks, &active).await.unwrap();
970 store_refresh_index(&ks, "live-token", "sess-live")
971 .await
972 .unwrap();
973
974 cleanup_expired_sessions(&ks, 300).await.unwrap();
975
976 let loaded = get_session(&ks, "sess-live").await.unwrap();
977 assert!(loaded.is_some(), "live session must not be cleaned up");
978 }
979
980 #[tokio::test]
983 async fn resolve_did_session_creates_aal1_keyed_on_did() {
984 let (ks, _dir) = temp_sessions_ks();
985 let did = "did:key:zResolveNew";
986 let now = now_epoch();
987 let s = resolve_did_session(&ks, did, now).await.unwrap();
988 assert_eq!(s.session_id, did, "canonical session_id is the DID itself");
989 assert_eq!(s.did, did);
990 assert_eq!(s.state, SessionState::Authenticated);
991 assert_eq!(s.acr, "aal1");
992 assert_eq!(s.amr, vec!["did".to_string()]);
993 assert!(
994 s.refresh_token.is_none(),
995 "intrinsic session has no refresh token"
996 );
997 assert_eq!(s.last_seen, now);
998 assert!(get_session(&ks, did).await.unwrap().is_some());
1000 }
1001
1002 #[tokio::test]
1003 async fn resolve_did_session_reports_elevation_within_window() {
1004 let (ks, _dir) = temp_sessions_ks();
1005 let did = "did:key:zResolveElevated";
1006 let now = now_epoch();
1007 let mut s = resolve_did_session(&ks, did, now).await.unwrap();
1009 s.acr = "aal2".into();
1010 s.amr.push("did-signed".into());
1011 s.acr_expires_at = Some(now + 900);
1012 update_session(&ks, &s).await.unwrap();
1013 let seen = resolve_did_session(&ks, did, now + 60).await.unwrap();
1016 assert_eq!(seen.acr, "aal2");
1017 assert!(seen.amr.iter().any(|m| m == "did-signed"));
1018 }
1019
1020 #[tokio::test]
1021 async fn resolve_did_session_downgrades_after_window() {
1022 let (ks, _dir) = temp_sessions_ks();
1023 let did = "did:key:zResolveLapsed";
1024 let now = now_epoch();
1025 let mut s = resolve_did_session(&ks, did, now).await.unwrap();
1026 s.acr = "aal2".into();
1027 s.amr = vec!["did".into(), "did-signed".into()];
1028 s.acr_expires_at = Some(now + 900);
1029 update_session(&ks, &s).await.unwrap();
1030 let seen = resolve_did_session(&ks, did, now + 901).await.unwrap();
1033 assert_eq!(seen.acr, "aal1");
1034 assert_eq!(seen.acr_expires_at, None);
1035 assert_eq!(seen.amr, vec!["did".to_string()]);
1036 let stored = get_session(&ks, did).await.unwrap().unwrap();
1037 assert_eq!(stored.acr, "aal1");
1038 assert_eq!(stored.acr_expires_at, None);
1039 }
1040
1041 #[tokio::test]
1042 async fn intrinsic_session_reaped_only_after_idle_ttl() {
1043 let (ks, _dir) = temp_sessions_ks();
1044 let did = "did:key:zIdle";
1045 let mut s = resolve_did_session(&ks, did, now_epoch()).await.unwrap();
1046 cleanup_expired_sessions(&ks, 60).await.unwrap();
1048 assert!(
1049 get_session(&ks, did).await.unwrap().is_some(),
1050 "a just-seen intrinsic session must not be reaped"
1051 );
1052 s.last_seen = now_epoch().saturating_sub(INTRINSIC_SESSION_IDLE_TTL_SECS + 10);
1054 s.created_at = s.last_seen;
1055 update_session(&ks, &s).await.unwrap();
1056 cleanup_expired_sessions(&ks, 60).await.unwrap();
1057 assert!(
1058 get_session(&ks, did).await.unwrap().is_none(),
1059 "an idle intrinsic session must be reaped"
1060 );
1061 }
1062
1063 #[tokio::test]
1064 async fn rest_session_sweep_rule_unchanged_for_uuid_sessions() {
1065 let (ks, _dir) = temp_sessions_ks();
1066 let mut live = sample_session("uuid-live", "did:key:zRest", SessionState::Authenticated);
1068 live.refresh_token = Some("rt-live".into());
1069 live.refresh_expires_at = Some(now_epoch() + 3600);
1070 store_session(&ks, &live).await.unwrap();
1071 let mut dead = sample_session("uuid-dead", "did:key:zRest", SessionState::Authenticated);
1072 dead.refresh_token = Some("rt-dead".into());
1073 dead.refresh_expires_at = Some(now_epoch().saturating_sub(10));
1074 store_session(&ks, &dead).await.unwrap();
1075 cleanup_expired_sessions(&ks, 60).await.unwrap();
1076 assert!(get_session(&ks, "uuid-live").await.unwrap().is_some());
1077 assert!(get_session(&ks, "uuid-dead").await.unwrap().is_none());
1078 }
1079
1080 #[test]
1083 fn now_epoch_is_monotonic() {
1084 let t = now_epoch();
1089 assert!(t > 1_700_000_000, "epoch should be post-2023; got {t}");
1090 }
1091}