CLI app for developers prototyping atproto functionality
1
fork

Configure Feed

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

fix(oauth-client): address Phase 4 code review feedback

- C1: Fix check emission order — ScopeIncludesAtproto now emits before ScopeGrammarValid
- C2: Add diagnostic types for all SpecViolations with stable codes
- C3: Resolve dead HTTP block for native redirect URIs — explicitly reject HTTP
- I1: Remove _metadata_facts landmine binding, add TODO for Phase 5
- I2: Convert RawDocumentDeserializationError to manual impl (compat with NamedSource)
- I3: Add RedirectSchemeReverseDomainMismatchDiagnostic type
- I4: Make run() synchronous (drop async, update caller)
- I5: Rename test scope_rejects_empty_string to scope_accepts_empty_string_as_empty_set
- M1: Remove Serialize from RawMetadataDocument derive
- M2: Use raw_source_name parameter instead of hardcoding 'metadata document'
- M3: Simplify parsed_id.as_str() instead of unwrap().as_str()
- M4: Trailing periods on all new comments

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

+216 -68
+2 -3
src/commands/test/oauth/client/pipeline.rs
··· 346 346 347 347 // Run metadata stage (consumes discovery facts). 348 348 let metadata_output = if let Some(discovery_facts) = discovery_output.facts { 349 - metadata::run(&discovery_facts, "metadata.json").await 349 + metadata::run(&discovery_facts, "metadata.json") 350 350 } else { 351 351 // Discovery failed at metadata-fetch step; emit Skipped rows for every metadata check. 352 352 metadata::emit_all_blocked_by(discovery::Check::MetadataDocumentFetchable.id()) ··· 355 355 report.record(result); 356 356 } 357 357 358 - // Facts are stashed for Phase 5+ (JWKS stage) to consume. 359 - let _metadata_facts = metadata_output.facts; 358 + // TODO: Phase 5 will use metadata_output.facts to drive JWKS stage. 360 359 361 360 // Mark the report as finished. 362 361 report.finish();
+193 -56
src/commands/test/oauth/client/pipeline/metadata.rs
··· 5 5 //! document. 6 6 7 7 use miette::{Diagnostic, NamedSource, SourceSpan}; 8 - use serde::{Deserialize, Serialize}; 8 + use serde::Deserialize; 9 9 use std::borrow::Cow; 10 10 use std::sync::Arc; 11 11 use thiserror::Error; ··· 18 18 /// Allows deserialization of metadata documents with missing or additional 19 19 /// fields. Additional OAuth fields are allowed per the spec; we only validate 20 20 /// the fields we care about. 21 - #[derive(Debug, Clone, Deserialize, Serialize)] 21 + #[derive(Debug, Clone, Deserialize)] 22 22 pub struct RawMetadataDocument { 23 23 pub client_id: Option<String>, 24 24 pub application_type: Option<String>, ··· 450 450 } 451 451 452 452 /// Run the metadata validation stage. 453 - pub async fn run( 453 + pub fn run( 454 454 discovery_facts: &super::discovery::DiscoveryFacts, 455 - _raw_source_name: &str, 455 + raw_source_name: &str, 456 456 ) -> MetadataStageOutput { 457 - // Note: This stage is synchronous; the async signature is for consistency 458 - // with the pipeline runner which chains async stages. 459 457 let mut results = Vec::new(); 460 458 461 459 match &discovery_facts.raw_metadata { ··· 496 494 let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 497 495 Box::new(RawDocumentDeserializationError { 498 496 source: crate::common::diagnostics::named_source_from_bytes( 499 - "metadata document", 497 + raw_source_name, 500 498 &pretty_body, 501 499 ), 502 500 span, ··· 539 537 let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 540 538 Box::new(ClientIdInvalidDiagnostic { 541 539 src: crate::common::diagnostics::named_source_from_bytes( 542 - "metadata document", 540 + raw_source_name, 543 541 &pretty_body, 544 542 ), 545 543 span, ··· 559 557 // Validate client_id matches discovery facts' client_id. 560 558 if let Some(ref parsed_id) = parsed_client_id { 561 559 if !urls_equal(parsed_id, &discovery_facts.client_id) { 562 - let id_str = parsed_client_id.as_ref().unwrap().as_str(); 560 + let id_str = parsed_id.as_str(); 563 561 let span = crate::common::diagnostics::span_for_quoted_literal( 564 562 &pretty_body, 565 563 id_str, ··· 567 565 let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 568 566 Box::new(ClientIdMismatchDiagnostic { 569 567 src: crate::common::diagnostics::named_source_from_bytes( 570 - "metadata document", 568 + raw_source_name, 571 569 &pretty_body, 572 570 ), 573 571 span, ··· 581 579 // Check ApplicationTypePresent. 582 580 let application_type_present = doc.application_type.is_some(); 583 581 let app_type_known = if !application_type_present { 584 - results.push(Check::ApplicationTypePresent.spec_violation(None)); 582 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 583 + Box::new(MetadataViolationDiagnostic { 584 + message: "application_type field is missing".to_string(), 585 + code: "oauth_client::metadata::application_type_present", 586 + }); 587 + results 588 + .push(Check::ApplicationTypePresent.spec_violation(Some(diagnostic))); 585 589 // Skip ApplicationTypeKnown as blocked. 586 590 results.push(blocked_by( 587 591 Check::ApplicationTypeKnown.id(), ··· 599 603 Some("web") | Some("native") 600 604 ); 601 605 if !known { 602 - results.push(Check::ApplicationTypeKnown.spec_violation(None)); 606 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 607 + Box::new(MetadataViolationDiagnostic { 608 + message: format!( 609 + "application_type must be 'web' or 'native', got '{}'", 610 + doc.application_type.as_deref().unwrap_or("unknown") 611 + ), 612 + code: "oauth_client::metadata::application_type_known", 613 + }); 614 + results 615 + .push(Check::ApplicationTypeKnown.spec_violation(Some(diagnostic))); 603 616 } else { 604 617 results.push(Check::ApplicationTypeKnown.pass()); 605 618 } ··· 612 625 None => false, 613 626 }; 614 627 if !response_types_valid { 615 - results.push(Check::ResponseTypesIsCode.spec_violation(None)); 628 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 629 + Box::new(MetadataViolationDiagnostic { 630 + message: format!( 631 + "response_types must be exactly [\"code\"], got {doc:?}", 632 + doc = doc.response_types 633 + ), 634 + code: "oauth_client::metadata::response_types_is_code", 635 + }); 636 + results.push(Check::ResponseTypesIsCode.spec_violation(Some(diagnostic))); 616 637 } else { 617 638 results.push(Check::ResponseTypesIsCode.pass()); 618 639 } ··· 628 649 None => false, 629 650 }; 630 651 if !grant_types_valid { 631 - results 632 - .push(Check::GrantTypesIncludesAuthorizationCode.spec_violation(None)); 652 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 653 + Box::new(MetadataViolationDiagnostic { 654 + message: "grant_types must include 'authorization_code' and only contain 'authorization_code' or 'refresh_token'".to_string(), 655 + code: "oauth_client::metadata::grant_types_includes_authorization_code", 656 + }); 657 + results.push( 658 + Check::GrantTypesIncludesAuthorizationCode 659 + .spec_violation(Some(diagnostic)), 660 + ); 633 661 } else { 634 662 results.push(Check::GrantTypesIncludesAuthorizationCode.pass()); 635 663 } ··· 637 665 // Check DpopBoundTrue. 638 666 let dpop_bound = doc.dpop_bound_access_tokens.unwrap_or(false); 639 667 if !matches!(doc.dpop_bound_access_tokens, Some(true)) { 640 - results.push(Check::DpopBoundTrue.spec_violation(None)); 668 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 669 + Box::new(MetadataViolationDiagnostic { 670 + message: "dpop_bound_access_tokens must be true".to_string(), 671 + code: "oauth_client::metadata::dpop_bound_required", 672 + }); 673 + results.push(Check::DpopBoundTrue.spec_violation(Some(diagnostic))); 641 674 } else { 642 675 results.push(Check::DpopBoundTrue.pass()); 643 676 } ··· 648 681 None => false, 649 682 }; 650 683 if !redirect_uris_present { 651 - results.push(Check::RedirectUrisPresent.spec_violation(None)); 684 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 685 + Box::new(MetadataViolationDiagnostic { 686 + message: "redirect_uris must be present and non-empty".to_string(), 687 + code: "oauth_client::metadata::redirect_uris_present", 688 + }); 689 + results.push(Check::RedirectUrisPresent.spec_violation(Some(diagnostic))); 652 690 } else { 653 691 results.push(Check::RedirectUrisPresent.pass()); 654 692 } ··· 719 757 if redirect_uris_shape_valid { 720 758 results.push(Check::RedirectUrisShape.pass()); 721 759 } else { 722 - results.push(Check::RedirectUrisShape.spec_violation(None)); 760 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 761 + Box::new(MetadataViolationDiagnostic { 762 + message: match kind { 763 + ClientKind::WebConfidential | ClientKind::WebPublic => { 764 + "web client redirect_uris must be HTTPS with origin matching client_id".to_string() 765 + } 766 + ClientKind::Native => { 767 + "native client redirect_uris must be HTTPS with origin matching, or use custom scheme matching reverse-domain of client_id host".to_string() 768 + } 769 + ClientKind::Loopback => unreachable!(), 770 + }, 771 + code: "oauth_client::metadata::redirect_uris_shape", 772 + }); 773 + results.push( 774 + Check::RedirectUrisShape.spec_violation(Some(diagnostic)), 775 + ); 723 776 } 724 777 } else { 725 - results.push(Check::RedirectUrisShape.spec_violation(None)); 778 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 779 + Box::new(MetadataViolationDiagnostic { 780 + message: "redirect_uris do not match client kind requirements" 781 + .to_string(), 782 + code: "oauth_client::metadata::redirect_uris_shape", 783 + }); 784 + results.push(Check::RedirectUrisShape.spec_violation(Some(diagnostic))); 726 785 } 727 786 728 787 // Emit TokenEndpointAuthMethodValid. 729 788 if auth_valid { 730 789 results.push(Check::TokenEndpointAuthMethodValid.pass()); 731 790 } else { 732 - results.push(Check::TokenEndpointAuthMethodValid.spec_violation(None)); 791 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = Box::new( 792 + MetadataViolationDiagnostic { 793 + message: 794 + "token_endpoint_auth_method does not match client kind" 795 + .to_string(), 796 + code: "oauth_client::metadata::token_endpoint_auth_method_valid", 797 + }, 798 + ); 799 + results.push( 800 + Check::TokenEndpointAuthMethodValid 801 + .spec_violation(Some(diagnostic)), 802 + ); 733 803 } 734 804 735 805 // Emit JWKS checks. ··· 743 813 results.push(Check::ConfidentialRequiresJwks.pass()); 744 814 } 745 815 (false, false) => { 816 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 817 + Box::new(MetadataViolationDiagnostic { 818 + message: "confidential client must provide exactly one of jwks or jwks_uri".to_string(), 819 + code: "oauth_client::metadata::confidential_requires_jwks", 820 + }); 746 821 results.push( 747 822 Check::ConfidentialRequiresJwks 748 - .spec_violation(None), 823 + .spec_violation(Some(diagnostic)), 749 824 ); 750 825 } 751 826 (true, true) => { 827 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 828 + Box::new(MetadataViolationDiagnostic { 829 + message: "confidential client must provide exactly one of jwks or jwks_uri, not both".to_string(), 830 + code: "oauth_client::metadata::confidential_requires_jwks", 831 + }); 752 832 results.push( 753 833 Check::ConfidentialRequiresJwks 754 - .spec_violation(None), 834 + .spec_violation(Some(diagnostic)), 755 835 ); 756 836 } 757 837 } ··· 760 840 let has_jwks = doc.jwks.is_some(); 761 841 let has_jwks_uri = doc.jwks_uri.is_some(); 762 842 if has_jwks || has_jwks_uri { 763 - results.push(Check::PublicForbidsJwks.spec_violation(None)); 843 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 844 + Box::new(MetadataViolationDiagnostic { 845 + message: "public and native clients must not provide jwks or jwks_uri".to_string(), 846 + code: "oauth_client::metadata::public_forbids_jwks", 847 + }); 848 + results.push( 849 + Check::PublicForbidsJwks 850 + .spec_violation(Some(diagnostic)), 851 + ); 764 852 } else { 765 853 results.push(Check::PublicForbidsJwks.pass()); 766 854 } ··· 774 862 kind 775 863 }; 776 864 777 - // Check Scope. 865 + // Check Scope — parse first, then emit in enum order. 778 866 let parsed_scope = match &doc.scope { 779 867 None => { 868 + // Scope absent: emit ScopePresent violation, then block others. 780 869 results.push(Check::ScopePresent.spec_violation(None)); 781 870 results.push(blocked_by( 782 871 Check::ScopeIncludesAtproto.id(), ··· 793 882 None 794 883 } 795 884 Some(scope_str) => { 885 + // Scope present: emit pass. 796 886 results.push(Check::ScopePresent.pass()); 797 887 798 - match parse_scope(scope_str) { 888 + // Parse scope to determine grammar and atproto presence. 889 + let parse_result = parse_scope(scope_str); 890 + 891 + // Emit ScopeIncludesAtproto (in enum order). 892 + match &parse_result { 893 + Err(_) => { 894 + // Parse failed: block ScopeIncludesAtproto by grammar check. 895 + results.push(blocked_by( 896 + Check::ScopeIncludesAtproto.id(), 897 + Stage::METADATA, 898 + Check::ScopeIncludesAtproto.summary(), 899 + Check::ScopeGrammarValid.id(), 900 + )); 901 + } 902 + Ok(scope_set) => { 903 + // Parse succeeded: check atproto presence. 904 + let has_atproto = scope_set 905 + .tokens 906 + .iter() 907 + .any(|t| matches!(t, ScopeToken::Atproto)); 908 + 909 + if !has_atproto { 910 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 911 + Box::new(MetadataViolationDiagnostic { 912 + message: "scope must include the 'atproto' token" 913 + .to_string(), 914 + code: "oauth_client::metadata::scope_includes_atproto", 915 + }); 916 + results.push( 917 + Check::ScopeIncludesAtproto 918 + .spec_violation(Some(diagnostic)), 919 + ); 920 + } else { 921 + results.push(Check::ScopeIncludesAtproto.pass()); 922 + } 923 + } 924 + } 925 + 926 + // Emit ScopeGrammarValid (in enum order). 927 + match parse_result { 799 928 Err(err) => { 800 929 // Parse error: emit ScopeGrammarValid violation. 801 930 let scope_span = ··· 809 938 let span = SourceSpan::new(offset.into(), err.token.len()); 810 939 let src = 811 940 crate::common::diagnostics::named_source_from_bytes( 812 - "metadata document", 941 + raw_source_name, 813 942 &pretty_body, 814 943 ); 815 944 Box::new(ScopeGrammarErrorDiagnostic { ··· 826 955 results.push( 827 956 Check::ScopeGrammarValid.spec_violation(Some(diagnostic)), 828 957 ); 829 - results.push(blocked_by( 830 - Check::ScopeIncludesAtproto.id(), 831 - Stage::METADATA, 832 - Check::ScopeIncludesAtproto.summary(), 833 - Check::ScopeGrammarValid.id(), 834 - )); 835 958 None 836 959 } 837 960 Ok(scope_set) => { 961 + // Parse succeeded: emit pass. 838 962 results.push(Check::ScopeGrammarValid.pass()); 839 - 840 - // Check if atproto token is present. 841 - let has_atproto = scope_set 842 - .tokens 843 - .iter() 844 - .any(|t| matches!(t, ScopeToken::Atproto)); 845 - 846 - if !has_atproto { 847 - results 848 - .push(Check::ScopeIncludesAtproto.spec_violation(None)); 849 - None 850 - } else { 851 - results.push(Check::ScopeIncludesAtproto.pass()); 852 - Some(scope_set) 853 - } 963 + Some(scope_set) 854 964 } 855 965 } 856 966 } ··· 925 1035 } 926 1036 } 927 1037 ClientKind::Native => { 928 - // If http/https, apply web rules. Otherwise, check reverse-domain. 1038 + // Redirect URIs are either HTTPS URLs with origin-match or custom-scheme URLs 1039 + // with reverse-domain matching. 929 1040 match Url::parse(uri_str) { 930 1041 Ok(uri) => { 931 - if uri.scheme() == "http" || uri.scheme() == "https" { 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 - } 1042 + if uri.scheme() == "https" { 1043 + // HTTPS: origin must match. 936 1044 if uri.origin() != client_id.origin() { 937 1045 return false; 938 1046 } 1047 + } else if uri.scheme() == "http" { 1048 + // HTTP is not allowed for native clients. 1049 + return false; 939 1050 } else { 940 1051 // Custom scheme: must match reverse-domain of client_id host. 941 1052 let expected_scheme = reverse_domain_from_host(client_id.host_str()); ··· 1002 1113 1003 1114 fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> { 1004 1115 Some(Box::new(std::iter::once(miette::LabeledSpan::new( 1005 - None, 1116 + Some("invalid JSON".to_string()), 1006 1117 self.span.offset(), 1007 1118 self.span.len(), 1008 1119 )))) ··· 1051 1162 reason: ScopeParseReason, 1052 1163 } 1053 1164 1165 + /// Native redirect URI scheme does not match reverse-domain of client_id host. 1166 + #[derive(Debug, Error, Diagnostic)] 1167 + #[error("native redirect_uri scheme does not match reverse-domain of client_id host")] 1168 + #[diagnostic(code = "oauth_client::metadata::redirect_scheme_reverse_domain_mismatch")] 1169 + #[expect(dead_code)] 1170 + struct RedirectSchemeReverseDomainMismatchDiagnostic { 1171 + #[source_code] 1172 + src: NamedSource<Arc<[u8]>>, 1173 + #[label("this redirect_uri")] 1174 + span: Option<SourceSpan>, 1175 + } 1176 + 1177 + /// Generic metadata violation with stable code. 1178 + #[derive(Debug, Error)] 1179 + #[error("{message}")] 1180 + struct MetadataViolationDiagnostic { 1181 + message: String, 1182 + code: &'static str, 1183 + } 1184 + 1185 + impl miette::Diagnostic for MetadataViolationDiagnostic { 1186 + fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> { 1187 + Some(Box::new(self.code)) 1188 + } 1189 + } 1190 + 1054 1191 // ============================================================================ 1055 1192 // Unit tests for Task 1 1056 1193 // ============================================================================ ··· 1086 1223 } 1087 1224 1088 1225 #[test] 1089 - fn scope_rejects_empty_string() { 1090 - // Empty string should produce empty token list but no error. 1226 + fn scope_accepts_empty_string_as_empty_set() { 1227 + // Empty scope is caught downstream via ScopeIncludesAtproto. 1091 1228 let result = parse_scope("").expect("parse failed"); 1092 1229 assert_eq!(result.tokens.len(), 0); 1093 1230 }
+1 -1
tests/snapshots/oauth_client_metadata__confidential_happy.snap
··· 22 22 [OK] `token_endpoint_auth_method` matches client kind 23 23 [OK] Confidential client provides exactly one of `jwks`/`jwks_uri` 24 24 [OK] `scope` field is present 25 - [OK] `scope` parses against the atproto permission grammar 26 25 [OK] `scope` includes the `atproto` token 26 + [OK] `scope` parses against the atproto permission grammar 27 27 28 28 Summary: 17 passed, 0 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 0
+4 -1
tests/snapshots/oauth_client_metadata__confidential_missing_jwks.snap
··· 21 21 [OK] Every `redirect_uri` has the right shape for the client kind 22 22 [OK] `token_endpoint_auth_method` matches client kind 23 23 [FAIL] Confidential client provides exactly one of `jwks`/`jwks_uri` 24 + oauth_client::metadata::confidential_requires_jwks 25 + 26 + × confidential client must provide exactly one of jwks or jwks_uri 24 27 [OK] `scope` field is present 25 - [OK] `scope` parses against the atproto permission grammar 26 28 [OK] `scope` includes the `atproto` token 29 + [OK] `scope` parses against the atproto permission grammar 27 30 28 31 Summary: 16 passed, 1 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 1
+4 -1
tests/snapshots/oauth_client_metadata__dpop_bound_false.snap
··· 17 17 [OK] `response_types` is `["code"]` 18 18 [OK] `grant_types` includes `authorization_code` 19 19 [FAIL] `dpop_bound_access_tokens` is `true` 20 + oauth_client::metadata::dpop_bound_required 21 + 22 + × dpop_bound_access_tokens must be true 20 23 [OK] `redirect_uris` is non-empty 21 24 [OK] Every `redirect_uri` has the right shape for the client kind 22 25 [OK] `token_endpoint_auth_method` matches client kind 23 26 [OK] Confidential client provides exactly one of `jwks`/`jwks_uri` 24 27 [OK] `scope` field is present 25 - [OK] `scope` parses against the atproto permission grammar 26 28 [OK] `scope` includes the `atproto` token 29 + [OK] `scope` parses against the atproto permission grammar 27 30 28 31 Summary: 16 passed, 1 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 1
+1 -1
tests/snapshots/oauth_client_metadata__native_happy.snap
··· 22 22 [OK] `token_endpoint_auth_method` matches client kind 23 23 [OK] Public/native client does not provide `jwks` or `jwks_uri` 24 24 [OK] `scope` field is present 25 - [OK] `scope` parses against the atproto permission grammar 26 25 [OK] `scope` includes the `atproto` token 26 + [OK] `scope` parses against the atproto permission grammar 27 27 28 28 Summary: 17 passed, 0 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 0
+4 -1
tests/snapshots/oauth_client_metadata__native_redirect_scheme_mismatch.snap
··· 19 19 [OK] `dpop_bound_access_tokens` is `true` 20 20 [OK] `redirect_uris` is non-empty 21 21 [FAIL] Every `redirect_uri` has the right shape for the client kind 22 + oauth_client::metadata::redirect_uris_shape 23 + 24 + × native client redirect_uris must be HTTPS with origin matching, or use custom scheme matching reverse-domain of client_id host 22 25 [OK] `token_endpoint_auth_method` matches client kind 23 26 [OK] Public/native client does not provide `jwks` or `jwks_uri` 24 27 [OK] `scope` field is present 25 - [OK] `scope` parses against the atproto permission grammar 26 28 [OK] `scope` includes the `atproto` token 29 + [OK] `scope` parses against the atproto permission grammar 27 30 28 31 Summary: 16 passed, 1 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 1
+1 -1
tests/snapshots/oauth_client_metadata__public_happy.snap
··· 22 22 [OK] `token_endpoint_auth_method` matches client kind 23 23 [OK] Public/native client does not provide `jwks` or `jwks_uri` 24 24 [OK] `scope` field is present 25 - [OK] `scope` parses against the atproto permission grammar 26 25 [OK] `scope` includes the `atproto` token 26 + [OK] `scope` parses against the atproto permission grammar 27 27 28 28 Summary: 17 passed, 0 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 0
+4 -1
tests/snapshots/oauth_client_metadata__public_with_token_endpoint_auth.snap
··· 20 20 [OK] `redirect_uris` is non-empty 21 21 [OK] Every `redirect_uri` has the right shape for the client kind 22 22 [FAIL] `token_endpoint_auth_method` matches client kind 23 + oauth_client::metadata::token_endpoint_auth_method_valid 24 + 25 + × token_endpoint_auth_method does not match client kind 23 26 [OK] Public/native client does not provide `jwks` or `jwks_uri` 24 27 [OK] `scope` field is present 25 - [OK] `scope` parses against the atproto permission grammar 26 28 [OK] `scope` includes the `atproto` token 29 + [OK] `scope` parses against the atproto permission grammar 27 30 28 31 Summary: 16 passed, 1 failed (spec), 0 network errors, 0 advisories, 0 skipped. Exit code: 1
+2 -2
tests/snapshots/oauth_client_metadata__scope_grammar_invalid.snap
··· 22 22 [OK] `token_endpoint_auth_method` matches client kind 23 23 [OK] Confidential client provides exactly one of `jwks`/`jwks_uri` 24 24 [OK] `scope` field is present 25 + [SKIP] `scope` includes the `atproto` token — blocked by oauth_client::metadata::scope_grammar 25 26 [FAIL] `scope` parses against the atproto permission grammar 26 27 oauth_client::metadata::scope_grammar 27 28 28 29 × scope grammar error: unknown resource name 29 - ╭─[metadata document:27:21] 30 + ╭─[metadata.json:27:21] 30 31 26 │ ], 31 32 27 │ "scope": "atproto invalid-scope-token", 32 33 · ─────────┬───────── 33 34 · ╰── invalid token 34 35 28 │ "token_endpoint_auth_method": "private_key_jwt" 35 36 ╰──── 36 - [SKIP] `scope` includes the `atproto` token — blocked by oauth_client::metadata::scope_grammar 37 37 38 38 Summary: 15 passed, 1 failed (spec), 0 network errors, 0 advisories, 1 skipped. Exit code: 1