1use std::collections::HashMap;
29use std::sync::RwLock;
30
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33use time::{Duration, OffsetDateTime};
34
35use crate::oauth::client_auth::{self, ClientAuthentication, Jwks};
36
37pub const CACHE_TTL: Duration = Duration::minutes(15);
44
45pub const CACHE_CAPACITY: usize = 512;
55
56pub const FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
70
71pub const MAX_DOCUMENT_BYTES: usize = 64 * 1024;
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92pub struct ClientMetadataDocument {
93 pub client_id: String,
95 #[serde(default, skip_serializing_if = "ApplicationType::is_web")]
99 pub application_type: ApplicationType,
100 pub client_name: Option<String>,
103 pub client_uri: Option<String>,
105 pub logo_uri: Option<String>,
107 pub redirect_uris: Vec<String>,
110 pub grant_types: Vec<String>,
113 pub response_types: Vec<String>,
115 #[serde(flatten)]
118 pub authentication: ClientAuthentication,
119 pub scope: Option<String>,
121 pub dpop_bound_access_tokens: bool,
125}
126
127#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(rename_all = "lowercase")]
131pub enum ApplicationType {
132 #[default]
135 Web,
136 Native,
139}
140
141impl ApplicationType {
142 fn is_web(&self) -> bool {
143 *self == Self::Web
144 }
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
149pub enum ClientMetadataError {
150 #[error("client_id `{0}` must be an https:// URL")]
154 NotHttps(String),
155 #[error("client_id `{0}` may not contain a fragment")]
159 HasFragment(String),
160 #[error("client_id `{0}` may not name a port")]
163 HasPort(String),
164 #[error("fetching client metadata for `{0}` failed: {1}")]
166 Fetch(String, String),
167 #[error("client metadata document at `{0}` did not parse: {1}")]
169 Malformed(String, String),
170 #[error("document at `{fetched_from}` claims client_id `{claimed}`, which does not match")]
173 ClientIdMismatch {
174 fetched_from: String,
176 claimed: String,
178 },
179 #[error("client metadata for `{0}` has no usable redirect_uris")]
181 NoRedirectUris(String),
182 #[error("a web client's redirect_uri `{0}` must be https:// with no fragment")]
185 BadRedirectUri(String),
186 #[error(
189 "a native client's redirect_uri `{0}` must be its client_id's host in reverse-domain \
190 order followed by `:/` and a path, https:// on its client_id's origin, or http:// on \
191 127.0.0.1 or [::1], with no fragment"
192 )]
193 BadNativeRedirectUri(String),
194 #[error("client metadata fetch for `{client_id}` was redirected to `{to}`")]
204 Redirected {
205 client_id: String,
207 to: String,
209 },
210 #[error("client metadata fetch for `{client_id}` answered {status}, not 200 OK")]
213 NotOk {
214 client_id: String,
216 status: reqwest::StatusCode,
218 },
219 #[error(
222 "client metadata for `{client_id}` was served as `{content_type}`, not application/json"
223 )]
224 NotJson {
225 client_id: String,
227 content_type: String,
229 },
230 #[error("client metadata document at `{0}` is larger than {MAX_DOCUMENT_BYTES} bytes")]
232 TooLarge(String),
233 #[error("client metadata for `{0}` does not opt into DPoP-bound access tokens")]
235 NotDpopBound(String),
236 #[error(
238 "client metadata for `{0}` names a token_endpoint_auth_method this server does not accept"
239 )]
240 UnsupportedAuthMethod(String),
241 #[error(
244 "client metadata for `{0}` names no token_endpoint_auth_signing_alg this server verifies"
245 )]
246 UnsupportedSigningAlg(String),
247 #[error(
250 "client metadata for `{0}` publishes no key a client assertion can be checked against"
251 )]
252 NoClientKeys(String),
253 #[error("client metadata for `{0}` names both jwks and jwks_uri")]
256 TwoKeySets(String),
257 #[error("jwks_uri `{0}` must be https://, or http:// on a loopback host, with no fragment")]
260 BadJwksUri(String),
261 #[error("loopback redirect_uri `{0}` must be http://127.0.0.1 or http://[::1], any port")]
266 LoopbackRedirectUri(String),
267 #[error("client_id `{0}` resolves to an address this server will not fetch from")]
271 RefusedAddress(String),
272}
273
274async fn pinned_public_client(
289 client_id: &str,
290 url: &reqwest::Url,
291) -> Result<reqwest::Client, ClientMetadataError> {
292 didbot_http::pinned_client(url, didbot_http::PrivateAddresses::Refused)
293 .await
294 .map_err(|error| match error {
295 didbot_http::AddressError::NoClient { message, .. } => {
296 ClientMetadataError::Fetch(client_id.to_owned(), message)
297 }
298 _ => ClientMetadataError::RefusedAddress(url.to_string()),
299 })
300}
301
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum LoopbackClients {
317 Fetched,
319 Refused,
321}
322
323impl LoopbackClients {
324 pub fn for_zone(zone: &str) -> Self {
327 let zone = zone.trim_end_matches('.').to_ascii_lowercase();
328 if zone == "localhost" || zone.ends_with(".localhost") {
329 Self::Fetched
330 } else {
331 Self::Refused
332 }
333 }
334}
335
336fn validate_url_scheme(raw: &str) -> Result<reqwest::Url, ()> {
338 let url = reqwest::Url::parse(raw).map_err(|_| ())?;
339 let ok_scheme =
340 url.scheme() == "https" || (url.scheme() == "http" && didbot_http::is_loopback(&url));
341 if !ok_scheme || url.fragment().is_some() {
342 return Err(());
343 }
344 Ok(url)
345}
346
347fn is_loopback_redirect_uri(raw: &str) -> bool {
353 let Ok(url) = reqwest::Url::parse(raw) else {
354 return false;
355 };
356 url.scheme() == "http" && matches!(url.host_str(), Some("127.0.0.1") | Some("[::1]"))
359}
360
361fn is_web_redirect_uri(raw: &str) -> bool {
365 reqwest::Url::parse(raw).is_ok_and(|url| url.scheme() == "https" && url.fragment().is_none())
366}
367
368fn is_native_redirect_uri(client_id: &str, raw: &str) -> bool {
377 let (Ok(client), Ok(url)) = (reqwest::Url::parse(client_id), reqwest::Url::parse(raw)) else {
378 return false;
379 };
380 if url.fragment().is_some() {
381 return false;
382 }
383 match url.scheme() {
384 "https" => url.origin() == client.origin(),
385 "http" => is_loopback_redirect_uri(raw),
386 scheme => {
387 let Some(host) = client.host_str() else {
388 return false;
389 };
390 let reversed = host.rsplit('.').collect::<Vec<_>>().join(".");
391 let rest = &url.as_str()[scheme.len() + 1..];
393 scheme == reversed && rest.starts_with('/') && !rest.starts_with("//")
394 }
395 }
396}
397
398impl ClientMetadataDocument {
399 pub fn admits_redirect_uri(&self, requested: &str) -> bool {
409 self.redirect_uris.iter().any(|registered| {
410 registered == requested || same_loopback_redirect(registered, requested)
411 })
412 }
413}
414
415fn same_loopback_redirect(registered: &str, requested: &str) -> bool {
420 if !is_loopback_redirect_uri(registered) {
421 return false;
422 }
423 let (Ok(registered), Ok(mut requested)) = (
424 reqwest::Url::parse(registered),
425 reqwest::Url::parse(requested),
426 ) else {
427 return false;
428 };
429 requested.set_port(registered.port()).is_ok() && requested == registered
430}
431
432fn parse_loopback_client_id(
443 client_id: &str,
444) -> Option<Result<ClientMetadataDocument, ClientMetadataError>> {
445 let url = reqwest::Url::parse(client_id).ok()?;
446 if url.scheme() != "http" {
447 return None;
448 }
449 if !url
450 .host_str()
451 .is_some_and(|host| host.eq_ignore_ascii_case("localhost"))
452 {
453 return None;
454 }
455 if url.port().is_some() {
456 return None;
457 }
458 if !matches!(url.path(), "" | "/") {
459 return None;
460 }
461
462 let mut redirect_uris: Vec<String> = url
463 .query_pairs()
464 .filter(|(key, _)| key == "redirect_uri")
465 .map(|(_, value)| value.into_owned())
466 .collect();
467 if redirect_uris.is_empty() {
468 redirect_uris = vec!["http://127.0.0.1/".to_owned(), "http://[::1]/".to_owned()];
469 }
470 for redirect_uri in &redirect_uris {
471 if !is_loopback_redirect_uri(redirect_uri) {
472 return Some(Err(ClientMetadataError::LoopbackRedirectUri(
473 redirect_uri.clone(),
474 )));
475 }
476 }
477
478 let scope = url
479 .query_pairs()
480 .find(|(key, _)| key == "scope")
481 .map(|(_, value)| value.into_owned())
482 .unwrap_or_else(|| "atproto".to_owned());
483
484 Some(Ok(ClientMetadataDocument {
485 client_id: client_id.to_owned(),
486 application_type: ApplicationType::Native,
488 client_name: None,
489 client_uri: None,
490 logo_uri: None,
491 redirect_uris,
492 grant_types: vec!["authorization_code".to_owned(), "refresh_token".to_owned()],
493 response_types: vec!["code".to_owned()],
494 authentication: ClientAuthentication::public(),
495 scope: Some(scope),
496 dpop_bound_access_tokens: true,
497 }))
498}
499
500pub fn validate(
503 client_id: &str,
504 mut document: ClientMetadataDocument,
505) -> Result<ClientMetadataDocument, ClientMetadataError> {
506 if document.client_id != client_id {
507 return Err(ClientMetadataError::ClientIdMismatch {
508 fetched_from: client_id.to_owned(),
509 claimed: document.client_id,
510 });
511 }
512 if document.grant_types.is_empty() {
513 document.grant_types = vec!["authorization_code".to_owned()];
514 }
515 if document.response_types.is_empty() {
516 document.response_types = vec!["code".to_owned()];
517 }
518 if document.redirect_uris.is_empty() {
519 return Err(ClientMetadataError::NoRedirectUris(client_id.to_owned()));
520 }
521 for redirect_uri in &document.redirect_uris {
522 match document.application_type {
523 ApplicationType::Web if !is_web_redirect_uri(redirect_uri) => {
524 return Err(ClientMetadataError::BadRedirectUri(redirect_uri.clone()));
525 }
526 ApplicationType::Native if !is_native_redirect_uri(client_id, redirect_uri) => {
527 return Err(ClientMetadataError::BadNativeRedirectUri(
528 redirect_uri.clone(),
529 ));
530 }
531 _ => {}
532 }
533 }
534 if !document.dpop_bound_access_tokens {
535 return Err(ClientMetadataError::NotDpopBound(client_id.to_owned()));
536 }
537 check_authentication(client_id, &document.authentication)?;
538 Ok(document)
539}
540
541fn check_authentication(
547 client_id: &str,
548 authentication: &ClientAuthentication,
549) -> Result<(), ClientMetadataError> {
550 let alg = authentication.token_endpoint_auth_signing_alg.as_deref();
551 if alg == Some("none") {
553 return Err(ClientMetadataError::UnsupportedSigningAlg(
554 client_id.to_owned(),
555 ));
556 }
557 match authentication.token_endpoint_auth_method.as_str() {
558 client_auth::NONE => Ok(()),
559 client_auth::PRIVATE_KEY_JWT => {
560 if alg != Some(client_auth::SIGNING_ALG) {
561 return Err(ClientMetadataError::UnsupportedSigningAlg(
562 client_id.to_owned(),
563 ));
564 }
565 match (&authentication.jwks, &authentication.jwks_uri) {
566 (Some(_), Some(_)) => Err(ClientMetadataError::TwoKeySets(client_id.to_owned())),
567 (Some(jwks), None) if !jwks.is_empty() => Ok(()),
568 (None, Some(jwks_uri)) => validate_url_scheme(jwks_uri)
569 .map(drop)
570 .map_err(|()| ClientMetadataError::BadJwksUri(jwks_uri.clone())),
571 _ => Err(ClientMetadataError::NoClientKeys(client_id.to_owned())),
572 }
573 }
574 _ => Err(ClientMetadataError::UnsupportedAuthMethod(
575 client_id.to_owned(),
576 )),
577 }
578}
579
580#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
585pub struct ClientKey(pub String);
586
587impl ClientKey {
588 pub fn compute(document: &ClientMetadataDocument) -> Self {
595 let bytes = serde_json::to_vec(document)
596 .expect("ClientMetadataDocument has no non-serializable field");
597 let digest = Sha256::digest(bytes);
598 Self(hex::encode(digest))
599 }
600}
601
602#[derive(Debug, Clone)]
604pub struct CachedClientMetadata {
605 pub document: ClientMetadataDocument,
607 pub key: ClientKey,
609 pub fetched_at: OffsetDateTime,
611}
612
613pub trait ClientMetadataCache: Send + Sync {
621 fn get(&self, client_id: &str) -> Option<CachedClientMetadata>;
624 fn put(&self, client_id: &str, entry: CachedClientMetadata);
626}
627
628#[derive(Debug, Default)]
630pub struct MemoryClientMetadataCache {
631 entries: RwLock<HashMap<String, CachedClientMetadata>>,
632}
633
634impl MemoryClientMetadataCache {
635 pub fn new() -> Self {
637 Self::default()
638 }
639}
640
641impl ClientMetadataCache for MemoryClientMetadataCache {
642 fn get(&self, client_id: &str) -> Option<CachedClientMetadata> {
643 let entries = self
644 .entries
645 .read()
646 .unwrap_or_else(|poisoned| poisoned.into_inner());
647 let entry = entries.get(client_id)?;
648 if OffsetDateTime::now_utc() - entry.fetched_at > CACHE_TTL {
649 return None;
650 }
651 Some(entry.clone())
652 }
653
654 fn put(&self, client_id: &str, entry: CachedClientMetadata) {
661 let now = OffsetDateTime::now_utc();
662 let mut entries = self
663 .entries
664 .write()
665 .unwrap_or_else(|poisoned| poisoned.into_inner());
666 entries.retain(|_, held| now - held.fetched_at <= CACHE_TTL);
667 while entries.len() >= CACHE_CAPACITY && !entries.contains_key(client_id) {
668 let Some(oldest) = entries
669 .iter()
670 .min_by_key(|(_, held)| held.fetched_at)
671 .map(|(id, _)| id.clone())
672 else {
673 break;
674 };
675 entries.remove(&oldest);
676 }
677 entries.insert(client_id.to_owned(), entry);
678 }
679}
680
681fn redirected_away_from(requested: &reqwest::Url, response: &reqwest::Response) -> Option<String> {
689 if response.url() != requested {
690 return Some(response.url().to_string());
691 }
692 if !response.status().is_redirection() {
693 return None;
694 }
695 let location = response
696 .headers()
697 .get(reqwest::header::LOCATION)
698 .and_then(|value| value.to_str().ok())
699 .map(str::to_owned);
700 Some(location.unwrap_or_else(|| response.status().to_string()))
701}
702
703async fn read_bounded(
710 client_id: &str,
711 mut response: reqwest::Response,
712) -> Result<Vec<u8>, ClientMetadataError> {
713 if response
714 .content_length()
715 .is_some_and(|len| len > MAX_DOCUMENT_BYTES as u64)
716 {
717 return Err(ClientMetadataError::TooLarge(client_id.to_owned()));
718 }
719 let mut body = Vec::new();
720 while let Some(chunk) = response
721 .chunk()
722 .await
723 .map_err(|err| ClientMetadataError::Fetch(client_id.to_owned(), err.to_string()))?
724 {
725 if body.len() + chunk.len() > MAX_DOCUMENT_BYTES {
726 tracing::debug!(
727 client_id,
728 max_bytes = MAX_DOCUMENT_BYTES,
729 "abandoning an oversized client metadata document mid-body"
730 );
731 return Err(ClientMetadataError::TooLarge(client_id.to_owned()));
732 }
733 body.extend_from_slice(&chunk);
734 }
735 Ok(body)
736}
737
738pub async fn resolve_client(
765 http: &reqwest::Client,
766 cache: &dyn ClientMetadataCache,
767 client_id: &str,
768 loopback: LoopbackClients,
769) -> Result<(ClientMetadataDocument, ClientKey), ClientMetadataError> {
770 if let Some(built) = parse_loopback_client_id(client_id) {
771 let document = validate(client_id, built?)?;
772 let key = ClientKey::compute(&document);
773 return Ok((document, key));
774 }
775 if let Some(cached) = cache.get(client_id) {
776 return Ok((cached.document, cached.key));
777 }
778 let url = validate_url_scheme(client_id)
779 .map_err(|()| ClientMetadataError::NotHttps(client_id.to_owned()))?;
780 if url.scheme() == "http" && loopback == LoopbackClients::Refused {
783 return Err(ClientMetadataError::NotHttps(client_id.to_owned()));
784 }
785 if url.port().is_some() {
786 return Err(ClientMetadataError::HasPort(client_id.to_owned()));
787 }
788 if url.fragment().is_some() {
789 return Err(ClientMetadataError::HasFragment(client_id.to_owned()));
790 }
791 let body = fetch_bounded(http, client_id, &url, loopback, true).await?;
792 let raw: ClientMetadataDocument = serde_json::from_slice(&body)
793 .map_err(|err| ClientMetadataError::Malformed(client_id.to_owned(), err.to_string()))?;
794 let mut document = validate(client_id, raw)?;
795 if document.authentication.is_confidential() {
796 if let Some(jwks_uri) = document.authentication.jwks_uri.clone() {
797 document.authentication.jwks_uri_keys =
798 Some(fetch_jwks(http, client_id, &jwks_uri, loopback).await?);
799 }
800 }
801 let key = ClientKey::compute(&document);
802 cache.put(
803 client_id,
804 CachedClientMetadata {
805 document: document.clone(),
806 key: key.clone(),
807 fetched_at: OffsetDateTime::now_utc(),
808 },
809 );
810 Ok((document, key))
811}
812
813async fn fetch_bounded(
827 http: &reqwest::Client,
828 client_id: &str,
829 url: &reqwest::Url,
830 loopback: LoopbackClients,
831 json_only: bool,
832) -> Result<Vec<u8>, ClientMetadataError> {
833 let pinned;
834 let mut url = url.clone();
835 let http = match loopback {
836 LoopbackClients::Fetched => {
837 let named = url.host_str().and_then(didbot_identity::did::resolve_port);
838 if let (None, Some(port)) = (url.port(), named) {
839 tracing::debug!(client_id, %url, port, "fetching on the port RESOLVE_PORTS names");
840 let _ = url.set_port(Some(port));
842 }
843 http
844 }
845 LoopbackClients::Refused => {
846 pinned = pinned_public_client(client_id, &url).await?;
847 &pinned
848 }
849 };
850 let response = http
851 .get(url.clone())
852 .timeout(FETCH_TIMEOUT)
853 .send()
854 .await
855 .map_err(|err| ClientMetadataError::Fetch(client_id.to_owned(), err.to_string()))?;
856 if let Some(to) = redirected_away_from(&url, &response) {
857 tracing::debug!(
858 client_id,
859 %url,
860 %to,
861 "refusing client metadata served from somewhere other than the URL fetched"
862 );
863 return Err(ClientMetadataError::Redirected {
864 client_id: client_id.to_owned(),
865 to,
866 });
867 }
868 if response.status() != reqwest::StatusCode::OK {
869 return Err(ClientMetadataError::NotOk {
870 client_id: client_id.to_owned(),
871 status: response.status(),
872 });
873 }
874 if json_only {
875 let served = response
876 .headers()
877 .get(reqwest::header::CONTENT_TYPE)
878 .and_then(|value| value.to_str().ok())
879 .unwrap_or_default();
880 let matches = served
881 .parse::<mime::Mime>()
882 .is_ok_and(|served| served.essence_str() == mime::APPLICATION_JSON.essence_str());
883 if !matches {
884 return Err(ClientMetadataError::NotJson {
885 client_id: client_id.to_owned(),
886 content_type: served.to_owned(),
887 });
888 }
889 }
890 read_bounded(client_id, response).await
891}
892
893async fn fetch_jwks(
896 http: &reqwest::Client,
897 client_id: &str,
898 jwks_uri: &str,
899 loopback: LoopbackClients,
900) -> Result<Jwks, ClientMetadataError> {
901 let url = validate_url_scheme(jwks_uri)
902 .map_err(|()| ClientMetadataError::BadJwksUri(jwks_uri.to_owned()))?;
903 if url.scheme() == "http" && loopback == LoopbackClients::Refused {
906 return Err(ClientMetadataError::BadJwksUri(jwks_uri.to_owned()));
907 }
908 let body = fetch_bounded(http, client_id, &url, loopback, false).await?;
909 let keys: Jwks = serde_json::from_slice(&body)
910 .map_err(|err| ClientMetadataError::Malformed(jwks_uri.to_owned(), err.to_string()))?;
911 if keys.is_empty() {
912 return Err(ClientMetadataError::NoClientKeys(client_id.to_owned()));
913 }
914 Ok(keys)
915}
916
917#[cfg(test)]
920pub(crate) use tests::{
921 client_host as tests_client_host, reaching as tests_reaching, HOST as TESTS_HOST,
922};
923
924#[cfg(test)]
925mod tests {
926 use axum::response::{IntoResponse, Redirect};
927 use axum::routing::get;
928 use axum::Router;
929 use tokio::io::AsyncWriteExt;
930 use tokio::net::TcpListener;
931
932 use super::*;
933
934 fn doc(client_id: &str, redirect_uris: Vec<&str>) -> ClientMetadataDocument {
935 ClientMetadataDocument {
936 client_id: client_id.to_owned(),
937 application_type: ApplicationType::Web,
938 client_name: Some("Placeholder Zydeco".to_owned()),
939 client_uri: None,
940 logo_uri: None,
941 redirect_uris: redirect_uris.into_iter().map(str::to_owned).collect(),
942 grant_types: vec![],
943 response_types: vec![],
944 authentication: ClientAuthentication::public(),
945 scope: None,
946 dpop_bound_access_tokens: true,
947 }
948 }
949
950 #[test]
951 fn validate_defaults_grant_and_response_types() {
952 let d = validate(
953 "https://client.example/id.json",
954 doc(
955 "https://client.example/id.json",
956 vec!["https://client.example/cb"],
957 ),
958 )
959 .unwrap();
960 assert_eq!(d.grant_types, vec!["authorization_code"]);
961 assert_eq!(d.response_types, vec!["code"]);
962 }
963
964 #[test]
965 fn client_id_must_match_the_document() {
966 let err = validate(
967 "https://client.example/id.json",
968 doc(
969 "https://someone-else.example/id.json",
970 vec!["https://client.example/cb"],
971 ),
972 )
973 .unwrap_err();
974 assert!(matches!(err, ClientMetadataError::ClientIdMismatch { .. }));
975 }
976
977 #[test]
978 fn empty_redirect_uris_is_refused() {
979 let err = validate(
980 "https://client.example/id.json",
981 doc("https://client.example/id.json", vec![]),
982 )
983 .unwrap_err();
984 assert!(matches!(err, ClientMetadataError::NoRedirectUris(_)));
985 }
986
987 #[test]
991 fn a_web_client_registers_only_https_redirects() {
992 let client_id = "https://client.example/id.json";
993 let web = |redirect_uri: &str| validate(client_id, doc(client_id, vec![redirect_uri]));
994 for admitted in [
995 "https://client.example/cb",
996 "https://elsewhere.example:8443/cb",
997 ] {
998 assert!(web(admitted).is_ok(), "{admitted}");
999 }
1000 for refused in [
1001 "http://evil.example/cb",
1002 "http://127.0.0.1/cb",
1003 "http://[::1]:4000/cb",
1004 "http://localhost:4000/cb",
1005 "https://client.example/cb#done",
1006 ] {
1007 assert_eq!(
1008 web(refused).unwrap_err(),
1009 ClientMetadataError::BadRedirectUri(refused.to_owned()),
1010 "{refused}"
1011 );
1012 }
1013 }
1014
1015 #[test]
1016 fn loopback_client_id_with_redirect_uri_and_scope_builds_its_own_document() {
1017 let client_id =
1018 "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%3A5555%2Fcb&scope=atproto+transition%3Ageneric";
1019 let document = parse_loopback_client_id(client_id)
1020 .expect("recognised as the loopback form")
1021 .expect("builds");
1022 assert_eq!(document.client_id, client_id);
1023 assert_eq!(document.redirect_uris, vec!["http://127.0.0.1:5555/cb"]);
1024 assert_eq!(
1025 document.scope.as_deref(),
1026 Some("atproto transition:generic")
1027 );
1028 assert_eq!(
1029 document.grant_types,
1030 vec!["authorization_code", "refresh_token"]
1031 );
1032 assert_eq!(document.response_types, vec!["code"]);
1033 assert_eq!(document.authentication.token_endpoint_auth_method, "none");
1034 assert!(document.dpop_bound_access_tokens);
1035 assert_eq!(document.application_type, ApplicationType::Native);
1036 }
1037
1038 #[test]
1041 fn a_native_client_registers_only_the_redirects_the_profile_allows() {
1042 let client_id = "https://app.example.com/oauth-client-metadata.json";
1043 let native = |redirect: &str| {
1044 validate(
1045 client_id,
1046 ClientMetadataDocument {
1047 application_type: ApplicationType::Native,
1048 ..doc(client_id, vec![redirect])
1049 },
1050 )
1051 };
1052 for admitted in [
1053 "com.example.app:/callback",
1054 "com.example.app:/",
1055 "https://app.example.com/callback",
1056 "http://127.0.0.1:4000/callback",
1057 "http://[::1]/callback",
1058 ] {
1059 assert!(native(admitted).is_ok(), "{admitted}");
1060 }
1061 for refused in [
1062 "com.example.other:/callback",
1064 "app.example.com:/callback",
1065 "com.example.app://callback",
1067 "com.example.app:///callback",
1068 "com.example.app:callback",
1069 "com.example.app:/callback#done",
1070 "https://other.example.com/callback",
1072 "https://app.example.com:8443/callback",
1073 "http://localhost:4000/callback",
1075 "http://app.example.com/callback",
1076 ] {
1077 assert_eq!(
1078 native(refused).unwrap_err(),
1079 ClientMetadataError::BadNativeRedirectUri(refused.to_owned()),
1080 "{refused}"
1081 );
1082 }
1083 }
1084
1085 #[test]
1088 fn a_web_client_may_not_register_a_custom_scheme() {
1089 let client_id = "https://app.example.com/oauth-client-metadata.json";
1090 let err =
1091 validate(client_id, doc(client_id, vec!["com.example.app:/callback"])).unwrap_err();
1092 assert_eq!(
1093 err,
1094 ClientMetadataError::BadRedirectUri("com.example.app:/callback".to_owned())
1095 );
1096 }
1097
1098 #[test]
1101 fn application_type_is_web_native_or_absent() {
1102 let read = |application_type: Option<&str>| {
1103 let mut document = serde_json::json!({
1104 "client_id": "https://app.example.com/oauth-client-metadata.json",
1105 "redirect_uris": ["https://app.example.com/callback"],
1106 "grant_types": ["authorization_code"],
1107 "response_types": ["code"],
1108 "token_endpoint_auth_method": "none",
1109 "dpop_bound_access_tokens": true,
1110 });
1111 if let Some(application_type) = application_type {
1112 document["application_type"] = application_type.into();
1113 }
1114 serde_json::from_value::<ClientMetadataDocument>(document)
1115 };
1116 let absent = read(None).expect("absent reads");
1117 let web = read(Some("web")).expect("web reads");
1118 let native = read(Some("native")).expect("native reads");
1119 assert_eq!(absent.application_type, ApplicationType::Web);
1120 assert_eq!(ClientKey::compute(&absent), ClientKey::compute(&web));
1121 assert_eq!(native.application_type, ApplicationType::Native);
1122 assert_ne!(ClientKey::compute(&absent), ClientKey::compute(&native));
1123 assert!(read(Some("desktop")).is_err());
1124 }
1125
1126 #[test]
1127 fn loopback_client_id_with_neither_param_gets_the_spec_defaults() {
1128 let document = parse_loopback_client_id("http://localhost")
1129 .expect("recognised as the loopback form")
1130 .expect("builds");
1131 assert_eq!(
1132 document.redirect_uris,
1133 vec!["http://127.0.0.1/", "http://[::1]/"]
1134 );
1135 assert_eq!(document.scope.as_deref(), Some("atproto"));
1136 }
1137
1138 #[test]
1142 fn a_loopback_redirect_matches_on_every_part_but_the_port() {
1143 let defaults = parse_loopback_client_id("http://localhost")
1144 .expect("recognised as the loopback form")
1145 .expect("builds");
1146 for admitted in [
1147 "http://127.0.0.1:61023/",
1148 "http://[::1]:61023/",
1149 "http://127.0.0.1",
1151 ] {
1152 assert!(defaults.admits_redirect_uri(admitted), "{admitted}");
1153 }
1154 for refused in [
1155 "http://127.0.0.1:61023/elsewhere",
1156 "http://127.0.0.1:61023/?next=elsewhere",
1157 "https://127.0.0.1:61023/",
1158 "http://localhost:61023/",
1159 "http://127.0.0.2:61023/",
1160 "http://user@127.0.0.1:61023/",
1161 ] {
1162 assert!(!defaults.admits_redirect_uri(refused), "{refused}");
1163 }
1164
1165 let named = parse_loopback_client_id(
1167 "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%3A4000%2Fcallback",
1168 )
1169 .expect("recognised as the loopback form")
1170 .expect("builds");
1171 assert!(named.admits_redirect_uri("http://127.0.0.1:5000/callback"));
1172
1173 let web = doc(
1175 "https://client.example/id.json",
1176 vec!["https://client.example/cb"],
1177 );
1178 assert!(web.admits_redirect_uri("https://client.example/cb"));
1179 assert!(!web.admits_redirect_uri("https://client.example:8443/cb"));
1180 assert!(!web.admits_redirect_uri("https://client.example/cb/"));
1181 }
1182
1183 #[test]
1184 fn loopback_client_id_with_a_trailing_slash_is_still_recognised() {
1185 let document = parse_loopback_client_id("http://localhost/")
1186 .expect("recognised as the loopback form")
1187 .expect("builds");
1188 assert_eq!(
1189 document.redirect_uris,
1190 vec!["http://127.0.0.1/", "http://[::1]/"]
1191 );
1192 }
1193
1194 #[test]
1195 fn loopback_client_ids_redirect_uri_on_localhost_is_refused() {
1196 let err = parse_loopback_client_id(
1197 "http://localhost/?redirect_uri=http%3A%2F%2Flocalhost%3A5555%2Fcb",
1198 )
1199 .expect("recognised as the loopback form")
1200 .expect_err("localhost is not a usable loopback redirect_uri");
1201 assert!(matches!(err, ClientMetadataError::LoopbackRedirectUri(_)));
1202 }
1203
1204 #[test]
1205 fn a_port_on_the_client_id_is_not_the_loopback_form() {
1206 assert!(parse_loopback_client_id(
1211 "http://localhost:4000/?redirect_uri=http%3A%2F%2F127.0.0.1%2Fcb"
1212 )
1213 .is_none());
1214 }
1215
1216 #[test]
1217 fn a_deeper_path_on_localhost_is_not_the_loopback_form() {
1218 assert!(parse_loopback_client_id("http://localhost/id.json").is_none());
1219 }
1220
1221 #[test]
1222 fn loopback_clients_with_different_scopes_get_different_keys() {
1223 let a = parse_loopback_client_id("http://localhost/?scope=atproto")
1224 .expect("recognised")
1225 .expect("builds");
1226 let b = parse_loopback_client_id("http://localhost/?scope=atproto+transition%3Ageneric")
1227 .expect("recognised")
1228 .expect("builds");
1229 assert_ne!(ClientKey::compute(&a), ClientKey::compute(&b));
1230 }
1231
1232 #[test]
1233 fn not_dpop_bound_is_refused() {
1234 let mut document = doc(
1235 "https://client.example/id.json",
1236 vec!["https://client.example/cb"],
1237 );
1238 document.dpop_bound_access_tokens = false;
1239 let err = validate("https://client.example/id.json", document).unwrap_err();
1240 assert!(matches!(err, ClientMetadataError::NotDpopBound(_)));
1241 }
1242
1243 #[test]
1244 fn changing_redirect_uris_changes_the_client_key() {
1245 let a = validate(
1246 "https://client.example/id.json",
1247 doc(
1248 "https://client.example/id.json",
1249 vec!["https://client.example/cb"],
1250 ),
1251 )
1252 .unwrap();
1253 let b = validate(
1254 "https://client.example/id.json",
1255 doc(
1256 "https://client.example/id.json",
1257 vec!["https://client.example/new-cb"],
1258 ),
1259 )
1260 .unwrap();
1261 assert_ne!(ClientKey::compute(&a), ClientKey::compute(&b));
1262 }
1263
1264 #[test]
1265 fn the_same_document_content_is_the_same_key_every_time() {
1266 let a = doc(
1267 "https://client.example/id.json",
1268 vec!["https://client.example/cb"],
1269 );
1270 let b = a.clone();
1271 assert_eq!(ClientKey::compute(&a), ClientKey::compute(&b));
1272 }
1273
1274 #[test]
1275 fn memory_cache_expires_after_ttl() {
1276 let cache = MemoryClientMetadataCache::new();
1277 let document = validate(
1278 "https://client.example/id.json",
1279 doc(
1280 "https://client.example/id.json",
1281 vec!["https://client.example/cb"],
1282 ),
1283 )
1284 .unwrap();
1285 let key = ClientKey::compute(&document);
1286 cache.put(
1287 "https://client.example/id.json",
1288 CachedClientMetadata {
1289 document,
1290 key,
1291 fetched_at: OffsetDateTime::now_utc() - CACHE_TTL - Duration::seconds(1),
1292 },
1293 );
1294 assert!(cache.get("https://client.example/id.json").is_none());
1295 }
1296
1297 fn document_json(client_id: &str) -> String {
1299 serde_json::json!({
1300 "client_id": client_id,
1301 "redirect_uris": ["https://app.example/cb"],
1302 "grant_types": ["authorization_code"],
1303 "response_types": ["code"],
1304 "token_endpoint_auth_method": "none",
1305 "dpop_bound_access_tokens": true,
1306 })
1307 .to_string()
1308 }
1309
1310 pub(crate) const HOST: &str = "http://localhost";
1314
1315 pub(crate) fn reaching(
1321 addr: std::net::SocketAddr,
1322 redirects: reqwest::redirect::Policy,
1323 ) -> reqwest::Client {
1324 didbot_http::builder()
1325 .timeout(FETCH_TIMEOUT)
1326 .redirect(redirects)
1327 .resolve("localhost", addr)
1328 .build()
1329 .expect("a client")
1330 }
1331
1332 fn reaching_only(addr: std::net::SocketAddr) -> reqwest::Client {
1335 reaching(addr, reqwest::redirect::Policy::none())
1336 }
1337
1338 pub(crate) async fn client_host() -> std::net::SocketAddr {
1348 let listener = TcpListener::bind("127.0.0.1:0")
1349 .await
1350 .expect("loopback binds");
1351 let addr = listener.local_addr().expect("bound listener has an addr");
1352 let good = document_json(&format!("{HOST}/id.json"));
1353 let impersonating = document_json(&format!("{HOST}/moved.json"));
1357 let oversized = "x".repeat(MAX_DOCUMENT_BYTES + 1);
1358 let app = Router::new()
1359 .route("/id.json", get(move || async move { json(good) }))
1360 .route(
1361 "/moved.json",
1362 get(|| async { Redirect::temporary("/elsewhere.json") }),
1363 )
1364 .route(
1365 "/elsewhere.json",
1366 get(move || async move { json(impersonating) }),
1367 )
1368 .route("/huge.json", get(move || async move { json(oversized) }));
1369 tokio::spawn(async move {
1370 axum::serve(listener, app)
1371 .await
1372 .expect("client host serves");
1373 });
1374 addr
1375 }
1376
1377 fn json(body: String) -> axum::response::Response {
1379 (
1380 [(axum::http::header::CONTENT_TYPE, "application/json")],
1381 body,
1382 )
1383 .into_response()
1384 }
1385
1386 async fn chunked_client_host() -> std::net::SocketAddr {
1394 let listener = TcpListener::bind("127.0.0.1:0")
1395 .await
1396 .expect("loopback binds");
1397 let addr = listener.local_addr().expect("bound listener has an addr");
1398 tokio::spawn(async move {
1399 while let Ok((mut socket, _)) = listener.accept().await {
1400 tokio::spawn(async move {
1401 let head = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
1403 Transfer-Encoding: chunked\r\n\r\n";
1404 if socket.write_all(head.as_bytes()).await.is_err() {
1405 return;
1406 }
1407 let payload = "x".repeat(16 * 1024);
1408 for _ in 0..5 {
1409 let chunk = format!("{:x}\r\n{payload}\r\n", payload.len());
1410 if socket.write_all(chunk.as_bytes()).await.is_err() {
1413 return;
1414 }
1415 }
1416 let _ = socket.write_all(b"0\r\n\r\n").await;
1417 });
1418 }
1419 });
1420 addr
1421 }
1422
1423 #[tokio::test(start_paused = true)]
1426 async fn a_client_id_host_that_never_answers_is_a_fetch_error_after_the_fetch_timeout() {
1427 let addr = didbot_http::test_support::hung_server();
1428 let cache = MemoryClientMetadataCache::new();
1429 let client = reaching_only(addr);
1430 let client_id = format!("{HOST}/client-metadata.json");
1431 let started = tokio::time::Instant::now();
1432 let resolving = resolve_client(&client, &cache, &client_id, LoopbackClients::Fetched);
1433 let err = tokio::time::timeout(FETCH_TIMEOUT * 2, resolving)
1434 .await
1435 .expect("the fetch's own timeout fires first")
1436 .expect_err("nothing ever answers");
1437 assert!(matches!(err, ClientMetadataError::Fetch(..)), "{err:?}");
1438 assert_eq!(started.elapsed(), FETCH_TIMEOUT);
1439 }
1440
1441 #[tokio::test]
1442 async fn resolve_client_recognises_the_loopback_form_without_any_network() {
1443 let cache = MemoryClientMetadataCache::new();
1444 let (document, key) = resolve_client(
1448 &crate::oauth::client_metadata_http_client(),
1449 &cache,
1450 "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%3A9%2Fcb&scope=atproto",
1451 LoopbackClients::Fetched,
1452 )
1453 .await
1454 .expect("the loopback form resolves without a fetch");
1455 assert_eq!(document.redirect_uris, vec!["http://127.0.0.1:9/cb"]);
1456 assert_eq!(key, ClientKey::compute(&document));
1457 }
1458
1459 #[tokio::test]
1460 async fn a_bare_loopback_client_id_resolves_with_both_default_redirect_uris() {
1461 let cache = MemoryClientMetadataCache::new();
1462 let (document, _key) = resolve_client(
1463 &crate::oauth::client_metadata_http_client(),
1464 &cache,
1465 "http://localhost",
1466 LoopbackClients::Fetched,
1467 )
1468 .await
1469 .expect("a client_id that spells no redirect_uri resolves on the spec defaults");
1470 assert_eq!(
1471 document.redirect_uris,
1472 vec!["http://127.0.0.1/", "http://[::1]/"]
1473 );
1474 }
1475
1476 #[tokio::test(start_paused = true)]
1482 async fn a_client_id_that_names_a_port_is_refused_before_the_fetch() {
1483 let addr = didbot_http::test_support::hung_server();
1484 let cache = MemoryClientMetadataCache::new();
1485 let started = tokio::time::Instant::now();
1486 for client_id in [
1487 format!("http://localhost:{}/id.json", addr.port()),
1488 format!("http://localhost:{}", addr.port()),
1489 format!("https://localhost:{}/id.json", addr.port()),
1490 "https://app.example:8443/id.json".to_owned(),
1491 ] {
1492 for loopback in [LoopbackClients::Fetched, LoopbackClients::Refused] {
1493 let err = resolve_client(&reaching_only(addr), &cache, &client_id, loopback)
1494 .await
1495 .expect_err("a client_id with a port is refused");
1496 let refused = match loopback {
1497 LoopbackClients::Refused if client_id.starts_with("http:") => {
1500 ClientMetadataError::NotHttps(client_id.clone())
1501 }
1502 _ => ClientMetadataError::HasPort(client_id.clone()),
1503 };
1504 assert_eq!(err, refused, "{client_id} {loopback:?}");
1505 }
1506 }
1507 assert_eq!(started.elapsed(), std::time::Duration::ZERO);
1508 }
1509
1510 #[tokio::test]
1511 async fn a_well_formed_document_still_resolves_through_the_bounded_read() {
1512 let addr = client_host().await;
1513 let cache = MemoryClientMetadataCache::new();
1514 let client_id = format!("{HOST}/id.json");
1515 let (document, _key) = resolve_client(
1516 &reaching_only(addr),
1517 &cache,
1518 &client_id,
1519 LoopbackClients::Fetched,
1520 )
1521 .await
1522 .expect("an ordinary document resolves");
1523 assert_eq!(document.client_id, client_id);
1524 }
1525
1526 #[tokio::test]
1529 async fn a_redirecting_client_id_is_refused_when_the_client_does_not_follow() {
1530 let addr = client_host().await;
1531 let cache = MemoryClientMetadataCache::new();
1532 let err = resolve_client(
1533 &reaching_only(addr),
1534 &cache,
1535 &format!("{HOST}/moved.json"),
1536 LoopbackClients::Fetched,
1537 )
1538 .await
1539 .expect_err("a redirect is not a client metadata document");
1540 assert!(
1541 matches!(err, ClientMetadataError::Redirected { .. }),
1542 "expected Redirected, got {err:?}"
1543 );
1544 }
1545
1546 #[tokio::test]
1551 async fn a_followed_redirect_is_refused_at_the_url_it_landed_on() {
1552 let addr = client_host().await;
1553 let cache = MemoryClientMetadataCache::new();
1554 let err = resolve_client(
1555 &reaching(addr, reqwest::redirect::Policy::default()),
1556 &cache,
1557 &format!("{HOST}/moved.json"),
1558 LoopbackClients::Fetched,
1559 )
1560 .await
1561 .expect_err("the document did not come from the client_id");
1562 match err {
1563 ClientMetadataError::Redirected { to, .. } => {
1564 assert!(to.ends_with("/elsewhere.json"), "landed on {to}");
1565 }
1566 other => panic!("expected Redirected, got {other:?}"),
1567 }
1568 }
1569
1570 #[tokio::test]
1575 async fn only_a_200_served_as_json_is_read() {
1576 let listener = TcpListener::bind("127.0.0.1:0")
1577 .await
1578 .expect("loopback binds");
1579 let addr = listener.local_addr().expect("bound listener has an addr");
1580 let app = Router::new().fallback(|uri: axum::http::Uri| async move {
1583 let (status, content_type) = match uri.path() {
1584 "/created.json" => (201, Some("application/json")),
1585 "/missing.json" => (404, Some("application/json")),
1586 "/text.json" => (200, Some("text/plain")),
1587 "/ld.json" => (200, Some("application/ld+json")),
1588 "/untyped.json" => (200, None),
1589 "/charset.json" => (200, Some("application/json; charset=utf-8")),
1590 _ => (200, Some("application/json")),
1591 };
1592 let mut response = axum::response::Response::builder().status(status);
1593 if let Some(content_type) = content_type {
1594 response = response.header(axum::http::header::CONTENT_TYPE, content_type);
1595 }
1596 response
1597 .body(axum::body::Body::from(document_json(&format!(
1598 "{HOST}{}",
1599 uri.path()
1600 ))))
1601 .expect("a response")
1602 });
1603 tokio::spawn(async move {
1604 axum::serve(listener, app)
1605 .await
1606 .expect("client host serves");
1607 });
1608
1609 let cache = MemoryClientMetadataCache::new();
1610 let resolve = |path: &str| {
1611 let client_id = format!("{HOST}{path}");
1612 let http = reaching_only(addr);
1613 let cache = &cache;
1614 async move {
1615 resolve_client(&http, cache, &client_id, LoopbackClients::Fetched)
1616 .await
1617 .map(|_| ())
1618 }
1619 };
1620 for read in ["/id.json", "/charset.json"] {
1621 assert_eq!(resolve(read).await, Ok(()), "{read}");
1622 }
1623 for (path, status) in [("/created.json", 201), ("/missing.json", 404)] {
1624 assert_eq!(
1625 resolve(path).await,
1626 Err(ClientMetadataError::NotOk {
1627 client_id: format!("{HOST}{path}"),
1628 status: reqwest::StatusCode::from_u16(status).expect("a status"),
1629 }),
1630 "{path}"
1631 );
1632 }
1633 for (path, content_type) in [
1634 ("/text.json", "text/plain"),
1635 ("/ld.json", "application/ld+json"),
1636 ("/untyped.json", ""),
1637 ] {
1638 assert_eq!(
1639 resolve(path).await,
1640 Err(ClientMetadataError::NotJson {
1641 client_id: format!("{HOST}{path}"),
1642 content_type: content_type.to_owned(),
1643 }),
1644 "{path}"
1645 );
1646 }
1647 }
1648
1649 #[tokio::test]
1650 async fn an_oversized_document_is_refused_on_its_declared_length() {
1651 let addr = client_host().await;
1652 let cache = MemoryClientMetadataCache::new();
1653 let err = resolve_client(
1654 &reaching_only(addr),
1655 &cache,
1656 &format!("{HOST}/huge.json"),
1657 LoopbackClients::Fetched,
1658 )
1659 .await
1660 .expect_err("a document over the cap is not read");
1661 assert!(
1662 matches!(err, ClientMetadataError::TooLarge(_)),
1663 "expected TooLarge, got {err:?}"
1664 );
1665 }
1666
1667 #[tokio::test]
1668 async fn an_oversized_document_that_declares_no_length_is_refused_mid_body() {
1669 let addr = chunked_client_host().await;
1670 let cache = MemoryClientMetadataCache::new();
1671 let err = resolve_client(
1672 &reaching_only(addr),
1673 &cache,
1674 &format!("{HOST}/id.json"),
1675 LoopbackClients::Fetched,
1676 )
1677 .await
1678 .expect_err("a body that outgrows the cap is abandoned");
1679 assert!(
1680 matches!(err, ClientMetadataError::TooLarge(_)),
1681 "expected TooLarge, got {err:?}"
1682 );
1683 }
1684
1685 #[tokio::test]
1694 async fn a_loopback_client_id_is_refused_before_the_fetch_off_a_localhost_zone() {
1695 let addr = client_host().await;
1696 let cache = MemoryClientMetadataCache::new();
1697 let client_id = format!("{HOST}/id.json");
1698 let err = resolve_client(
1699 &reaching_only(addr),
1700 &cache,
1701 &client_id,
1702 LoopbackClients::Refused,
1703 )
1704 .await
1705 .expect_err("a real deployment does not fetch loopback on a caller's say-so");
1706 assert!(matches!(err, ClientMetadataError::NotHttps(_)), "{err:?}");
1707 assert!(cache.get(&client_id).is_none());
1708
1709 let virtual_id =
1712 "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1%3A9%2Fcb&scope=atproto";
1713 let (built, _key) = resolve_client(
1714 &reaching_only(addr),
1715 &cache,
1716 virtual_id,
1717 LoopbackClients::Refused,
1718 )
1719 .await
1720 .expect("a native client's virtual document needs no fetch");
1721 assert_eq!(built.redirect_uris, ["http://127.0.0.1:9/cb"]);
1722 }
1723
1724 #[tokio::test(start_paused = true)]
1728 async fn a_client_id_on_this_network_is_refused_before_the_fetch() {
1729 let cache = MemoryClientMetadataCache::new();
1730 let client_id = "https://127.0.0.1/client-metadata.json".to_owned();
1731 let started = tokio::time::Instant::now();
1732 let err = resolve_client(
1733 &crate::oauth::client_metadata_http_client(),
1734 &cache,
1735 &client_id,
1736 LoopbackClients::Refused,
1737 )
1738 .await
1739 .expect_err("a real deployment does not fetch from its own network");
1740 assert!(
1741 matches!(err, ClientMetadataError::RefusedAddress(_)),
1742 "{err:?}"
1743 );
1744 assert_eq!(started.elapsed(), std::time::Duration::ZERO);
1745 assert!(cache.get(&client_id).is_none());
1746 }
1747
1748 #[tokio::test]
1752 async fn a_localhost_zone_still_fetches_from_its_own_machine() {
1753 let addr = client_host().await;
1754 let cache = MemoryClientMetadataCache::new();
1755 let client_id = format!("{HOST}/id.json");
1756 let (document, _key) = resolve_client(
1757 &reaching_only(addr),
1758 &cache,
1759 &client_id,
1760 LoopbackClients::Fetched,
1761 )
1762 .await
1763 .expect("a development deployment reaches its own machine");
1764 assert_eq!(document.client_id, client_id);
1765 }
1766
1767 #[test]
1768 fn only_a_localhost_zone_fetches_loopback_clients() {
1769 for zone in [
1770 "localhost",
1771 "agents.localhost",
1772 "AGENTS.LOCALHOST",
1773 "a.b.localhost.",
1774 ] {
1775 assert_eq!(
1776 LoopbackClients::for_zone(zone),
1777 LoopbackClients::Fetched,
1778 "{zone}"
1779 );
1780 }
1781 for zone in ["pds.did.bot", "localhost.example", "example.com", ""] {
1782 assert_eq!(
1783 LoopbackClients::for_zone(zone),
1784 LoopbackClients::Refused,
1785 "{zone}"
1786 );
1787 }
1788 }
1789
1790 #[test]
1796 fn the_cache_never_holds_more_than_its_capacity() {
1797 let cache = MemoryClientMetadataCache::new();
1798 let document = ClientMetadataDocument {
1799 client_id: "https://client.example/id.json".to_owned(),
1800 application_type: ApplicationType::Web,
1801 client_name: None,
1802 client_uri: None,
1803 logo_uri: None,
1804 redirect_uris: vec!["https://client.example/cb".to_owned()],
1805 grant_types: vec!["authorization_code".to_owned()],
1806 response_types: vec!["code".to_owned()],
1807 authentication: ClientAuthentication::public(),
1808 scope: Some("atproto".to_owned()),
1809 dpop_bound_access_tokens: true,
1810 };
1811 let key = ClientKey::compute(&document);
1812 let start = OffsetDateTime::now_utc();
1813 for n in 0..(CACHE_CAPACITY * 2) {
1814 cache.put(
1815 &format!("https://invented-{n}.example/id.json"),
1816 CachedClientMetadata {
1817 document: document.clone(),
1818 key: key.clone(),
1819 fetched_at: start + Duration::seconds(n as i64),
1821 },
1822 );
1823 }
1824 assert_eq!(
1825 cache.entries.read().unwrap().len(),
1826 CACHE_CAPACITY,
1827 "the cache grew past its bound"
1828 );
1829 assert!(cache.get("https://invented-0.example/id.json").is_none());
1830 assert!(cache
1831 .get(&format!(
1832 "https://invented-{}.example/id.json",
1833 CACHE_CAPACITY * 2 - 1
1834 ))
1835 .is_some());
1836 }
1837
1838 #[tokio::test]
1841 async fn a_refused_fetch_caches_nothing() {
1842 let addr = client_host().await;
1843 let cache = MemoryClientMetadataCache::new();
1844 let client_id = format!("{HOST}/moved.json");
1845 let _ = resolve_client(
1846 &reaching_only(addr),
1847 &cache,
1848 &client_id,
1849 LoopbackClients::Fetched,
1850 )
1851 .await;
1852 assert!(cache.get(&client_id).is_none());
1853 }
1854
1855 fn published_keys(kid: &str) -> serde_json::Value {
1858 let keyset = jacquard_oauth::keyset::Keyset::generate_es256(kid).expect("a key");
1859 serde_json::to_value(keyset.public_jwks()).expect("a key set serializes")
1860 }
1861
1862 #[test]
1866 fn what_the_profile_refuses_of_client_authentication_is_refused() {
1867 let client_id = "https://client.example/id.json";
1868 let keys: Jwks =
1869 serde_json::from_value(published_keys("client-key-1")).expect("the key set reads");
1870 let confidential = ClientAuthentication {
1871 token_endpoint_auth_method: client_auth::PRIVATE_KEY_JWT.to_owned(),
1872 token_endpoint_auth_signing_alg: Some(client_auth::SIGNING_ALG.to_owned()),
1873 jwks: Some(keys),
1874 ..ClientAuthentication::public()
1875 };
1876 let with = |authentication: ClientAuthentication| ClientMetadataDocument {
1877 authentication,
1878 ..doc(client_id, vec!["https://client.example/cb"])
1879 };
1880 let jwks_uri = Some("https://client.example/jwks.json".to_owned());
1881 assert!(validate(client_id, with(confidential.clone())).is_ok());
1882 assert!(validate(
1883 client_id,
1884 with(ClientAuthentication {
1885 jwks: None,
1886 jwks_uri: jwks_uri.clone(),
1887 ..confidential.clone()
1888 })
1889 )
1890 .is_ok());
1891
1892 let id = client_id.to_owned();
1893 for (authentication, refused) in [
1894 (
1895 ClientAuthentication {
1896 token_endpoint_auth_method: "client_secret_basic".to_owned(),
1897 ..confidential.clone()
1898 },
1899 ClientMetadataError::UnsupportedAuthMethod(id.clone()),
1900 ),
1901 (
1902 ClientAuthentication {
1903 token_endpoint_auth_signing_alg: None,
1904 ..confidential.clone()
1905 },
1906 ClientMetadataError::UnsupportedSigningAlg(id.clone()),
1907 ),
1908 (
1909 ClientAuthentication {
1910 token_endpoint_auth_signing_alg: Some("RS256".to_owned()),
1911 ..confidential.clone()
1912 },
1913 ClientMetadataError::UnsupportedSigningAlg(id.clone()),
1914 ),
1915 (
1916 ClientAuthentication {
1917 token_endpoint_auth_signing_alg: Some("none".to_owned()),
1918 ..ClientAuthentication::public()
1919 },
1920 ClientMetadataError::UnsupportedSigningAlg(id.clone()),
1921 ),
1922 (
1923 ClientAuthentication {
1924 jwks: None,
1925 ..confidential.clone()
1926 },
1927 ClientMetadataError::NoClientKeys(id.clone()),
1928 ),
1929 (
1930 ClientAuthentication {
1931 jwks: Some(Jwks::default()),
1932 ..confidential.clone()
1933 },
1934 ClientMetadataError::NoClientKeys(id.clone()),
1935 ),
1936 (
1937 ClientAuthentication {
1938 jwks_uri: jwks_uri.clone(),
1939 ..confidential.clone()
1940 },
1941 ClientMetadataError::TwoKeySets(id.clone()),
1942 ),
1943 (
1944 ClientAuthentication {
1945 jwks: None,
1946 jwks_uri: Some("http://client.example/jwks.json".to_owned()),
1947 ..confidential.clone()
1948 },
1949 ClientMetadataError::BadJwksUri("http://client.example/jwks.json".to_owned()),
1950 ),
1951 ] {
1952 assert_eq!(validate(client_id, with(authentication)), Err(refused));
1953 }
1954 }
1955
1956 #[tokio::test]
1960 async fn a_jwks_uri_is_fetched_with_the_document_and_cached_with_it() {
1961 let listener = TcpListener::bind("127.0.0.1:0")
1962 .await
1963 .expect("loopback binds");
1964 let addr = listener.local_addr().expect("bound listener has an addr");
1965 let client_id = format!("{HOST}/id.json");
1966 let served = serde_json::json!({
1967 "client_id": client_id,
1968 "redirect_uris": ["https://app.example/cb"],
1969 "grant_types": ["authorization_code"],
1970 "response_types": ["code"],
1971 "token_endpoint_auth_method": client_auth::PRIVATE_KEY_JWT,
1972 "token_endpoint_auth_signing_alg": client_auth::SIGNING_ALG,
1973 "jwks_uri": format!("{HOST}/jwks.json"),
1974 "dpop_bound_access_tokens": true,
1975 })
1976 .to_string();
1977 let keys = published_keys("client-key-1").to_string();
1978 let app = Router::new()
1979 .route("/id.json", get(move || async move { json(served) }))
1980 .route("/jwks.json", get(move || async move { json(keys) }));
1981 tokio::spawn(async move {
1982 axum::serve(listener, app)
1983 .await
1984 .expect("client host serves");
1985 });
1986
1987 let cache = MemoryClientMetadataCache::new();
1988 let (document, key) = resolve_client(
1989 &reaching_only(addr),
1990 &cache,
1991 &client_id,
1992 LoopbackClients::Fetched,
1993 )
1994 .await
1995 .expect("a confidential client's document and keys resolve");
1996 assert!(document
1997 .authentication
1998 .keys()
1999 .is_some_and(|keys| !keys.is_empty()));
2000 let cached = cache.get(&client_id).expect("the document is cached");
2001 assert_eq!(
2002 cached.document.authentication.keys(),
2003 document.authentication.keys()
2004 );
2005
2006 let mut as_served = document.clone();
2007 as_served.authentication.jwks_uri_keys = None;
2008 assert_eq!(key, ClientKey::compute(&as_served));
2009 }
2010
2011 #[tokio::test(start_paused = true)]
2016 async fn a_jwks_uri_on_this_network_is_refused_before_the_fetch() {
2017 let addr = didbot_http::test_support::hung_server();
2018 let started = tokio::time::Instant::now();
2019 let err = fetch_jwks(
2020 &crate::oauth::client_metadata_http_client(),
2021 "https://client.example/id.json",
2022 &format!("https://{addr}/jwks.json"),
2023 LoopbackClients::Refused,
2024 )
2025 .await
2026 .expect_err("a real deployment does not fetch keys from its own network");
2027 assert!(
2028 matches!(err, ClientMetadataError::RefusedAddress(_)),
2029 "{err:?}"
2030 );
2031 assert_eq!(started.elapsed(), std::time::Duration::ZERO);
2032 }
2033}