Skip to content

Commit f8ac986

Browse files
authored
Merge branch 'main' into ft/auth_testing
2 parents 513e8aa + 3920dcc commit f8ac986

File tree

4 files changed

+86
-34
lines changed

4 files changed

+86
-34
lines changed

backend/lib/src/data/storage/boxed.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::error::Error as StdError;
44

55
use async_trait::async_trait;
66

7-
use super::Storage;
7+
use super::{Storage, WithExpiry};
88

99
/// A boxed storage error that can wrap any storage implementation's error type
1010
pub type BoxedStorageError = Box<dyn StdError + Send + Sync>;
@@ -21,7 +21,7 @@ pub trait BoxedStorage: Send + Sync {
2121
expiration_seconds: u64,
2222
) -> Result<(), BoxedStorageError>;
2323

24-
async fn get_nonce(&self, message: &str) -> Result<Option<String>, BoxedStorageError>;
24+
async fn get_nonce(&self, message: &str) -> Result<WithExpiry<String>, BoxedStorageError>;
2525
}
2626

2727
/// Wrapper struct that implements BoxedStorage for any Storage implementation
@@ -63,7 +63,7 @@ where
6363
.map_err(Self::wrap_err)
6464
}
6565

66-
async fn get_nonce(&self, message: &str) -> Result<Option<String>, BoxedStorageError> {
66+
async fn get_nonce(&self, message: &str) -> Result<WithExpiry<String>, BoxedStorageError> {
6767
self.inner.get_nonce(message).await.map_err(Self::wrap_err)
6868
}
6969
}

backend/lib/src/data/storage/memory.rs

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use tokio::{
2424
};
2525
use tracing::warn;
2626

27-
use super::Storage;
27+
use super::{Storage, WithExpiry};
2828

2929
/// Errors that can occur during in-memory storage operations
3030
#[derive(Debug, Error)]
@@ -38,8 +38,17 @@ pub enum InMemoryStorageError {
3838
struct NonceEntry {
3939
/// The user address associated with the nonce key
4040
address: String,
41-
/// Timestamp when the nonce will expire from storage
42-
expires_at: Instant,
41+
/// Timestamp when the nonce was issued
42+
issued_at: Instant,
43+
/// Duration from `issued_at` when the nonce will expire from storage
44+
expiry: Duration,
45+
}
46+
47+
impl NonceEntry {
48+
/// Checks if the entry has expired by the given `at` timestamp
49+
fn expired_at(&self, at: Instant) -> bool {
50+
at.saturating_duration_since(self.issued_at) >= self.expiry
51+
}
4352
}
4453

4554
/// In-memory storage implementation
@@ -97,7 +106,7 @@ impl InMemoryStorage {
97106
let now = Instant::now();
98107

99108
let mut nonces_guard = nonces.write();
100-
nonces_guard.retain(|_, entry| entry.expires_at > now);
109+
nonces_guard.retain(|_, entry| !entry.expired_at(now));
101110
}
102111
});
103112

@@ -138,30 +147,35 @@ impl Storage for InMemoryStorage {
138147
address: String,
139148
expiration_seconds: u64,
140149
) -> Result<(), Self::Error> {
141-
let now = Instant::now();
142-
let expires_at = now + Duration::from_secs(expiration_seconds);
150+
let issued_at = Instant::now();
151+
let expiry = Duration::from_secs(expiration_seconds);
143152

144153
let entry = NonceEntry {
145154
address,
146-
expires_at,
155+
issued_at,
156+
expiry,
147157
};
148158

149159
self.nonces.write().insert(message, entry);
150160
Ok(())
151161
}
152162

153-
async fn get_nonce(&self, message: &str) -> Result<Option<String>, Self::Error> {
163+
async fn get_nonce(&self, message: &str) -> Result<WithExpiry<String>, Self::Error> {
154164
let mut nonces = self.nonces.write();
155165

156166
if let Some(entry) = nonces.remove(message) {
157167
let now = Instant::now();
158168

159-
if now < entry.expires_at {
160-
return Ok(Some(entry.address));
161-
}
169+
let value = if !entry.expired_at(now) {
170+
WithExpiry::Valid(entry.address)
171+
} else {
172+
WithExpiry::Expired
173+
};
174+
175+
return Ok(value);
162176
}
163177

164-
Ok(None)
178+
Ok(WithExpiry::NotFound)
165179
}
166180
}
167181

@@ -192,11 +206,11 @@ mod tests {
192206

193207
// Retrieve nonce
194208
let retrieved = storage.get_nonce(message).await.unwrap();
195-
assert_eq!(retrieved, Some(address.to_string()));
209+
assert_eq!(retrieved, WithExpiry::Valid(address.to_string()));
196210

197211
// Verify it can't be retrieved twice
198212
let retrieved_again = storage.get_nonce(message).await.unwrap();
199-
assert_eq!(retrieved_again, None);
213+
assert_eq!(retrieved_again, WithExpiry::NotFound);
200214
}
201215

202216
#[tokio::test]
@@ -214,7 +228,7 @@ mod tests {
214228

215229
// Try to retrieve - should be None since it's expired
216230
let retrieved = storage.get_nonce(message).await.unwrap();
217-
assert_eq!(retrieved, None);
231+
assert_eq!(retrieved, WithExpiry::Expired);
218232
}
219233

