use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use did_hosting_common::server::auth::session::{Session, SessionState, now_epoch};
use did_hosting_common::server::config::AuthConfig;
use tracing::{info, warn};
use crate::error::{AppError, LIMITER_PENDING_CHALLENGES_PER_DID};
use crate::store::KeyspaceHandle;
pub const LIMITER_GLOBAL: &str = "auth-challenge-pending-global";
pub const MAX_GLOBAL_PENDING: usize = 10_000;
pub type Clock = Arc<dyn Fn() -> Instant + Send + Sync>;
#[must_use = "bind the reservation to the issued session id, or cancel it"]
#[derive(Debug)]
pub struct Reservation {
id: u64,
victims: Vec<Victim>,
}
impl Reservation {
pub fn evicting_sessions(&self) -> Vec<&str> {
self.victims.iter().map(Victim::session_id).collect()
}
}
#[derive(Debug)]
pub struct Victim {
id: u64,
session_id: String,
}
impl Victim {
pub fn session_id(&self) -> &str {
&self.session_id
}
}
#[must_use = "claim each victim's row, then complete or restore its eviction"]
#[derive(Debug, Default)]
pub struct Eviction {
victims: Vec<Victim>,
}
impl Eviction {
pub fn into_victims(self) -> Vec<Victim> {
self.victims
}
}
#[derive(Debug)]
struct Entry {
did: String,
session_id: Option<String>,
evicting: bool,
}
#[derive(Debug, Default)]
struct Inner {
next_id: u64,
entries: HashMap<u64, Entry>,
by_session: HashMap<String, u64>,
per_did: HashMap<String, VecDeque<(Instant, u64)>>,
expiry: VecDeque<(Instant, u64)>,
}
impl Inner {
fn remove(&mut self, id: u64) -> bool {
let Some(entry) = self.entries.remove(&id) else {
return false;
};
if let Some(sid) = entry.session_id {
self.by_session.remove(&sid);
}
if let Some(q) = self.per_did.get_mut(&entry.did) {
q.retain(|(_, qid)| *qid != id);
if q.is_empty() {
self.per_did.remove(&entry.did);
}
}
true
}
fn restore(&mut self, id: u64) {
if let Some(entry) = self.entries.get_mut(&id) {
entry.evicting = false;
}
}
fn prune(&mut self, now: Instant) {
while let Some(&(expires_at, id)) = self.expiry.front() {
let live = self.entries.contains_key(&id);
if live && expires_at > now {
break;
}
self.expiry.pop_front();
if live {
self.remove(id);
}
}
}
fn insert(&mut self, did: &str, expires_at: Instant, session_id: Option<String>) -> u64 {
let id = self.next_id;
self.next_id += 1;
if let Some(sid) = &session_id {
self.by_session.insert(sid.clone(), id);
}
self.entries.insert(
id,
Entry {
did: did.to_string(),
session_id,
evicting: false,
},
);
insert_sorted(
self.per_did.entry(did.to_string()).or_default(),
expires_at,
id,
);
insert_sorted(&mut self.expiry, expires_at, id);
id
}
}
fn insert_sorted(q: &mut VecDeque<(Instant, u64)>, expires_at: Instant, id: u64) {
let pos = q.partition_point(|(e, _)| *e <= expires_at);
q.insert(pos, (expires_at, id));
}
fn secs_until(at: Instant, now: Instant) -> u64 {
let d = at.saturating_duration_since(now);
let secs = d.as_secs() + u64::from(d.subsec_nanos() > 0);
secs.max(1)
}
pub struct PendingChallengeTracker {
inner: Mutex<Inner>,
challenge_ttl: Duration,
global_cap: usize,
clock: Clock,
}
impl std::fmt::Debug for PendingChallengeTracker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PendingChallengeTracker")
.field("challenge_ttl", &self.challenge_ttl)
.field("global_cap", &self.global_cap)
.finish_non_exhaustive()
}
}
impl Default for PendingChallengeTracker {
fn default() -> Self {
Self::for_auth_config(&AuthConfig::default())
}
}
impl PendingChallengeTracker {
pub fn new() -> Self {
Self::default()
}
pub fn for_auth_config(auth: &AuthConfig) -> Self {
Self::with_clock(
Duration::from_secs(auth.challenge_ttl),
auth.max_global_pending_challenges,
Arc::new(Instant::now),
)
}
pub fn with_clock(challenge_ttl: Duration, global_cap: usize, clock: Clock) -> Self {
Self {
inner: Mutex::new(Inner::default()),
challenge_ttl,
global_cap,
clock,
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
self.inner.lock().unwrap_or_else(|p| p.into_inner())
}
pub fn try_issue(&self, did: &str, per_did_cap: usize) -> Result<Reservation, AppError> {
let now = (self.clock)();
let mut inner = self.lock();
inner.prune(now);
if inner.entries.len() >= self.global_cap {
let retry_after_secs = inner
.expiry
.front()
.map_or(1, |(expires_at, _)| secs_until(*expires_at, now));
return Err(AppError::RateLimited {
limiter: LIMITER_GLOBAL,
message: format!(
"global pending-challenge cap reached ({} concurrent); try again later",
self.global_cap,
),
retry_after_secs,
});
}
let mut counted = 0usize;
let mut evictable: Vec<Victim> = Vec::new();
if let Some(q) = inner.per_did.get(did) {
for (_, id) in q {
let Some(entry) = inner.entries.get(id) else {
continue;
};
if entry.evicting {
continue;
}
counted += 1;
if let Some(sid) = &entry.session_id {
evictable.push(Victim {
id: *id,
session_id: sid.clone(),
});
}
}
}
let excess = (counted + 1).saturating_sub(per_did_cap);
evictable.truncate(excess);
if evictable.len() < excess {
let retry_after_secs = inner
.per_did
.get(did)
.and_then(|q| q.front())
.map_or(1, |(expires_at, _)| secs_until(*expires_at, now));
return Err(AppError::RateLimited {
limiter: LIMITER_PENDING_CHALLENGES_PER_DID,
message: format!(
"too many pending challenges for this DID (>= {per_did_cap}); try again later",
),
retry_after_secs,
});
}
for victim in &evictable {
if let Some(entry) = inner.entries.get_mut(&victim.id) {
entry.evicting = true;
}
}
if !evictable.is_empty() {
warn!(
did,
evicting = evictable.len(),
per_did_cap,
"per-DID challenge cap reached; evicting the oldest"
);
}
let id = inner.insert(did, now + self.challenge_ttl, None);
Ok(Reservation {
id,
victims: evictable,
})
}
pub fn bind(&self, reservation: Reservation, session_id: &str) -> Eviction {
let mut inner = self.lock();
let Inner {
entries,
by_session,
..
} = &mut *inner;
if let Some(entry) = entries.get_mut(&reservation.id) {
entry.session_id = Some(session_id.to_string());
by_session.insert(session_id.to_string(), reservation.id);
}
Eviction {
victims: reservation.victims,
}
}
pub fn cancel(&self, reservation: Reservation) {
let mut inner = self.lock();
inner.remove(reservation.id);
for victim in &reservation.victims {
inner.restore(victim.id);
}
}
pub fn complete_eviction(&self, victim: &Victim) {
self.lock().remove(victim.id);
}
pub fn restore_eviction(&self, victim: &Victim) {
self.lock().restore(victim.id);
}
pub fn release_session(&self, session_id: &str) -> bool {
let now = (self.clock)();
let mut inner = self.lock();
inner.prune(now);
match inner.by_session.get(session_id).copied() {
Some(id) => inner.remove(id),
None => false,
}
}
pub async fn seed_from_sessions(&self, sessions: &KeyspaceHandle) -> Result<usize, AppError> {
let rows = sessions.prefix_iter_raw("session:").await?;
let now_epoch = now_epoch();
let now = (self.clock)();
let ttl = self.challenge_ttl.as_secs();
let mut inner = self.lock();
let mut seeded = 0usize;
for (_key, value) in rows {
let Ok(session) = serde_json::from_slice::<Session>(&value) else {
continue;
};
if session.state != SessionState::ChallengeSent {
continue;
}
let expires_epoch = session.created_at.saturating_add(ttl);
if expires_epoch < now_epoch {
continue;
}
if inner.by_session.contains_key(&session.session_id) {
continue;
}
let remaining = Duration::from_secs(expires_epoch - now_epoch + 1);
inner.insert(&session.did, now + remaining, Some(session.session_id));
seeded += 1;
}
Ok(seeded)
}
pub async fn seed_from_sessions_or_warn(&self, sessions: &KeyspaceHandle) {
match self.seed_from_sessions(sessions).await {
Ok(n) => info!(
seeded = n,
"pending-challenge tracker seeded from session store"
),
Err(e) => {
warn!(error = %e, "could not seed pending-challenge tracker from session store")
}
}
}
pub fn count_for(&self, did: &str) -> usize {
let now = (self.clock)();
let mut inner = self.lock();
inner.prune(now);
inner.per_did.get(did).map_or(0, VecDeque::len)
}
pub fn global_count(&self) -> usize {
let now = (self.clock)();
let mut inner = self.lock();
inner.prune(now);
inner.entries.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
const TTL: Duration = Duration::from_secs(30);
fn manual_clock() -> (Clock, Arc<Mutex<Instant>>) {
let t = Arc::new(Mutex::new(Instant::now()));
let c = t.clone();
(Arc::new(move || *c.lock().unwrap()), t)
}
fn advance(t: &Mutex<Instant>, d: Duration) {
*t.lock().unwrap() += d;
}
fn tracker(global_cap: usize) -> (PendingChallengeTracker, Arc<Mutex<Instant>>) {
let (clock, t) = manual_clock();
(
PendingChallengeTracker::with_clock(TTL, global_cap, clock),
t,
)
}
fn issue(t: &PendingChallengeTracker, did: &str, cap: usize, sid: &str) {
let r = t.try_issue(did, cap).expect("under the cap");
for victim in t.bind(r, sid).into_victims() {
t.complete_eviction(&victim);
}
}
#[test]
fn issues_within_caps() {
let (t, _) = tracker(100);
for i in 0..5 {
issue(&t, "did:example:a", 10, &format!("s{i}"));
}
assert_eq!(t.count_for("did:example:a"), 5);
assert_eq!(t.global_count(), 5);
}
#[test]
fn per_did_cap_evicts_the_oldest_issued_challenge() {
let (t, clock) = tracker(100);
issue(&t, "did:example:a", 3, "s0");
advance(&clock, Duration::from_secs(1));
issue(&t, "did:example:a", 3, "s1");
issue(&t, "did:example:a", 3, "s2");
let r = t
.try_issue("did:example:a", 3)
.expect("evicts, not refuses");
assert_eq!(r.evicting_sessions(), ["s0"]);
assert_eq!(t.count_for("did:example:a"), 4);
let victims = t.bind(r, "s3").into_victims();
assert_eq!(t.count_for("did:example:a"), 4);
for v in &victims {
t.complete_eviction(v);
}
assert_eq!(t.count_for("did:example:a"), 3);
assert_eq!(t.global_count(), 3);
assert!(!t.release_session("s0"), "the evicted slot is gone");
assert!(t.release_session("s3"));
}
#[test]
fn a_lowered_cap_evicts_down_to_it() {
let (t, _) = tracker(100);
for i in 0..5 {
issue(&t, "did:example:a", 10, &format!("s{i}"));
}
let r = t.try_issue("did:example:a", 3).unwrap();
assert_eq!(r.evicting_sessions(), ["s0", "s1", "s2"]);
for v in t.bind(r, "s5").into_victims() {
t.complete_eviction(&v);
}
assert_eq!(t.count_for("did:example:a"), 3);
}
#[test]
fn cancel_restores_the_victims() {
let (t, _) = tracker(100);
for i in 0..3 {
issue(&t, "did:example:a", 3, &format!("s{i}"));
}
let r = t.try_issue("did:example:a", 3).unwrap();
assert_eq!(r.evicting_sessions(), ["s0"]);
t.cancel(r);
assert_eq!(t.count_for("did:example:a"), 3);
let r = t.try_issue("did:example:a", 3).unwrap();
assert_eq!(r.evicting_sessions(), ["s0"], "s0 is a candidate again");
t.cancel(r);
}
#[test]
fn a_failed_claim_keeps_the_victim_counted() {
let (t, _) = tracker(100);
for i in 0..3 {
issue(&t, "did:example:a", 3, &format!("s{i}"));
}
let r = t.try_issue("did:example:a", 3).unwrap();
for v in t.bind(r, "s3").into_victims() {
t.restore_eviction(&v);
}
assert_eq!(t.count_for("did:example:a"), 4);
assert!(t.release_session("s0"), "s0 is still tracked");
}
#[test]
fn concurrent_issues_evict_distinct_victims() {
let (t, _) = tracker(100);
for i in 0..3 {
issue(&t, "did:example:a", 3, &format!("s{i}"));
}
let a = t.try_issue("did:example:a", 3).unwrap();
let b = t.try_issue("did:example:a", 3).unwrap();
assert_eq!(a.evicting_sessions(), ["s0"]);
assert_eq!(b.evicting_sessions(), ["s1"]);
for v in t
.bind(a, "s3")
.into_victims()
.into_iter()
.chain(t.bind(b, "s4").into_victims())
{
t.complete_eviction(&v);
}
assert_eq!(t.count_for("did:example:a"), 3);
}
#[test]
fn completing_an_eviction_after_authentication_is_a_noop() {
let (t, _) = tracker(100);
for i in 0..3 {
issue(&t, "did:example:a", 3, &format!("s{i}"));
}
let r = t.try_issue("did:example:a", 3).unwrap();
let victims = t.bind(r, "s3").into_victims();
assert!(t.release_session("s0"));
for v in &victims {
t.complete_eviction(v);
}
assert_eq!(t.count_for("did:example:a"), 3);
assert!(t.release_session("s1"));
}
#[test]
fn per_did_cap_refuses_when_every_slot_is_still_being_minted() {
let (t, clock) = tracker(100);
let in_flight: Vec<Reservation> = (0..3)
.map(|_| {
let r = t.try_issue("did:example:a", 3).unwrap();
advance(&clock, Duration::from_secs(5));
r
})
.collect();
let err = t.try_issue("did:example:a", 3).unwrap_err();
assert!(matches!(
err,
AppError::RateLimited {
limiter: LIMITER_PENDING_CHALLENGES_PER_DID,
retry_after_secs: 15,
..
}
));
assert_eq!(t.count_for("did:example:a"), 3, "nothing evicted");
for r in in_flight {
t.cancel(r);
}
}
#[test]
fn abandoned_challenges_free_their_per_did_slots_on_expiry() {
let (t, clock) = tracker(100);
let abandoned: Vec<Reservation> = (0..10)
.map(|_| t.try_issue("did:example:a", 10).unwrap())
.collect();
let err = t.try_issue("did:example:a", 10).unwrap_err();
let AppError::RateLimited {
retry_after_secs, ..
} = err
else {
panic!("expected a rate limit, got {err:?}");
};
advance(&clock, Duration::from_secs(retry_after_secs - 1));
assert!(t.try_issue("did:example:a", 10).is_err());
advance(&clock, Duration::from_secs(1));
issue(&t, "did:example:a", 10, "fresh");
assert_eq!(t.count_for("did:example:a"), 1);
assert_eq!(t.global_count(), 1);
drop(abandoned);
}
#[test]
fn abandoned_challenges_free_global_slots_on_expiry() {
let (t, clock) = tracker(5);
for i in 0..5 {
issue(&t, &format!("did:example:{i}"), 10, &format!("s{i}"));
}
let err = t.try_issue("did:example:6", 10).unwrap_err();
assert!(matches!(
err,
AppError::RateLimited {
limiter: LIMITER_GLOBAL,
retry_after_secs: 30,
..
}
));
advance(&clock, TTL + Duration::from_millis(1));
assert_eq!(t.global_count(), 0);
issue(&t, "did:example:6", 10, "s6");
assert_eq!(t.lock().per_did.len(), 1);
}
#[test]
fn authentication_releases_exactly_its_own_slot() {
let (t, _) = tracker(100);
issue(&t, "did:example:a", 10, "s1");
issue(&t, "did:example:a", 10, "s2");
assert!(t.release_session("s1"));
assert_eq!(t.count_for("did:example:a"), 1);
assert_eq!(t.global_count(), 1);
assert!(t.lock().by_session.contains_key("s2"));
}
#[test]
fn double_release_is_a_noop() {
let (t, clock) = tracker(100);
issue(&t, "did:example:a", 10, "s1");
issue(&t, "did:example:a", 10, "s2");
assert!(t.release_session("s1"));
assert!(!t.release_session("s1"));
assert!(!t.release_session("s1"));
assert_eq!(t.count_for("did:example:a"), 1);
assert_eq!(t.global_count(), 1);
advance(&clock, TTL + Duration::from_secs(1));
assert!(!t.release_session("s2"), "already expired");
assert_eq!(t.global_count(), 0);
}
#[test]
fn release_unknown_session_is_a_noop() {
let (t, _) = tracker(100);
assert!(!t.release_session("never-issued"));
assert_eq!(t.global_count(), 0);
}
#[test]
fn cancel_hands_the_slot_back() {
let (t, _) = tracker(1);
let r = t.try_issue("did:example:a", 10).unwrap();
t.cancel(r);
assert_eq!(t.global_count(), 0);
issue(&t, "did:example:b", 10, "s");
}
#[test]
fn released_entries_do_not_linger_in_the_expiry_queue() {
let (t, clock) = tracker(2);
issue(&t, "did:example:a", 10, "old");
advance(&clock, Duration::from_secs(10));
issue(&t, "did:example:b", 10, "mid");
assert!(t.release_session("old"));
issue(&t, "did:example:c", 10, "new");
let err = t.try_issue("did:example:d", 10).unwrap_err();
assert!(matches!(
err,
AppError::RateLimited {
limiter: LIMITER_GLOBAL,
retry_after_secs: 30,
..
}
));
}
#[test]
fn retry_after_rounds_up_and_is_at_least_one() {
let now = Instant::now();
assert_eq!(secs_until(now, now), 1);
assert_eq!(secs_until(now + Duration::from_millis(1), now), 1);
assert_eq!(secs_until(now + Duration::from_millis(1001), now), 2);
assert_eq!(secs_until(now + Duration::from_secs(5), now), 5);
}
#[test]
fn custom_global_cap_is_honoured() {
let (t, _) = tracker(3);
for i in 0..3 {
issue(&t, &format!("did:example:{i}"), 10, &format!("s{i}"));
}
assert_eq!(t.global_count(), 3);
let err = t.try_issue("did:example:over", 10).unwrap_err();
assert!(matches!(
err,
AppError::RateLimited {
limiter: LIMITER_GLOBAL,
..
}
));
}
#[test]
fn for_auth_config_uses_the_configured_global_cap() {
let auth = AuthConfig {
max_global_pending_challenges: 2,
..AuthConfig::default()
};
let t = PendingChallengeTracker::for_auth_config(&auth);
let _ = t.bind(t.try_issue("did:example:a", 100).unwrap(), "s0");
let _ = t.bind(t.try_issue("did:example:b", 100).unwrap(), "s1");
let err = t.try_issue("did:example:c", 100).unwrap_err();
assert!(matches!(
err,
AppError::RateLimited {
limiter: LIMITER_GLOBAL,
..
}
));
}
#[test]
fn config_defaults_match_constants() {
let auth = AuthConfig::default();
assert_eq!(auth.max_global_pending_challenges, MAX_GLOBAL_PENDING);
assert_eq!(
auth.max_pending_challenges_per_did,
crate::routes::auth::MAX_PENDING_CHALLENGES_PER_DID,
);
}
}
#[cfg(all(test, feature = "store-fjall"))]
mod seed_tests {
use super::*;
use did_hosting_common::server::auth::session::store_session;
use did_hosting_common::server::config::StoreConfig;
use did_hosting_common::server::store::{KS_SESSIONS, Store};
fn row(session_id: &str, did: &str, state: SessionState, created_at: u64) -> Session {
Session {
session_id: session_id.into(),
did: did.into(),
challenge: "c".into(),
state,
created_at,
last_seen: created_at,
refresh_token: None,
refresh_expires_at: None,
tee_attested: false,
token_id: None,
session_pubkey_b58btc: None,
amr: vec![],
acr: String::new(),
acr_expires_at: None,
not_after: None,
}
}
#[tokio::test]
async fn seeding_reloads_only_redeemable_challenges() {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
..StoreConfig::default()
})
.await
.unwrap();
let ks = store.keyspace(KS_SESSIONS).unwrap();
let now = now_epoch();
let did = "did:example:a";
for i in 0..3 {
store_session(
&ks,
&row(&format!("live{i}"), did, SessionState::ChallengeSent, now),
)
.await
.unwrap();
}
store_session(
&ks,
&row("stale", did, SessionState::ChallengeSent, now - 3600),
)
.await
.unwrap();
store_session(&ks, &row(did, did, SessionState::Authenticated, now))
.await
.unwrap();
let t = PendingChallengeTracker::with_clock(
Duration::from_secs(30),
100,
Arc::new(Instant::now),
);
assert_eq!(t.seed_from_sessions(&ks).await.unwrap(), 3);
assert_eq!(t.count_for(did), 3);
assert!(t.release_session("live0"));
assert_eq!(t.count_for(did), 2);
assert_eq!(t.seed_from_sessions(&ks).await.unwrap(), 1);
assert_eq!(t.count_for(did), 3);
}
}