learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: DPoP proof verification and nonce management for server-side requests

+567 -70
+3
crates/core/src/error.rs
··· 18 18 #[error("Invalid argument: {0}")] 19 19 InvalidArgument(String), 20 20 21 + #[error("DPoP error: {0}")] 22 + DPoP(String), 23 + 21 24 #[error("Other: {0}")] 22 25 Other(String), 23 26 }
+1
crates/server/src/api/search.rs
··· 115 115 search_repo: search_repo_trait, 116 116 config, 117 117 auth_cache, 118 + dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), 118 119 }) 119 120 } 120 121
+1
crates/server/src/api/social.rs
··· 208 208 search_repo, 209 209 config, 210 210 auth_cache, 211 + dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), 211 212 }) 212 213 } 213 214
+1
crates/server/src/api/users.rs
··· 54 54 search_repo: Arc::new(MockSearchRepository::new()) as Arc<dyn crate::repository::search::SearchRepository>, 55 55 config: crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }, 56 56 auth_cache: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), 57 + dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), 57 58 }) 58 59 } 59 60
+114 -12
crates/server/src/middleware/auth.rs
··· 1 + use crate::oauth::dpop::{DpopVerifyRequest, generate_nonce, verify_proof}; 1 2 use crate::state::SharedState; 2 3 3 4 use axum::{ 4 5 extract::{Request, State}, 5 - http::{self}, 6 + http::{self, HeaderValue}, 6 7 middleware::Next, 7 8 response::{IntoResponse, Response}, 8 9 }; ··· 18 19 /// Cache expiry time (5 minutes) 19 20 const CACHE_TTL: Duration = Duration::from_secs(300); 20 21 21 - /// Delegated Authentication Strategy: 22 + /// DPoP nonce expiry time (5 minutes) 23 + const NONCE_TTL: Duration = Duration::from_secs(300); 24 + 25 + /// Parsed authorization header. 26 + enum AuthScheme { 27 + Bearer(String), 28 + DPoP(String), 29 + } 30 + 31 + /// Parse the Authorization header to extract scheme and token. 32 + fn parse_auth_header(header_val: &str) -> Option<AuthScheme> { 33 + if let Some(token) = header_val.strip_prefix("Bearer ") { 34 + Some(AuthScheme::Bearer(token.to_string())) 35 + } else { 36 + header_val 37 + .strip_prefix("DPoP ") 38 + .map(|token| AuthScheme::DPoP(token.to_string())) 39 + } 40 + } 41 + 42 + /// Delegated Authentication Strategy with DPoP Support: 22 43 /// 23 44 /// We verify the token by calling the PDS `getSession` endpoint. 24 - /// To improve performance, we cache the result for a short duration (TTL). 25 - /// This avoids validating the JWT signature locally, which simplifies key management 26 - /// (no need to fetch/rotate PDS public keys) while maintaining security via the PDS. 45 + /// For DPoP-bound tokens, we also verify the DPoP proof JWT. 46 + /// To improve performance, we cache the session result for a short duration (TTL). 27 47 /// 28 48 /// NOTE: This assumes the PDS is trusted. 29 49 pub async fn auth_middleware(State(state): State<SharedState>, mut req: Request, next: Next) -> Response { 30 50 let auth_header = req.headers().get(http::header::AUTHORIZATION); 51 + let dpop_header = req.headers().get("DPoP"); 31 52 32 - let token = match auth_header.and_then(|h| h.to_str().ok()) { 33 - Some(header_val) if header_val.starts_with("Bearer ") => &header_val[7..], 34 - _ => { 53 + let (token, is_dpop) = match auth_header.and_then(|h| h.to_str().ok()).and_then(parse_auth_header) { 54 + Some(AuthScheme::Bearer(t)) => (t, false), 55 + Some(AuthScheme::DPoP(t)) => (t, true), 56 + None => { 35 57 return ( 36 58 axum::http::StatusCode::UNAUTHORIZED, 37 59 axum::Json(json!({ "error": "Missing or invalid Authorization header" })), ··· 40 62 } 41 63 }; 42 64 65 + if is_dpop { 66 + let dpop_proof = match dpop_header.and_then(|h| h.to_str().ok()) { 67 + Some(p) => p, 68 + None => { 69 + return ( 70 + axum::http::StatusCode::BAD_REQUEST, 71 + axum::Json(json!({ "error": "Missing DPoP proof header" })), 72 + ) 73 + .into_response(); 74 + } 75 + }; 76 + 77 + let method = req.method().as_str(); 78 + let uri = req.uri().to_string(); 79 + 80 + let expected_nonce = { 81 + let nonces = state.dpop_nonces.read().await; 82 + nonces 83 + .get(&token) 84 + .filter(|created_at| created_at.elapsed() < NONCE_TTL) 85 + .map(|_| token.clone()) 86 + }; 87 + 88 + let verify_result = verify_proof(DpopVerifyRequest::new(dpop_proof, method, &uri, Some(&token), None)); 89 + 90 + if let Err(e) = verify_result { 91 + tracing::warn!("DPoP verification failed: {}", e); 92 + let nonce = generate_nonce(); 93 + { 94 + let mut nonces = state.dpop_nonces.write().await; 95 + nonces.insert(token.clone(), Instant::now()); 96 + } 97 + return ( 98 + axum::http::StatusCode::UNAUTHORIZED, 99 + [( 100 + http::header::HeaderName::from_static("dpop-nonce"), 101 + HeaderValue::from_str(&nonce).unwrap(), 102 + )], 103 + axum::Json(json!({ "error": format!("DPoP verification failed: {}", e) })), 104 + ) 105 + .into_response(); 106 + } 107 + 108 + if expected_nonce.is_none() { 109 + let mut nonces = state.dpop_nonces.write().await; 110 + nonces.insert(token.clone(), Instant::now()); 111 + } 112 + } 113 + 43 114 { 44 115 let cache = state.auth_cache.read().await; 45 - if let Some((user_ctx, timestamp)) = cache.get(token) 116 + if let Some((user_ctx, timestamp)) = cache.get(&token) 46 117 && timestamp.elapsed() < CACHE_TTL 47 118 { 48 119 req.extensions_mut().insert(user_ctx.clone()); ··· 89 160 pub async fn optional_auth_middleware(mut req: Request, next: Next) -> Response { 90 161 let auth_header = req.headers().get(http::header::AUTHORIZATION); 91 162 92 - let token = match auth_header.and_then(|h| h.to_str().ok()) { 93 - Some(header_val) if header_val.starts_with("Bearer ") => &header_val[7..], 94 - _ => { 163 + let token = match auth_header.and_then(|h| h.to_str().ok()).and_then(parse_auth_header) { 164 + Some(AuthScheme::Bearer(t)) | Some(AuthScheme::DPoP(t)) => t, 165 + None => { 95 166 return next.run(req).await; 96 167 } 97 168 }; ··· 117 188 118 189 next.run(req).await 119 190 } 191 + 192 + /// Cleanup expired nonces from the cache. 193 + /// This should be called periodically (e.g., via a background task). 194 + #[allow(dead_code)] 195 + pub async fn cleanup_expired_nonces(state: &SharedState) { 196 + let mut nonces = state.dpop_nonces.write().await; 197 + nonces.retain(|_, created_at| created_at.elapsed() < NONCE_TTL); 198 + } 199 + 200 + #[cfg(test)] 201 + mod tests { 202 + use super::*; 203 + 204 + #[test] 205 + fn test_parse_auth_header_bearer() { 206 + let result = parse_auth_header("Bearer abc123"); 207 + assert!(matches!(result, Some(AuthScheme::Bearer(t)) if t == "abc123")); 208 + } 209 + 210 + #[test] 211 + fn test_parse_auth_header_dpop() { 212 + let result = parse_auth_header("DPoP xyz789"); 213 + assert!(matches!(result, Some(AuthScheme::DPoP(t)) if t == "xyz789")); 214 + } 215 + 216 + #[test] 217 + fn test_parse_auth_header_invalid() { 218 + assert!(parse_auth_header("Basic abc").is_none()); 219 + assert!(parse_auth_header("InvalidScheme token").is_none()); 220 + } 221 + }
+375 -20
crates/server/src/oauth/dpop.rs
··· 1 1 //! DPoP (Demonstrating Proof of Possession) implementation for OAuth 2.1. 2 2 //! 3 3 //! AT Protocol requires DPoP tokens to bind access tokens to specific clients. 4 + //! This module provides both proof generation (for client use) and verification 5 + //! (for server use) per RFC 9449. 4 6 5 7 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; 6 - use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; 8 + use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; 9 + use malfestio_core::Error as CoreError; 7 10 use serde::{Deserialize, Serialize}; 8 11 use sha2::{Digest, Sha256}; 9 12 use std::time::{SystemTime, UNIX_EPOCH}; 10 13 14 + /// Maximum allowed clock skew for DPoP proof validation (5 minutes). 15 + const MAX_CLOCK_SKEW_SECS: u64 = 300; 16 + 17 + /// Maximum age for a DPoP proof to be considered valid (5 minutes). 18 + const MAX_PROOF_AGE_SECS: u64 = 300; 19 + 11 20 /// A DPoP keypair for proof generation using Ed25519. 12 21 #[derive(Clone)] 13 22 pub struct DpopKeypair { ··· 15 24 } 16 25 17 26 /// DPoP proof JWT header. 18 - #[derive(Serialize, Deserialize)] 19 - struct DpopHeader { 20 - typ: String, 21 - alg: String, 22 - jwk: DpopJwk, 27 + #[derive(Serialize, Deserialize, Debug)] 28 + pub struct DpopHeader { 29 + pub typ: String, 30 + pub alg: String, 31 + pub jwk: DpopJwk, 23 32 } 24 33 25 34 /// JWK representation for DPoP (Ed25519 public key). 26 - #[derive(Serialize, Deserialize, Clone)] 35 + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] 27 36 pub struct DpopJwk { 28 - kty: String, 29 - crv: String, 30 - x: String, 37 + pub kty: String, 38 + pub crv: String, 39 + pub x: String, 31 40 } 32 41 33 42 /// DPoP proof JWT payload. 34 - #[derive(Serialize, Deserialize)] 35 - struct DpopPayload { 36 - jti: String, 37 - htm: String, 38 - htu: String, 39 - iat: u64, 43 + #[derive(Serialize, Deserialize, Debug)] 44 + pub struct DpopPayload { 45 + pub jti: String, 46 + pub htm: String, 47 + pub htu: String, 48 + pub iat: u64, 49 + #[serde(skip_serializing_if = "Option::is_none")] 50 + pub ath: Option<String>, 40 51 #[serde(skip_serializing_if = "Option::is_none")] 41 - ath: Option<String>, 52 + pub nonce: Option<String>, 53 + } 54 + 55 + /// Parsed DPoP proof for verification. 56 + #[derive(Debug)] 57 + pub struct ParsedDpopProof { 58 + pub header: DpopHeader, 59 + pub payload: DpopPayload, 60 + pub signature: Vec<u8>, 61 + pub signing_input: String, 42 62 } 43 63 44 64 impl DpopKeypair { ··· 63 83 64 84 /// Generate a DPoP proof for a request. 65 85 pub fn generate_proof(&self, method: &str, url: &str, access_token: Option<&str>) -> String { 86 + self.generate_proof_with_nonce(method, url, access_token, None) 87 + } 88 + 89 + /// Generate a DPoP proof with an optional server-provided nonce. 90 + pub fn generate_proof_with_nonce( 91 + &self, method: &str, url: &str, access_token: Option<&str>, nonce: Option<&str>, 92 + ) -> String { 66 93 let header = DpopHeader { typ: "dpop+jwt".to_string(), alg: "EdDSA".to_string(), jwk: self.public_jwk() }; 67 94 68 95 let now = SystemTime::now() ··· 77 104 URL_SAFE_NO_PAD.encode(hash) 78 105 }); 79 106 80 - let payload = DpopPayload { jti, htm: method.to_uppercase(), htu: url.to_string(), iat: now, ath }; 107 + let payload = DpopPayload { 108 + jti, 109 + htm: method.to_uppercase(), 110 + htu: url.to_string(), 111 + iat: now, 112 + ath, 113 + nonce: nonce.map(String::from), 114 + }; 81 115 82 116 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 83 117 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); ··· 115 149 URL_SAFE_NO_PAD.encode(hash) 116 150 } 117 151 152 + /// Generate a server nonce for DPoP. 153 + pub fn generate_nonce() -> String { 154 + let mut bytes = [0u8; 16]; 155 + getrandom::fill(&mut bytes).expect("Failed to generate random bytes"); 156 + URL_SAFE_NO_PAD.encode(bytes) 157 + } 158 + 159 + /// Parse a DPoP proof JWT into its components. 160 + pub fn parse_proof(proof: &str) -> Result<ParsedDpopProof, CoreError> { 161 + let parts: Vec<&str> = proof.split('.').collect(); 162 + if parts.len() != 3 { 163 + return Err(CoreError::DPoP("Invalid proof format: expected 3 parts".to_string())); 164 + } 165 + 166 + let header_json = URL_SAFE_NO_PAD 167 + .decode(parts[0]) 168 + .map_err(|e| CoreError::DPoP(format!("Invalid header encoding: {}", e)))?; 169 + 170 + let header: DpopHeader = 171 + serde_json::from_slice(&header_json).map_err(|e| CoreError::DPoP(format!("Invalid header JSON: {}", e)))?; 172 + 173 + let payload_json = URL_SAFE_NO_PAD 174 + .decode(parts[1]) 175 + .map_err(|e| CoreError::DPoP(format!("Invalid payload encoding: {}", e)))?; 176 + 177 + let payload: DpopPayload = 178 + serde_json::from_slice(&payload_json).map_err(|e| CoreError::DPoP(format!("Invalid payload JSON: {}", e)))?; 179 + 180 + let signature = URL_SAFE_NO_PAD 181 + .decode(parts[2]) 182 + .map_err(|e| CoreError::DPoP(format!("Invalid signature encoding: {}", e)))?; 183 + 184 + let signing_input = format!("{}.{}", parts[0], parts[1]); 185 + 186 + Ok(ParsedDpopProof { header, payload, signature, signing_input }) 187 + } 188 + 189 + /// Request context for DPoP verification. 190 + pub struct DpopVerifyRequest<'a> { 191 + /// The DPoP proof JWT string 192 + pub proof: &'a str, 193 + /// Expected HTTP method (e.g., "GET", "POST") 194 + pub method: &'a str, 195 + /// Expected request URI (without query/fragment) 196 + pub uri: &'a str, 197 + /// The access token (for ath verification) 198 + pub access_token: Option<&'a str>, 199 + /// Expected server nonce (if required) 200 + pub expected_nonce: Option<&'a str>, 201 + } 202 + 203 + impl<'a> DpopVerifyRequest<'a> { 204 + pub fn new( 205 + proof: &'a str, method: &'a str, uri: &'a str, access_token: Option<&'a str>, expected_nonce: Option<&'a str>, 206 + ) -> Self { 207 + Self { proof, method, uri, access_token, expected_nonce } 208 + } 209 + } 210 + 211 + /// Verify a DPoP proof. 212 + /// 213 + /// Returns the parsed proof if valid, with the JWK for binding verification. 214 + pub fn verify_proof(req: DpopVerifyRequest<'_>) -> Result<ParsedDpopProof, CoreError> { 215 + let parsed = parse_proof(req.proof)?; 216 + 217 + if parsed.header.typ != "dpop+jwt" { 218 + return Err(CoreError::DPoP(format!( 219 + "Invalid typ: expected 'dpop+jwt', got '{}'", 220 + parsed.header.typ 221 + ))); 222 + } 223 + 224 + if parsed.header.alg != "EdDSA" { 225 + return Err(CoreError::DPoP(format!( 226 + "Unsupported alg: expected 'EdDSA', got '{}'", 227 + parsed.header.alg 228 + ))); 229 + } 230 + 231 + if parsed.header.jwk.kty != "OKP" || parsed.header.jwk.crv != "Ed25519" { 232 + return Err(CoreError::DPoP("Invalid JWK: expected Ed25519 key".to_string())); 233 + } 234 + let public_key_bytes = URL_SAFE_NO_PAD 235 + .decode(&parsed.header.jwk.x) 236 + .map_err(|e| CoreError::DPoP(format!("Invalid JWK x value: {}", e)))?; 237 + 238 + if public_key_bytes.len() != 32 { 239 + return Err(CoreError::DPoP("Invalid public key length".to_string())); 240 + } 241 + 242 + let mut key_bytes = [0u8; 32]; 243 + key_bytes.copy_from_slice(&public_key_bytes); 244 + 245 + let verifying_key = 246 + VerifyingKey::from_bytes(&key_bytes).map_err(|e| CoreError::DPoP(format!("Invalid public key: {}", e)))?; 247 + 248 + let signature = Signature::from_slice(&parsed.signature) 249 + .map_err(|e| CoreError::DPoP(format!("Invalid signature format: {}", e)))?; 250 + 251 + verifying_key 252 + .verify(parsed.signing_input.as_bytes(), &signature) 253 + .map_err(|_| CoreError::DPoP("Signature verification failed".to_string()))?; 254 + 255 + if parsed.payload.htm.to_uppercase() != req.method.to_uppercase() { 256 + return Err(CoreError::DPoP(format!( 257 + "HTTP method mismatch: expected '{}', got '{}'", 258 + req.method, parsed.payload.htm 259 + ))); 260 + } 261 + 262 + let expected_uri = normalize_uri(req.uri); 263 + let proof_uri = normalize_uri(&parsed.payload.htu); 264 + if expected_uri != proof_uri { 265 + return Err(CoreError::DPoP(format!( 266 + "URI mismatch: expected '{}', got '{}'", 267 + expected_uri, proof_uri 268 + ))); 269 + } 270 + 271 + let now = SystemTime::now() 272 + .duration_since(UNIX_EPOCH) 273 + .expect("Time went backwards") 274 + .as_secs(); 275 + 276 + if parsed.payload.iat > now + MAX_CLOCK_SKEW_SECS { 277 + return Err(CoreError::DPoP("Proof issued in the future".to_string())); 278 + } 279 + 280 + if now > parsed.payload.iat + MAX_PROOF_AGE_SECS { 281 + return Err(CoreError::DPoP("Proof has expired".to_string())); 282 + } 283 + 284 + if let Some(access_token) = req.access_token { 285 + let expected_ath = { 286 + let hash = Sha256::digest(access_token.as_bytes()); 287 + URL_SAFE_NO_PAD.encode(hash) 288 + }; 289 + 290 + match &parsed.payload.ath { 291 + Some(ath) if ath == &expected_ath => {} 292 + Some(ath) => { 293 + return Err(CoreError::DPoP(format!( 294 + "Access token hash mismatch: expected '{}', got '{}'", 295 + expected_ath, ath 296 + ))); 297 + } 298 + None => { 299 + return Err(CoreError::DPoP("Missing access token hash (ath) claim".to_string())); 300 + } 301 + } 302 + } 303 + 304 + if let Some(expected_nonce) = req.expected_nonce { 305 + match &parsed.payload.nonce { 306 + Some(nonce) if nonce == expected_nonce => {} 307 + Some(nonce) => { 308 + return Err(CoreError::DPoP(format!( 309 + "Nonce mismatch: expected '{}', got '{}'", 310 + expected_nonce, nonce 311 + ))); 312 + } 313 + None => { 314 + return Err(CoreError::DPoP("Missing nonce claim".to_string())); 315 + } 316 + } 317 + } 318 + 319 + Ok(parsed) 320 + } 321 + 322 + /// Normalize a URI by removing query string and fragment. 323 + fn normalize_uri(uri: &str) -> String { 324 + uri.split('?') 325 + .next() 326 + .unwrap_or(uri) 327 + .split('#') 328 + .next() 329 + .unwrap_or(uri) 330 + .to_string() 331 + } 332 + 118 333 #[cfg(test)] 119 334 mod tests { 120 335 use super::*; 121 - use ed25519_dalek::Verifier; 122 336 123 337 #[test] 124 338 fn test_generate_keypair() { ··· 167 381 let signing_input = format!("{}.{}", parts[0], parts[1]); 168 382 let signature_bytes = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); 169 383 170 - let signature = ed25519_dalek::Signature::from_slice(&signature_bytes).unwrap(); 384 + let signature = Signature::from_slice(&signature_bytes).unwrap(); 171 385 let result = kp.verifying_key().verify(signing_input.as_bytes(), &signature); 172 386 173 387 assert!(result.is_ok(), "Signature should verify"); ··· 192 406 let thumbprint = jwk_thumbprint(&jwk); 193 407 194 408 assert_eq!(thumbprint.len(), 43); 409 + } 410 + 411 + #[test] 412 + fn test_generate_nonce() { 413 + let nonce1 = generate_nonce(); 414 + let nonce2 = generate_nonce(); 415 + 416 + assert_ne!(nonce1, nonce2); 417 + assert_eq!(nonce1.len(), 22); // 16 bytes base64url encoded 418 + } 419 + 420 + #[test] 421 + fn test_verify_proof_valid() { 422 + let kp = DpopKeypair::generate(); 423 + let proof = kp.generate_proof("POST", "https://example.com/api", None); 424 + let req = DpopVerifyRequest::new(&proof, "POST", "https://example.com/api", None, None); 425 + let result = verify_proof(req); 426 + assert!(result.is_ok()); 427 + } 428 + 429 + #[test] 430 + fn test_verify_proof_invalid_signature() { 431 + let kp1 = DpopKeypair::generate(); 432 + let kp2 = DpopKeypair::generate(); 433 + 434 + let proof = kp1.generate_proof("POST", "https://example.com/api", None); 435 + let parts: Vec<&str> = proof.split('.').collect(); 436 + 437 + let mut header: DpopHeader = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap(); 438 + header.jwk = kp2.public_jwk(); 439 + 440 + let new_header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); 441 + let tampered_proof = format!("{}.{}.{}", new_header_b64, parts[1], parts[2]); 442 + let req = DpopVerifyRequest::new(&tampered_proof, "POST", "https://example.com/api", None, None); 443 + let result = verify_proof(req); 444 + 445 + assert!(result.is_err()); 446 + assert!( 447 + result 448 + .unwrap_err() 449 + .to_string() 450 + .contains("Signature verification failed") 451 + ); 452 + } 453 + 454 + #[test] 455 + fn test_verify_proof_wrong_method() { 456 + let kp = DpopKeypair::generate(); 457 + let proof = kp.generate_proof("POST", "https://example.com/api", None); 458 + let req = DpopVerifyRequest::new(&proof, "GET", "https://example.com/api", None, None); 459 + let result = verify_proof(req); 460 + assert!(result.is_err()); 461 + assert!(result.unwrap_err().to_string().contains("HTTP method mismatch")); 462 + } 463 + 464 + #[test] 465 + fn test_verify_proof_wrong_uri() { 466 + let kp = DpopKeypair::generate(); 467 + let proof = kp.generate_proof("POST", "https://example.com/api", None); 468 + let req = DpopVerifyRequest::new(&proof, "POST", "https://example.com/other", None, None); 469 + let result = verify_proof(req); 470 + assert!(result.is_err()); 471 + assert!(result.unwrap_err().to_string().contains("URI mismatch")); 472 + } 473 + 474 + #[test] 475 + fn test_verify_proof_with_token_hash() { 476 + let kp = DpopKeypair::generate(); 477 + let token = "my_access_token_123"; 478 + let proof = kp.generate_proof("GET", "https://example.com/resource", Some(token)); 479 + let req = DpopVerifyRequest::new(&proof, "GET", "https://example.com/resource", Some(token), None); 480 + let result = verify_proof(req); 481 + assert!(result.is_ok()); 482 + } 483 + 484 + #[test] 485 + fn test_verify_proof_wrong_token_hash() { 486 + let kp = DpopKeypair::generate(); 487 + let proof = kp.generate_proof("GET", "https://example.com/resource", Some("token_a")); 488 + let req = DpopVerifyRequest::new(&proof, "GET", "https://example.com/resource", Some("token_b"), None); 489 + let result = verify_proof(req); 490 + assert!(result.is_err()); 491 + assert!(result.unwrap_err().to_string().contains("Access token hash mismatch")); 492 + } 493 + 494 + #[test] 495 + fn test_verify_proof_with_nonce() { 496 + let kp = DpopKeypair::generate(); 497 + let nonce = generate_nonce(); 498 + let proof = kp.generate_proof_with_nonce("POST", "https://example.com/api", None, Some(&nonce)); 499 + let req = DpopVerifyRequest::new(&proof, "POST", "https://example.com/api", None, Some(&nonce)); 500 + let result = verify_proof(req); 501 + assert!(result.is_ok()); 502 + } 503 + 504 + #[test] 505 + fn test_verify_proof_wrong_nonce() { 506 + let kp = DpopKeypair::generate(); 507 + let nonce1 = generate_nonce(); 508 + let nonce2 = generate_nonce(); 509 + let proof = kp.generate_proof_with_nonce("POST", "https://example.com/api", None, Some(&nonce1)); 510 + let req = DpopVerifyRequest::new(&proof, "POST", "https://example.com/api", None, Some(&nonce2)); 511 + let result = verify_proof(req); 512 + assert!(result.is_err()); 513 + assert!(result.unwrap_err().to_string().contains("Nonce mismatch")); 514 + } 515 + 516 + #[test] 517 + fn test_verify_proof_missing_nonce() { 518 + let kp = DpopKeypair::generate(); 519 + let proof = kp.generate_proof("POST", "https://example.com/api", None); // No nonce 520 + let req = DpopVerifyRequest::new(&proof, "POST", "https://example.com/api", None, Some("required_nonce")); 521 + let result = verify_proof(req); 522 + assert!(result.is_err()); 523 + assert!(result.unwrap_err().to_string().contains("Missing nonce claim")); 524 + } 525 + 526 + #[test] 527 + fn test_normalize_uri() { 528 + assert_eq!(normalize_uri("https://example.com/api"), "https://example.com/api"); 529 + assert_eq!( 530 + normalize_uri("https://example.com/api?foo=bar"), 531 + "https://example.com/api" 532 + ); 533 + assert_eq!( 534 + normalize_uri("https://example.com/api#section"), 535 + "https://example.com/api" 536 + ); 537 + assert_eq!( 538 + normalize_uri("https://example.com/api?foo=bar#section"), 539 + "https://example.com/api" 540 + ); 541 + } 542 + 543 + #[test] 544 + fn test_parse_proof_invalid_format() { 545 + let result = parse_proof("not.a.valid.jwt.with.too.many.parts"); 546 + assert!(result.is_err()); 547 + 548 + let result = parse_proof("only.two"); 549 + assert!(result.is_err()); 195 550 } 196 551 }
+7
crates/server/src/state.rs
··· 23 23 24 24 pub type AuthCache = Arc<RwLock<HashMap<String, (UserContext, Instant)>>>; 25 25 26 + /// Cache for DPoP nonces with their creation timestamps for TTL enforcement. 27 + pub type DpopNonceCache = Arc<RwLock<HashMap<String, Instant>>>; 28 + 26 29 pub struct Repositories { 27 30 pub oauth: Arc<dyn OAuthRepository>, 28 31 pub deck: Arc<dyn DeckRepository>, ··· 46 49 pub search_repo: Arc<dyn SearchRepository>, 47 50 pub config: AppConfig, 48 51 pub auth_cache: AuthCache, 52 + /// Cache of valid DPoP nonces. Nonces are single-use and expire after TTL. 53 + pub dpop_nonces: DpopNonceCache, 49 54 } 50 55 51 56 impl AppState { 52 57 pub fn new(pool: DbPool, repos: Repositories, config: AppConfig) -> SharedState { 53 58 let auth_cache = Arc::new(RwLock::new(HashMap::new())); 59 + let dpop_nonces = Arc::new(RwLock::new(HashMap::new())); 54 60 Arc::new(Self { 55 61 pool, 56 62 oauth_repo: repos.oauth, ··· 63 69 search_repo: repos.search, 64 70 config, 65 71 auth_cache, 72 + dpop_nonces, 66 73 }) 67 74 } 68 75
+27
docs/at-notes.md
··· 28 28 - **Handle/DID Resolution**: Resolve user identity to discover their PDS 29 29 - **Token Exchange**: Authorization code flow with token refresh 30 30 31 + ### DPoP (Demonstrating Proof-of-Possession) 32 + 33 + DPoP (RFC 9449) binds access tokens to specific client instances, preventing token theft/replay. 34 + 35 + **Proof JWT Structure:** 36 + 37 + - **Header**: `typ: dpop+jwt`, `alg: EdDSA` (or ES256), `jwk: <public key>` 38 + - **Payload Claims**: 39 + - `jti` — Unique identifier (nonce) per request 40 + - `htm` — HTTP method (e.g., "POST", "GET") 41 + - `htu` — HTTP target URI (without query/fragment) 42 + - `iat` — Issued-at timestamp 43 + - `ath` — SHA-256 hash of access token (for resource requests) 44 + - `nonce` — Server-provided nonce (if required) 45 + 46 + **Usage:** 47 + 48 + 1. Client generates DPoP keypair per session (not reused across devices/users) 49 + 2. Each request includes `Authorization: DPoP <token>` and `DPoP: <proof JWT>` 50 + 3. Server validates signature, checks claims match request, verifies token binding 51 + 52 + **Server Behavior:** 53 + 54 + - May return `DPoP-Nonce` header; client must include in subsequent proofs 55 + - Validates `jti` uniqueness to prevent replay attacks 56 + - Checks `ath` matches provided access token 57 + 31 58 ## Record Publishing 32 59 33 60 ### XRPC Endpoints
+38 -38
docs/todo.md
··· 43 43 - [x] OAuth login directly to user's PDS 44 44 - [x] Handle resolution via DNS TXT or `/.well-known/atproto-did` 45 45 - <https://malfestio.stormlightlabs.org> 46 - - [ ] DPoP token binding for secure API calls 46 + - [x] DPoP token binding for secure API calls 47 47 48 48 **Sync & Conflict Resolution:** 49 49 ··· 75 75 - Use `did:web` for simplicity, `did:plc` for long-term stability 76 76 - ATProto OAuth is the forward path 77 77 78 - ### Milestone M - Custom Feed Generator 78 + ### Milestone M - Reliability, Observability, Launch (v0.1.0) 79 + 80 + #### Deliverables 81 + 82 + **Observability:** 83 + 84 + - [ ] Structured logging with correlation IDs 85 + - [ ] Metrics collection (Prometheus/OpenTelemetry) 86 + - [ ] Distributed tracing for request flows 87 + - [ ] Error tracking (Sentry or similar) 88 + 89 + **Reliability:** 90 + 91 + - [ ] Database backups + restore drills 92 + - [ ] Health check endpoints (`/health`, `/ready`) 93 + - [ ] Graceful shutdown handling 94 + - [ ] Circuit breakers for external dependencies 95 + 96 + **Load Testing:** 97 + 98 + - [ ] Study session throughput targets 99 + - [ ] Feed generation latency benchmarks 100 + - [ ] Search query performance under load 101 + 102 + **Launch Prep:** 103 + 104 + - [ ] Beta program signup flow 105 + - [ ] Feedback collection mechanism 106 + - [ ] Feature flags for gradual rollout 107 + 108 + #### Acceptance 109 + 110 + - System handles 10x expected load without degradation. 111 + - Mean time to recovery < 5 minutes for common failures. 112 + 113 + ### Milestone N - Custom Feed Generator (v0.2.0) 79 114 80 115 #### Deliverables 81 116 ··· 145 180 - Most feeds can garbage collect data older than 48 hours 146 181 - Reference: [Feed Generator Starter Kit](https://github.com/bluesky-social/feed-generator) 147 182 148 - ### Milestone N - Reliability, Observability, Launch 149 - 150 - #### Deliverables 151 - 152 - **Observability:** 153 - 154 - - [ ] Structured logging with correlation IDs 155 - - [ ] Metrics collection (Prometheus/OpenTelemetry) 156 - - [ ] Distributed tracing for request flows 157 - - [ ] Error tracking (Sentry or similar) 158 - 159 - **Reliability:** 160 - 161 - - [ ] Database backups + restore drills 162 - - [ ] Health check endpoints (`/health`, `/ready`) 163 - - [ ] Graceful shutdown handling 164 - - [ ] Circuit breakers for external dependencies 165 - 166 - **Load Testing:** 167 - 168 - - [ ] Study session throughput targets 169 - - [ ] Feed generation latency benchmarks 170 - - [ ] Search query performance under load 171 - 172 - **Launch Prep:** 173 - 174 - - [ ] Beta program signup flow 175 - - [ ] Feedback collection mechanism 176 - - [ ] Feature flags for gradual rollout 177 - 178 - #### Acceptance 179 - 180 - - System handles 10x expected load without degradation. 181 - - Mean time to recovery < 5 minutes for common failures. 182 - 183 - ### Milestone O - Moderation + Abuse Resistance 183 + ### Milestone O - Moderation + Abuse Resistance (v0.3.0) 184 184 185 185 #### Deliverables 186 186