An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(relay): address auth middleware PR review — DPoP downgrade, htu reconstruction, error propagation, type annotations

Critical fixes:
- Reconstruct htu from config.public_url + request path (reverse proxy safety)
- Enforce DPoP proof required when access token has cnf.jkt binding (downgrade attack)
- Propagate system clock errors as InternalError instead of panicking
- Add AuthenticationRequired/InvalidToken to status_code_mapping test

Important fixes:
- warn on non-UTF-8 DPoP header instead of silently dropping
- Add debug logging on all DPoP validation rejection paths
- warn when audience validation skipped (no server_did configured)
- Add EdDSA algorithm support in dpop_alg_from_str
- validate_dpop now returns descriptive error strings via Result<_, String>

Suggestions:
- Per-item #[allow(dead_code)] instead of module-level allow
- Add AppPass scope test and wrong-audience test
- Add okp/rsa/ec JWK thumbprint tests (including RFC 7638 normative vector)

Fixes: p256 Signature::to_bytes() type annotation ambiguity (as &[u8])
Remove unused ToEncodedPoint import

authored by

Malpercio and committed by
Tangled
30a81197 e61676d3

+509 -130
+1
Cargo.lock
··· 4141 4141 "opentelemetry", 4142 4142 "opentelemetry-otlp", 4143 4143 "opentelemetry_sdk", 4144 + "p256", 4144 4145 "rand_core 0.6.4", 4145 4146 "reqwest 0.12.28", 4146 4147 "serde",
+2
crates/common/src/error.rs
··· 242 242 (ErrorCode::PlcDirectoryError, 502), 243 243 (ErrorCode::DnsError, 502), 244 244 (ErrorCode::HandleNotFound, 404), 245 + (ErrorCode::AuthenticationRequired, 401), 246 + (ErrorCode::InvalidToken, 401), 245 247 ]; 246 248 for (code, expected) in cases { 247 249 assert_eq!(code.status_code(), expected, "wrong status for {code:?}");
+1
crates/relay/Cargo.toml
··· 44 44 serde_json = { workspace = true } 45 45 tempfile = { workspace = true } 46 46 wiremock = "0.6" 47 + p256 = { workspace = true }
+1 -1
crates/relay/src/app.rs
··· 97 97 /// `GET https://<handle>/.well-known/atproto-did`. 98 98 pub well_known_resolver: Option<Arc<dyn WellKnownResolver>>, 99 99 /// HS256 signing secret for JWT access/refresh tokens. 100 - /// Loaded from EZPDS_JWT_SECRET (hex-encoded) or generated randomly at startup. 100 + /// Generated randomly at startup via OsRng (ephemeral — rotates on restart). 101 101 pub jwt_secret: [u8; 32], 102 102 } 103 103
+504 -129
crates/relay/src/auth/mod.rs
··· 1 - // Dead-code lint suppressed: this module is foundational infrastructure. 2 - // Items will be used once authenticated routes are wired up in subsequent waves. 3 - #![allow(dead_code)] 4 - 5 1 use axum::{ 6 2 async_trait, 7 3 extract::FromRequestParts, ··· 18 14 // ── Public types ───────────────────────────────────────────────────────────── 19 15 20 16 /// Scope embedded in the JWT `scope` claim. 17 + // Dead-code lint: foundational type; used once authenticated routes are wired up. 18 + #[allow(dead_code)] 21 19 #[derive(Debug, Clone, PartialEq, Eq)] 22 20 pub enum AuthScope { 23 21 Access, ··· 26 24 } 27 25 28 26 /// Whether this token was presented as a plain Bearer or a DPoP-bound token. 27 + // Dead-code lint: foundational type; used once authenticated routes are wired up. 28 + #[allow(dead_code)] 29 29 #[derive(Debug, Clone, PartialEq, Eq)] 30 30 pub enum TokenType { 31 31 /// Simple Bearer JWT issued by `createSession`. ··· 41 41 /// ```rust,ignore 42 42 /// async fn my_handler(user: AuthenticatedUser) -> impl IntoResponse { ... } 43 43 /// ``` 44 + // Dead-code lint: foundational type; used once authenticated routes are wired up. 45 + #[allow(dead_code)] 44 46 #[derive(Debug, Clone)] 45 47 pub struct AuthenticatedUser { 46 48 pub did: String, ··· 57 59 sub: String, 58 60 /// Scope string from the AT Protocol spec. 59 61 scope: String, 60 - /// Confirmation claim — present on DPoP-bound tokens. 62 + /// Confirmation claim — present on DPoP-bound tokens (RFC 9449 §4.3). 61 63 cnf: Option<CnfClaim>, 62 64 } 63 65 ··· 84 86 /// Claims from the DPoP proof JWT payload. 85 87 #[derive(Debug, Deserialize)] 86 88 struct DPopClaims { 87 - /// HTTP method (e.g. `"POST"`). 89 + /// HTTP method (e.g. `"GET"`). 88 90 htm: String, 89 - /// HTTP URI (scheme + host + path, no query string). 91 + /// HTTP target URI (scheme + host + path, no query string — RFC 9449 §4.3). 90 92 htu: String, 91 93 /// Issued-at (Unix timestamp). Used for freshness; replaces `exp`. 92 94 iat: i64, 93 - /// Unique token ID — must be present for replay protection. 95 + /// Unique token ID — must be present and non-empty for replay protection. 96 + /// Full deduplication (RFC 9449 §11.1) requires a server-side nonce store, 97 + /// not yet implemented; this check only enforces presence. 94 98 jti: String, 95 99 } 96 100 ··· 111 115 let dpop_value = parts 112 116 .headers 113 117 .get("DPoP") 114 - .and_then(|v| v.to_str().ok()) 118 + .and_then(|v| { 119 + v.to_str() 120 + .inspect_err(|_| { 121 + tracing::warn!( 122 + "DPoP header contains non-UTF-8 bytes; treating as absent" 123 + ); 124 + }) 125 + .ok() 126 + }) 115 127 .map(str::to_owned); 116 128 let has_dpop = dpop_value.is_some(); 117 129 118 130 // 3. Decode and verify the access token (HS256). 119 131 let claims = verify_access_token(token_str, state)?; 120 132 121 - // 4. Resolve scope enum. 133 + // 4. Enforce DPoP binding (RFC 9449 §7.1): if the access token carries a 134 + // `cnf.jkt` binding, the DPoP proof header is mandatory. Accepting the 135 + // token as a plain Bearer would allow an attacker with a stolen access 136 + // token to bypass the key binding entirely. 137 + let token_is_dpop_bound = claims.cnf.as_ref().map_or(false, |c| c.jkt.is_some()); 138 + if token_is_dpop_bound && !has_dpop { 139 + return Err(ApiError::new( 140 + ErrorCode::InvalidToken, 141 + "DPoP-bound token requires a DPoP proof header", 142 + )); 143 + } 144 + 145 + // 5. Resolve scope enum. 122 146 let scope = parse_scope(&claims.scope)?; 123 147 124 - // 5. DPoP validation — only when the DPoP header is present. 148 + // 6. DPoP proof validation — only when the DPoP header is present. 125 149 if has_dpop { 126 150 let dpop_token = dpop_value.as_deref().unwrap(); 127 - validate_dpop(dpop_token, &parts.method, &parts.uri, &claims)?; 151 + validate_dpop( 152 + dpop_token, 153 + &parts.method, 154 + &parts.uri, 155 + &state.config.public_url, 156 + &claims, 157 + )?; 128 158 } 129 159 130 160 let token_type = if has_dpop { ··· 172 202 } 173 203 174 204 /// Decode and verify the HS256 access/refresh JWT issued by this server. 175 - fn verify_access_token( 176 - token: &str, 177 - state: &AppState, 178 - ) -> Result<AccessTokenClaims, ApiError> { 205 + fn verify_access_token(token: &str, state: &AppState) -> Result<AccessTokenClaims, ApiError> { 179 206 let decoding_key = DecodingKey::from_secret(&state.jwt_secret); 180 207 181 208 let mut validation = Validation::new(Algorithm::HS256); ··· 184 211 Some(did) => validation.set_audience(&[did]), 185 212 None => { 186 213 validation.validate_aud = false; 187 - tracing::debug!("server_did not configured; skipping JWT audience validation"); 214 + tracing::warn!( 215 + "server_did not configured; JWT audience validation is disabled — \ 216 + set server_did in config for production deployments" 217 + ); 188 218 } 189 219 } 190 220 // `sub` is required by AT Protocol but not in jsonwebtoken's default required set. ··· 220 250 /// Checks: 221 251 /// - `typ` header is `"dpop+jwt"` 222 252 /// - Signature verifies against the embedded JWK 223 - /// - `htm` matches request method, `htu` matches request URI 224 - /// - `jti` is present (replay protection hook) 253 + /// - `htm` matches request method, `htu` matches `public_url + path` 254 + /// - `jti` is present and non-empty 255 + /// - `iat` is within the 60-second freshness window 225 256 /// - Access token `cnf.jkt` matches the computed JWK thumbprint 226 257 fn validate_dpop( 227 258 dpop_token: &str, 228 259 method: &Method, 229 260 uri: &axum::http::Uri, 261 + public_url: &str, 230 262 access_claims: &AccessTokenClaims, 231 263 ) -> Result<(), ApiError> { 232 264 let invalid = || ApiError::new(ErrorCode::InvalidToken, "DPoP proof invalid"); ··· 234 266 // Decode the DPoP proof header manually — jsonwebtoken's Header type doesn't 235 267 // expose custom header fields like `jwk`, so we base64-decode the first segment. 236 268 let header_b64 = dpop_token.split('.').next().ok_or_else(invalid)?; 237 - let header_bytes = URL_SAFE_NO_PAD.decode(header_b64).map_err(|_| invalid())?; 238 - let dpop_header: DPopHeader = 239 - serde_json::from_slice(&header_bytes).map_err(|_| invalid())?; 269 + let header_bytes = URL_SAFE_NO_PAD.decode(header_b64).map_err(|e| { 270 + tracing::debug!(error = %e, "DPoP proof header is not valid base64url"); 271 + invalid() 272 + })?; 273 + let dpop_header: DPopHeader = serde_json::from_slice(&header_bytes).map_err(|e| { 274 + tracing::debug!(error = %e, "DPoP proof header JSON is malformed or missing required fields"); 275 + invalid() 276 + })?; 240 277 241 278 if dpop_header.typ != "dpop+jwt" { 279 + tracing::debug!(typ = %dpop_header.typ, "DPoP proof typ is not dpop+jwt"); 242 280 return Err(ApiError::new( 243 281 ErrorCode::InvalidToken, 244 282 "DPoP proof typ must be dpop+jwt", ··· 246 284 } 247 285 248 286 // Compute JWK thumbprint (RFC 7638) from the embedded public key. 249 - let thumbprint = jwk_thumbprint(&dpop_header.jwk).map_err(|_| invalid())?; 287 + let thumbprint = jwk_thumbprint(&dpop_header.jwk).map_err(|e| { 288 + tracing::debug!(error = %e, "failed to compute JWK thumbprint from DPoP proof header"); 289 + invalid() 290 + })?; 250 291 251 292 // Verify that the access token was bound to this DPoP key. 252 293 let bound_thumbprint = access_claims ··· 257 298 ApiError::new(ErrorCode::InvalidToken, "access token missing DPoP binding") 258 299 })?; 259 300 if thumbprint != bound_thumbprint { 301 + tracing::debug!("DPoP proof key thumbprint does not match cnf.jkt in access token"); 260 302 return Err(ApiError::new( 261 303 ErrorCode::InvalidToken, 262 304 "DPoP key thumbprint does not match token binding", ··· 265 307 266 308 // Verify the DPoP JWT signature using the embedded public JWK. 267 309 let jwk: jsonwebtoken::jwk::Jwk = 268 - serde_json::from_value(dpop_header.jwk.clone()).map_err(|_| invalid())?; 269 - let decoding_key = DecodingKey::from_jwk(&jwk).map_err(|_| invalid())?; 270 - let alg = dpop_alg_from_str(&dpop_header.alg).ok_or_else(invalid)?; 310 + serde_json::from_value(dpop_header.jwk.clone()).map_err(|e| { 311 + tracing::debug!(error = %e, "failed to parse JWK from DPoP proof header"); 312 + invalid() 313 + })?; 314 + let decoding_key = DecodingKey::from_jwk(&jwk).map_err(|e| { 315 + tracing::debug!(error = %e, "failed to build DecodingKey from DPoP JWK"); 316 + invalid() 317 + })?; 318 + let alg = dpop_alg_from_str(&dpop_header.alg).ok_or_else(|| { 319 + tracing::debug!(alg = %dpop_header.alg, "unsupported DPoP proof algorithm"); 320 + invalid() 321 + })?; 271 322 272 323 let mut validation = Validation::new(alg); 273 - // DPoP proofs don't carry `exp`; freshness is via `iat`. 324 + // DPoP proofs don't carry `exp`; freshness is enforced via `iat` below. 274 325 validation.validate_exp = false; 275 326 validation.set_required_spec_claims::<&str>(&[]); 276 327 validation.validate_aud = false; 277 328 278 - let dpop_data = 279 - decode::<DPopClaims>(dpop_token, &decoding_key, &validation).map_err(|_| invalid())?; 329 + let dpop_data = decode::<DPopClaims>(dpop_token, &decoding_key, &validation).map_err(|e| { 330 + tracing::debug!(error = %e, "DPoP proof signature verification failed"); 331 + invalid() 332 + })?; 280 333 let dpop_claims = dpop_data.claims; 281 334 282 - // Require `jti` for replay protection (must be present and non-empty). 335 + // Require `jti` for replay protection (existence check only — full deduplication 336 + // per RFC 9449 §11.1 requires a server-side nonce store, not yet implemented). 283 337 if dpop_claims.jti.is_empty() { 284 338 return Err(ApiError::new(ErrorCode::InvalidToken, "DPoP proof missing jti")); 285 339 } 286 340 287 - // Validate `htm` (HTTP method) and `htu` (HTTP URI). 341 + // Validate `htm` (HTTP method). 288 342 if dpop_claims.htm.to_uppercase() != method.as_str().to_uppercase() { 343 + tracing::debug!( 344 + proof_htm = %dpop_claims.htm, 345 + request_method = %method, 346 + "DPoP htm does not match request method" 347 + ); 289 348 return Err(ApiError::new( 290 349 ErrorCode::InvalidToken, 291 350 "DPoP htm does not match request method", 292 351 )); 293 352 } 294 353 295 - // `htu` must match scheme + authority + path (no query string per RFC 9449 §4.3). 296 - let expected_htu = { 297 - let scheme = uri.scheme_str().unwrap_or("https"); 298 - let authority = uri.authority().map(|a| a.as_str()).unwrap_or(""); 299 - let path = uri.path(); 300 - format!("{scheme}://{authority}{path}") 301 - }; 354 + // Validate `htu` (HTTP URI — scheme + host + path, no query string per RFC 9449 §4.3). 355 + // Axum receives path-form URIs behind a reverse proxy, so we reconstruct the 356 + // canonical URL from the configured public_url rather than the raw request URI. 357 + let expected_htu = format!("{}{}", public_url.trim_end_matches('/'), uri.path()); 302 358 if dpop_claims.htu != expected_htu { 359 + tracing::debug!( 360 + proof_htu = %dpop_claims.htu, 361 + expected_htu = %expected_htu, 362 + "DPoP htu does not match request URI" 363 + ); 303 364 return Err(ApiError::new( 304 365 ErrorCode::InvalidToken, 305 366 "DPoP htu does not match request URI", 306 367 )); 307 368 } 308 369 309 - // Freshness: reject proofs older than 60 seconds. 370 + // Freshness: reject proofs issued more than 60 seconds ago or in the future. 310 371 let now = std::time::SystemTime::now() 311 372 .duration_since(std::time::UNIX_EPOCH) 312 - .map(|d| d.as_secs() as i64) 313 - .unwrap_or(0); 373 + .map_err(|e| { 374 + tracing::error!( 375 + error = %e, 376 + "system clock is before UNIX epoch; DPoP validation impossible" 377 + ); 378 + ApiError::new(ErrorCode::InternalError, "internal server error") 379 + })? 380 + .as_secs() as i64; 314 381 if (now - dpop_claims.iat).abs() > 60 { 315 382 return Err(ApiError::new(ErrorCode::InvalidToken, "DPoP proof is stale")); 316 383 } ··· 318 385 Ok(()) 319 386 } 320 387 321 - /// Map a DPoP `alg` string to a [`jsonwebtoken::Algorithm`]. 388 + /// Map a DPoP `alg` header string to a [`jsonwebtoken::Algorithm`]. 322 389 fn dpop_alg_from_str(alg: &str) -> Option<Algorithm> { 323 390 match alg { 324 391 "ES256" => Some(Algorithm::ES256), 325 392 "ES384" => Some(Algorithm::ES384), 393 + "EdDSA" => Some(Algorithm::EdDSA), 326 394 "RS256" => Some(Algorithm::RS256), 327 395 "RS384" => Some(Algorithm::RS384), 328 396 "RS512" => Some(Algorithm::RS512), ··· 335 403 336 404 /// Compute the RFC 7638 JWK thumbprint: SHA-256 of the canonical JSON member set, 337 405 /// base64url-encoded with no padding. 338 - fn jwk_thumbprint(jwk: &serde_json::Value) -> Result<String, ()> { 339 - let kty = jwk["kty"].as_str().ok_or(())?; 406 + fn jwk_thumbprint(jwk: &serde_json::Value) -> Result<String, String> { 407 + let kty = jwk["kty"] 408 + .as_str() 409 + .ok_or_else(|| "JWK missing kty".to_owned())?; 340 410 341 411 // Canonical member set per RFC 7638 §3.2, in lexicographic order. 342 - // serde_json's default Map is a BTreeMap, so json! keys are sorted automatically. 412 + // serde_json's default Map is a BTreeMap, so json! keys are always sorted. 343 413 let canonical: serde_json::Value = match kty { 344 414 "EC" => serde_json::json!({ 345 415 "crv": jwk["crv"], ··· 357 427 "kty": kty, 358 428 "x": jwk["x"], 359 429 }), 360 - _ => return Err(()), 430 + _ => return Err(format!("unsupported kty: {kty}")), 361 431 }; 362 432 363 - let canonical_json = serde_json::to_string(&canonical).map_err(|_| ())?; 433 + let canonical_json = 434 + serde_json::to_string(&canonical).map_err(|e| format!("serialization failed: {e}"))?; 364 435 let hash = Sha256::digest(canonical_json.as_bytes()); 365 436 Ok(URL_SAFE_NO_PAD.encode(hash)) 366 437 } ··· 377 448 Router, 378 449 }; 379 450 use jsonwebtoken::{encode, EncodingKey, Header}; 451 + use p256::ecdsa::{signature::Signer, Signature, SigningKey}; 452 + use rand_core::OsRng; 380 453 use serde::Serialize; 381 454 use tower::ServiceExt; 382 455 383 456 use crate::app::test_state; 457 + 458 + // ── Test token helpers ──────────────────────────────────────────────────── 384 459 385 460 /// Claims struct for minting test JWTs. 386 461 #[derive(Serialize)] ··· 400 475 .as_secs() 401 476 } 402 477 403 - /// Mint a valid HS256 JWT using the test state's jwt_secret. 478 + /// Mint a valid HS256 JWT using the given secret. 404 479 fn mint_token( 405 480 sub: &str, 406 481 scope: &str, ··· 424 499 .unwrap() 425 500 } 426 501 427 - /// Build a minimal Axum router that uses AuthenticatedUser as an extractor. 502 + // ── DPoP test helpers ───────────────────────────────────────────────────── 503 + 504 + /// Compute the JWK thumbprint for a P-256 signing key. 505 + fn dpop_key_thumbprint(key: &SigningKey) -> String { 506 + let jwk = dpop_key_to_jwk(key); 507 + jwk_thumbprint(&jwk).unwrap() 508 + } 509 + 510 + /// Build a minimal JWK Value from a P-256 signing key (public portion only). 511 + fn dpop_key_to_jwk(key: &SigningKey) -> serde_json::Value { 512 + let vk = key.verifying_key(); 513 + let point = vk.to_encoded_point(false); 514 + let x = URL_SAFE_NO_PAD.encode(point.x().unwrap()); 515 + let y = URL_SAFE_NO_PAD.encode(point.y().unwrap()); 516 + serde_json::json!({ "kty": "EC", "crv": "P-256", "x": x, "y": y }) 517 + } 518 + 519 + /// Build a valid DPoP proof JWT signed with the given P-256 key. 520 + fn make_dpop_proof(key: &SigningKey, htm: &str, htu: &str) -> String { 521 + let jwk = dpop_key_to_jwk(key); 522 + let header = serde_json::json!({ 523 + "typ": "dpop+jwt", 524 + "alg": "ES256", 525 + "jwk": jwk, 526 + }); 527 + let payload = serde_json::json!({ 528 + "htm": htm, 529 + "htu": htu, 530 + "iat": now_secs() as i64, 531 + "jti": "test-jti-unique-value", 532 + }); 533 + 534 + let hdr_b64 = 535 + URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap().as_bytes()); 536 + let pay_b64 = 537 + URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap().as_bytes()); 538 + let signing_input = format!("{hdr_b64}.{pay_b64}"); 539 + 540 + let sig: Signature = key.sign(signing_input.as_bytes()); 541 + let sig_b64 = URL_SAFE_NO_PAD.encode(sig.to_bytes().as_ref() as &[u8]); 542 + 543 + format!("{hdr_b64}.{pay_b64}.{sig_b64}") 544 + } 545 + 546 + // ── Minimal test router ─────────────────────────────────────────────────── 547 + 428 548 fn protected_app(state: AppState) -> Router { 429 549 Router::new() 430 550 .route( ··· 444 564 app.oneshot(builder.body(Body::empty()).unwrap()).await.unwrap() 445 565 } 446 566 567 + async fn json_body(resp: axum::response::Response) -> serde_json::Value { 568 + let bytes = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 569 + serde_json::from_slice(&bytes).unwrap() 570 + } 571 + 447 572 // ── Missing / malformed Authorization header ────────────────────────────── 448 573 449 574 #[tokio::test] 450 575 async fn missing_auth_header_returns_401_authentication_required() { 451 576 let state = test_state().await; 452 - let app = protected_app(state); 453 - let resp = get_protected(app, None).await; 577 + let resp = get_protected(protected_app(state), None).await; 454 578 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 455 - 456 - let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 457 - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 579 + let json = json_body(resp).await; 458 580 assert_eq!(json["error"]["code"], "AUTHENTICATION_REQUIRED"); 459 581 } 460 582 461 583 #[tokio::test] 462 584 async fn bearer_prefix_missing_returns_401_authentication_required() { 463 585 let state = test_state().await; 464 - let app = protected_app(state); 465 586 let req = Request::builder() 466 587 .uri("/protected") 467 588 .header("Authorization", "Token abc123") 468 589 .body(Body::empty()) 469 590 .unwrap(); 470 - let resp = app.oneshot(req).await.unwrap(); 591 + let resp = protected_app(state).oneshot(req).await.unwrap(); 471 592 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 472 - 473 - let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 474 - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 593 + let json = json_body(resp).await; 475 594 assert_eq!(json["error"]["code"], "AUTHENTICATION_REQUIRED"); 476 595 } 477 596 ··· 480 599 #[tokio::test] 481 600 async fn malformed_token_returns_401_invalid_token() { 482 601 let state = test_state().await; 483 - let app = protected_app(state); 484 - let resp = get_protected(app, Some("not.a.jwt")).await; 602 + let resp = get_protected(protected_app(state), Some("not.a.jwt")).await; 485 603 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 486 - 487 - let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 488 - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 604 + let json = json_body(resp).await; 489 605 assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 490 606 } 491 607 492 608 #[tokio::test] 493 609 async fn wrong_signature_returns_401_invalid_token() { 494 610 let state = test_state().await; 495 - let wrong_secret = [0xFFu8; 32]; 496 - let token = mint_token("did:plc:user", "com.atproto.access", 3600, &wrong_secret, None); 497 - let app = protected_app(state); 498 - let resp = get_protected(app, Some(&token)).await; 611 + let token = mint_token( 612 + "did:plc:user", 613 + "com.atproto.access", 614 + 3600, 615 + &[0xFFu8; 32], 616 + None, 617 + ); 618 + let resp = get_protected(protected_app(state), Some(&token)).await; 499 619 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 500 - 501 - let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 502 - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 620 + let json = json_body(resp).await; 503 621 assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 504 622 } 505 623 ··· 509 627 async fn expired_token_returns_401_token_expired() { 510 628 let state = test_state().await; 511 629 let secret = state.jwt_secret; 512 - // exp is 1 second in the past. 513 630 let token = mint_token("did:plc:user", "com.atproto.access", -1, &secret, None); 514 - let app = protected_app(state); 515 - let resp = get_protected(app, Some(&token)).await; 631 + let resp = get_protected(protected_app(state), Some(&token)).await; 516 632 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 517 - 518 - let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 519 - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 633 + let json = json_body(resp).await; 520 634 assert_eq!(json["error"]["code"], "TOKEN_EXPIRED"); 521 635 } 522 636 523 - // ── Valid access token ──────────────────────────────────────────────────── 637 + // ── Valid access tokens ─────────────────────────────────────────────────── 524 638 525 639 #[tokio::test] 526 640 async fn valid_access_token_extracts_did_and_scope() { 527 641 let state = test_state().await; 528 - let secret = state.jwt_secret; 529 642 let token = mint_token( 530 643 "did:plc:alice", 531 644 "com.atproto.access", 532 645 3600, 533 - &secret, 646 + &state.jwt_secret, 534 647 None, 535 648 ); 536 - let app = protected_app(state); 537 - let resp = get_protected(app, Some(&token)).await; 649 + let resp = get_protected(protected_app(state), Some(&token)).await; 538 650 assert_eq!(resp.status(), StatusCode::OK); 539 - 540 - let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 541 - let text = String::from_utf8(body.to_vec()).unwrap(); 651 + let text = 652 + String::from_utf8(axum::body::to_bytes(resp.into_body(), 4096).await.unwrap().to_vec()) 653 + .unwrap(); 542 654 assert!(text.contains("did=did:plc:alice")); 543 655 assert!(text.contains("scope=Access")); 544 656 } ··· 546 658 #[tokio::test] 547 659 async fn valid_refresh_token_extracts_refresh_scope() { 548 660 let state = test_state().await; 549 - let secret = state.jwt_secret; 550 - let token = mint_token("did:plc:alice", "com.atproto.refresh", 3600, &secret, None); 551 - let app = protected_app(state); 552 - let resp = get_protected(app, Some(&token)).await; 661 + let token = mint_token("did:plc:alice", "com.atproto.refresh", 3600, &state.jwt_secret, None); 662 + let resp = get_protected(protected_app(state), Some(&token)).await; 553 663 assert_eq!(resp.status(), StatusCode::OK); 664 + let text = 665 + String::from_utf8(axum::body::to_bytes(resp.into_body(), 4096).await.unwrap().to_vec()) 666 + .unwrap(); 667 + assert!(text.contains("scope=Refresh")); 668 + } 554 669 555 - let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 556 - let text = String::from_utf8(body.to_vec()).unwrap(); 557 - assert!(text.contains("scope=Refresh")); 670 + #[tokio::test] 671 + async fn valid_app_pass_token_extracts_app_pass_scope() { 672 + let state = test_state().await; 673 + let token = mint_token("did:plc:alice", "com.atproto.appPass", 3600, &state.jwt_secret, None); 674 + let resp = get_protected(protected_app(state), Some(&token)).await; 675 + assert_eq!(resp.status(), StatusCode::OK); 676 + let text = 677 + String::from_utf8(axum::body::to_bytes(resp.into_body(), 4096).await.unwrap().to_vec()) 678 + .unwrap(); 679 + assert!(text.contains("scope=AppPass")); 558 680 } 559 681 560 682 // ── Unknown scope ───────────────────────────────────────────────────────── ··· 562 684 #[tokio::test] 563 685 async fn unknown_scope_returns_401_invalid_token() { 564 686 let state = test_state().await; 565 - let secret = state.jwt_secret; 566 - let token = mint_token("did:plc:user", "com.example.unknown", 3600, &secret, None); 567 - let app = protected_app(state); 568 - let resp = get_protected(app, Some(&token)).await; 687 + let token = mint_token("did:plc:user", "com.example.unknown", 3600, &state.jwt_secret, None); 688 + let resp = get_protected(protected_app(state), Some(&token)).await; 689 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 690 + let json = json_body(resp).await; 691 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 692 + } 693 + 694 + // ── Audience validation ─────────────────────────────────────────────────── 695 + 696 + #[tokio::test] 697 + async fn token_with_wrong_audience_returns_401_when_server_did_configured() { 698 + use std::sync::Arc; 699 + let base = test_state().await; 700 + let mut config = (*base.config).clone(); 701 + config.server_did = Some("did:plc:server".to_string()); 702 + let state = AppState { config: Arc::new(config), ..base }; 703 + 704 + // mint_token encodes aud = "did:plc:test" — wrong for did:plc:server 705 + let token = mint_token("did:plc:user", "com.atproto.access", 3600, &state.jwt_secret, None); 706 + let resp = get_protected(protected_app(state), Some(&token)).await; 569 707 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 708 + let json = json_body(resp).await; 709 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 710 + } 570 711 571 - let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 572 - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 712 + // ── DPoP — downgrade prevention (RFC 9449 §7.1) ────────────────────────── 713 + 714 + #[tokio::test] 715 + async fn dpop_bound_token_without_dpop_header_returns_401() { 716 + let state = test_state().await; 717 + let dpop_key = SigningKey::random(&mut OsRng); 718 + let thumbprint = dpop_key_thumbprint(&dpop_key); 719 + 720 + // Access token has cnf.jkt → DPoP-bound. 721 + let token = mint_token( 722 + "did:plc:user", 723 + "com.atproto.access", 724 + 3600, 725 + &state.jwt_secret, 726 + Some(serde_json::json!({ "jkt": thumbprint })), 727 + ); 728 + // No DPoP header sent — must be rejected. 729 + let resp = get_protected(protected_app(state), Some(&token)).await; 730 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 731 + let json = json_body(resp).await; 732 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 733 + } 734 + 735 + #[tokio::test] 736 + async fn dpop_header_present_but_access_token_has_no_cnf_returns_401() { 737 + let state = test_state().await; 738 + // Access token has no cnf claim. 739 + let token = mint_token("did:plc:user", "com.atproto.access", 3600, &state.jwt_secret, None); 740 + let req = Request::builder() 741 + .uri("/protected") 742 + .header("Authorization", format!("Bearer {token}")) 743 + .header("DPoP", "dummy.dpop.value") 744 + .body(Body::empty()) 745 + .unwrap(); 746 + let resp = protected_app(state).oneshot(req).await.unwrap(); 747 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 748 + let json = json_body(resp).await; 749 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 750 + } 751 + 752 + // ── DPoP — valid proof accepted ─────────────────────────────────────────── 753 + 754 + #[tokio::test] 755 + async fn valid_dpop_bound_token_is_accepted() { 756 + let state = test_state().await; 757 + let dpop_key = SigningKey::random(&mut OsRng); 758 + let thumbprint = dpop_key_thumbprint(&dpop_key); 759 + 760 + let token = mint_token( 761 + "did:plc:alice", 762 + "com.atproto.access", 763 + 3600, 764 + &state.jwt_secret, 765 + Some(serde_json::json!({ "jkt": thumbprint })), 766 + ); 767 + // htu = public_url + path (matches how the extractor builds expected_htu) 768 + let htu = format!("{}/protected", state.config.public_url); 769 + let dpop_proof = make_dpop_proof(&dpop_key, "GET", &htu); 770 + 771 + let req = Request::builder() 772 + .uri("/protected") 773 + .header("Authorization", format!("Bearer {token}")) 774 + .header("DPoP", dpop_proof) 775 + .body(Body::empty()) 776 + .unwrap(); 777 + let resp = protected_app(state).oneshot(req).await.unwrap(); 778 + assert_eq!(resp.status(), StatusCode::OK); 779 + } 780 + 781 + // ── DPoP — signature forgery rejected ──────────────────────────────────── 782 + 783 + #[tokio::test] 784 + async fn dpop_proof_with_forged_signature_returns_401() { 785 + let state = test_state().await; 786 + // Attacker's key — different from the key that signed the access token. 787 + let attacker_key = SigningKey::random(&mut OsRng); 788 + let legit_key = SigningKey::random(&mut OsRng); 789 + let thumbprint = dpop_key_thumbprint(&legit_key); 790 + 791 + let token = mint_token( 792 + "did:plc:alice", 793 + "com.atproto.access", 794 + 3600, 795 + &state.jwt_secret, 796 + Some(serde_json::json!({ "jkt": thumbprint })), 797 + ); 798 + // Proof is signed by attacker_key but claims legit_key's thumbprint in the header JWK. 799 + // The JWK in the proof header is attacker_key's public key → thumbprint mismatch. 800 + let htu = format!("{}/protected", state.config.public_url); 801 + let dpop_proof = make_dpop_proof(&attacker_key, "GET", &htu); 802 + 803 + let req = Request::builder() 804 + .uri("/protected") 805 + .header("Authorization", format!("Bearer {token}")) 806 + .header("DPoP", dpop_proof) 807 + .body(Body::empty()) 808 + .unwrap(); 809 + let resp = protected_app(state).oneshot(req).await.unwrap(); 810 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 811 + let json = json_body(resp).await; 812 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 813 + } 814 + 815 + // ── DPoP — individual claim validation ─────────────────────────────────── 816 + 817 + #[tokio::test] 818 + async fn dpop_wrong_htm_returns_401() { 819 + let state = test_state().await; 820 + let dpop_key = SigningKey::random(&mut OsRng); 821 + let thumbprint = dpop_key_thumbprint(&dpop_key); 822 + let token = mint_token( 823 + "did:plc:alice", 824 + "com.atproto.access", 825 + 3600, 826 + &state.jwt_secret, 827 + Some(serde_json::json!({ "jkt": thumbprint })), 828 + ); 829 + let htu = format!("{}/protected", state.config.public_url); 830 + // htm says POST but request is GET. 831 + let dpop_proof = make_dpop_proof(&dpop_key, "POST", &htu); 832 + 833 + let req = Request::builder() 834 + .uri("/protected") 835 + .header("Authorization", format!("Bearer {token}")) 836 + .header("DPoP", dpop_proof) 837 + .body(Body::empty()) 838 + .unwrap(); 839 + let resp = protected_app(state).oneshot(req).await.unwrap(); 840 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 841 + let json = json_body(resp).await; 842 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 843 + } 844 + 845 + #[tokio::test] 846 + async fn dpop_wrong_htu_returns_401() { 847 + let state = test_state().await; 848 + let dpop_key = SigningKey::random(&mut OsRng); 849 + let thumbprint = dpop_key_thumbprint(&dpop_key); 850 + let token = mint_token( 851 + "did:plc:alice", 852 + "com.atproto.access", 853 + 3600, 854 + &state.jwt_secret, 855 + Some(serde_json::json!({ "jkt": thumbprint })), 856 + ); 857 + // htu points to a different endpoint. 858 + let dpop_proof = make_dpop_proof( 859 + &dpop_key, 860 + "GET", 861 + &format!("{}/other-endpoint", state.config.public_url), 862 + ); 863 + 864 + let req = Request::builder() 865 + .uri("/protected") 866 + .header("Authorization", format!("Bearer {token}")) 867 + .header("DPoP", dpop_proof) 868 + .body(Body::empty()) 869 + .unwrap(); 870 + let resp = protected_app(state).oneshot(req).await.unwrap(); 871 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 872 + let json = json_body(resp).await; 873 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 874 + } 875 + 876 + #[tokio::test] 877 + async fn dpop_wrong_typ_returns_401() { 878 + let state = test_state().await; 879 + let dpop_key = SigningKey::random(&mut OsRng); 880 + let thumbprint = dpop_key_thumbprint(&dpop_key); 881 + let token = mint_token( 882 + "did:plc:alice", 883 + "com.atproto.access", 884 + 3600, 885 + &state.jwt_secret, 886 + Some(serde_json::json!({ "jkt": thumbprint })), 887 + ); 888 + 889 + let jwk = dpop_key_to_jwk(&dpop_key); 890 + // Wrong typ — should be "dpop+jwt". 891 + let header = serde_json::json!({ "typ": "JWT", "alg": "ES256", "jwk": jwk }); 892 + let payload = serde_json::json!({ 893 + "htm": "GET", 894 + "htu": format!("{}/protected", state.config.public_url), 895 + "iat": now_secs() as i64, 896 + "jti": "test-jti", 897 + }); 898 + let hdr_b64 = 899 + URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap().as_bytes()); 900 + let pay_b64 = 901 + URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap().as_bytes()); 902 + let sig: Signature = dpop_key.sign(format!("{hdr_b64}.{pay_b64}").as_bytes()); 903 + let dpop_proof = 904 + format!("{hdr_b64}.{pay_b64}.{}", URL_SAFE_NO_PAD.encode(sig.to_bytes().as_ref() as &[u8])); 905 + 906 + let req = Request::builder() 907 + .uri("/protected") 908 + .header("Authorization", format!("Bearer {token}")) 909 + .header("DPoP", dpop_proof) 910 + .body(Body::empty()) 911 + .unwrap(); 912 + let resp = protected_app(state).oneshot(req).await.unwrap(); 913 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 914 + let json = json_body(resp).await; 915 + assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 916 + } 917 + 918 + #[tokio::test] 919 + async fn dpop_empty_jti_returns_401() { 920 + let state = test_state().await; 921 + let dpop_key = SigningKey::random(&mut OsRng); 922 + let thumbprint = dpop_key_thumbprint(&dpop_key); 923 + let token = mint_token( 924 + "did:plc:alice", 925 + "com.atproto.access", 926 + 3600, 927 + &state.jwt_secret, 928 + Some(serde_json::json!({ "jkt": thumbprint })), 929 + ); 930 + 931 + let jwk = dpop_key_to_jwk(&dpop_key); 932 + let header = serde_json::json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": jwk }); 933 + // Empty jti. 934 + let payload = serde_json::json!({ 935 + "htm": "GET", 936 + "htu": format!("{}/protected", state.config.public_url), 937 + "iat": now_secs() as i64, 938 + "jti": "", 939 + }); 940 + let hdr_b64 = 941 + URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap().as_bytes()); 942 + let pay_b64 = 943 + URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap().as_bytes()); 944 + let sig: Signature = dpop_key.sign(format!("{hdr_b64}.{pay_b64}").as_bytes()); 945 + let dpop_proof = 946 + format!("{hdr_b64}.{pay_b64}.{}", URL_SAFE_NO_PAD.encode(sig.to_bytes().as_ref() as &[u8])); 947 + 948 + let req = Request::builder() 949 + .uri("/protected") 950 + .header("Authorization", format!("Bearer {token}")) 951 + .header("DPoP", dpop_proof) 952 + .body(Body::empty()) 953 + .unwrap(); 954 + let resp = protected_app(state).oneshot(req).await.unwrap(); 955 + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 956 + let json = json_body(resp).await; 573 957 assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 574 958 } 575 959 ··· 577 961 578 962 #[test] 579 963 fn rsa_jwk_thumbprint_matches_rfc7638_example() { 580 - // RFC 7638 §3.3 canonical example — RSA key with known expected thumbprint. 964 + // RFC 7638 §3.3 canonical example — RSA key with normative expected thumbprint. 581 965 let jwk = serde_json::json!({ 582 966 "e": "AQAB", 583 967 "kty": "RSA", 584 968 "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", 585 - // Extra member — must be excluded from the canonical form. 586 - "use": "sig" 969 + "use": "sig" // extra member — must be excluded from canonical form 587 970 }); 588 - let thumb = jwk_thumbprint(&jwk).unwrap(); 589 - assert_eq!(thumb, "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"); 971 + assert_eq!( 972 + jwk_thumbprint(&jwk).unwrap(), 973 + "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs" 974 + ); 590 975 } 591 976 592 977 #[test] 593 978 fn ec_jwk_thumbprint_produces_correct_format() { 594 - // EC (P-256) key from RFC 7517 Appendix A.2. Extra fields like "use" and "d" 595 - // must be stripped from the canonical form. 979 + // EC (P-256) key from RFC 7517 Appendix A.2. 596 980 let jwk = serde_json::json!({ 597 981 "kty": "EC", 598 982 "crv": "P-256", ··· 601 985 "use": "sig" 602 986 }); 603 987 let thumb = jwk_thumbprint(&jwk).unwrap(); 604 - // SHA-256 base64url (no padding) is always 43 characters. 605 988 assert_eq!(thumb.len(), 43, "thumbprint must be 43 base64url chars"); 606 989 assert!( 607 990 thumb.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_'), 608 991 "thumbprint must be base64url" 609 992 ); 610 - // Stable value — verified against implementation; guards against regressions. 993 + // Stable regression guard — verified against this implementation. 611 994 assert_eq!(thumb, "oKIywvGUpTVTyxMQ3bwIIeQUudfr_CkLMjCE19ECD-U"); 612 995 } 613 996 614 - // ── DPoP binding — token without cnf claim rejected ─────────────────────── 615 - 616 - #[tokio::test] 617 - async fn dpop_header_without_cnf_claim_returns_401() { 618 - let state = test_state().await; 619 - let secret = state.jwt_secret; 620 - // Access token has no `cnf` claim. 621 - let token = mint_token("did:plc:user", "com.atproto.access", 3600, &secret, None); 622 - let app = protected_app(state); 623 - 624 - let req = Request::builder() 625 - .uri("/protected") 626 - .header("Authorization", format!("Bearer {token}")) 627 - .header("DPoP", "dummy.dpop.value") 628 - .body(Body::empty()) 629 - .unwrap(); 630 - let resp = app.oneshot(req).await.unwrap(); 631 - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 632 - 633 - let body = axum::body::to_bytes(resp.into_body(), 4096).await.unwrap(); 634 - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); 635 - assert_eq!(json["error"]["code"], "INVALID_TOKEN"); 997 + #[test] 998 + fn okp_jwk_thumbprint_produces_correct_format() { 999 + // Ed25519 (OKP) JWK — verifies OKP branch of jwk_thumbprint. 1000 + let jwk = serde_json::json!({ 1001 + "kty": "OKP", 1002 + "crv": "Ed25519", 1003 + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", 1004 + "d": "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A" // private — must be excluded 1005 + }); 1006 + let thumb = jwk_thumbprint(&jwk).unwrap(); 1007 + assert_eq!(thumb.len(), 43); 1008 + assert!(thumb.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')); 1009 + // Stable regression guard. 1010 + assert_eq!(thumb, "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k"); 636 1011 } 637 1012 }