Skip to main content

reifydb_auth/
challenge.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2025 ReifyDB
3
4//! In-memory challenge store for multi-step authentication flows.
5//!
6//! Challenges are one-time-use and expire after a configurable TTL.
7
8use std::{
9	collections::HashMap,
10	sync::RwLock,
11	time::{Duration, Instant},
12};
13
14use reifydb_type::value::uuid::Uuid7;
15
16/// A pending authentication challenge.
17struct ChallengeEntry {
18	pub identifier: String,
19	pub method: String,
20	pub payload: HashMap<String, String>,
21	pub created_at: Instant,
22}
23
24/// Stored challenge info returned when consuming a challenge.
25pub struct ChallengeInfo {
26	pub identifier: String,
27	pub method: String,
28	pub payload: HashMap<String, String>,
29}
30
31/// In-memory store for pending authentication challenges.
32///
33/// Challenges are created during multi-step authentication (e.g., wallet signing)
34/// and consumed on the client's response. Each challenge is one-time-use and
35/// expires after the configured TTL.
36pub struct ChallengeStore {
37	entries: RwLock<HashMap<String, ChallengeEntry>>,
38	ttl: Duration,
39}
40
41impl ChallengeStore {
42	pub fn new(ttl: Duration) -> Self {
43		Self {
44			entries: RwLock::new(HashMap::new()),
45			ttl,
46		}
47	}
48
49	/// Create a new challenge and return its ID.
50	pub fn create(&self, identifier: String, method: String, payload: HashMap<String, String>) -> String {
51		let challenge_id = Uuid7::generate().to_string();
52		let entry = ChallengeEntry {
53			identifier,
54			method,
55			payload,
56			created_at: Instant::now(),
57		};
58		let mut entries = self.entries.write().unwrap();
59		entries.insert(challenge_id.clone(), entry);
60		challenge_id
61	}
62
63	/// Consume a challenge by ID. Returns the challenge data if valid and not expired.
64	/// The challenge is removed after consumption (one-time use).
65	pub fn consume(&self, challenge_id: &str) -> Option<ChallengeInfo> {
66		let mut entries = self.entries.write().unwrap();
67		let entry = entries.remove(challenge_id)?;
68
69		if entry.created_at.elapsed() > self.ttl {
70			return None;
71		}
72
73		Some(ChallengeInfo {
74			identifier: entry.identifier,
75			method: entry.method,
76			payload: entry.payload,
77		})
78	}
79
80	/// Remove all expired entries.
81	pub fn cleanup_expired(&self) {
82		let ttl = self.ttl;
83		let mut entries = self.entries.write().unwrap();
84		entries.retain(|_, e| e.created_at.elapsed() <= ttl);
85	}
86}
87
88#[cfg(test)]
89mod tests {
90	use std::thread;
91
92	use super::*;
93
94	#[test]
95	fn test_create_and_consume() {
96		let store = ChallengeStore::new(Duration::from_secs(60));
97		let data = HashMap::from([("nonce".to_string(), "abc123".to_string())]);
98
99		let id = store.create("alice".to_string(), "solana".to_string(), data);
100		let info = store.consume(&id).unwrap();
101
102		assert_eq!(info.identifier, "alice");
103		assert_eq!(info.method, "solana");
104		assert_eq!(info.payload.get("nonce").unwrap(), "abc123");
105	}
106
107	#[test]
108	fn test_one_time_use() {
109		let store = ChallengeStore::new(Duration::from_secs(60));
110		let id = store.create("alice".to_string(), "solana".to_string(), HashMap::new());
111
112		assert!(store.consume(&id).is_some());
113		assert!(store.consume(&id).is_none()); // second attempt fails
114	}
115
116	#[test]
117	fn test_unknown_challenge() {
118		let store = ChallengeStore::new(Duration::from_secs(60));
119		assert!(store.consume("nonexistent").is_none());
120	}
121
122	#[test]
123	fn test_expired_challenge() {
124		let store = ChallengeStore::new(Duration::from_millis(1));
125		let id = store.create("alice".to_string(), "solana".to_string(), HashMap::new());
126
127		thread::sleep(Duration::from_millis(10));
128		assert!(store.consume(&id).is_none());
129	}
130}