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

feat(auth): validate_dpop_for_token_endpoint + DpopTokenEndpointError

Add nonce field to DPopClaims struct and implement token-endpoint-specific DPoP
validation. New DpopTokenEndpointError enum and validate_dpop_for_token_endpoint
function handle nonce validation at the token endpoint.

Also add #[allow(dead_code)] to auth and db functions pending use in Task 3 handler.

+114 -4
+110
crates/relay/src/auth/mod.rs
··· 123 123 /// Full deduplication (RFC 9449 §11.1) requires a server-side nonce store, 124 124 /// not yet implemented; this check only enforces presence. 125 125 jti: String, 126 + /// Server-issued DPoP nonce (RFC 9449 §8). Required when the server has issued one. 127 + #[serde(default)] 128 + nonce: Option<String>, 126 129 } 127 130 128 131 // ── Extractor implementation ───────────────────────────────────────────────── ··· 358 361 .to_pkcs8_der() 359 362 .map_err(|e| anyhow::anyhow!("PKCS#8 DER encoding failed: {e}"))?; 360 363 Ok(jsonwebtoken::EncodingKey::from_ec_der(pkcs8_der.as_bytes())) 364 + } 365 + 366 + /// Error from DPoP validation at the token endpoint. 367 + /// 368 + /// Converted to `OAuthTokenError` by the handler in `routes/oauth_token.rs`. 369 + #[allow(dead_code)] 370 + pub(crate) enum DpopTokenEndpointError { 371 + /// `DPoP:` header is absent. 372 + MissingHeader, 373 + /// DPoP proof is syntactically or semantically invalid. 374 + InvalidProof(&'static str), 375 + /// Nonce is missing, unknown, or expired — fresh nonce included for the response header. 376 + UseNonce(String), 377 + } 378 + 379 + /// Validate the DPoP proof at the token endpoint and return the JWK thumbprint. 380 + /// 381 + /// This is a token-endpoint-specific variant of `validate_dpop`: 382 + /// - Does NOT check `cnf.jkt` against an existing access token (no token yet). 383 + /// - DOES validate the `nonce` claim against the nonce store. 384 + /// - Returns the JWK thumbprint (jkt) so the handler can embed it in `cnf.jkt`. 385 + /// 386 + /// `htm` must be `"POST"`. `htu` must be the token endpoint URL (e.g. 387 + /// `"https://relay.example.com/oauth/token"`). 388 + #[allow(dead_code)] 389 + pub(crate) async fn validate_dpop_for_token_endpoint( 390 + dpop_token: &str, 391 + htm: &str, 392 + htu: &str, 393 + nonce_store: &DpopNonceStore, 394 + ) -> Result<String, DpopTokenEndpointError> { 395 + // Decode the DPoP proof header manually (same pattern as validate_dpop). 396 + let header_b64 = dpop_token 397 + .split('.') 398 + .next() 399 + .ok_or(DpopTokenEndpointError::InvalidProof("malformed DPoP JWT"))?; 400 + let header_bytes = URL_SAFE_NO_PAD 401 + .decode(header_b64) 402 + .map_err(|_| DpopTokenEndpointError::InvalidProof("DPoP header base64 invalid"))?; 403 + let dpop_header: DPopHeader = serde_json::from_slice(&header_bytes) 404 + .map_err(|_| DpopTokenEndpointError::InvalidProof("DPoP header JSON malformed"))?; 405 + 406 + if dpop_header.typ != "dpop+jwt" { 407 + return Err(DpopTokenEndpointError::InvalidProof("DPoP typ must be dpop+jwt")); 408 + } 409 + 410 + // Verify the signature against the embedded JWK. 411 + let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_value(dpop_header.jwk.clone()) 412 + .map_err(|_| DpopTokenEndpointError::InvalidProof("DPoP JWK parse failed"))?; 413 + let decoding_key = DecodingKey::from_jwk(&jwk) 414 + .map_err(|_| DpopTokenEndpointError::InvalidProof("DPoP DecodingKey build failed"))?; 415 + let alg = dpop_alg_from_str(&dpop_header.alg) 416 + .ok_or(DpopTokenEndpointError::InvalidProof("DPoP unsupported alg"))?; 417 + 418 + let mut validation = Validation::new(alg); 419 + validation.validate_exp = false; 420 + validation.set_required_spec_claims::<&str>(&[]); 421 + validation.validate_aud = false; 422 + 423 + let dpop_data = decode::<DPopClaims>(dpop_token, &decoding_key, &validation) 424 + .map_err(|_| DpopTokenEndpointError::InvalidProof("DPoP signature verification failed"))?; 425 + let claims = dpop_data.claims; 426 + 427 + // Validate htm (HTTP method). 428 + if claims.htm.to_uppercase() != htm.to_uppercase() { 429 + return Err(DpopTokenEndpointError::InvalidProof("DPoP htm mismatch")); 430 + } 431 + 432 + // Validate htu (target URI). 433 + if claims.htu != htu { 434 + return Err(DpopTokenEndpointError::InvalidProof("DPoP htu mismatch")); 435 + } 436 + 437 + // Validate jti (presence only — server nonce provides replay protection). 438 + if claims.jti.is_empty() { 439 + return Err(DpopTokenEndpointError::InvalidProof("DPoP jti missing")); 440 + } 441 + 442 + // Freshness: reject proofs older than 60 seconds or from the future. 443 + let now = std::time::SystemTime::now() 444 + .duration_since(std::time::UNIX_EPOCH) 445 + .map_err(|_| DpopTokenEndpointError::InvalidProof("system clock error"))? 446 + .as_secs() as i64; 447 + let diff = (now as i128) - (claims.iat as i128); 448 + if diff.unsigned_abs() > 60 { 449 + return Err(DpopTokenEndpointError::InvalidProof("DPoP proof stale")); 450 + } 451 + 452 + // Validate nonce claim. 453 + match claims.nonce.as_deref() { 454 + None | Some("") => { 455 + // No nonce — issue a fresh one for the client to retry with. 456 + let fresh = issue_nonce(nonce_store).await; 457 + return Err(DpopTokenEndpointError::UseNonce(fresh)); 458 + } 459 + Some(nonce) => { 460 + if !validate_and_consume_nonce(nonce_store, nonce).await { 461 + // Unknown or expired nonce — issue a fresh one. 462 + let fresh = issue_nonce(nonce_store).await; 463 + return Err(DpopTokenEndpointError::UseNonce(fresh)); 464 + } 465 + } 466 + } 467 + 468 + // Compute and return the JWK thumbprint. 469 + jwk_thumbprint(&dpop_header.jwk) 470 + .map_err(|_| DpopTokenEndpointError::InvalidProof("JWK thumbprint computation failed")) 361 471 } 362 472 363 473 // ── Internal helpers ─────────────────────────────────────────────────────────
+4 -4
crates/relay/src/db/oauth.rs
··· 73 73 /// before lookup, consistent with the session and refresh-token patterns in this codebase. 74 74 /// 75 75 /// The code expires 60 seconds after creation (single-use, short-lived per RFC 6749 §4.1.2). 76 + #[allow(clippy::too_many_arguments)] 76 77 pub async fn store_authorization_code( 77 78 pool: &SqlitePool, 78 79 code: &str, ··· 163 164 } 164 165 165 166 /// A row read from `oauth_authorization_codes` during code exchange. 167 + #[allow(dead_code)] 166 168 pub struct AuthCodeRow { 167 169 pub client_id: String, 168 170 pub did: String, 169 - #[allow(dead_code)] 170 171 pub code_challenge: String, 171 - #[allow(dead_code)] 172 172 pub code_challenge_method: String, 173 - #[allow(dead_code)] 174 173 pub redirect_uri: String, 175 - #[allow(dead_code)] 176 174 pub scope: String, 177 175 } 178 176 ··· 183 181 /// 184 182 /// The code column stores the SHA-256 hex hash of the raw code bytes. Callers must 185 183 /// hash the presented code before calling this function (use `routes::token::sha256_hex`). 184 + #[allow(dead_code)] 186 185 pub async fn consume_authorization_code( 187 186 pool: &SqlitePool, 188 187 code_hash: &str, ··· 228 227 /// `scope` is always `'com.atproto.refresh'` for OAuth refresh tokens. 229 228 /// `jkt` is the DPoP key thumbprint binding this token to the client's keypair. 230 229 /// Expires 24 hours after insertion. 230 + #[allow(dead_code)] 231 231 pub async fn store_oauth_refresh_token( 232 232 pool: &SqlitePool, 233 233 token_hash: &str,