220234
#[tokio::test(start_paused = true)]
@@ -230,21 +244,25 @@ mod tests {
230244
.await
231245
.unwrap();
232246

233-
// Should be retrievable immediately
234-
let retrieved = storage.get_nonce(message).await.unwrap();
235-
assert_eq!(retrieved, Some(address.to_string()));
236-
237247
// Advance time by 2 seconds to expire the nonce
238248
advance(Duration::from_secs(2)).await;
239249

240-
// Should return None since it's expired
241-
let retrieved_after_expiry = storage.get_nonce(message).await.unwrap();
242-
assert_eq!(retrieved_after_expiry, None);
250+
// At this point the nonce should be expired, but it should still be in storage
251+
{
252+
let guard = storage.nonces.read();
253+
let expired_entry = guard.get(message).expect("should still be present");
254+
assert!(
255+
expired_entry.expired_at(Instant::now()),
256+
"Nonce should be expired by now"
257+
);
258+
}
243259

244260
// Advance time to trigger cleanup task (runs every 10 seconds)
245261
advance(Duration::from_secs(10)).await;
246262

247-
// Should be gone from storage after cleanup task runs
263+
// Should have been cleaned up
264+
let retrieved_after_cleanup = storage.get_nonce(message).await.unwrap();
265+
assert_eq!(retrieved_after_cleanup, WithExpiry::NotFound);
248266
assert!(storage.nonces.read().get(message).is_none());
249267
}
250268
}

backend/lib/src/data/storage/mod.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ pub mod memory;
1010
pub use boxed::{BoxedStorage, BoxedStorageWrapper};
1111
pub use memory::InMemoryStorage;
1212

13+
/// Represents the various possibilities of retrieval for an expirable value
14+
#[derive(Debug, PartialEq, Eq)]
15+
pub enum WithExpiry<T> {
16+
/// The value was found and is still valid
17+
Valid(T),
18+
/// The value was found but has expired
19+
Expired,
20+
/// The value was not found
21+
NotFound,
22+
}
23+
1324
/// Storage trait for backend-specific data operations
1425
#[async_trait]
1526
pub trait Storage: Send + Sync {
@@ -29,7 +40,5 @@ pub trait Storage: Send + Sync {
2940
) -> Result<(), Self::Error>;
3041

3142
/// Retrieve nonce data by message. Will remove the nonce from storage.
32-
///
33-
/// Returns None if not found or expired
34-
async fn get_nonce(&self, message: &str) -> Result<Option<String>, Self::Error>;
43+
async fn get_nonce(&self, message: &str) -> Result<WithExpiry<String>, Self::Error>;
3544
}

backend/lib/src/services/auth.rs

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use crate::{
2323
},
2424
mocks::MOCK_ADDRESS,
2525
},
26-
data::storage::BoxedStorage,
26+
data::storage::{BoxedStorage, WithExpiry},
2727
error::Error,
2828
models::auth::{JwtClaims, NonceResponse, TokenResponse, User, VerifyResponse},
2929
services::Services,
@@ -202,8 +202,12 @@ impl AuthService {
202202
.storage
203203
.get_nonce(message)
204204
.await
205-
.map_err(|_| Error::Internal)?
206-
.ok_or_else(|| Error::Unauthorized("Invalid or expired nonce".to_string()))?;
205+
.map_err(|_| Error::Internal)
206+
.and_then(|entry| match entry {
207+
WithExpiry::Valid(address) => Ok(address),
208+
WithExpiry::Expired => Err(Error::Unauthorized("Expired nonce".to_string())),
209+
WithExpiry::NotFound => Err(Error::Unauthorized("Invalid nonce".to_string())),
210+
})?;
207211

208212
if self.validate_signature {
209213
let recovered_address = Self::recover_eth_address_from_sig(message, signature)?;
@@ -386,6 +390,8 @@ where
386390

387391
#[cfg(test)]
388392
mod tests {
393+
use std::time::Duration;
394+
389395
use jsonwebtoken::{decode, Algorithm, Validation};
390396

391397
use crate::{
@@ -477,7 +483,7 @@ mod tests {
477483

478484
// Check that message was stored in storage
479485
let stored_address = storage.get_nonce(&result.message).await.unwrap();
480-
assert_eq!(stored_address, Some(MOCK_ADDRESS.to_string()));
486+
assert_eq!(stored_address, WithExpiry::Valid(MOCK_ADDRESS.to_string()));
481487
}
482488

483489
#[test]
@@ -528,11 +534,30 @@ mod tests {
528534
let result = auth_service.login(message, &sig_str).await;
529535
assert!(result.is_err(), "Should fail if challenge wasn't called");
530536
match result {
531-
Err(Error::Unauthorized(msg)) => assert!(msg.contains("Invalid or expired nonce")),
537+
Err(Error::Unauthorized(msg)) => assert!(msg.contains("Invalid nonce")),
532538
_ => panic!("Expected unauthorized error for missing nonce"),
533539
}
534540
}
535541

542+
#[tokio::test(start_paused = true)]
543+
async fn login_fails_after_expiry() {
544+
let (auth_service, _, _) = create_test_auth_service(true);
545+
let (address, sk) = eth_wallet();
546+
547+
let challenge = auth_service.challenge(&address, 1).await.unwrap();
548+
let sig_str = sign_message(&sk, &challenge.message);
549+
550+
// Advance time to expire the nonce
551+
tokio::time::advance(Duration::from_secs(AUTH_NONCE_EXPIRATION_SECONDS + 1)).await;
552+
553+
let result = auth_service.login(&challenge.message, &sig_str).await;
554+
assert!(result.is_err(), "Should fail if nonce has expired");
555+
match result {
556+
Err(Error::Unauthorized(msg)) => assert!(msg.contains("Expired nonce")),
557+
_ => panic!("Expected unauthorized error for expired nonce"),
558+
}
559+
}
560+
536561
#[tokio::test]
537562
async fn login_rejects_invalid_signature_when_validation_enabled() {
538563
let (auth_service, _, _) = create_test_auth_service(true);

0 commit comments

Comments
 (0)