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 discovery stage - fetch metadata doc or synthesize implicit

Implement Task 2 of Phase 3: the discovery stage that resolves client metadata
documents for HTTPS targets or synthesizes implicit metadata for loopback
clients. Adds DiscoveryFacts, ClientIdKind, and RawMetadata types for facts
transport, Check enum with stable diagnostic codes, and the async run function
that handles HTTP metadata fetches with content-type tracking.

Extends HttpClient trait in src/common/identity.rs with get_bytes_with_content_type
method and implements it on RealHttpClient to read Content-Type header. Default
implementation maintains backward compatibility with existing stages.

Check results include:
- ClientIdWellFormed: always passes for valid targets
- MetadataDocumentFetchable: passes for 2xx responses, network error or
non-2xx status, skipped for loopback (implicit metadata)
- MetadataIsJson: passes for valid JSON, spec violation for parse errors
with source context, skipped for loopback clients

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

+404
+2
src/commands/test/oauth/client/pipeline.rs
··· 1 1 //! OAuth client conformance test pipeline and target parsing. 2 2 3 + pub mod discovery; 4 + 3 5 use miette::Diagnostic; 4 6 use thiserror::Error; 5 7 use url::Url;
+374
src/commands/test/oauth/client/pipeline/discovery.rs
··· 1 + //! OAuth client discovery stage — fetch and validate client metadata. 2 + //! 3 + //! This stage resolves the client metadata document for HTTPS targets or 4 + //! synthesizes implicit metadata for loopback development clients. 5 + 6 + use std::borrow::Cow; 7 + use std::sync::Arc; 8 + 9 + use miette::{Diagnostic, NamedSource, SourceSpan}; 10 + use url::Url; 11 + 12 + use crate::common::diagnostics::{named_source_from_bytes, span_at_line_column}; 13 + use crate::common::identity::HttpClient; 14 + use crate::common::report::{CheckResult, Stage, blocked_by, skipped_with_reason}; 15 + 16 + use super::{LoopbackHost, LoopbackTarget, OauthClientTarget}; 17 + 18 + /// Facts extracted from the discovery stage, populated when metadata is available. 19 + #[derive(Debug, Clone)] 20 + pub struct DiscoveryFacts { 21 + /// The client_id URL (HTTPS or reconstructed loopback). 22 + pub client_id: Url, 23 + /// Whether this is an HTTPS or loopback client. 24 + pub kind: ClientIdKind, 25 + /// The raw metadata, either fetched or implicit. 26 + pub raw_metadata: RawMetadata, 27 + } 28 + 29 + /// The kind of client identified by the target. 30 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 31 + pub enum ClientIdKind { 32 + /// HTTPS URL targeting a production metadata document. 33 + HttpsUrl, 34 + /// Loopback development client with implicit metadata. 35 + Loopback, 36 + } 37 + 38 + /// The raw metadata document for the client. 39 + #[derive(Debug, Clone)] 40 + pub enum RawMetadata { 41 + /// A fetched JSON metadata document with optional Content-Type header. 42 + Document { 43 + /// The raw response body as bytes. 44 + bytes: Arc<[u8]>, 45 + /// The Content-Type header from the HTTP response, if present. 46 + content_type: Option<String>, 47 + }, 48 + /// Implicit metadata for a loopback client. 49 + Implicit { 50 + /// The reconstructed loopback client_id URL. 51 + client_id: Url, 52 + }, 53 + } 54 + 55 + /// Checks performed by the discovery stage. 56 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 57 + pub enum Check { 58 + /// Whether the client_id is well-formed. 59 + ClientIdWellFormed, 60 + /// Whether the metadata document is fetchable (for HTTPS) or implicit (loopback). 61 + MetadataDocumentFetchable, 62 + /// Whether the metadata document is valid JSON. 63 + MetadataIsJson, 64 + } 65 + 66 + impl Check { 67 + /// Stable check ID string used in `CheckResult.id`. 68 + pub fn id(self) -> &'static str { 69 + match self { 70 + Check::ClientIdWellFormed => "oauth_client::discovery::client_id_well_formed", 71 + Check::MetadataDocumentFetchable => { 72 + "oauth_client::discovery::metadata_document_fetchable" 73 + } 74 + Check::MetadataIsJson => "oauth_client::discovery::metadata_is_json", 75 + } 76 + } 77 + 78 + /// Create a passing check result. 79 + pub fn pass(self) -> CheckResult { 80 + CheckResult { 81 + id: self.id(), 82 + stage: Stage::DISCOVERY, 83 + status: crate::common::report::CheckStatus::Pass, 84 + summary: Cow::Borrowed(match self { 85 + Check::ClientIdWellFormed => "Client ID well-formed", 86 + Check::MetadataDocumentFetchable => "Metadata document fetchable", 87 + Check::MetadataIsJson => "Metadata is valid JSON", 88 + }), 89 + diagnostic: None, 90 + skipped_reason: None, 91 + } 92 + } 93 + 94 + /// Create a spec violation check result. 95 + pub fn spec_violation( 96 + self, 97 + diagnostic: Option<Box<dyn miette::Diagnostic + Send + Sync>>, 98 + ) -> CheckResult { 99 + CheckResult { 100 + id: self.id(), 101 + stage: Stage::DISCOVERY, 102 + status: crate::common::report::CheckStatus::SpecViolation, 103 + summary: Cow::Borrowed(match self { 104 + Check::ClientIdWellFormed => "Client ID validation failed", 105 + Check::MetadataDocumentFetchable => "Metadata document fetch failed", 106 + Check::MetadataIsJson => "Metadata is not valid JSON", 107 + }), 108 + diagnostic, 109 + skipped_reason: None, 110 + } 111 + } 112 + 113 + /// Create a network error check result. 114 + pub fn network_error( 115 + self, 116 + diagnostic: Option<Box<dyn miette::Diagnostic + Send + Sync>>, 117 + ) -> CheckResult { 118 + CheckResult { 119 + id: self.id(), 120 + stage: Stage::DISCOVERY, 121 + status: crate::common::report::CheckStatus::NetworkError, 122 + summary: Cow::Borrowed(match self { 123 + Check::ClientIdWellFormed => "Client ID network error", 124 + Check::MetadataDocumentFetchable => "Metadata document unreachable", 125 + Check::MetadataIsJson => "Metadata fetch failed", 126 + }), 127 + diagnostic, 128 + skipped_reason: None, 129 + } 130 + } 131 + 132 + /// Create a skipped check result with a reason. 133 + pub fn skipped(self, reason: &'static str) -> CheckResult { 134 + skipped_with_reason( 135 + self.id(), 136 + Stage::DISCOVERY, 137 + match self { 138 + Check::ClientIdWellFormed => "Client ID well-formed", 139 + Check::MetadataDocumentFetchable => "Metadata document fetchable", 140 + Check::MetadataIsJson => "Metadata is valid JSON", 141 + }, 142 + reason, 143 + ) 144 + } 145 + } 146 + 147 + /// Output from the discovery stage. 148 + #[derive(Debug)] 149 + pub struct DiscoveryStageOutput { 150 + /// Facts populated when discovery succeeds. 151 + pub facts: Option<DiscoveryFacts>, 152 + /// All check results from this stage. 153 + pub results: Vec<CheckResult>, 154 + } 155 + 156 + /// Run the discovery stage for the given target. 157 + pub async fn run(target: &OauthClientTarget, http: &dyn HttpClient) -> DiscoveryStageOutput { 158 + let mut results = Vec::new(); 159 + 160 + match target { 161 + OauthClientTarget::HttpsUrl(url) => { 162 + // Check that the client_id is well-formed (it's already validated by parse_target, 163 + // but we still emit the check). 164 + results.push(Check::ClientIdWellFormed.pass()); 165 + 166 + // Fetch the metadata document. 167 + match http.get_bytes_with_content_type(url).await { 168 + Err(e) => { 169 + // Network error on fetch. 170 + let diagnostic: Box<dyn Diagnostic + Send + Sync> = 171 + Box::new(FetchError::from_identity_error(e, url)); 172 + results.push(Check::MetadataDocumentFetchable.network_error(Some(diagnostic))); 173 + results.push(blocked_by( 174 + Check::MetadataIsJson.id(), 175 + Stage::DISCOVERY, 176 + "Metadata is valid JSON", 177 + Check::MetadataDocumentFetchable.id(), 178 + )); 179 + DiscoveryStageOutput { 180 + facts: None, 181 + results, 182 + } 183 + } 184 + Ok((status, _, _)) if !(200..=299).contains(&status) => { 185 + // Non-2xx status. 186 + let diagnostic: Box<dyn Diagnostic + Send + Sync> = Box::new(HttpStatusError { 187 + url: url.clone(), 188 + status, 189 + }); 190 + results.push(Check::MetadataDocumentFetchable.network_error(Some(diagnostic))); 191 + results.push(blocked_by( 192 + Check::MetadataIsJson.id(), 193 + Stage::DISCOVERY, 194 + "Metadata is valid JSON", 195 + Check::MetadataDocumentFetchable.id(), 196 + )); 197 + DiscoveryStageOutput { 198 + facts: None, 199 + results, 200 + } 201 + } 202 + Ok((_status, body, content_type)) => { 203 + // 2xx response received. Check if it's valid JSON. 204 + results.push(Check::MetadataDocumentFetchable.pass()); 205 + 206 + // Try to parse as JSON. 207 + match serde_json::from_slice::<serde_json::Value>(&body) { 208 + Err(json_err) => { 209 + // Parse error. 210 + let pretty_body = 211 + crate::common::diagnostics::pretty_json_for_display(&body); 212 + let span = span_at_line_column( 213 + &pretty_body, 214 + json_err.line(), 215 + json_err.column(), 216 + ); 217 + let ct = content_type.as_deref().unwrap_or("<unknown>"); 218 + let diagnostic: Box<dyn Diagnostic + Send + Sync> = 219 + Box::new(JsonParseError { 220 + source: named_source_from_bytes( 221 + format!("metadata document (content-type: {ct})"), 222 + &pretty_body, 223 + ), 224 + span, 225 + message: format!( 226 + "response body is not valid JSON (content-type: {ct})" 227 + ), 228 + }); 229 + results.push(Check::MetadataIsJson.spec_violation(Some(diagnostic))); 230 + DiscoveryStageOutput { 231 + facts: None, 232 + results, 233 + } 234 + } 235 + Ok(_) => { 236 + // Valid JSON. 237 + results.push(Check::MetadataIsJson.pass()); 238 + DiscoveryStageOutput { 239 + facts: Some(DiscoveryFacts { 240 + client_id: url.clone(), 241 + kind: ClientIdKind::HttpsUrl, 242 + raw_metadata: RawMetadata::Document { 243 + bytes: Arc::from(body), 244 + content_type, 245 + }, 246 + }), 247 + results, 248 + } 249 + } 250 + } 251 + } 252 + } 253 + } 254 + OauthClientTarget::Loopback(loopback_target) => { 255 + // Loopback targets have well-formed client_id by definition. 256 + results.push(Check::ClientIdWellFormed.pass()); 257 + 258 + // Metadata is implicit for loopback clients. 259 + results.push( 260 + Check::MetadataDocumentFetchable 261 + .skipped("metadata is implicit for loopback clients"), 262 + ); 263 + results 264 + .push(Check::MetadataIsJson.skipped("metadata is implicit for loopback clients")); 265 + 266 + // Reconstruct the client_id URL. 267 + let client_id = reconstruct_loopback_url(loopback_target); 268 + 269 + DiscoveryStageOutput { 270 + facts: Some(DiscoveryFacts { 271 + client_id: client_id.clone(), 272 + kind: ClientIdKind::Loopback, 273 + raw_metadata: RawMetadata::Implicit { 274 + client_id: client_id.clone(), 275 + }, 276 + }), 277 + results, 278 + } 279 + } 280 + } 281 + } 282 + 283 + /// Reconstruct an HTTP loopback URL from a LoopbackTarget. 284 + fn reconstruct_loopback_url(target: &LoopbackTarget) -> Url { 285 + let host_str = match target.host { 286 + LoopbackHost::Localhost => "localhost", 287 + LoopbackHost::Loopback127 => "127.0.0.1", 288 + }; 289 + 290 + let url_str = if let Some(port) = target.port { 291 + format!("http://{}:{}{}", host_str, port, target.path) 292 + } else { 293 + format!("http://{}{}", host_str, target.path) 294 + }; 295 + 296 + Url::parse(&url_str).expect("reconstructed loopback URL must be valid") 297 + } 298 + 299 + // Error types for diagnostics. 300 + 301 + /// Error fetching metadata document. 302 + #[derive(Debug)] 303 + struct FetchError { 304 + message: String, 305 + } 306 + 307 + impl FetchError { 308 + fn from_identity_error(err: crate::common::identity::IdentityError, url: &Url) -> Self { 309 + FetchError { 310 + message: format!("Failed to fetch metadata from {url}: {err}"), 311 + } 312 + } 313 + } 314 + 315 + impl std::fmt::Display for FetchError { 316 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 317 + write!(f, "{}", self.message) 318 + } 319 + } 320 + 321 + impl std::error::Error for FetchError {} 322 + 323 + impl miette::Diagnostic for FetchError {} 324 + 325 + /// Error for non-2xx HTTP status. 326 + #[derive(Debug)] 327 + struct HttpStatusError { 328 + url: Url, 329 + status: u16, 330 + } 331 + 332 + impl std::fmt::Display for HttpStatusError { 333 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 334 + write!( 335 + f, 336 + "Metadata document fetch returned HTTP {}: {}", 337 + self.status, self.url 338 + ) 339 + } 340 + } 341 + 342 + impl std::error::Error for HttpStatusError {} 343 + 344 + impl miette::Diagnostic for HttpStatusError {} 345 + 346 + /// Error for invalid JSON in metadata document. 347 + #[derive(Debug)] 348 + struct JsonParseError { 349 + source: NamedSource<Arc<[u8]>>, 350 + span: SourceSpan, 351 + message: String, 352 + } 353 + 354 + impl std::fmt::Display for JsonParseError { 355 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 356 + write!(f, "{}", self.message) 357 + } 358 + } 359 + 360 + impl std::error::Error for JsonParseError {} 361 + 362 + impl miette::Diagnostic for JsonParseError { 363 + fn source_code(&self) -> Option<&(dyn miette::SourceCode + 'static)> { 364 + Some(&self.source) 365 + } 366 + 367 + fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> { 368 + Some(Box::new(std::iter::once(miette::LabeledSpan::new( 369 + None, 370 + self.span.offset(), 371 + self.span.len(), 372 + )))) 373 + } 374 + }
+28
src/common/identity.rs
··· 282 282 pub trait HttpClient: Send + Sync { 283 283 /// Fetches the bytes at the given URL, returning status code and body. 284 284 async fn get_bytes(&self, url: &Url) -> Result<(u16, Vec<u8>), IdentityError>; 285 + 286 + /// Like `get_bytes` but additionally returns the response's `Content-Type` 287 + /// header value (if any). Added in Phase 3 of the oauth_client rollout so 288 + /// discovery can include content-type in AC1.6 diagnostics. Default impl 289 + /// calls `get_bytes` and returns `None` for `content_type`, so existing 290 + /// implementers (labeler stages) don't need updates. 291 + async fn get_bytes_with_content_type( 292 + &self, 293 + url: &Url, 294 + ) -> Result<(u16, Vec<u8>, Option<String>), IdentityError> { 295 + let (status, body) = self.get_bytes(url).await?; 296 + Ok((status, body, None)) 297 + } 285 298 } 286 299 287 300 /// Trait for DNS resolvers used by identity resolution. ··· 327 340 let status = response.status().as_u16(); 328 341 let bytes = response.bytes().await?; 329 342 Ok((status, bytes.to_vec())) 343 + } 344 + 345 + async fn get_bytes_with_content_type( 346 + &self, 347 + url: &Url, 348 + ) -> Result<(u16, Vec<u8>, Option<String>), IdentityError> { 349 + let response = self.inner.get(url.clone()).send().await?; 350 + let status = response.status().as_u16(); 351 + let content_type = response 352 + .headers() 353 + .get("content-type") 354 + .and_then(|v| v.to_str().ok()) 355 + .map(String::from); 356 + let bytes = response.bytes().await?; 357 + Ok((status, bytes.to_vec(), content_type)) 330 358 } 331 359 } 332 360