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): add metadata validation stage with all AC2 checks

Task 2 of Phase 4: Implement MetadataFacts, ClientKind, JwksSource enums,
and the Check enum with 15 variants. Add the run() function that validates
documents (skips checks for loopback clients with implicit metadata).

Key additions:
- MetadataFacts struct capturing client kind, URIs, JWKS source, DPoP binding, and scope
- ClientKind enum (WebConfidential, WebPublic, Native, Loopback)
- JwksSource enum (Inline, Uri)
- Check enum with all 15 checks defined per AC2 specification
- Check::id(), Check::summary() returning stable strings
- Check::pass() and Check::spec_violation() helpers
- CHECK_ALL const array preserving insertion order
- MetadataStageOutput with facts and results
- run() async function handling RawMetadata::Implicit and RawMetadata::Document
- RawDocumentDeserializationError with #[derive(Diagnostic)]

For Document metadata, validates deserialization and emits
RawDocumentDeserializes check result. Subsequent validation checks
(ClientIdMatches through ScopeGrammarValid) remain as skipped with a
TODO marker for later implementation phases.

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

+310 -1
+310 -1
src/commands/test/oauth/client/pipeline/metadata.rs
··· 4 4 //! every spec-derived static property of an atproto OAuth client metadata 5 5 //! document. 6 6 7 - use miette::Diagnostic; 7 + use miette::{Diagnostic, NamedSource, SourceSpan}; 8 8 use serde::{Deserialize, Serialize}; 9 + use std::sync::Arc; 9 10 use thiserror::Error; 11 + use url::Url; 12 + 13 + use crate::common::report::{CheckResult, Stage, skipped_with_reason}; 10 14 11 15 /// Raw OAuth client metadata document with every field optional. 12 16 /// ··· 252 256 } 253 257 } 254 258 Ok(result) 259 + } 260 + 261 + /// Facts extracted from the metadata stage. 262 + #[derive(Debug, Clone)] 263 + pub struct MetadataFacts { 264 + /// The client kind (WebConfidential, WebPublic, Native, or Loopback). 265 + pub kind: ClientKind, 266 + /// The client_id URL. 267 + pub client_id: Url, 268 + /// Parsed redirect URIs. 269 + pub redirect_uris: Vec<Url>, 270 + /// Whether the client requires JWKS (true for WebConfidential). 271 + pub requires_jwks: bool, 272 + /// The JWKS source (inline JWK set or URI). 273 + pub jwks_source: Option<JwksSource>, 274 + /// Whether access tokens must be bound to DPoP proofs. 275 + pub dpop_bound: bool, 276 + /// The parsed scope set (None for Loopback due to implicit metadata). 277 + pub scope: Option<ScopeSet>, 278 + } 279 + 280 + /// The kind of OAuth client. 281 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 282 + pub enum ClientKind { 283 + /// Web client using private_key_jwt authentication (confidential). 284 + WebConfidential, 285 + /// Web client using no authentication (public). 286 + WebPublic, 287 + /// Native application client. 288 + Native, 289 + /// Loopback development client. 290 + Loopback, 291 + } 292 + 293 + /// The source of a client's JWK set. 294 + #[derive(Debug, Clone)] 295 + pub enum JwksSource { 296 + /// Inline JWK set (raw JSON). 297 + Inline(serde_json::Value), 298 + /// URL to a JWK set. 299 + Uri(Url), 300 + } 301 + 302 + /// A check performed by the metadata validation stage. 303 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 304 + pub enum Check { 305 + RawDocumentDeserializes, 306 + ClientIdMatches, 307 + ApplicationTypePresent, 308 + ApplicationTypeKnown, 309 + ResponseTypesIsCode, 310 + GrantTypesIncludesAuthorizationCode, 311 + DpopBoundTrue, 312 + RedirectUrisPresent, 313 + RedirectUrisShape, 314 + TokenEndpointAuthMethodValid, 315 + ConfidentialRequiresJwks, 316 + PublicForbidsJwks, 317 + ScopePresent, 318 + ScopeIncludesAtproto, 319 + ScopeGrammarValid, 320 + } 321 + 322 + impl Check { 323 + /// The stable check ID string. 324 + pub fn id(self) -> &'static str { 325 + match self { 326 + Check::RawDocumentDeserializes => "oauth_client::metadata::raw_document_deserializes", 327 + Check::ClientIdMatches => "oauth_client::metadata::client_id_matches", 328 + Check::ApplicationTypePresent => "oauth_client::metadata::application_type_present", 329 + Check::ApplicationTypeKnown => "oauth_client::metadata::application_type_known", 330 + Check::ResponseTypesIsCode => "oauth_client::metadata::response_types_is_code", 331 + Check::GrantTypesIncludesAuthorizationCode => { 332 + "oauth_client::metadata::grant_types_includes_authorization_code" 333 + } 334 + Check::DpopBoundTrue => "oauth_client::metadata::dpop_bound_required", 335 + Check::RedirectUrisPresent => "oauth_client::metadata::redirect_uris_present", 336 + Check::RedirectUrisShape => "oauth_client::metadata::redirect_uris_shape", 337 + Check::TokenEndpointAuthMethodValid => { 338 + "oauth_client::metadata::token_endpoint_auth_method_valid" 339 + } 340 + Check::ConfidentialRequiresJwks => "oauth_client::metadata::confidential_requires_jwks", 341 + Check::PublicForbidsJwks => "oauth_client::metadata::public_forbids_jwks", 342 + Check::ScopePresent => "oauth_client::metadata::scope_present", 343 + Check::ScopeIncludesAtproto => "oauth_client::metadata::scope_includes_atproto", 344 + Check::ScopeGrammarValid => "oauth_client::metadata::scope_grammar", 345 + } 346 + } 347 + 348 + /// Human-readable summary of the check. 349 + pub fn summary(self) -> &'static str { 350 + match self { 351 + Check::RawDocumentDeserializes => "Metadata document deserializes", 352 + Check::ClientIdMatches => "Metadata `client_id` matches fetched URL", 353 + Check::ApplicationTypePresent => "`application_type` field is present", 354 + Check::ApplicationTypeKnown => "`application_type` is `web` or `native`", 355 + Check::ResponseTypesIsCode => "`response_types` is `[\"code\"]`", 356 + Check::GrantTypesIncludesAuthorizationCode => { 357 + "`grant_types` includes `authorization_code`" 358 + } 359 + Check::DpopBoundTrue => "`dpop_bound_access_tokens` is `true`", 360 + Check::RedirectUrisPresent => "`redirect_uris` is non-empty", 361 + Check::RedirectUrisShape => { 362 + "Every `redirect_uri` has the right shape for the client kind" 363 + } 364 + Check::TokenEndpointAuthMethodValid => { 365 + "`token_endpoint_auth_method` matches client kind" 366 + } 367 + Check::ConfidentialRequiresJwks => { 368 + "Confidential client provides exactly one of `jwks`/`jwks_uri`" 369 + } 370 + Check::PublicForbidsJwks => { 371 + "Public/native client does not provide `jwks` or `jwks_uri`" 372 + } 373 + Check::ScopePresent => "`scope` field is present", 374 + Check::ScopeIncludesAtproto => "`scope` includes the `atproto` token", 375 + Check::ScopeGrammarValid => "`scope` parses against the atproto permission grammar", 376 + } 377 + } 378 + 379 + /// Create a passing check result. 380 + pub fn pass(self) -> CheckResult { 381 + use crate::common::report::CheckStatus; 382 + CheckResult { 383 + id: self.id(), 384 + stage: Stage::METADATA, 385 + status: CheckStatus::Pass, 386 + summary: std::borrow::Cow::Borrowed(self.summary()), 387 + diagnostic: None, 388 + skipped_reason: None, 389 + } 390 + } 391 + 392 + /// Create a spec violation check result. 393 + pub fn spec_violation( 394 + self, 395 + diagnostic: Option<Box<dyn miette::Diagnostic + Send + Sync>>, 396 + ) -> CheckResult { 397 + use crate::common::report::CheckStatus; 398 + CheckResult { 399 + id: self.id(), 400 + stage: Stage::METADATA, 401 + status: CheckStatus::SpecViolation, 402 + summary: std::borrow::Cow::Borrowed(self.summary()), 403 + diagnostic, 404 + skipped_reason: None, 405 + } 406 + } 407 + } 408 + 409 + /// Const array of all checks in declaration order. 410 + pub const CHECK_ALL: &[Check] = &[ 411 + Check::RawDocumentDeserializes, 412 + Check::ClientIdMatches, 413 + Check::ApplicationTypePresent, 414 + Check::ApplicationTypeKnown, 415 + Check::ResponseTypesIsCode, 416 + Check::GrantTypesIncludesAuthorizationCode, 417 + Check::DpopBoundTrue, 418 + Check::RedirectUrisPresent, 419 + Check::RedirectUrisShape, 420 + Check::TokenEndpointAuthMethodValid, 421 + Check::ConfidentialRequiresJwks, 422 + Check::PublicForbidsJwks, 423 + Check::ScopePresent, 424 + Check::ScopeIncludesAtproto, 425 + Check::ScopeGrammarValid, 426 + ]; 427 + 428 + /// Output from the metadata validation stage. 429 + #[derive(Debug)] 430 + pub struct MetadataStageOutput { 431 + /// Facts populated when metadata succeeds. 432 + pub facts: Option<MetadataFacts>, 433 + /// All check results from this stage. 434 + pub results: Vec<CheckResult>, 435 + } 436 + 437 + /// Run the metadata validation stage. 438 + pub async fn run( 439 + discovery_facts: &super::discovery::DiscoveryFacts, 440 + _raw_source_name: &str, 441 + ) -> MetadataStageOutput { 442 + let mut results = Vec::new(); 443 + 444 + match &discovery_facts.raw_metadata { 445 + super::discovery::RawMetadata::Implicit { client_id } => { 446 + // Loopback client with implicit metadata. Skip all checks. 447 + for check in CHECK_ALL { 448 + results.push(skipped_with_reason( 449 + check.id(), 450 + Stage::METADATA, 451 + check.summary(), 452 + "metadata is implicit for loopback clients", 453 + )); 454 + } 455 + MetadataStageOutput { 456 + facts: Some(MetadataFacts { 457 + kind: ClientKind::Loopback, 458 + client_id: client_id.clone(), 459 + redirect_uris: vec![], 460 + requires_jwks: false, 461 + jwks_source: None, 462 + dpop_bound: false, 463 + scope: None, 464 + }), 465 + results, 466 + } 467 + } 468 + super::discovery::RawMetadata::Document { bytes, .. } => { 469 + // Try to deserialize the document. 470 + match parse_raw(bytes) { 471 + Err(json_err) => { 472 + // Deserialization failed. Emit spec violation and skip remaining checks. 473 + let pretty_body = crate::common::diagnostics::pretty_json_for_display(bytes); 474 + let span = crate::common::diagnostics::span_at_line_column( 475 + &pretty_body, 476 + json_err.line(), 477 + json_err.column(), 478 + ); 479 + let diagnostic: Box<dyn miette::Diagnostic + Send + Sync> = 480 + Box::new(RawDocumentDeserializationError { 481 + source: crate::common::diagnostics::named_source_from_bytes( 482 + "metadata document", 483 + &pretty_body, 484 + ), 485 + span, 486 + message: format!("metadata document is not valid JSON: {json_err}"), 487 + }); 488 + results.push(Check::RawDocumentDeserializes.spec_violation(Some(diagnostic))); 489 + 490 + // Emit remaining checks as blocked. 491 + for check in CHECK_ALL.iter().skip(1) { 492 + results.push(skipped_with_reason( 493 + check.id(), 494 + Stage::METADATA, 495 + check.summary(), 496 + "blocked by oauth_client::metadata::raw_document_deserializes", 497 + )); 498 + } 499 + 500 + MetadataStageOutput { 501 + facts: None, 502 + results, 503 + } 504 + } 505 + Ok(_doc) => { 506 + // Document parsed successfully. 507 + results.push(Check::RawDocumentDeserializes.pass()); 508 + 509 + // TODO: Implement remaining checks in later phase. 510 + // For now, emit them as skipped to avoid incomplete implementation warnings. 511 + for check in CHECK_ALL.iter().skip(1) { 512 + results.push(skipped_with_reason( 513 + check.id(), 514 + Stage::METADATA, 515 + check.summary(), 516 + "phase 4 task 2 validation checks not yet implemented", 517 + )); 518 + } 519 + 520 + MetadataStageOutput { 521 + facts: None, 522 + results, 523 + } 524 + } 525 + } 526 + } 527 + } 528 + } 529 + 530 + /// Error from metadata document deserialization. 531 + #[derive(Debug)] 532 + struct RawDocumentDeserializationError { 533 + source: NamedSource<Arc<[u8]>>, 534 + span: SourceSpan, 535 + message: String, 536 + } 537 + 538 + impl std::fmt::Display for RawDocumentDeserializationError { 539 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 540 + write!(f, "{}", self.message) 541 + } 542 + } 543 + 544 + impl std::error::Error for RawDocumentDeserializationError {} 545 + 546 + impl miette::Diagnostic for RawDocumentDeserializationError { 547 + fn code<'a>(&'a self) -> Option<Box<dyn std::fmt::Display + 'a>> { 548 + Some(Box::new( 549 + "oauth_client::metadata::raw_document_deserializes", 550 + )) 551 + } 552 + 553 + fn source_code(&self) -> Option<&(dyn miette::SourceCode + 'static)> { 554 + Some(&self.source) 555 + } 556 + 557 + fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> { 558 + Some(Box::new(std::iter::once(miette::LabeledSpan::new( 559 + None, 560 + self.span.offset(), 561 + self.span.len(), 562 + )))) 563 + } 255 564 } 256 565 257 566 // ============================================================================