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")]
74 pub acr_expires_at: Option<u64>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub token_id: Option<String>,
87 #[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
128fn 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
152pub 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
161pub 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
169pub async fn update_session(sessions: &KeyspaceHandle, session: &Session) -> Result<(), AppError> {
171 sessions
172 .insert(session_key(&session.session_id), session)
173 .await
174}
175
176pub const INTRINSIC_SESSION_IDLE_TTL_SECS: u64 = 86_400; pub 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
237pub 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
248pub 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
263pub async fn delete_refresh_index(sessions: &KeyspaceHandle, token: &str) -> Result<(), AppError> {
268 sessions.remove(refresh_key(token)).await
269}
270
271pub 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
295pub 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
324pub fn now_epoch() -> u64 {
326 SystemTime::now()
327 .duration_since(UNIX_EPOCH)
328 .unwrap()
329 .as_secs()
330}
331
332pub 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
345pub 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
357pub 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 session
393 .refresh_expires_at
394 .is_none_or(|expires| now > expires)
395 } else if session.session_id == session.did {
396 let last = session.last_seen.max(session.created_at);
400 now.saturating_sub(last) > INTRINSIC_SESSION_IDLE_TTL_SECS
401 } else {
402 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 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 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 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 #[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 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 assert_eq!(refresh_key("very-secret-uuid-v4-12345"), key);
541 assert_ne!(refresh_key("other-token"), key);
543 }
544
545 #[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 #[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 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 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 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 #[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 delete_session(&ks, "never-existed")
697 .await
698 .expect("delete of missing session must not error");
699 }
700
701 #[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 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 #[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 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 let (ks, _dir) = temp_sessions_ks();
822
823 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 ks.insert_raw(
832 "nonce:orphan-challenge".to_string(),
833 b"sess-vanished".to_vec(),
834 )
835 .await
836 .unwrap();
837
838 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 #[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 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 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 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 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 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 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 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 #[test]
989 fn now_epoch_is_monotonic() {
990 let t = now_epoch();
995 assert!(t > 1_700_000_000, "epoch should be post-2023; got {t}");
996 }
997}