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): parse_target — accept https and loopback forms, reject others

Implement Task 1 of Phase 3: target parsing and validation for OAuth client
conformance tests. Adds OauthClientTarget enum with HttpsUrl and Loopback
variants, LoopbackTarget with host/port/path, LoopbackHost enum, and
parse_target function that enforces atproto client_id rules:

- HTTPS form requires a host and forbids query/fragment
- HTTP form is accepted only for localhost or 127.0.0.1 (with optional port/path)
- All other schemes, missing hosts, or non-loopback HTTP hosts are rejected

Each TargetParseError variant derives Diagnostic with stable error codes
under oauth_client::target::*. Includes 10 unit tests covering all happy paths
and every rejection reason.

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

+316
+2
src/commands/test/oauth/client.rs
··· 6 6 //! spins up an in-process fake authorization server and observes the 7 7 //! client driving end-to-end OAuth flows. 8 8 9 + pub mod pipeline; 10 + 9 11 use std::process::ExitCode; 10 12 11 13 use clap::{Args, Subcommand};
+314
src/commands/test/oauth/client/pipeline.rs
··· 1 + //! OAuth client conformance test pipeline and target parsing. 2 + 3 + use miette::Diagnostic; 4 + use thiserror::Error; 5 + use url::Url; 6 + 7 + /// An OAuth client target that has been parsed and validated. 8 + #[derive(Debug, Clone)] 9 + pub enum OauthClientTarget { 10 + /// An HTTPS URL pointing at a client metadata document. 11 + HttpsUrl(Url), 12 + /// A loopback development client using implicit metadata. 13 + Loopback(LoopbackTarget), 14 + } 15 + 16 + /// A loopback development client target. 17 + #[derive(Debug, Clone)] 18 + pub struct LoopbackTarget { 19 + /// The loopback host (localhost or 127.0.0.1). 20 + pub host: LoopbackHost, 21 + /// Optional port number. 22 + pub port: Option<u16>, 23 + /// The path component, including leading slash. 24 + pub path: String, 25 + } 26 + 27 + /// A recognized loopback hostname. 28 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 29 + pub enum LoopbackHost { 30 + /// `localhost` 31 + Localhost, 32 + /// `127.0.0.1` 33 + Loopback127, 34 + } 35 + 36 + /// Error parsing a client_id target. 37 + #[derive(Debug, Error, Diagnostic)] 38 + pub enum TargetParseError { 39 + /// The input could not be parsed as a URL. 40 + #[error("target must be a valid URL")] 41 + #[diagnostic(code = "oauth_client::target::not_a_url")] 42 + NotAUrl { 43 + /// The invalid input. 44 + input: String, 45 + /// Help text explaining the accepted formats. 46 + #[help] 47 + help: &'static str, 48 + }, 49 + 50 + /// An HTTPS URL is missing the host component. 51 + #[error("HTTPS client_id must include a hostname")] 52 + #[diagnostic(code = "oauth_client::target::https_missing_host")] 53 + HttpsMissingHost { 54 + /// The invalid input. 55 + input: String, 56 + /// Help text. 57 + #[help] 58 + help: &'static str, 59 + }, 60 + 61 + /// An HTTPS URL contains query string or fragment. 62 + #[error("HTTPS client_id must not include query string or fragment")] 63 + #[diagnostic(code = "oauth_client::target::https_has_query_or_fragment")] 64 + HttpsHasQueryOrFragment { 65 + /// The invalid input. 66 + input: String, 67 + /// Help text. 68 + #[help] 69 + help: &'static str, 70 + }, 71 + 72 + /// The URL uses an unsupported scheme (neither https nor http). 73 + #[error("client_id scheme must be 'https' or 'http'")] 74 + #[diagnostic(code = "oauth_client::target::unsupported_scheme")] 75 + UnsupportedScheme { 76 + /// The invalid input. 77 + input: String, 78 + /// The unsupported scheme. 79 + scheme: String, 80 + /// Help text. 81 + #[help] 82 + help: &'static str, 83 + }, 84 + 85 + /// An HTTP URL with a hostname that is not a recognized loopback address. 86 + #[error("HTTP client_id must be localhost or 127.0.0.1")] 87 + #[diagnostic(code = "oauth_client::target::http_non_loopback")] 88 + HttpNonLoopback { 89 + /// The invalid input. 90 + input: String, 91 + /// The non-loopback hostname. 92 + host: String, 93 + /// Help text. 94 + #[help] 95 + help: &'static str, 96 + }, 97 + } 98 + 99 + impl TargetParseError { 100 + fn help_text() -> &'static str { 101 + "Accepted forms: https://example.com/path (for production), http://localhost[:port][/path] or http://127.0.0.1[:port][/path] (for development)" 102 + } 103 + } 104 + 105 + /// Parse a client_id target string into a validated `OauthClientTarget`. 106 + /// 107 + /// The atproto OAuth spec allows two forms: 108 + /// - Production: `https://...` with a hostname and no query/fragment. 109 + /// - Development loopback: `http://localhost[:port][/path]` or `http://127.0.0.1[:port][/path]`. 110 + pub fn parse_target(raw: &str) -> Result<OauthClientTarget, TargetParseError> { 111 + let url = Url::parse(raw).map_err(|_| TargetParseError::NotAUrl { 112 + input: raw.to_string(), 113 + help: TargetParseError::help_text(), 114 + })?; 115 + 116 + match url.scheme() { 117 + "https" => { 118 + // HTTPS must have a host. 119 + if url.host().is_none() { 120 + return Err(TargetParseError::HttpsMissingHost { 121 + input: raw.to_string(), 122 + help: TargetParseError::help_text(), 123 + }); 124 + } 125 + 126 + // HTTPS must not have query or fragment. 127 + if url.query().is_some() || url.fragment().is_some() { 128 + return Err(TargetParseError::HttpsHasQueryOrFragment { 129 + input: raw.to_string(), 130 + help: TargetParseError::help_text(), 131 + }); 132 + } 133 + 134 + Ok(OauthClientTarget::HttpsUrl(url)) 135 + } 136 + "http" => { 137 + // HTTP is only allowed for loopback addresses. 138 + match url.host_str() { 139 + Some("localhost") => { 140 + let port = url.port(); 141 + let path = url.path().to_string(); 142 + Ok(OauthClientTarget::Loopback(LoopbackTarget { 143 + host: LoopbackHost::Localhost, 144 + port, 145 + path, 146 + })) 147 + } 148 + Some("127.0.0.1") => { 149 + let port = url.port(); 150 + let path = url.path().to_string(); 151 + Ok(OauthClientTarget::Loopback(LoopbackTarget { 152 + host: LoopbackHost::Loopback127, 153 + port, 154 + path, 155 + })) 156 + } 157 + Some(host) => Err(TargetParseError::HttpNonLoopback { 158 + input: raw.to_string(), 159 + host: host.to_string(), 160 + help: TargetParseError::help_text(), 161 + }), 162 + None => Err(TargetParseError::HttpNonLoopback { 163 + input: raw.to_string(), 164 + host: "<no host>".to_string(), 165 + help: TargetParseError::help_text(), 166 + }), 167 + } 168 + } 169 + scheme => Err(TargetParseError::UnsupportedScheme { 170 + input: raw.to_string(), 171 + scheme: scheme.to_string(), 172 + help: TargetParseError::help_text(), 173 + }), 174 + } 175 + } 176 + 177 + #[cfg(test)] 178 + mod tests { 179 + use super::*; 180 + use miette::Diagnostic; 181 + 182 + #[test] 183 + fn https_happy() { 184 + let url = "https://client.example.com/metadata.json"; 185 + let result = parse_target(url).expect("should parse"); 186 + match result { 187 + OauthClientTarget::HttpsUrl(u) => { 188 + assert_eq!(u.scheme(), "https"); 189 + assert_eq!(u.host_str(), Some("client.example.com")); 190 + } 191 + _ => panic!("expected HttpsUrl"), 192 + } 193 + } 194 + 195 + #[test] 196 + fn https_with_query_rejected() { 197 + let url = "https://client.example.com/metadata.json?key=value"; 198 + let result = parse_target(url); 199 + match result { 200 + Err(TargetParseError::HttpsHasQueryOrFragment { .. }) => { 201 + // Verified it's the right variant with the right code. 202 + let err = TargetParseError::HttpsHasQueryOrFragment { 203 + input: url.to_string(), 204 + help: TargetParseError::help_text(), 205 + }; 206 + assert_eq!( 207 + err.code().map(|c| c.to_string()), 208 + Some("oauth_client::target::https_has_query_or_fragment".to_string()) 209 + ); 210 + } 211 + _ => panic!("expected HttpsHasQueryOrFragment, got {result:?}"), 212 + } 213 + } 214 + 215 + #[test] 216 + fn https_with_fragment_rejected() { 217 + let url = "https://client.example.com/metadata.json#section"; 218 + let result = parse_target(url); 219 + match result { 220 + Err(TargetParseError::HttpsHasQueryOrFragment { .. }) => {} 221 + _ => panic!("expected HttpsHasQueryOrFragment, got {result:?}"), 222 + } 223 + } 224 + 225 + #[test] 226 + fn localhost_no_port() { 227 + let url = "http://localhost"; 228 + let result = parse_target(url).expect("should parse"); 229 + match result { 230 + OauthClientTarget::Loopback(l) => { 231 + assert_eq!(l.host, LoopbackHost::Localhost); 232 + assert_eq!(l.port, None); 233 + assert_eq!(l.path, "/"); 234 + } 235 + _ => panic!("expected Loopback"), 236 + } 237 + } 238 + 239 + #[test] 240 + fn localhost_with_port() { 241 + let url = "http://localhost:8080"; 242 + let result = parse_target(url).expect("should parse"); 243 + match result { 244 + OauthClientTarget::Loopback(l) => { 245 + assert_eq!(l.host, LoopbackHost::Localhost); 246 + assert_eq!(l.port, Some(8080)); 247 + assert_eq!(l.path, "/"); 248 + } 249 + _ => panic!("expected Loopback"), 250 + } 251 + } 252 + 253 + #[test] 254 + fn localhost_with_path() { 255 + let url = "http://localhost/client.json"; 256 + let result = parse_target(url).expect("should parse"); 257 + match result { 258 + OauthClientTarget::Loopback(l) => { 259 + assert_eq!(l.host, LoopbackHost::Localhost); 260 + assert_eq!(l.port, None); 261 + assert_eq!(l.path, "/client.json"); 262 + } 263 + _ => panic!("expected Loopback"), 264 + } 265 + } 266 + 267 + #[test] 268 + fn ipv4_loopback() { 269 + let url = "http://127.0.0.1:3000/"; 270 + let result = parse_target(url).expect("should parse"); 271 + match result { 272 + OauthClientTarget::Loopback(l) => { 273 + assert_eq!(l.host, LoopbackHost::Loopback127); 274 + assert_eq!(l.port, Some(3000)); 275 + assert_eq!(l.path, "/"); 276 + } 277 + _ => panic!("expected Loopback"), 278 + } 279 + } 280 + 281 + #[test] 282 + fn http_non_loopback_host() { 283 + let url = "http://example.com/metadata.json"; 284 + let result = parse_target(url); 285 + match result { 286 + Err(TargetParseError::HttpNonLoopback { host, .. }) => { 287 + assert_eq!(host, "example.com"); 288 + } 289 + _ => panic!("expected HttpNonLoopback, got {result:?}"), 290 + } 291 + } 292 + 293 + #[test] 294 + fn ftp_scheme_rejected() { 295 + let url = "ftp://example.com/file"; 296 + let result = parse_target(url); 297 + match result { 298 + Err(TargetParseError::UnsupportedScheme { scheme, .. }) => { 299 + assert_eq!(scheme, "ftp"); 300 + } 301 + _ => panic!("expected UnsupportedScheme, got {result:?}"), 302 + } 303 + } 304 + 305 + #[test] 306 + fn garbage_input_not_a_url() { 307 + let url = "this is not a url at all!!!"; 308 + let result = parse_target(url); 309 + match result { 310 + Err(TargetParseError::NotAUrl { .. }) => {} 311 + _ => panic!("expected NotAUrl, got {result:?}"), 312 + } 313 + } 314 + }