1use std::fmt;
12
13use crate::algorithms::{Algorithm, DEFAULT_ALGORITHMS, parse_algorithm};
14use crate::validator::plain_http_non_loopback;
15
16pub const DEFAULT_SCOPE_CLAIMS: &[&str] = &["scope", "scp"];
24
25pub const DEFAULT_PRINCIPAL_CLAIMS: &[&str] = &["preferred_username", "sub"];
29
30pub const DEFAULT_LEEWAY_SECS: u64 = 60;
32
33pub const MAX_LEEWAY_SECS: u64 = 300;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46#[non_exhaustive]
47pub enum KeyNaming<'a> {
48 Dotted(&'a str),
50 Env(&'a str),
52}
53
54impl KeyNaming<'_> {
55 pub fn key(&self, field: &str) -> String {
57 match self {
58 KeyNaming::Dotted("") => field.to_string(),
59 KeyNaming::Dotted(prefix) => format!("{prefix}.{field}"),
60 KeyNaming::Env(prefix) => format!("{prefix}{}", field.to_ascii_uppercase()),
61 }
62 }
63
64 pub fn section(&self) -> String {
69 match self {
70 KeyNaming::Dotted("") => "OAuth config".to_string(),
71 KeyNaming::Dotted(prefix) => prefix.to_string(),
72 KeyNaming::Env(prefix) => format!("{prefix}*"),
73 }
74 }
75
76 pub fn to_buf(&self) -> KeyNamingBuf {
78 match self {
79 KeyNaming::Dotted(p) => KeyNamingBuf::Dotted((*p).to_string()),
80 KeyNaming::Env(p) => KeyNamingBuf::Env((*p).to_string()),
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
90#[non_exhaustive]
91pub enum KeyNamingBuf {
92 Dotted(String),
94 Env(String),
96}
97
98impl KeyNamingBuf {
99 pub fn as_naming(&self) -> KeyNaming<'_> {
101 match self {
102 KeyNamingBuf::Dotted(p) => KeyNaming::Dotted(p),
103 KeyNamingBuf::Env(p) => KeyNaming::Env(p),
104 }
105 }
106
107 pub fn key(&self, field: &str) -> String {
109 self.as_naming().key(field)
110 }
111
112 pub fn section(&self) -> String {
114 self.as_naming().section()
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
130pub struct ConfigError {
131 pub problems: Vec<String>,
133 naming: KeyNamingBuf,
134}
135
136impl ConfigError {
137 pub fn new(naming: KeyNaming<'_>, problems: Vec<String>) -> Self {
144 debug_assert!(
145 !problems.is_empty(),
146 "ConfigError::new called with no problems"
147 );
148 Self {
149 problems,
150 naming: naming.to_buf(),
151 }
152 }
153
154 pub fn naming(&self) -> KeyNaming<'_> {
156 self.naming.as_naming()
157 }
158}
159
160impl fmt::Display for ConfigError {
161 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162 let list = self.problems.join("\n - ");
163 match &self.naming {
164 KeyNamingBuf::Dotted(_) => {
165 let enabled = self.naming.key("enabled");
166 write!(
167 f,
168 "{enabled} is true but the OAuth config is not usable:\n - {list}\n\
169 Fix these, or set {enabled}: false."
170 )
171 }
172 KeyNamingBuf::Env(_) => {
173 let section = self.naming.section();
174 write!(
175 f,
176 "OAuth is configured through {section} but the config is not usable:\n \
177 - {list}\nFix these, or unset every {section} variable."
178 )
179 }
180 }
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
214#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
215#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
216pub struct OAuthConfig {
217 #[cfg_attr(feature = "serde", serde(default))]
221 pub enabled: bool,
222 #[cfg_attr(feature = "serde", serde(default))]
235 pub issuer: String,
236 #[cfg_attr(
244 feature = "serde",
245 serde(default, skip_serializing_if = "Option::is_none")
246 )]
247 pub jwks_uri: Option<String>,
248 #[cfg_attr(feature = "serde", serde(default))]
268 pub audience: String,
269 #[cfg_attr(feature = "serde", serde(default))]
273 pub audiences: Vec<String>,
274 #[cfg_attr(feature = "serde", serde(default))]
288 pub resource: String,
289 #[cfg_attr(
306 feature = "serde",
307 serde(default, skip_serializing_if = "Option::is_none")
308 )]
309 pub required_scope: Option<String>,
310 #[cfg_attr(feature = "serde", serde(default))]
314 pub required_scopes: Vec<String>,
315 #[cfg_attr(
331 feature = "serde",
332 serde(default, skip_serializing_if = "Option::is_none")
333 )]
334 pub scopes_supported: Option<Vec<String>>,
335 #[cfg_attr(feature = "serde", serde(default = "default_scope_claims"))]
341 pub scope_claims: Vec<String>,
342 #[cfg_attr(feature = "serde", serde(default = "default_principal_claims"))]
349 pub principal_claims: Vec<String>,
350 #[cfg_attr(feature = "serde", serde(default = "default_algorithms"))]
357 pub algorithms: Vec<String>,
358 #[cfg_attr(feature = "serde", serde(default = "default_leeway_secs"))]
362 pub leeway_secs: u64,
363 #[cfg_attr(feature = "serde", serde(default))]
375 pub require_at_jwt: bool,
376 #[cfg_attr(feature = "serde", serde(default))]
385 pub allow_unscoped_tokens: bool,
386 #[cfg_attr(feature = "serde", serde(default))]
406 pub allow_insecure_http: bool,
407 #[cfg_attr(feature = "serde", serde(default = "default_true"))]
413 pub accept_static_bearer: bool,
414}
415
416impl Default for OAuthConfig {
417 fn default() -> Self {
418 Self {
419 enabled: false,
420 issuer: String::new(),
421 jwks_uri: None,
422 audience: String::new(),
423 audiences: Vec::new(),
424 resource: String::new(),
425 required_scope: None,
426 required_scopes: Vec::new(),
427 scopes_supported: None,
428 scope_claims: default_scope_claims(),
429 principal_claims: default_principal_claims(),
430 algorithms: default_algorithms(),
431 leeway_secs: default_leeway_secs(),
432 require_at_jwt: false,
433 allow_unscoped_tokens: false,
434 allow_insecure_http: false,
435 accept_static_bearer: true,
436 }
437 }
438}
439
440fn strings(list: &[&str]) -> Vec<String> {
441 list.iter().map(|s| s.to_string()).collect()
442}
443
444fn default_scope_claims() -> Vec<String> {
445 strings(DEFAULT_SCOPE_CLAIMS)
446}
447
448fn default_principal_claims() -> Vec<String> {
449 strings(DEFAULT_PRINCIPAL_CLAIMS)
450}
451
452fn default_algorithms() -> Vec<String> {
453 strings(DEFAULT_ALGORITHMS)
454}
455
456fn default_leeway_secs() -> u64 {
457 DEFAULT_LEEWAY_SECS
458}
459
460#[cfg(feature = "serde")]
461fn default_true() -> bool {
462 true
463}
464
465impl OAuthConfig {
466 pub fn resolve(
524 self,
525 naming: KeyNaming<'_>,
526 ) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
527 if !self.enabled {
528 return Ok(None);
529 }
530 let key = |field: &str| naming.key(field);
531 let mut problems: Vec<String> = Vec::new();
532
533 let mut blank = Vec::new();
534 for (name, value) in [("issuer", &self.issuer), ("resource", &self.resource)] {
535 if value.trim().is_empty() {
536 blank.push(key(name));
537 }
538 }
539 if self.audience.trim().is_empty() && self.audiences.is_empty() {
540 blank.push(format!("{} (or {})", key("audience"), key("audiences")));
541 }
542 if !blank.is_empty() {
543 problems.push(format!(
544 "these required settings are empty: {}. Set issuer to the authorization \
545 server's issuer (byte-exact, including any trailing slash), resource to \
546 this server's public URL, and audience to what that server puts in \
547 an access token's `aud` — the resource URL if it honours RFC 8707 or \
548 lets you configure an audience (e.g. Authelia), or the OAuth client_id \
549 if it stamps that (e.g. Authentik, Kanidm)",
550 blank.join(", ")
551 ));
552 }
553
554 let urls = [
555 ("issuer", Some(&self.issuer), true),
556 ("resource", Some(&self.resource), true),
557 ("jwks_uri", self.jwks_uri.as_ref(), false),
558 ];
559 for (name, value, identifier) in urls {
560 let Some(value) = value.filter(|v| !v.trim().is_empty()) else {
561 continue;
562 };
563 match check_url(&key(name), value, identifier) {
564 Err(e) => problems.push(e),
565 Ok(()) if !self.allow_insecure_http && plain_http_non_loopback(value) => {
566 problems.push(format!(
567 "{} {value:?} uses plain http on a non-loopback host — {}. Use https, \
568 or set {} if this address is on a network you trust",
569 key(name),
570 if name == "resource" {
571 "bearer tokens sent to it can be read in transit (RFC 9728 §1.2 \
572 requires https)"
573 } else {
574 "signing keys fetched over it can be substituted by anyone on the \
575 path (RFC 8414 §2 requires https)"
576 },
577 key("allow_insecure_http")
578 ));
579 }
580 Ok(()) => {}
581 }
582 }
583 if self.audiences.iter().any(|a| a.trim().is_empty()) {
584 problems.push(format!("{} contains an empty entry", key("audiences")));
585 }
586
587 if let Some(required_scope) = &self.required_scope {
588 if required_scope.trim().is_empty() {
589 problems.push(format!(
590 "{} must not be empty — a blank required scope would let any signed \
591 token through unscoped. Use a scope your authorization server \
592 actually issues",
593 key("required_scope")
594 ));
595 } else if required_scope.split_whitespace().count() != 1 {
596 problems.push(format!(
597 "{} {:?} must be a single scope (no spaces) — scopes are matched one \
598 token at a time",
599 key("required_scope"),
600 required_scope
601 ));
602 } else if !is_scope_token(required_scope.trim()) {
603 problems.push(scope_token_problem(&key("required_scope"), required_scope));
604 }
605 }
606 for scope in &self.required_scopes {
607 if scope.trim().is_empty() {
608 problems.push(format!(
609 "{} contains an empty entry — a blank required scope would let a \
610 token through without it. Remove the entry or name a scope your \
611 authorization server actually issues",
612 key("required_scopes")
613 ));
614 } else if scope.split_whitespace().count() != 1 {
615 problems.push(format!(
616 "{} entry {:?} must be a single scope (no spaces) — scopes are matched \
617 one token at a time; list each one as its own entry",
618 key("required_scopes"),
619 scope
620 ));
621 } else if !is_scope_token(scope.trim()) {
622 problems.push(scope_token_problem(
623 &format!("{} entry", key("required_scopes")),
624 scope,
625 ));
626 }
627 }
628 if let Some(supported) = &self.scopes_supported {
629 if supported.iter().any(|s| s.trim().is_empty()) {
630 problems.push(format!(
631 "{} contains an empty entry",
632 key("scopes_supported")
633 ));
634 }
635 for scope in supported.iter().filter(|s| !s.trim().is_empty()) {
636 if !is_scope_token(scope.trim()) {
637 problems.push(scope_token_problem(
638 &format!("{} entry", key("scopes_supported")),
639 scope,
640 ));
641 }
642 }
643 }
644 if self.required_scope.is_none()
645 && self.required_scopes.is_empty()
646 && !self.require_at_jwt
647 && !self.allow_unscoped_tokens
648 {
649 problems.push(format!(
650 "no required scope is configured ({} and {} are unset) and {} is off — \
651 nothing would tell an access token from an OIDC ID token minted for the \
652 same client, so any token this issuer signs for the audience would be \
653 accepted. Set {} to a scope only access tokens carry, turn on {} if the \
654 authorization server emits typ at+jwt, or set {} to accept that",
655 key("required_scope"),
656 key("required_scopes"),
657 key("require_at_jwt"),
658 key("required_scope"),
659 key("require_at_jwt"),
660 key("allow_unscoped_tokens")
661 ));
662 }
663 if self.scope_claims.is_empty() || self.scope_claims.iter().any(|c| c.trim().is_empty()) {
664 problems.push(format!(
665 "{} must list at least one non-empty claim name (default: [\"scope\", \
666 \"scp\"])",
667 key("scope_claims")
668 ));
669 }
670 if self.principal_claims.iter().any(|c| c.trim().is_empty()) {
671 problems.push(format!(
672 "{} contains an empty entry",
673 key("principal_claims")
674 ));
675 }
676
677 let mut algorithms = Vec::new();
678 let mut bad_algorithms = Vec::new();
679 for name in &self.algorithms {
680 match parse_algorithm(name) {
681 Ok(alg) if !algorithms.contains(&alg) => algorithms.push(alg),
682 Ok(_) => {}
683 Err(reason) => bad_algorithms.push(reason.to_string()),
684 }
685 }
686 if !bad_algorithms.is_empty() {
687 problems.push(format!(
688 "{} has unacceptable entries: {}",
689 key("algorithms"),
690 bad_algorithms.join("; ")
691 ));
692 } else if algorithms.is_empty() {
693 problems.push(format!(
694 "{} must list at least one algorithm",
695 key("algorithms")
696 ));
697 }
698
699 if self.leeway_secs > MAX_LEEWAY_SECS {
700 problems.push(format!(
701 "{} {} is over the {}-second cap — leeway is for clock drift, not for \
702 extending token lifetimes",
703 key("leeway_secs"),
704 self.leeway_secs,
705 MAX_LEEWAY_SECS
706 ));
707 }
708
709 if !problems.is_empty() {
710 return Err(ConfigError::new(naming, problems));
711 }
712
713 let mut required_scopes: Vec<String> = Vec::new();
717 for scope in self
718 .required_scope
719 .iter()
720 .chain(self.required_scopes.iter())
721 {
722 let scope = scope.trim();
723 if !required_scopes.iter().any(|s| s == scope) {
724 required_scopes.push(scope.to_string());
725 }
726 }
727
728 let scopes_supported = match self.scopes_supported {
731 Some(listed) => listed.iter().map(|s| s.trim().to_string()).collect(),
732 None => required_scopes.clone(),
733 };
734
735 Ok(Some(ResolvedOAuthConfig {
736 issuer: self.issuer,
737 jwks_uri: self.jwks_uri.filter(|u| !u.trim().is_empty()),
738 audience: self.audience,
739 audiences: self.audiences,
740 resource: self.resource,
741 required_scopes,
742 scopes_supported,
743 scope_claims: self.scope_claims,
744 principal_claims: self.principal_claims,
745 algorithms,
746 leeway_secs: self.leeway_secs,
747 require_at_jwt: self.require_at_jwt,
748 allow_unscoped_tokens: self.allow_unscoped_tokens,
749 allow_insecure_http: self.allow_insecure_http,
750 accept_static_bearer: self.accept_static_bearer,
751 resource_name: None,
752 key_naming: naming.to_buf(),
753 }))
754 }
755}
756
757fn check_url(key: &str, value: &str, identifier: bool) -> Result<(), String> {
769 let parsed = reqwest::Url::parse(value.trim())
770 .map_err(|e| format!("{key} {value:?} is not an absolute URL ({e})"))?;
771 if !matches!(parsed.scheme(), "https" | "http") {
772 return Err(format!("{key} {value:?} must be an http(s) URL"));
773 }
774 if identifier && (parsed.fragment().is_some() || parsed.query().is_some()) {
775 return Err(format!(
776 "{key} {value:?} must not contain a query or fragment"
777 ));
778 }
779 if value != value.trim() {
780 return Err(format!(
781 "{key} {value:?} has leading/trailing whitespace — it is compared byte-for-byte"
782 ));
783 }
784 if value.chars().any(|c| !c.is_ascii_graphic()) {
785 return Err(format!(
786 "{key} {value:?} contains a space, a control character or a non-ASCII \
787 character — write it percent-encoded (and an internationalized host in its \
788 punycode form)"
789 ));
790 }
791 Ok(())
792}
793
794fn is_scope_token(scope: &str) -> bool {
799 !scope.is_empty()
800 && scope
801 .bytes()
802 .all(|b| b == 0x21 || (0x23..=0x5B).contains(&b) || (0x5D..=0x7E).contains(&b))
803}
804
805fn scope_token_problem(what: &str, scope: &str) -> String {
806 format!(
807 "{what} {scope:?} is not a valid scope — a scope is printable ASCII with no space, \
808 '\"' or '\\' (RFC 6749 §3.3)"
809 )
810}
811
812#[derive(Debug, Clone, PartialEq, Eq)]
827#[non_exhaustive]
828pub struct ResolvedOAuthConfig {
829 pub issuer: String,
831 pub jwks_uri: Option<String>,
833 pub audience: String,
836 pub audiences: Vec<String>,
838 pub resource: String,
840 pub required_scopes: Vec<String>,
844 pub scopes_supported: Vec<String>,
848 pub scope_claims: Vec<String>,
850 pub principal_claims: Vec<String>,
852 pub algorithms: Vec<Algorithm>,
855 pub leeway_secs: u64,
857 pub require_at_jwt: bool,
859 pub allow_unscoped_tokens: bool,
862 pub allow_insecure_http: bool,
866 pub accept_static_bearer: bool,
868 pub resource_name: Option<String>,
874 pub key_naming: KeyNamingBuf,
877}
878
879impl ResolvedOAuthConfig {
880 pub fn accepted_audiences(&self) -> Vec<String> {
882 let mut out: Vec<String> = Vec::new();
883 for a in std::iter::once(&self.audience).chain(self.audiences.iter()) {
884 if !a.trim().is_empty() && !out.contains(a) {
885 out.push(a.clone());
886 }
887 }
888 out
889 }
890}
891
892#[cfg(test)]
893mod tests {
894 use super::*;
895
896 const WIKI: KeyNaming<'static> = KeyNaming::Dotted("mcp.oauth");
897
898 fn enabled(edit: impl FnOnce(&mut OAuthConfig)) -> OAuthConfig {
903 let mut cfg = OAuthConfig {
904 enabled: true,
905 issuer: "https://idp.example.test/".into(),
906 resource: "https://kb.example.test/mcp".into(),
907 audience: "c".into(),
908 allow_unscoped_tokens: true,
909 ..OAuthConfig::default()
910 };
911 edit(&mut cfg);
912 cfg
913 }
914
915 fn resolve_err(cfg: OAuthConfig) -> String {
916 cfg.resolve(WIKI).unwrap_err().to_string()
917 }
918
919 #[test]
922 fn oauth_is_disabled_by_default_and_has_no_default_scope() {
923 let cfg = OAuthConfig::default();
924 assert!(!cfg.enabled);
925 assert_eq!(cfg.required_scope, None);
928 assert!(cfg.required_scopes.is_empty());
929 assert_eq!(cfg.scopes_supported, None);
930 assert_eq!(cfg.jwks_uri, None);
931 assert_eq!(cfg.scope_claims, ["scope", "scp"]);
932 assert_eq!(cfg.principal_claims, ["preferred_username", "sub"]);
933 assert_eq!(cfg.algorithms, DEFAULT_ALGORITHMS);
934 assert_eq!(cfg.leeway_secs, 60);
935 assert!(!cfg.require_at_jwt);
936 assert!(!cfg.allow_unscoped_tokens);
937 assert!(!cfg.allow_insecure_http);
938 assert!(cfg.accept_static_bearer);
939 }
940
941 #[test]
942 fn disabled_resolves_to_none() {
943 assert_eq!(OAuthConfig::default().resolve(WIKI), Ok(None));
944 let cfg = OAuthConfig {
946 algorithms: vec!["HS256".into()],
947 ..OAuthConfig::default()
948 };
949 assert_eq!(cfg.resolve(WIKI), Ok(None));
950 }
951
952 #[test]
953 fn a_minimal_enabled_block_resolves_with_every_default() {
954 let cfg = OAuthConfig {
955 enabled: true,
956 issuer: "https://authentik.example.test/application/o/example-app/".into(),
957 jwks_uri: Some("https://authentik.example.test/application/o/example-app/jwks/".into()),
958 audience: "some-client-id".into(),
959 resource: "https://kb.example.test/mcp".into(),
960 required_scope: Some("api:read".into()),
961 ..OAuthConfig::default()
962 };
963 let oauth = cfg.resolve(WIKI).unwrap().expect("enabled");
964 assert_eq!(
967 oauth.issuer,
968 "https://authentik.example.test/application/o/example-app/"
969 );
970 assert_eq!(oauth.audience, "some-client-id");
971 assert_eq!(oauth.resource, "https://kb.example.test/mcp");
972 assert_eq!(oauth.required_scopes, ["api:read"]);
973 assert_eq!(oauth.scopes_supported, ["api:read"]);
975 assert_eq!(oauth.accepted_audiences(), ["some-client-id"]);
976 assert_eq!(oauth.scope_claims, ["scope", "scp"]);
977 assert_eq!(oauth.principal_claims, ["preferred_username", "sub"]);
978 assert!(oauth.algorithms.contains(&Algorithm::RS256));
979 assert_eq!(oauth.leeway_secs, 60);
980 assert!(!oauth.require_at_jwt);
981 assert!(!oauth.allow_unscoped_tokens);
982 assert!(!oauth.allow_insecure_http);
983 assert!(oauth.accept_static_bearer);
984 assert_eq!(oauth.resource_name, None);
985 assert_eq!(oauth.key_naming, KeyNamingBuf::Dotted("mcp.oauth".into()));
986 }
987
988 #[test]
991 fn enabled_with_blank_required_settings_is_rejected_naming_all_of_them() {
992 let cfg = OAuthConfig {
993 enabled: true,
994 ..OAuthConfig::default()
995 };
996 let err = resolve_err(cfg);
997 for setting in [
998 "mcp.oauth.issuer",
999 "mcp.oauth.audience",
1000 "mcp.oauth.resource",
1001 ] {
1002 assert!(err.contains(setting), "{setting} missing from: {err}");
1003 }
1004 assert!(!err.contains("mcp.oauth.jwks_uri"), "{err}");
1006 }
1007
1008 const WIKI_REWRITES: [(&str, &str); 2] = [
1021 ("this server's public URL,", "this server's public MCP URL,"),
1022 (
1023 "unscoped. Use a scope your",
1024 "unscoped. Use \"mcp:read\" (the default) or a scope your",
1025 ),
1026 ];
1027
1028 fn as_wiki_text(problem: &str) -> String {
1029 WIKI_REWRITES
1030 .iter()
1031 .fold(problem.to_string(), |p, (generic, wiki)| {
1032 p.replace(generic, wiki)
1033 })
1034 }
1035
1036 #[test]
1037 fn config_error_display_for_dotted_naming_is_generic_and_maps_to_mcp_md_wiki_text() {
1038 let cfg = OAuthConfig {
1041 enabled: true,
1042 required_scope: Some("mcp:read".into()),
1043 ..OAuthConfig::default()
1044 };
1045 let expected = "mcp.oauth.enabled is true but the OAuth config is not usable:\n - \
1047these required settings are empty: mcp.oauth.issuer, mcp.oauth.resource, mcp.oauth.audience \
1048(or mcp.oauth.audiences). Set issuer to the authorization server's issuer (byte-exact, \
1049including any trailing slash), resource to this server's public URL, and audience to \
1050what that server puts in an access token's `aud` — the resource URL if it honours RFC 8707 \
1051or lets you configure an audience (e.g. Authelia), or the OAuth client_id if it stamps that \
1052(e.g. Authentik, Kanidm)\nFix these, or set mcp.oauth.enabled: false.";
1053 let mut err = cfg.resolve(WIKI).unwrap_err();
1054 assert_eq!(err.to_string(), expected);
1055 assert_eq!(err.problems[0].matches(WIKI_REWRITES[0].0).count(), 1);
1056
1057 let wiki_expected = "mcp.oauth.enabled is true but the OAuth config is not usable:\n - \
1060these required settings are empty: mcp.oauth.issuer, mcp.oauth.resource, mcp.oauth.audience \
1061(or mcp.oauth.audiences). Set issuer to the authorization server's issuer (byte-exact, \
1062including any trailing slash), resource to this server's public MCP URL, and audience to \
1063what that server puts in an access token's `aud` — the resource URL if it honours RFC 8707 \
1064or lets you configure an audience (e.g. Authelia), or the OAuth client_id if it stamps that \
1065(e.g. Authentik, Kanidm)\nFix these, or set mcp.oauth.enabled: false.";
1066 err.problems = err.problems.iter().map(|p| as_wiki_text(p)).collect();
1067 assert_eq!(err.to_string(), wiki_expected);
1068 }
1069
1070 #[test]
1071 fn scope_problem_messages_are_generic_and_map_to_mcp_md_wiki_text() {
1072 let err = enabled(|c| c.required_scope = Some(String::new()))
1073 .resolve(WIKI)
1074 .unwrap_err();
1075 assert_eq!(
1076 err.problems,
1077 [
1078 "mcp.oauth.required_scope must not be empty — a blank required scope would \
1079 let any signed token through unscoped. Use a scope your authorization server \
1080 actually issues"
1081 ]
1082 );
1083 assert_eq!(err.problems[0].matches(WIKI_REWRITES[1].0).count(), 1);
1084 assert_eq!(
1086 as_wiki_text(&err.problems[0]),
1087 "mcp.oauth.required_scope must not be empty — a blank required scope would \
1088 let any signed token through unscoped. Use \"mcp:read\" (the default) or a \
1089 scope your authorization server actually issues"
1090 );
1091 let text = OAuthConfig {
1093 enabled: true,
1094 required_scope: Some(String::new()),
1095 ..OAuthConfig::default()
1096 }
1097 .resolve(KeyNaming::Env("APP_OAUTH_"))
1098 .unwrap_err()
1099 .to_string();
1100 assert!(!text.contains("MCP") && !text.contains("mcp"), "{text}");
1101 let err = enabled(|c| c.required_scope = Some("mcp:read mcp:write".into()))
1102 .resolve(WIKI)
1103 .unwrap_err();
1104 assert_eq!(
1105 err.problems,
1106 [
1107 "mcp.oauth.required_scope \"mcp:read mcp:write\" must be a single scope (no \
1108 spaces) — scopes are matched one token at a time"
1109 ]
1110 );
1111 let err = enabled(|c| c.leeway_secs = 3600).resolve(WIKI).unwrap_err();
1112 assert_eq!(
1113 err.to_string(),
1114 "mcp.oauth.enabled is true but the OAuth config is not usable:\n - \
1115 mcp.oauth.leeway_secs 3600 is over the 300-second cap — leeway is for clock \
1116 drift, not for extending token lifetimes\nFix these, or set mcp.oauth.enabled: \
1117 false."
1118 );
1119 }
1120
1121 #[test]
1122 fn env_naming_uppercases_and_appends_the_field() {
1123 let naming = KeyNaming::Env("MYAPP_OAUTH_");
1124 assert_eq!(naming.key("issuer"), "MYAPP_OAUTH_ISSUER");
1125 assert_eq!(naming.key("required_scopes"), "MYAPP_OAUTH_REQUIRED_SCOPES");
1126 assert_eq!(naming.section(), "MYAPP_OAUTH_*");
1127 assert_eq!(
1128 KeyNaming::Dotted("mcp.oauth").key("issuer"),
1129 "mcp.oauth.issuer"
1130 );
1131 assert_eq!(KeyNaming::Dotted("").key("issuer"), "issuer");
1132 assert_eq!(KeyNaming::Dotted("").section(), "OAuth config");
1133 assert_eq!(KeyNaming::Dotted("mcp.oauth").section(), "mcp.oauth");
1134
1135 let cfg = OAuthConfig {
1136 enabled: true,
1137 required_scope: Some(String::new()),
1138 ..OAuthConfig::default()
1139 };
1140 let err = cfg.resolve(naming).unwrap_err();
1141 let text = err.to_string();
1142 for key in [
1143 "MYAPP_OAUTH_ISSUER",
1144 "MYAPP_OAUTH_RESOURCE",
1145 "MYAPP_OAUTH_AUDIENCE (or MYAPP_OAUTH_AUDIENCES)",
1146 "MYAPP_OAUTH_REQUIRED_SCOPE must not be empty",
1147 ] {
1148 assert!(text.contains(key), "{key} missing from: {text}");
1149 }
1150 assert!(!text.contains("mcp.oauth"), "{text}");
1151 assert!(
1152 text.starts_with(
1153 "OAuth is configured through MYAPP_OAUTH_* but the config is not \
1154 usable:\n - "
1155 ),
1156 "{text}"
1157 );
1158 assert!(
1159 text.ends_with("\nFix these, or unset every MYAPP_OAUTH_* variable."),
1160 "{text}"
1161 );
1162 assert_eq!(err.naming(), naming);
1163 }
1164
1165 #[test]
1166 fn accepts_audiences_without_audience_and_an_omitted_or_blank_jwks_uri() {
1167 for jwks_uri in [None, Some(String::new()), Some(" ".to_string())] {
1168 let oauth = enabled(|c| {
1169 c.audience = String::new();
1170 c.audiences = vec!["https://kb.example.test/mcp".into()];
1171 c.jwks_uri = jwks_uri.clone();
1172 })
1173 .resolve(WIKI)
1174 .unwrap()
1175 .unwrap();
1176 assert_eq!(oauth.accepted_audiences(), ["https://kb.example.test/mcp"]);
1177 assert_eq!(oauth.jwks_uri, None, "blank means discover: {jwks_uri:?}");
1178 }
1179 }
1180
1181 #[test]
1182 fn refuses_hmac_and_none_algorithms() {
1183 for alg in ["HS256", "none"] {
1184 let err = resolve_err(enabled(|c| {
1185 c.algorithms = vec!["RS256".into(), alg.into()];
1186 }));
1187 assert!(err.contains("mcp.oauth.algorithms"), "{err}");
1188 assert!(err.contains(alg), "{err}");
1189 }
1190 let err = resolve_err(enabled(|c| c.algorithms = vec![]));
1191 assert!(err.contains("at least one algorithm"), "{err}");
1192 }
1193
1194 #[test]
1195 fn refuses_malformed_urls_naming_the_key() {
1196 for jwks_uri in ["idp.example.test/jwks", "ftp://idp.example.test/jwks"] {
1197 let err = resolve_err(enabled(|c| c.jwks_uri = Some(jwks_uri.into())));
1198 assert!(err.contains("mcp.oauth.jwks_uri"), "{err}");
1199 }
1200 let err = resolve_err(enabled(|c| {
1201 c.issuer = "https://idp.example.test/?x=1".into();
1202 c.resource = "kb.example.test/mcp".into();
1203 }));
1204 assert!(err.contains("mcp.oauth.issuer"), "{err}");
1205 assert!(err.contains("mcp.oauth.resource"), "{err}");
1206 let err = resolve_err(enabled(|c| c.issuer = " https://idp.example.test/".into()));
1207 assert!(err.contains("leading/trailing whitespace"), "{err}");
1208 }
1209
1210 #[test]
1211 fn refuses_bad_scope_and_leeway_settings() {
1212 type Edit = fn(&mut OAuthConfig);
1213 let cases: [(Edit, &str); 5] = [
1214 (
1215 |c| c.required_scope = Some("mcp:read mcp:write".into()),
1216 "single scope",
1217 ),
1218 (|c| c.scope_claims = vec![], "mcp.oauth.scope_claims"),
1219 (
1220 |c| c.principal_claims = vec![String::new()],
1221 "mcp.oauth.principal_claims",
1222 ),
1223 (|c| c.leeway_secs = 3600, "mcp.oauth.leeway_secs"),
1224 (|c| c.audiences = vec![String::new()], "mcp.oauth.audiences"),
1225 ];
1226 for (edit, needle) in cases {
1227 let err = resolve_err(enabled(edit));
1228 assert!(err.contains(needle), "{needle} not in: {err}");
1229 }
1230 }
1231
1232 #[test]
1233 fn a_blank_required_scope_is_rejected_not_treated_as_absent() {
1234 for blank in ["", " "] {
1235 let err = resolve_err(enabled(|c| c.required_scope = Some(blank.into())));
1236 assert!(
1237 err.contains("mcp.oauth.required_scope"),
1238 "an empty required scope must not silently mean 'no scope': {err}"
1239 );
1240 }
1241 }
1242
1243 #[test]
1244 fn every_problem_is_reported_at_once() {
1245 let err = enabled(|c| {
1246 c.issuer = String::new();
1247 c.required_scope = Some(String::new());
1248 c.required_scopes = vec!["a b".into()];
1249 c.leeway_secs = 9999;
1250 c.algorithms = vec!["HS256".into()];
1251 })
1252 .resolve(WIKI)
1253 .unwrap_err();
1254 assert_eq!(err.problems.len(), 5, "{err}");
1255 }
1256
1257 #[test]
1260 fn required_scopes_are_a_trimmed_deduplicated_order_stable_union() {
1261 let oauth = enabled(|c| {
1262 c.required_scope = Some(" a ".into());
1263 c.required_scopes = vec!["b".into(), "a".into(), " c".into(), "b".into()];
1264 })
1265 .resolve(WIKI)
1266 .unwrap()
1267 .unwrap();
1268 assert_eq!(oauth.required_scopes, ["a", "b", "c"]);
1269
1270 let only_list = enabled(|c| c.required_scopes = vec!["x".into(), "y".into()])
1271 .resolve(WIKI)
1272 .unwrap()
1273 .unwrap();
1274 assert_eq!(only_list.required_scopes, ["x", "y"]);
1275
1276 let only_single = enabled(|c| c.required_scope = Some("mcp:read".into()))
1277 .resolve(WIKI)
1278 .unwrap()
1279 .unwrap();
1280 assert_eq!(only_single.required_scopes, ["mcp:read"]);
1281 }
1282
1283 #[test]
1284 fn an_empty_required_scope_union_is_valid_and_means_no_scope_check() {
1285 let oauth = enabled(|_| {}).resolve(WIKI).unwrap().unwrap();
1286 assert!(oauth.required_scopes.is_empty());
1287 }
1288
1289 #[test]
1290 fn required_scopes_entries_follow_the_single_scope_rules() {
1291 let err = enabled(|c| c.required_scopes = vec!["ok".into(), " ".into()])
1292 .resolve(WIKI)
1293 .unwrap_err();
1294 assert_eq!(err.problems.len(), 1, "{err}");
1295 assert!(
1296 err.problems[0].starts_with("mcp.oauth.required_scopes contains an empty entry"),
1297 "{err}"
1298 );
1299 let err = enabled(|c| c.required_scopes = vec!["a b".into()])
1300 .resolve(WIKI)
1301 .unwrap_err();
1302 assert!(
1303 err.problems[0]
1304 .starts_with("mcp.oauth.required_scopes entry \"a b\" must be a single scope"),
1305 "{err}"
1306 );
1307 }
1308
1309 #[cfg(feature = "serde")]
1312 #[test]
1313 fn round_trips_from_yaml_with_every_default() {
1314 let yaml = "enabled: true
1315issuer: \"https://authentik.example.test/application/o/example-app/\"
1316jwks_uri: \"https://authentik.example.test/application/o/example-app/jwks/\"
1317audience: \"some-client-id\"
1318resource: \"https://kb.example.test/mcp\"
1319";
1320 let parsed: OAuthConfig = serde_yaml_ng::from_str(yaml).unwrap();
1321 assert_eq!(
1322 parsed,
1323 OAuthConfig {
1324 enabled: true,
1325 issuer: "https://authentik.example.test/application/o/example-app/".into(),
1326 jwks_uri: Some(
1327 "https://authentik.example.test/application/o/example-app/jwks/".into()
1328 ),
1329 audience: "some-client-id".into(),
1330 resource: "https://kb.example.test/mcp".into(),
1331 ..OAuthConfig::default()
1332 }
1333 );
1334 let back: OAuthConfig =
1335 serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&parsed).unwrap()).unwrap();
1336 assert_eq!(back, parsed);
1337 let empty: OAuthConfig = serde_yaml_ng::from_str("{}").unwrap();
1339 assert_eq!(empty, OAuthConfig::default());
1340 }
1341
1342 #[cfg(feature = "serde")]
1343 #[test]
1344 fn serde_reads_both_scope_keys_and_refuses_unknown_keys() {
1345 let parsed: OAuthConfig =
1346 serde_yaml_ng::from_str("required_scope: \"a\"\nrequired_scopes: [\"b\", \"c\"]\n")
1347 .unwrap();
1348 assert_eq!(parsed.required_scope.as_deref(), Some("a"));
1349 assert_eq!(parsed.required_scopes, ["b", "c"]);
1350 let parsed: OAuthConfig = serde_yaml_ng::from_str("required_scope: \"\"\n").unwrap();
1352 assert_eq!(parsed.required_scope.as_deref(), Some(""));
1353 assert!(serde_yaml_ng::from_str::<OAuthConfig>("bogus: true\n").is_err());
1354 assert!(serde_yaml_ng::from_str::<OAuthConfig>("resource_name: \"x\"\n").is_err());
1355 let parsed: OAuthConfig =
1356 serde_yaml_ng::from_str("allow_unscoped_tokens: true\nallow_insecure_http: true\n")
1357 .unwrap();
1358 assert!(parsed.allow_unscoped_tokens && parsed.allow_insecure_http);
1359 }
1360
1361 #[cfg(feature = "serde")]
1362 #[test]
1363 fn serde_distinguishes_an_omitted_scopes_supported_from_an_explicit_empty_list() {
1364 let omitted: OAuthConfig = serde_yaml_ng::from_str("enabled: true\n").unwrap();
1365 assert_eq!(omitted.scopes_supported, None);
1366 let empty: OAuthConfig = serde_yaml_ng::from_str("scopes_supported: []\n").unwrap();
1367 assert_eq!(empty.scopes_supported, Some(vec![]));
1368 let listed: OAuthConfig =
1369 serde_yaml_ng::from_str("scopes_supported: [\"a\", \"b\"]\n").unwrap();
1370 assert_eq!(listed.scopes_supported, Some(vec!["a".into(), "b".into()]));
1371 for cfg in [omitted, empty, listed] {
1374 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1375 let back: OAuthConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1376 assert_eq!(back, cfg, "{yaml}");
1377 }
1378 }
1379
1380 #[test]
1381 fn an_omitted_scopes_supported_advertises_the_required_scopes_and_an_explicit_list_wins() {
1382 let with_required = |required: Option<&str>, scopes: Option<Vec<String>>| {
1383 enabled(|c| {
1384 c.required_scope = required.map(str::to_string);
1385 c.required_scopes = vec!["b".into(), "a".into()];
1386 c.scopes_supported = scopes;
1387 })
1388 .resolve(WIKI)
1389 .unwrap()
1390 .expect("enabled")
1391 .scopes_supported
1392 };
1393 assert_eq!(with_required(Some("a"), None), ["a", "b"]);
1395 assert_eq!(with_required(None, None), ["b", "a"]);
1396 assert!(with_required(Some("a"), Some(vec![])).is_empty());
1398 assert_eq!(with_required(Some("a"), Some(vec![" c ".into()])), ["c"]);
1399
1400 let resolve = |scopes: Option<Vec<String>>| {
1402 enabled(|c| c.scopes_supported = scopes)
1403 .resolve(WIKI)
1404 .unwrap()
1405 .expect("enabled")
1406 .scopes_supported
1407 };
1408 assert!(resolve(None).is_empty());
1409 assert!(resolve(Some(vec![])).is_empty());
1410 assert_eq!(
1411 resolve(Some(vec!["mcp:read".into(), "mcp:write".into()])),
1412 ["mcp:read", "mcp:write"]
1413 );
1414 let app_default = |mut c: OAuthConfig| {
1417 c.scopes_supported
1418 .get_or_insert_with(|| vec!["mcp:read".into(), "mcp:write".into()]);
1419 c.resolve(WIKI).unwrap().expect("enabled").scopes_supported
1420 };
1421 assert_eq!(
1422 app_default(enabled(|c| c.scopes_supported = None)),
1423 ["mcp:read", "mcp:write"]
1424 );
1425 assert!(app_default(enabled(|c| c.scopes_supported = Some(vec![]))).is_empty());
1426 }
1427
1428 #[test]
1431 fn every_required_and_advertised_scope_must_be_a_scope_token() {
1432 for bad in ["a\"b", "a\\b", "caf\u{e9}", "a\u{7f}"] {
1433 let err = enabled(|c| c.required_scope = Some(bad.into()))
1434 .resolve(WIKI)
1435 .unwrap_err();
1436 assert_eq!(err.problems.len(), 1, "{err}");
1437 assert!(
1438 err.problems[0].starts_with("mcp.oauth.required_scope ")
1439 && err.problems[0].contains("is not a valid scope"),
1440 "{err}"
1441 );
1442 let err = enabled(|c| c.required_scopes = vec!["ok".into(), bad.into()])
1443 .resolve(WIKI)
1444 .unwrap_err();
1445 assert!(
1446 err.problems[0].starts_with("mcp.oauth.required_scopes entry "),
1447 "{err}"
1448 );
1449 let err = enabled(|c| c.scopes_supported = Some(vec![bad.into()]))
1450 .resolve(WIKI)
1451 .unwrap_err();
1452 assert!(
1453 err.problems[0].starts_with("mcp.oauth.scopes_supported entry "),
1454 "{err}"
1455 );
1456 }
1457 let err = enabled(|c| c.scopes_supported = Some(vec!["".into(), "two words".into()]))
1459 .resolve(WIKI)
1460 .unwrap_err();
1461 assert_eq!(err.problems.len(), 2, "{err}");
1462 assert_eq!(
1463 err.problems[0],
1464 "mcp.oauth.scopes_supported contains an empty entry"
1465 );
1466 assert!(err.problems[1].contains("\"two words\""), "{err}");
1467 let oauth = enabled(|c| {
1470 c.required_scope =
1471 Some("https://api.example.test/things.read!#$%&'()*+,-./:;<=>?@[]^_`{|}~".into());
1472 })
1473 .resolve(WIKI)
1474 .unwrap()
1475 .unwrap();
1476 assert_eq!(oauth.required_scopes.len(), 1);
1477 }
1478
1479 #[test]
1482 fn a_url_with_a_space_control_or_non_ascii_character_is_refused() {
1483 for bad in [
1486 "https://kb.example.test/m\ncp",
1487 "https://kb.example.test/m\tcp",
1488 "https://kb.example.test/m cp",
1489 "https://kb.example.test/caf\u{e9}",
1490 ] {
1491 let err = resolve_err(enabled(|c| c.resource = bad.into()));
1492 assert!(
1493 err.contains("mcp.oauth.resource") && err.contains("percent-encoded"),
1494 "{bad:?}: {err}"
1495 );
1496 let err = resolve_err(enabled(|c| c.issuer = bad.into()));
1497 assert!(err.contains("mcp.oauth.issuer"), "{bad:?}: {err}");
1498 let err = resolve_err(enabled(|c| c.jwks_uri = Some(bad.into())));
1499 assert!(err.contains("mcp.oauth.jwks_uri"), "{bad:?}: {err}");
1500 }
1501 enabled(|c| {
1503 c.resource = "https://kb.example.test/caf%C3%A9".into();
1504 c.jwks_uri = Some("https://idp.example.test/keys?tenant=a".into());
1505 })
1506 .resolve(WIKI)
1507 .unwrap()
1508 .unwrap();
1509 }
1510
1511 #[test]
1512 fn plain_http_off_loopback_is_refused_unless_explicitly_allowed() {
1513 let err = enabled(|c| {
1514 c.issuer = "http://idp.internal.test/app/".into();
1515 c.jwks_uri = Some("http://idp.internal.test/app/jwks/".into());
1516 c.resource = "http://kb.internal.test/mcp".into();
1517 })
1518 .resolve(WIKI)
1519 .unwrap_err();
1520 assert_eq!(err.problems.len(), 3, "{err}");
1521 assert!(
1522 err.problems[0].starts_with(
1523 "mcp.oauth.issuer \"http://idp.internal.test/app/\" uses plain http on a \
1524 non-loopback host — signing keys"
1525 ),
1526 "{err}"
1527 );
1528 assert!(err.problems[0].contains("RFC 8414 §2"), "{err}");
1529 assert!(err.problems[1].starts_with("mcp.oauth.resource "), "{err}");
1530 assert!(err.problems[1].contains("RFC 9728 §1.2"), "{err}");
1531 assert!(err.problems[2].starts_with("mcp.oauth.jwks_uri "), "{err}");
1532 assert!(
1533 err.problems[2].contains("set mcp.oauth.allow_insecure_http"),
1534 "{err}"
1535 );
1536
1537 let oauth = enabled(|c| {
1539 c.issuer = "http://idp.internal.test/app/".into();
1540 c.jwks_uri = Some("http://idp.internal.test/app/jwks/".into());
1541 c.resource = "http://kb.internal.test/mcp".into();
1542 c.allow_insecure_http = true;
1543 })
1544 .resolve(WIKI)
1545 .unwrap()
1546 .unwrap();
1547 assert!(oauth.allow_insecure_http);
1548
1549 enabled(|c| {
1551 c.issuer = "http://127.0.0.1:9000/app/".into();
1552 c.jwks_uri = Some("http://[::1]:9000/jwks".into());
1553 c.resource = "http://localhost:8001/mcp".into();
1554 })
1555 .resolve(WIKI)
1556 .unwrap()
1557 .unwrap();
1558 }
1559
1560 #[test]
1563 fn no_required_scope_and_no_typ_check_is_refused_unless_explicitly_allowed() {
1564 let bare = |edit: fn(&mut OAuthConfig)| {
1565 let mut cfg = enabled(|c| c.allow_unscoped_tokens = false);
1566 edit(&mut cfg);
1567 cfg.resolve(WIKI)
1568 };
1569 let err = bare(|_| {}).unwrap_err();
1570 assert_eq!(err.problems.len(), 1, "{err}");
1571 assert_eq!(
1572 err.problems[0],
1573 "no required scope is configured (mcp.oauth.required_scope and \
1574 mcp.oauth.required_scopes are unset) and mcp.oauth.require_at_jwt is off — \
1575 nothing would tell an access token from an OIDC ID token minted for the same \
1576 client, so any token this issuer signs for the audience would be accepted. Set \
1577 mcp.oauth.required_scope to a scope only access tokens carry, turn on \
1578 mcp.oauth.require_at_jwt if the authorization server emits typ at+jwt, or set \
1579 mcp.oauth.allow_unscoped_tokens to accept that"
1580 );
1581 assert!(bare(|c| c.required_scope = Some("a".into())).is_ok());
1583 assert!(bare(|c| c.required_scopes = vec!["a".into()]).is_ok());
1584 assert!(bare(|c| c.require_at_jwt = true).is_ok());
1585 let allowed = bare(|c| c.allow_unscoped_tokens = true).unwrap().unwrap();
1586 assert!(allowed.required_scopes.is_empty());
1587 assert!(allowed.allow_unscoped_tokens);
1588 let err = bare(|c| c.required_scope = Some(String::new())).unwrap_err();
1590 assert_eq!(err.problems.len(), 1, "{err}");
1591 let err = OAuthConfig {
1593 enabled: true,
1594 ..OAuthConfig::default()
1595 }
1596 .resolve(KeyNaming::Env("APP_OAUTH_"))
1597 .unwrap_err();
1598 assert!(
1599 err.problems
1600 .iter()
1601 .any(|p| p.contains("set APP_OAUTH_ALLOW_UNSCOPED_TOKENS")),
1602 "{err}"
1603 );
1604 }
1605}