CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

feat(oauth-client): wire metadata stage into pipeline with dependency gating

After discovery completes, the metadata stage is now invoked to validate
the discovered metadata document. When discovery fails to fetch metadata,
all metadata checks are emitted as blocked by the discovery stage's
metadata_document_fetchable check. Metadata facts are stashed for
consumption by the JWKS stage in Phase 5.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

+67 -9
+13 -3
src/commands/test/oauth/client/pipeline.rs
··· 344 344 report.record(result); 345 345 } 346 346 347 - // Facts are stashed for future stages (Phase 4+) to consume. 348 - // TODO: Phase 4 will add metadata validation stage that consumes discovery_output.facts. 349 - let _discovery_facts = discovery_output.facts; 347 + // Run metadata stage (consumes discovery facts). 348 + let metadata_output = if let Some(discovery_facts) = discovery_output.facts { 349 + metadata::run(&discovery_facts, "metadata.json").await 350 + } else { 351 + // Discovery failed at metadata-fetch step; emit Skipped rows for every metadata check. 352 + metadata::emit_all_blocked_by(discovery::Check::MetadataDocumentFetchable.id()) 353 + }; 354 + for result in metadata_output.results { 355 + report.record(result); 356 + } 357 + 358 + // Facts are stashed for Phase 5+ (JWKS stage) to consume. 359 + let _metadata_facts = metadata_output.facts; 350 360 351 361 // Mark the report as finished. 352 362 report.finish();
+54 -6
src/commands/test/oauth/client/pipeline/metadata.rs
··· 433 433 pub results: Vec<CheckResult>, 434 434 } 435 435 436 + /// Emit all metadata checks as blocked by a discovery check failure. 437 + /// 438 + /// Used when the discovery stage fails before providing metadata facts, 439 + /// so the metadata stage cannot proceed. 440 + pub fn emit_all_blocked_by(blocker_check_id: &'static str) -> MetadataStageOutput { 441 + let results = CHECK_ALL 442 + .iter() 443 + .copied() 444 + .map(|c| blocked_by(c.id(), Stage::METADATA, c.summary(), blocker_check_id)) 445 + .collect(); 446 + MetadataStageOutput { 447 + facts: None, 448 + results, 449 + } 450 + } 451 + 436 452 /// Run the metadata validation stage. 437 453 pub async fn run( 438 454 discovery_facts: &super::discovery::DiscoveryFacts, 439 455 _raw_source_name: &str, 440 456 ) -> MetadataStageOutput { 457 + // Note: This stage is synchronous; the async signature is for consistency 458 + // with the pipeline runner which chains async stages. 441 459 let mut results = Vec::new(); 442 460 443 461 match &discovery_facts.raw_metadata { ··· 693 711 if let Some(ref kind) = kind { 694 712 // Validate redirect URIs shape. 695 713 let redirect_uris_shape_valid = 696 - validate_redirect_uris(&doc.redirect_uris, kind, &pretty_body); 714 + if let Some(parsed_id) = &parsed_client_id { 715 + validate_redirect_uris(&doc.redirect_uris, kind, parsed_id) 716 + } else { 717 + false 718 + }; 697 719 if redirect_uris_shape_valid { 698 720 results.push(Check::RedirectUrisShape.pass()); 699 721 } else { ··· 875 897 } 876 898 } 877 899 878 - /// Validate redirect URIs against client kind. 900 + /// Validate redirect URIs against client kind and client_id. 879 901 fn validate_redirect_uris( 880 902 redirect_uris: &Option<Vec<String>>, 881 903 kind: &ClientKind, 882 - _pretty_body: &Arc<[u8]>, 904 + client_id: &Url, 883 905 ) -> bool { 884 906 let Some(uris) = redirect_uris else { 885 907 return false; ··· 894 916 if uri.scheme() != "https" { 895 917 return false; 896 918 } 897 - // Origin comparison is done elsewhere; for now just check scheme. 919 + // Check origin (scheme + host + port) matches. 920 + if uri.origin() != client_id.origin() { 921 + return false; 922 + } 898 923 } 899 924 Err(_) => return false, 900 925 } ··· 904 929 match Url::parse(uri_str) { 905 930 Ok(uri) => { 906 931 if uri.scheme() == "http" || uri.scheme() == "https" { 907 - // Apply web rules (scheme check). 932 + // Apply web rules (scheme check and origin match). 933 + if uri.scheme() != "https" { 934 + // HTTP is allowed for loopback; native clients can use it. 935 + } 936 + if uri.origin() != client_id.origin() { 937 + return false; 938 + } 939 + } else { 940 + // Custom scheme: must match reverse-domain of client_id host. 941 + let expected_scheme = reverse_domain_from_host(client_id.host_str()); 942 + if uri.scheme() != expected_scheme { 943 + return false; 944 + } 908 945 } 909 - // Custom scheme validation is deferred; for now accept all. 910 946 } 911 947 Err(_) => return false, 912 948 } ··· 918 954 } 919 955 920 956 true 957 + } 958 + 959 + /// Compute the reverse-domain form of a hostname. 960 + /// 961 + /// For example, `app.example.com` becomes `com.example.app`. 962 + fn reverse_domain_from_host(host: Option<&str>) -> String { 963 + if let Some(h) = host { 964 + let parts: Vec<&str> = h.split('.').collect(); 965 + parts.into_iter().rev().collect::<Vec<_>>().join(".") 966 + } else { 967 + String::new() 968 + } 921 969 } 922 970 923 971 /// Compare two URLs for equality on scheme, host, port, and path.