use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
const PREFIX: &str = "recognise-challenge:";
pub const DEFAULT_CHALLENGE_TTL: Duration = Duration::minutes(5);
fn key(nonce: &str) -> Vec<u8> {
format!("{PREFIX}{nonce}").into_bytes()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RecogniseChallenge {
aud: String,
expires_at: DateTime<Utc>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConsumedChallenge {
pub aud: String,
}
pub async fn issue(
ks: &KeyspaceHandle,
aud: &str,
ttl: Duration,
now: DateTime<Utc>,
) -> Result<String, AppError> {
let nonce = Uuid::new_v4().to_string();
let rec = RecogniseChallenge {
aud: aud.to_string(),
expires_at: now + ttl,
};
ks.insert(key(&nonce), &rec).await?;
Ok(nonce)
}
pub async fn consume(
ks: &KeyspaceHandle,
nonce: &str,
now: DateTime<Utc>,
) -> Result<ConsumedChallenge, AppError> {
let rec: RecogniseChallenge = ks.get(key(nonce)).await?.ok_or_else(|| {
AppError::Validation(
"no recognise challenge for this nonce (unknown or already consumed)".into(),
)
})?;
ks.remove(key(nonce)).await?;
if now >= rec.expires_at {
return Err(AppError::Validation(
"recognise challenge expired (fetch a fresh one)".into(),
));
}
Ok(ConsumedChallenge { aud: rec.aud })
}
#[cfg(test)]
mod tests {
use super::*;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
fn fresh_ks() -> (tempfile::TempDir, KeyspaceHandle) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store.keyspace("join_requests").unwrap();
(dir, ks)
}
#[tokio::test]
async fn issue_then_consume_round_trips() {
let (_dir, ks) = fresh_ks();
let now = Utc::now();
let nonce = issue(&ks, "did:web:vtc.example", DEFAULT_CHALLENGE_TTL, now)
.await
.unwrap();
let consumed = consume(&ks, &nonce, now).await.unwrap();
assert_eq!(consumed.aud, "did:web:vtc.example");
}
#[tokio::test]
async fn consume_is_single_use() {
let (_dir, ks) = fresh_ks();
let now = Utc::now();
let nonce = issue(&ks, "did:web:vtc.example", DEFAULT_CHALLENGE_TTL, now)
.await
.unwrap();
consume(&ks, &nonce, now).await.expect("first consume");
let err = consume(&ks, &nonce, now).await.unwrap_err();
assert!(
matches!(&err, AppError::Validation(m) if m.contains("already consumed")),
"{err:?}"
);
}
#[tokio::test]
async fn consume_rejects_an_expired_challenge() {
let (_dir, ks) = fresh_ks();
let issued_at = Utc::now() - Duration::minutes(10);
let nonce = issue(&ks, "did:web:vtc.example", DEFAULT_CHALLENGE_TTL, issued_at)
.await
.unwrap();
let err = consume(&ks, &nonce, Utc::now()).await.unwrap_err();
assert!(
matches!(&err, AppError::Validation(m) if m.contains("expired")),
"{err:?}"
);
let again = consume(&ks, &nonce, Utc::now()).await.unwrap_err();
assert!(matches!(&again, AppError::Validation(m) if m.contains("already consumed")));
}
#[tokio::test]
async fn consume_rejects_an_unknown_nonce() {
let (_dir, ks) = fresh_ks();
let err = consume(&ks, "never-issued", Utc::now()).await.unwrap_err();
assert!(
matches!(&err, AppError::Validation(m) if m.contains("unknown")),
"{err:?}"
);
}
}