use crate::error::AppError;
use crate::store::KeyspaceHandle;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::debug;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SessionState {
ChallengeSent,
Authenticated,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Session {
pub session_id: String,
pub did: String,
pub challenge: String,
pub state: SessionState,
pub created_at: u64,
pub refresh_token: Option<String>,
pub refresh_expires_at: Option<u64>,
#[serde(default)]
pub tee_attested: bool,
#[serde(default)]
pub amr: Vec<String>,
#[serde(default)]
pub acr: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_pubkey_b58btc: Option<String>,
}
impl std::fmt::Debug for Session {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Session")
.field("session_id", &self.session_id)
.field("did", &self.did)
.field("challenge", &"<redacted>")
.field("state", &self.state)
.field("created_at", &self.created_at)
.field(
"refresh_token",
&self.refresh_token.as_ref().map(|_| "<redacted>"),
)
.field("refresh_expires_at", &self.refresh_expires_at)
.field("tee_attested", &self.tee_attested)
.field("amr", &self.amr)
.field("acr", &self.acr)
.field("token_id", &self.token_id.as_ref().map(|_| "<redacted>"))
.field("session_pubkey_b58btc", &self.session_pubkey_b58btc)
.finish()
}
}
fn session_key(session_id: &str) -> String {
format!("session:{session_id}")
}
fn refresh_key(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes());
format!("refresh:{}", hex_lower(&digest))
}
fn hex_lower(bytes: &[u8]) -> String {
const TABLE: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(TABLE[(b >> 4) as usize] as char);
out.push(TABLE[(b & 0x0f) as usize] as char);
}
out
}
pub async fn store_session(sessions: &KeyspaceHandle, session: &Session) -> Result<(), AppError> {
sessions
.insert(session_key(&session.session_id), session)
.await?;
debug!(session_id = %session.session_id, did = %session.did, "session stored");
Ok(())
}
pub async fn get_session(
sessions: &KeyspaceHandle,
session_id: &str,
) -> Result<Option<Session>, AppError> {
sessions.get(session_key(session_id)).await
}
pub async fn update_session(sessions: &KeyspaceHandle, session: &Session) -> Result<(), AppError> {
sessions
.insert(session_key(&session.session_id), session)
.await
}
pub async fn store_refresh_index(
sessions: &KeyspaceHandle,
token: &str,
session_id: &str,
) -> Result<(), AppError> {
sessions
.insert_raw(refresh_key(token), session_id.as_bytes().to_vec())
.await
}
pub async fn get_session_by_refresh(
sessions: &KeyspaceHandle,
token: &str,
) -> Result<Option<String>, AppError> {
match sessions.get_raw(refresh_key(token)).await? {
Some(bytes) => {
let session_id = String::from_utf8(bytes)
.map_err(|e| AppError::Internal(format!("invalid session_id bytes: {e}")))?;
Ok(Some(session_id))
}
None => Ok(None),
}
}
pub async fn delete_refresh_index(sessions: &KeyspaceHandle, token: &str) -> Result<(), AppError> {
sessions.remove(refresh_key(token)).await
}
pub async fn take_session_id_by_refresh(
sessions: &KeyspaceHandle,
token: &str,
) -> Result<Option<String>, AppError> {
match sessions.take_raw(refresh_key(token)).await? {
Some(bytes) => {
let session_id = String::from_utf8(bytes)
.map_err(|e| AppError::Internal(format!("invalid session_id bytes: {e}")))?;
Ok(Some(session_id))
}
None => Ok(None),
}
}
pub async fn count_pending_challenges(
sessions: &KeyspaceHandle,
did: &str,
) -> Result<usize, AppError> {
let entries = sessions.prefix_iter_raw("session:").await?;
let mut count = 0usize;
for (_key, value) in entries {
if let Ok(s) = serde_json::from_slice::<Session>(&value)
&& s.did == did
&& s.state == SessionState::ChallengeSent
{
count += 1;
}
}
Ok(count)
}
pub fn now_epoch() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
pub async fn delete_session(sessions: &KeyspaceHandle, session_id: &str) -> Result<(), AppError> {
let session: Option<Session> = sessions.get(session_key(session_id)).await?;
if let Some(session) = session {
if let Some(ref token) = session.refresh_token {
sessions.remove(refresh_key(token)).await?;
}
sessions.remove(session_key(session_id)).await?;
debug!(session_id, "session deleted");
}
Ok(())
}
pub async fn list_sessions(sessions: &KeyspaceHandle) -> Result<Vec<Session>, AppError> {
let raw = sessions.prefix_iter_raw("session:").await?;
let mut result = Vec::with_capacity(raw.len());
for (_key, value) in raw {
if let Ok(session) = serde_json::from_slice::<Session>(&value) {
result.push(session);
}
}
Ok(result)
}
pub async fn cleanup_expired_sessions(
sessions: &KeyspaceHandle,
challenge_ttl: u64,
) -> Result<(), AppError> {
let entries = sessions.prefix_iter_raw("session:").await?;
let now = now_epoch();
let mut removed = 0u64;
let mut live_sessions: std::collections::HashSet<String> =
std::collections::HashSet::with_capacity(entries.len());
for (key, value) in entries {
let session: Session = match serde_json::from_slice(&value) {
Ok(s) => s,
Err(_) => continue,
};
let expired = match session.state {
SessionState::ChallengeSent => now.saturating_sub(session.created_at) > challenge_ttl,
SessionState::Authenticated => session
.refresh_expires_at
.is_none_or(|expires| now > expires),
};
if expired {
sessions.remove(key).await?;
if let Some(ref token) = session.refresh_token {
sessions.remove(refresh_key(token)).await?;
}
removed += 1;
} else {
live_sessions.insert(session.session_id);
}
}
let nonce_entries = sessions.prefix_iter_raw("nonce:").await?;
let mut nonce_removed = 0u64;
for (key, value) in nonce_entries {
let session_id = match std::str::from_utf8(&value) {
Ok(s) => s,
Err(_) => {
sessions.remove(key).await?;
nonce_removed += 1;
continue;
}
};
if !live_sessions.contains(session_id) {
sessions.remove(key).await?;
nonce_removed += 1;
}
}
debug!(
removed,
nonces_removed = nonce_removed,
"session cleanup complete"
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StoreConfig;
use crate::store::Store;
fn temp_sessions_ks() -> (KeyspaceHandle, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("tempdir");
let config = StoreConfig {
data_dir: dir.path().to_path_buf(),
};
let store = Store::open(&config).expect("open store");
let ks = store.keyspace("sessions").expect("keyspace");
(ks, dir)
}
fn sample_session(session_id: &str, did: &str, state: SessionState) -> Session {
Session {
session_id: session_id.to_string(),
did: did.to_string(),
challenge: "test-challenge-hex".into(),
state,
created_at: now_epoch(),
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
amr: Vec::new(),
acr: String::new(),
token_id: None,
session_pubkey_b58btc: None,
}
}
#[test]
fn debug_redacts_refresh_token() {
let mut s = sample_session("sess-1", "did:key:zA", SessionState::Authenticated);
s.refresh_token = Some("super-secret-refresh-uuid".into());
let rendered = format!("{s:?}");
assert!(
!rendered.contains("super-secret-refresh-uuid"),
"raw refresh token must not appear in Debug output, got: {rendered}"
);
assert!(
rendered.contains("<redacted>"),
"expected redaction marker, got: {rendered}"
);
}
#[test]
fn session_key_is_prefixed_for_scan() {
assert_eq!(session_key("abc"), "session:abc");
}
#[test]
fn refresh_key_hashes_token_not_stores_raw() {
let key = refresh_key("very-secret-uuid-v4-12345");
assert!(
key.starts_with("refresh:"),
"prefix must survive for prefix scans"
);
let hash_part = key.strip_prefix("refresh:").unwrap();
assert_eq!(
hash_part.len(),
64,
"SHA-256 as hex is 64 chars; got {hash_part}"
);
assert!(
!hash_part.contains("very-secret"),
"raw token must not appear in the index key — got {key}"
);
assert_eq!(refresh_key("very-secret-uuid-v4-12345"), key);
assert_ne!(refresh_key("other-token"), key);
}
#[tokio::test]
async fn store_and_load_session() {
let (ks, _dir) = temp_sessions_ks();
let session = sample_session("sess-1", "did:key:zA", SessionState::ChallengeSent);
store_session(&ks, &session).await.unwrap();
let loaded = get_session(&ks, "sess-1")
.await
.unwrap()
.expect("session must be present");
assert_eq!(loaded.session_id, "sess-1");
assert_eq!(loaded.did, "did:key:zA");
assert_eq!(loaded.state, SessionState::ChallengeSent);
}
#[tokio::test]
async fn get_session_returns_none_for_missing() {
let (ks, _dir) = temp_sessions_ks();
let result = get_session(&ks, "never-existed").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn update_session_overwrites_state() {
let (ks, _dir) = temp_sessions_ks();
let mut session = sample_session("sess-1", "did:key:zA", SessionState::ChallengeSent);
store_session(&ks, &session).await.unwrap();
session.state = SessionState::Authenticated;
update_session(&ks, &session).await.unwrap();
let loaded = get_session(&ks, "sess-1").await.unwrap().unwrap();
assert_eq!(loaded.state, SessionState::Authenticated);
}
#[tokio::test]
async fn refresh_index_lookup_round_trip() {
let (ks, _dir) = temp_sessions_ks();
store_refresh_index(&ks, "refresh-token-abc", "sess-1")
.await
.unwrap();
let session_id = get_session_by_refresh(&ks, "refresh-token-abc")
.await
.unwrap()
.expect("refresh token must resolve to session id");
assert_eq!(session_id, "sess-1");
}
#[tokio::test]
async fn refresh_index_returns_none_for_unknown_token() {
let (ks, _dir) = temp_sessions_ks();
let result = get_session_by_refresh(&ks, "bogus-token").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn delete_refresh_index_removes_only_the_named_token() {
let (ks, _dir) = temp_sessions_ks();
store_refresh_index(&ks, "token-a", "sess-a").await.unwrap();
store_refresh_index(&ks, "token-b", "sess-b").await.unwrap();
delete_refresh_index(&ks, "token-a").await.unwrap();
assert!(
get_session_by_refresh(&ks, "token-a")
.await
.unwrap()
.is_none(),
"deleted token must no longer resolve"
);
assert_eq!(
get_session_by_refresh(&ks, "token-b")
.await
.unwrap()
.as_deref(),
Some("sess-b"),
"untouched token must still resolve"
);
}
#[tokio::test]
async fn delete_refresh_index_is_idempotent() {
let (ks, _dir) = temp_sessions_ks();
delete_refresh_index(&ks, "never-existed").await.unwrap();
store_refresh_index(&ks, "once", "sess-x").await.unwrap();
delete_refresh_index(&ks, "once").await.unwrap();
delete_refresh_index(&ks, "once").await.unwrap();
}
#[tokio::test]
async fn refresh_index_is_keyed_by_hash_not_raw_token() {
let (ks, _dir) = temp_sessions_ks();
store_refresh_index(&ks, "super-secret-token-value", "sess-xyz")
.await
.unwrap();
let all: Vec<_> = ks.prefix_iter_raw("refresh:").await.unwrap();
assert_eq!(all.len(), 1, "exactly one refresh index entry");
let (key_bytes, _value_bytes) = &all[0];
let key = String::from_utf8_lossy(key_bytes);
assert!(
!key.contains("super-secret-token-value"),
"raw token must not appear in stored key — got {key}"
);
}
#[tokio::test]
async fn delete_session_removes_session_and_refresh_index() {
let (ks, _dir) = temp_sessions_ks();
let mut session = sample_session("sess-1", "did:key:zA", SessionState::Authenticated);
session.refresh_token = Some("refresh-token-abc".into());
session.refresh_expires_at = Some(now_epoch() + 86400);
store_session(&ks, &session).await.unwrap();
store_refresh_index(&ks, "refresh-token-abc", "sess-1")
.await
.unwrap();
delete_session(&ks, "sess-1").await.unwrap();
assert!(get_session(&ks, "sess-1").await.unwrap().is_none());
assert!(
get_session_by_refresh(&ks, "refresh-token-abc")
.await
.unwrap()
.is_none(),
"refresh-index entry must be removed alongside the session"
);
}
#[tokio::test]
async fn delete_missing_session_is_a_noop() {
let (ks, _dir) = temp_sessions_ks();
delete_session(&ks, "never-existed")
.await
.expect("delete of missing session must not error");
}
#[tokio::test]
async fn list_sessions_returns_all_records() {
let (ks, _dir) = temp_sessions_ks();
for i in 0..3 {
let session = sample_session(
&format!("sess-{i}"),
&format!("did:key:z{i}"),
SessionState::Authenticated,
);
store_session(&ks, &session).await.unwrap();
}
let listed = list_sessions(&ks).await.unwrap();
assert_eq!(listed.len(), 3);
}
#[tokio::test]
async fn list_sessions_ignores_refresh_index_entries() {
let (ks, _dir) = temp_sessions_ks();
store_session(
&ks,
&sample_session("sess-1", "did:key:zA", SessionState::Authenticated),
)
.await
.unwrap();
store_refresh_index(&ks, "refresh-token-1", "sess-1")
.await
.unwrap();
let listed = list_sessions(&ks).await.unwrap();
assert_eq!(listed.len(), 1, "only the session entry should appear");
assert_eq!(listed[0].session_id, "sess-1");
}
#[tokio::test]
async fn cleanup_removes_challenge_sent_past_ttl() {
let (ks, _dir) = temp_sessions_ks();
let challenge_ttl = 300u64;
let mut expired = sample_session("sess-stale", "did:key:zA", SessionState::ChallengeSent);
expired.created_at = now_epoch().saturating_sub(challenge_ttl + 60);
store_session(&ks, &expired).await.unwrap();
let mut fresh = sample_session("sess-fresh", "did:key:zB", SessionState::ChallengeSent);
fresh.created_at = now_epoch();
store_session(&ks, &fresh).await.unwrap();
cleanup_expired_sessions(&ks, challenge_ttl).await.unwrap();
assert!(
get_session(&ks, "sess-stale").await.unwrap().is_none(),
"stale ChallengeSent session must be removed"
);
assert!(
get_session(&ks, "sess-fresh").await.unwrap().is_some(),
"fresh ChallengeSent session must remain"
);
}
#[tokio::test]
async fn cleanup_removes_authenticated_past_refresh_expiry() {
let (ks, _dir) = temp_sessions_ks();
let mut expired = sample_session("sess-expired", "did:key:zA", SessionState::Authenticated);
expired.refresh_token = Some("expired-token".into());
expired.refresh_expires_at = Some(now_epoch().saturating_sub(10));
store_session(&ks, &expired).await.unwrap();
store_refresh_index(&ks, "expired-token", "sess-expired")
.await
.unwrap();
cleanup_expired_sessions(&ks, 300).await.unwrap();
assert!(
get_session(&ks, "sess-expired").await.unwrap().is_none(),
"expired Authenticated session must be removed"
);
assert!(
get_session_by_refresh(&ks, "expired-token")
.await
.unwrap()
.is_none(),
"refresh index must be cleaned up alongside the session"
);
}
#[tokio::test]
async fn cleanup_removes_authenticated_with_no_refresh_expiry() {
let (ks, _dir) = temp_sessions_ks();
let mut odd = sample_session("sess-odd", "did:key:zA", SessionState::Authenticated);
odd.refresh_token = Some("odd-token".into());
odd.refresh_expires_at = None;
store_session(&ks, &odd).await.unwrap();
cleanup_expired_sessions(&ks, 300).await.unwrap();
assert!(
get_session(&ks, "sess-odd").await.unwrap().is_none(),
"Authenticated session with no expiry must be garbage-collected"
);
}
#[tokio::test]
async fn cleanup_gc_orphan_nonce_indices() {
let (ks, _dir) = temp_sessions_ks();
let live = sample_session("sess-live", "did:key:zA", SessionState::ChallengeSent);
store_session(&ks, &live).await.unwrap();
ks.insert_raw("nonce:live-challenge".to_string(), b"sess-live".to_vec())
.await
.unwrap();
ks.insert_raw(
"nonce:orphan-challenge".to_string(),
b"sess-vanished".to_vec(),
)
.await
.unwrap();
let mut stale = sample_session("sess-stale", "did:key:zB", SessionState::ChallengeSent);
stale.created_at = now_epoch().saturating_sub(3600);
store_session(&ks, &stale).await.unwrap();
ks.insert_raw("nonce:stale-challenge".to_string(), b"sess-stale".to_vec())
.await
.unwrap();
cleanup_expired_sessions(&ks, 300).await.unwrap();
let nonces = ks.prefix_iter_raw("nonce:").await.unwrap();
let nonce_keys: Vec<String> = nonces
.iter()
.map(|(k, _)| String::from_utf8_lossy(k).into_owned())
.collect();
assert!(
nonce_keys.iter().any(|k| k == "nonce:live-challenge"),
"live session's nonce must survive — got {nonce_keys:?}"
);
assert!(
!nonce_keys.iter().any(|k| k == "nonce:orphan-challenge"),
"orphan nonce must be removed — got {nonce_keys:?}"
);
assert!(
!nonce_keys.iter().any(|k| k == "nonce:stale-challenge"),
"stale-session nonce must be removed — got {nonce_keys:?}"
);
}
#[tokio::test]
async fn cleanup_preserves_active_authenticated_session() {
let (ks, _dir) = temp_sessions_ks();
let mut active = sample_session("sess-live", "did:key:zA", SessionState::Authenticated);
active.refresh_token = Some("live-token".into());
active.refresh_expires_at = Some(now_epoch() + 86400);
store_session(&ks, &active).await.unwrap();
store_refresh_index(&ks, "live-token", "sess-live")
.await
.unwrap();
cleanup_expired_sessions(&ks, 300).await.unwrap();
let loaded = get_session(&ks, "sess-live").await.unwrap();
assert!(loaded.is_some(), "live session must not be cleaned up");
}
#[test]
fn now_epoch_is_monotonic() {
let t = now_epoch();
assert!(t > 1_700_000_000, "epoch should be post-2023; got {t}");
}
}