use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::store::KeyspaceHandle;
use super::session::now_epoch;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PendingStepUp {
pub challenge: String,
pub session_id: String,
pub subject: String,
pub target_acr: String,
#[serde(default)]
pub acceptable_evidence: Vec<String>,
pub created_at: u64,
pub expires_at: u64,
}
fn step_up_key(challenge: &str) -> String {
format!("stepup:{challenge}")
}
#[derive(Debug, PartialEq)]
pub enum ConsumeOutcome {
NotFound,
Expired,
Found(Box<PendingStepUp>),
}
pub async fn store_pending_step_up(
sessions: &KeyspaceHandle,
pending: &PendingStepUp,
) -> Result<(), AppError> {
sessions
.insert(step_up_key(&pending.challenge), pending)
.await
}
pub async fn get_pending_step_up(
sessions: &KeyspaceHandle,
challenge: &str,
) -> Result<Option<PendingStepUp>, AppError> {
sessions.get(step_up_key(challenge)).await
}
pub async fn consume_pending_step_up(
sessions: &KeyspaceHandle,
challenge: &str,
now: u64,
) -> Result<ConsumeOutcome, AppError> {
let key = step_up_key(challenge);
let Some(pending): Option<PendingStepUp> = sessions.get(key.clone()).await? else {
return Ok(ConsumeOutcome::NotFound);
};
sessions.remove(key).await?;
if now >= pending.expires_at {
return Ok(ConsumeOutcome::Expired);
}
Ok(ConsumeOutcome::Found(Box::new(pending)))
}
pub fn new_pending_step_up(
challenge: impl Into<String>,
session_id: impl Into<String>,
subject: impl Into<String>,
target_acr: impl Into<String>,
acceptable_evidence: Vec<String>,
ttl_secs: u64,
) -> PendingStepUp {
let created_at = now_epoch();
PendingStepUp {
challenge: challenge.into(),
session_id: session_id.into(),
subject: subject.into(),
target_acr: target_acr.into(),
acceptable_evidence,
created_at,
expires_at: created_at.saturating_add(ttl_secs),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::StoreConfig;
use crate::store::Store;
async fn ks() -> KeyspaceHandle {
let dir = tempfile::tempdir().expect("tempdir");
let dir = Box::leak(Box::new(dir));
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.expect("open store");
store.keyspace("sessions").expect("keyspace")
}
fn sample(challenge: &str, expires_at: u64) -> PendingStepUp {
PendingStepUp {
challenge: challenge.to_string(),
session_id: "sess-1".to_string(),
subject: "did:key:zHolder".to_string(),
target_acr: "aal2".to_string(),
acceptable_evidence: vec!["did-signed".into(), "webauthn".into()],
created_at: 1000,
expires_at,
}
}
#[tokio::test]
async fn round_trips_and_consumes_once() {
let ks = ks().await;
let p = sample("VHJhbnNmZXJDb25maXJtTm9uY2VYWQ", now_epoch() + 300);
store_pending_step_up(&ks, &p).await.unwrap();
assert_eq!(
get_pending_step_up(&ks, &p.challenge).await.unwrap(),
Some(p.clone())
);
match consume_pending_step_up(&ks, &p.challenge, now_epoch())
.await
.unwrap()
{
ConsumeOutcome::Found(found) => assert_eq!(*found, p),
other => panic!("expected Found, got {other:?}"),
}
assert_eq!(
consume_pending_step_up(&ks, &p.challenge, now_epoch())
.await
.unwrap(),
ConsumeOutcome::NotFound
);
}
#[tokio::test]
async fn unknown_challenge_is_not_found() {
let ks = ks().await;
assert_eq!(
consume_pending_step_up(&ks, "no-such-challenge", now_epoch())
.await
.unwrap(),
ConsumeOutcome::NotFound
);
}
#[tokio::test]
async fn expired_challenge_is_consumed_and_reported_expired() {
let ks = ks().await;
let p = sample("RXhwaXJlZENoYWxsZW5nZVZhbHVlWA", 1000); store_pending_step_up(&ks, &p).await.unwrap();
assert_eq!(
consume_pending_step_up(&ks, &p.challenge, now_epoch())
.await
.unwrap(),
ConsumeOutcome::Expired
);
assert_eq!(get_pending_step_up(&ks, &p.challenge).await.unwrap(), None);
}
#[test]
fn new_pending_sets_expiry() {
let p = new_pending_step_up(
"VHJhbnNmZXJDb25maXJtTm9uY2VYWQ",
"sess-1",
"did:key:zHolder",
"aal2",
vec!["webauthn".into()],
300,
);
assert_eq!(p.expires_at, p.created_at + 300);
assert_eq!(p.target_acr, "aal2");
}
}