An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(auth): DPoP nonce store — issue, validate_and_consume, cleanup_expired

Implements MM-77.AC3 nonce management:

- issue_nonce: Generate 22-char base64url nonce, store with 5-min TTL
- validate_and_consume_nonce: Verify and consume nonce (once only, reject if expired/unknown)
- cleanup_expired_nonces: Prune expired nonces on each token request

Adds comprehensive unit tests verifying:
- Fresh nonce validates exactly once (AC3.1)
- Unknown nonces rejected (AC3.4)
- Expired nonces rejected (AC3.3)
- Cleanup preserves fresh nonces while pruning expired ones
- Nonce format is 22-char base64url (16 random bytes)

All 54 auth tests pass.

+136
+136
crates/relay/src/auth/mod.rs
··· 6 6 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; 7 7 use common::{ApiError, ErrorCode}; 8 8 use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; 9 + use rand_core::{OsRng, RngCore}; 9 10 use serde::Deserialize; 10 11 use sha2::{Digest, Sha256}; 11 12 ··· 218 219 /// Create an empty `DpopNonceStore`. 219 220 pub fn new_nonce_store() -> DpopNonceStore { 220 221 Arc::new(Mutex::new(HashMap::new())) 222 + } 223 + 224 + /// Issue a fresh DPoP nonce with a 5-minute TTL. 225 + /// 226 + /// Returns a 22-character base64url string (16 random bytes). The nonce is 227 + /// inserted into the store with an expiry of `Instant::now() + 5 minutes`. 228 + #[allow(dead_code)] 229 + pub(crate) async fn issue_nonce(store: &DpopNonceStore) -> String { 230 + let mut bytes = [0u8; 16]; 231 + OsRng.fill_bytes(&mut bytes); 232 + let nonce = URL_SAFE_NO_PAD.encode(bytes); 233 + let expiry = std::time::Instant::now() + std::time::Duration::from_secs(300); 234 + store.lock().await.insert(nonce.clone(), expiry); 235 + nonce 236 + } 237 + 238 + /// Validate and consume a DPoP nonce. 239 + /// 240 + /// Returns `true` if the nonce is present in the store and has not expired. 241 + /// Removes the nonce unconditionally (whether valid or expired) to prevent reuse. 242 + /// Returns `false` for unknown nonces. 243 + #[allow(dead_code)] 244 + pub(crate) async fn validate_and_consume_nonce(store: &DpopNonceStore, nonce: &str) -> bool { 245 + let mut map = store.lock().await; 246 + match map.remove(nonce) { 247 + Some(expiry) => expiry > std::time::Instant::now(), 248 + None => false, 249 + } 250 + } 251 + 252 + /// Remove all expired nonces from the store. 253 + /// 254 + /// Call this on every token request to prevent unbounded memory growth. 255 + /// Under normal relay load (low request volume) this is sufficient without a background task. 256 + #[allow(dead_code)] 257 + pub(crate) async fn cleanup_expired_nonces(store: &DpopNonceStore) { 258 + let now = std::time::Instant::now(); 259 + store.lock().await.retain(|_, expiry| *expiry > now); 221 260 } 222 261 223 262 /// Load the OAuth signing key from the database, or generate a new one on first boot. ··· 1368 1407 ); 1369 1408 // Stable regression guard — verified against this implementation. 1370 1409 assert_eq!(thumb, "oKIywvGUpTVTyxMQ3bwIIeQUudfr_CkLMjCE19ECD-U"); 1410 + } 1411 + 1412 + // ── DPoP nonce store tests ──────────────────────────────────────────────── 1413 + 1414 + #[tokio::test] 1415 + async fn issued_nonce_validates_once() { 1416 + // AC3.1: Valid unexpired nonce is accepted. 1417 + let store = new_nonce_store(); 1418 + let nonce = issue_nonce(&store).await; 1419 + 1420 + // First use: valid. 1421 + assert!( 1422 + validate_and_consume_nonce(&store, &nonce).await, 1423 + "freshly issued nonce must validate" 1424 + ); 1425 + 1426 + // Second use: consumed — must fail (even though not expired). 1427 + assert!( 1428 + !validate_and_consume_nonce(&store, &nonce).await, 1429 + "already-consumed nonce must not validate again" 1430 + ); 1431 + } 1432 + 1433 + #[tokio::test] 1434 + async fn unknown_nonce_is_rejected() { 1435 + // AC3.4: Fabricated nonce not in store. 1436 + let store = new_nonce_store(); 1437 + assert!( 1438 + !validate_and_consume_nonce(&store, "this-nonce-was-never-issued").await, 1439 + "unknown nonce must be rejected" 1440 + ); 1441 + } 1442 + 1443 + #[tokio::test] 1444 + async fn expired_nonce_is_rejected() { 1445 + // AC3.3: Expired nonce returns false. 1446 + let store = new_nonce_store(); 1447 + // Manually insert a nonce that expired 1 second in the past. 1448 + let nonce = "expired-nonce-test"; 1449 + { 1450 + let mut map = store.lock().await; 1451 + let past = std::time::Instant::now() 1452 + .checked_sub(std::time::Duration::from_secs(1)) 1453 + .unwrap(); 1454 + map.insert(nonce.to_string(), past); 1455 + } 1456 + 1457 + assert!( 1458 + !validate_and_consume_nonce(&store, nonce).await, 1459 + "expired nonce must be rejected" 1460 + ); 1461 + } 1462 + 1463 + #[tokio::test] 1464 + async fn cleanup_removes_only_expired_nonces() { 1465 + let store = new_nonce_store(); 1466 + 1467 + // Insert one fresh nonce (not yet expired). 1468 + let fresh_nonce = issue_nonce(&store).await; 1469 + 1470 + // Insert one already-expired nonce directly. 1471 + { 1472 + let mut map = store.lock().await; 1473 + let past = std::time::Instant::now() 1474 + .checked_sub(std::time::Duration::from_secs(1)) 1475 + .unwrap(); 1476 + map.insert("stale-nonce".to_string(), past); 1477 + } 1478 + 1479 + cleanup_expired_nonces(&store).await; 1480 + 1481 + let map = store.lock().await; 1482 + assert!( 1483 + map.contains_key(&fresh_nonce), 1484 + "fresh nonce must survive cleanup" 1485 + ); 1486 + assert!( 1487 + !map.contains_key("stale-nonce"), 1488 + "stale nonce must be pruned by cleanup" 1489 + ); 1490 + } 1491 + 1492 + #[tokio::test] 1493 + async fn issued_nonce_is_22_chars_base64url() { 1494 + let store = new_nonce_store(); 1495 + let nonce = issue_nonce(&store).await; 1496 + assert_eq!( 1497 + nonce.len(), 1498 + 22, 1499 + "nonce must be 22 chars (16 bytes base64url no-pad)" 1500 + ); 1501 + assert!( 1502 + nonce 1503 + .chars() 1504 + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), 1505 + "nonce must be base64url charset" 1506 + ); 1371 1507 } 1372 1508 1373 1509 #[test]