1use anyhow::{bail, Context as _, Result};
21use serde_json::Value;
22
23const ES256: &str = "ES256";
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct AuthorizationServer {
29 pub issuer: String,
30 pub par_endpoint: String,
31 pub authorization_endpoint: String,
32 pub token_endpoint: String,
33 pub revocation_endpoint: Option<String>,
38}
39
40pub async fn discover(
58 http: &reqwest::Client,
59 pds_url: &str,
60 auth_method: &str,
61 expected_issuer: Option<&str>,
62) -> Result<AuthorizationServer> {
63 discover_with(
64 |url| async move {
65 super::fetch::get_json(http, &url, super::fetch::JSON)
66 .await
67 .with_context(|| format!("fetching {url}"))
68 },
69 pds_url,
70 auth_method,
71 expected_issuer,
72 )
73 .await
74}
75
76pub async fn discover_with<F, Fut>(
89 fetch: F,
90 pds_url: &str,
91 auth_method: &str,
92 expected_issuer: Option<&str>,
93) -> Result<AuthorizationServer>
94where
95 F: Fn(String) -> Fut,
96 Fut: std::future::Future<Output = Result<Value>>,
97{
98 let prm_url = protected_resource_url(pds_url)?;
99 let prm = fetch(prm_url).await?;
100
101 let issuer = validate_protected_resource(&prm, pds_url)?;
103 let asm_url = authorization_server_url(&issuer);
104 let asm = fetch(asm_url.clone()).await?;
105
106 let server = resolve_documents(&prm, &asm, &asm_url, pds_url, auth_method)?;
107
108 if let Some(expected) = expected_issuer {
113 super::session::same_issuer(&server.issuer, expected)?;
114 }
115 Ok(server)
116}
117
118pub fn protected_resource_url(pds_url: &str) -> Result<String> {
120 Ok(format!(
121 "{}/.well-known/oauth-protected-resource",
122 origin_of(pds_url)?
123 ))
124}
125
126pub fn authorization_server_url(issuer: &str) -> String {
133 format!("{issuer}/.well-known/oauth-authorization-server")
134}
135
136pub fn resolve_documents(
148 prm: &Value,
149 asm: &Value,
150 asm_fetched_from: &str,
151 pds_url: &str,
152 auth_method: &str,
153) -> Result<AuthorizationServer> {
154 let issuer = validate_protected_resource(prm, pds_url)?;
155
156 let expected = authorization_server_url(&issuer);
157 if asm_fetched_from != expected {
158 bail!(
159 "the authorization-server metadata was fetched from {asm_fetched_from:?}, not from \
160 the issuer's own {expected:?}; the issuer comparison would be meaningless"
161 );
162 }
163
164 validate_authorization_server(asm, &issuer, pds_url, auth_method)
167}
168
169pub fn origin_of(url: &str) -> Result<String> {
177 let parsed = url::Url::parse(url).with_context(|| format!("{url:?} is not a URL"))?;
178 let host = parsed
179 .host_str()
180 .with_context(|| format!("{url:?} has no host"))?;
181 Ok(match parsed.port() {
182 Some(port) => format!("{}://{host}:{port}", parsed.scheme()),
184 None => format!("{}://{host}", parsed.scheme()),
185 })
186}
187
188pub fn validate_issuer_form(issuer: &str) -> Result<()> {
195 let parsed = url::Url::parse(issuer)
196 .with_context(|| format!("issuer {issuer:?} is not an absolute URL"))?;
197 if parsed.scheme() != "https" {
198 bail!("issuer {issuer:?} must be https");
199 }
200 if !parsed.has_host() {
201 bail!("issuer {issuer:?} has no host");
202 }
203 if parsed.path() != "" && parsed.path() != "/" {
204 bail!("issuer {issuer:?} must have no path");
205 }
206 if issuer.ends_with('/') {
207 bail!("issuer {issuer:?} must not have a trailing slash");
208 }
209 if parsed.query().is_some() {
210 bail!("issuer {issuer:?} must have no query string");
211 }
212 if parsed.fragment().is_some() {
213 bail!("issuer {issuer:?} must have no fragment");
214 }
215 if !parsed.username().is_empty() || parsed.password().is_some() {
216 bail!("issuer {issuer:?} must not carry credentials");
217 }
218 let canonical = origin_of(issuer)?;
223 if issuer != canonical {
224 bail!("issuer {issuer:?} is not in canonical form (expected {canonical:?})");
225 }
226 Ok(())
227}
228
229pub fn validate_protected_resource(metadata: &Value, pds_url: &str) -> Result<String> {
240 let expected = origin_of(pds_url)?;
241 let resource = metadata
242 .get("resource")
243 .and_then(Value::as_str)
244 .context("protected-resource metadata has no `resource`")?;
245 if resource != expected {
246 bail!(
247 "protected-resource `resource` is {resource:?}, expected the PDS origin {expected:?}"
248 );
249 }
250
251 let servers = metadata
252 .get("authorization_servers")
253 .and_then(Value::as_array)
254 .context("protected-resource metadata has no `authorization_servers`")?;
255 if servers.len() != 1 {
256 bail!(
257 "atproto requires exactly one authorization server, found {}",
258 servers.len()
259 );
260 }
261 let issuer = servers[0]
262 .as_str()
263 .context("`authorization_servers` entry is not a string")?;
264 validate_issuer_form(issuer)?;
265 Ok(issuer.to_string())
266}
267
268fn require_listed(metadata: &Value, field: &str, wanted: &str) -> Result<()> {
270 let values = metadata
271 .get(field)
272 .and_then(Value::as_array)
273 .with_context(|| format!("authorization-server metadata has no `{field}`"))?;
274 if !values.iter().filter_map(Value::as_str).any(|v| v == wanted) {
275 bail!("authorization-server `{field}` does not include {wanted:?}");
276 }
277 Ok(())
278}
279
280fn require_absent(metadata: &Value, field: &str, forbidden: &str) -> Result<()> {
282 if let Some(values) = metadata.get(field).and_then(Value::as_array) {
283 if values
284 .iter()
285 .filter_map(Value::as_str)
286 .any(|v| v == forbidden)
287 {
288 bail!("authorization-server `{field}` includes {forbidden:?}, which is not allowed");
289 }
290 }
291 Ok(())
292}
293
294fn require_endpoint(metadata: &Value, field: &str, issuer: &str) -> Result<String> {
296 let raw = metadata
297 .get(field)
298 .and_then(Value::as_str)
299 .with_context(|| format!("authorization-server metadata has no `{field}`"))?;
300 let parsed = url::Url::parse(raw)
301 .with_context(|| format!("`{field}` {raw:?} is not an absolute URL"))?;
302 if parsed.scheme() != "https" {
303 bail!("`{field}` must be https, got {raw:?}");
304 }
305 let origin = origin_of(raw)?;
327 if origin != issuer {
328 bail!(
329 "`{field}` {raw:?} is on {origin:?}, not the issuer's own origin {issuer:?}; \
330 refusing to treat it as part of this authorization server"
331 );
332 }
333 Ok(raw.to_string())
334}
335
336fn optional_endpoint(metadata: &Value, field: &str, issuer: &str) -> Result<Option<String>> {
342 match metadata.get(field) {
343 None | Some(Value::Null) => Ok(None),
344 Some(_) => require_endpoint(metadata, field, issuer).map(Some),
345 }
346}
347
348pub fn validate_authorization_server(
354 metadata: &Value,
355 issuer: &str,
356 pds_url: &str,
357 auth_method: &str,
358) -> Result<AuthorizationServer> {
359 let declared = metadata
363 .get("issuer")
364 .and_then(Value::as_str)
365 .context("authorization-server metadata has no `issuer`")?;
366 if declared != issuer {
367 bail!("authorization-server declares issuer {declared:?} but was fetched from {issuer:?}");
368 }
369
370 if let Some(resources) = metadata
374 .get("protected_resources")
375 .and_then(Value::as_array)
376 {
377 let pds_origin = origin_of(pds_url)?;
378 if !resources
379 .iter()
380 .filter_map(Value::as_str)
381 .any(|r| r == pds_origin)
382 {
383 bail!("PDS {pds_origin:?} is not listed in the authorization server's protected_resources");
384 }
385 }
386
387 if metadata
390 .get("client_id_metadata_document_supported")
391 .and_then(Value::as_bool)
392 != Some(true)
393 {
394 bail!("authorization server does not support client-id metadata documents");
395 }
396 if metadata
398 .get("require_pushed_authorization_requests")
399 .and_then(Value::as_bool)
400 != Some(true)
401 {
402 bail!("authorization server does not require pushed authorization requests");
403 }
404 if metadata
409 .get("authorization_response_iss_parameter_supported")
410 .and_then(Value::as_bool)
411 != Some(true)
412 {
413 bail!("authorization server does not send the `iss` response parameter");
414 }
415
416 require_listed(metadata, "code_challenge_methods_supported", "S256")?;
417 require_absent(metadata, "code_challenge_methods_supported", "plain")?;
419 require_listed(metadata, "dpop_signing_alg_values_supported", ES256)?;
420 require_listed(metadata, "response_types_supported", "code")?;
421 require_listed(metadata, "grant_types_supported", "authorization_code")?;
422 require_listed(metadata, "grant_types_supported", "refresh_token")?;
423 require_listed(
424 metadata,
425 "token_endpoint_auth_methods_supported",
426 auth_method,
427 )?;
428 require_listed(metadata, "scopes_supported", "atproto")?;
429
430 if metadata
434 .get("token_endpoint_auth_signing_alg_values_supported")
435 .is_some()
436 {
437 require_listed(
438 metadata,
439 "token_endpoint_auth_signing_alg_values_supported",
440 ES256,
441 )?;
442 require_absent(
444 metadata,
445 "token_endpoint_auth_signing_alg_values_supported",
446 "none",
447 )?;
448 }
449
450 Ok(AuthorizationServer {
451 issuer: issuer.to_string(),
452 par_endpoint: require_endpoint(metadata, "pushed_authorization_request_endpoint", issuer)?,
453 authorization_endpoint: require_endpoint(metadata, "authorization_endpoint", issuer)?,
454 token_endpoint: require_endpoint(metadata, "token_endpoint", issuer)?,
455 revocation_endpoint: optional_endpoint(metadata, "revocation_endpoint", issuer)?,
456 })
457}
458
459#[cfg(test)]
460mod live_pds {
461 use super::*;
479
480 const SUBJECT: &str = "justin-stanley.com";
481 const PLC: &str = "https://plc.directory";
482
483 #[tokio::test]
484 #[ignore]
485 async fn discovery_round_trips_against_a_live_pds() -> anyhow::Result<()> {
486 let http = crate::feed::build_client()?;
487 let resolver = crate::oauth::resolve::resolver()?;
488
489 let account = crate::oauth::resolve::resolve(&resolver, &http, SUBJECT, PLC).await?;
490 println!("resolved {SUBJECT}");
491 println!(" did: {}", account.did);
492 println!(" handle verified: {:?}", account.handle);
493 println!(" pds: {}", account.pds_url);
494
495 let server = discover(&http, &account.pds_url, "private_key_jwt", None).await?;
496 println!("discovery:");
497 println!(" issuer: {}", server.issuer);
498 println!(" par: {}", server.par_endpoint);
499 println!(" authorization: {}", server.authorization_endpoint);
500 println!(" token: {}", server.token_endpoint);
501 println!(
502 " revocation: {}",
503 server.revocation_endpoint.as_deref().unwrap_or("(absent)")
504 );
505
506 assert!(
508 server.issuer.starts_with("https://"),
509 "issuer must be https"
510 );
511 assert!(
512 !server.par_endpoint.is_empty() && !server.token_endpoint.is_empty(),
513 "a PDS must advertise PAR and token endpoints"
514 );
515 let again = discover(
518 &http,
519 &account.pds_url,
520 "private_key_jwt",
521 Some(&server.issuer),
522 )
523 .await?;
524 assert_eq!(again.issuer, server.issuer);
525
526 let bad = discover(
529 &http,
530 &account.pds_url,
531 "private_key_jwt",
532 Some("https://evil.example"),
533 )
534 .await;
535 let err = format!(
541 "{:#}",
542 bad.expect_err("a mismatched issuer must be refused")
543 );
544 assert!(
545 err.contains("different authorization server")
546 && err.contains("refusing to send credentials"),
547 "refused, but not BY the mix-up defence — this test would not notice \
548 if that check stopped firing: {err}"
549 );
550 println!(" mix-up defence: refused a mismatched issuer, by `same_issuer`");
551
552 match server.revocation_endpoint {
554 Some(ref r) => println!("\n => this PDS SUPPORTS RFC 7009 revocation at {r}"),
555 None => println!("\n => this PDS advertises NO revocation endpoint"),
556 }
557 Ok(())
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use serde_json::json;
565
566 #[test]
578 fn an_endpoint_on_a_foreign_origin_is_refused() {
579 for field in [
580 "pushed_authorization_request_endpoint",
581 "authorization_endpoint",
582 "token_endpoint",
583 "revocation_endpoint",
584 ] {
585 let mut asm = as_metadata();
586 asm[field] = json!("https://totally-other.example/authorize");
587 let err = match validate_authorization_server(&asm, ISS, PDS, "none") {
588 Err(err) => err,
589 Ok(_) => panic!("accepted a foreign-origin {field}"),
590 };
591 let rendered = format!("{err:#}");
592 assert!(
593 rendered.contains("issuer's own origin"),
594 "{field} failed for the wrong reason: {rendered}"
595 );
596 }
597 }
598
599 #[test]
603 fn an_absent_revocation_endpoint_is_tolerated() {
604 assert_eq!(
605 optional_endpoint(
606 &json!({}),
607 "revocation_endpoint",
608 "https://auth.example.com"
609 )
610 .unwrap(),
611 None
612 );
613 assert_eq!(
614 optional_endpoint(
615 &json!({ "revocation_endpoint": null }),
616 "revocation_endpoint",
617 "https://auth.example.com"
618 )
619 .unwrap(),
620 None
621 );
622 }
623
624 #[test]
631 fn a_malformed_revocation_endpoint_is_an_error_rather_than_absent() {
632 let plain_http = json!({ "revocation_endpoint": "http://auth.example.com/revoke" });
633 let err = optional_endpoint(
634 &plain_http,
635 "revocation_endpoint",
636 "https://auth.example.com",
637 )
638 .expect_err("plain http must be refused");
639 assert!(format!("{err:#}").contains("must be https"));
640
641 let relative = json!({ "revocation_endpoint": "/revoke" });
642 assert!(
643 optional_endpoint(&relative, "revocation_endpoint", "https://auth.example.com")
644 .is_err()
645 );
646
647 let wrong_type = json!({ "revocation_endpoint": 42 });
648 assert!(optional_endpoint(
649 &wrong_type,
650 "revocation_endpoint",
651 "https://auth.example.com"
652 )
653 .is_err());
654 }
655
656 const PDS: &str = "https://pds.example.com";
657 const ISS: &str = "https://auth.example.com";
658
659 fn protected_resource() -> serde_json::Value {
660 json!({ "resource": PDS, "authorization_servers": [ISS] })
661 }
662
663 fn as_metadata() -> serde_json::Value {
664 json!({
665 "issuer": ISS,
666 "pushed_authorization_request_endpoint": format!("{ISS}/par"),
667 "authorization_endpoint": format!("{ISS}/authorize"),
668 "token_endpoint": format!("{ISS}/token"),
669 "client_id_metadata_document_supported": true,
670 "require_pushed_authorization_requests": true,
671 "code_challenge_methods_supported": ["S256"],
672 "dpop_signing_alg_values_supported": ["ES256"],
673 "token_endpoint_auth_methods_supported": ["none", "private_key_jwt"],
674 "token_endpoint_auth_signing_alg_values_supported": ["ES256"],
675 "response_types_supported": ["code"],
676 "grant_types_supported": ["authorization_code", "refresh_token"],
677 "scopes_supported": ["atproto", "transition:generic"],
678 "authorization_response_iss_parameter_supported": true
679 })
680 }
681
682 #[test]
685 fn a_well_formed_issuer_is_accepted() {
686 assert!(validate_issuer_form("https://auth.example.com").is_ok());
687 assert!(validate_issuer_form("https://auth.example.com:8443").is_ok());
688 }
689
690 #[test]
693 fn a_non_canonical_issuer_is_rejected() {
694 for issuer in [
695 "http://auth.example.com", "https://auth.example.com/", "https://auth.example.com/path", "https://auth.example.com?a=1", "https://auth.example.com#f", "https://u:p@auth.example.com", "auth.example.com", "",
703 "https://auth.example.com:443",
705 "https://AUTH.example.com",
708 "https://auth%2eexample.com",
709 ] {
710 assert!(validate_issuer_form(issuer).is_err(), "accepted {issuer:?}");
711 }
712 }
713
714 #[test]
718 fn origin_of_drops_userinfo_path_query_and_default_ports() {
719 assert_eq!(origin_of("https://u:p@pds.example.com/x").unwrap(), PDS);
720 assert_eq!(origin_of("https://pds.example.com/a/b?c=1#d").unwrap(), PDS);
721 assert_eq!(origin_of("https://pds.example.com:443").unwrap(), PDS);
722 assert_eq!(
723 origin_of("https://pds.example.com:8443").unwrap(),
724 "https://pds.example.com:8443"
725 );
726 }
727
728 #[test]
731 fn a_valid_protected_resource_document_yields_its_issuer() {
732 assert_eq!(
733 validate_protected_resource(&protected_resource(), PDS).unwrap(),
734 ISS
735 );
736 }
737
738 #[test]
741 fn the_resource_must_equal_the_pds_origin() {
742 let mut doc = protected_resource();
743 doc["resource"] = json!("https://other.example.com");
744 assert!(validate_protected_resource(&doc, PDS).is_err());
745 }
746
747 #[test]
750 fn the_resource_comparison_uses_the_origin_of_the_pds_url() {
751 assert!(
752 validate_protected_resource(&protected_resource(), "https://pds.example.com/xrpc")
753 .is_ok()
754 );
755 assert!(
756 validate_protected_resource(&protected_resource(), "https://pds.example.com/").is_ok()
757 );
758 }
759
760 #[test]
765 fn exactly_one_authorization_server_is_required() {
766 let mut doc = protected_resource();
767 doc["authorization_servers"] = json!([ISS, "https://evil.example.com"]);
768 assert!(validate_protected_resource(&doc, PDS).is_err());
769
770 doc["authorization_servers"] = json!([]);
771 assert!(validate_protected_resource(&doc, PDS).is_err());
772
773 doc.as_object_mut().unwrap().remove("authorization_servers");
774 assert!(validate_protected_resource(&doc, PDS).is_err());
775 }
776
777 #[test]
778 fn the_named_issuer_must_itself_be_well_formed() {
779 let mut doc = protected_resource();
780 doc["authorization_servers"] = json!(["https://auth.example.com/path"]);
781 assert!(validate_protected_resource(&doc, PDS).is_err());
782 }
783
784 #[test]
787 fn a_valid_as_document_yields_its_endpoints() {
788 let server =
789 validate_authorization_server(&as_metadata(), ISS, PDS, "private_key_jwt").unwrap();
790 assert_eq!(server.issuer, ISS);
791 assert_eq!(server.par_endpoint, format!("{ISS}/par"));
792 assert_eq!(server.authorization_endpoint, format!("{ISS}/authorize"));
793 assert_eq!(server.token_endpoint, format!("{ISS}/token"));
794 }
795
796 #[test]
801 fn the_documents_issuer_must_equal_the_url_it_came_from() {
802 let mut doc = as_metadata();
803 doc["issuer"] = json!("https://someone-else.example.com");
804 assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_err());
805 }
806
807 #[test]
810 fn a_declared_protected_resources_list_must_contain_the_pds() {
811 let mut doc = as_metadata();
812 doc["protected_resources"] = json!(["https://other.example.com"]);
813 assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_err());
814
815 doc["protected_resources"] = json!([PDS]);
816 assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_ok());
817 }
818
819 #[test]
821 fn an_absent_protected_resources_list_is_not_an_error() {
822 let doc = as_metadata();
823 assert!(doc.get("protected_resources").is_none());
824 assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_ok());
825 }
826
827 #[test]
830 fn required_capabilities_are_enforced() {
831 let cases: &[(&str, serde_json::Value)] = &[
832 ("client_id_metadata_document_supported", json!(false)),
833 ("code_challenge_methods_supported", json!(["plain"])),
834 ("dpop_signing_alg_values_supported", json!(["ES384"])),
835 ("response_types_supported", json!(["token"])),
836 ("grant_types_supported", json!(["authorization_code"])),
837 (
838 "token_endpoint_auth_methods_supported",
839 json!(["client_secret_basic"]),
840 ),
841 (
842 "token_endpoint_auth_signing_alg_values_supported",
843 json!(["RS256"]),
844 ),
845 ("scopes_supported", json!(["transition:generic"])),
847 ("require_pushed_authorization_requests", json!(false)),
848 (
849 "authorization_response_iss_parameter_supported",
850 json!(false),
851 ),
852 ("code_challenge_methods_supported", json!(["S256", "plain"])),
854 (
856 "token_endpoint_auth_signing_alg_values_supported",
857 json!(["ES256", "none"]),
858 ),
859 ];
860 for (field, bad) in cases {
861 let mut doc = as_metadata();
862 doc[*field] = bad.clone();
863 assert!(
864 validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_err(),
865 "accepted {field} = {bad}"
866 );
867 }
868 }
869
870 #[test]
876 fn every_field_in_the_fixture_is_load_bearing() {
877 const OPTIONAL: &str = "token_endpoint_auth_signing_alg_values_supported";
882
883 let base = as_metadata();
884 for field in base.as_object().unwrap().keys() {
885 let mut doc = base.clone();
886 doc.as_object_mut().unwrap().remove(field);
887 let result = validate_authorization_server(&doc, ISS, PDS, "private_key_jwt");
888 if field == OPTIONAL {
889 assert!(result.is_ok(), "`{field}` is documented as optional");
890 } else {
891 assert!(
892 result.is_err(),
893 "removing `{field}` changed nothing -- it is unchecked, or it \
894 does not belong in the fixture"
895 );
896 }
897 }
898 }
899
900 #[test]
901 fn a_missing_required_endpoint_is_rejected() {
902 for field in [
903 "pushed_authorization_request_endpoint",
904 "authorization_endpoint",
905 "token_endpoint",
906 ] {
907 let mut doc = as_metadata();
908 doc.as_object_mut().unwrap().remove(field);
909 assert!(
910 validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_err(),
911 "accepted a document with no {field}"
912 );
913 }
914 }
915
916 #[test]
920 fn an_absent_signing_alg_list_defaults_to_es256_rather_than_failing() {
921 let mut doc = as_metadata();
922 doc.as_object_mut()
923 .unwrap()
924 .remove("token_endpoint_auth_signing_alg_values_supported");
925 assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_ok());
926 }
927
928 #[test]
933 fn the_negotiated_auth_method_must_be_supported() {
934 let mut doc = as_metadata();
935 doc["token_endpoint_auth_methods_supported"] = json!(["private_key_jwt"]);
936 assert!(validate_authorization_server(&doc, ISS, PDS, "none").is_err());
937 assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_ok());
938
939 doc["token_endpoint_auth_methods_supported"] = json!(["none"]);
940 assert!(validate_authorization_server(&doc, ISS, PDS, "none").is_ok());
941 assert!(validate_authorization_server(&doc, ISS, PDS, "private_key_jwt").is_err());
942 }
943
944 #[test]
948 fn endpoints_must_be_absolute_https_urls() {
949 for field in [
950 "pushed_authorization_request_endpoint",
951 "authorization_endpoint",
952 "token_endpoint",
953 ] {
954 for bad in ["/par", "http://auth.example.com/par", "not a url"] {
955 let mut doc = as_metadata();
956 doc[field] = json!(bad);
957 let err = validate_authorization_server(&doc, ISS, PDS, "private_key_jwt")
958 .expect_err(&format!("accepted {field} = {bad}"));
959 if bad.starts_with("http://") {
967 assert!(
968 format!("{err:#}").contains("must be https"),
969 "{field} = {bad} was refused for the wrong reason: {err:#}"
970 );
971 }
972 }
973 }
974 }
975 #[test]
988 fn a_real_pds_passes_the_preflight() {
989 const REAL_PROTECTED_RESOURCE: &str = r#"{"resource":"https://pds.justin-stanley.com","authorization_servers":["https://pds.justin-stanley.com"],"scopes_supported":[],"bearer_methods_supported":["header"],"resource_documentation":"https://atproto.com"}"#;
990 const REAL_AUTHORIZATION_SERVER: &str = r#"{"issuer":"https://pds.justin-stanley.com","request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"scopes_supported":["atproto","transition:email","transition:generic","transition:chat.bsky"],"subject_types_supported":["public"],"response_types_supported":["code"],"response_modes_supported":["query","fragment","form_post"],"grant_types_supported":["authorization_code","refresh_token"],"code_challenge_methods_supported":["S256"],"ui_locales_supported":["en-US"],"display_values_supported":["page","popup","touch"],"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512","none"],"authorization_response_iss_parameter_supported":true,"request_object_encryption_alg_values_supported":[],"request_object_encryption_enc_values_supported":[],"jwks_uri":"https://pds.justin-stanley.com/oauth/jwks","authorization_endpoint":"https://pds.justin-stanley.com/oauth/authorize","token_endpoint":"https://pds.justin-stanley.com/oauth/token","token_endpoint_auth_methods_supported":["none","private_key_jwt"],"token_endpoint_auth_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"revocation_endpoint":"https://pds.justin-stanley.com/oauth/revoke","pushed_authorization_request_endpoint":"https://pds.justin-stanley.com/oauth/par","require_pushed_authorization_requests":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES256K","ES384","ES512"],"protected_resources":["https://pds.justin-stanley.com"],"client_id_metadata_document_supported":true,"prompt_values_supported":["none","login","consent","select_account","create"]}"#;
991 let pds = "https://pds.justin-stanley.com";
992
993 let prm: Value = serde_json::from_str(REAL_PROTECTED_RESOURCE).unwrap();
994 let issuer = validate_protected_resource(&prm, pds).unwrap();
995 assert_eq!(issuer, pds);
996
997 let asm: Value = serde_json::from_str(REAL_AUTHORIZATION_SERVER).unwrap();
998 for method in ["none", "private_key_jwt"] {
1001 let server = validate_authorization_server(&asm, &issuer, pds, method)
1002 .unwrap_or_else(|e| panic!("a real PDS was rejected for {method}: {e:#}"));
1003 assert_eq!(
1004 server.par_endpoint,
1005 "https://pds.justin-stanley.com/oauth/par"
1006 );
1007 assert_eq!(
1008 server.authorization_endpoint,
1009 "https://pds.justin-stanley.com/oauth/authorize"
1010 );
1011 assert_eq!(
1012 server.token_endpoint,
1013 "https://pds.justin-stanley.com/oauth/token"
1014 );
1015 assert_eq!(
1021 server.revocation_endpoint.as_deref(),
1022 Some("https://pds.justin-stanley.com/oauth/revoke")
1023 );
1024 }
1025 }
1026
1027 fn prm_naming(issuer: &str) -> serde_json::Value {
1030 json!({ "resource": PDS, "authorization_servers": [issuer] })
1031 }
1032
1033 #[test]
1042 fn the_expected_issuer_comes_from_the_protected_resource_document() {
1043 let other = "https://evil.example";
1046 let mut asm = as_metadata();
1047 asm["issuer"] = json!(other);
1048 for field in [
1049 "pushed_authorization_request_endpoint",
1050 "authorization_endpoint",
1051 "token_endpoint",
1052 ] {
1053 asm[field] = json!(format!("{other}/x"));
1054 }
1055
1056 let err = match resolve_documents(
1057 &prm_naming(ISS),
1058 &asm,
1059 &authorization_server_url(ISS),
1060 PDS,
1061 "none",
1062 ) {
1063 Err(err) => err,
1064 Ok(_) => panic!("a self-consistent impostor was accepted"),
1065 };
1066 assert!(
1067 format!("{err:#}").contains("issuer"),
1068 "failed for the wrong reason: {err:#}"
1069 );
1070 }
1071
1072 #[test]
1078 fn metadata_fetched_from_the_wrong_place_is_refused() {
1079 let err = match resolve_documents(
1080 &prm_naming(ISS),
1081 &as_metadata(),
1082 &authorization_server_url(PDS),
1084 PDS,
1085 "none",
1086 ) {
1087 Err(err) => err,
1088 Ok(_) => panic!("metadata from the wrong origin was accepted"),
1089 };
1090 assert!(format!("{err:#}").contains("issuer's own"), "{err:#}");
1091
1092 resolve_documents(
1094 &prm_naming(ISS),
1095 &as_metadata(),
1096 &authorization_server_url(ISS),
1097 PDS,
1098 "none",
1099 )
1100 .expect("the honest pair must resolve");
1101 }
1102
1103 #[test]
1105 fn the_discovery_urls_come_from_the_right_inputs() {
1106 assert_eq!(
1107 protected_resource_url("https://pds.example.com/xrpc/x").unwrap(),
1108 "https://pds.example.com/.well-known/oauth-protected-resource"
1109 );
1110 assert_eq!(
1111 authorization_server_url(ISS),
1112 format!("{ISS}/.well-known/oauth-authorization-server")
1113 );
1114 }
1115
1116 struct Fetcher {
1120 docs: std::collections::HashMap<String, serde_json::Value>,
1121 asked: std::sync::Mutex<Vec<String>>,
1122 }
1123
1124 impl Fetcher {
1125 fn new(pairs: &[(&str, serde_json::Value)]) -> Self {
1126 Self {
1127 docs: pairs
1128 .iter()
1129 .map(|(u, d)| ((*u).to_string(), d.clone()))
1130 .collect(),
1131 asked: std::sync::Mutex::new(Vec::new()),
1132 }
1133 }
1134
1135 async fn get(&self, url: String) -> Result<serde_json::Value> {
1136 self.asked.lock().unwrap().push(url.clone());
1137 self.docs
1138 .get(&url)
1139 .cloned()
1140 .ok_or_else(|| anyhow::anyhow!("nothing served at {url}"))
1141 }
1142
1143 fn asked(&self) -> Vec<String> {
1144 self.asked.lock().unwrap().clone()
1145 }
1146 }
1147
1148 #[tokio::test]
1155 async fn the_metadata_is_fetched_from_the_issuers_own_location() {
1156 let fetcher = Fetcher::new(&[
1157 (
1158 &protected_resource_url(PDS).unwrap(),
1159 json!({ "resource": PDS, "authorization_servers": [ISS] }),
1160 ),
1161 (&authorization_server_url(ISS), as_metadata()),
1162 ]);
1163
1164 let server = discover_with(|url| fetcher.get(url), PDS, "none", None)
1165 .await
1166 .expect("the honest pair must resolve");
1167 assert_eq!(server.issuer, ISS);
1168
1169 assert_eq!(
1170 fetcher.asked(),
1171 vec![
1172 protected_resource_url(PDS).unwrap(),
1173 authorization_server_url(ISS),
1174 ],
1175 "discovery asked for the wrong locations, or in the wrong order"
1176 );
1177 }
1178
1179 #[tokio::test]
1195 async fn a_repointed_pds_cannot_move_an_existing_grant() {
1196 let attacker = "https://as.attacker.example";
1201 let mut moved = as_metadata();
1202 moved["issuer"] = json!(attacker);
1203 moved["pushed_authorization_request_endpoint"] = json!(format!("{attacker}/par"));
1204 moved["authorization_endpoint"] = json!(format!("{attacker}/authorize"));
1205 moved["token_endpoint"] = json!(format!("{attacker}/token"));
1206 moved["revocation_endpoint"] = json!(format!("{attacker}/revoke"));
1207
1208 let fetcher = Fetcher::new(&[
1209 (
1210 &protected_resource_url(PDS).unwrap(),
1211 json!({ "resource": PDS, "authorization_servers": [attacker] }),
1212 ),
1213 (&authorization_server_url(attacker), moved),
1214 ]);
1215
1216 discover_with(|url| fetcher.get(url), PDS, "none", None)
1218 .await
1219 .expect("a self-consistent pair must resolve when there is no grant yet");
1220
1221 let err = discover_with(|url| fetcher.get(url), PDS, "none", Some(ISS))
1222 .await
1223 .expect_err("a grant issued by ISS must not follow the PDS to a new issuer");
1224 let msg = err.to_string();
1225 assert!(
1226 msg.contains(ISS) || msg.contains(attacker),
1227 "the error should name the issuers it compared, got: {msg}"
1228 );
1229 }
1230
1231 #[tokio::test]
1235 async fn the_issuer_carried_forward_is_the_one_the_pds_named() {
1236 let other = "https://other.example";
1237 let mut impostor = as_metadata();
1238 impostor["issuer"] = json!(other);
1239
1240 let fetcher = Fetcher::new(&[
1241 (
1242 &protected_resource_url(PDS).unwrap(),
1243 json!({ "resource": PDS, "authorization_servers": [ISS] }),
1244 ),
1245 (&authorization_server_url(ISS), impostor),
1248 ]);
1249
1250 let err = match discover_with(|url| fetcher.get(url), PDS, "none", None).await {
1251 Err(err) => err,
1252 Ok(_) => panic!("a document claiming a different issuer was accepted"),
1253 };
1254 assert!(format!("{err:#}").contains("issuer"), "{err:#}");
1255 }
1256
1257 #[tokio::test]
1260 async fn a_missing_document_fails_the_discovery() {
1261 let fetcher = Fetcher::new(&[(
1263 &protected_resource_url(PDS).unwrap(),
1264 json!({ "resource": PDS, "authorization_servers": [ISS] }),
1265 )]);
1266 assert!(discover_with(|url| fetcher.get(url), PDS, "none", None)
1267 .await
1268 .is_err());
1269
1270 let empty = Fetcher::new(&[]);
1272 assert!(discover_with(|url| empty.get(url), PDS, "none", None)
1273 .await
1274 .is_err());
1275 }
1276}