1use std::collections::BTreeMap;
12use std::fmt;
13
14use serde_json::Value;
15
16use crate::algorithms::{Algorithm, DEFAULT_ALGORITHMS, parse_algorithm};
17use crate::jwks::{debug_url, try_redact_url};
18use crate::token::for_log;
19use crate::validator::plain_http_non_loopback;
20
21pub const DEFAULT_SCOPE_CLAIMS: &[&str] = &["scope", "scp"];
29
30pub const DEFAULT_PRINCIPAL_CLAIMS: &[&str] = &["preferred_username", "sub"];
34
35pub const DEFAULT_LEEWAY_SECS: u64 = 60;
37
38pub const MAX_LEEWAY_SECS: u64 = 300;
43
44pub const MAX_TOKEN_AGE_SECS: u64 = 30 * 24 * 3600;
49
50pub(crate) const RESERVED_REQUIRED_CLAIMS: &[&str] = &["iss", "aud", "exp", "nbf", "iat", "cnf"];
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum KeyNaming<'a> {
68 Dotted(&'a str),
70 Env(&'a str),
72}
73
74impl KeyNaming<'_> {
75 pub fn key(&self, field: &str) -> String {
77 match self {
78 KeyNaming::Dotted("") => field.to_string(),
79 KeyNaming::Dotted(prefix) => format!("{prefix}.{field}"),
80 KeyNaming::Env(prefix) => format!("{prefix}{}", field.to_ascii_uppercase()),
81 }
82 }
83
84 pub fn section(&self) -> String {
89 match self {
90 KeyNaming::Dotted("") => "OAuth config".to_string(),
91 KeyNaming::Dotted(prefix) => prefix.to_string(),
92 KeyNaming::Env(prefix) => format!("{prefix}*"),
93 }
94 }
95
96 pub fn to_buf(&self) -> KeyNamingBuf {
98 match self {
99 KeyNaming::Dotted(p) => KeyNamingBuf::Dotted((*p).to_string()),
100 KeyNaming::Env(p) => KeyNamingBuf::Env((*p).to_string()),
101 }
102 }
103}
104
105#[derive(Debug, Clone, PartialEq, Eq)]
110#[non_exhaustive]
111pub enum KeyNamingBuf {
112 Dotted(String),
114 Env(String),
116}
117
118impl KeyNamingBuf {
119 pub fn as_naming(&self) -> KeyNaming<'_> {
121 match self {
122 KeyNamingBuf::Dotted(p) => KeyNaming::Dotted(p),
123 KeyNamingBuf::Env(p) => KeyNaming::Env(p),
124 }
125 }
126
127 pub fn key(&self, field: &str) -> String {
129 self.as_naming().key(field)
130 }
131
132 pub fn section(&self) -> String {
134 self.as_naming().section()
135 }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
147#[non_exhaustive]
148pub enum ProblemKind {
149 MissingRequired,
152 InvalidUrl,
157 InsecureHttp,
160 BlankRequiredScope,
162 MultiWordScope,
165 InvalidScopeToken,
167 EmptyListEntry,
170 NoRequiredScope,
173 EmptyScopeClaims,
175 BadAlgorithm,
177 NoAlgorithms,
179 LeewayTooLarge,
181 TokenAgeOutOfRange,
183 InvalidRequiredClaim,
188 EnvLoad,
191 EnvParse,
196 Other,
198}
199
200impl ProblemKind {
201 pub fn as_str(self) -> &'static str {
205 match self {
206 Self::MissingRequired => "missing_required",
207 Self::InvalidUrl => "invalid_url",
208 Self::InsecureHttp => "insecure_http",
209 Self::BlankRequiredScope => "blank_required_scope",
210 Self::MultiWordScope => "multi_word_scope",
211 Self::InvalidScopeToken => "invalid_scope_token",
212 Self::EmptyListEntry => "empty_list_entry",
213 Self::NoRequiredScope => "no_required_scope",
214 Self::EmptyScopeClaims => "empty_scope_claims",
215 Self::BadAlgorithm => "bad_algorithm",
216 Self::NoAlgorithms => "no_algorithms",
217 Self::LeewayTooLarge => "leeway_too_large",
218 Self::TokenAgeOutOfRange => "token_age_out_of_range",
219 Self::InvalidRequiredClaim => "invalid_required_claim",
220 Self::EnvLoad => "env_load",
221 Self::EnvParse => "env_parse",
222 Self::Other => "other",
223 }
224 }
225}
226
227impl fmt::Display for ProblemKind {
228 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229 f.write_str(self.as_str())
230 }
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
244#[non_exhaustive]
245pub struct ConfigProblem {
246 kind: ProblemKind,
247 keys: Vec<String>,
248 message: String,
249}
250
251impl ConfigProblem {
252 pub fn new(
255 kind: ProblemKind,
256 keys: impl IntoIterator<Item = impl Into<String>>,
257 message: impl Into<String>,
258 ) -> Self {
259 Self {
260 kind,
261 keys: keys.into_iter().map(Into::into).collect(),
262 message: message.into(),
263 }
264 }
265
266 pub fn kind(&self) -> ProblemKind {
268 self.kind
269 }
270
271 pub fn keys(&self) -> &[String] {
275 &self.keys
276 }
277
278 pub fn message(&self) -> &str {
281 &self.message
282 }
283}
284
285impl From<String> for ConfigProblem {
286 fn from(message: String) -> Self {
288 Self::new(ProblemKind::Other, Vec::<String>::new(), message)
289 }
290}
291
292impl fmt::Display for ConfigProblem {
293 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294 f.write_str(&self.message)
295 }
296}
297
298#[derive(Debug, Clone, thiserror::Error)]
317pub struct ConfigError {
318 pub problems: Vec<String>,
325 details: Vec<ConfigProblem>,
326 naming: KeyNamingBuf,
327}
328
329impl ConfigError {
330 pub fn new(naming: KeyNaming<'_>, problems: Vec<String>) -> Self {
340 let details = problems.iter().cloned().map(ConfigProblem::from).collect();
341 Self::assemble(naming, problems, details)
342 }
343
344 pub fn from_problems(
352 naming: KeyNaming<'_>,
353 problems: impl IntoIterator<Item = ConfigProblem>,
354 ) -> Self {
355 let details: Vec<ConfigProblem> = problems.into_iter().collect();
356 let text = details.iter().map(|p| p.message.clone()).collect();
357 Self::assemble(naming, text, details)
358 }
359
360 fn assemble(naming: KeyNaming<'_>, problems: Vec<String>, details: Vec<ConfigProblem>) -> Self {
362 debug_assert!(!problems.is_empty(), "ConfigError built with no problems");
363 debug_assert_eq!(problems.len(), details.len());
364 Self {
365 problems,
366 details,
367 naming: naming.to_buf(),
368 }
369 }
370
371 pub fn problem_details(&self) -> &[ConfigProblem] {
404 &self.details
405 }
406
407 pub fn naming(&self) -> KeyNaming<'_> {
409 self.naming.as_naming()
410 }
411}
412
413impl PartialEq for ConfigError {
417 fn eq(&self, other: &Self) -> bool {
418 self.problems == other.problems && self.naming == other.naming
419 }
420}
421
422impl Eq for ConfigError {}
423
424impl fmt::Display for ConfigError {
425 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426 let list = self.problems.join("\n - ");
427 match &self.naming {
428 KeyNamingBuf::Dotted(_) => {
429 let enabled = self.naming.key("enabled");
430 write!(
431 f,
432 "{enabled} is true but the OAuth config is not usable:\n - {list}\n\
433 Fix these, or set {enabled}: false."
434 )
435 }
436 KeyNamingBuf::Env(_) => {
437 let section = self.naming.section();
438 write!(
439 f,
440 "OAuth is configured through {section} but the config is not usable:\n \
441 - {list}\nFix these, or unset every {section} variable."
442 )
443 }
444 }
445 }
446}
447
448#[derive(Clone, PartialEq, Eq)]
482#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
483#[cfg_attr(feature = "serde", serde(deny_unknown_fields))]
484pub struct OAuthConfig {
485 #[cfg_attr(feature = "serde", serde(default))]
489 pub enabled: bool,
490 #[cfg_attr(feature = "serde", serde(default))]
513 pub issuer: String,
514 #[cfg_attr(
529 feature = "serde",
530 serde(default, skip_serializing_if = "Option::is_none")
531 )]
532 pub jwks_uri: Option<String>,
533 #[cfg_attr(feature = "serde", serde(default))]
553 pub audience: String,
554 #[cfg_attr(feature = "serde", serde(default))]
558 pub audiences: Vec<String>,
559 #[cfg_attr(feature = "serde", serde(default))]
580 pub resource: String,
581 #[cfg_attr(
598 feature = "serde",
599 serde(default, skip_serializing_if = "Option::is_none")
600 )]
601 pub required_scope: Option<String>,
602 #[cfg_attr(feature = "serde", serde(default))]
606 pub required_scopes: Vec<String>,
607 #[cfg_attr(
623 feature = "serde",
624 serde(default, skip_serializing_if = "Option::is_none")
625 )]
626 pub scopes_supported: Option<Vec<String>>,
627 #[cfg_attr(feature = "serde", serde(default = "default_scope_claims"))]
633 pub scope_claims: Vec<String>,
634 #[cfg_attr(feature = "serde", serde(default = "default_principal_claims"))]
641 pub principal_claims: Vec<String>,
642 #[cfg_attr(feature = "serde", serde(default = "default_algorithms"))]
649 pub algorithms: Vec<String>,
650 #[cfg_attr(feature = "serde", serde(default = "default_leeway_secs"))]
654 pub leeway_secs: u64,
655 #[cfg_attr(feature = "serde", serde(default))]
667 pub require_at_jwt: bool,
668 #[cfg_attr(feature = "serde", serde(default))]
677 pub allow_unscoped_tokens: bool,
678 #[cfg_attr(feature = "serde", serde(default))]
698 pub allow_insecure_http: bool,
699 #[cfg_attr(feature = "serde", serde(default = "default_true"))]
705 pub accept_static_bearer: bool,
706 #[cfg_attr(feature = "serde", serde(default))]
745 pub allowed_client_ids: Vec<String>,
746 #[cfg_attr(feature = "serde", serde(default))]
783 pub max_token_age_secs: Option<u64>,
784 #[cfg_attr(
854 feature = "serde",
855 serde(default, deserialize_with = "required_claims_without_duplicates")
856 )]
857 pub required_claims: BTreeMap<String, Value>,
858}
859
860#[cfg(feature = "serde")]
865fn required_claims_without_duplicates<'de, D>(
866 deserializer: D,
867) -> Result<BTreeMap<String, Value>, D::Error>
868where
869 D: serde::Deserializer<'de>,
870{
871 struct Claims;
872 impl<'de> serde::de::Visitor<'de> for Claims {
873 type Value = BTreeMap<String, Value>;
874 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
875 f.write_str("a map of claim names to required values")
876 }
877 fn visit_map<A: serde::de::MapAccess<'de>>(
878 self,
879 mut map: A,
880 ) -> Result<Self::Value, A::Error> {
881 let mut claims = BTreeMap::new();
882 while let Some((name, value)) = map.next_entry::<String, Value>()? {
883 if claims.contains_key(&name) {
884 return Err(serde::de::Error::custom(format!(
885 "required_claims names {:?} more than once",
886 crate::token::for_log(&name)
887 )));
888 }
889 claims.insert(name, value);
890 }
891 Ok(claims)
892 }
893 }
894 deserializer.deserialize_map(Claims)
895}
896
897impl fmt::Debug for OAuthConfig {
900 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
901 let Self {
903 enabled,
904 issuer,
905 jwks_uri,
906 audience,
907 audiences,
908 resource,
909 required_scope,
910 required_scopes,
911 scopes_supported,
912 scope_claims,
913 principal_claims,
914 algorithms,
915 leeway_secs,
916 require_at_jwt,
917 allow_unscoped_tokens,
918 allow_insecure_http,
919 accept_static_bearer,
920 allowed_client_ids,
921 max_token_age_secs,
922 required_claims,
923 } = self;
924 f.debug_struct("OAuthConfig")
925 .field("enabled", enabled)
926 .field("issuer", &debug_url(issuer))
927 .field("jwks_uri", &jwks_uri.as_deref().map(debug_url))
928 .field("audience", audience)
929 .field("audiences", audiences)
930 .field("resource", &debug_url(resource))
931 .field("required_scope", required_scope)
932 .field("required_scopes", required_scopes)
933 .field("scopes_supported", scopes_supported)
934 .field("scope_claims", scope_claims)
935 .field("principal_claims", principal_claims)
936 .field("algorithms", algorithms)
937 .field("leeway_secs", leeway_secs)
938 .field("require_at_jwt", require_at_jwt)
939 .field("allow_unscoped_tokens", allow_unscoped_tokens)
940 .field("allow_insecure_http", allow_insecure_http)
941 .field("accept_static_bearer", accept_static_bearer)
942 .field("allowed_client_ids", allowed_client_ids)
943 .field("max_token_age_secs", max_token_age_secs)
944 .field("required_claims", required_claims)
945 .finish()
946 }
947}
948
949impl Default for OAuthConfig {
950 fn default() -> Self {
951 Self {
952 enabled: false,
953 issuer: String::new(),
954 jwks_uri: None,
955 audience: String::new(),
956 audiences: Vec::new(),
957 resource: String::new(),
958 required_scope: None,
959 required_scopes: Vec::new(),
960 scopes_supported: None,
961 scope_claims: default_scope_claims(),
962 principal_claims: default_principal_claims(),
963 algorithms: default_algorithms(),
964 leeway_secs: default_leeway_secs(),
965 require_at_jwt: false,
966 allow_unscoped_tokens: false,
967 allow_insecure_http: false,
968 accept_static_bearer: true,
969 allowed_client_ids: Vec::new(),
970 max_token_age_secs: None,
971 required_claims: BTreeMap::new(),
972 }
973 }
974}
975
976fn strings(list: &[&str]) -> Vec<String> {
977 list.iter().map(|s| s.to_string()).collect()
978}
979
980fn default_scope_claims() -> Vec<String> {
981 strings(DEFAULT_SCOPE_CLAIMS)
982}
983
984fn default_principal_claims() -> Vec<String> {
985 strings(DEFAULT_PRINCIPAL_CLAIMS)
986}
987
988fn default_algorithms() -> Vec<String> {
989 strings(DEFAULT_ALGORITHMS)
990}
991
992fn default_leeway_secs() -> u64 {
993 DEFAULT_LEEWAY_SECS
994}
995
996#[cfg(feature = "serde")]
997fn default_true() -> bool {
998 true
999}
1000
1001impl OAuthConfig {
1002 pub fn resolve(
1064 self,
1065 naming: KeyNaming<'_>,
1066 ) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
1067 if !self.enabled {
1068 return Ok(None);
1069 }
1070 let key = |field: &str| naming.key(field);
1071 let mut problems: Vec<ConfigProblem> = Vec::new();
1072
1073 let mut blank_fields: Vec<&str> = Vec::new();
1074 for (name, value) in [("issuer", &self.issuer), ("resource", &self.resource)] {
1075 if value.trim().is_empty() {
1076 blank_fields.push(name);
1077 }
1078 }
1079 if self.audience.trim().is_empty() && self.audiences.is_empty() {
1080 blank_fields.push("audience");
1081 }
1082 if !blank_fields.is_empty() {
1083 let blank: Vec<String> = blank_fields
1085 .iter()
1086 .map(|&f| match f {
1087 "audience" => format!("{} (or {})", key("audience"), key("audiences")),
1088 _ => key(f),
1089 })
1090 .collect();
1091 let blank_keys: Vec<String> = blank_fields
1092 .iter()
1093 .flat_map(|&f| match f {
1094 "audience" => vec![key("audience"), key("audiences")],
1095 _ => vec![key(f)],
1096 })
1097 .collect();
1098 problems.push(ConfigProblem::new(
1099 ProblemKind::MissingRequired,
1100 blank_keys,
1101 format!(
1102 "these required settings are empty: {}. Set issuer to the authorization \
1103 server's issuer (byte-exact, including any trailing slash), resource to \
1104 this server's public URL, and audience to what that server puts in \
1105 an access token's `aud` — the resource URL if it honours RFC 8707 or \
1106 lets you configure an audience (e.g. Authelia), or the OAuth client_id \
1107 if it stamps that (e.g. Authentik, Kanidm)",
1108 blank.join(", ")
1109 ),
1110 ));
1111 }
1112
1113 let urls = [
1114 ("issuer", Some(&self.issuer), true),
1115 ("resource", Some(&self.resource), true),
1116 ("jwks_uri", self.jwks_uri.as_ref(), false),
1117 ];
1118 for (name, value, identifier) in urls {
1119 let Some(value) = value.filter(|v| !v.trim().is_empty()) else {
1120 continue;
1121 };
1122 match check_url(&key(name), value, identifier) {
1123 Err(e) => {
1124 problems.push(ConfigProblem::new(ProblemKind::InvalidUrl, [key(name)], e));
1125 }
1126 Ok(()) if !self.allow_insecure_http && plain_http_non_loopback(value) => {
1127 problems.push(ConfigProblem::new(
1128 ProblemKind::InsecureHttp,
1129 [key(name), key("allow_insecure_http")],
1130 format!(
1131 "{} uses plain http on a non-loopback host — {}. Use \
1132 https, or set {} if this address is on a network you trust",
1133 shown(&key(name), value),
1134 if name == "resource" {
1135 "bearer tokens sent to it can be read in transit (RFC 9728 \
1136 §1.2 requires https)"
1137 } else {
1138 "signing keys fetched over it can be substituted by anyone on \
1139 the path (RFC 8414 §2 requires https)"
1140 },
1141 key("allow_insecure_http")
1142 ),
1143 ));
1144 }
1145 Ok(()) => {}
1146 }
1147 }
1148 if self.audiences.iter().any(|a| a.trim().is_empty()) {
1149 problems.push(ConfigProblem::new(
1150 ProblemKind::EmptyListEntry,
1151 [key("audiences")],
1152 format!("{} contains an empty entry", key("audiences")),
1153 ));
1154 }
1155
1156 if let Some(required_scope) = &self.required_scope {
1157 let k = key("required_scope");
1158 if required_scope.trim().is_empty() {
1159 problems.push(ConfigProblem::new(
1160 ProblemKind::BlankRequiredScope,
1161 [k.clone()],
1162 format!(
1163 "{k} must not be empty — a blank required scope would let any signed \
1164 token through unscoped. Use a scope your authorization server \
1165 actually issues"
1166 ),
1167 ));
1168 } else if required_scope.split_whitespace().count() != 1 {
1169 problems.push(ConfigProblem::new(
1170 ProblemKind::MultiWordScope,
1171 [k.clone()],
1172 format!(
1173 "{k} {required_scope:?} must be a single scope (no spaces) — scopes \
1174 are matched one token at a time"
1175 ),
1176 ));
1177 } else if !is_scope_token(required_scope.trim()) {
1178 problems.push(ConfigProblem::new(
1179 ProblemKind::InvalidScopeToken,
1180 [k.clone()],
1181 scope_token_problem(&k, required_scope),
1182 ));
1183 }
1184 }
1185 for scope in &self.required_scopes {
1186 let k = key("required_scopes");
1187 if scope.trim().is_empty() {
1188 problems.push(ConfigProblem::new(
1189 ProblemKind::BlankRequiredScope,
1190 [k.clone()],
1191 format!(
1192 "{k} contains an empty entry — a blank required scope would let a \
1193 token through without it. Remove the entry or name a scope your \
1194 authorization server actually issues"
1195 ),
1196 ));
1197 } else if scope.split_whitespace().count() != 1 {
1198 problems.push(ConfigProblem::new(
1199 ProblemKind::MultiWordScope,
1200 [k.clone()],
1201 format!(
1202 "{k} entry {scope:?} must be a single scope (no spaces) — scopes are \
1203 matched one token at a time; list each one as its own entry"
1204 ),
1205 ));
1206 } else if !is_scope_token(scope.trim()) {
1207 problems.push(ConfigProblem::new(
1208 ProblemKind::InvalidScopeToken,
1209 [k.clone()],
1210 scope_token_problem(&format!("{k} entry"), scope),
1211 ));
1212 }
1213 }
1214 if let Some(supported) = &self.scopes_supported {
1215 let k = key("scopes_supported");
1216 if supported.iter().any(|s| s.trim().is_empty()) {
1217 problems.push(ConfigProblem::new(
1218 ProblemKind::EmptyListEntry,
1219 [k.clone()],
1220 format!("{k} contains an empty entry"),
1221 ));
1222 }
1223 for scope in supported.iter().filter(|s| !s.trim().is_empty()) {
1224 if !is_scope_token(scope.trim()) {
1225 problems.push(ConfigProblem::new(
1226 ProblemKind::InvalidScopeToken,
1227 [k.clone()],
1228 scope_token_problem(&format!("{k} entry"), scope),
1229 ));
1230 }
1231 }
1232 }
1233 if self.required_scope.is_none()
1234 && self.required_scopes.is_empty()
1235 && !self.require_at_jwt
1236 && !self.allow_unscoped_tokens
1237 {
1238 problems.push(ConfigProblem::new(
1239 ProblemKind::NoRequiredScope,
1240 [
1241 key("required_scope"),
1242 key("required_scopes"),
1243 key("require_at_jwt"),
1244 key("allow_unscoped_tokens"),
1245 ],
1246 format!(
1247 "no required scope is configured ({} and {} are unset) and {} is off — \
1248 nothing would tell an access token from an OIDC ID token minted for the \
1249 same client, so any token this issuer signs for the audience would be \
1250 accepted. Set {} to a scope only access tokens carry, turn on {} if the \
1251 authorization server emits typ at+jwt, or set {} to accept that",
1252 key("required_scope"),
1253 key("required_scopes"),
1254 key("require_at_jwt"),
1255 key("required_scope"),
1256 key("require_at_jwt"),
1257 key("allow_unscoped_tokens")
1258 ),
1259 ));
1260 }
1261 if self.scope_claims.is_empty() || self.scope_claims.iter().any(|c| c.trim().is_empty()) {
1262 problems.push(ConfigProblem::new(
1263 ProblemKind::EmptyScopeClaims,
1264 [key("scope_claims")],
1265 format!(
1266 "{} must list at least one non-empty claim name (default: [\"scope\", \
1267 \"scp\"])",
1268 key("scope_claims")
1269 ),
1270 ));
1271 }
1272 if self.principal_claims.iter().any(|c| c.trim().is_empty()) {
1273 problems.push(ConfigProblem::new(
1274 ProblemKind::EmptyListEntry,
1275 [key("principal_claims")],
1276 format!("{} contains an empty entry", key("principal_claims")),
1277 ));
1278 }
1279
1280 let mut algorithms = Vec::new();
1281 let mut bad_algorithms = Vec::new();
1282 for name in &self.algorithms {
1283 match parse_algorithm(name) {
1284 Ok(alg) if !algorithms.contains(&alg) => algorithms.push(alg),
1285 Ok(_) => {}
1286 Err(reason) => bad_algorithms.push(reason.to_string()),
1287 }
1288 }
1289 if !bad_algorithms.is_empty() {
1290 problems.push(ConfigProblem::new(
1291 ProblemKind::BadAlgorithm,
1292 [key("algorithms")],
1293 format!(
1294 "{} has unacceptable entries: {}",
1295 key("algorithms"),
1296 bad_algorithms.join("; ")
1297 ),
1298 ));
1299 } else if algorithms.is_empty() {
1300 problems.push(ConfigProblem::new(
1301 ProblemKind::NoAlgorithms,
1302 [key("algorithms")],
1303 format!("{} must list at least one algorithm", key("algorithms")),
1304 ));
1305 }
1306
1307 if self.leeway_secs > MAX_LEEWAY_SECS {
1308 problems.push(ConfigProblem::new(
1309 ProblemKind::LeewayTooLarge,
1310 [key("leeway_secs")],
1311 format!(
1312 "{} {} is over the {}-second cap — leeway is for clock drift, not for \
1313 extending token lifetimes",
1314 key("leeway_secs"),
1315 self.leeway_secs,
1316 MAX_LEEWAY_SECS
1317 ),
1318 ));
1319 }
1320
1321 if self.allowed_client_ids.iter().any(|c| c.trim().is_empty()) {
1322 problems.push(ConfigProblem::new(
1323 ProblemKind::EmptyListEntry,
1324 [key("allowed_client_ids")],
1325 format!(
1326 "{} contains an empty entry — list the OAuth client IDs whose tokens \
1327 are accepted",
1328 key("allowed_client_ids")
1329 ),
1330 ));
1331 }
1332 if let Some(age) = self.max_token_age_secs
1333 && !(1..=MAX_TOKEN_AGE_SECS).contains(&age)
1334 {
1335 problems.push(ConfigProblem::new(
1336 ProblemKind::TokenAgeOutOfRange,
1337 [key("max_token_age_secs")],
1338 format!(
1339 "{} {age} is outside 1..={MAX_TOKEN_AGE_SECS} seconds (30 days) — leave it \
1340 unset to not bound token age",
1341 key("max_token_age_secs")
1342 ),
1343 ));
1344 }
1345 for (name, value) in &self.required_claims {
1346 let k = key("required_claims");
1347 let why = if name.trim().is_empty() {
1348 Some("has an entry with a blank claim name".to_string())
1349 } else if RESERVED_REQUIRED_CLAIMS.contains(&name.as_str()) {
1350 Some(format!(
1351 "names {name:?}, which this crate already checks (iss, aud, exp, nbf, iat, \
1352 cnf) — use issuer/audience, leeway_secs or max_token_age_secs instead"
1353 ))
1354 } else if !matches!(value, Value::String(_) | Value::Number(_) | Value::Bool(_)) {
1355 let json_type = match value {
1359 Value::Null => "null",
1360 Value::Array(_) => "an array",
1361 _ => "an object",
1362 };
1363 Some(format!(
1364 "entry {name:?} requires {json_type}, but a required value must be a \
1365 string, number or boolean (a token's array claim passes when it contains \
1366 it)"
1367 ))
1368 } else {
1369 None
1370 };
1371 if let Some(why) = why {
1372 problems.push(ConfigProblem::new(
1373 ProblemKind::InvalidRequiredClaim,
1374 [k.clone()],
1375 format!("{k} {why}"),
1376 ));
1377 }
1378 }
1379
1380 if !problems.is_empty() {
1381 return Err(ConfigError::from_problems(naming, problems));
1382 }
1383
1384 let mut required_scopes: Vec<String> = Vec::new();
1388 for scope in self
1389 .required_scope
1390 .iter()
1391 .chain(self.required_scopes.iter())
1392 {
1393 let scope = scope.trim();
1394 if !required_scopes.iter().any(|s| s == scope) {
1395 required_scopes.push(scope.to_string());
1396 }
1397 }
1398
1399 let scopes_supported = match self.scopes_supported {
1402 Some(listed) => listed.iter().map(|s| s.trim().to_string()).collect(),
1403 None => required_scopes.clone(),
1404 };
1405
1406 Ok(Some(ResolvedOAuthConfig {
1407 issuer: self.issuer,
1408 jwks_uri: self.jwks_uri.filter(|u| !u.trim().is_empty()),
1409 audience: self.audience,
1410 audiences: self.audiences,
1411 resource: self.resource,
1412 required_scopes,
1413 scopes_supported,
1414 scope_claims: self.scope_claims,
1415 principal_claims: self.principal_claims,
1416 algorithms,
1417 leeway_secs: self.leeway_secs,
1418 require_at_jwt: self.require_at_jwt,
1419 allow_unscoped_tokens: self.allow_unscoped_tokens,
1420 allow_insecure_http: self.allow_insecure_http,
1421 accept_static_bearer: self.accept_static_bearer,
1422 allowed_client_ids: self.allowed_client_ids,
1423 max_token_age_secs: self.max_token_age_secs,
1424 required_claims: self.required_claims,
1425 resource_name: None,
1426 key_naming: naming.to_buf(),
1427 }))
1428 }
1429}
1430
1431fn check_url(key: &str, value: &str, identifier: bool) -> Result<(), String> {
1448 let parsed = reqwest::Url::parse(value.trim()).map_err(|e| {
1451 format!(
1452 "{} is not an absolute URL ({e})",
1453 shown_unparsed(key, value)
1454 )
1455 })?;
1456 if !matches!(parsed.scheme(), "https" | "http") {
1457 return Err(format!("{} must be an http(s) URL", shown(key, value)));
1458 }
1459 if identifier && (parsed.fragment().is_some() || parsed.query().is_some()) {
1460 return Err(format!(
1461 "{} must not contain a query or fragment",
1462 shown(key, value)
1463 ));
1464 }
1465 if value != value.trim() {
1466 return Err(format!(
1467 "{} has leading/trailing whitespace — it is compared byte-for-byte",
1468 shown(key, value)
1469 ));
1470 }
1471 if value.chars().any(|c| !c.is_ascii_graphic()) {
1472 return Err(format!(
1473 "{} contains a space, a control character or a non-ASCII character — write it \
1474 percent-encoded (and an internationalized host in its punycode form)",
1475 shown(key, value)
1476 ));
1477 }
1478 Ok(())
1479}
1480
1481fn shown(key: &str, value: &str) -> String {
1487 match try_redact_url(value) {
1488 Some(redacted) if redacted == value => format!("{key} {redacted:?}"),
1489 Some(redacted) => format!("{key} {redacted:?} (shown normalized, credential masked)"),
1490 None => shown_unparsed(key, value),
1491 }
1492}
1493
1494fn shown_unparsed(key: &str, value: &str) -> String {
1499 let safe = !value.is_empty()
1500 && value
1501 .chars()
1502 .all(|c| c.is_ascii_graphic() && !matches!(c, '@' | '?' | '#'));
1503 if safe {
1504 format!("{key} {:?}", for_log(value))
1505 } else {
1506 key.to_string()
1507 }
1508}
1509
1510pub(crate) fn is_scope_token(scope: &str) -> bool {
1515 !scope.is_empty()
1516 && scope
1517 .bytes()
1518 .all(|b| b == 0x21 || (0x23..=0x5B).contains(&b) || (0x5D..=0x7E).contains(&b))
1519}
1520
1521fn scope_token_problem(what: &str, scope: &str) -> String {
1522 format!(
1523 "{what} {scope:?} is not a valid scope — a scope is printable ASCII with no space, \
1524 '\"' or '\\' (RFC 6749 §3.3)"
1525 )
1526}
1527
1528#[derive(Clone, PartialEq, Eq)]
1546#[non_exhaustive]
1547pub struct ResolvedOAuthConfig {
1548 pub issuer: String,
1550 pub jwks_uri: Option<String>,
1552 pub audience: String,
1555 pub audiences: Vec<String>,
1557 pub resource: String,
1559 pub required_scopes: Vec<String>,
1563 pub scopes_supported: Vec<String>,
1567 pub scope_claims: Vec<String>,
1569 pub principal_claims: Vec<String>,
1571 pub algorithms: Vec<Algorithm>,
1574 pub leeway_secs: u64,
1576 pub require_at_jwt: bool,
1578 pub allow_unscoped_tokens: bool,
1581 pub allow_insecure_http: bool,
1585 pub accept_static_bearer: bool,
1587 pub allowed_client_ids: Vec<String>,
1589 pub max_token_age_secs: Option<u64>,
1591 pub required_claims: BTreeMap<String, Value>,
1593 pub resource_name: Option<String>,
1599 pub key_naming: KeyNamingBuf,
1602}
1603
1604impl fmt::Debug for ResolvedOAuthConfig {
1607 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1608 let Self {
1610 issuer,
1611 jwks_uri,
1612 audience,
1613 audiences,
1614 resource,
1615 required_scopes,
1616 scopes_supported,
1617 scope_claims,
1618 principal_claims,
1619 algorithms,
1620 leeway_secs,
1621 require_at_jwt,
1622 allow_unscoped_tokens,
1623 allow_insecure_http,
1624 accept_static_bearer,
1625 allowed_client_ids,
1626 max_token_age_secs,
1627 required_claims,
1628 resource_name,
1629 key_naming,
1630 } = self;
1631 f.debug_struct("ResolvedOAuthConfig")
1632 .field("issuer", &debug_url(issuer))
1633 .field("jwks_uri", &jwks_uri.as_deref().map(debug_url))
1634 .field("audience", audience)
1635 .field("audiences", audiences)
1636 .field("resource", &debug_url(resource))
1637 .field("required_scopes", required_scopes)
1638 .field("scopes_supported", scopes_supported)
1639 .field("scope_claims", scope_claims)
1640 .field("principal_claims", principal_claims)
1641 .field("algorithms", algorithms)
1642 .field("leeway_secs", leeway_secs)
1643 .field("require_at_jwt", require_at_jwt)
1644 .field("allow_unscoped_tokens", allow_unscoped_tokens)
1645 .field("allow_insecure_http", allow_insecure_http)
1646 .field("accept_static_bearer", accept_static_bearer)
1647 .field("allowed_client_ids", allowed_client_ids)
1648 .field("max_token_age_secs", max_token_age_secs)
1649 .field("required_claims", required_claims)
1650 .field("resource_name", resource_name)
1651 .field("key_naming", key_naming)
1652 .finish()
1653 }
1654}
1655
1656impl ResolvedOAuthConfig {
1657 pub fn accepted_audiences(&self) -> Vec<String> {
1659 let mut out: Vec<String> = Vec::new();
1660 for a in std::iter::once(&self.audience).chain(self.audiences.iter()) {
1661 if !a.trim().is_empty() && !out.contains(a) {
1662 out.push(a.clone());
1663 }
1664 }
1665 out
1666 }
1667}
1668
1669#[cfg(test)]
1670mod tests {
1671 use super::*;
1672
1673 const WIKI: KeyNaming<'static> = KeyNaming::Dotted("mcp.oauth");
1674
1675 fn enabled(edit: impl FnOnce(&mut OAuthConfig)) -> OAuthConfig {
1680 let mut cfg = OAuthConfig {
1681 enabled: true,
1682 issuer: "https://idp.example.test/".into(),
1683 resource: "https://kb.example.test/mcp".into(),
1684 audience: "c".into(),
1685 allow_unscoped_tokens: true,
1686 ..OAuthConfig::default()
1687 };
1688 edit(&mut cfg);
1689 cfg
1690 }
1691
1692 fn resolve_err(cfg: OAuthConfig) -> String {
1693 cfg.resolve(WIKI).unwrap_err().to_string()
1694 }
1695
1696 #[test]
1699 fn oauth_is_disabled_by_default_and_has_no_default_scope() {
1700 let cfg = OAuthConfig::default();
1701 assert!(!cfg.enabled);
1702 assert_eq!(cfg.required_scope, None);
1705 assert!(cfg.required_scopes.is_empty());
1706 assert_eq!(cfg.scopes_supported, None);
1707 assert_eq!(cfg.jwks_uri, None);
1708 assert_eq!(cfg.scope_claims, ["scope", "scp"]);
1709 assert_eq!(cfg.principal_claims, ["preferred_username", "sub"]);
1710 assert_eq!(cfg.algorithms, DEFAULT_ALGORITHMS);
1711 assert_eq!(cfg.leeway_secs, 60);
1712 assert!(!cfg.require_at_jwt);
1713 assert!(!cfg.allow_unscoped_tokens);
1714 assert!(!cfg.allow_insecure_http);
1715 assert!(cfg.accept_static_bearer);
1716 }
1717
1718 #[test]
1719 fn disabled_resolves_to_none() {
1720 assert_eq!(OAuthConfig::default().resolve(WIKI), Ok(None));
1721 let cfg = OAuthConfig {
1723 algorithms: vec!["HS256".into()],
1724 ..OAuthConfig::default()
1725 };
1726 assert_eq!(cfg.resolve(WIKI), Ok(None));
1727 }
1728
1729 #[test]
1730 fn a_minimal_enabled_block_resolves_with_every_default() {
1731 let cfg = OAuthConfig {
1732 enabled: true,
1733 issuer: "https://authentik.example.test/application/o/example-app/".into(),
1734 jwks_uri: Some("https://authentik.example.test/application/o/example-app/jwks/".into()),
1735 audience: "some-client-id".into(),
1736 resource: "https://kb.example.test/mcp".into(),
1737 required_scope: Some("api:read".into()),
1738 ..OAuthConfig::default()
1739 };
1740 let oauth = cfg.resolve(WIKI).unwrap().expect("enabled");
1741 assert_eq!(
1744 oauth.issuer,
1745 "https://authentik.example.test/application/o/example-app/"
1746 );
1747 assert_eq!(oauth.audience, "some-client-id");
1748 assert_eq!(oauth.resource, "https://kb.example.test/mcp");
1749 assert_eq!(oauth.required_scopes, ["api:read"]);
1750 assert_eq!(oauth.scopes_supported, ["api:read"]);
1752 assert_eq!(oauth.accepted_audiences(), ["some-client-id"]);
1753 assert_eq!(oauth.scope_claims, ["scope", "scp"]);
1754 assert_eq!(oauth.principal_claims, ["preferred_username", "sub"]);
1755 assert!(oauth.algorithms.contains(&Algorithm::RS256));
1756 assert_eq!(oauth.leeway_secs, 60);
1757 assert!(!oauth.require_at_jwt);
1758 assert!(!oauth.allow_unscoped_tokens);
1759 assert!(!oauth.allow_insecure_http);
1760 assert!(oauth.accept_static_bearer);
1761 assert_eq!(oauth.resource_name, None);
1762 assert_eq!(oauth.key_naming, KeyNamingBuf::Dotted("mcp.oauth".into()));
1763 }
1764
1765 #[test]
1768 fn enabled_with_blank_required_settings_is_rejected_naming_all_of_them() {
1769 let cfg = OAuthConfig {
1770 enabled: true,
1771 ..OAuthConfig::default()
1772 };
1773 let err = resolve_err(cfg);
1774 for setting in [
1775 "mcp.oauth.issuer",
1776 "mcp.oauth.audience",
1777 "mcp.oauth.resource",
1778 ] {
1779 assert!(err.contains(setting), "{setting} missing from: {err}");
1780 }
1781 assert!(!err.contains("mcp.oauth.jwks_uri"), "{err}");
1783 }
1784
1785 const WIKI_REWRITES: [(&str, &str); 2] = [
1798 ("this server's public URL,", "this server's public MCP URL,"),
1799 (
1800 "unscoped. Use a scope your",
1801 "unscoped. Use \"mcp:read\" (the default) or a scope your",
1802 ),
1803 ];
1804
1805 fn as_wiki_text(problem: &str) -> String {
1806 WIKI_REWRITES
1807 .iter()
1808 .fold(problem.to_string(), |p, (generic, wiki)| {
1809 p.replace(generic, wiki)
1810 })
1811 }
1812
1813 #[test]
1814 fn config_error_display_for_dotted_naming_is_generic_and_maps_to_mcp_md_wiki_text() {
1815 let cfg = OAuthConfig {
1818 enabled: true,
1819 required_scope: Some("mcp:read".into()),
1820 ..OAuthConfig::default()
1821 };
1822 let expected = "mcp.oauth.enabled is true but the OAuth config is not usable:\n - \
1824these required settings are empty: mcp.oauth.issuer, mcp.oauth.resource, mcp.oauth.audience \
1825(or mcp.oauth.audiences). Set issuer to the authorization server's issuer (byte-exact, \
1826including any trailing slash), resource to this server's public URL, and audience to \
1827what that server puts in an access token's `aud` — the resource URL if it honours RFC 8707 \
1828or lets you configure an audience (e.g. Authelia), or the OAuth client_id if it stamps that \
1829(e.g. Authentik, Kanidm)\nFix these, or set mcp.oauth.enabled: false.";
1830 let mut err = cfg.resolve(WIKI).unwrap_err();
1831 assert_eq!(err.to_string(), expected);
1832 assert_eq!(err.problems[0].matches(WIKI_REWRITES[0].0).count(), 1);
1833
1834 let wiki_expected = "mcp.oauth.enabled is true but the OAuth config is not usable:\n - \
1837these required settings are empty: mcp.oauth.issuer, mcp.oauth.resource, mcp.oauth.audience \
1838(or mcp.oauth.audiences). Set issuer to the authorization server's issuer (byte-exact, \
1839including any trailing slash), resource to this server's public MCP URL, and audience to \
1840what that server puts in an access token's `aud` — the resource URL if it honours RFC 8707 \
1841or lets you configure an audience (e.g. Authelia), or the OAuth client_id if it stamps that \
1842(e.g. Authentik, Kanidm)\nFix these, or set mcp.oauth.enabled: false.";
1843 err.problems = err.problems.iter().map(|p| as_wiki_text(p)).collect();
1844 assert_eq!(err.to_string(), wiki_expected);
1845 }
1846
1847 #[test]
1848 fn scope_problem_messages_are_generic_and_map_to_mcp_md_wiki_text() {
1849 let err = enabled(|c| c.required_scope = Some(String::new()))
1850 .resolve(WIKI)
1851 .unwrap_err();
1852 assert_eq!(
1853 err.problems,
1854 [
1855 "mcp.oauth.required_scope must not be empty — a blank required scope would \
1856 let any signed token through unscoped. Use a scope your authorization server \
1857 actually issues"
1858 ]
1859 );
1860 assert_eq!(err.problems[0].matches(WIKI_REWRITES[1].0).count(), 1);
1861 assert_eq!(
1863 as_wiki_text(&err.problems[0]),
1864 "mcp.oauth.required_scope must not be empty — a blank required scope would \
1865 let any signed token through unscoped. Use \"mcp:read\" (the default) or a \
1866 scope your authorization server actually issues"
1867 );
1868 let text = OAuthConfig {
1870 enabled: true,
1871 required_scope: Some(String::new()),
1872 ..OAuthConfig::default()
1873 }
1874 .resolve(KeyNaming::Env("APP_OAUTH_"))
1875 .unwrap_err()
1876 .to_string();
1877 assert!(!text.contains("MCP") && !text.contains("mcp"), "{text}");
1878 let err = enabled(|c| c.required_scope = Some("mcp:read mcp:write".into()))
1879 .resolve(WIKI)
1880 .unwrap_err();
1881 assert_eq!(
1882 err.problems,
1883 [
1884 "mcp.oauth.required_scope \"mcp:read mcp:write\" must be a single scope (no \
1885 spaces) — scopes are matched one token at a time"
1886 ]
1887 );
1888 let err = enabled(|c| c.leeway_secs = 3600).resolve(WIKI).unwrap_err();
1889 assert_eq!(
1890 err.to_string(),
1891 "mcp.oauth.enabled is true but the OAuth config is not usable:\n - \
1892 mcp.oauth.leeway_secs 3600 is over the 300-second cap — leeway is for clock \
1893 drift, not for extending token lifetimes\nFix these, or set mcp.oauth.enabled: \
1894 false."
1895 );
1896 }
1897
1898 #[test]
1899 fn env_naming_uppercases_and_appends_the_field() {
1900 let naming = KeyNaming::Env("MYAPP_OAUTH_");
1901 assert_eq!(naming.key("issuer"), "MYAPP_OAUTH_ISSUER");
1902 assert_eq!(naming.key("required_scopes"), "MYAPP_OAUTH_REQUIRED_SCOPES");
1903 assert_eq!(naming.section(), "MYAPP_OAUTH_*");
1904 assert_eq!(
1905 KeyNaming::Dotted("mcp.oauth").key("issuer"),
1906 "mcp.oauth.issuer"
1907 );
1908 assert_eq!(KeyNaming::Dotted("").key("issuer"), "issuer");
1909 assert_eq!(KeyNaming::Dotted("").section(), "OAuth config");
1910 assert_eq!(KeyNaming::Dotted("mcp.oauth").section(), "mcp.oauth");
1911
1912 let cfg = OAuthConfig {
1913 enabled: true,
1914 required_scope: Some(String::new()),
1915 ..OAuthConfig::default()
1916 };
1917 let err = cfg.resolve(naming).unwrap_err();
1918 let text = err.to_string();
1919 for key in [
1920 "MYAPP_OAUTH_ISSUER",
1921 "MYAPP_OAUTH_RESOURCE",
1922 "MYAPP_OAUTH_AUDIENCE (or MYAPP_OAUTH_AUDIENCES)",
1923 "MYAPP_OAUTH_REQUIRED_SCOPE must not be empty",
1924 ] {
1925 assert!(text.contains(key), "{key} missing from: {text}");
1926 }
1927 assert!(!text.contains("mcp.oauth"), "{text}");
1928 assert!(
1929 text.starts_with(
1930 "OAuth is configured through MYAPP_OAUTH_* but the config is not \
1931 usable:\n - "
1932 ),
1933 "{text}"
1934 );
1935 assert!(
1936 text.ends_with("\nFix these, or unset every MYAPP_OAUTH_* variable."),
1937 "{text}"
1938 );
1939 assert_eq!(err.naming(), naming);
1940 }
1941
1942 #[test]
1943 fn accepts_audiences_without_audience_and_an_omitted_or_blank_jwks_uri() {
1944 for jwks_uri in [None, Some(String::new()), Some(" ".to_string())] {
1945 let oauth = enabled(|c| {
1946 c.audience = String::new();
1947 c.audiences = vec!["https://kb.example.test/mcp".into()];
1948 c.jwks_uri = jwks_uri.clone();
1949 })
1950 .resolve(WIKI)
1951 .unwrap()
1952 .unwrap();
1953 assert_eq!(oauth.accepted_audiences(), ["https://kb.example.test/mcp"]);
1954 assert_eq!(oauth.jwks_uri, None, "blank means discover: {jwks_uri:?}");
1955 }
1956 }
1957
1958 #[test]
1959 fn refuses_hmac_and_none_algorithms() {
1960 for alg in ["HS256", "none"] {
1961 let err = resolve_err(enabled(|c| {
1962 c.algorithms = vec!["RS256".into(), alg.into()];
1963 }));
1964 assert!(err.contains("mcp.oauth.algorithms"), "{err}");
1965 assert!(err.contains(alg), "{err}");
1966 }
1967 let err = resolve_err(enabled(|c| c.algorithms = vec![]));
1968 assert!(err.contains("at least one algorithm"), "{err}");
1969 }
1970
1971 #[test]
1972 fn refuses_malformed_urls_naming_the_key() {
1973 for jwks_uri in ["idp.example.test/jwks", "ftp://idp.example.test/jwks"] {
1974 let err = resolve_err(enabled(|c| c.jwks_uri = Some(jwks_uri.into())));
1975 assert!(err.contains("mcp.oauth.jwks_uri"), "{err}");
1976 }
1977 let err = resolve_err(enabled(|c| {
1978 c.issuer = "https://idp.example.test/?x=1".into();
1979 c.resource = "kb.example.test/mcp".into();
1980 }));
1981 assert!(err.contains("mcp.oauth.issuer"), "{err}");
1982 assert!(err.contains("mcp.oauth.resource"), "{err}");
1983 let err = resolve_err(enabled(|c| c.issuer = " https://idp.example.test/".into()));
1984 assert!(err.contains("leading/trailing whitespace"), "{err}");
1985 }
1986
1987 #[test]
1988 fn refuses_bad_scope_and_leeway_settings() {
1989 type Edit = fn(&mut OAuthConfig);
1990 let cases: [(Edit, &str); 5] = [
1991 (
1992 |c| c.required_scope = Some("mcp:read mcp:write".into()),
1993 "single scope",
1994 ),
1995 (|c| c.scope_claims = vec![], "mcp.oauth.scope_claims"),
1996 (
1997 |c| c.principal_claims = vec![String::new()],
1998 "mcp.oauth.principal_claims",
1999 ),
2000 (|c| c.leeway_secs = 3600, "mcp.oauth.leeway_secs"),
2001 (|c| c.audiences = vec![String::new()], "mcp.oauth.audiences"),
2002 ];
2003 for (edit, needle) in cases {
2004 let err = resolve_err(enabled(edit));
2005 assert!(err.contains(needle), "{needle} not in: {err}");
2006 }
2007 }
2008
2009 #[test]
2010 fn a_blank_required_scope_is_rejected_not_treated_as_absent() {
2011 for blank in ["", " "] {
2012 let err = resolve_err(enabled(|c| c.required_scope = Some(blank.into())));
2013 assert!(
2014 err.contains("mcp.oauth.required_scope"),
2015 "an empty required scope must not silently mean 'no scope': {err}"
2016 );
2017 }
2018 }
2019
2020 #[test]
2021 fn every_problem_is_reported_at_once() {
2022 let err = enabled(|c| {
2023 c.issuer = String::new();
2024 c.required_scope = Some(String::new());
2025 c.required_scopes = vec!["a b".into()];
2026 c.leeway_secs = 9999;
2027 c.algorithms = vec!["HS256".into()];
2028 })
2029 .resolve(WIKI)
2030 .unwrap_err();
2031 assert_eq!(err.problems.len(), 5, "{err}");
2032 }
2033
2034 #[test]
2037 fn required_scopes_are_a_trimmed_deduplicated_order_stable_union() {
2038 let oauth = enabled(|c| {
2039 c.required_scope = Some(" a ".into());
2040 c.required_scopes = vec!["b".into(), "a".into(), " c".into(), "b".into()];
2041 })
2042 .resolve(WIKI)
2043 .unwrap()
2044 .unwrap();
2045 assert_eq!(oauth.required_scopes, ["a", "b", "c"]);
2046
2047 let only_list = enabled(|c| c.required_scopes = vec!["x".into(), "y".into()])
2048 .resolve(WIKI)
2049 .unwrap()
2050 .unwrap();
2051 assert_eq!(only_list.required_scopes, ["x", "y"]);
2052
2053 let only_single = enabled(|c| c.required_scope = Some("mcp:read".into()))
2054 .resolve(WIKI)
2055 .unwrap()
2056 .unwrap();
2057 assert_eq!(only_single.required_scopes, ["mcp:read"]);
2058 }
2059
2060 #[test]
2061 fn an_empty_required_scope_union_is_valid_and_means_no_scope_check() {
2062 let oauth = enabled(|_| {}).resolve(WIKI).unwrap().unwrap();
2063 assert!(oauth.required_scopes.is_empty());
2064 }
2065
2066 #[test]
2067 fn required_scopes_entries_follow_the_single_scope_rules() {
2068 let err = enabled(|c| c.required_scopes = vec!["ok".into(), " ".into()])
2069 .resolve(WIKI)
2070 .unwrap_err();
2071 assert_eq!(err.problems.len(), 1, "{err}");
2072 assert!(
2073 err.problems[0].starts_with("mcp.oauth.required_scopes contains an empty entry"),
2074 "{err}"
2075 );
2076 let err = enabled(|c| c.required_scopes = vec!["a b".into()])
2077 .resolve(WIKI)
2078 .unwrap_err();
2079 assert!(
2080 err.problems[0]
2081 .starts_with("mcp.oauth.required_scopes entry \"a b\" must be a single scope"),
2082 "{err}"
2083 );
2084 }
2085
2086 #[cfg(feature = "serde")]
2089 #[test]
2090 fn round_trips_from_yaml_with_every_default() {
2091 let yaml = "enabled: true
2092issuer: \"https://authentik.example.test/application/o/example-app/\"
2093jwks_uri: \"https://authentik.example.test/application/o/example-app/jwks/\"
2094audience: \"some-client-id\"
2095resource: \"https://kb.example.test/mcp\"
2096";
2097 let parsed: OAuthConfig = serde_yaml_ng::from_str(yaml).unwrap();
2098 assert_eq!(
2099 parsed,
2100 OAuthConfig {
2101 enabled: true,
2102 issuer: "https://authentik.example.test/application/o/example-app/".into(),
2103 jwks_uri: Some(
2104 "https://authentik.example.test/application/o/example-app/jwks/".into()
2105 ),
2106 audience: "some-client-id".into(),
2107 resource: "https://kb.example.test/mcp".into(),
2108 ..OAuthConfig::default()
2109 }
2110 );
2111 let back: OAuthConfig =
2112 serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&parsed).unwrap()).unwrap();
2113 assert_eq!(back, parsed);
2114 let empty: OAuthConfig = serde_yaml_ng::from_str("{}").unwrap();
2116 assert_eq!(empty, OAuthConfig::default());
2117 }
2118
2119 #[cfg(feature = "serde")]
2120 #[test]
2121 fn serde_reads_both_scope_keys_and_refuses_unknown_keys() {
2122 let parsed: OAuthConfig =
2123 serde_yaml_ng::from_str("required_scope: \"a\"\nrequired_scopes: [\"b\", \"c\"]\n")
2124 .unwrap();
2125 assert_eq!(parsed.required_scope.as_deref(), Some("a"));
2126 assert_eq!(parsed.required_scopes, ["b", "c"]);
2127 let parsed: OAuthConfig = serde_yaml_ng::from_str("required_scope: \"\"\n").unwrap();
2129 assert_eq!(parsed.required_scope.as_deref(), Some(""));
2130 assert!(serde_yaml_ng::from_str::<OAuthConfig>("bogus: true\n").is_err());
2131 assert!(serde_yaml_ng::from_str::<OAuthConfig>("resource_name: \"x\"\n").is_err());
2132 let parsed: OAuthConfig =
2133 serde_yaml_ng::from_str("allow_unscoped_tokens: true\nallow_insecure_http: true\n")
2134 .unwrap();
2135 assert!(parsed.allow_unscoped_tokens && parsed.allow_insecure_http);
2136 }
2137
2138 #[cfg(feature = "serde")]
2139 #[test]
2140 fn serde_distinguishes_an_omitted_scopes_supported_from_an_explicit_empty_list() {
2141 let omitted: OAuthConfig = serde_yaml_ng::from_str("enabled: true\n").unwrap();
2142 assert_eq!(omitted.scopes_supported, None);
2143 let empty: OAuthConfig = serde_yaml_ng::from_str("scopes_supported: []\n").unwrap();
2144 assert_eq!(empty.scopes_supported, Some(vec![]));
2145 let listed: OAuthConfig =
2146 serde_yaml_ng::from_str("scopes_supported: [\"a\", \"b\"]\n").unwrap();
2147 assert_eq!(listed.scopes_supported, Some(vec!["a".into(), "b".into()]));
2148 for cfg in [omitted, empty, listed] {
2151 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
2152 let back: OAuthConfig = serde_yaml_ng::from_str(&yaml).unwrap();
2153 assert_eq!(back, cfg, "{yaml}");
2154 }
2155 }
2156
2157 #[test]
2158 fn an_omitted_scopes_supported_advertises_the_required_scopes_and_an_explicit_list_wins() {
2159 let with_required = |required: Option<&str>, scopes: Option<Vec<String>>| {
2160 enabled(|c| {
2161 c.required_scope = required.map(str::to_string);
2162 c.required_scopes = vec!["b".into(), "a".into()];
2163 c.scopes_supported = scopes;
2164 })
2165 .resolve(WIKI)
2166 .unwrap()
2167 .expect("enabled")
2168 .scopes_supported
2169 };
2170 assert_eq!(with_required(Some("a"), None), ["a", "b"]);
2172 assert_eq!(with_required(None, None), ["b", "a"]);
2173 assert!(with_required(Some("a"), Some(vec![])).is_empty());
2175 assert_eq!(with_required(Some("a"), Some(vec![" c ".into()])), ["c"]);
2176
2177 let resolve = |scopes: Option<Vec<String>>| {
2179 enabled(|c| c.scopes_supported = scopes)
2180 .resolve(WIKI)
2181 .unwrap()
2182 .expect("enabled")
2183 .scopes_supported
2184 };
2185 assert!(resolve(None).is_empty());
2186 assert!(resolve(Some(vec![])).is_empty());
2187 assert_eq!(
2188 resolve(Some(vec!["mcp:read".into(), "mcp:write".into()])),
2189 ["mcp:read", "mcp:write"]
2190 );
2191 let app_default = |mut c: OAuthConfig| {
2194 c.scopes_supported
2195 .get_or_insert_with(|| vec!["mcp:read".into(), "mcp:write".into()]);
2196 c.resolve(WIKI).unwrap().expect("enabled").scopes_supported
2197 };
2198 assert_eq!(
2199 app_default(enabled(|c| c.scopes_supported = None)),
2200 ["mcp:read", "mcp:write"]
2201 );
2202 assert!(app_default(enabled(|c| c.scopes_supported = Some(vec![]))).is_empty());
2203 }
2204
2205 #[test]
2208 fn every_required_and_advertised_scope_must_be_a_scope_token() {
2209 for bad in ["a\"b", "a\\b", "caf\u{e9}", "a\u{7f}"] {
2210 let err = enabled(|c| c.required_scope = Some(bad.into()))
2211 .resolve(WIKI)
2212 .unwrap_err();
2213 assert_eq!(err.problems.len(), 1, "{err}");
2214 assert!(
2215 err.problems[0].starts_with("mcp.oauth.required_scope ")
2216 && err.problems[0].contains("is not a valid scope"),
2217 "{err}"
2218 );
2219 let err = enabled(|c| c.required_scopes = vec!["ok".into(), bad.into()])
2220 .resolve(WIKI)
2221 .unwrap_err();
2222 assert!(
2223 err.problems[0].starts_with("mcp.oauth.required_scopes entry "),
2224 "{err}"
2225 );
2226 let err = enabled(|c| c.scopes_supported = Some(vec![bad.into()]))
2227 .resolve(WIKI)
2228 .unwrap_err();
2229 assert!(
2230 err.problems[0].starts_with("mcp.oauth.scopes_supported entry "),
2231 "{err}"
2232 );
2233 }
2234 let err = enabled(|c| c.scopes_supported = Some(vec!["".into(), "two words".into()]))
2236 .resolve(WIKI)
2237 .unwrap_err();
2238 assert_eq!(err.problems.len(), 2, "{err}");
2239 assert_eq!(
2240 err.problems[0],
2241 "mcp.oauth.scopes_supported contains an empty entry"
2242 );
2243 assert!(err.problems[1].contains("\"two words\""), "{err}");
2244 let oauth = enabled(|c| {
2247 c.required_scope =
2248 Some("https://api.example.test/things.read!#$%&'()*+,-./:;<=>?@[]^_`{|}~".into());
2249 })
2250 .resolve(WIKI)
2251 .unwrap()
2252 .unwrap();
2253 assert_eq!(oauth.required_scopes.len(), 1);
2254 }
2255
2256 #[test]
2259 fn a_url_with_a_space_control_or_non_ascii_character_is_refused() {
2260 for bad in [
2263 "https://kb.example.test/m\ncp",
2264 "https://kb.example.test/m\tcp",
2265 "https://kb.example.test/m cp",
2266 "https://kb.example.test/caf\u{e9}",
2267 ] {
2268 let err = resolve_err(enabled(|c| c.resource = bad.into()));
2269 assert!(
2270 err.contains("mcp.oauth.resource") && err.contains("percent-encoded"),
2271 "{bad:?}: {err}"
2272 );
2273 let err = resolve_err(enabled(|c| c.issuer = bad.into()));
2274 assert!(err.contains("mcp.oauth.issuer"), "{bad:?}: {err}");
2275 let err = resolve_err(enabled(|c| c.jwks_uri = Some(bad.into())));
2276 assert!(err.contains("mcp.oauth.jwks_uri"), "{bad:?}: {err}");
2277 }
2278 enabled(|c| {
2280 c.resource = "https://kb.example.test/caf%C3%A9".into();
2281 c.jwks_uri = Some("https://idp.example.test/keys?tenant=a".into());
2282 })
2283 .resolve(WIKI)
2284 .unwrap()
2285 .unwrap();
2286 }
2287
2288 #[test]
2289 fn plain_http_off_loopback_is_refused_unless_explicitly_allowed() {
2290 let err = enabled(|c| {
2291 c.issuer = "http://idp.internal.test/app/".into();
2292 c.jwks_uri = Some("http://idp.internal.test/app/jwks/".into());
2293 c.resource = "http://kb.internal.test/mcp".into();
2294 })
2295 .resolve(WIKI)
2296 .unwrap_err();
2297 assert_eq!(err.problems.len(), 3, "{err}");
2298 assert!(
2299 err.problems[0].starts_with(
2300 "mcp.oauth.issuer \"http://idp.internal.test/app/\" uses plain http on a \
2301 non-loopback host — signing keys"
2302 ),
2303 "{err}"
2304 );
2305 assert!(err.problems[0].contains("RFC 8414 §2"), "{err}");
2306 assert!(err.problems[1].starts_with("mcp.oauth.resource "), "{err}");
2307 assert!(err.problems[1].contains("RFC 9728 §1.2"), "{err}");
2308 assert!(err.problems[2].starts_with("mcp.oauth.jwks_uri "), "{err}");
2309 assert!(
2310 err.problems[2].contains("set mcp.oauth.allow_insecure_http"),
2311 "{err}"
2312 );
2313
2314 let oauth = enabled(|c| {
2316 c.issuer = "http://idp.internal.test/app/".into();
2317 c.jwks_uri = Some("http://idp.internal.test/app/jwks/".into());
2318 c.resource = "http://kb.internal.test/mcp".into();
2319 c.allow_insecure_http = true;
2320 })
2321 .resolve(WIKI)
2322 .unwrap()
2323 .unwrap();
2324 assert!(oauth.allow_insecure_http);
2325
2326 enabled(|c| {
2328 c.issuer = "http://127.0.0.1:9000/app/".into();
2329 c.jwks_uri = Some("http://[::1]:9000/jwks".into());
2330 c.resource = "http://localhost:8001/mcp".into();
2331 })
2332 .resolve(WIKI)
2333 .unwrap()
2334 .unwrap();
2335 }
2336
2337 const NON_CANONICAL_HTTP: [&str; 4] = [
2341 "http:/idp.example.test/jwks",
2342 "http:idp.example.test/jwks",
2343 "HTTP:\\\\idp.example.test\\jwks",
2344 " http://idp.example.test/jwks",
2345 ];
2346
2347 fn with_url(name: &str, url: &str, allow_insecure_http: bool) -> OAuthConfig {
2349 enabled(|c| {
2350 c.allow_insecure_http = allow_insecure_http;
2351 match name {
2352 "issuer" => c.issuer = url.into(),
2353 "jwks_uri" => c.jwks_uri = Some(url.into()),
2354 "resource" => c.resource = url.into(),
2355 other => panic!("no URL setting {other}"),
2356 }
2357 })
2358 }
2359
2360 #[test]
2361 fn non_canonical_plain_http_spellings_are_refused_for_every_url_setting() {
2362 for spelling in NON_CANONICAL_HTTP {
2363 for name in ["issuer", "jwks_uri", "resource"] {
2364 let err = with_url(name, spelling, false)
2365 .resolve(WIKI)
2366 .expect_err(&format!("{name} = {spelling:?} must be refused"));
2367 assert_eq!(err.problems.len(), 1, "{name} = {spelling:?}: {err}");
2368 assert!(
2369 err.problems[0].starts_with(&format!("mcp.oauth.{name} ")),
2370 "{name} = {spelling:?}: {err}"
2371 );
2372 let expected = if spelling.starts_with(' ') {
2375 ProblemKind::InvalidUrl
2376 } else {
2377 ProblemKind::InsecureHttp
2378 };
2379 assert_eq!(
2380 err.problem_details()[0].kind(),
2381 expected,
2382 "{name} = {spelling:?}: {err}"
2383 );
2384 }
2385 }
2386 }
2387
2388 #[test]
2389 fn non_canonical_spellings_that_are_not_a_cleartext_hole_resolve_with_a_warning() {
2390 let cases: [(&str, bool, &str); 8] = [
2394 (
2395 "http:/idp.example.test/jwks",
2396 true,
2397 "http://idp.example.test/jwks",
2398 ),
2399 (
2400 "http:idp.example.test/jwks",
2401 true,
2402 "http://idp.example.test/jwks",
2403 ),
2404 (
2405 "HTTP:\\\\idp.example.test\\jwks",
2406 true,
2407 "http://idp.example.test/jwks",
2408 ),
2409 (
2410 "http:/localhost:9000/jwks",
2411 false,
2412 "http://localhost:9000/jwks",
2413 ),
2414 (
2415 "https:/idp.example.test/jwks",
2416 false,
2417 "https://idp.example.test/jwks",
2418 ),
2419 (
2420 "https:idp.example.test/jwks",
2421 false,
2422 "https://idp.example.test/jwks",
2423 ),
2424 (
2425 "https://idp.example.test\\jwks",
2426 false,
2427 "https://idp.example.test/jwks",
2428 ),
2429 (
2430 "https:///idp.example.test/jwks",
2431 false,
2432 "https://idp.example.test/jwks",
2433 ),
2434 ];
2435 for (spelling, opt_in, canonical) in cases {
2436 for name in ["issuer", "jwks_uri", "resource"] {
2437 let resolved = with_url(name, spelling, opt_in)
2438 .resolve(WIKI)
2439 .unwrap_or_else(|e| panic!("{name} = {spelling:?}: {e}"))
2440 .unwrap();
2441 let warnings = crate::validator::non_canonical_warnings(&resolved);
2442 assert_eq!(warnings.len(), 1, "{name} = {spelling:?}: {warnings:?}");
2443 assert!(
2444 warnings[0].starts_with(&format!(
2445 "mcp.oauth.{name} {spelling:?} is not canonically spelled — it is \
2446 read as {canonical:?}; "
2447 )),
2448 "{name} = {spelling:?}: {warnings:?}"
2449 );
2450 }
2451 }
2452 let resolved = enabled(|c| c.jwks_uri = Some("https://idp.example.test/jwks".into()))
2454 .resolve(WIKI)
2455 .unwrap()
2456 .unwrap();
2457 assert!(crate::validator::non_canonical_warnings(&resolved).is_empty());
2458 }
2459
2460 #[test]
2461 fn canonical_url_spellings_behave_as_before() {
2462 for url in [
2465 "http://idp.example.test/jwks",
2466 "HTTP://idp.example.test/jwks",
2467 ] {
2468 for name in ["issuer", "jwks_uri", "resource"] {
2469 let err = with_url(name, url, false).resolve(WIKI).unwrap_err();
2470 assert_eq!(err.problems.len(), 1, "{name} = {url:?}: {err}");
2471 assert!(
2472 err.problems[0].contains("uses plain http on a non-loopback host"),
2473 "{name} = {url:?}: {err}"
2474 );
2475 with_url(name, url, true)
2476 .resolve(WIKI)
2477 .unwrap_or_else(|e| panic!("{name} = {url:?}: {e}"))
2478 .unwrap();
2479 }
2480 }
2481 for url in [
2484 "http://localhost/jwks",
2485 "http://127.0.0.1:9000/jwks",
2486 "http://[::1]:9000/jwks",
2487 "https://idp.example.test/jwks",
2488 "https://IDP.example.test:443",
2489 ] {
2490 for name in ["issuer", "jwks_uri", "resource"] {
2491 with_url(name, url, false)
2492 .resolve(WIKI)
2493 .unwrap_or_else(|e| panic!("{name} = {url:?}: {e}"))
2494 .unwrap();
2495 }
2496 }
2497 }
2498
2499 #[test]
2502 fn no_required_scope_and_no_typ_check_is_refused_unless_explicitly_allowed() {
2503 let bare = |edit: fn(&mut OAuthConfig)| {
2504 let mut cfg = enabled(|c| c.allow_unscoped_tokens = false);
2505 edit(&mut cfg);
2506 cfg.resolve(WIKI)
2507 };
2508 let err = bare(|_| {}).unwrap_err();
2509 assert_eq!(err.problems.len(), 1, "{err}");
2510 assert_eq!(
2511 err.problems[0],
2512 "no required scope is configured (mcp.oauth.required_scope and \
2513 mcp.oauth.required_scopes are unset) and mcp.oauth.require_at_jwt is off — \
2514 nothing would tell an access token from an OIDC ID token minted for the same \
2515 client, so any token this issuer signs for the audience would be accepted. Set \
2516 mcp.oauth.required_scope to a scope only access tokens carry, turn on \
2517 mcp.oauth.require_at_jwt if the authorization server emits typ at+jwt, or set \
2518 mcp.oauth.allow_unscoped_tokens to accept that"
2519 );
2520 assert!(bare(|c| c.required_scope = Some("a".into())).is_ok());
2522 assert!(bare(|c| c.required_scopes = vec!["a".into()]).is_ok());
2523 assert!(bare(|c| c.require_at_jwt = true).is_ok());
2524 let allowed = bare(|c| c.allow_unscoped_tokens = true).unwrap().unwrap();
2525 assert!(allowed.required_scopes.is_empty());
2526 assert!(allowed.allow_unscoped_tokens);
2527 let err = bare(|c| c.required_scope = Some(String::new())).unwrap_err();
2529 assert_eq!(err.problems.len(), 1, "{err}");
2530 let err = OAuthConfig {
2532 enabled: true,
2533 ..OAuthConfig::default()
2534 }
2535 .resolve(KeyNaming::Env("APP_OAUTH_"))
2536 .unwrap_err();
2537 assert!(
2538 err.problems
2539 .iter()
2540 .any(|p| p.contains("set APP_OAUTH_ALLOW_UNSCOPED_TOKENS")),
2541 "{err}"
2542 );
2543 }
2544
2545 fn spelled(naming: KeyNaming<'_>, field: &str) -> String {
2550 match naming {
2551 KeyNaming::Dotted(p) => format!("{p}.{field}"),
2552 KeyNaming::Env(p) => format!("{p}{}", field.to_uppercase()),
2553 }
2554 }
2555
2556 type Edit = fn(&mut OAuthConfig);
2557
2558 fn one_problem_per_kind() -> Vec<(ProblemKind, Edit, &'static [&'static str])> {
2561 vec![
2562 (
2563 ProblemKind::MissingRequired,
2564 |c| c.issuer.clear(),
2565 &["issuer"],
2566 ),
2567 (
2568 ProblemKind::MissingRequired,
2569 |c| {
2570 c.audience.clear();
2571 c.audiences.clear();
2572 },
2573 &["audience", "audiences"],
2574 ),
2575 (
2576 ProblemKind::InvalidUrl,
2577 |c| c.jwks_uri = Some("not a url".into()),
2578 &["jwks_uri"],
2579 ),
2580 (
2581 ProblemKind::InsecureHttp,
2582 |c| c.resource = "http://kb.example.test/mcp".into(),
2583 &["resource", "allow_insecure_http"],
2584 ),
2585 (
2586 ProblemKind::BlankRequiredScope,
2587 |c| c.required_scope = Some(" ".into()),
2588 &["required_scope"],
2589 ),
2590 (
2591 ProblemKind::BlankRequiredScope,
2592 |c| c.required_scopes = vec!["".into()],
2593 &["required_scopes"],
2594 ),
2595 (
2596 ProblemKind::MultiWordScope,
2597 |c| c.required_scope = Some("a b".into()),
2598 &["required_scope"],
2599 ),
2600 (
2601 ProblemKind::MultiWordScope,
2602 |c| c.required_scopes = vec!["a b".into()],
2603 &["required_scopes"],
2604 ),
2605 (
2606 ProblemKind::InvalidScopeToken,
2607 |c| c.required_scope = Some("a\"b".into()),
2608 &["required_scope"],
2609 ),
2610 (
2611 ProblemKind::InvalidScopeToken,
2612 |c| c.required_scopes = vec!["a\\b".into()],
2613 &["required_scopes"],
2614 ),
2615 (
2616 ProblemKind::InvalidScopeToken,
2617 |c| c.scopes_supported = Some(vec!["a\"b".into()]),
2618 &["scopes_supported"],
2619 ),
2620 (
2621 ProblemKind::EmptyListEntry,
2622 |c| c.audiences = vec!["".into()],
2623 &["audiences"],
2624 ),
2625 (
2626 ProblemKind::EmptyListEntry,
2627 |c| c.scopes_supported = Some(vec!["".into()]),
2628 &["scopes_supported"],
2629 ),
2630 (
2631 ProblemKind::EmptyListEntry,
2632 |c| c.principal_claims = vec!["".into()],
2633 &["principal_claims"],
2634 ),
2635 (
2636 ProblemKind::NoRequiredScope,
2637 |c| c.allow_unscoped_tokens = false,
2638 &[
2639 "required_scope",
2640 "required_scopes",
2641 "require_at_jwt",
2642 "allow_unscoped_tokens",
2643 ],
2644 ),
2645 (
2646 ProblemKind::EmptyScopeClaims,
2647 |c| c.scope_claims.clear(),
2648 &["scope_claims"],
2649 ),
2650 (
2651 ProblemKind::BadAlgorithm,
2652 |c| c.algorithms = vec!["HS256".into()],
2653 &["algorithms"],
2654 ),
2655 (
2656 ProblemKind::NoAlgorithms,
2657 |c| c.algorithms.clear(),
2658 &["algorithms"],
2659 ),
2660 (
2661 ProblemKind::LeewayTooLarge,
2662 |c| c.leeway_secs = MAX_LEEWAY_SECS + 1,
2663 &["leeway_secs"],
2664 ),
2665 (
2666 ProblemKind::EmptyListEntry,
2667 |c| c.allowed_client_ids = vec!["client-a".into(), " ".into()],
2668 &["allowed_client_ids"],
2669 ),
2670 (
2671 ProblemKind::TokenAgeOutOfRange,
2672 |c| c.max_token_age_secs = Some(0),
2673 &["max_token_age_secs"],
2674 ),
2675 (
2676 ProblemKind::TokenAgeOutOfRange,
2677 |c| c.max_token_age_secs = Some(MAX_TOKEN_AGE_SECS + 1),
2678 &["max_token_age_secs"],
2679 ),
2680 (
2681 ProblemKind::InvalidRequiredClaim,
2682 |c| {
2683 c.required_claims.insert(" ".into(), "x".into());
2684 },
2685 &["required_claims"],
2686 ),
2687 (
2688 ProblemKind::InvalidRequiredClaim,
2689 |c| {
2690 c.required_claims.insert("aud".into(), "x".into());
2691 },
2692 &["required_claims"],
2693 ),
2694 (
2695 ProblemKind::InvalidRequiredClaim,
2696 |c| {
2697 c.required_claims.insert("tid".into(), Value::Null);
2698 },
2699 &["required_claims"],
2700 ),
2701 (
2702 ProblemKind::InvalidRequiredClaim,
2703 |c| {
2704 c.required_claims
2705 .insert("groups".into(), serde_json::json!(["a"]));
2706 },
2707 &["required_claims"],
2708 ),
2709 (
2710 ProblemKind::InvalidRequiredClaim,
2711 |c| {
2712 c.required_claims
2713 .insert("org".into(), serde_json::json!({"id": 1}));
2714 },
2715 &["required_claims"],
2716 ),
2717 ]
2718 }
2719
2720 #[test]
2721 fn every_kind_resolve_can_produce_is_produced_and_names_its_settings() {
2722 let mut seen = std::collections::HashSet::new();
2723 for naming in [WIKI, KeyNaming::Env("APP_OAUTH_")] {
2724 for (kind, edit, fields) in one_problem_per_kind() {
2725 let err = enabled(edit).resolve(naming).unwrap_err();
2726 let details = err.problem_details();
2727 assert_eq!(details.len(), 1, "{kind:?}: {err}");
2728 let p = &details[0];
2729 assert_eq!(p.kind(), kind, "{err}");
2730 let want: Vec<String> = fields.iter().map(|f| spelled(naming, f)).collect();
2731 assert_eq!(p.keys(), want.as_slice(), "{kind:?} under {naming:?}");
2732 assert_eq!(p.message(), err.problems[0]);
2733 assert_eq!(p.to_string(), p.message());
2734 seen.insert(kind);
2735 }
2736 }
2737 for kind in [
2739 ProblemKind::MissingRequired,
2740 ProblemKind::InvalidUrl,
2741 ProblemKind::InsecureHttp,
2742 ProblemKind::BlankRequiredScope,
2743 ProblemKind::MultiWordScope,
2744 ProblemKind::InvalidScopeToken,
2745 ProblemKind::EmptyListEntry,
2746 ProblemKind::NoRequiredScope,
2747 ProblemKind::EmptyScopeClaims,
2748 ProblemKind::BadAlgorithm,
2749 ProblemKind::NoAlgorithms,
2750 ProblemKind::LeewayTooLarge,
2751 ProblemKind::TokenAgeOutOfRange,
2752 ProblemKind::InvalidRequiredClaim,
2753 ] {
2754 assert!(seen.contains(&kind), "{kind:?} is never produced");
2755 }
2756 for kind in seen {
2758 match kind {
2759 ProblemKind::EnvLoad | ProblemKind::EnvParse | ProblemKind::Other => {
2760 panic!("resolve produced {kind:?}")
2761 }
2762 _ => {}
2763 }
2764 }
2765 }
2766
2767 #[test]
2768 fn a_missing_required_problem_names_every_blank_setting() {
2769 let err = enabled(|c| {
2770 c.issuer.clear();
2771 c.resource.clear();
2772 c.audience.clear();
2773 })
2774 .resolve(KeyNaming::Env("APP_OAUTH_"))
2775 .unwrap_err();
2776 assert_eq!(
2777 err.problem_details()[0].keys(),
2778 [
2779 "APP_OAUTH_ISSUER",
2780 "APP_OAUTH_RESOURCE",
2781 "APP_OAUTH_AUDIENCE",
2782 "APP_OAUTH_AUDIENCES"
2783 ]
2784 );
2785 }
2786
2787 #[test]
2788 fn problems_and_problem_details_agree_across_a_multi_problem_config() {
2789 let cfg = OAuthConfig {
2790 enabled: true,
2791 leeway_secs: MAX_LEEWAY_SECS + 1,
2792 required_scope: Some("a b".into()),
2793 algorithms: vec!["none".into()],
2794 principal_claims: vec![" ".into()],
2795 ..OAuthConfig::default()
2796 };
2797 for naming in [WIKI, KeyNaming::Env("APP_OAUTH_")] {
2798 let err = cfg.clone().resolve(naming).unwrap_err();
2799 let details = err.problem_details();
2800 assert_eq!(details.len(), 5, "{err}");
2801 let texts: Vec<&str> = details.iter().map(ConfigProblem::message).collect();
2802 assert_eq!(texts, err.problems);
2803 let kinds: Vec<ProblemKind> = details.iter().map(ConfigProblem::kind).collect();
2804 assert_eq!(
2805 kinds,
2806 [
2807 ProblemKind::MissingRequired,
2808 ProblemKind::MultiWordScope,
2809 ProblemKind::EmptyListEntry,
2810 ProblemKind::BadAlgorithm,
2811 ProblemKind::LeewayTooLarge,
2812 ]
2813 );
2814 }
2815 }
2816
2817 #[test]
2818 fn config_error_new_is_unchanged_and_wraps_strings_as_other() {
2819 let err = ConfigError::new(WIKI, vec!["first".to_string(), "second".to_string()]);
2820 assert_eq!(err.problems, ["first", "second"]);
2821 let details = err.problem_details();
2822 assert_eq!(details.len(), 2);
2823 assert!(details.iter().all(|p| p.kind() == ProblemKind::Other));
2824 assert!(details.iter().all(|p| p.keys().is_empty()));
2825 assert_eq!(details[1].message(), "second");
2826 assert_eq!(
2827 err.to_string(),
2828 "mcp.oauth.enabled is true but the OAuth config is not usable:\n - first\n - second\n\
2829 Fix these, or set mcp.oauth.enabled: false."
2830 );
2831 let collected = ConfigError::new(WIKI, ["a", "b"].iter().map(|s| s.to_string()).collect());
2833 assert_eq!(collected.problems, ["a", "b"]);
2834 }
2835
2836 #[test]
2837 fn from_problems_and_from_string_mix_structured_and_plain_problems() {
2838 let p = ConfigProblem::from("app problem".to_string());
2839 assert_eq!(p.kind(), ProblemKind::Other);
2840 assert!(p.keys().is_empty());
2841 assert_eq!(p.message(), "app problem");
2842 assert_eq!(p.to_string(), "app problem");
2843
2844 let own = ConfigProblem::new(ProblemKind::InvalidUrl, ["my.key"], "my.key is bad");
2845 assert_eq!(own.keys(), ["my.key"]);
2846 let err = ConfigError::from_problems(WIKI, [own.clone(), p.clone()]);
2847 assert_eq!(err.problems, ["my.key is bad", "app problem"]);
2848 assert_eq!(err.problem_details(), [own, p]);
2849 assert!(
2850 err.to_string()
2851 .contains("\n - my.key is bad\n - app problem\n")
2852 );
2853 assert_eq!(err.naming(), WIKI);
2854 }
2855
2856 #[test]
2857 fn config_error_equality_ignores_the_structured_details() {
2858 let err = enabled(|c| c.leeway_secs = MAX_LEEWAY_SECS + 1)
2859 .resolve(WIKI)
2860 .unwrap_err();
2861 let rebuilt = ConfigError::new(WIKI, err.problems.clone());
2863 assert_eq!(rebuilt, err);
2864 assert_ne!(rebuilt.problem_details(), err.problem_details());
2865 assert_ne!(ConfigError::new(WIKI, vec!["other".into()]), err);
2867 assert_ne!(
2868 ConfigError::new(KeyNaming::Env("APP_OAUTH_"), err.problems.clone()),
2869 err
2870 );
2871 }
2872
2873 #[test]
2874 fn editing_the_public_problems_field_does_not_touch_problem_details() {
2875 let mut err = enabled(|c| c.leeway_secs = MAX_LEEWAY_SECS + 1)
2876 .resolve(WIKI)
2877 .unwrap_err();
2878 let before = err.problem_details().to_vec();
2879 err.problems.push("extra".into());
2880 assert_eq!(err.problem_details(), before);
2881 }
2882
2883 #[test]
2884 fn problem_kind_labels_are_stable_and_distinct() {
2885 let all = [
2886 (ProblemKind::MissingRequired, "missing_required"),
2887 (ProblemKind::InvalidUrl, "invalid_url"),
2888 (ProblemKind::InsecureHttp, "insecure_http"),
2889 (ProblemKind::BlankRequiredScope, "blank_required_scope"),
2890 (ProblemKind::MultiWordScope, "multi_word_scope"),
2891 (ProblemKind::InvalidScopeToken, "invalid_scope_token"),
2892 (ProblemKind::EmptyListEntry, "empty_list_entry"),
2893 (ProblemKind::NoRequiredScope, "no_required_scope"),
2894 (ProblemKind::EmptyScopeClaims, "empty_scope_claims"),
2895 (ProblemKind::BadAlgorithm, "bad_algorithm"),
2896 (ProblemKind::NoAlgorithms, "no_algorithms"),
2897 (ProblemKind::LeewayTooLarge, "leeway_too_large"),
2898 (ProblemKind::TokenAgeOutOfRange, "token_age_out_of_range"),
2899 (ProblemKind::InvalidRequiredClaim, "invalid_required_claim"),
2900 (ProblemKind::EnvLoad, "env_load"),
2901 (ProblemKind::EnvParse, "env_parse"),
2902 (ProblemKind::Other, "other"),
2903 ];
2904 for (kind, label) in all {
2905 assert_eq!(kind.as_str(), label);
2906 assert_eq!(kind.to_string(), label);
2907 }
2908 let labels: std::collections::HashSet<_> = all.iter().map(|(_, l)| *l).collect();
2909 assert_eq!(labels.len(), all.len());
2910 }
2911
2912 #[test]
2916 fn a_non_scalar_required_claim_is_described_never_echoed() {
2917 for (value, shown) in [
2918 (serde_json::json!({"nested": "s3cret-object"}), "an object"),
2919 (serde_json::json!(["s3cret-array"]), "an array"),
2920 (Value::Null, "null"),
2921 ] {
2922 let mut cfg = OAuthConfig {
2923 enabled: true,
2924 issuer: "https://idp.example.test/".into(),
2925 audience: "client-a".into(),
2926 resource: "https://kb.example.test/".into(),
2927 required_scope: Some("api:read".into()),
2928 ..OAuthConfig::default()
2929 };
2930 cfg.required_claims.insert("tid".into(), value.clone());
2931 let err = cfg.resolve(KeyNaming::Dotted("oauth")).unwrap_err();
2932 assert_eq!(
2933 err.problem_details()[0].kind(),
2934 ProblemKind::InvalidRequiredClaim
2935 );
2936 let text = err.to_string();
2937 assert!(text.contains(&format!("requires {shown}")), "{text}");
2938 assert!(!text.contains("s3cret"), "{value}: {text}");
2939 }
2940 }
2941
2942 #[cfg(feature = "serde")]
2945 #[test]
2946 fn a_config_file_naming_a_required_claim_twice_is_refused() {
2947 let err = serde_json::from_str::<OAuthConfig>(
2948 r#"{"required_claims": {"tid": "good", "tid": "evil"}}"#,
2949 )
2950 .unwrap_err()
2951 .to_string();
2952 assert!(err.contains("\"tid\" more than once"), "{err}");
2953 assert!(!err.contains("evil"), "{err}");
2954 let ok: OAuthConfig =
2955 serde_json::from_str(r#"{"required_claims": {"tid": "good", "level": 2}}"#).unwrap();
2956 assert_eq!(ok.required_claims.len(), 2);
2957 }
2958
2959 #[cfg(feature = "serde")]
2960 #[test]
2961 fn the_claim_policy_settings_deserialize_and_default_to_off() {
2962 let yaml = "\
2963enabled: true
2964allowed_client_ids: [\"client-a\", \"client-b\"]
2965max_token_age_secs: 3600
2966required_claims:
2967 tid: \"tenant-1\"
2968 level: 2
2969 mfa: true
2970";
2971 let parsed: OAuthConfig = serde_yaml_ng::from_str(yaml).unwrap();
2972 assert_eq!(parsed.allowed_client_ids, ["client-a", "client-b"]);
2973 assert_eq!(parsed.max_token_age_secs, Some(3600));
2974 assert_eq!(parsed.required_claims["tid"], serde_json::json!("tenant-1"));
2975 assert_eq!(parsed.required_claims["level"], serde_json::json!(2));
2976 assert_eq!(parsed.required_claims["mfa"], serde_json::json!(true));
2977 let back: OAuthConfig =
2978 serde_yaml_ng::from_str(&serde_yaml_ng::to_string(&parsed).unwrap()).unwrap();
2979 assert_eq!(back, parsed);
2980
2981 let empty: OAuthConfig = serde_yaml_ng::from_str("{}").unwrap();
2983 assert_eq!(empty, OAuthConfig::default());
2984 assert!(empty.allowed_client_ids.is_empty());
2985 assert_eq!(empty.max_token_age_secs, None);
2986 assert!(empty.required_claims.is_empty());
2987 assert!(serde_yaml_ng::from_str::<OAuthConfig>("allowed_clients: [\"a\"]\n").is_err());
2989 assert!(serde_yaml_ng::from_str::<OAuthConfig>("max_token_age: 5\n").is_err());
2990 }
2991
2992 #[test]
2993 fn the_claim_policy_settings_resolve_unchanged_and_default_off() {
2994 let resolved = enabled(|_| {}).resolve(WIKI).unwrap().unwrap();
2995 assert!(resolved.allowed_client_ids.is_empty());
2996 assert_eq!(resolved.max_token_age_secs, None);
2997 assert!(resolved.required_claims.is_empty());
2998
2999 let resolved = enabled(|c| {
3000 c.allowed_client_ids = vec!["client-a".into()];
3001 c.max_token_age_secs = Some(MAX_TOKEN_AGE_SECS);
3002 c.required_claims
3003 .insert("groups".into(), serde_json::json!("api-users"));
3004 c.required_claims
3005 .insert("level".into(), serde_json::json!(2));
3006 c.required_claims
3007 .insert("mfa".into(), serde_json::json!(false));
3008 })
3009 .resolve(WIKI)
3010 .unwrap()
3011 .unwrap();
3012 assert_eq!(resolved.allowed_client_ids, ["client-a"]);
3013 assert_eq!(resolved.max_token_age_secs, Some(MAX_TOKEN_AGE_SECS));
3014 assert_eq!(resolved.required_claims.len(), 3);
3015 let one = enabled(|c| c.max_token_age_secs = Some(1)).resolve(WIKI);
3016 assert!(one.unwrap().is_some());
3017
3018 let err = enabled(|c| {
3020 for name in RESERVED_REQUIRED_CLAIMS {
3021 c.required_claims.insert((*name).into(), "x".into());
3022 }
3023 })
3024 .resolve(KeyNaming::Env("APP_OAUTH_"))
3025 .unwrap_err();
3026 assert_eq!(err.problems.len(), RESERVED_REQUIRED_CLAIMS.len(), "{err}");
3027 for p in err.problem_details() {
3028 assert_eq!(p.kind(), ProblemKind::InvalidRequiredClaim);
3029 assert_eq!(p.keys(), ["APP_OAUTH_REQUIRED_CLAIMS"]);
3030 }
3031 }
3032
3033 #[test]
3034 fn no_problem_message_carries_a_run_of_spaces() {
3035 for naming in [WIKI, KeyNaming::Env("APP_OAUTH_")] {
3038 for (kind, edit, _) in one_problem_per_kind() {
3039 let err = enabled(edit).resolve(naming).unwrap_err();
3040 for problem in &err.problems {
3041 assert!(!problem.contains(" "), "{kind:?}: {problem:?}");
3042 }
3043 }
3044 }
3045 }
3046
3047 #[cfg(feature = "serde")]
3048 #[test]
3049 fn unset_claim_policy_settings_are_still_serialized() {
3050 let yaml = serde_yaml_ng::to_string(&OAuthConfig::default()).unwrap();
3053 for key in [
3054 "allowed_client_ids",
3055 "max_token_age_secs",
3056 "required_claims",
3057 ] {
3058 assert!(yaml.contains(key), "{key} missing from {yaml}");
3059 }
3060 }
3061}