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}