CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

Improve testability of the OAuth client pipeline

- RelyingParty: document why this module deviates from the project's
HttpClient seam (it talks to a real fake-AS over genuine HTTP, and
do_authorize needs a redirect-following policy distinct from the
rest of the RP). Build the redirect-disabled client once at
construction time as a new http_no_redirect field instead of
rebuilding it on every do_authorize call.
- Extract pure helpers from discovery::run (evaluate_https_metadata_response,
evaluate_loopback_metadata, HttpsFetchOutcome) and from
RelyingParty::discover_as (parse_as_descriptor). Add 12 new unit
tests covering network failures, status-code branches, content-type
edge cases, missing-required-field cases, and non-URL endpoints —
paths previously only reachable through the integration suite.
- Add proptest as a dev-dependency and pin the cryptographic roundtrip
invariants documented in src/common/CLAUDE.md as property tests:
multikey encode/parse roundtrip (k256 + p256), AnySignature::to_jws_bytes
always 64 bytes, sign_prehash/verify_prehash roundtrip, JWT
encode_compact/verify_compact roundtrip, ES256 sign/verify roundtrip.
Keys are seeded from a 32-byte proptest input via ChaCha20Rng so
shrunk failures are reproducible from the seed alone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+893 -210
+74
Cargo.lock
··· 176 176 "multibase", 177 177 "p256", 178 178 "percent-encoding", 179 + "proptest", 179 180 "rand 0.8.6", 180 181 "rand_chacha 0.3.1", 181 182 "rand_core 0.6.4", ··· 385 386 version = "1.8.3" 386 387 source = "registry+https://github.com/rust-lang/crates.io-index" 387 388 checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" 389 + 390 + [[package]] 391 + name = "bit-set" 392 + version = "0.8.0" 393 + source = "registry+https://github.com/rust-lang/crates.io-index" 394 + checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" 395 + dependencies = [ 396 + "bit-vec", 397 + ] 398 + 399 + [[package]] 400 + name = "bit-vec" 401 + version = "0.8.0" 402 + source = "registry+https://github.com/rust-lang/crates.io-index" 403 + checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" 388 404 389 405 [[package]] 390 406 name = "bitflags" ··· 1032 1048 "crc32fast", 1033 1049 "miniz_oxide", 1034 1050 ] 1051 + 1052 + [[package]] 1053 + name = "fnv" 1054 + version = "1.0.7" 1055 + source = "registry+https://github.com/rust-lang/crates.io-index" 1056 + checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" 1035 1057 1036 1058 [[package]] 1037 1059 name = "foldhash" ··· 2210 2232 ] 2211 2233 2212 2234 [[package]] 2235 + name = "proptest" 2236 + version = "1.11.0" 2237 + source = "registry+https://github.com/rust-lang/crates.io-index" 2238 + checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" 2239 + dependencies = [ 2240 + "bit-set", 2241 + "bit-vec", 2242 + "bitflags", 2243 + "num-traits", 2244 + "rand 0.9.4", 2245 + "rand_chacha 0.9.0", 2246 + "rand_xorshift", 2247 + "regex-syntax", 2248 + "rusty-fork", 2249 + "tempfile", 2250 + "unarray", 2251 + ] 2252 + 2253 + [[package]] 2254 + name = "quick-error" 2255 + version = "1.2.3" 2256 + source = "registry+https://github.com/rust-lang/crates.io-index" 2257 + checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" 2258 + 2259 + [[package]] 2213 2260 name = "quinn" 2214 2261 version = "0.11.9" 2215 2262 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2343 2390 checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" 2344 2391 dependencies = [ 2345 2392 "getrandom 0.3.4", 2393 + ] 2394 + 2395 + [[package]] 2396 + name = "rand_xorshift" 2397 + version = "0.4.0" 2398 + source = "registry+https://github.com/rust-lang/crates.io-index" 2399 + checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" 2400 + dependencies = [ 2401 + "rand_core 0.9.5", 2346 2402 ] 2347 2403 2348 2404 [[package]] ··· 2616 2672 version = "1.0.22" 2617 2673 source = "registry+https://github.com/rust-lang/crates.io-index" 2618 2674 checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" 2675 + 2676 + [[package]] 2677 + name = "rusty-fork" 2678 + version = "0.3.1" 2679 + source = "registry+https://github.com/rust-lang/crates.io-index" 2680 + checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" 2681 + dependencies = [ 2682 + "fnv", 2683 + "quick-error", 2684 + "tempfile", 2685 + "wait-timeout", 2686 + ] 2619 2687 2620 2688 [[package]] 2621 2689 name = "ryu" ··· 3303 3371 version = "1.19.0" 3304 3372 source = "registry+https://github.com/rust-lang/crates.io-index" 3305 3373 checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 3374 + 3375 + [[package]] 3376 + name = "unarray" 3377 + version = "0.1.4" 3378 + source = "registry+https://github.com/rust-lang/crates.io-index" 3379 + checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" 3306 3380 3307 3381 [[package]] 3308 3382 name = "unicode-ident"
+6
Cargo.toml
··· 60 60 [dev-dependencies] 61 61 assert_cmd = "2.0" 62 62 insta = "1.47" 63 + # Property-based tests pin roundtrip / idempotence properties for the 64 + # cryptographic primitives (multikey encode/decode, JWT encode/verify, 65 + # JWS sign/verify, signature byte-length invariants). 64 cases per 66 + # property is enough to exercise every byte-pattern edge case without 67 + # making the test suite slow. 68 + proptest = "1.5" 63 69 rand = "0.8" 64 70 rand_chacha = "0.3" 65 71 tokio = { version = "1.51", features = ["rt", "macros", "test-util", "time"] }
+338 -148
src/commands/test/oauth/client/pipeline/discovery.rs
··· 190 190 pub results: Vec<CheckResult>, 191 191 } 192 192 193 + /// Outcome of an HTTPS metadata fetch, in a shape suitable for the pure 194 + /// `evaluate_https_metadata_response` evaluator below. 195 + #[derive(Debug)] 196 + pub(super) enum HttpsFetchOutcome { 197 + /// The fetch failed at the transport layer. 198 + NetworkFailure(crate::common::identity::IdentityError), 199 + /// The fetch returned an HTTP response (2xx or otherwise). 200 + Response { 201 + status: u16, 202 + body: Vec<u8>, 203 + content_type: Option<String>, 204 + }, 205 + } 206 + 193 207 /// Run the discovery stage for the given target. 208 + /// 209 + /// This is the imperative shell: it does the network fetch and dispatches 210 + /// to the pure `evaluate_*` helpers below to compute every check result. 194 211 pub async fn run(target: &OauthClientTarget, http: &dyn HttpClient) -> DiscoveryStageOutput { 195 - let mut results = Vec::new(); 196 - 197 212 match target { 198 213 OauthClientTarget::HttpsUrl(url) => { 199 - // Check that the client_id is well-formed (it's already validated by parse_target, 200 - // but we still emit the check). 201 - results.push(Check::ClientIdWellFormed.pass()); 214 + let outcome = match http.get_bytes_with_content_type(url).await { 215 + Err(e) => HttpsFetchOutcome::NetworkFailure(e), 216 + Ok((status, body, content_type)) => HttpsFetchOutcome::Response { 217 + status, 218 + body, 219 + content_type, 220 + }, 221 + }; 222 + evaluate_https_metadata_response(url, outcome) 223 + } 224 + OauthClientTarget::Loopback(_) => evaluate_loopback_metadata(), 225 + } 226 + } 202 227 203 - // Fetch the metadata document. 204 - match http.get_bytes_with_content_type(url).await { 205 - Err(e) => { 206 - // Network error on fetch. 207 - let diagnostic: Box<dyn Diagnostic + Send + Sync> = 208 - Box::new(FetchError::from_identity_error(e, url)); 209 - results.push(Check::MetadataDocumentFetchable.network_error(Some(diagnostic))); 210 - for check in [Check::MetadataContentTypeIsJson, Check::MetadataIsJson] { 211 - results.push(blocked_by( 212 - check.id(), 213 - Stage::OAUTH_CLIENT_DISCOVERY, 214 - check.pass_summary(), 215 - Check::MetadataDocumentFetchable.id(), 216 - )); 217 - } 218 - DiscoveryStageOutput { 219 - facts: None, 220 - results, 221 - } 222 - } 223 - Ok((status, _, _)) if !(200..=299).contains(&status) => { 224 - // Non-2xx status: transport-layer failure. 225 - let diagnostic: Box<dyn Diagnostic + Send + Sync> = Box::new(HttpStatusError { 228 + /// Pure: evaluate an HTTPS metadata fetch outcome and emit all four 229 + /// discovery-stage check results plus the discovery facts (when the 230 + /// fetch produced parseable JSON). 231 + pub(super) fn evaluate_https_metadata_response( 232 + url: &Url, 233 + fetch_outcome: HttpsFetchOutcome, 234 + ) -> DiscoveryStageOutput { 235 + let mut results = Vec::new(); 236 + // `client_id` validity is already enforced by `parse_target`; emit the 237 + // pass row so the report still shows the check. 238 + results.push(Check::ClientIdWellFormed.pass()); 239 + 240 + match fetch_outcome { 241 + HttpsFetchOutcome::NetworkFailure(e) => { 242 + let diagnostic: Box<dyn Diagnostic + Send + Sync> = 243 + Box::new(FetchError::from_identity_error(e, url)); 244 + results.push(Check::MetadataDocumentFetchable.network_error(Some(diagnostic))); 245 + push_blocked_by_fetchable(&mut results); 246 + DiscoveryStageOutput { 247 + facts: None, 248 + results, 249 + } 250 + } 251 + HttpsFetchOutcome::Response { 252 + status, 253 + body, 254 + content_type, 255 + } => { 256 + if !(200..=299).contains(&status) { 257 + // Non-2xx status: transport-layer failure. 258 + let diagnostic: Box<dyn Diagnostic + Send + Sync> = Box::new(HttpStatusError { 259 + url: url.clone(), 260 + status, 261 + }); 262 + results.push(Check::MetadataDocumentFetchable.network_error(Some(diagnostic))); 263 + push_blocked_by_fetchable(&mut results); 264 + return DiscoveryStageOutput { 265 + facts: None, 266 + results, 267 + }; 268 + } 269 + if status != 200 { 270 + // 2xx but not 200: spec violation per 271 + // <https://atproto.com/specs/oauth#client-metadata> 272 + // ("must be 200 (not another 2xx or a redirect)"). 273 + let diagnostic: Box<dyn Diagnostic + Send + Sync> = Box::new(NonOkStatusError { 274 + url: url.clone(), 275 + status, 276 + }); 277 + results.push(Check::MetadataDocumentFetchable.spec_violation(Some(diagnostic))); 278 + push_blocked_by_fetchable(&mut results); 279 + return DiscoveryStageOutput { 280 + facts: None, 281 + results, 282 + }; 283 + } 284 + results.push(Check::MetadataDocumentFetchable.pass()); 285 + 286 + // Content-Type check: the atproto OAuth profile requires the 287 + // metadata response carry the correct JSON content type. 288 + // Accept `application/json` with optional parameters like 289 + // `charset=utf-8`. 290 + if content_type_is_json(content_type.as_deref()) { 291 + results.push(Check::MetadataContentTypeIsJson.pass()); 292 + } else { 293 + let diagnostic: Box<dyn Diagnostic + Send + Sync> = 294 + Box::new(NonJsonContentTypeError { 226 295 url: url.clone(), 227 - status, 296 + content_type: content_type.clone(), 297 + }); 298 + results.push(Check::MetadataContentTypeIsJson.spec_violation(Some(diagnostic))); 299 + } 300 + 301 + // Try to parse as JSON. 302 + match serde_json::from_slice::<serde_json::Value>(&body) { 303 + Err(json_err) => { 304 + let pretty_body = crate::common::diagnostics::pretty_json_for_display(&body); 305 + let span = 306 + span_at_line_column(&pretty_body, json_err.line(), json_err.column()); 307 + let ct = content_type.as_deref().unwrap_or("<unknown>"); 308 + let diagnostic: Box<dyn Diagnostic + Send + Sync> = Box::new(JsonParseError { 309 + source: named_source_from_bytes( 310 + format!("metadata document (content-type: {ct})"), 311 + pretty_body, 312 + ), 313 + span, 314 + message: format!("response body is not valid JSON (content-type: {ct})"), 228 315 }); 229 - results.push(Check::MetadataDocumentFetchable.network_error(Some(diagnostic))); 230 - for check in [Check::MetadataContentTypeIsJson, Check::MetadataIsJson] { 231 - results.push(blocked_by( 232 - check.id(), 233 - Stage::OAUTH_CLIENT_DISCOVERY, 234 - check.pass_summary(), 235 - Check::MetadataDocumentFetchable.id(), 236 - )); 237 - } 316 + results.push(Check::MetadataIsJson.spec_violation(Some(diagnostic))); 238 317 DiscoveryStageOutput { 239 318 facts: None, 240 319 results, 241 320 } 242 321 } 243 - Ok((status, _, _)) if status != 200 => { 244 - // 2xx but not 200: spec violation per 245 - // <https://atproto.com/specs/oauth#client-metadata> 246 - // ("must be 200 (not another 2xx or a redirect)"). 247 - let diagnostic: Box<dyn Diagnostic + Send + Sync> = 248 - Box::new(NonOkStatusError { 249 - url: url.clone(), 250 - status, 251 - }); 252 - results.push(Check::MetadataDocumentFetchable.spec_violation(Some(diagnostic))); 253 - for check in [Check::MetadataContentTypeIsJson, Check::MetadataIsJson] { 254 - results.push(blocked_by( 255 - check.id(), 256 - Stage::OAUTH_CLIENT_DISCOVERY, 257 - check.pass_summary(), 258 - Check::MetadataDocumentFetchable.id(), 259 - )); 260 - } 322 + Ok(_) => { 323 + results.push(Check::MetadataIsJson.pass()); 261 324 DiscoveryStageOutput { 262 - facts: None, 325 + facts: Some(DiscoveryFacts { 326 + client_id: url.clone(), 327 + kind: ClientIdKind::HttpsUrl, 328 + raw_metadata: RawMetadata::Document { 329 + bytes: Arc::from(body), 330 + content_type, 331 + }, 332 + }), 263 333 results, 264 334 } 265 335 } 266 - Ok((_status, body, content_type)) => { 267 - results.push(Check::MetadataDocumentFetchable.pass()); 268 - 269 - // Content-Type check: the atproto OAuth profile 270 - // requires the metadata response carry the correct 271 - // JSON content type. Accept `application/json` 272 - // with optional parameters like `charset=utf-8`. 273 - if content_type_is_json(content_type.as_deref()) { 274 - results.push(Check::MetadataContentTypeIsJson.pass()); 275 - } else { 276 - let diagnostic: Box<dyn Diagnostic + Send + Sync> = 277 - Box::new(NonJsonContentTypeError { 278 - url: url.clone(), 279 - content_type: content_type.clone(), 280 - }); 281 - results.push( 282 - Check::MetadataContentTypeIsJson.spec_violation(Some(diagnostic)), 283 - ); 284 - } 285 - 286 - // Try to parse as JSON. 287 - match serde_json::from_slice::<serde_json::Value>(&body) { 288 - Err(json_err) => { 289 - // Parse error. 290 - let pretty_body = 291 - crate::common::diagnostics::pretty_json_for_display(&body); 292 - let span = span_at_line_column( 293 - &pretty_body, 294 - json_err.line(), 295 - json_err.column(), 296 - ); 297 - let ct = content_type.as_deref().unwrap_or("<unknown>"); 298 - let diagnostic: Box<dyn Diagnostic + Send + Sync> = 299 - Box::new(JsonParseError { 300 - source: named_source_from_bytes( 301 - format!("metadata document (content-type: {ct})"), 302 - pretty_body, 303 - ), 304 - span, 305 - message: format!( 306 - "response body is not valid JSON (content-type: {ct})" 307 - ), 308 - }); 309 - results.push(Check::MetadataIsJson.spec_violation(Some(diagnostic))); 310 - DiscoveryStageOutput { 311 - facts: None, 312 - results, 313 - } 314 - } 315 - Ok(_) => { 316 - // Valid JSON. 317 - results.push(Check::MetadataIsJson.pass()); 318 - DiscoveryStageOutput { 319 - facts: Some(DiscoveryFacts { 320 - client_id: url.clone(), 321 - kind: ClientIdKind::HttpsUrl, 322 - raw_metadata: RawMetadata::Document { 323 - bytes: Arc::from(body), 324 - content_type, 325 - }, 326 - }), 327 - results, 328 - } 329 - } 330 - } 331 - } 332 336 } 333 337 } 334 - OauthClientTarget::Loopback(_) => { 335 - // Loopback targets have well-formed client_id by definition. 336 - results.push(Check::ClientIdWellFormed.pass()); 338 + } 339 + } 337 340 338 - // Metadata is implicit for loopback clients. 339 - for check in [ 340 - Check::MetadataDocumentFetchable, 341 - Check::MetadataContentTypeIsJson, 342 - Check::MetadataIsJson, 343 - ] { 344 - results.push(check.skipped("metadata is implicit for loopback clients")); 345 - } 341 + /// Pure: emit the discovery-stage results for a loopback client. Loopback 342 + /// targets have no metadata document; the well-formedness check passes 343 + /// by definition and the three document-related checks are skipped. 344 + pub(super) fn evaluate_loopback_metadata() -> DiscoveryStageOutput { 345 + let mut results = vec![Check::ClientIdWellFormed.pass()]; 346 + for check in [ 347 + Check::MetadataDocumentFetchable, 348 + Check::MetadataContentTypeIsJson, 349 + Check::MetadataIsJson, 350 + ] { 351 + results.push(check.skipped("metadata is implicit for loopback clients")); 352 + } 353 + let client_id = Url::parse("http://localhost/") 354 + .expect("`http://localhost/` is a statically known-good URL"); 355 + DiscoveryStageOutput { 356 + facts: Some(DiscoveryFacts { 357 + client_id: client_id.clone(), 358 + kind: ClientIdKind::Loopback, 359 + raw_metadata: RawMetadata::Implicit { client_id }, 360 + }), 361 + results, 362 + } 363 + } 346 364 347 - // The atproto loopback client_id is fixed at 348 - // `http://localhost/`. 349 - let client_id = Url::parse("http://localhost/") 350 - .expect("`http://localhost/` is a statically known-good URL"); 351 - 352 - DiscoveryStageOutput { 353 - facts: Some(DiscoveryFacts { 354 - client_id: client_id.clone(), 355 - kind: ClientIdKind::Loopback, 356 - raw_metadata: RawMetadata::Implicit { client_id }, 357 - }), 358 - results, 359 - } 360 - } 365 + /// Append the two `blocked_by` skip rows that follow a failed 366 + /// `MetadataDocumentFetchable` check. 367 + fn push_blocked_by_fetchable(results: &mut Vec<CheckResult>) { 368 + for check in [Check::MetadataContentTypeIsJson, Check::MetadataIsJson] { 369 + results.push(blocked_by( 370 + check.id(), 371 + Stage::OAUTH_CLIENT_DISCOVERY, 372 + check.pass_summary(), 373 + Check::MetadataDocumentFetchable.id(), 374 + )); 361 375 } 362 376 } 363 377 ··· 508 522 let media_type = ct.split(';').next().unwrap_or("").trim(); 509 523 media_type.eq_ignore_ascii_case("application/json") 510 524 } 525 + 526 + #[cfg(test)] 527 + mod tests { 528 + use super::*; 529 + use crate::common::identity::IdentityError; 530 + use crate::common::report::CheckStatus; 531 + 532 + fn https_url() -> Url { 533 + Url::parse("https://example.com/client-metadata.json").unwrap() 534 + } 535 + 536 + fn check_ids(output: &DiscoveryStageOutput) -> Vec<&'static str> { 537 + output.results.iter().map(|r| r.id).collect() 538 + } 539 + 540 + fn statuses(output: &DiscoveryStageOutput) -> Vec<CheckStatus> { 541 + output.results.iter().map(|r| r.status).collect() 542 + } 543 + 544 + #[test] 545 + fn loopback_emits_one_pass_and_three_skips_with_facts() { 546 + let output = evaluate_loopback_metadata(); 547 + assert_eq!( 548 + check_ids(&output), 549 + vec![ 550 + Check::ClientIdWellFormed.id(), 551 + Check::MetadataDocumentFetchable.id(), 552 + Check::MetadataContentTypeIsJson.id(), 553 + Check::MetadataIsJson.id(), 554 + ] 555 + ); 556 + assert_eq!( 557 + statuses(&output), 558 + vec![ 559 + CheckStatus::Pass, 560 + CheckStatus::Skipped, 561 + CheckStatus::Skipped, 562 + CheckStatus::Skipped, 563 + ] 564 + ); 565 + let facts = output.facts.expect("loopback always produces facts"); 566 + assert_eq!(facts.kind, ClientIdKind::Loopback); 567 + assert!(matches!(facts.raw_metadata, RawMetadata::Implicit { .. })); 568 + } 569 + 570 + #[test] 571 + fn network_failure_blocks_downstream_checks() { 572 + let outcome = HttpsFetchOutcome::NetworkFailure(IdentityError::InvalidHandle); 573 + let output = evaluate_https_metadata_response(&https_url(), outcome); 574 + assert!(output.facts.is_none()); 575 + assert_eq!( 576 + statuses(&output), 577 + vec![ 578 + CheckStatus::Pass, 579 + CheckStatus::NetworkError, 580 + CheckStatus::Skipped, 581 + CheckStatus::Skipped, 582 + ] 583 + ); 584 + } 585 + 586 + #[test] 587 + fn non_2xx_is_network_error_not_spec_violation() { 588 + let outcome = HttpsFetchOutcome::Response { 589 + status: 500, 590 + body: b"{}".to_vec(), 591 + content_type: Some("application/json".to_string()), 592 + }; 593 + let output = evaluate_https_metadata_response(&https_url(), outcome); 594 + assert!(output.facts.is_none()); 595 + assert_eq!( 596 + statuses(&output), 597 + vec![ 598 + CheckStatus::Pass, 599 + CheckStatus::NetworkError, 600 + CheckStatus::Skipped, 601 + CheckStatus::Skipped, 602 + ] 603 + ); 604 + } 605 + 606 + #[test] 607 + fn non_200_2xx_is_spec_violation() { 608 + // 201 / 204 / 3xx (followed) all violate the 609 + // <https://atproto.com/specs/oauth#client-metadata> "must be 200" 610 + // requirement. 611 + let outcome = HttpsFetchOutcome::Response { 612 + status: 201, 613 + body: b"{}".to_vec(), 614 + content_type: Some("application/json".to_string()), 615 + }; 616 + let output = evaluate_https_metadata_response(&https_url(), outcome); 617 + assert!(output.facts.is_none()); 618 + assert_eq!( 619 + statuses(&output), 620 + vec![ 621 + CheckStatus::Pass, 622 + CheckStatus::SpecViolation, 623 + CheckStatus::Skipped, 624 + CheckStatus::Skipped, 625 + ] 626 + ); 627 + } 628 + 629 + #[test] 630 + fn ok_with_html_content_type_passes_json_parse_but_flags_content_type() { 631 + let outcome = HttpsFetchOutcome::Response { 632 + status: 200, 633 + body: br#"{"client_id":"https://example.com/x"}"#.to_vec(), 634 + content_type: Some("text/html".to_string()), 635 + }; 636 + let output = evaluate_https_metadata_response(&https_url(), outcome); 637 + assert_eq!( 638 + statuses(&output), 639 + vec![ 640 + CheckStatus::Pass, // ClientIdWellFormed 641 + CheckStatus::Pass, // MetadataDocumentFetchable 642 + CheckStatus::SpecViolation, // MetadataContentTypeIsJson 643 + CheckStatus::Pass, // MetadataIsJson 644 + ] 645 + ); 646 + // Facts are still produced — downstream metadata stage can run. 647 + assert!(output.facts.is_some()); 648 + } 649 + 650 + #[test] 651 + fn ok_with_invalid_json_emits_spec_violation_and_no_facts() { 652 + let outcome = HttpsFetchOutcome::Response { 653 + status: 200, 654 + body: b"not json".to_vec(), 655 + content_type: Some("application/json".to_string()), 656 + }; 657 + let output = evaluate_https_metadata_response(&https_url(), outcome); 658 + assert!(output.facts.is_none()); 659 + assert_eq!( 660 + statuses(&output), 661 + vec![ 662 + CheckStatus::Pass, 663 + CheckStatus::Pass, 664 + CheckStatus::Pass, 665 + CheckStatus::SpecViolation, 666 + ] 667 + ); 668 + } 669 + 670 + #[test] 671 + fn ok_with_charset_param_is_accepted_as_json_content_type() { 672 + let outcome = HttpsFetchOutcome::Response { 673 + status: 200, 674 + body: b"{}".to_vec(), 675 + content_type: Some("application/json; charset=utf-8".to_string()), 676 + }; 677 + let output = evaluate_https_metadata_response(&https_url(), outcome); 678 + assert!(output.facts.is_some()); 679 + assert_eq!( 680 + statuses(&output), 681 + vec![ 682 + CheckStatus::Pass, 683 + CheckStatus::Pass, 684 + CheckStatus::Pass, 685 + CheckStatus::Pass, 686 + ] 687 + ); 688 + } 689 + 690 + #[test] 691 + fn content_type_predicate_handles_edge_cases() { 692 + assert!(content_type_is_json(Some("application/json"))); 693 + assert!(content_type_is_json(Some("application/JSON"))); 694 + assert!(content_type_is_json(Some("application/json;charset=utf-8"))); 695 + assert!(content_type_is_json(Some(" application/json "))); 696 + assert!(!content_type_is_json(None)); 697 + assert!(!content_type_is_json(Some("text/html"))); 698 + assert!(!content_type_is_json(Some("application/ld+json"))); 699 + } 700 + }
+106
src/common/identity.rs
··· 1739 1739 _ => panic!("Expected P256 keys"), 1740 1740 } 1741 1741 } 1742 + 1743 + // Property-based tests pinning the roundtrip and length invariants 1744 + // documented in `src/common/CLAUDE.md`: 1745 + // - `encode_multikey(parse_multikey(s).verifying_key) == s` for 1746 + // every well-formed atproto multikey, 1747 + // - `AnySignature::to_jws_bytes()` is always exactly 64 bytes for 1748 + // both curves. 1749 + // Keys are generated deterministically from the proptest-supplied 1750 + // 32-byte seed via `ChaCha20Rng`, so each shrunk failure is 1751 + // reproducible from the seed alone. 1752 + mod pbt { 1753 + use super::*; 1754 + use proptest::prelude::*; 1755 + use rand_chacha::ChaCha20Rng; 1756 + use rand_core::SeedableRng; 1757 + 1758 + proptest! { 1759 + #![proptest_config(ProptestConfig::with_cases(64))] 1760 + 1761 + #[test] 1762 + fn multikey_roundtrip_k256(seed in any::<[u8; 32]>()) { 1763 + let mut rng = ChaCha20Rng::from_seed(seed); 1764 + let signing = k256::ecdsa::SigningKey::random(&mut rng); 1765 + let original = AnyVerifyingKey::K256(*signing.verifying_key()); 1766 + 1767 + let encoded = encode_multikey(&original); 1768 + prop_assert!(encoded.starts_with('z')); 1769 + 1770 + let parsed = parse_multikey(&encoded) 1771 + .expect("a freshly encoded multikey must parse"); 1772 + let re_encoded = encode_multikey(&parsed.verifying_key); 1773 + prop_assert_eq!( 1774 + encoded, 1775 + re_encoded, 1776 + "encode_multikey must round-trip through parse_multikey" 1777 + ); 1778 + } 1779 + 1780 + #[test] 1781 + fn multikey_roundtrip_p256(seed in any::<[u8; 32]>()) { 1782 + let mut rng = ChaCha20Rng::from_seed(seed); 1783 + let signing = p256::ecdsa::SigningKey::random(&mut rng); 1784 + let original = AnyVerifyingKey::P256(*signing.verifying_key()); 1785 + 1786 + let encoded = encode_multikey(&original); 1787 + prop_assert!(encoded.starts_with('z')); 1788 + 1789 + let parsed = parse_multikey(&encoded) 1790 + .expect("a freshly encoded multikey must parse"); 1791 + let re_encoded = encode_multikey(&parsed.verifying_key); 1792 + prop_assert_eq!( 1793 + encoded, 1794 + re_encoded, 1795 + "encode_multikey must round-trip through parse_multikey" 1796 + ); 1797 + } 1798 + 1799 + #[test] 1800 + fn signature_jws_bytes_is_always_64_k256( 1801 + seed in any::<[u8; 32]>(), 1802 + msg_seed in any::<[u8; 32]>(), 1803 + ) { 1804 + let mut rng = ChaCha20Rng::from_seed(seed); 1805 + let signing = AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut rng)); 1806 + let signature = signing.sign_prehash(&msg_seed); 1807 + prop_assert_eq!(signature.to_jws_bytes().len(), 64); 1808 + } 1809 + 1810 + #[test] 1811 + fn signature_jws_bytes_is_always_64_p256( 1812 + seed in any::<[u8; 32]>(), 1813 + msg_seed in any::<[u8; 32]>(), 1814 + ) { 1815 + let mut rng = ChaCha20Rng::from_seed(seed); 1816 + let signing = AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut rng)); 1817 + let signature = signing.sign_prehash(&msg_seed); 1818 + prop_assert_eq!(signature.to_jws_bytes().len(), 64); 1819 + } 1820 + 1821 + #[test] 1822 + fn sign_verify_prehash_roundtrip_k256( 1823 + seed in any::<[u8; 32]>(), 1824 + msg in any::<[u8; 32]>(), 1825 + ) { 1826 + let mut rng = ChaCha20Rng::from_seed(seed); 1827 + let signing = AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut rng)); 1828 + let signature = signing.sign_prehash(&msg); 1829 + let verifying = signing.verifying_key(); 1830 + verifying.verify_prehash(&msg, &signature) 1831 + .expect("verify_prehash must accept a freshly produced signature"); 1832 + } 1833 + 1834 + #[test] 1835 + fn sign_verify_prehash_roundtrip_p256( 1836 + seed in any::<[u8; 32]>(), 1837 + msg in any::<[u8; 32]>(), 1838 + ) { 1839 + let mut rng = ChaCha20Rng::from_seed(seed); 1840 + let signing = AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut rng)); 1841 + let signature = signing.sign_prehash(&msg); 1842 + let verifying = signing.verifying_key(); 1843 + verifying.verify_prehash(&msg, &signature) 1844 + .expect("verify_prehash must accept a freshly produced signature"); 1845 + } 1846 + } 1847 + } 1742 1848 }
+89
src/common/jwt.rs
··· 459 459 let result = verify_compact(&tampered, &vkey); 460 460 assert!(matches!(result, Err(JwtError::InvalidSignatureScalar))); 461 461 } 462 + 463 + // Property-based roundtrip tests pinning the invariant that 464 + // `verify_compact(encode_compact(claims, key), key.verifying_key())` 465 + // recovers the same claim payload, for every well-formed claim set 466 + // and every signing key generated from a 32-byte seed. 467 + mod pbt { 468 + use super::*; 469 + use proptest::prelude::*; 470 + use rand_chacha::ChaCha20Rng; 471 + use rand_core::SeedableRng; 472 + 473 + // 16 hex chars matches the format atproto labelers expect for 474 + // `jti` and is what `RelyingParty::new_jti` produces. 475 + const JTI_REGEX: &str = "[0-9a-f]{16}"; 476 + 477 + proptest! { 478 + #![proptest_config(ProptestConfig::with_cases(32))] 479 + 480 + #[test] 481 + fn encode_verify_compact_roundtrip_k256( 482 + seed in any::<[u8; 32]>(), 483 + jti in JTI_REGEX, 484 + iat in 1_500_000_000i64..2_500_000_000i64, 485 + exp_offset in 1i64..86_400i64, 486 + ) { 487 + let mut rng = ChaCha20Rng::from_seed(seed); 488 + let signing = AnySigningKey::K256(k256::ecdsa::SigningKey::random(&mut rng)); 489 + let vkey = signing.verifying_key(); 490 + let header = JwtHeader::for_signing_key(&signing); 491 + let claims = JwtClaims { 492 + iss: "did:web:test".to_string(), 493 + aud: "did:plc:test".to_string(), 494 + exp: iat + exp_offset, 495 + iat, 496 + lxm: "com.atproto.moderation.createReport".to_string(), 497 + jti, 498 + }; 499 + 500 + let token = encode_compact(&header, &claims, &signing) 501 + .expect("encode_compact must succeed"); 502 + let (decoded_header, decoded_claims) = verify_compact(&token, &vkey) 503 + .expect("verify_compact must accept a freshly produced token"); 504 + 505 + prop_assert_eq!(decoded_header.alg, "ES256K"); 506 + prop_assert_eq!(decoded_header.typ, header.typ); 507 + prop_assert_eq!(decoded_claims.iss, claims.iss); 508 + prop_assert_eq!(decoded_claims.aud, claims.aud); 509 + prop_assert_eq!(decoded_claims.exp, claims.exp); 510 + prop_assert_eq!(decoded_claims.iat, claims.iat); 511 + prop_assert_eq!(decoded_claims.lxm, claims.lxm); 512 + prop_assert_eq!(decoded_claims.jti, claims.jti); 513 + } 514 + 515 + #[test] 516 + fn encode_verify_compact_roundtrip_p256( 517 + seed in any::<[u8; 32]>(), 518 + jti in JTI_REGEX, 519 + iat in 1_500_000_000i64..2_500_000_000i64, 520 + exp_offset in 1i64..86_400i64, 521 + ) { 522 + let mut rng = ChaCha20Rng::from_seed(seed); 523 + let signing = AnySigningKey::P256(p256::ecdsa::SigningKey::random(&mut rng)); 524 + let vkey = signing.verifying_key(); 525 + let header = JwtHeader::for_signing_key(&signing); 526 + let claims = JwtClaims { 527 + iss: "did:web:example.com".to_string(), 528 + aud: "did:plc:test".to_string(), 529 + exp: iat + exp_offset, 530 + iat, 531 + lxm: "com.atproto.moderation.createReport".to_string(), 532 + jti, 533 + }; 534 + 535 + let token = encode_compact(&header, &claims, &signing) 536 + .expect("encode_compact must succeed"); 537 + let (decoded_header, decoded_claims) = verify_compact(&token, &vkey) 538 + .expect("verify_compact must accept a freshly produced token"); 539 + 540 + prop_assert_eq!(decoded_header.alg, "ES256"); 541 + prop_assert_eq!(decoded_header.typ, header.typ); 542 + prop_assert_eq!(decoded_claims.iss, claims.iss); 543 + prop_assert_eq!(decoded_claims.aud, claims.aud); 544 + prop_assert_eq!(decoded_claims.exp, claims.exp); 545 + prop_assert_eq!(decoded_claims.iat, claims.iat); 546 + prop_assert_eq!(decoded_claims.lxm, claims.lxm); 547 + prop_assert_eq!(decoded_claims.jti, claims.jti); 548 + } 549 + } 550 + } 462 551 }
+71
src/common/oauth/jws.rs
··· 750 750 assert_eq!(code_str, Some("oauth_client::jws::not_json".to_string())); 751 751 } 752 752 } 753 + 754 + // Property-based test for the ES256 sign/verify roundtrip: 755 + // `verify_jws(sign_es256_jws(claims, key), parsed_jwk(key)) == claims` 756 + // for every signing key generated from a 32-byte seed and arbitrary 757 + // string claim payload. 758 + mod pbt { 759 + use super::*; 760 + use p256::ecdsa::SigningKey; 761 + use p256::pkcs8::EncodePrivateKey; 762 + use proptest::prelude::*; 763 + use rand_chacha::ChaCha20Rng; 764 + use rand_core::SeedableRng; 765 + 766 + /// Construct the JWK + encoding key pair backing a P-256 signing 767 + /// key, in the same shape `oauth/jws::parse_jwk` accepts. 768 + fn build_es256_material(seed: [u8; 32]) -> (ParsedJwk, EncodingKey) { 769 + let mut rng = ChaCha20Rng::from_seed(seed); 770 + let signing_key = SigningKey::random(&mut rng); 771 + 772 + let public_key = signing_key.verifying_key(); 773 + let sec1 = public_key.to_sec1_bytes(); 774 + let x_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&sec1[1..33]); 775 + let y_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&sec1[33..65]); 776 + 777 + let jwk_json = serde_json::json!({ 778 + "kty": "EC", 779 + "crv": "P-256", 780 + "x": x_b64, 781 + "y": y_b64, 782 + "kid": "k1", 783 + "alg": "ES256", 784 + "use": "sig", 785 + }); 786 + let source = Arc::<[u8]>::from(b"test".as_ref()); 787 + let parsed = parse_jwk(&jwk_json, "test", source).expect("freshly built JWK parses"); 788 + 789 + let der = signing_key 790 + .to_pkcs8_der() 791 + .expect("p256 keys must export to PKCS8"); 792 + let encoding_key = EncodingKey::from_ec_der(der.as_bytes()); 793 + 794 + (parsed, encoding_key) 795 + } 796 + 797 + proptest! { 798 + // Each iteration builds a key + does a sign/verify pair; cap 799 + // at 16 to keep the suite fast. 800 + #![proptest_config(ProptestConfig::with_cases(16))] 801 + 802 + #[test] 803 + fn es256_sign_verify_roundtrip( 804 + seed in any::<[u8; 32]>(), 805 + foo in any::<String>(), 806 + count in 0u32..1000u32, 807 + ) { 808 + let (jwk, encoding_key) = build_es256_material(seed); 809 + 810 + let claims = serde_json::json!({"foo": foo, "count": count}); 811 + let mut header = jsonwebtoken::Header::new(Algorithm::ES256); 812 + header.kid = Some("k1".to_string()); 813 + 814 + let token = sign_es256_jws(&header, &claims, &encoding_key) 815 + .expect("sign_es256_jws must succeed"); 816 + 817 + let decoded: jsonwebtoken::TokenData<serde_json::Value> = 818 + verify_jws(&token, &jwk, JwsAlg::Es256) 819 + .expect("verify_jws must accept a freshly produced token"); 820 + prop_assert_eq!(decoded.claims, claims); 821 + } 822 + } 823 + } 753 824 }
+209 -62
src/common/oauth/relying_party.rs
··· 4 4 //! (Pushed Authorization Request), PKCE S256, DPoP proof, and private_key_jwt 5 5 //! flows. The RelyingParty is constructed with a deterministic seeded RNG to 6 6 //! enable reproducible testing. 7 + //! 8 + //! # HTTP-client seam exception 9 + //! 10 + //! Unlike every other network-touching module in the crate (which routes 11 + //! through the `HttpClient` trait from `common::identity` so tests can 12 + //! intercept traffic with `FakeHttpClient`), the RelyingParty uses 13 + //! `reqwest::Client` directly. This is deliberate: 14 + //! 15 + //! 1. The RP only ever talks to an authorization server over genuine HTTP. 16 + //! The interactive tests stand up a real axum fake-AS on `127.0.0.1`, 17 + //! so determinism comes from a fixed `Clock` + seeded `ChaCha20Rng`, 18 + //! not from intercepting bytes on the wire. 19 + //! 2. The `do_authorize` flow needs a redirect-following policy distinct 20 + //! from the rest of the RP's calls, which is awkward to express 21 + //! through a two-method `HttpClient` trait without leaking 22 + //! reqwest-specific concepts back through the seam. 23 + //! 24 + //! The RP owns two `reqwest::Client` instances built once at construction 25 + //! time: `http` (default redirect policy) for PAR / token / refresh / 26 + //! discover_as, and `http_no_redirect` for `do_authorize_inner`, which 27 + //! manually inspects the first 3xx response. 7 28 8 29 use std::collections::HashMap; 9 30 use std::sync::{Arc, Mutex}; ··· 147 168 signing_jwk_public: Value, 148 169 clock: Arc<dyn Clock>, 149 170 rng: Mutex<ChaCha20Rng>, 171 + /// Default-redirect-policy client used for PAR, token, refresh, 172 + /// and `discover_as`. 150 173 http: ReqwestClient, 174 + /// Redirect-disabled client used by `do_authorize_inner`, which 175 + /// inspects the first 3xx response from the authorization endpoint 176 + /// and resolves the `redirect_uri` itself. 177 + http_no_redirect: ReqwestClient, 151 178 /// DPoP nonces keyed by endpoint URL, for use_dpop_nonce retry. 152 179 dpop_nonces: Mutex<HashMap<Url, String>>, 153 180 } ··· 246 273 "y": y, 247 274 }); 248 275 249 - // Build HTTP client with rustls, user-agent, and timeout. 276 + // Build HTTP clients with rustls, user-agent, and timeout. The 277 + // no-redirect variant is used by `do_authorize_inner` to inspect 278 + // the first 3xx response from the authorization endpoint 279 + // directly. Both clients share the same TLS pool only within 280 + // their own builder; reqwest does not let us share a connection 281 + // pool across two redirect policies, so we accept two pools per 282 + // RP. 250 283 let http = ReqwestClient::builder() 251 284 .use_rustls_tls() 252 285 .user_agent(APP_USER_AGENT) 253 286 .timeout(Duration::from_secs(30)) 254 287 .build() 255 288 .unwrap_or_else(|_| ReqwestClient::new()); 289 + let http_no_redirect = ReqwestClient::builder() 290 + .use_rustls_tls() 291 + .user_agent(APP_USER_AGENT) 292 + .timeout(Duration::from_secs(30)) 293 + .redirect(reqwest::redirect::Policy::none()) 294 + .build() 295 + .unwrap_or_else(|_| ReqwestClient::new()); 256 296 257 297 Self { 258 298 client_id, ··· 262 302 clock, 263 303 rng: Mutex::new(rng), 264 304 http, 305 + http_no_redirect, 265 306 dpop_nonces: Mutex::new(HashMap::new()), 266 307 } 267 308 } ··· 278 319 }); 279 320 } 280 321 281 - let metadata: serde_json::Value = response.json().await?; 282 - 283 - let issuer = metadata 284 - .get("issuer") 285 - .and_then(|v| v.as_str()) 286 - .and_then(|s| Url::parse(s).ok()) 287 - .ok_or_else(|| RpError::MetadataMalformed { 288 - reason: "missing or invalid issuer".to_string(), 289 - })?; 290 - 291 - let pushed_authorization_request_endpoint = metadata 292 - .get("pushed_authorization_request_endpoint") 293 - .and_then(|v| v.as_str()) 294 - .and_then(|s| Url::parse(s).ok()) 295 - .ok_or_else(|| RpError::MetadataMalformed { 296 - reason: "missing or invalid pushed_authorization_request_endpoint".to_string(), 297 - })?; 298 - 299 - let authorization_endpoint = metadata 300 - .get("authorization_endpoint") 301 - .and_then(|v| v.as_str()) 302 - .and_then(|s| Url::parse(s).ok()) 303 - .ok_or_else(|| RpError::MetadataMalformed { 304 - reason: "missing or invalid authorization_endpoint".to_string(), 305 - })?; 306 - 307 - let token_endpoint = metadata 308 - .get("token_endpoint") 309 - .and_then(|v| v.as_str()) 310 - .and_then(|s| Url::parse(s).ok()) 311 - .ok_or_else(|| RpError::MetadataMalformed { 312 - reason: "missing or invalid token_endpoint".to_string(), 313 - })?; 314 - 315 - let require_pushed_authorization_requests = metadata 316 - .get("require_pushed_authorization_requests") 317 - .and_then(|v| v.as_bool()) 318 - .unwrap_or(false); 319 - 320 - if !require_pushed_authorization_requests { 321 - return Err(RpError::MetadataMalformed { 322 - reason: "require_pushed_authorization_requests is not true".to_string(), 323 - }); 324 - } 325 - 326 - Ok(AsDescriptor { 327 - issuer, 328 - pushed_authorization_request_endpoint, 329 - authorization_endpoint, 330 - token_endpoint, 331 - }) 322 + let metadata: Value = response.json().await?; 323 + parse_as_descriptor(&metadata) 332 324 } 333 325 334 326 /// Perform Pushed Authorization Request (PAR). ··· 478 470 .append_pair("request_uri", request_uri) 479 471 .append_pair("client_id", self.client_id.as_str()); 480 472 481 - // Build a client with redirect policy disabled to manually follow redirects. 482 - let client = ReqwestClient::builder() 483 - .use_rustls_tls() 484 - .user_agent(APP_USER_AGENT) 485 - .timeout(Duration::from_secs(30)) 486 - .redirect(reqwest::redirect::Policy::none()) 487 - .build() 488 - .unwrap_or_else(|_| ReqwestClient::new()); 489 - 490 - let response = client.get(url).send().await?; 473 + // Reuse the redirect-disabled client built once in 474 + // `RelyingParty::new` so successive `do_authorize` calls share 475 + // its connection pool. 476 + let response = self.http_no_redirect.get(url).send().await?; 491 477 492 478 // Follow redirects manually: stop on the first redirect whose 493 479 // Location header matches the redirect_uri scheme/origin. ··· 965 951 } 966 952 } 967 953 954 + /// Pure: parse an authorization-server metadata document into an 955 + /// `AsDescriptor`. Returns `RpError::MetadataMalformed` if any required 956 + /// field (`issuer`, `pushed_authorization_request_endpoint`, 957 + /// `authorization_endpoint`, `token_endpoint`) is missing or unparseable, 958 + /// or if `require_pushed_authorization_requests` is not advertised as 959 + /// `true` (atproto requires PAR). 960 + fn parse_as_descriptor(metadata: &Value) -> Result<AsDescriptor, RpError> { 961 + let issuer = metadata 962 + .get("issuer") 963 + .and_then(|v| v.as_str()) 964 + .and_then(|s| Url::parse(s).ok()) 965 + .ok_or_else(|| RpError::MetadataMalformed { 966 + reason: "missing or invalid issuer".to_string(), 967 + })?; 968 + 969 + let pushed_authorization_request_endpoint = metadata 970 + .get("pushed_authorization_request_endpoint") 971 + .and_then(|v| v.as_str()) 972 + .and_then(|s| Url::parse(s).ok()) 973 + .ok_or_else(|| RpError::MetadataMalformed { 974 + reason: "missing or invalid pushed_authorization_request_endpoint".to_string(), 975 + })?; 976 + 977 + let authorization_endpoint = metadata 978 + .get("authorization_endpoint") 979 + .and_then(|v| v.as_str()) 980 + .and_then(|s| Url::parse(s).ok()) 981 + .ok_or_else(|| RpError::MetadataMalformed { 982 + reason: "missing or invalid authorization_endpoint".to_string(), 983 + })?; 984 + 985 + let token_endpoint = metadata 986 + .get("token_endpoint") 987 + .and_then(|v| v.as_str()) 988 + .and_then(|s| Url::parse(s).ok()) 989 + .ok_or_else(|| RpError::MetadataMalformed { 990 + reason: "missing or invalid token_endpoint".to_string(), 991 + })?; 992 + 993 + let require_pushed_authorization_requests = metadata 994 + .get("require_pushed_authorization_requests") 995 + .and_then(|v| v.as_bool()) 996 + .unwrap_or(false); 997 + 998 + if !require_pushed_authorization_requests { 999 + return Err(RpError::MetadataMalformed { 1000 + reason: "require_pushed_authorization_requests is not true".to_string(), 1001 + }); 1002 + } 1003 + 1004 + Ok(AsDescriptor { 1005 + issuer, 1006 + pushed_authorization_request_endpoint, 1007 + authorization_endpoint, 1008 + token_endpoint, 1009 + }) 1010 + } 1011 + 968 1012 /// Verify that the `iss` query parameter on an authorization 969 1013 /// redirect matches the expected AS issuer. The comparison strips a 970 1014 /// single trailing slash from both sides because atproto's ··· 1109 1153 assert_eq!(payload.get("htm").and_then(|v| v.as_str()), Some("POST")); 1110 1154 assert!(payload.get("jti").is_some(), "JTI should be present"); 1111 1155 assert!(payload.get("iat").is_some(), "iat should be present"); 1156 + } 1157 + 1158 + fn good_metadata() -> serde_json::Value { 1159 + json!({ 1160 + "issuer": "https://auth.example.com", 1161 + "pushed_authorization_request_endpoint": "https://auth.example.com/oauth/par", 1162 + "authorization_endpoint": "https://auth.example.com/oauth/authorize", 1163 + "token_endpoint": "https://auth.example.com/oauth/token", 1164 + "require_pushed_authorization_requests": true, 1165 + }) 1166 + } 1167 + 1168 + #[test] 1169 + fn parse_as_descriptor_accepts_well_formed_metadata() { 1170 + let metadata = good_metadata(); 1171 + let descriptor = parse_as_descriptor(&metadata).expect("well-formed metadata parses"); 1172 + assert_eq!(descriptor.issuer.as_str(), "https://auth.example.com/"); 1173 + assert_eq!( 1174 + descriptor.token_endpoint.as_str(), 1175 + "https://auth.example.com/oauth/token" 1176 + ); 1177 + } 1178 + 1179 + #[test] 1180 + fn parse_as_descriptor_requires_par_advertisement() { 1181 + // atproto requires `require_pushed_authorization_requests = true`; 1182 + // any other value (including missing) must error. 1183 + let mut metadata = good_metadata(); 1184 + metadata 1185 + .as_object_mut() 1186 + .unwrap() 1187 + .remove("require_pushed_authorization_requests"); 1188 + let err = parse_as_descriptor(&metadata).expect_err("missing PAR flag must error"); 1189 + assert!(matches!(err, RpError::MetadataMalformed { .. })); 1190 + 1191 + let metadata = json!({ 1192 + "issuer": "https://auth.example.com", 1193 + "pushed_authorization_request_endpoint": "https://auth.example.com/oauth/par", 1194 + "authorization_endpoint": "https://auth.example.com/oauth/authorize", 1195 + "token_endpoint": "https://auth.example.com/oauth/token", 1196 + "require_pushed_authorization_requests": false, 1197 + }); 1198 + let err = parse_as_descriptor(&metadata).expect_err("PAR=false must error"); 1199 + assert!(matches!(err, RpError::MetadataMalformed { .. })); 1200 + } 1201 + 1202 + #[test] 1203 + fn parse_as_descriptor_rejects_each_missing_required_field() { 1204 + for field in [ 1205 + "issuer", 1206 + "pushed_authorization_request_endpoint", 1207 + "authorization_endpoint", 1208 + "token_endpoint", 1209 + ] { 1210 + let mut metadata = good_metadata(); 1211 + metadata.as_object_mut().unwrap().remove(field); 1212 + let err = parse_as_descriptor(&metadata) 1213 + .unwrap_err_with(|_| format!("missing {field} must error")); 1214 + match err { 1215 + RpError::MetadataMalformed { reason } => { 1216 + assert!( 1217 + reason.contains(field), 1218 + "error message should mention {field}, got {reason:?}" 1219 + ); 1220 + } 1221 + other => panic!("expected MetadataMalformed, got {other:?}"), 1222 + } 1223 + } 1224 + } 1225 + 1226 + #[test] 1227 + fn parse_as_descriptor_rejects_non_url_endpoint() { 1228 + let mut metadata = good_metadata(); 1229 + metadata.as_object_mut().unwrap().insert( 1230 + "token_endpoint".to_string(), 1231 + serde_json::Value::String("not a url".to_string()), 1232 + ); 1233 + let err = parse_as_descriptor(&metadata).expect_err("non-URL endpoint must error"); 1234 + match err { 1235 + RpError::MetadataMalformed { reason } => { 1236 + assert!( 1237 + reason.contains("token_endpoint"), 1238 + "error message should mention token_endpoint, got {reason:?}" 1239 + ); 1240 + } 1241 + other => panic!("expected MetadataMalformed, got {other:?}"), 1242 + } 1243 + } 1244 + 1245 + /// Variant of `Result::expect_err` whose panic message is computed 1246 + /// from the surprising `Ok` value, so loop iterations can attribute 1247 + /// the failure back to the field under test. 1248 + trait UnwrapErrWith<T, E> { 1249 + fn unwrap_err_with<F: FnOnce(&T) -> String>(self, msg: F) -> E; 1250 + } 1251 + 1252 + impl<T: std::fmt::Debug, E> UnwrapErrWith<T, E> for Result<T, E> { 1253 + fn unwrap_err_with<F: FnOnce(&T) -> String>(self, msg: F) -> E { 1254 + match self { 1255 + Ok(v) => panic!("{}: got Ok({v:?})", msg(&v)), 1256 + Err(e) => e, 1257 + } 1258 + } 1112 1259 } 1113 1260 }