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}