A better Rust ATProto crate
at lifetimes 16 kB view raw
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 118pub trait OAuthResolver: IdentityResolver + HttpClient { 119 fn verify_issuer( 120 &self, 121 server_metadata: &OAuthAuthorizationServerMetadata<'_>, 122 sub: &Did<'_>, 123 ) -> impl std::future::Future<Output = Result<Url, ResolverError>> { 124 async { 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 } 136 fn resolve_oauth( 137 &self, 138 input: &str, 139 ) -> impl Future< 140 Output = Result< 141 ( 142 OAuthAuthorizationServerMetadata<'static>, 143 Option<DidDocument<'static>>, 144 ), 145 ResolverError, 146 >, 147 > { 148 // Allow using an entryway, or PDS url, directly as login input (e.g. 149 // when the user forgot their handle, or when the handle does not 150 // resolve to a DID) 151 async { 152 Ok(if input.starts_with("https://") { 153 let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?; 154 (self.resolve_from_service(&url).await?, None) 155 } else { 156 let (metadata, identity) = self.resolve_from_identity(input).await?; 157 (metadata, Some(identity)) 158 }) 159 } 160 } 161 fn resolve_from_service( 162 &self, 163 input: &Url, 164 ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> 165 { 166 async { 167 // Assume first that input is a PDS URL (as required by ATPROTO) 168 if let Ok(metadata) = self.get_resource_server_metadata(input).await { 169 return Ok(metadata); 170 } 171 // Fallback to trying to fetch as an issuer (Entryway) 172 self.get_authorization_server_metadata(input).await 173 } 174 } 175 fn resolve_from_identity( 176 &self, 177 input: &str, 178 ) -> impl Future< 179 Output = Result< 180 ( 181 OAuthAuthorizationServerMetadata<'static>, 182 DidDocument<'static>, 183 ), 184 ResolverError, 185 >, 186 > { 187 async { 188 let actor = AtIdentifier::new(input) 189 .map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?; 190 let identity = self.resolve_ident_owned(&actor).await?; 191 if let Some(pds) = &identity.pds_endpoint() { 192 let metadata = self.get_resource_server_metadata(pds).await?; 193 Ok((metadata, identity)) 194 } else { 195 Err(ResolverError::DidDocument(format!("Did doc lacking pds"))) 196 } 197 } 198 } 199 fn get_authorization_server_metadata( 200 &self, 201 issuer: &Url, 202 ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> 203 { 204 async { 205 let mut md = resolve_authorization_server(self, issuer).await?; 206 // Normalize issuer string to the input URL representation to avoid slash quirks 207 md.issuer = jacquard_common::CowStr::from(issuer.as_str()).into_static(); 208 Ok(md) 209 } 210 } 211 fn get_resource_server_metadata( 212 &self, 213 pds: &Url, 214 ) -> impl Future<Output = Result<OAuthAuthorizationServerMetadata<'static>, ResolverError>> 215 { 216 async move { 217 let rs_metadata = resolve_protected_resource_info(self, pds).await?; 218 // ATPROTO requires one, and only one, authorization server entry 219 // > That document MUST contain a single item in the authorization_servers array. 220 // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 221 let issuer = match &rs_metadata.authorization_servers { 222 Some(servers) if !servers.is_empty() => { 223 if servers.len() > 1 { 224 return Err(ResolverError::ProtectedResourceMetadata(format!( 225 "unable to determine authorization server for PDS: {pds}" 226 ))); 227 } 228 &servers[0] 229 } 230 _ => { 231 return Err(ResolverError::ProtectedResourceMetadata(format!( 232 "no authorization server found for PDS: {pds}" 233 ))); 234 } 235 }; 236 let as_metadata = self.get_authorization_server_metadata(issuer).await?; 237 // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 238 if let Some(protected_resources) = &as_metadata.protected_resources { 239 let resource_url = rs_metadata 240 .resource 241 .strip_suffix('/') 242 .unwrap_or(rs_metadata.resource.as_str()); 243 if !protected_resources.contains(&CowStr::Borrowed(resource_url)) { 244 return Err(ResolverError::AuthorizationServerMetadata(format!( 245 "pds {pds}, resource {0} not protected by issuer: {issuer}, protected resources: {1:?}", 246 rs_metadata.resource, protected_resources 247 ))); 248 } 249 } 250 251 // TODO: atproot specific validation? 252 // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 253 // 254 // eg. 255 // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html 256 // if as_metadata.client_id_metadata_document_supported != Some(true) { 257 // return Err(Error::AuthorizationServerMetadata(format!( 258 // "authorization server does not support client_id_metadata_document: {issuer}" 259 // ))); 260 // } 261 262 Ok(as_metadata) 263 } 264 } 265} 266 267pub async fn resolve_authorization_server<T: HttpClient + ?Sized>( 268 client: &T, 269 server: &Url, 270) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 271 let url = server 272 .join("/.well-known/oauth-authorization-server") 273 .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 274 275 let req = Request::builder() 276 .uri(url.to_string()) 277 .body(Vec::new()) 278 .map_err(|e| ResolverError::Transport(TransportError::InvalidRequest(e.to_string())))?; 279 let res = client 280 .send_http(req) 281 .await 282 .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 283 if res.status() == StatusCode::OK { 284 let mut metadata = serde_json::from_slice::<OAuthAuthorizationServerMetadata>(res.body()) 285 .map_err(ResolverError::SerdeJson)?; 286 // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 287 // Accept semantically equivalent issuer (normalize to the requested URL form) 288 if issuer_equivalent(&metadata.issuer, server.as_str()) { 289 metadata.issuer = server.as_str().into(); 290 Ok(metadata.into_static()) 291 } else { 292 Err(ResolverError::AuthorizationServerMetadata(format!( 293 "invalid issuer: {}", 294 metadata.issuer 295 ))) 296 } 297 } else { 298 Err(ResolverError::HttpStatus(res.status())) 299 } 300} 301 302pub async fn resolve_protected_resource_info<T: HttpClient + ?Sized>( 303 client: &T, 304 server: &Url, 305) -> Result<OAuthProtectedResourceMetadata<'static>, ResolverError> { 306 let url = server 307 .join("/.well-known/oauth-protected-resource") 308 .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 309 310 let req = Request::builder() 311 .uri(url.to_string()) 312 .body(Vec::new()) 313 .map_err(|e| ResolverError::Transport(TransportError::InvalidRequest(e.to_string())))?; 314 let res = client 315 .send_http(req) 316 .await 317 .map_err(|e| ResolverError::Transport(TransportError::Other(Box::new(e))))?; 318 if res.status() == StatusCode::OK { 319 let mut metadata = serde_json::from_slice::<OAuthProtectedResourceMetadata>(res.body()) 320 .map_err(ResolverError::SerdeJson)?; 321 // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 322 // Accept semantically equivalent resource URL (normalize to the requested URL form) 323 if issuer_equivalent(&metadata.resource, server.as_str()) { 324 metadata.resource = server.as_str().into(); 325 Ok(metadata.into_static()) 326 } else { 327 Err(ResolverError::AuthorizationServerMetadata(format!( 328 "invalid resource: {}", 329 metadata.resource 330 ))) 331 } 332 } else { 333 Err(ResolverError::HttpStatus(res.status())) 334 } 335} 336 337impl OAuthResolver for jacquard_identity::JacquardResolver {} 338 339#[cfg(test)] 340mod tests { 341 use super::*; 342 use http::{Request as HttpRequest, Response as HttpResponse, StatusCode}; 343 use jacquard_common::http_client::HttpClient; 344 345 #[derive(Default, Clone)] 346 struct MockHttp { 347 next: std::sync::Arc<tokio::sync::Mutex<Option<HttpResponse<Vec<u8>>>>>, 348 } 349 350 impl HttpClient for MockHttp { 351 type Error = std::convert::Infallible; 352 fn send_http( 353 &self, 354 _request: HttpRequest<Vec<u8>>, 355 ) -> impl core::future::Future< 356 Output = core::result::Result<HttpResponse<Vec<u8>>, Self::Error>, 357 > + Send { 358 let next = self.next.clone(); 359 async move { Ok(next.lock().await.take().unwrap()) } 360 } 361 } 362 363 #[tokio::test] 364 async fn authorization_server_http_status() { 365 let client = MockHttp::default(); 366 *client.next.lock().await = Some( 367 HttpResponse::builder() 368 .status(StatusCode::NOT_FOUND) 369 .body(Vec::new()) 370 .unwrap(), 371 ); 372 let issuer = url::Url::parse("https://issuer").unwrap(); 373 let err = super::resolve_authorization_server(&client, &issuer) 374 .await 375 .unwrap_err(); 376 matches!(err, ResolverError::HttpStatus(StatusCode::NOT_FOUND)); 377 } 378 379 #[tokio::test] 380 async fn authorization_server_bad_json() { 381 let client = MockHttp::default(); 382 *client.next.lock().await = Some( 383 HttpResponse::builder() 384 .status(StatusCode::OK) 385 .body(b"{not json}".to_vec()) 386 .unwrap(), 387 ); 388 let issuer = url::Url::parse("https://issuer").unwrap(); 389 let err = super::resolve_authorization_server(&client, &issuer) 390 .await 391 .unwrap_err(); 392 matches!(err, ResolverError::SerdeJson(_)); 393 } 394 395 #[test] 396 fn issuer_equivalence_rules() { 397 assert!(super::issuer_equivalent( 398 "https://issuer", 399 "https://issuer/" 400 )); 401 assert!(super::issuer_equivalent( 402 "https://issuer:443/", 403 "https://issuer/" 404 )); 405 assert!(!super::issuer_equivalent( 406 "http://issuer/", 407 "https://issuer/" 408 )); 409 assert!(!super::issuer_equivalent( 410 "https://issuer/foo", 411 "https://issuer/" 412 )); 413 assert!(!super::issuer_equivalent( 414 "https://issuer/?q=1", 415 "https://issuer/" 416 )); 417 } 418}