A better Rust ATProto crate
1use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata}; 2use http::{Request, StatusCode}; 3use jacquard_common::CowStr; 4use jacquard_common::types::did_doc::DidDocument; 5use jacquard_common::types::ident::AtIdentifier; 6use jacquard_common::{IntoStatic, error::TransportError}; 7use jacquard_common::{http_client::HttpClient, types::did::Did}; 8use jacquard_identity::resolver::{IdentityError, IdentityResolver}; 9use url::Url; 10 11/// Compare two issuer strings strictly but without spuriously failing on trivial differences. 12/// 13/// Rules: 14/// - Schemes must match exactly. 15/// - Hostnames and effective ports must match (treat missing port the same as default port). 16/// - Path must match, except that an empty path and `/` are equivalent. 17/// - Query/fragment are not considered; if present on either side, the comparison fails. 18pub(crate) fn issuer_equivalent(a: &str, b: &str) -> bool { 19 fn normalize(url: &Url) -> Option<(String, String, u16, String)> { 20 if url.query().is_some() || url.fragment().is_some() { 21 return None; 22 } 23 let scheme = url.scheme().to_string(); 24 let host = url.host_str()?.to_string(); 25 let port = url.port_or_known_default()?; 26 let path = match url.path() { 27 "" => "/".to_string(), 28 "/" => "/".to_string(), 29 other => other.to_string(), 30 }; 31 Some((scheme, host, port, path)) 32 } 33 34 match (Url::parse(a), Url::parse(b)) { 35 (Ok(ua), Ok(ub)) => match (normalize(&ua), normalize(&ub)) { 36 (Some((sa, ha, pa, pa_path)), Some((sb, hb, pb, pb_path))) => { 37 if sa != sb || ha != hb || pa != pb { 38 return false; 39 } 40 if pa_path == "/" && pb_path == "/" { 41 return true; 42 } 43 pa_path == pb_path 44 } 45 _ => false, 46 }, 47 _ => a == b, 48 } 49} 50 51#[derive(thiserror::Error, Debug, miette::Diagnostic)] 52pub enum ResolverError { 53 #[error("resource not found")] 54 #[diagnostic( 55 code(jacquard_oauth::resolver::not_found), 56 help("check the base URL or identifier") 57 )] 58 NotFound, 59 #[error("invalid at identifier: {0}")] 60 #[diagnostic( 61 code(jacquard_oauth::resolver::at_identifier), 62 help("ensure a valid handle or DID was provided") 63 )] 64 AtIdentifier(String), 65 #[error("invalid did: {0}")] 66 #[diagnostic( 67 code(jacquard_oauth::resolver::did), 68 help("ensure DID is correctly formed (did:plc or did:web)") 69 )] 70 Did(String), 71 #[error("invalid did document: {0}")] 72 #[diagnostic( 73 code(jacquard_oauth::resolver::did_document), 74 help("verify the DID document structure and service entries") 75 )] 76 DidDocument(String), 77 #[error("protected resource metadata is invalid: {0}")] 78 #[diagnostic( 79 code(jacquard_oauth::resolver::protected_resource_metadata), 80 help("PDS must advertise an authorization server in its protected resource metadata") 81 )] 82 ProtectedResourceMetadata(String), 83 #[error("authorization server metadata is invalid: {0}")] 84 #[diagnostic( 85 code(jacquard_oauth::resolver::authorization_server_metadata), 86 help("issuer must match and include the PDS resource") 87 )] 88 AuthorizationServerMetadata(String), 89 #[error("error resolving identity: {0}")] 90 #[diagnostic(code(jacquard_oauth::resolver::identity))] 91 IdentityResolverError(#[from] IdentityError), 92 #[error("unsupported did method: {0:?}")] 93 #[diagnostic( 94 code(jacquard_oauth::resolver::unsupported_did_method), 95 help("supported DID methods: did:web, did:plc") 96 )] 97 UnsupportedDidMethod(Did<'static>), 98 #[error(transparent)] 99 #[diagnostic(code(jacquard_oauth::resolver::transport))] 100 Transport(#[from] TransportError), 101 #[error("http status: {0:?}")] 102 #[diagnostic( 103 code(jacquard_oauth::resolver::http_status), 104 help("check well-known paths and server configuration") 105 )] 106 HttpStatus(StatusCode), 107 #[error(transparent)] 108 #[diagnostic(code(jacquard_oauth::resolver::serde_json))] 109 SerdeJson(#[from] serde_json::Error), 110 #[error(transparent)] 111 #[diagnostic(code(jacquard_oauth::resolver::serde_form))] 112 SerdeHtmlForm(#[from] serde_html_form::ser::Error), 113 #[error(transparent)] 114 #[diagnostic(code(jacquard_oauth::resolver::url))] 115 Uri(#[from] url::ParseError), 116} 117 118#[async_trait::async_trait] 119pub trait OAuthResolver: IdentityResolver + HttpClient { 120 async fn verify_issuer( 121 &self, 122 server_metadata: &OAuthAuthorizationServerMetadata<'_>, 123 sub: &Did<'_>, 124 ) -> Result<Url, ResolverError> { 125 let (metadata, identity) = self.resolve_from_identity(sub).await?; 126 if !issuer_equivalent(&metadata.issuer, &server_metadata.issuer) { 127 return Err(ResolverError::AuthorizationServerMetadata( 128 "issuer mismatch".to_string(), 129 )); 130 } 131 Ok(identity 132 .pds_endpoint() 133 .ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?) 134 } 135 async fn resolve_oauth( 136 &self, 137 input: &str, 138 ) -> Result< 139 ( 140 OAuthAuthorizationServerMetadata<'static>, 141 Option<DidDocument<'static>>, 142 ), 143 ResolverError, 144 > { 145 // Allow using an entryway, or PDS url, directly as login input (e.g. 146 // when the user forgot their handle, or when the handle does not 147 // resolve to a DID) 148 Ok(if input.starts_with("https://") { 149 let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?; 150 (self.resolve_from_service(&url).await?, None) 151 } else { 152 let (metadata, identity) = self.resolve_from_identity(input).await?; 153 (metadata, Some(identity)) 154 }) 155 } 156 async fn resolve_from_service( 157 &self, 158 input: &Url, 159 ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 160 // Assume first that input is a PDS URL (as required by ATPROTO) 161 if let Ok(metadata) = self.get_resource_server_metadata(input).await { 162 return Ok(metadata); 163 } 164 // Fallback to trying to fetch as an issuer (Entryway) 165 self.get_authorization_server_metadata(input).await 166 } 167 async fn resolve_from_identity( 168 &self, 169 input: &str, 170 ) -> Result< 171 ( 172 OAuthAuthorizationServerMetadata<'static>, 173 DidDocument<'static>, 174 ), 175 ResolverError, 176 > { 177 let actor = AtIdentifier::new(input) 178 .map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?; 179 let identity = self.resolve_ident_owned(&actor).await?; 180 if let Some(pds) = &identity.pds_endpoint() { 181 let metadata = self.get_resource_server_metadata(pds).await?; 182 Ok((metadata, identity)) 183 } else { 184 Err(ResolverError::DidDocument(format!("Did doc lacking pds"))) 185 } 186 } 187 async fn get_authorization_server_metadata( 188 &self, 189 issuer: &Url, 190 ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 191 let mut md = resolve_authorization_server(self, issuer).await?; 192 // Normalize issuer string to the input URL representation to avoid slash quirks 193 md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static(); 194 Ok(md) 195 } 196 async fn get_resource_server_metadata( 197 &self, 198 pds: &Url, 199 ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 200 let rs_metadata = resolve_protected_resource_info(self, pds).await?; 201 // ATPROTO requires one, and only one, authorization server entry 202 // > That document MUST contain a single item in the authorization_servers array. 203 // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 204 let issuer = match &rs_metadata.authorization_servers { 205 Some(servers) if !servers.is_empty() => { 206 if servers.len() > 1 { 207 return Err(ResolverError::ProtectedResourceMetadata(format!( 208 "unable to determine authorization server for PDS: {pds}" 209 ))); 210 } 211 &servers[0] 212 } 213 _ => { 214 return Err(ResolverError::ProtectedResourceMetadata(format!( 215 "no authorization server found for PDS: {pds}" 216 ))); 217 } 218 }; 219 let as_metadata = self.get_authorization_server_metadata(issuer).await?; 220 // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 221 if let Some(protected_resources) = &as_metadata.protected_resources { 222 let resource_url = rs_metadata 223 .resource 224 .strip_suffix('/') 225 .unwrap_or(rs_metadata.resource.as_str()); 226 if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { 227 return Err(ResolverError::AuthorizationServerMetadata(format!( 228 "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 229 rs_metadata.resource, protected_resources 230 ))); 231 } 232 } 233 234 // TODO: atproot specific validation? 235 // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 236 // 237 // eg. 238 // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html 239 // if as_metadata.client_id_metadata_document_supported != Some(true) { 240 // return Err(Error::AuthorizationServerMetadata(format!( 241 // "authorization server does not support client_id_metadata_document: {issuer}" 242 // ))); 243 // } 244 245 Ok(as_metadata) 246 } 247} 248 249pub async fn resolve_authorization_server<T: HttpClient + ?Sized>( 250 client: &T, 251 server: &Url, 252) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 253 let url = server 254 .join("/.well-known/oauth-authorization-server") 255 .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 256 257 let req = Request::builder() 258 .uri(url.to_string()) 259 .body(Vec::new()) 260 .map_err(|e| ResolverError::Transport(TransportError::InvalidRequest(e.to_string())))?; 261 let res = client 262 .send_http(req) 263 .await 264 .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 265 if res.status() == StatusCode::OK { 266 let mut metadata = serde_json::from_slice::<OAuthAuthorizationServerMetadata>(res.body()) 267 .map_err(ResolverError::SerdeJson)?; 268 // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 269 // Accept semantically equivalent issuer (normalize to the requested URL form) 270 if issuer_equivalent(&metadata.issuer, server.as_str()) { 271 metadata.issuer = server.as_str().into(); 272 Ok(metadata.into_static()) 273 } else { 274 Err(ResolverError::AuthorizationServerMetadata(format!( 275 "invalid issuer: {}", 276 metadata.issuer 277 ))) 278 } 279 } else { 280 Err(ResolverError::HttpStatus(res.status())) 281 } 282} 283 284pub async fn resolve_protected_resource_info<T: HttpClient + ?Sized>( 285 client: &T, 286 server: &Url, 287) -> Result<OAuthProtectedResourceMetadata<'static>, ResolverError> { 288 let url = server 289 .join("/.well-known/oauth-protected-resource") 290 .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 291 292 let req = Request::builder() 293 .uri(url.to_string()) 294 .body(Vec::new()) 295 .map_err(|e| ResolverError::Transport(TransportError::InvalidRequest(e.to_string())))?; 296 let res = client 297 .send_http(req) 298 .await 299 .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 300 if res.status() == StatusCode::OK { 301 let mut metadata = serde_json::from_slice::<OAuthProtectedResourceMetadata>(res.body()) 302 .map_err(ResolverError::SerdeJson)?; 303 // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 304 // Accept semantically equivalent resource URL (normalize to the requested URL form) 305 if issuer_equivalent(&metadata.resource, server.as_str()) { 306 metadata.resource = server.as_str().into(); 307 Ok(metadata.into_static()) 308 } else { 309 Err(ResolverError::AuthorizationServerMetadata(format!( 310 "invalid resource: {}", 311 metadata.resource 312 ))) 313 } 314 } else { 315 Err(ResolverError::HttpStatus(res.status())) 316 } 317} 318 319#[async_trait::async_trait] 320impl OAuthResolver for jacquard_identity::JacquardResolver {} 321 322#[cfg(test)] 323mod tests { 324 use super::*; 325 use http::{Request as HttpRequest, Response as HttpResponse, StatusCode}; 326 use jacquard_common::http_client::HttpClient; 327 328 #[derive(Default, Clone)] 329 struct MockHttp { 330 next: std::sync::Arc<tokio::sync::Mutex<Option<HttpResponse<Vec<u8>>>>>, 331 } 332 333 impl HttpClient for MockHttp { 334 type Error = std::convert::Infallible; 335 fn send_http( 336 &self, 337 _request: HttpRequest<Vec<u8>>, 338 ) -> impl core::future::Future< 339 Output = core::result::Result<HttpResponse<Vec<u8>>, Self::Error>, 340 > + Send { 341 let next = self.next.clone(); 342 async move { Ok(next.lock().await.take().unwrap()) } 343 } 344 } 345 346 #[tokio::test] 347 async fn authorization_server_http_status() { 348 let client = MockHttp::default(); 349 *client.next.lock().await = Some( 350 HttpResponse::builder() 351 .status(StatusCode::NOT_FOUND) 352 .body(Vec::new()) 353 .unwrap(), 354 ); 355 let issuer = url::Url::parse("https://issuer").unwrap(); 356 let err = super::resolve_authorization_server(&client, &issuer) 357 .await 358 .unwrap_err(); 359 matches!(err, ResolverError::HttpStatus(StatusCode::NOT_FOUND)); 360 } 361 362 #[tokio::test] 363 async fn authorization_server_bad_json() { 364 let client = MockHttp::default(); 365 *client.next.lock().await = Some( 366 HttpResponse::builder() 367 .status(StatusCode::OK) 368 .body(b"{not json}".to_vec()) 369 .unwrap(), 370 ); 371 let issuer = url::Url::parse("https://issuer").unwrap(); 372 let err = super::resolve_authorization_server(&client, &issuer) 373 .await 374 .unwrap_err(); 375 matches!(err, ResolverError::SerdeJson(_)); 376 } 377 378 #[test] 379 fn issuer_equivalence_rules() { 380 assert!(super::issuer_equivalent( 381 "https://issuer", 382 "https://issuer/" 383 )); 384 assert!(super::issuer_equivalent( 385 "https://issuer:443/", 386 "https://issuer/" 387 )); 388 assert!(!super::issuer_equivalent( 389 "http://issuer/", 390 "https://issuer/" 391 )); 392 assert!(!super::issuer_equivalent( 393 "https://issuer/foo", 394 "https://issuer/" 395 )); 396 assert!(!super::issuer_equivalent( 397 "https://issuer/?q=1", 398 "https://issuer/" 399 )); 400 } 401}