A better Rust ATProto crate
1use crate::types::{OAuthAuthorizationServerMetadata, OAuthProtectedResourceMetadata}; 2use http::{Request, StatusCode}; 3use jacquard_common::IntoStatic; 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#[derive(thiserror::Error, Debug, miette::Diagnostic)] 11pub enum ResolverError { 12 #[error("resource not found")] 13 NotFound, 14 #[error("invalid at identifier: {0}")] 15 AtIdentifier(String), 16 #[error("invalid did: {0}")] 17 Did(String), 18 #[error("invalid did document: {0}")] 19 DidDocument(String), 20 #[error("protected resource metadata is invalid: {0}")] 21 ProtectedResourceMetadata(String), 22 #[error("authorization server metadata is invalid: {0}")] 23 AuthorizationServerMetadata(String), 24 #[error("error resolving identity: {0}")] 25 IdentityResolverError(#[from] IdentityError), 26 #[error("unsupported did method: {0:?}")] 27 UnsupportedDidMethod(Did<'static>), 28 #[error(transparent)] 29 Http(#[from] http::Error), 30 #[error("http client error: {0}")] 31 HttpClient(Box<dyn std::error::Error + Send + Sync + 'static>), 32 #[error("http status: {0:?}")] 33 HttpStatus(StatusCode), 34 #[error(transparent)] 35 SerdeJson(#[from] serde_json::Error), 36 #[error(transparent)] 37 SerdeHtmlForm(#[from] serde_html_form::ser::Error), 38 #[error(transparent)] 39 Uri(#[from] url::ParseError), 40} 41 42#[async_trait::async_trait] 43pub trait OAuthResolver: IdentityResolver + HttpClient { 44 async fn verify_issuer( 45 &self, 46 server_metadata: &OAuthAuthorizationServerMetadata<'_>, 47 sub: &Did<'_>, 48 ) -> Result<Url, ResolverError> { 49 let (metadata, identity) = self.resolve_from_identity(sub).await?; 50 if metadata.issuer != server_metadata.issuer { 51 return Err(ResolverError::Did(format!("DIDs did not match"))); 52 } 53 Ok(identity 54 .pds_endpoint() 55 .ok_or(ResolverError::DidDocument(format!("{:?}", identity).into()))?) 56 } 57 async fn resolve_oauth( 58 &self, 59 input: &str, 60 ) -> Result< 61 ( 62 OAuthAuthorizationServerMetadata<'static>, 63 Option<DidDocument<'static>>, 64 ), 65 ResolverError, 66 > { 67 // Allow using an entryway, or PDS url, directly as login input (e.g. 68 // when the user forgot their handle, or when the handle does not 69 // resolve to a DID) 70 Ok(if input.starts_with("https://") { 71 let url = Url::parse(input).map_err(|_| ResolverError::NotFound)?; 72 (self.resolve_from_service(&url).await?, None) 73 } else { 74 let (metadata, identity) = self.resolve_from_identity(input).await?; 75 (metadata, Some(identity)) 76 }) 77 } 78 async fn resolve_from_service( 79 &self, 80 input: &Url, 81 ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 82 // Assume first that input is a PDS URL (as required by ATPROTO) 83 if let Ok(metadata) = self.get_resource_server_metadata(input).await { 84 return Ok(metadata); 85 } 86 // Fallback to trying to fetch as an issuer (Entryway) 87 self.get_authorization_server_metadata(input).await 88 } 89 async fn resolve_from_identity( 90 &self, 91 input: &str, 92 ) -> Result< 93 ( 94 OAuthAuthorizationServerMetadata<'static>, 95 DidDocument<'static>, 96 ), 97 ResolverError, 98 > { 99 let actor = AtIdentifier::new(input) 100 .map_err(|e| ResolverError::AtIdentifier(format!("{:?}", e)))?; 101 let identity = self.resolve_ident_owned(&actor).await?; 102 if let Some(pds) = &identity.pds_endpoint() { 103 let metadata = self.get_resource_server_metadata(pds).await?; 104 Ok((metadata, identity)) 105 } else { 106 Err(ResolverError::DidDocument(format!("Did doc lacking pds"))) 107 } 108 } 109 async fn get_authorization_server_metadata( 110 &self, 111 issuer: &Url, 112 ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 113 Ok(resolve_authorization_server(self, issuer).await?) 114 } 115 async fn get_resource_server_metadata( 116 &self, 117 pds: &Url, 118 ) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 119 let rs_metadata = resolve_protected_resource_info(self, pds).await?; 120 // ATPROTO requires one, and only one, authorization server entry 121 // > That document MUST contain a single item in the authorization_servers array. 122 // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 123 let issuer = match &rs_metadata.authorization_servers { 124 Some(servers) if !servers.is_empty() => { 125 if servers.len() > 1 { 126 return Err(ResolverError::ProtectedResourceMetadata(format!( 127 "unable to determine authorization server for PDS: {pds}" 128 ))); 129 } 130 &servers[0] 131 } 132 _ => { 133 return Err(ResolverError::ProtectedResourceMetadata(format!( 134 "no authorization server found for PDS: {pds}" 135 ))); 136 } 137 }; 138 let as_metadata = self.get_authorization_server_metadata(issuer).await?; 139 // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-08#name-authorization-server-metada 140 if let Some(protected_resources) = &as_metadata.protected_resources { 141 if !protected_resources.contains(&rs_metadata.resource) { 142 return Err(ResolverError::AuthorizationServerMetadata(format!( 143 "pds {pds} does not protected by issuer: {issuer}", 144 ))); 145 } 146 } 147 148 // TODO: atproot specific validation? 149 // https://github.com/bluesky-social/proposals/tree/main/0004-oauth#server-metadata 150 // 151 // eg. 152 // https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html 153 // if as_metadata.client_id_metadata_document_supported != Some(true) { 154 // return Err(Error::AuthorizationServerMetadata(format!( 155 // "authorization server does not support client_id_metadata_document: {issuer}" 156 // ))); 157 // } 158 159 Ok(as_metadata) 160 } 161} 162 163pub async fn resolve_authorization_server<T: HttpClient + ?Sized>( 164 client: &T, 165 server: &Url, 166) -> Result<OAuthAuthorizationServerMetadata<'static>, ResolverError> { 167 let url = server 168 .join("/.well-known/oauth-authorization-server") 169 .map_err(|e| ResolverError::HttpClient(e.into()))?; 170 171 let req = Request::builder() 172 .uri(url.to_string()) 173 .body(Vec::new()) 174 .map_err(|e| ResolverError::HttpClient(e.into()))?; 175 let res = client 176 .send_http(req) 177 .await 178 .map_err(|e| ResolverError::HttpClient(e.into()))?; 179 if res.status() == StatusCode::OK { 180 let metadata = serde_json::from_slice::<OAuthAuthorizationServerMetadata>(res.body()) 181 .map_err(ResolverError::SerdeJson)?; 182 // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 183 if metadata.issuer == server.as_str() { 184 Ok(metadata.into_static()) 185 } else { 186 Err(ResolverError::AuthorizationServerMetadata(format!( 187 "invalid issuer: {}", 188 metadata.issuer 189 ))) 190 } 191 } else { 192 Err(ResolverError::HttpStatus(res.status())) 193 } 194} 195 196pub async fn resolve_protected_resource_info<T: HttpClient + ?Sized>( 197 client: &T, 198 server: &Url, 199) -> Result<OAuthProtectedResourceMetadata<'static>, ResolverError> { 200 let url = server 201 .join("/.well-known/oauth-protected-resource") 202 .map_err(|e| ResolverError::HttpClient(e.into()))?; 203 204 let req = Request::builder() 205 .uri(url.to_string()) 206 .body(Vec::new()) 207 .map_err(|e| ResolverError::HttpClient(e.into()))?; 208 let res = client 209 .send_http(req) 210 .await 211 .map_err(|e| ResolverError::HttpClient(e.into()))?; 212 if res.status() == StatusCode::OK { 213 let metadata = serde_json::from_slice::<OAuthProtectedResourceMetadata>(res.body()) 214 .map_err(ResolverError::SerdeJson)?; 215 // https://datatracker.ietf.org/doc/html/rfc8414#section-3.3 216 if metadata.resource == server.as_str() { 217 Ok(metadata.into_static()) 218 } else { 219 Err(ResolverError::AuthorizationServerMetadata(format!( 220 "invalid resource: {}", 221 metadata.resource 222 ))) 223 } 224 } else { 225 Err(ResolverError::HttpStatus(res.status())) 226 } 227}