diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index 2a95f03..f0d3136 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -18,6 +18,9 @@ pub enum Error { #[error("Invalid argument: {0}")] InvalidArgument(String), + #[error("DPoP error: {0}")] + DPoP(String), + #[error("Other: {0}")] Other(String), } diff --git a/crates/server/src/api/search.rs b/crates/server/src/api/search.rs index 363587f..1dc9551 100644 --- a/crates/server/src/api/search.rs +++ b/crates/server/src/api/search.rs @@ -115,6 +115,7 @@ mod tests { search_repo: search_repo_trait, config, auth_cache, + dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), }) } diff --git a/crates/server/src/api/social.rs b/crates/server/src/api/social.rs index ed0871b..d625132 100644 --- a/crates/server/src/api/social.rs +++ b/crates/server/src/api/social.rs @@ -208,6 +208,7 @@ mod tests { search_repo, config, auth_cache, + dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), }) } diff --git a/crates/server/src/api/users.rs b/crates/server/src/api/users.rs index bf1483a..f90b72d 100644 --- a/crates/server/src/api/users.rs +++ b/crates/server/src/api/users.rs @@ -54,6 +54,7 @@ mod tests { search_repo: Arc::new(MockSearchRepository::new()) as Arc, config: crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }, auth_cache: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), + dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), }) } diff --git a/crates/server/src/middleware/auth.rs b/crates/server/src/middleware/auth.rs index 2d6e921..a2be8ec 100644 --- a/crates/server/src/middleware/auth.rs +++ b/crates/server/src/middleware/auth.rs @@ -1,8 +1,9 @@ +use crate::oauth::dpop::{DpopVerifyRequest, generate_nonce, verify_proof}; use crate::state::SharedState; use axum::{ extract::{Request, State}, - http::{self}, + http::{self, HeaderValue}, middleware::Next, response::{IntoResponse, Response}, }; @@ -18,20 +19,41 @@ pub struct UserContext { /// Cache expiry time (5 minutes) const CACHE_TTL: Duration = Duration::from_secs(300); -/// Delegated Authentication Strategy: +/// DPoP nonce expiry time (5 minutes) +const NONCE_TTL: Duration = Duration::from_secs(300); + +/// Parsed authorization header. +enum AuthScheme { + Bearer(String), + DPoP(String), +} + +/// Parse the Authorization header to extract scheme and token. +fn parse_auth_header(header_val: &str) -> Option { + if let Some(token) = header_val.strip_prefix("Bearer ") { + Some(AuthScheme::Bearer(token.to_string())) + } else { + header_val + .strip_prefix("DPoP ") + .map(|token| AuthScheme::DPoP(token.to_string())) + } +} + +/// Delegated Authentication Strategy with DPoP Support: /// /// We verify the token by calling the PDS `getSession` endpoint. -/// To improve performance, we cache the result for a short duration (TTL). -/// This avoids validating the JWT signature locally, which simplifies key management -/// (no need to fetch/rotate PDS public keys) while maintaining security via the PDS. +/// For DPoP-bound tokens, we also verify the DPoP proof JWT. +/// To improve performance, we cache the session result for a short duration (TTL). /// /// NOTE: This assumes the PDS is trusted. pub async fn auth_middleware(State(state): State, mut req: Request, next: Next) -> Response { let auth_header = req.headers().get(http::header::AUTHORIZATION); + let dpop_header = req.headers().get("DPoP"); - let token = match auth_header.and_then(|h| h.to_str().ok()) { - Some(header_val) if header_val.starts_with("Bearer ") => &header_val[7..], - _ => { + let (token, is_dpop) = match auth_header.and_then(|h| h.to_str().ok()).and_then(parse_auth_header) { + Some(AuthScheme::Bearer(t)) => (t, false), + Some(AuthScheme::DPoP(t)) => (t, true), + None => { return ( axum::http::StatusCode::UNAUTHORIZED, axum::Json(json!({ "error": "Missing or invalid Authorization header" })), @@ -40,9 +62,58 @@ pub async fn auth_middleware(State(state): State, mut req: Request, } }; + if is_dpop { + let dpop_proof = match dpop_header.and_then(|h| h.to_str().ok()) { + Some(p) => p, + None => { + return ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(json!({ "error": "Missing DPoP proof header" })), + ) + .into_response(); + } + }; + + let method = req.method().as_str(); + let uri = req.uri().to_string(); + + let expected_nonce = { + let nonces = state.dpop_nonces.read().await; + nonces + .get(&token) + .filter(|created_at| created_at.elapsed() < NONCE_TTL) + .map(|_| token.clone()) + }; + + let verify_result = verify_proof(DpopVerifyRequest::new(dpop_proof, method, &uri, Some(&token), None)); + + if let Err(e) = verify_result { + tracing::warn!("DPoP verification failed: {}", e); + let nonce = generate_nonce(); + { + let mut nonces = state.dpop_nonces.write().await; + nonces.insert(token.clone(), Instant::now()); + } + return ( + axum::http::StatusCode::UNAUTHORIZED, + [( + http::header::HeaderName::from_static("dpop-nonce"), + HeaderValue::from_str(&nonce).unwrap(), + )], + axum::Json(json!({ "error": format!("DPoP verification failed: {}", e) })), + ) + .into_response(); + } + + if expected_nonce.is_none() { + let mut nonces = state.dpop_nonces.write().await; + nonces.insert(token.clone(), Instant::now()); + } + } + { let cache = state.auth_cache.read().await; - if let Some((user_ctx, timestamp)) = cache.get(token) + if let Some((user_ctx, timestamp)) = cache.get(&token) && timestamp.elapsed() < CACHE_TTL { req.extensions_mut().insert(user_ctx.clone()); @@ -89,9 +160,9 @@ pub async fn auth_middleware(State(state): State, mut req: Request, pub async fn optional_auth_middleware(mut req: Request, next: Next) -> Response { let auth_header = req.headers().get(http::header::AUTHORIZATION); - let token = match auth_header.and_then(|h| h.to_str().ok()) { - Some(header_val) if header_val.starts_with("Bearer ") => &header_val[7..], - _ => { + let token = match auth_header.and_then(|h| h.to_str().ok()).and_then(parse_auth_header) { + Some(AuthScheme::Bearer(t)) | Some(AuthScheme::DPoP(t)) => t, + None => { return next.run(req).await; } }; @@ -117,3 +188,34 @@ pub async fn optional_auth_middleware(mut req: Request, next: Next) -> Response next.run(req).await } + +/// Cleanup expired nonces from the cache. +/// This should be called periodically (e.g., via a background task). +#[allow(dead_code)] +pub async fn cleanup_expired_nonces(state: &SharedState) { + let mut nonces = state.dpop_nonces.write().await; + nonces.retain(|_, created_at| created_at.elapsed() < NONCE_TTL); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_auth_header_bearer() { + let result = parse_auth_header("Bearer abc123"); + assert!(matches!(result, Some(AuthScheme::Bearer(t)) if t == "abc123")); + } + + #[test] + fn test_parse_auth_header_dpop() { + let result = parse_auth_header("DPoP xyz789"); + assert!(matches!(result, Some(AuthScheme::DPoP(t)) if t == "xyz789")); + } + + #[test] + fn test_parse_auth_header_invalid() { + assert!(parse_auth_header("Basic abc").is_none()); + assert!(parse_auth_header("InvalidScheme token").is_none()); + } +} diff --git a/crates/server/src/oauth/dpop.rs b/crates/server/src/oauth/dpop.rs index b9d5fc5..55e0231 100644 --- a/crates/server/src/oauth/dpop.rs +++ b/crates/server/src/oauth/dpop.rs @@ -1,13 +1,22 @@ //! DPoP (Demonstrating Proof of Possession) implementation for OAuth 2.1. //! //! AT Protocol requires DPoP tokens to bind access tokens to specific clients. +//! This module provides both proof generation (for client use) and verification +//! (for server use) per RFC 9449. use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use malfestio_core::Error as CoreError; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::time::{SystemTime, UNIX_EPOCH}; +/// Maximum allowed clock skew for DPoP proof validation (5 minutes). +const MAX_CLOCK_SKEW_SECS: u64 = 300; + +/// Maximum age for a DPoP proof to be considered valid (5 minutes). +const MAX_PROOF_AGE_SECS: u64 = 300; + /// A DPoP keypair for proof generation using Ed25519. #[derive(Clone)] pub struct DpopKeypair { @@ -15,30 +24,41 @@ pub struct DpopKeypair { } /// DPoP proof JWT header. -#[derive(Serialize, Deserialize)] -struct DpopHeader { - typ: String, - alg: String, - jwk: DpopJwk, +#[derive(Serialize, Deserialize, Debug)] +pub struct DpopHeader { + pub typ: String, + pub alg: String, + pub jwk: DpopJwk, } /// JWK representation for DPoP (Ed25519 public key). -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct DpopJwk { - kty: String, - crv: String, - x: String, + pub kty: String, + pub crv: String, + pub x: String, } /// DPoP proof JWT payload. -#[derive(Serialize, Deserialize)] -struct DpopPayload { - jti: String, - htm: String, - htu: String, - iat: u64, +#[derive(Serialize, Deserialize, Debug)] +pub struct DpopPayload { + pub jti: String, + pub htm: String, + pub htu: String, + pub iat: u64, #[serde(skip_serializing_if = "Option::is_none")] - ath: Option, + pub ath: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, +} + +/// Parsed DPoP proof for verification. +#[derive(Debug)] +pub struct ParsedDpopProof { + pub header: DpopHeader, + pub payload: DpopPayload, + pub signature: Vec, + pub signing_input: String, } impl DpopKeypair { @@ -63,6 +83,13 @@ impl DpopKeypair { /// Generate a DPoP proof for a request. pub fn generate_proof(&self, method: &str, url: &str, access_token: Option<&str>) -> String { + self.generate_proof_with_nonce(method, url, access_token, None) + } + + /// Generate a DPoP proof with an optional server-provided nonce. + pub fn generate_proof_with_nonce( + &self, method: &str, url: &str, access_token: Option<&str>, nonce: Option<&str>, + ) -> String { let header = DpopHeader { typ: "dpop+jwt".to_string(), alg: "EdDSA".to_string(), jwk: self.public_jwk() }; let now = SystemTime::now() @@ -77,7 +104,14 @@ impl DpopKeypair { URL_SAFE_NO_PAD.encode(hash) }); - let payload = DpopPayload { jti, htm: method.to_uppercase(), htu: url.to_string(), iat: now, ath }; + let payload = DpopPayload { + jti, + htm: method.to_uppercase(), + htu: url.to_string(), + iat: now, + ath, + nonce: nonce.map(String::from), + }; let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()); @@ -115,10 +149,190 @@ pub fn jwk_thumbprint(jwk: &DpopJwk) -> String { URL_SAFE_NO_PAD.encode(hash) } +/// Generate a server nonce for DPoP. +pub fn generate_nonce() -> String { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("Failed to generate random bytes"); + URL_SAFE_NO_PAD.encode(bytes) +} + +/// Parse a DPoP proof JWT into its components. +pub fn parse_proof(proof: &str) -> Result { + let parts: Vec<&str> = proof.split('.').collect(); + if parts.len() != 3 { + return Err(CoreError::DPoP("Invalid proof format: expected 3 parts".to_string())); + } + + let header_json = URL_SAFE_NO_PAD + .decode(parts[0]) + .map_err(|e| CoreError::DPoP(format!("Invalid header encoding: {}", e)))?; + + let header: DpopHeader = + serde_json::from_slice(&header_json).map_err(|e| CoreError::DPoP(format!("Invalid header JSON: {}", e)))?; + + let payload_json = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|e| CoreError::DPoP(format!("Invalid payload encoding: {}", e)))?; + + let payload: DpopPayload = + serde_json::from_slice(&payload_json).map_err(|e| CoreError::DPoP(format!("Invalid payload JSON: {}", e)))?; + + let signature = URL_SAFE_NO_PAD + .decode(parts[2]) + .map_err(|e| CoreError::DPoP(format!("Invalid signature encoding: {}", e)))?; + + let signing_input = format!("{}.{}", parts[0], parts[1]); + + Ok(ParsedDpopProof { header, payload, signature, signing_input }) +} + +/// Request context for DPoP verification. +pub struct DpopVerifyRequest<'a> { + /// The DPoP proof JWT string + pub proof: &'a str, + /// Expected HTTP method (e.g., "GET", "POST") + pub method: &'a str, + /// Expected request URI (without query/fragment) + pub uri: &'a str, + /// The access token (for ath verification) + pub access_token: Option<&'a str>, + /// Expected server nonce (if required) + pub expected_nonce: Option<&'a str>, +} + +impl<'a> DpopVerifyRequest<'a> { + pub fn new( + proof: &'a str, method: &'a str, uri: &'a str, access_token: Option<&'a str>, expected_nonce: Option<&'a str>, + ) -> Self { + Self { proof, method, uri, access_token, expected_nonce } + } +} + +/// Verify a DPoP proof. +/// +/// Returns the parsed proof if valid, with the JWK for binding verification. +pub fn verify_proof(req: DpopVerifyRequest<'_>) -> Result { + let parsed = parse_proof(req.proof)?; + + if parsed.header.typ != "dpop+jwt" { + return Err(CoreError::DPoP(format!( + "Invalid typ: expected 'dpop+jwt', got '{}'", + parsed.header.typ + ))); + } + + if parsed.header.alg != "EdDSA" { + return Err(CoreError::DPoP(format!( + "Unsupported alg: expected 'EdDSA', got '{}'", + parsed.header.alg + ))); + } + + if parsed.header.jwk.kty != "OKP" || parsed.header.jwk.crv != "Ed25519" { + return Err(CoreError::DPoP("Invalid JWK: expected Ed25519 key".to_string())); + } + let public_key_bytes = URL_SAFE_NO_PAD + .decode(&parsed.header.jwk.x) + .map_err(|e| CoreError::DPoP(format!("Invalid JWK x value: {}", e)))?; + + if public_key_bytes.len() != 32 { + return Err(CoreError::DPoP("Invalid public key length".to_string())); + } + + let mut key_bytes = [0u8; 32]; + key_bytes.copy_from_slice(&public_key_bytes); + + let verifying_key = + VerifyingKey::from_bytes(&key_bytes).map_err(|e| CoreError::DPoP(format!("Invalid public key: {}", e)))?; + + let signature = Signature::from_slice(&parsed.signature) + .map_err(|e| CoreError::DPoP(format!("Invalid signature format: {}", e)))?; + + verifying_key + .verify(parsed.signing_input.as_bytes(), &signature) + .map_err(|_| CoreError::DPoP("Signature verification failed".to_string()))?; + + if parsed.payload.htm.to_uppercase() != req.method.to_uppercase() { + return Err(CoreError::DPoP(format!( + "HTTP method mismatch: expected '{}', got '{}'", + req.method, parsed.payload.htm + ))); + } + + let expected_uri = normalize_uri(req.uri); + let proof_uri = normalize_uri(&parsed.payload.htu); + if expected_uri != proof_uri { + return Err(CoreError::DPoP(format!( + "URI mismatch: expected '{}', got '{}'", + expected_uri, proof_uri + ))); + } + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards") + .as_secs(); + + if parsed.payload.iat > now + MAX_CLOCK_SKEW_SECS { + return Err(CoreError::DPoP("Proof issued in the future".to_string())); + } + + if now > parsed.payload.iat + MAX_PROOF_AGE_SECS { + return Err(CoreError::DPoP("Proof has expired".to_string())); + } + + if let Some(access_token) = req.access_token { + let expected_ath = { + let hash = Sha256::digest(access_token.as_bytes()); + URL_SAFE_NO_PAD.encode(hash) + }; + + match &parsed.payload.ath { + Some(ath) if ath == &expected_ath => {} + Some(ath) => { + return Err(CoreError::DPoP(format!( + "Access token hash mismatch: expected '{}', got '{}'", + expected_ath, ath + ))); + } + None => { + return Err(CoreError::DPoP("Missing access token hash (ath) claim".to_string())); + } + } + } + + if let Some(expected_nonce) = req.expected_nonce { + match &parsed.payload.nonce { + Some(nonce) if nonce == expected_nonce => {} + Some(nonce) => { + return Err(CoreError::DPoP(format!( + "Nonce mismatch: expected '{}', got '{}'", + expected_nonce, nonce + ))); + } + None => { + return Err(CoreError::DPoP("Missing nonce claim".to_string())); + } + } + } + + Ok(parsed) +} + +/// Normalize a URI by removing query string and fragment. +fn normalize_uri(uri: &str) -> String { + uri.split('?') + .next() + .unwrap_or(uri) + .split('#') + .next() + .unwrap_or(uri) + .to_string() +} + #[cfg(test)] mod tests { use super::*; - use ed25519_dalek::Verifier; #[test] fn test_generate_keypair() { @@ -167,7 +381,7 @@ mod tests { let signing_input = format!("{}.{}", parts[0], parts[1]); let signature_bytes = URL_SAFE_NO_PAD.decode(parts[2]).unwrap(); - let signature = ed25519_dalek::Signature::from_slice(&signature_bytes).unwrap(); + let signature = Signature::from_slice(&signature_bytes).unwrap(); let result = kp.verifying_key().verify(signing_input.as_bytes(), &signature); assert!(result.is_ok(), "Signature should verify"); @@ -193,4 +407,145 @@ mod tests { assert_eq!(thumbprint.len(), 43); } + + #[test] + fn test_generate_nonce() { + let nonce1 = generate_nonce(); + let nonce2 = generate_nonce(); + + assert_ne!(nonce1, nonce2); + assert_eq!(nonce1.len(), 22); // 16 bytes base64url encoded + } + + #[test] + fn test_verify_proof_valid() { + let kp = DpopKeypair::generate(); + let proof = kp.generate_proof("POST", "https://example.com/api", None); + let req = DpopVerifyRequest::new(&proof, "POST", "https://example.com/api", None, None); + let result = verify_proof(req); + assert!(result.is_ok()); + } + + #[test] + fn test_verify_proof_invalid_signature() { + let kp1 = DpopKeypair::generate(); + let kp2 = DpopKeypair::generate(); + + let proof = kp1.generate_proof("POST", "https://example.com/api", None); + let parts: Vec<&str> = proof.split('.').collect(); + + let mut header: DpopHeader = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap(); + header.jwk = kp2.public_jwk(); + + let new_header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()); + let tampered_proof = format!("{}.{}.{}", new_header_b64, parts[1], parts[2]); + let req = DpopVerifyRequest::new(&tampered_proof, "POST", "https://example.com/api", None, None); + let result = verify_proof(req); + + assert!(result.is_err()); + assert!( + result + .unwrap_err() + .to_string() + .contains("Signature verification failed") + ); + } + + #[test] + fn test_verify_proof_wrong_method() { + let kp = DpopKeypair::generate(); + let proof = kp.generate_proof("POST", "https://example.com/api", None); + let req = DpopVerifyRequest::new(&proof, "GET", "https://example.com/api", None, None); + let result = verify_proof(req); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("HTTP method mismatch")); + } + + #[test] + fn test_verify_proof_wrong_uri() { + let kp = DpopKeypair::generate(); + let proof = kp.generate_proof("POST", "https://example.com/api", None); + let req = DpopVerifyRequest::new(&proof, "POST", "https://example.com/other", None, None); + let result = verify_proof(req); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("URI mismatch")); + } + + #[test] + fn test_verify_proof_with_token_hash() { + let kp = DpopKeypair::generate(); + let token = "my_access_token_123"; + let proof = kp.generate_proof("GET", "https://example.com/resource", Some(token)); + let req = DpopVerifyRequest::new(&proof, "GET", "https://example.com/resource", Some(token), None); + let result = verify_proof(req); + assert!(result.is_ok()); + } + + #[test] + fn test_verify_proof_wrong_token_hash() { + let kp = DpopKeypair::generate(); + let proof = kp.generate_proof("GET", "https://example.com/resource", Some("token_a")); + let req = DpopVerifyRequest::new(&proof, "GET", "https://example.com/resource", Some("token_b"), None); + let result = verify_proof(req); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Access token hash mismatch")); + } + + #[test] + fn test_verify_proof_with_nonce() { + let kp = DpopKeypair::generate(); + let nonce = generate_nonce(); + let proof = kp.generate_proof_with_nonce("POST", "https://example.com/api", None, Some(&nonce)); + let req = DpopVerifyRequest::new(&proof, "POST", "https://example.com/api", None, Some(&nonce)); + let result = verify_proof(req); + assert!(result.is_ok()); + } + + #[test] + fn test_verify_proof_wrong_nonce() { + let kp = DpopKeypair::generate(); + let nonce1 = generate_nonce(); + let nonce2 = generate_nonce(); + let proof = kp.generate_proof_with_nonce("POST", "https://example.com/api", None, Some(&nonce1)); + let req = DpopVerifyRequest::new(&proof, "POST", "https://example.com/api", None, Some(&nonce2)); + let result = verify_proof(req); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Nonce mismatch")); + } + + #[test] + fn test_verify_proof_missing_nonce() { + let kp = DpopKeypair::generate(); + let proof = kp.generate_proof("POST", "https://example.com/api", None); // No nonce + let req = DpopVerifyRequest::new(&proof, "POST", "https://example.com/api", None, Some("required_nonce")); + let result = verify_proof(req); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Missing nonce claim")); + } + + #[test] + fn test_normalize_uri() { + assert_eq!(normalize_uri("https://example.com/api"), "https://example.com/api"); + assert_eq!( + normalize_uri("https://example.com/api?foo=bar"), + "https://example.com/api" + ); + assert_eq!( + normalize_uri("https://example.com/api#section"), + "https://example.com/api" + ); + assert_eq!( + normalize_uri("https://example.com/api?foo=bar#section"), + "https://example.com/api" + ); + } + + #[test] + fn test_parse_proof_invalid_format() { + let result = parse_proof("not.a.valid.jwt.with.too.many.parts"); + assert!(result.is_err()); + + let result = parse_proof("only.two"); + assert!(result.is_err()); + } } diff --git a/crates/server/src/state.rs b/crates/server/src/state.rs index 1db347b..bccc4be 100644 --- a/crates/server/src/state.rs +++ b/crates/server/src/state.rs @@ -23,6 +23,9 @@ pub struct AppConfig { pub type AuthCache = Arc>>; +/// Cache for DPoP nonces with their creation timestamps for TTL enforcement. +pub type DpopNonceCache = Arc>>; + pub struct Repositories { pub oauth: Arc, pub deck: Arc, @@ -46,11 +49,14 @@ pub struct AppState { pub search_repo: Arc, pub config: AppConfig, pub auth_cache: AuthCache, + /// Cache of valid DPoP nonces. Nonces are single-use and expire after TTL. + pub dpop_nonces: DpopNonceCache, } impl AppState { pub fn new(pool: DbPool, repos: Repositories, config: AppConfig) -> SharedState { let auth_cache = Arc::new(RwLock::new(HashMap::new())); + let dpop_nonces = Arc::new(RwLock::new(HashMap::new())); Arc::new(Self { pool, oauth_repo: repos.oauth, @@ -63,6 +69,7 @@ impl AppState { search_repo: repos.search, config, auth_cache, + dpop_nonces, }) } diff --git a/docs/at-notes.md b/docs/at-notes.md index d722418..d89edb0 100644 --- a/docs/at-notes.md +++ b/docs/at-notes.md @@ -28,6 +28,33 @@ AT Protocol uses a specific profile of OAuth 2.1 for client↔PDS authorization. - **Handle/DID Resolution**: Resolve user identity to discover their PDS - **Token Exchange**: Authorization code flow with token refresh +### DPoP (Demonstrating Proof-of-Possession) + +DPoP (RFC 9449) binds access tokens to specific client instances, preventing token theft/replay. + +**Proof JWT Structure:** + +- **Header**: `typ: dpop+jwt`, `alg: EdDSA` (or ES256), `jwk: ` +- **Payload Claims**: + - `jti` — Unique identifier (nonce) per request + - `htm` — HTTP method (e.g., "POST", "GET") + - `htu` — HTTP target URI (without query/fragment) + - `iat` — Issued-at timestamp + - `ath` — SHA-256 hash of access token (for resource requests) + - `nonce` — Server-provided nonce (if required) + +**Usage:** + +1. Client generates DPoP keypair per session (not reused across devices/users) +2. Each request includes `Authorization: DPoP ` and `DPoP: ` +3. Server validates signature, checks claims match request, verifies token binding + +**Server Behavior:** + +- May return `DPoP-Nonce` header; client must include in subsequent proofs +- Validates `jti` uniqueness to prevent replay attacks +- Checks `ath` matches provided access token + ## Record Publishing ### XRPC Endpoints diff --git a/docs/todo.md b/docs/todo.md index 443c001..38aad2d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -43,7 +43,7 @@ - [x] OAuth login directly to user's PDS - [x] Handle resolution via DNS TXT or `/.well-known/atproto-did` - -- [ ] DPoP token binding for secure API calls +- [x] DPoP token binding for secure API calls **Sync & Conflict Resolution:** @@ -75,7 +75,42 @@ - Use `did:web` for simplicity, `did:plc` for long-term stability - ATProto OAuth is the forward path -### Milestone M - Custom Feed Generator +### Milestone M - Reliability, Observability, Launch (v0.1.0) + +#### Deliverables + +**Observability:** + +- [ ] Structured logging with correlation IDs +- [ ] Metrics collection (Prometheus/OpenTelemetry) +- [ ] Distributed tracing for request flows +- [ ] Error tracking (Sentry or similar) + +**Reliability:** + +- [ ] Database backups + restore drills +- [ ] Health check endpoints (`/health`, `/ready`) +- [ ] Graceful shutdown handling +- [ ] Circuit breakers for external dependencies + +**Load Testing:** + +- [ ] Study session throughput targets +- [ ] Feed generation latency benchmarks +- [ ] Search query performance under load + +**Launch Prep:** + +- [ ] Beta program signup flow +- [ ] Feedback collection mechanism +- [ ] Feature flags for gradual rollout + +#### Acceptance + +- System handles 10x expected load without degradation. +- Mean time to recovery < 5 minutes for common failures. + +### Milestone N - Custom Feed Generator (v0.2.0) #### Deliverables @@ -145,42 +180,7 @@ type ReasonRepost = { - Most feeds can garbage collect data older than 48 hours - Reference: [Feed Generator Starter Kit](https://github.com/bluesky-social/feed-generator) -### Milestone N - Reliability, Observability, Launch - -#### Deliverables - -**Observability:** - -- [ ] Structured logging with correlation IDs -- [ ] Metrics collection (Prometheus/OpenTelemetry) -- [ ] Distributed tracing for request flows -- [ ] Error tracking (Sentry or similar) - -**Reliability:** - -- [ ] Database backups + restore drills -- [ ] Health check endpoints (`/health`, `/ready`) -- [ ] Graceful shutdown handling -- [ ] Circuit breakers for external dependencies - -**Load Testing:** - -- [ ] Study session throughput targets -- [ ] Feed generation latency benchmarks -- [ ] Search query performance under load - -**Launch Prep:** - -- [ ] Beta program signup flow -- [ ] Feedback collection mechanism -- [ ] Feature flags for gradual rollout - -#### Acceptance - -- System handles 10x expected load without degradation. -- Mean time to recovery < 5 minutes for common failures. - -### Milestone O - Moderation + Abuse Resistance +### Milestone O - Moderation + Abuse Resistance (v0.3.0) #### Deliverables