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}