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