1use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22use std::fmt;
23use url::Url;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum MethodPathError {
32 Empty,
34 InvalidFormat(String),
36}
37
38impl fmt::Display for MethodPathError {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 match self {
41 Self::Empty => write!(f, "method path is empty"),
42 Self::InvalidFormat(s) => write!(
43 f,
44 "method path {:?} does not match `^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+$`",
45 s
46 ),
47 }
48 }
49}
50
51impl std::error::Error for MethodPathError {}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum IssuerUrlError {
56 InsecureScheme(String),
58}
59
60impl fmt::Display for IssuerUrlError {
61 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62 match self {
63 Self::InsecureScheme(u) => write!(
64 f,
65 "issuer URL {:?} must be https:// (or http://localhost)",
66 u
67 ),
68 }
69 }
70}
71
72impl std::error::Error for IssuerUrlError {}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub enum CookieNameError {
77 Empty,
79 InvalidToken(String),
81}
82
83impl fmt::Display for CookieNameError {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 match self {
86 Self::Empty => write!(f, "cookie name is empty"),
87 Self::InvalidToken(s) => write!(
88 f,
89 "cookie name {:?} contains characters outside RFC 6265 token set",
90 s
91 ),
92 }
93 }
94}
95
96impl std::error::Error for CookieNameError {}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub enum HeaderNameError {
101 Empty,
103 InvalidToken(String),
105}
106
107impl fmt::Display for HeaderNameError {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 match self {
110 Self::Empty => write!(f, "header name is empty"),
111 Self::InvalidToken(s) => write!(
112 f,
113 "header name {:?} contains characters outside RFC 7230 token set",
114 s
115 ),
116 }
117 }
118}
119
120impl std::error::Error for HeaderNameError {}
121
122#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum ClientIdError {
125 Empty,
127}
128
129impl fmt::Display for ClientIdError {
130 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131 match self {
132 Self::Empty => write!(f, "client_id is empty"),
133 }
134 }
135}
136
137impl std::error::Error for ClientIdError {}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum BackendAuthCapabilitiesError {
142 DefaultOutOfRange { default: usize, len: usize },
144}
145
146impl fmt::Display for BackendAuthCapabilitiesError {
147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148 match self {
149 Self::DefaultOutOfRange { default, len } => write!(
150 f,
151 "default index {} is out of range for mechanisms (len = {})",
152 default, len
153 ),
154 }
155 }
156}
157
158impl std::error::Error for BackendAuthCapabilitiesError {}
159
160#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
174#[serde(transparent)]
175pub struct MethodPath(String);
176
177impl MethodPath {
178 pub fn try_new(s: impl Into<String>) -> Result<Self, MethodPathError> {
180 let s = s.into();
181 if s.is_empty() {
182 return Err(MethodPathError::Empty);
183 }
184 if !is_valid_method_path(&s) {
185 return Err(MethodPathError::InvalidFormat(s));
186 }
187 Ok(Self(s))
188 }
189
190 pub fn as_str(&self) -> &str {
192 &self.0
193 }
194
195 pub fn activation(&self) -> &str {
198 self.0.split('.').next().expect("validated: at least one segment")
199 }
200
201 pub fn method(&self) -> &str {
204 self.0.rsplit('.').next().expect("validated: at least one segment")
205 }
206}
207
208impl fmt::Display for MethodPath {
209 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210 f.write_str(&self.0)
211 }
212}
213
214fn is_valid_method_path(s: &str) -> bool {
215 let mut segments = s.split('.');
218 let first = match segments.next() {
219 Some(seg) => seg,
220 None => return false,
221 };
222 if !is_valid_path_segment(first) {
223 return false;
224 }
225 let mut has_second_segment = false;
226 for seg in segments {
227 has_second_segment = true;
228 if !is_valid_path_segment(seg) {
229 return false;
230 }
231 }
232 has_second_segment
233}
234
235fn is_valid_path_segment(seg: &str) -> bool {
236 let mut chars = seg.chars();
237 let first = match chars.next() {
238 Some(c) => c,
239 None => return false, };
241 if !first.is_ascii_lowercase() {
242 return false;
243 }
244 for c in chars {
245 if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') {
246 return false;
247 }
248 }
249 true
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
264#[serde(transparent)]
265pub struct IssuerUrl(#[schemars(with = "String")] Url);
266
267impl IssuerUrl {
268 pub fn try_new(u: Url) -> Result<Self, IssuerUrlError> {
270 let scheme = u.scheme();
271 let is_https = scheme == "https";
272 let is_localhost_http = scheme == "http"
273 && u
274 .host_str()
275 .map(|h| h == "localhost" || h == "127.0.0.1" || h == "[::1]")
276 .unwrap_or(false);
277 if !(is_https || is_localhost_http) {
278 return Err(IssuerUrlError::InsecureScheme(u.to_string()));
279 }
280 Ok(Self(u))
281 }
282
283 pub fn as_url(&self) -> &Url {
285 &self.0
286 }
287}
288
289impl fmt::Display for IssuerUrl {
290 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291 f.write_str(self.0.as_str())
292 }
293}
294
295impl std::hash::Hash for IssuerUrl {
296 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
297 self.0.as_str().hash(state);
298 }
299}
300
301#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
309#[serde(transparent)]
310pub struct ClientId(String);
311
312impl ClientId {
313 pub fn try_new(s: impl Into<String>) -> Result<Self, ClientIdError> {
315 let s = s.into();
316 if s.is_empty() {
317 return Err(ClientIdError::Empty);
318 }
319 Ok(Self(s))
320 }
321
322 pub fn as_str(&self) -> &str {
324 &self.0
325 }
326}
327
328impl fmt::Display for ClientId {
329 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
330 f.write_str(&self.0)
331 }
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
344#[serde(transparent)]
345pub struct CookieName(String);
346
347impl CookieName {
348 pub fn try_new(s: impl Into<String>) -> Result<Self, CookieNameError> {
350 let s = s.into();
351 if s.is_empty() {
352 return Err(CookieNameError::Empty);
353 }
354 if !s.bytes().all(is_rfc7230_token_byte) {
355 return Err(CookieNameError::InvalidToken(s));
356 }
357 Ok(Self(s))
358 }
359
360 pub fn as_str(&self) -> &str {
362 &self.0
363 }
364}
365
366impl fmt::Display for CookieName {
367 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368 f.write_str(&self.0)
369 }
370}
371
372fn is_rfc7230_token_byte(b: u8) -> bool {
378 matches!(
379 b,
380 b'!' | b'#'
381 | b'$'
382 | b'%'
383 | b'&'
384 | b'\''
385 | b'*'
386 | b'+'
387 | b'-'
388 | b'.'
389 | b'^'
390 | b'_'
391 | b'`'
392 | b'|'
393 | b'~'
394 | b'0'..=b'9'
395 | b'A'..=b'Z'
396 | b'a'..=b'z'
397 )
398}
399
400#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
410#[serde(transparent)]
411pub struct HeaderName(String);
412
413impl HeaderName {
414 pub fn try_new(s: impl Into<String>) -> Result<Self, HeaderNameError> {
417 let s = s.into();
418 if s.is_empty() {
419 return Err(HeaderNameError::Empty);
420 }
421 if !s.bytes().all(is_rfc7230_token_byte) {
422 return Err(HeaderNameError::InvalidToken(s));
423 }
424 Ok(Self(s.to_ascii_lowercase()))
425 }
426
427 pub fn as_str(&self) -> &str {
429 &self.0
430 }
431}
432
433impl fmt::Display for HeaderName {
434 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
435 f.write_str(&self.0)
436 }
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
449#[serde(tag = "kind", rename_all = "snake_case")]
450pub enum AuthMechanism {
451 Bearer {
454 header: HeaderName,
456 },
457
458 Cookie {
461 cookie: CookieName,
463 login: MethodPath,
465 #[serde(skip_serializing_if = "Option::is_none", default)]
467 refresh: Option<MethodPath>,
468 #[serde(skip_serializing_if = "Option::is_none", default)]
470 logout: Option<MethodPath>,
471 },
472
473 Oidc {
475 issuer: IssuerUrl,
477 client_id: ClientId,
479 #[serde(skip_serializing_if = "Option::is_none", default)]
482 exchange: Option<MethodPath>,
483 request_scopes: Vec<String>,
487 },
488
489 Anonymous,
498}
499
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
509pub struct BackendAuthCapabilities {
510 pub mechanisms: Vec<AuthMechanism>,
515
516 #[serde(skip_serializing_if = "Option::is_none", default)]
519 pub default: Option<usize>,
520}
521
522impl BackendAuthCapabilities {
523 pub fn new(
526 mechanisms: Vec<AuthMechanism>,
527 default: Option<usize>,
528 ) -> Result<Self, BackendAuthCapabilitiesError> {
529 if let Some(idx) = default {
530 if idx >= mechanisms.len() {
531 return Err(BackendAuthCapabilitiesError::DefaultOutOfRange {
532 default: idx,
533 len: mechanisms.len(),
534 });
535 }
536 }
537 Ok(Self {
538 mechanisms,
539 default,
540 })
541 }
542
543 pub fn anonymous_default() -> Self {
550 Self {
551 mechanisms: vec![AuthMechanism::Anonymous],
552 default: None,
553 }
554 }
555}
556
557#[cfg(test)]
562mod tests {
563 use super::*;
564 use serde_json::json;
565
566 #[test]
569 fn method_path_accepts_two_segments() {
570 let m = MethodPath::try_new("auth.login").expect("valid");
571 assert_eq!(m.as_str(), "auth.login");
572 assert_eq!(m.activation(), "auth");
573 assert_eq!(m.method(), "login");
574 }
575
576 #[test]
577 fn method_path_accepts_three_segments() {
578 let m = MethodPath::try_new("cone.threads.list").expect("valid");
579 assert_eq!(m.activation(), "cone");
580 assert_eq!(m.method(), "list");
581 }
582
583 #[test]
584 fn method_path_accepts_underscored_segments() {
585 MethodPath::try_new("cone.send_message").expect("underscores ok");
586 MethodPath::try_new("a_b.c_d_e").expect("multiple underscores ok");
587 }
588
589 #[test]
590 fn method_path_rejects_single_segment() {
591 assert!(matches!(
592 MethodPath::try_new("login"),
593 Err(MethodPathError::InvalidFormat(_))
594 ));
595 }
596
597 #[test]
598 fn method_path_rejects_uppercase() {
599 assert!(matches!(
600 MethodPath::try_new("Auth.login"),
601 Err(MethodPathError::InvalidFormat(_))
602 ));
603 assert!(matches!(
604 MethodPath::try_new("auth.Login"),
605 Err(MethodPathError::InvalidFormat(_))
606 ));
607 }
608
609 #[test]
610 fn method_path_rejects_leading_digit() {
611 assert!(matches!(
612 MethodPath::try_new("1auth.login"),
613 Err(MethodPathError::InvalidFormat(_))
614 ));
615 assert!(matches!(
616 MethodPath::try_new("auth.2login"),
617 Err(MethodPathError::InvalidFormat(_))
618 ));
619 }
620
621 #[test]
622 fn method_path_rejects_trailing_dot() {
623 assert!(matches!(
624 MethodPath::try_new("auth."),
625 Err(MethodPathError::InvalidFormat(_))
626 ));
627 }
628
629 #[test]
630 fn method_path_rejects_leading_dot() {
631 assert!(matches!(
632 MethodPath::try_new(".login"),
633 Err(MethodPathError::InvalidFormat(_))
634 ));
635 }
636
637 #[test]
638 fn method_path_rejects_wildcard() {
639 assert!(matches!(
641 MethodPath::try_new("auth.*"),
642 Err(MethodPathError::InvalidFormat(_))
643 ));
644 assert!(matches!(
645 MethodPath::try_new("*"),
646 Err(MethodPathError::InvalidFormat(_))
647 ));
648 }
649
650 #[test]
651 fn method_path_rejects_empty() {
652 assert_eq!(MethodPath::try_new(""), Err(MethodPathError::Empty));
653 }
654
655 #[test]
656 fn method_path_rejects_double_dot() {
657 assert!(matches!(
658 MethodPath::try_new("auth..login"),
659 Err(MethodPathError::InvalidFormat(_))
660 ));
661 }
662
663 #[test]
664 fn method_path_serde_roundtrip() {
665 let m = MethodPath::try_new("auth.login").unwrap();
666 let s = serde_json::to_string(&m).unwrap();
667 assert_eq!(s, "\"auth.login\"");
668 let back: MethodPath = serde_json::from_str(&s).unwrap();
669 assert_eq!(back, m);
670 }
671
672 #[test]
675 fn issuer_url_accepts_https() {
676 let u: Url = "https://accounts.example.com/".parse().unwrap();
677 let i = IssuerUrl::try_new(u.clone()).expect("https ok");
678 assert_eq!(i.as_url(), &u);
679 }
680
681 #[test]
682 fn issuer_url_accepts_localhost_http() {
683 let u: Url = "http://localhost:8080/".parse().unwrap();
684 IssuerUrl::try_new(u).expect("http://localhost ok");
685 let u2: Url = "http://127.0.0.1:8080/".parse().unwrap();
686 IssuerUrl::try_new(u2).expect("http://127.0.0.1 ok");
687 let u3: Url = "http://[::1]:8080/".parse().unwrap();
688 IssuerUrl::try_new(u3).expect("http://[::1] ok");
689 }
690
691 #[test]
692 fn issuer_url_rejects_non_localhost_http() {
693 let u: Url = "http://accounts.example.com/".parse().unwrap();
694 assert!(matches!(
695 IssuerUrl::try_new(u),
696 Err(IssuerUrlError::InsecureScheme(_))
697 ));
698 }
699
700 #[test]
701 fn issuer_url_rejects_other_schemes() {
702 let u: Url = "ftp://example.com/".parse().unwrap();
703 assert!(matches!(
704 IssuerUrl::try_new(u),
705 Err(IssuerUrlError::InsecureScheme(_))
706 ));
707 }
708
709 #[test]
710 fn issuer_url_serde_roundtrip() {
711 let u: Url = "https://accounts.example.com/".parse().unwrap();
712 let i = IssuerUrl::try_new(u).unwrap();
713 let s = serde_json::to_string(&i).unwrap();
714 assert_eq!(s, "\"https://accounts.example.com/\"");
715 let back: IssuerUrl = serde_json::from_str(&s).unwrap();
716 assert_eq!(back, i);
717 }
718
719 #[test]
722 fn client_id_accepts_non_empty() {
723 let c = ClientId::try_new("plexus-substrate").unwrap();
724 assert_eq!(c.as_str(), "plexus-substrate");
725 }
726
727 #[test]
728 fn client_id_rejects_empty() {
729 assert_eq!(ClientId::try_new(""), Err(ClientIdError::Empty));
730 }
731
732 #[test]
735 fn cookie_name_accepts_valid_token() {
736 CookieName::try_new("plexus_session").unwrap();
737 CookieName::try_new("Session-Id").unwrap();
738 CookieName::try_new("a.b.c").unwrap();
739 }
740
741 #[test]
742 fn cookie_name_rejects_separator_chars() {
743 for ch in [' ', '(', ')', ',', '/', ':', ';', '<', '=', '>', '?', '@', '[', '\\', ']', '{', '}', '"'] {
745 let name = format!("a{}b", ch);
746 assert!(
747 matches!(
748 CookieName::try_new(name.clone()),
749 Err(CookieNameError::InvalidToken(_))
750 ),
751 "expected reject for {:?}",
752 name
753 );
754 }
755 }
756
757 #[test]
758 fn cookie_name_rejects_empty() {
759 assert_eq!(CookieName::try_new(""), Err(CookieNameError::Empty));
760 }
761
762 #[test]
765 fn header_name_normalizes_to_lowercase() {
766 let h = HeaderName::try_new("Authorization").unwrap();
767 assert_eq!(h.as_str(), "authorization");
768
769 let h2 = HeaderName::try_new("AUTHORIZATION").unwrap();
771 let h3 = HeaderName::try_new("authorization").unwrap();
772 assert_eq!(h, h2);
773 assert_eq!(h, h3);
774 }
775
776 #[test]
777 fn header_name_rejects_invalid_chars() {
778 assert!(matches!(
779 HeaderName::try_new("X Header"),
780 Err(HeaderNameError::InvalidToken(_))
781 ));
782 assert!(matches!(
783 HeaderName::try_new("X:Header"),
784 Err(HeaderNameError::InvalidToken(_))
785 ));
786 }
787
788 #[test]
789 fn header_name_rejects_empty() {
790 assert_eq!(HeaderName::try_new(""), Err(HeaderNameError::Empty));
791 }
792
793 #[test]
796 fn auth_mechanism_bearer_round_trip() {
797 let m = AuthMechanism::Bearer {
798 header: HeaderName::try_new("authorization").unwrap(),
799 };
800 let v = serde_json::to_value(&m).unwrap();
801 assert_eq!(
802 v,
803 json!({
804 "kind": "bearer",
805 "header": "authorization"
806 })
807 );
808 let back: AuthMechanism = serde_json::from_value(v).unwrap();
809 assert_eq!(back, m);
810 }
811
812 #[test]
813 fn auth_mechanism_cookie_round_trip_minimal() {
814 let m = AuthMechanism::Cookie {
816 cookie: CookieName::try_new("plexus_session").unwrap(),
817 login: MethodPath::try_new("auth.login").unwrap(),
818 refresh: None,
819 logout: None,
820 };
821 let v = serde_json::to_value(&m).unwrap();
822 assert_eq!(
823 v,
824 json!({
825 "kind": "cookie",
826 "cookie": "plexus_session",
827 "login": "auth.login"
828 }),
829 "refresh / logout should be omitted when None"
830 );
831 let back: AuthMechanism = serde_json::from_value(v).unwrap();
832 assert_eq!(back, m);
833 }
834
835 #[test]
836 fn auth_mechanism_cookie_round_trip_full() {
837 let m = AuthMechanism::Cookie {
838 cookie: CookieName::try_new("plexus_session").unwrap(),
839 login: MethodPath::try_new("auth.login").unwrap(),
840 refresh: Some(MethodPath::try_new("auth.refresh").unwrap()),
841 logout: Some(MethodPath::try_new("auth.logout").unwrap()),
842 };
843 let v = serde_json::to_value(&m).unwrap();
844 assert_eq!(
845 v,
846 json!({
847 "kind": "cookie",
848 "cookie": "plexus_session",
849 "login": "auth.login",
850 "refresh": "auth.refresh",
851 "logout": "auth.logout"
852 })
853 );
854 let back: AuthMechanism = serde_json::from_value(v).unwrap();
855 assert_eq!(back, m);
856 }
857
858 #[test]
859 fn auth_mechanism_oidc_round_trip() {
860 let m = AuthMechanism::Oidc {
861 issuer: IssuerUrl::try_new("https://accounts.example.com/".parse().unwrap()).unwrap(),
862 client_id: ClientId::try_new("plexus-substrate").unwrap(),
863 exchange: Some(MethodPath::try_new("auth.exchange").unwrap()),
864 request_scopes: vec!["openid".into(), "profile".into(), "email".into()],
865 };
866 let v = serde_json::to_value(&m).unwrap();
867 assert_eq!(
868 v,
869 json!({
870 "kind": "oidc",
871 "issuer": "https://accounts.example.com/",
872 "client_id": "plexus-substrate",
873 "exchange": "auth.exchange",
874 "request_scopes": ["openid", "profile", "email"]
875 })
876 );
877 let back: AuthMechanism = serde_json::from_value(v).unwrap();
878 assert_eq!(back, m);
879 }
880
881 #[test]
882 fn auth_mechanism_anonymous_round_trip() {
883 let m = AuthMechanism::Anonymous;
884 let v = serde_json::to_value(&m).unwrap();
885 assert_eq!(v, json!({ "kind": "anonymous" }));
886 let back: AuthMechanism = serde_json::from_value(v).unwrap();
887 assert_eq!(back, m);
888 }
889
890 #[test]
893 fn backend_auth_capabilities_constructs_with_valid_default() {
894 let caps = BackendAuthCapabilities::new(
895 vec![
896 AuthMechanism::Bearer {
897 header: HeaderName::try_new("authorization").unwrap(),
898 },
899 AuthMechanism::Cookie {
900 cookie: CookieName::try_new("plexus_session").unwrap(),
901 login: MethodPath::try_new("auth.login").unwrap(),
902 refresh: None,
903 logout: None,
904 },
905 ],
906 Some(1),
907 )
908 .unwrap();
909 assert_eq!(caps.default, Some(1));
910 assert_eq!(caps.mechanisms.len(), 2);
911 }
912
913 #[test]
914 fn backend_auth_capabilities_rejects_out_of_range_default() {
915 let res = BackendAuthCapabilities::new(
916 vec![AuthMechanism::Anonymous],
917 Some(5),
918 );
919 assert_eq!(
920 res,
921 Err(BackendAuthCapabilitiesError::DefaultOutOfRange {
922 default: 5,
923 len: 1,
924 })
925 );
926 }
927
928 #[test]
929 fn backend_auth_capabilities_rejects_default_for_empty_mechanisms() {
930 let res = BackendAuthCapabilities::new(vec![], Some(0));
931 assert_eq!(
932 res,
933 Err(BackendAuthCapabilitiesError::DefaultOutOfRange {
934 default: 0,
935 len: 0,
936 })
937 );
938 }
939
940 #[test]
941 fn backend_auth_capabilities_anonymous_default() {
942 let caps = BackendAuthCapabilities::anonymous_default();
943 assert_eq!(caps.mechanisms, vec![AuthMechanism::Anonymous]);
944 assert_eq!(caps.default, None);
945
946 let v = serde_json::to_value(&caps).unwrap();
948 assert_eq!(
949 v,
950 json!({
951 "mechanisms": [{ "kind": "anonymous" }]
952 }),
953 "default should be omitted when None"
954 );
955 }
956
957 #[test]
958 fn backend_auth_capabilities_full_wire_form_matches_spec() {
959 let caps = BackendAuthCapabilities::new(
961 vec![
962 AuthMechanism::Bearer {
963 header: HeaderName::try_new("authorization").unwrap(),
964 },
965 AuthMechanism::Cookie {
966 cookie: CookieName::try_new("plexus_session").unwrap(),
967 login: MethodPath::try_new("auth.login").unwrap(),
968 refresh: Some(MethodPath::try_new("auth.refresh").unwrap()),
969 logout: Some(MethodPath::try_new("auth.logout").unwrap()),
970 },
971 AuthMechanism::Oidc {
972 issuer: IssuerUrl::try_new(
973 "https://accounts.example.com/".parse().unwrap(),
974 )
975 .unwrap(),
976 client_id: ClientId::try_new("plexus-substrate").unwrap(),
977 exchange: Some(MethodPath::try_new("auth.exchange").unwrap()),
978 request_scopes: vec!["openid".into(), "profile".into(), "email".into()],
979 },
980 ],
981 Some(1),
982 )
983 .unwrap();
984
985 let v = serde_json::to_value(&caps).unwrap();
986 assert_eq!(
987 v,
988 json!({
989 "mechanisms": [
990 { "kind": "bearer", "header": "authorization" },
991 {
992 "kind": "cookie",
993 "cookie": "plexus_session",
994 "login": "auth.login",
995 "refresh": "auth.refresh",
996 "logout": "auth.logout"
997 },
998 {
999 "kind": "oidc",
1000 "issuer": "https://accounts.example.com/",
1001 "client_id": "plexus-substrate",
1002 "exchange": "auth.exchange",
1003 "request_scopes": ["openid", "profile", "email"]
1004 }
1005 ],
1006 "default": 1
1007 })
1008 );
1009
1010 let back: BackendAuthCapabilities = serde_json::from_value(v).unwrap();
1012 assert_eq!(back, caps);
1013 }
1014}