1use std::io::{self, Read};
26
27use zeroize::Zeroizing;
28
29use crate::authenticate::StaticTokens;
30use crate::config::{
31 ConfigError, ConfigProblem, KeyNaming, OAuthConfig, ProblemKind, ResolvedOAuthConfig,
32};
33
34#[derive(Debug, thiserror::Error)]
39#[non_exhaustive]
40pub enum EnvError {
41 #[error("{var} and {var}_FILE are both set (file: {path}) — set exactly one, not both")]
45 #[non_exhaustive]
46 BothSet {
47 var: String,
50 path: String,
52 },
53 #[error("{var}_FILE={path}: failed to read secret file")]
61 #[non_exhaustive]
62 ReadFailed {
63 var: String,
65 path: String,
67 #[source]
69 source: io::Error,
70 },
71 #[error("{var}_FILE={path}: not a regular file")]
75 #[non_exhaustive]
76 NotAFile {
77 var: String,
79 path: String,
81 },
82 #[error("{var}_FILE={path}: secret file is over the 65536-byte limit")]
87 #[non_exhaustive]
88 FileTooLarge {
89 var: String,
91 path: String,
93 },
94 #[error("{var}_FILE={path}: secret file is empty")]
99 #[non_exhaustive]
100 EmptyFile {
101 var: String,
103 path: String,
105 },
106 #[error("{var}_NEXT is set but {var} is not: set the current key in {var} (or {var}_FILE)")]
111 #[non_exhaustive]
112 NextWithoutCurrent {
113 var: String,
115 },
116 #[error("{}", join_errors(errors))]
121 #[non_exhaustive]
122 Several {
123 errors: Vec<EnvError>,
125 },
126}
127
128fn duplicated_claim(json: &str) -> Option<String> {
131 let pairs: Vec<(String, serde::de::IgnoredAny)> = serde_json::from_str::<Pairs>(json).ok()?.0;
132 let mut seen = std::collections::HashSet::new();
133 pairs
134 .into_iter()
135 .map(|(name, _)| name)
136 .find(|name| !seen.insert(name.clone()))
137}
138
139struct Pairs(Vec<(String, serde::de::IgnoredAny)>);
141
142impl<'de> serde::Deserialize<'de> for Pairs {
143 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
144 struct Visit;
145 impl<'de> serde::de::Visitor<'de> for Visit {
146 type Value = Pairs;
147 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148 f.write_str("a JSON object")
149 }
150 fn visit_map<A: serde::de::MapAccess<'de>>(
151 self,
152 mut map: A,
153 ) -> Result<Pairs, A::Error> {
154 let mut pairs = Vec::new();
155 while let Some(pair) = map.next_entry()? {
156 pairs.push(pair);
157 }
158 Ok(Pairs(pairs))
159 }
160 }
161 deserializer.deserialize_map(Visit)
162 }
163}
164
165pub const MAX_SECRET_FILE_BYTES: usize = 64 * 1024;
168
169#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
177#[non_exhaustive]
178pub enum FileRefused {
179 #[error("not a regular file")]
181 NotAFile,
182 #[error("over the size limit")]
184 TooLarge,
185}
186
187pub fn read_secret_file(path: &str) -> io::Result<String> {
230 let refused = |why| io::Error::new(io::ErrorKind::InvalidInput, why);
231 if !std::fs::metadata(path)?.is_file() {
232 return Err(refused(FileRefused::NotAFile));
233 }
234 let file = std::fs::File::open(path)?;
235 if !file.metadata()?.is_file() {
236 return Err(refused(FileRefused::NotAFile));
237 }
238 let mut bytes = Zeroizing::new(Vec::new());
239 file.take(MAX_SECRET_FILE_BYTES as u64 + 1)
240 .read_to_end(&mut bytes)?;
241 if bytes.len() > MAX_SECRET_FILE_BYTES {
242 return Err(refused(FileRefused::TooLarge));
243 }
244 std::str::from_utf8(&bytes).map(str::to_owned).map_err(|_| {
245 io::Error::new(
246 io::ErrorKind::InvalidData,
247 "stream did not contain valid UTF-8",
248 )
249 })
250}
251
252fn file_error(var: &str, path: String, source: io::Error) -> EnvError {
256 let var = var.to_string();
257 match source
258 .get_ref()
259 .and_then(|e| e.downcast_ref::<FileRefused>())
260 {
261 Some(FileRefused::NotAFile) => EnvError::NotAFile { var, path },
262 Some(FileRefused::TooLarge) => EnvError::FileTooLarge { var, path },
263 None => EnvError::ReadFailed { var, path, source },
264 }
265}
266
267fn join_errors(errors: &[EnvError]) -> String {
268 errors.iter().map(error_text).collect::<Vec<_>>().join("; ")
271}
272
273pub const CURRENT_KEY_LABEL: &str = "current";
275pub const NEXT_KEY_LABEL: &str = "next";
277
278pub fn secret_from_env(var: &str) -> Result<Option<String>, EnvError> {
326 secret_from_lookup(var, |v| std::env::var(v).ok(), read_secret_file)
327}
328
329pub fn secret_from_lookup(
371 var: &str,
372 lookup: impl Fn(&str) -> Option<String>,
373 read_file: impl Fn(&str) -> io::Result<String>,
374) -> Result<Option<String>, EnvError> {
375 Ok(secret_zeroizing(var, lookup, read_file)?.map(|mut v| std::mem::take(&mut *v)))
378}
379
380fn secret_zeroizing(
384 var: &str,
385 lookup: impl Fn(&str) -> Option<String>,
386 read_file: impl Fn(&str) -> io::Result<String>,
387) -> Result<Option<Zeroizing<String>>, EnvError> {
388 let file_var = format!("{var}_FILE");
389 let direct = lookup(var)
390 .map(Zeroizing::new)
391 .filter(|s| !s.trim().is_empty());
392 let path = lookup(&file_var).filter(|s| !s.trim().is_empty());
393
394 match (direct, path) {
395 (Some(_), Some(path)) => Err(EnvError::BothSet {
396 var: var.to_string(),
397 path,
398 }),
399 (Some(v), None) => Ok(Some(Zeroizing::new(v.trim().to_string()))),
400 (None, Some(path)) => Ok(Some(read_file_value(var, path, read_file)?)),
401 (None, None) => Ok(None),
402 }
403}
404
405fn read_file_value(
409 var: &str,
410 path: String,
411 read_file: impl Fn(&str) -> io::Result<String>,
412) -> Result<Zeroizing<String>, EnvError> {
413 let raw =
414 Zeroizing::new(read_file(&path).map_err(|source| file_error(var, path.clone(), source))?);
415 if raw.len() > MAX_SECRET_FILE_BYTES {
416 return Err(EnvError::FileTooLarge {
417 var: var.to_string(),
418 path,
419 });
420 }
421 let value = Zeroizing::new(raw.trim().to_string());
422 if value.is_empty() {
423 return Err(EnvError::EmptyFile {
424 var: var.to_string(),
425 path,
426 });
427 }
428 Ok(value)
429}
430
431pub fn config_value_from_env(var: &str) -> Result<Option<String>, EnvError> {
485 config_value_from_lookup(var, |v| std::env::var(v).ok(), read_secret_file)
486}
487
488pub fn config_value_from_lookup(
528 var: &str,
529 lookup: impl Fn(&str) -> Option<String>,
530 read_file: impl Fn(&str) -> io::Result<String>,
531) -> Result<Option<String>, EnvError> {
532 let direct = lookup(var);
533 let path = lookup(&format!("{var}_FILE")).filter(|s| !s.trim().is_empty());
534
535 match (direct, path) {
536 (Some(v), Some(path)) if !v.trim().is_empty() => Err(EnvError::BothSet {
537 var: var.to_string(),
538 path,
539 }),
540 (_, Some(path)) => {
541 let mut value = read_file_value(var, path, read_file)?;
542 Ok(Some(std::mem::take(&mut *value)))
543 }
544 (Some(v), None) => Ok(Some(v)),
545 (None, None) => Ok(None),
546 }
547}
548
549pub fn static_tokens_from_env(var: &str) -> Result<Option<StaticTokens>, EnvError> {
605 static_tokens_from_lookup(var, |v| std::env::var(v).ok(), read_secret_file)
606}
607
608pub fn static_tokens_from_lookup(
644 var: &str,
645 lookup: impl Fn(&str) -> Option<String>,
646 read_file: impl Fn(&str) -> io::Result<String>,
647) -> Result<Option<StaticTokens>, EnvError> {
648 let current = secret_zeroizing(var, &lookup, &read_file);
651 let next = secret_zeroizing(&format!("{var}_NEXT"), &lookup, &read_file);
652 let (current, next) = match (current, next) {
653 (Ok(current), Ok(next)) => (current, next),
654 (Err(a), Err(b)) => return Err(EnvError::Several { errors: vec![a, b] }),
655 (Err(e), Ok(_)) | (Ok(_), Err(e)) => return Err(e),
656 };
657 match (current, next) {
658 (None, None) => Ok(None),
659 (None, Some(_)) => Err(EnvError::NextWithoutCurrent {
660 var: var.to_string(),
661 }),
662 (Some(current), next) => {
663 let mut tokens = StaticTokens::new();
664 tokens.push_checked(CURRENT_KEY_LABEL, current);
669 if let Some(next) = next
670 && !tokens.contains(&next)
671 {
672 tokens.push_checked(NEXT_KEY_LABEL, next);
673 }
674 Ok(Some(tokens))
675 }
676 }
677}
678
679fn split_list(value: &str) -> Vec<String> {
683 value.split_whitespace().map(str::to_string).collect()
684}
685
686fn parse_strict_bool(value: &str) -> Result<bool, ()> {
691 match value {
692 "true" => Ok(true),
693 "false" => Ok(false),
694 _ => Err(()),
695 }
696}
697
698fn bool_problem(naming: KeyNaming<'_>, field: &str, value: &str) -> ConfigProblem {
702 ConfigProblem::new(
703 ProblemKind::EnvParse,
704 [naming.key(field)],
705 format!(
706 "{} {value:?} must be \"true\" or \"false\"",
707 naming.key(field)
708 ),
709 )
710}
711
712fn env_problem(err: &EnvError) -> ConfigProblem {
717 ConfigProblem::new(ProblemKind::EnvLoad, error_keys(err), error_text(err))
718}
719
720fn error_text(err: &EnvError) -> String {
722 let mut text = err.to_string();
723 let mut source = std::error::Error::source(err);
724 while let Some(cause) = source {
725 text.push_str(": ");
726 text.push_str(&cause.to_string());
727 source = cause.source();
728 }
729 text
730}
731
732fn error_keys(err: &EnvError) -> Vec<String> {
737 match err {
738 EnvError::BothSet { var, .. } => vec![var.clone(), format!("{var}_FILE")],
739 EnvError::ReadFailed { var, .. }
740 | EnvError::EmptyFile { var, .. }
741 | EnvError::NotAFile { var, .. }
742 | EnvError::FileTooLarge { var, .. } => {
743 vec![format!("{var}_FILE")]
744 }
745 EnvError::NextWithoutCurrent { var } => vec![var.clone(), format!("{var}_NEXT")],
746 EnvError::Several { errors } => errors.iter().flat_map(error_keys).collect(),
747 }
748}
749
750fn take(
753 result: Result<Option<String>, EnvError>,
754 problems: &mut Vec<ConfigProblem>,
755) -> Option<String> {
756 result.unwrap_or_else(|e| {
757 problems.push(env_problem(&e));
758 None
759 })
760}
761
762struct IdentifyingVars {
771 issuer: Result<Option<String>, EnvError>,
772 jwks_uri: Result<Option<String>, EnvError>,
773 audience: Result<Option<String>, EnvError>,
774 audiences: Result<Option<String>, EnvError>,
775 resource: Result<Option<String>, EnvError>,
776}
777
778impl IdentifyingVars {
779 fn any_set(&self) -> bool {
785 [
786 &self.issuer,
787 &self.jwks_uri,
788 &self.audience,
789 &self.audiences,
790 &self.resource,
791 ]
792 .into_iter()
793 .any(|r| !matches!(r, Ok(None)))
794 }
795}
796
797pub fn oauth_config_from_env(prefix: &str) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
892 oauth_config_from_lookup(prefix, |v| std::env::var(v).ok(), read_secret_file)
893}
894
895pub fn oauth_config_from_lookup<L, R>(
937 prefix: &str,
938 lookup: L,
939 read_file: R,
940) -> Result<Option<ResolvedOAuthConfig>, ConfigError>
941where
942 L: Fn(&str) -> Option<String>,
943 R: Fn(&str) -> io::Result<String>,
944{
945 match unresolved_oauth_config_from_lookup(prefix, lookup, read_file) {
946 Some(loaded) => loaded.resolve(),
947 None => Ok(None),
948 }
949}
950
951#[derive(Debug, Clone)]
987#[non_exhaustive]
988pub struct EnvOAuthConfig {
989 pub config: OAuthConfig,
993 pub problems: Vec<String>,
1003 details: Vec<ConfigProblem>,
1004 prefix: String,
1005}
1006
1007impl PartialEq for EnvOAuthConfig {
1010 fn eq(&self, other: &Self) -> bool {
1011 self.config == other.config
1012 && self.problems == other.problems
1013 && self.prefix == other.prefix
1014 }
1015}
1016
1017impl Eq for EnvOAuthConfig {}
1018
1019impl EnvOAuthConfig {
1020 pub fn problem_details(&self) -> &[ConfigProblem] {
1031 &self.details
1032 }
1033
1034 pub fn prefix(&self) -> &str {
1037 &self.prefix
1038 }
1039
1040 pub fn resolve(self) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
1058 let Self {
1059 config,
1060 problems,
1061 details,
1062 prefix,
1063 } = self;
1064 let naming = KeyNaming::Env(&prefix);
1065 let mut all = reconcile(problems, details);
1069 match config.resolve(naming) {
1070 Ok(resolved) if all.is_empty() => Ok(resolved),
1071 Ok(_) => Err(ConfigError::from_problems(naming, all)),
1072 Err(resolve_err) => {
1073 all.extend(resolve_err.problem_details().iter().cloned());
1074 Err(ConfigError::from_problems(naming, all))
1075 }
1076 }
1077 }
1078}
1079
1080fn reconcile(problems: Vec<String>, details: Vec<ConfigProblem>) -> Vec<ConfigProblem> {
1083 let mut pool: Vec<Option<ConfigProblem>> = details.into_iter().map(Some).collect();
1084 problems
1085 .into_iter()
1086 .map(|text| {
1087 pool.iter_mut()
1088 .find(|slot| slot.as_ref().is_some_and(|d| d.message() == text))
1089 .and_then(Option::take)
1090 .unwrap_or_else(|| ConfigProblem::from(text))
1091 })
1092 .collect()
1093}
1094
1095pub fn unresolved_oauth_config_from_env(prefix: &str) -> Option<EnvOAuthConfig> {
1105 unresolved_oauth_config_from_lookup(prefix, |v| std::env::var(v).ok(), read_secret_file)
1106}
1107
1108pub fn unresolved_oauth_config_from_lookup<L, R>(
1118 prefix: &str,
1119 lookup: L,
1120 read_file: R,
1121) -> Option<EnvOAuthConfig>
1122where
1123 L: Fn(&str) -> Option<String>,
1124 R: Fn(&str) -> io::Result<String>,
1125{
1126 let naming = KeyNaming::Env(prefix);
1127 let field = |f: &str| secret_from_lookup(&naming.key(f), &lookup, &read_file);
1128 let mut problems: Vec<ConfigProblem> = Vec::new();
1129
1130 let explicitly_enabled = match field("enabled") {
1134 Ok(None) => false,
1135 Ok(Some(v)) => match parse_strict_bool(&v) {
1136 Ok(false) => return None,
1137 Ok(true) => true,
1138 Err(()) => {
1139 problems.push(bool_problem(naming, "enabled", &v));
1140 true
1141 }
1142 },
1143 Err(e) => {
1144 problems.push(env_problem(&e));
1145 true
1146 }
1147 };
1148
1149 let identifying = IdentifyingVars {
1150 issuer: field("issuer"),
1151 jwks_uri: field("jwks_uri"),
1152 audience: field("audience"),
1153 audiences: field("audiences"),
1154 resource: field("resource"),
1155 };
1156 if !explicitly_enabled && !identifying.any_set() {
1157 return None;
1158 }
1159
1160 let mut cfg = OAuthConfig {
1161 enabled: true,
1162 ..OAuthConfig::default()
1163 };
1164 let IdentifyingVars {
1165 issuer,
1166 jwks_uri,
1167 audience,
1168 audiences,
1169 resource,
1170 } = identifying;
1171 if let Some(v) = take(issuer, &mut problems) {
1172 cfg.issuer = v;
1173 }
1174 cfg.jwks_uri = take(jwks_uri, &mut problems);
1175 if let Some(v) = take(audience, &mut problems) {
1176 cfg.audience = v;
1177 }
1178 if let Some(v) = take(audiences, &mut problems) {
1179 cfg.audiences = split_list(&v);
1180 }
1181 if let Some(v) = take(resource, &mut problems) {
1182 cfg.resource = v;
1183 }
1184
1185 cfg.required_scope = take(field("required_scope"), &mut problems);
1186 if cfg.required_scope.is_none()
1190 && lookup(&naming.key("required_scope"))
1191 .is_some_and(|v| !v.is_empty() && v.trim().is_empty())
1192 {
1193 cfg.required_scope = Some(String::new());
1194 }
1195 if let Some(v) = take(field("required_scopes"), &mut problems) {
1196 cfg.required_scopes = split_list(&v);
1197 }
1198 cfg.scopes_supported = take(field("scopes_supported"), &mut problems).map(|v| split_list(&v));
1200 if let Some(v) = take(field("scope_claims"), &mut problems) {
1201 cfg.scope_claims = split_list(&v);
1202 }
1203 if let Some(v) = take(field("principal_claims"), &mut problems) {
1204 cfg.principal_claims = split_list(&v);
1205 }
1206 if let Some(v) = take(field("algorithms"), &mut problems) {
1207 cfg.algorithms = split_list(&v);
1208 }
1209 if let Some(v) = take(field("leeway_secs"), &mut problems) {
1210 match v.parse::<u64>() {
1211 Ok(n) => cfg.leeway_secs = n,
1212 Err(_) => problems.push(ConfigProblem::new(
1213 ProblemKind::EnvParse,
1214 [naming.key("leeway_secs")],
1215 format!(
1216 "{} {v:?} is not a valid non-negative integer",
1217 naming.key("leeway_secs")
1218 ),
1219 )),
1220 }
1221 }
1222 if let Some(v) = take(field("allowed_client_ids"), &mut problems) {
1223 cfg.allowed_client_ids = split_list(&v);
1224 }
1225 if let Some(v) = take(field("max_token_age_secs"), &mut problems) {
1226 match v.parse::<u64>() {
1227 Ok(n) => cfg.max_token_age_secs = Some(n),
1228 Err(_) => problems.push(ConfigProblem::new(
1229 ProblemKind::EnvParse,
1230 [naming.key("max_token_age_secs")],
1231 format!(
1232 "{} {v:?} is not a valid non-negative integer",
1233 naming.key("max_token_age_secs")
1234 ),
1235 )),
1236 }
1237 }
1238 if let Some(v) = take(field("required_claims"), &mut problems) {
1239 match serde_json::from_str::<serde_json::Value>(&v) {
1243 Ok(serde_json::Value::Object(map)) => match duplicated_claim(&v) {
1244 Some(name) => problems.push(ConfigProblem::new(
1247 ProblemKind::InvalidRequiredClaim,
1248 [naming.key("required_claims")],
1249 format!(
1250 "{} names {:?} more than once — each claim may appear once",
1251 naming.key("required_claims"),
1252 crate::token::for_log(&name)
1253 ),
1254 )),
1255 None => cfg.required_claims = map.into_iter().collect(),
1256 },
1257 Ok(_) => problems.push(ConfigProblem::new(
1258 ProblemKind::EnvParse,
1259 [naming.key("required_claims")],
1260 format!(
1261 "{} must be a JSON object, e.g. {{\"tid\": \"<tenant id>\"}}",
1262 naming.key("required_claims")
1263 ),
1264 )),
1265 Err(e) => problems.push(ConfigProblem::new(
1266 ProblemKind::EnvParse,
1267 [naming.key("required_claims")],
1268 format!(
1269 "{} is not valid JSON ({e}); it must be a JSON object, e.g. \
1270 {{\"tid\": \"<tenant id>\"}}",
1271 naming.key("required_claims")
1272 ),
1273 )),
1274 }
1275 }
1276 for (name, slot) in [
1277 ("require_at_jwt", &mut cfg.require_at_jwt),
1278 ("allow_unscoped_tokens", &mut cfg.allow_unscoped_tokens),
1279 ("allow_insecure_http", &mut cfg.allow_insecure_http),
1280 ("accept_static_bearer", &mut cfg.accept_static_bearer),
1281 ] {
1282 if let Some(v) = take(field(name), &mut problems) {
1283 match parse_strict_bool(&v) {
1284 Ok(b) => *slot = b,
1285 Err(()) => problems.push(bool_problem(naming, name, &v)),
1286 }
1287 }
1288 }
1289
1290 Some(EnvOAuthConfig {
1291 config: cfg,
1292 problems: problems.iter().map(|p| p.message().to_string()).collect(),
1293 details: problems,
1294 prefix: prefix.to_string(),
1295 })
1296}
1297#[cfg(test)]
1298mod tests {
1299 use super::*;
1300 use std::cell::Cell;
1301 use std::collections::HashMap;
1302
1303 fn lookup_from<'a>(
1306 vars: &'a HashMap<&'static str, &'static str>,
1307 ) -> impl Fn(&str) -> Option<String> + 'a {
1308 move |k| vars.get(k).map(|v| v.to_string())
1309 }
1310
1311 fn files_from<'a>(
1312 files: &'a HashMap<&'static str, &'static str>,
1313 ) -> impl Fn(&str) -> io::Result<String> + 'a {
1314 move |p| {
1315 files
1316 .get(p)
1317 .map(|c| c.to_string())
1318 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no such file"))
1319 }
1320 }
1321
1322 #[test]
1323 fn absent_is_none() {
1324 let vars = HashMap::new();
1325 let files = HashMap::new();
1326 assert_eq!(
1330 secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1331 None
1332 );
1333 }
1334
1335 #[test]
1336 fn direct_value_is_trimmed() {
1337 let vars = HashMap::from([("FOO", " bar ")]);
1338 let files = HashMap::new();
1339 assert_eq!(
1340 secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1341 Some("bar".to_string())
1342 );
1343 }
1344
1345 #[test]
1346 fn direct_value_empty_or_whitespace_is_none() {
1347 for value in ["", " "] {
1348 let vars = HashMap::from([("FOO", value)]);
1349 let files = HashMap::new();
1350 assert_eq!(
1351 secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1352 None,
1353 "{value:?} should read as unset"
1354 );
1355 }
1356 }
1357
1358 #[test]
1359 fn file_value_is_read_and_trimmed() {
1360 let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo")]);
1361 let files = HashMap::from([("/run/secrets/foo", "bar\n")]);
1362 assert_eq!(
1363 secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1364 Some("bar".to_string())
1365 );
1366 }
1367
1368 const SENTINEL: &str = "s3cr3t-sentinel";
1370
1371 fn assert_no_leak(err: &EnvError) {
1373 let display = err.to_string();
1374 let debug = format!("{err:?}");
1375 assert!(!display.contains(SENTINEL), "Display leaks: {display}");
1376 assert!(!debug.contains(SENTINEL), "Debug leaks: {debug}");
1377 }
1378
1379 #[test]
1380 fn both_set_is_an_error_naming_var_and_path_only() {
1381 let vars = HashMap::from([("FOO", SENTINEL), ("FOO_FILE", "/run/secrets/foo")]);
1382 let files = HashMap::from([("/run/secrets/foo", SENTINEL)]);
1383 let err = secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap_err();
1384 let text = err.to_string();
1385 assert!(text.contains("FOO"), "{text}");
1386 assert!(text.contains("FOO_FILE"), "{text}");
1387 assert!(text.contains("/run/secrets/foo"), "{text}");
1388 assert!(matches!(err, EnvError::BothSet { .. }));
1389 assert_no_leak(&err);
1390 }
1391
1392 #[test]
1393 fn unreadable_file_is_an_error_naming_the_path() {
1394 let vars = HashMap::from([("FOO_FILE", "/run/secrets/missing")]);
1395 let files = HashMap::new();
1396 let err = secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap_err();
1397 assert!(matches!(err, EnvError::ReadFailed { .. }));
1398 let text = err.to_string();
1399 assert!(text.contains("FOO_FILE"), "{text}");
1400 assert!(text.contains("/run/secrets/missing"), "{text}");
1401 assert!(!text.contains("no such file"), "{text}");
1404 let source = std::error::Error::source(&err).expect("the I/O error is the source");
1405 assert_eq!(source.to_string(), "no such file");
1406 }
1407
1408 #[test]
1411 fn a_read_failure_problem_carries_its_io_cause_once() {
1412 let vars = HashMap::from([
1413 ("APP_OAUTH_ISSUER_FILE", "/run/secrets/missing"),
1414 ("APP_OAUTH_AUDIENCE", "client-id"),
1415 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1416 ]);
1417 let files = HashMap::new();
1418 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1419 .unwrap_err();
1420 assert_eq!(
1421 err.problems[0],
1422 "APP_OAUTH_ISSUER_FILE=/run/secrets/missing: failed to read secret file: no such file"
1423 );
1424 assert_eq!(err.to_string().matches("no such file").count(), 1);
1425 }
1426
1427 #[test]
1432 fn a_file_that_fails_to_decode_does_not_leak_its_contents() {
1433 let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo")]);
1434 let files = HashMap::from([("/run/secrets/foo", SENTINEL)]);
1435 let read_file = |path: &str| -> io::Result<String> {
1436 assert!(files.contains_key(path), "unexpected path {path}");
1437 Err(io::Error::new(
1438 io::ErrorKind::InvalidData,
1439 "stream did not contain valid UTF-8",
1440 ))
1441 };
1442 let err = secret_from_lookup("FOO", lookup_from(&vars), read_file).unwrap_err();
1443 assert!(matches!(err, EnvError::ReadFailed { .. }));
1444 assert_no_leak(&err);
1445 }
1446
1447 #[test]
1448 fn empty_file_is_an_error_not_none() {
1449 let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo"), ("FOO_TOKEN", SENTINEL)]);
1452 let files = HashMap::from([("/run/secrets/foo", " \n")]);
1453 let err = secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap_err();
1454 assert!(matches!(err, EnvError::EmptyFile { .. }));
1455 let text = err.to_string();
1456 assert!(text.contains("FOO_FILE"), "{text}");
1457 assert!(text.contains("/run/secrets/foo"), "{text}");
1458 assert_no_leak(&err);
1459 }
1460
1461 #[test]
1462 fn secret_from_env_wraps_the_real_environment() {
1463 assert_eq!(
1466 secret_from_env("OAUTH_RESOURCE_SERVER_ENV_RS_TEST_UNSET_VAR_9f3c").unwrap(),
1467 None
1468 );
1469 }
1470
1471 #[test]
1474 fn config_value_absent_is_none() {
1475 let vars = HashMap::new();
1476 let files = HashMap::new();
1477 assert_eq!(
1478 config_value_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1479 None
1480 );
1481 }
1482
1483 #[test]
1484 fn config_value_direct_is_returned_as_set() {
1485 for value in ["bar", "", " ", " bar "] {
1488 let vars = HashMap::from([("FOO", value)]);
1489 let files = HashMap::new();
1490 assert_eq!(
1491 config_value_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1492 Some(value.to_string()),
1493 "{value:?}"
1494 );
1495 }
1496 }
1497
1498 #[test]
1499 fn config_value_file_is_read_and_trimmed() {
1500 let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo")]);
1501 let files = HashMap::from([("/run/secrets/foo", "bar\n")]);
1502 assert_eq!(
1503 config_value_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1504 Some("bar".to_string())
1505 );
1506 }
1507
1508 #[test]
1509 fn config_value_blank_file_variable_is_unset() {
1510 let vars = HashMap::from([("FOO_FILE", " ")]);
1511 let files = HashMap::new();
1512 assert_eq!(
1513 config_value_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1514 None
1515 );
1516 }
1517
1518 #[test]
1519 fn config_value_blank_direct_yields_to_a_file() {
1520 for blank in ["", " "] {
1521 let vars = HashMap::from([("FOO", blank), ("FOO_FILE", "/run/secrets/foo")]);
1522 let files = HashMap::from([("/run/secrets/foo", "bar\n")]);
1523 assert_eq!(
1524 config_value_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1525 Some("bar".to_string()),
1526 "{blank:?}"
1527 );
1528 }
1529 }
1530
1531 #[test]
1532 fn config_value_both_set_is_an_error_naming_var_and_path_only() {
1533 let vars = HashMap::from([("FOO", SENTINEL), ("FOO_FILE", "/run/secrets/foo")]);
1534 let files = HashMap::from([("/run/secrets/foo", SENTINEL)]);
1535 let err =
1536 config_value_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap_err();
1537 assert!(matches!(err, EnvError::BothSet { .. }));
1538 let text = err.to_string();
1539 assert!(text.contains("FOO_FILE"), "{text}");
1540 assert!(text.contains("/run/secrets/foo"), "{text}");
1541 assert_no_leak(&err);
1542 }
1543
1544 type BoxedReader = Box<dyn Fn(&str) -> io::Result<String>>;
1545 type ReadCase<'a> = (&'a [(&'a str, &'a str)], Option<&'a str>, usize);
1548
1549 #[test]
1550 fn config_value_file_failures_are_the_same_variants_as_secret_from_lookup() {
1551 let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo")]);
1552 let big = "x".repeat(MAX_SECRET_FILE_BYTES + 1);
1553 let refused = |why: FileRefused| {
1554 move |_: &str| Err(io::Error::new(io::ErrorKind::InvalidInput, why.clone()))
1555 };
1556 let readers: Vec<(&str, BoxedReader)> = vec![
1557 (
1558 "unreadable",
1559 Box::new(|_: &str| Err(io::Error::from(io::ErrorKind::NotFound))),
1560 ),
1561 ("empty", Box::new(|_: &str| Ok(" \n".to_string()))),
1562 ("oversized", Box::new(move |_: &str| Ok(big.clone()))),
1563 ("not a file", Box::new(refused(FileRefused::NotAFile))),
1564 ("too large", Box::new(refused(FileRefused::TooLarge))),
1565 ];
1566 for (name, reader) in &readers {
1567 let config = config_value_from_lookup("FOO", lookup_from(&vars), reader).unwrap_err();
1568 let secret = secret_from_lookup("FOO", lookup_from(&vars), reader).unwrap_err();
1569 assert_eq!(
1570 std::mem::discriminant(&config),
1571 std::mem::discriminant(&secret),
1572 "{name}: {config:?} vs {secret:?}"
1573 );
1574 assert_eq!(config.to_string(), secret.to_string(), "{name}");
1575 }
1576 }
1577
1578 #[test]
1579 fn config_value_file_is_read_only_when_var_is_blank_and_the_file_variable_is_set() {
1580 let reads = Cell::new(0);
1581 let reader = |_: &str| {
1582 reads.set(reads.get() + 1);
1583 Ok("from-file".to_string())
1584 };
1585 let cases: [ReadCase; 5] = [
1586 (&[("FOO", "x"), ("FOO_FILE", "/f")], None, 0),
1588 (&[("FOO", "x"), ("FOO_FILE", " ")], Some("x"), 0),
1590 (&[("FOO", ""), ("FOO_FILE", "")], Some(""), 0),
1592 (&[], None, 0),
1594 (&[("FOO", ""), ("FOO_FILE", "/f")], Some("from-file"), 1),
1596 ];
1597 for (vars, want, want_reads) in cases {
1598 reads.set(0);
1599 let vars: HashMap<&str, &str> = vars.iter().copied().collect();
1600 let got =
1601 config_value_from_lookup("FOO", |k| vars.get(k).map(|v| v.to_string()), reader);
1602 match want {
1603 Some(want) => assert_eq!(got.unwrap().as_deref(), Some(want), "{vars:?}"),
1604 None => assert!(
1605 matches!(&got, Err(EnvError::BothSet { .. }) | Ok(None)),
1606 "{vars:?}: {got:?}"
1607 ),
1608 }
1609 assert_eq!(reads.get(), want_reads, "{vars:?}");
1610 }
1611 }
1612
1613 #[test]
1614 fn config_value_from_env_wraps_the_real_environment() {
1615 assert_eq!(
1616 config_value_from_env("OAUTH_RESOURCE_SERVER_ENV_RS_TEST_UNSET_VAR_9f3c").unwrap(),
1617 None
1618 );
1619 }
1620
1621 #[test]
1622 fn the_public_reader_refuses_a_directory_and_an_oversized_file() {
1623 let refusal = |err: io::Error| {
1625 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
1626 err.get_ref()
1627 .and_then(|e| e.downcast_ref::<FileRefused>())
1628 .cloned()
1629 };
1630
1631 let dir = std::env::temp_dir();
1632 let err = read_secret_file(dir.to_str().unwrap()).unwrap_err();
1633 assert_eq!(refusal(err), Some(FileRefused::NotAFile));
1634
1635 let path = temp_path("public-reader-big");
1636 std::fs::write(&path, "x".repeat(MAX_SECRET_FILE_BYTES + 1)).unwrap();
1637 let result = read_secret_file(path.to_str().unwrap());
1638 std::fs::remove_file(&path).unwrap();
1639 assert_eq!(refusal(result.unwrap_err()), Some(FileRefused::TooLarge));
1640
1641 let missing = read_secret_file(temp_path("public-reader-missing").to_str().unwrap());
1643 assert_eq!(missing.unwrap_err().kind(), io::ErrorKind::NotFound);
1644 }
1645
1646 struct RecordingLookup<'a> {
1652 vars: &'a HashMap<&'static str, &'static str>,
1653 calls: Cell<Vec<String>>,
1654 }
1655
1656 impl<'a> RecordingLookup<'a> {
1657 fn new(vars: &'a HashMap<&'static str, &'static str>) -> Self {
1658 Self {
1659 vars,
1660 calls: Cell::new(Vec::new()),
1661 }
1662 }
1663
1664 fn call(&self, var: &str) -> Option<String> {
1665 let mut calls = self.calls.take();
1666 calls.push(var.to_string());
1667 self.calls.set(calls);
1668 self.vars.get(var).map(|v| v.to_string())
1669 }
1670 }
1671
1672 #[test]
1673 fn nothing_set_is_none_and_touches_only_identifying_variables() {
1674 let vars = HashMap::new();
1675 let files = HashMap::new();
1676 let recorder = RecordingLookup::new(&vars);
1677 let result =
1678 oauth_config_from_lookup("APP_OAUTH_", |v| recorder.call(v), files_from(&files));
1679 assert_eq!(result, Ok(None));
1680 let calls = recorder.calls.take();
1681 for var in &calls {
1682 assert!(
1683 var.starts_with("APP_OAUTH_")
1684 && (var.ends_with("ENABLED")
1685 || var.ends_with("ENABLED_FILE")
1686 || var.ends_with("ISSUER")
1687 || var.ends_with("ISSUER_FILE")
1688 || var.ends_with("JWKS_URI")
1689 || var.ends_with("JWKS_URI_FILE")
1690 || var.ends_with("AUDIENCE")
1691 || var.ends_with("AUDIENCE_FILE")
1692 || var.ends_with("AUDIENCES")
1693 || var.ends_with("AUDIENCES_FILE")
1694 || var.ends_with("RESOURCE")
1695 || var.ends_with("RESOURCE_FILE")),
1696 "unexpected variable consulted while OAuth is unconfigured: {var} (all: {calls:?})"
1697 );
1698 }
1699 assert!(!calls.is_empty(), "the identifying vars must be checked");
1700 }
1701
1702 #[test]
1707 fn a_typical_prefixed_variable_set_resolves_verbatim() {
1708 let vars = HashMap::from([
1709 (
1710 "MYAPP_OAUTH_ISSUER",
1711 "https://idp.example.test/application/o/myapp/",
1712 ),
1713 (
1714 "MYAPP_OAUTH_JWKS_URI",
1715 "https://idp.example.test/application/o/myapp/jwks/",
1716 ),
1717 ("MYAPP_OAUTH_AUDIENCE", "myapp-client-id"),
1718 ("MYAPP_OAUTH_RESOURCE", "https://myapp.example.test/mcp"),
1719 ("MYAPP_OAUTH_REQUIRED_SCOPE", "myapp:read"),
1720 ("MYAPP_OAUTH_SCOPES_SUPPORTED", "myapp:read myapp:write"),
1721 ]);
1722 let files = HashMap::new();
1723 let resolved =
1724 oauth_config_from_lookup("MYAPP_OAUTH_", lookup_from(&vars), files_from(&files))
1725 .unwrap()
1726 .expect("identifying vars are set");
1727
1728 assert_eq!(
1729 resolved.issuer,
1730 "https://idp.example.test/application/o/myapp/"
1731 );
1732 assert_eq!(
1733 resolved.jwks_uri.as_deref(),
1734 Some("https://idp.example.test/application/o/myapp/jwks/")
1735 );
1736 assert_eq!(resolved.audience, "myapp-client-id");
1737 assert_eq!(resolved.resource, "https://myapp.example.test/mcp");
1738 assert_eq!(resolved.required_scopes, ["myapp:read"]);
1739 assert_eq!(resolved.scopes_supported, ["myapp:read", "myapp:write"]);
1740 assert_eq!(resolved.scope_claims, ["scope", "scp"]);
1742 assert!(!resolved.require_at_jwt);
1743 assert!(resolved.accept_static_bearer);
1744 }
1745
1746 #[test]
1747 fn jwks_uri_alone_counts_as_identifying_even_though_resolve_does_not_require_it() {
1748 let vars = HashMap::from([("APP_OAUTH_JWKS_URI", "https://idp.example.test/jwks")]);
1749 let files = HashMap::new();
1750 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1751 .unwrap_err();
1752 assert!(err.problems.iter().any(|p| p.contains("APP_OAUTH_ISSUER")));
1755 assert!(
1756 !err.problems
1757 .iter()
1758 .any(|p| p.contains("APP_OAUTH_JWKS_URI"))
1759 );
1760 }
1761
1762 #[test]
1763 fn partial_set_is_an_error_listing_what_is_missing() {
1764 let vars = HashMap::from([("APP_OAUTH_ISSUER", "https://idp.example.test/")]);
1765 let files = HashMap::new();
1766 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1767 .unwrap_err();
1768 let text = err.to_string();
1769 assert!(text.contains("APP_OAUTH_AUDIENCE"), "{text}");
1770 assert!(text.contains("APP_OAUTH_RESOURCE"), "{text}");
1771 }
1772
1773 #[test]
1774 fn a_both_set_error_on_an_identifying_variable_still_counts_as_configured() {
1775 let vars = HashMap::from([
1776 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1777 ("APP_OAUTH_ISSUER_FILE", "/run/secrets/issuer"),
1778 ]);
1779 let files = HashMap::from([("/run/secrets/issuer", "https://idp.example.test/")]);
1780 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1781 .unwrap_err();
1782 assert!(
1784 err.problems[0].contains("APP_OAUTH_ISSUER") && err.problems[0].contains("both set"),
1785 "{:?}",
1786 err.problems
1787 );
1788 assert!(
1793 err.problems[1..]
1794 .iter()
1795 .any(|p| p.starts_with("these required settings are empty")
1796 && p.contains("APP_OAUTH_ISSUER")),
1797 "{:?}",
1798 err.problems
1799 );
1800 }
1801
1802 #[test]
1805 fn scopes_supported_defaults_to_the_required_scope() {
1806 let vars = HashMap::from([
1807 ("MYAPP_OAUTH_ISSUER", "https://idp.example.test/"),
1808 ("MYAPP_OAUTH_JWKS_URI", "http://127.0.0.1:1/jwks"),
1809 ("MYAPP_OAUTH_AUDIENCE", "myapp-client-id"),
1810 ("MYAPP_OAUTH_RESOURCE", "https://myapp.example.test/mcp"),
1811 ("MYAPP_OAUTH_REQUIRED_SCOPE", "myapp:read"),
1812 ]);
1813 let files = HashMap::new();
1814 let resolved =
1815 oauth_config_from_lookup("MYAPP_OAUTH_", lookup_from(&vars), files_from(&files))
1816 .unwrap()
1817 .unwrap();
1818 assert_eq!(resolved.required_scopes, ["myapp:read"]);
1819 assert_eq!(resolved.scopes_supported, ["myapp:read"]);
1820 }
1821
1822 #[test]
1823 fn scopes_supported_default_unions_required_scopes_in_order_without_duplicates() {
1824 let vars = HashMap::from([
1825 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1826 ("APP_OAUTH_AUDIENCE", "client-id"),
1827 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1828 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1829 ("APP_OAUTH_REQUIRED_SCOPES", "api:write api:read"),
1830 ]);
1831 let files = HashMap::new();
1832 let resolved =
1833 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1834 .unwrap()
1835 .unwrap();
1836 assert_eq!(resolved.scopes_supported, ["api:read", "api:write"]);
1837 }
1838
1839 #[test]
1840 fn scopes_supported_stays_empty_with_no_required_scope() {
1841 let vars = HashMap::from([
1842 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1843 ("APP_OAUTH_AUDIENCE", "client-id"),
1844 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1845 ("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", "true"),
1847 ("APP_OAUTH_SCOPES_SUPPORTED", " "),
1849 ]);
1850 let files = HashMap::new();
1851 let resolved =
1852 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1853 .unwrap()
1854 .unwrap();
1855 assert!(resolved.required_scopes.is_empty());
1856 assert!(resolved.scopes_supported.is_empty());
1857 }
1858
1859 #[test]
1860 fn explicit_scopes_supported_is_not_replaced_by_the_default() {
1861 let vars = HashMap::from([
1862 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1863 ("APP_OAUTH_AUDIENCE", "client-id"),
1864 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1865 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1866 ("APP_OAUTH_SCOPES_SUPPORTED", "api:admin"),
1867 ]);
1868 let files = HashMap::new();
1869 let resolved =
1870 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1871 .unwrap()
1872 .unwrap();
1873 assert_eq!(resolved.scopes_supported, ["api:admin"]);
1874 }
1875
1876 #[test]
1877 fn enabled_true_alone_turns_oauth_on_and_reports_what_is_missing() {
1878 let vars = HashMap::from([("APP_OAUTH_ENABLED", "true")]);
1879 let files = HashMap::new();
1880 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1881 .unwrap_err();
1882 let text = err.to_string();
1883 assert!(text.contains("APP_OAUTH_ISSUER"), "{text}");
1884 assert!(text.contains("APP_OAUTH_AUDIENCE"), "{text}");
1885 assert!(text.contains("APP_OAUTH_RESOURCE"), "{text}");
1886 }
1887
1888 #[test]
1889 fn enabled_true_with_a_complete_set_resolves() {
1890 let vars = HashMap::from([
1891 ("APP_OAUTH_ENABLED", "true"),
1892 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1893 ("APP_OAUTH_AUDIENCE", "client-id"),
1894 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1895 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1896 ]);
1897 let files = HashMap::new();
1898 let resolved =
1899 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files)).unwrap();
1900 assert!(resolved.is_some());
1901 }
1902
1903 #[test]
1904 fn enabled_false_turns_oauth_off_without_reading_anything_else() {
1905 let vars = HashMap::from([
1906 ("APP_OAUTH_ENABLED", "false"),
1907 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1908 ("APP_OAUTH_AUDIENCE", "client-id"),
1909 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1910 ("APP_OAUTH_LEEWAY_SECS", "not-a-number"),
1912 ]);
1913 let files = HashMap::new();
1914 let recorder = RecordingLookup::new(&vars);
1915 let result =
1916 oauth_config_from_lookup("APP_OAUTH_", |v| recorder.call(v), files_from(&files));
1917 assert_eq!(result, Ok(None));
1918 let calls = recorder.calls.take();
1919 assert_eq!(calls, ["APP_OAUTH_ENABLED", "APP_OAUTH_ENABLED_FILE"]);
1920 }
1921
1922 #[test]
1923 fn enabled_from_a_file_is_honoured() {
1924 let vars = HashMap::from([
1925 ("APP_OAUTH_ENABLED_FILE", "/run/secrets/enabled"),
1926 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1927 ]);
1928 let files = HashMap::from([("/run/secrets/enabled", "false\n")]);
1929 assert_eq!(
1930 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files)),
1931 Ok(None)
1932 );
1933 }
1934
1935 #[test]
1936 fn an_unparsable_enabled_is_reported_and_treated_as_on() {
1937 let vars = HashMap::from([("APP_OAUTH_ENABLED", "yes")]);
1938 let files = HashMap::new();
1939 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1940 .unwrap_err();
1941 assert!(
1942 err.problems[0].contains("APP_OAUTH_ENABLED")
1943 && err.problems[0].contains("must be \"true\" or \"false\""),
1944 "{:?}",
1945 err.problems
1946 );
1947 assert!(err.to_string().contains("APP_OAUTH_ISSUER"));
1949 }
1950
1951 #[test]
1952 fn an_enabled_that_fails_to_load_is_reported_and_treated_as_on() {
1953 let vars = HashMap::from([("APP_OAUTH_ENABLED_FILE", "/run/secrets/missing")]);
1954 let files = HashMap::new();
1955 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1956 .unwrap_err();
1957 assert!(
1958 err.problems[0].contains("APP_OAUTH_ENABLED_FILE"),
1959 "{:?}",
1960 err.problems
1961 );
1962 assert!(err.to_string().contains("APP_OAUTH_ISSUER"));
1963 }
1964
1965 #[test]
1966 fn loader_problems_carry_env_load_and_env_parse_kinds() {
1967 let vars = HashMap::from([
1968 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1969 ("APP_OAUTH_ISSUER_FILE", "/run/secrets/issuer"),
1970 ("APP_OAUTH_AUDIENCE_FILE", "/run/secrets/missing"),
1971 ("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
1972 ("APP_OAUTH_REQUIRE_AT_JWT", "yes"),
1973 ("APP_OAUTH_LEEWAY_SECS", "soon"),
1974 ("APP_OAUTH_ALLOW_UNSCOPED_TOKENS_FILE", "/run/secrets/empty"),
1975 ]);
1976 let files = HashMap::from([("/run/secrets/empty", " ")]);
1977 let loaded = unresolved_oauth_config_from_lookup(
1978 "APP_OAUTH_",
1979 lookup_from(&vars),
1980 files_from(&files),
1981 )
1982 .unwrap();
1983 let got: Vec<_> = loaded
1984 .problem_details()
1985 .iter()
1986 .map(|p| (p.kind(), p.keys().to_vec()))
1987 .collect();
1988 let env = |v: &str| vec![v.to_string()];
1989 assert_eq!(
1990 got,
1991 [
1992 (
1993 ProblemKind::EnvLoad,
1994 vec![
1995 "APP_OAUTH_ISSUER".to_string(),
1996 "APP_OAUTH_ISSUER_FILE".to_string()
1997 ],
1998 ),
1999 (ProblemKind::EnvLoad, env("APP_OAUTH_AUDIENCE_FILE")),
2000 (ProblemKind::EnvParse, env("APP_OAUTH_LEEWAY_SECS")),
2001 (ProblemKind::EnvParse, env("APP_OAUTH_REQUIRE_AT_JWT")),
2002 (
2003 ProblemKind::EnvLoad,
2004 env("APP_OAUTH_ALLOW_UNSCOPED_TOKENS_FILE")
2005 ),
2006 ]
2007 );
2008 let texts: Vec<&str> = loaded
2010 .problem_details()
2011 .iter()
2012 .map(ConfigProblem::message)
2013 .collect();
2014 assert_eq!(texts, loaded.problems);
2015
2016 let err = loaded.resolve().unwrap_err();
2018 assert_eq!(err.problems.len(), err.problem_details().len());
2019 assert_eq!(err.problem_details()[0].kind(), ProblemKind::EnvLoad);
2020 assert_eq!(err.problem_details()[2].kind(), ProblemKind::EnvParse);
2021 let texts: Vec<&str> = err
2022 .problem_details()
2023 .iter()
2024 .map(ConfigProblem::message)
2025 .collect();
2026 assert_eq!(texts, err.problems);
2027 }
2028
2029 #[test]
2030 fn env_oauth_config_equality_ignores_the_structured_details() {
2031 let vars = HashMap::from([
2032 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
2033 ("APP_OAUTH_ALLOW_INSECURE_HTTP", "maybe"),
2034 ]);
2035 let files = HashMap::new();
2036 let load = || {
2037 unresolved_oauth_config_from_lookup(
2038 "APP_OAUTH_",
2039 lookup_from(&vars),
2040 files_from(&files),
2041 )
2042 .unwrap()
2043 };
2044 let a = load();
2045 let mut b = load();
2046 assert_eq!(a, b);
2047 b.details = vec![ConfigProblem::from(a.problems[0].clone())];
2049 assert_eq!(a, b);
2050 b.problems.push("extra".into());
2052 assert_ne!(a, b);
2053 }
2054
2055 #[test]
2056 fn an_enabled_that_is_not_a_bool_is_an_env_parse_problem() {
2057 let vars = HashMap::from([("APP_OAUTH_ENABLED", "maybe")]);
2058 let files = HashMap::new();
2059 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2060 .unwrap_err();
2061 let p = &err.problem_details()[0];
2062 assert_eq!(p.kind(), ProblemKind::EnvParse);
2063 assert_eq!(p.keys(), ["APP_OAUTH_ENABLED"]);
2064 assert!(
2066 err.problem_details()
2067 .iter()
2068 .any(|p| p.kind() == ProblemKind::MissingRequired
2069 && p.keys().contains(&"APP_OAUTH_ISSUER".to_string()))
2070 );
2071 }
2072
2073 #[test]
2074 fn edited_problems_still_reach_resolve_as_other_and_matches_keep_their_kind() {
2075 let vars = HashMap::from([
2076 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
2077 ("APP_OAUTH_AUDIENCE", "client-id"),
2078 ("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
2079 ("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", "maybe"),
2080 ]);
2081 let files = HashMap::new();
2082 let mut loaded = unresolved_oauth_config_from_lookup(
2083 "APP_OAUTH_",
2084 lookup_from(&vars),
2085 files_from(&files),
2086 )
2087 .unwrap();
2088 loaded.problems.insert(0, "app-side problem".into());
2089 assert_eq!(loaded.problem_details().len(), 1);
2091 let err = loaded.resolve().unwrap_err();
2092 assert_eq!(err.problems[0], "app-side problem");
2093 assert_eq!(err.problem_details()[0].kind(), ProblemKind::Other);
2094 assert_eq!(err.problem_details()[1].kind(), ProblemKind::EnvParse);
2095 }
2096
2097 #[test]
2098 fn whitespace_lists_are_split() {
2099 let vars = HashMap::from([
2100 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
2101 ("APP_OAUTH_AUDIENCE", "client-id"),
2102 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
2103 ("APP_OAUTH_REQUIRED_SCOPES", " api:read api:write "),
2104 ("APP_OAUTH_AUDIENCES", "extra-aud another-aud"),
2105 ("APP_OAUTH_ALGORITHMS", "RS256 ES256"),
2106 ]);
2107 let files = HashMap::new();
2108 let resolved =
2109 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2110 .unwrap()
2111 .unwrap();
2112 assert_eq!(resolved.required_scopes, ["api:read", "api:write"]);
2113 assert_eq!(
2114 resolved.accepted_audiences(),
2115 ["client-id", "extra-aud", "another-aud"]
2116 );
2117 assert!(resolved.algorithms.contains(&crate::Algorithm::RS256));
2118 assert!(resolved.algorithms.contains(&crate::Algorithm::ES256));
2119 }
2120
2121 #[test]
2122 fn parse_errors_for_bool_and_integer_fields_are_aggregated_with_other_problems() {
2123 let vars = HashMap::from([
2124 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
2125 ("APP_OAUTH_AUDIENCE", "client-id"),
2126 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
2127 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
2128 ("APP_OAUTH_LEEWAY_SECS", "not-a-number"),
2129 ("APP_OAUTH_REQUIRE_AT_JWT", "yes"),
2130 ("APP_OAUTH_ACCEPT_STATIC_BEARER", "0"),
2131 ]);
2132 let files = HashMap::new();
2133 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2134 .unwrap_err();
2135 assert_eq!(err.problems.len(), 3, "{:?}", err.problems);
2136 let text = err.to_string();
2137 assert!(text.contains("APP_OAUTH_LEEWAY_SECS"), "{text}");
2138 assert!(text.contains("not-a-number"), "{text}");
2139 assert!(text.contains("APP_OAUTH_REQUIRE_AT_JWT"), "{text}");
2140 assert!(text.contains("APP_OAUTH_ACCEPT_STATIC_BEARER"), "{text}");
2141 }
2142
2143 #[test]
2144 fn strict_bool_parsing_accepts_only_true_and_false() {
2145 for good in ["true", "false"] {
2146 let vars = HashMap::from([
2147 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
2148 ("APP_OAUTH_AUDIENCE", "client-id"),
2149 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
2150 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
2151 ("APP_OAUTH_REQUIRE_AT_JWT", good),
2152 ]);
2153 let files = HashMap::new();
2154 let resolved =
2155 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2156 .unwrap()
2157 .unwrap();
2158 assert_eq!(resolved.require_at_jwt, good == "true");
2159 }
2160 for bad in ["True", "FALSE", "1", "0", "yes"] {
2161 let vars = HashMap::from([
2162 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
2163 ("APP_OAUTH_AUDIENCE", "client-id"),
2164 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
2165 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
2166 ("APP_OAUTH_REQUIRE_AT_JWT", bad),
2167 ]);
2168 let files = HashMap::new();
2169 let result =
2170 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files));
2171 assert!(result.is_err(), "{bad:?} should be rejected");
2172 }
2173 let vars = HashMap::from([
2175 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
2176 ("APP_OAUTH_AUDIENCE", "client-id"),
2177 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
2178 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
2179 ("APP_OAUTH_REQUIRE_AT_JWT", ""),
2180 ]);
2181 let files = HashMap::new();
2182 let resolved =
2183 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2184 .unwrap()
2185 .unwrap();
2186 assert!(!resolved.require_at_jwt);
2187 }
2188
2189 #[test]
2190 fn the_explicit_opt_ins_are_read_as_strict_booleans() {
2191 let files = HashMap::new();
2192 let vars = HashMap::from([
2193 ("APP_OAUTH_ISSUER", "http://idp.internal.test/"),
2194 ("APP_OAUTH_AUDIENCE", "client-id"),
2195 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
2196 ("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", "true"),
2197 ("APP_OAUTH_ALLOW_INSECURE_HTTP", "true"),
2198 ]);
2199 let resolved =
2200 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2201 .unwrap()
2202 .unwrap();
2203 assert!(resolved.allow_unscoped_tokens && resolved.allow_insecure_http);
2204
2205 let mut vars = vars;
2207 vars.remove("APP_OAUTH_ALLOW_UNSCOPED_TOKENS");
2208 vars.insert("APP_OAUTH_ALLOW_INSECURE_HTTP", "yes");
2209 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2210 .unwrap_err();
2211 let text = err.to_string();
2212 assert!(
2213 text.contains("APP_OAUTH_ALLOW_INSECURE_HTTP \"yes\" must be"),
2214 "{text}"
2215 );
2216 assert!(
2217 text.contains("APP_OAUTH_ISSUER \"http://idp.internal.test/\" uses plain http"),
2218 "{text}"
2219 );
2220 assert!(
2221 text.contains("set APP_OAUTH_ALLOW_UNSCOPED_TOKENS"),
2222 "{text}"
2223 );
2224 }
2225
2226 #[test]
2227 fn problem_messages_are_named_with_the_configured_prefix() {
2228 let vars = HashMap::from([("MYAPP_OAUTH_ISSUER", "https://idp.example.test/")]);
2229 let files = HashMap::new();
2230 let err = oauth_config_from_lookup("MYAPP_OAUTH_", lookup_from(&vars), files_from(&files))
2231 .unwrap_err();
2232 let text = err.to_string();
2233 assert!(text.starts_with(
2234 "OAuth is configured through MYAPP_OAUTH_* but the config is not usable:"
2235 ));
2236 assert!(text.contains("MYAPP_OAUTH_AUDIENCE"), "{text}");
2237 assert!(text.contains("MYAPP_OAUTH_RESOURCE"), "{text}");
2238 assert!(!text.contains("mcp.oauth"), "{text}");
2239 }
2240
2241 fn base_vars() -> HashMap<&'static str, &'static str> {
2245 HashMap::from([
2246 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
2247 ("APP_OAUTH_AUDIENCE", "client-id"),
2248 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
2249 ])
2250 }
2251
2252 fn with_app_scope_default(mut loaded: EnvOAuthConfig) -> EnvOAuthConfig {
2255 let cfg = &mut loaded.config;
2256 if cfg.required_scope.is_none() && cfg.required_scopes.is_empty() {
2257 cfg.required_scope = Some("mcp:read".into());
2258 }
2259 loaded
2260 }
2261
2262 #[test]
2263 fn unresolved_is_none_exactly_when_oauth_is_off() {
2264 let files = HashMap::new();
2265 let nothing = HashMap::new();
2266 assert_eq!(
2267 unresolved_oauth_config_from_lookup(
2268 "APP_OAUTH_",
2269 lookup_from(¬hing),
2270 files_from(&files)
2271 ),
2272 None
2273 );
2274 let mut disabled = base_vars();
2275 disabled.insert("APP_OAUTH_ENABLED", "false");
2276 assert_eq!(
2277 unresolved_oauth_config_from_lookup(
2278 "APP_OAUTH_",
2279 lookup_from(&disabled),
2280 files_from(&files)
2281 ),
2282 None
2283 );
2284 let loaded = unresolved_oauth_config_from_lookup(
2285 "APP_OAUTH_",
2286 lookup_from(&base_vars()),
2287 files_from(&files),
2288 )
2289 .expect("identifying vars are set");
2290 assert!(loaded.config.enabled);
2291 assert_eq!(loaded.config.issuer, "https://idp.example.test/");
2292 assert_eq!(
2293 loaded.config.scopes_supported, None,
2294 "default not yet applied"
2295 );
2296 assert!(loaded.problems.is_empty());
2297 assert_eq!(loaded.prefix(), "APP_OAUTH_");
2298 }
2299
2300 #[test]
2301 fn an_app_scope_default_is_enforced_and_advertised() {
2302 let files = HashMap::new();
2303 let loaded = unresolved_oauth_config_from_lookup(
2304 "APP_OAUTH_",
2305 lookup_from(&base_vars()),
2306 files_from(&files),
2307 )
2308 .unwrap();
2309 let resolved = with_app_scope_default(loaded).resolve().unwrap().unwrap();
2310 assert_eq!(resolved.required_scopes, ["mcp:read"]);
2311 assert_eq!(resolved.scopes_supported, ["mcp:read"]);
2314 }
2315
2316 #[test]
2317 fn an_operator_scope_wins_over_the_app_default() {
2318 let mut vars = base_vars();
2319 vars.insert("APP_OAUTH_REQUIRED_SCOPE", "api:read");
2320 let files = HashMap::new();
2321 let loaded = unresolved_oauth_config_from_lookup(
2322 "APP_OAUTH_",
2323 lookup_from(&vars),
2324 files_from(&files),
2325 )
2326 .unwrap();
2327 let resolved = with_app_scope_default(loaded).resolve().unwrap().unwrap();
2328 assert_eq!(resolved.required_scopes, ["api:read"]);
2329 assert_eq!(resolved.scopes_supported, ["api:read"]);
2330 }
2331
2332 #[test]
2333 fn an_app_default_goes_through_resolves_validation() {
2334 let files = HashMap::new();
2335 let mut loaded = unresolved_oauth_config_from_lookup(
2336 "APP_OAUTH_",
2337 lookup_from(&base_vars()),
2338 files_from(&files),
2339 )
2340 .unwrap();
2341 loaded.config.required_scope = Some("mcp:read mcp:write".into());
2342 let err = loaded.resolve().unwrap_err();
2343 assert!(
2344 err.problems
2345 .iter()
2346 .any(|p| p.contains("APP_OAUTH_REQUIRED_SCOPE") && p.contains("single scope")),
2347 "{:?}",
2348 err.problems
2349 );
2350 }
2351
2352 #[test]
2353 fn an_app_set_explicit_empty_scopes_supported_is_kept() {
2354 let mut vars = base_vars();
2355 vars.insert("APP_OAUTH_REQUIRED_SCOPE", "api:read");
2356 let files = HashMap::new();
2357 let mut loaded = unresolved_oauth_config_from_lookup(
2358 "APP_OAUTH_",
2359 lookup_from(&vars),
2360 files_from(&files),
2361 )
2362 .unwrap();
2363 loaded.config.scopes_supported = Some(vec![]);
2364 let resolved = loaded.resolve().unwrap().unwrap();
2365 assert!(resolved.scopes_supported.is_empty());
2366 }
2367
2368 #[test]
2369 fn loader_problems_are_carried_and_reported_first_by_resolve() {
2370 let mut vars = base_vars();
2371 vars.insert("APP_OAUTH_LEEWAY_SECS", "soon");
2372 vars.remove("APP_OAUTH_RESOURCE");
2373 let files = HashMap::new();
2374 let loaded = unresolved_oauth_config_from_lookup(
2375 "APP_OAUTH_",
2376 lookup_from(&vars),
2377 files_from(&files),
2378 )
2379 .unwrap();
2380 assert_eq!(loaded.problems.len(), 1, "{:?}", loaded.problems);
2381 assert!(loaded.problems[0].contains("APP_OAUTH_LEEWAY_SECS"));
2382 let err = loaded.resolve().unwrap_err();
2383 assert!(
2384 err.problems[0].contains("APP_OAUTH_LEEWAY_SECS"),
2385 "{:?}",
2386 err.problems
2387 );
2388 assert!(
2389 err.problems[1..]
2390 .iter()
2391 .any(|p| p.contains("APP_OAUTH_RESOURCE")),
2392 "{:?}",
2393 err.problems
2394 );
2395 }
2396
2397 #[test]
2398 fn loader_problems_fail_resolve_even_when_the_config_is_otherwise_valid() {
2399 let mut vars = base_vars();
2400 vars.insert("APP_OAUTH_REQUIRED_SCOPE", "api:read");
2401 vars.insert("APP_OAUTH_REQUIRE_AT_JWT", "yes");
2402 let files = HashMap::new();
2403 let loaded = unresolved_oauth_config_from_lookup(
2404 "APP_OAUTH_",
2405 lookup_from(&vars),
2406 files_from(&files),
2407 )
2408 .unwrap();
2409 let err = loaded.resolve().unwrap_err();
2410 assert_eq!(err.problems.len(), 1, "{:?}", err.problems);
2411 assert!(err.problems[0].contains("APP_OAUTH_REQUIRE_AT_JWT"));
2412 }
2413
2414 #[test]
2415 fn unresolved_oauth_config_from_env_wraps_the_real_environment() {
2416 assert_eq!(
2417 unresolved_oauth_config_from_env("OAUTH_RESOURCE_SERVER_ENV_RS_TEST_UNSET_9f3c_"),
2418 None
2419 );
2420 }
2421
2422 #[test]
2423 fn oauth_config_from_env_wraps_the_real_environment() {
2424 assert_eq!(
2427 oauth_config_from_env("OAUTH_RESOURCE_SERVER_ENV_RS_TEST_UNSET_9f3c_"),
2428 Ok(None)
2429 );
2430 }
2431
2432 fn policy_vars() -> HashMap<&'static str, &'static str> {
2435 HashMap::from([
2436 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
2437 ("APP_OAUTH_AUDIENCE", "client-a"),
2438 ("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
2439 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
2440 ])
2441 }
2442
2443 #[test]
2444 fn the_claim_policy_settings_load_from_their_variables() {
2445 let mut vars = policy_vars();
2446 vars.insert("APP_OAUTH_ALLOWED_CLIENT_IDS", "client-a client-b");
2447 vars.insert("APP_OAUTH_MAX_TOKEN_AGE_SECS", "3600");
2448 vars.insert(
2449 "APP_OAUTH_REQUIRED_CLAIMS",
2450 r#"{"tid": "tenant-1", "level": 2, "mfa": true}"#,
2451 );
2452 let files = HashMap::new();
2453 let resolved =
2454 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2455 .unwrap()
2456 .unwrap();
2457 assert_eq!(resolved.allowed_client_ids, ["client-a", "client-b"]);
2458 assert_eq!(resolved.max_token_age_secs, Some(3600));
2459 assert_eq!(
2460 resolved.required_claims,
2461 [
2462 ("level".to_string(), serde_json::json!(2)),
2463 ("mfa".to_string(), serde_json::json!(true)),
2464 ("tid".to_string(), serde_json::json!("tenant-1")),
2465 ]
2466 .into_iter()
2467 .collect()
2468 );
2469
2470 let vars = policy_vars();
2472 let resolved =
2473 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2474 .unwrap()
2475 .unwrap();
2476 assert!(resolved.allowed_client_ids.is_empty());
2477 assert_eq!(resolved.max_token_age_secs, None);
2478 assert!(resolved.required_claims.is_empty());
2479 }
2480
2481 #[test]
2482 fn claim_policy_parse_and_resolve_problems_are_reported_together() {
2483 let mut vars = policy_vars();
2484 vars.insert("APP_OAUTH_MAX_TOKEN_AGE_SECS", "an hour");
2485 vars.insert("APP_OAUTH_REQUIRED_CLAIMS", r#"["tid"]"#);
2486 let files = HashMap::new();
2487 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2488 .unwrap_err();
2489 let got: Vec<_> = err
2490 .problem_details()
2491 .iter()
2492 .map(|p| (p.kind(), p.keys().to_vec()))
2493 .collect();
2494 assert_eq!(
2495 got,
2496 [
2497 (
2498 ProblemKind::EnvParse,
2499 vec!["APP_OAUTH_MAX_TOKEN_AGE_SECS".to_string()]
2500 ),
2501 (
2502 ProblemKind::EnvParse,
2503 vec!["APP_OAUTH_REQUIRED_CLAIMS".to_string()]
2504 ),
2505 ]
2506 );
2507 assert!(err.problems[1].contains("must be a JSON object"), "{err}");
2508
2509 let mut vars = policy_vars();
2511 vars.insert("APP_OAUTH_REQUIRED_CLAIMS", "tid=tenant-1");
2512 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2513 .unwrap_err();
2514 assert_eq!(err.problem_details()[0].kind(), ProblemKind::EnvParse);
2515 assert!(err.problems[0].contains("is not valid JSON"), "{err}");
2516
2517 let mut vars = policy_vars();
2520 vars.insert("APP_OAUTH_ALLOWED_CLIENT_IDS", "client-a");
2521 vars.insert("APP_OAUTH_MAX_TOKEN_AGE_SECS", "0");
2522 vars.insert(
2523 "APP_OAUTH_REQUIRED_CLAIMS",
2524 r#"{"aud": "x", "org": {"id": 1}}"#,
2525 );
2526 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2527 .unwrap_err();
2528 let got: Vec<_> = err
2529 .problem_details()
2530 .iter()
2531 .map(|p| (p.kind(), p.keys().to_vec()))
2532 .collect();
2533 assert_eq!(
2534 got,
2535 [
2536 (
2537 ProblemKind::TokenAgeOutOfRange,
2538 vec!["APP_OAUTH_MAX_TOKEN_AGE_SECS".to_string()]
2539 ),
2540 (
2541 ProblemKind::InvalidRequiredClaim,
2542 vec!["APP_OAUTH_REQUIRED_CLAIMS".to_string()]
2543 ),
2544 (
2545 ProblemKind::InvalidRequiredClaim,
2546 vec!["APP_OAUTH_REQUIRED_CLAIMS".to_string()]
2547 ),
2548 ]
2549 );
2550 }
2551
2552 fn temp_path(name: &str) -> std::path::PathBuf {
2556 std::env::temp_dir().join(format!(
2557 "oauth-resource-server-{}-{name}",
2558 std::process::id()
2559 ))
2560 }
2561
2562 #[test]
2563 fn a_file_var_naming_a_directory_is_not_a_file_and_is_never_read() {
2564 let dir = std::env::temp_dir();
2565 let dir = dir.to_str().unwrap().to_string();
2566 let lookup = |name: &str| (name == "KEY_FILE").then(|| dir.clone());
2567 match secret_from_lookup("KEY", lookup, read_secret_file) {
2568 Err(EnvError::NotAFile { var, path }) => {
2569 assert_eq!((var.as_str(), path.as_str()), ("KEY", dir.as_str()));
2570 }
2571 other => panic!("{other:?}"),
2572 }
2573 let err = secret_from_lookup("KEY", lookup, read_secret_file).unwrap_err();
2574 assert_eq!(
2575 err.to_string(),
2576 format!("KEY_FILE={dir}: not a regular file")
2577 );
2578 assert_eq!(error_keys(&err), ["KEY_FILE"]);
2579 }
2580
2581 #[test]
2582 fn a_file_over_the_cap_is_refused_and_one_at_the_cap_is_read() {
2583 let over = temp_path("over-cap");
2584 let at = temp_path("at-cap");
2585 std::fs::write(&over, "x".repeat(MAX_SECRET_FILE_BYTES + 1)).unwrap();
2586 std::fs::write(&at, "y".repeat(MAX_SECRET_FILE_BYTES)).unwrap();
2587 let over_s = over.to_str().unwrap().to_string();
2588 let at_s = at.to_str().unwrap().to_string();
2589 let result_over = secret_from_lookup(
2590 "KEY",
2591 |n: &str| (n == "KEY_FILE").then(|| over_s.clone()),
2592 read_secret_file,
2593 );
2594 let result_at = secret_from_lookup(
2595 "KEY",
2596 |n: &str| (n == "KEY_FILE").then(|| at_s.clone()),
2597 read_secret_file,
2598 );
2599 let _ = std::fs::remove_file(&over);
2600 let _ = std::fs::remove_file(&at);
2601 match result_over {
2602 Err(EnvError::FileTooLarge { var, path }) => {
2603 assert_eq!((var.as_str(), path.as_str()), ("KEY", over_s.as_str()));
2604 }
2605 other => panic!("{:?}", other.map(|v| v.map(|s| s.len()))),
2606 }
2607 assert_eq!(result_at.unwrap().unwrap().len(), MAX_SECRET_FILE_BYTES);
2608
2609 let big = "z".repeat(MAX_SECRET_FILE_BYTES + 1);
2611 let err = secret_from_lookup(
2612 "KEY",
2613 |n: &str| (n == "KEY_FILE").then(|| "/run/secrets/key".to_string()),
2614 |_: &str| Ok(big.clone()),
2615 )
2616 .unwrap_err();
2617 assert!(matches!(err, EnvError::FileTooLarge { .. }), "{err:?}");
2618 let vars = HashMap::from([
2620 ("APP_OAUTH_ISSUER_FILE", "/run/secrets/issuer"),
2621 ("APP_OAUTH_AUDIENCE", "client-a"),
2622 ("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
2623 ]);
2624 let err =
2625 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), |_: &str| Ok(big.clone()))
2626 .unwrap_err();
2627 assert_eq!(err.problem_details()[0].kind(), ProblemKind::EnvLoad);
2628 assert_eq!(err.problem_details()[0].keys(), ["APP_OAUTH_ISSUER_FILE"]);
2629 }
2630
2631 #[test]
2632 fn a_readable_small_file_still_reads_and_trims() {
2633 let path = temp_path("small");
2634 std::fs::write(&path, "s3cret\n").unwrap();
2635 let p = path.to_str().unwrap().to_string();
2636 let got = secret_from_lookup(
2637 "KEY",
2638 |n: &str| (n == "KEY_FILE").then(|| p.clone()),
2639 read_secret_file,
2640 );
2641 let missing = secret_from_lookup(
2642 "KEY",
2643 |n: &str| (n == "KEY_FILE").then(|| format!("{p}-missing")),
2644 read_secret_file,
2645 );
2646 let _ = std::fs::remove_file(&path);
2647 assert_eq!(got.unwrap().as_deref(), Some("s3cret"));
2648 assert!(
2649 matches!(missing, Err(EnvError::ReadFailed { .. })),
2650 "{missing:?}"
2651 );
2652 }
2653
2654 #[test]
2657 fn a_claim_named_twice_in_required_claims_is_refused() {
2658 let files = HashMap::new();
2659 for json in [
2660 r#"{"tid": "good", "tid": "evil"}"#,
2661 r#"{"a": 1, "tid": "good", "b": true, "tid": "good"}"#,
2662 ] {
2663 let mut vars = policy_vars();
2664 vars.insert("APP_OAUTH_REQUIRED_CLAIMS", json);
2665 let err =
2666 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2667 .unwrap_err();
2668 let details = err.problem_details();
2669 assert_eq!(details.len(), 1, "{err}");
2670 assert_eq!(details[0].kind(), ProblemKind::InvalidRequiredClaim);
2671 assert_eq!(details[0].keys(), ["APP_OAUTH_REQUIRED_CLAIMS"]);
2672 assert!(err.problems[0].contains("\"tid\" more than once"), "{err}");
2673 assert!(!err.problems[0].contains("evil"), "{err}");
2674 }
2675 }
2676
2677 #[test]
2681 fn a_blank_required_scope_variable_is_an_error_not_unset() {
2682 let files = HashMap::new();
2683 for allow_unscoped in [None, Some("true")] {
2684 let mut vars = policy_vars();
2685 vars.insert("APP_OAUTH_REQUIRED_SCOPE", " ");
2686 if let Some(v) = allow_unscoped {
2687 vars.insert("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", v);
2688 }
2689 let err =
2690 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2691 .unwrap_err();
2692 let kinds: Vec<_> = err.problem_details().iter().map(|p| p.kind()).collect();
2693 assert!(kinds.contains(&ProblemKind::BlankRequiredScope), "{err}");
2694 assert!(
2695 err.problem_details()
2696 .iter()
2697 .any(|p| p.keys() == ["APP_OAUTH_REQUIRED_SCOPE"]),
2698 "{err}"
2699 );
2700 }
2701 let mut vars = policy_vars();
2703 vars.insert("APP_OAUTH_REQUIRED_SCOPE", "");
2704 vars.insert("APP_OAUTH_REQUIRE_AT_JWT", "true");
2705 assert!(
2706 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2707 .unwrap()
2708 .unwrap()
2709 .required_scopes
2710 .is_empty()
2711 );
2712 }
2713
2714 fn tokens(
2717 vars: &[(&'static str, &'static str)],
2718 files: &[(&'static str, &'static str)],
2719 ) -> Result<Option<StaticTokens>, EnvError> {
2720 let vars: HashMap<_, _> = vars.iter().copied().collect();
2721 let files: HashMap<_, _> = files.iter().copied().collect();
2722 static_tokens_from_lookup("KEY", lookup_from(&vars), files_from(&files))
2723 }
2724
2725 fn labels(set: &StaticTokens) -> Vec<Option<&str>> {
2726 set.labels().collect()
2727 }
2728
2729 fn accepts(set: &StaticTokens, candidate: &str) -> Option<Option<String>> {
2731 let rt = tokio::runtime::Builder::new_current_thread()
2732 .build()
2733 .unwrap();
2734 rt.block_on(crate::authenticate_with_static_tokens(
2735 [candidate],
2736 Some(set),
2737 None,
2738 ))
2739 .ok()
2740 .and_then(|(_, m)| m)
2741 .map(|m| m.label().map(str::to_string))
2742 }
2743
2744 #[test]
2745 fn static_tokens_absent_is_none() {
2746 assert!(tokens(&[], &[]).unwrap().is_none());
2747 assert!(
2748 tokens(&[("KEY", " "), ("KEY_NEXT", "")], &[])
2749 .unwrap()
2750 .is_none()
2751 );
2752 }
2753
2754 #[test]
2755 fn static_tokens_var_only_is_the_current_key() {
2756 let set = tokens(&[("KEY", " old\n")], &[]).unwrap().unwrap();
2757 assert_eq!(labels(&set), [Some("current")]);
2758 assert_eq!(accepts(&set, "old"), Some(Some("current".into())));
2759 assert_eq!(accepts(&set, " old\n"), None, "trimmed, as secret_from_env");
2760 }
2761
2762 #[test]
2763 fn static_tokens_var_and_next_are_both_accepted() {
2764 let set = tokens(&[("KEY", "old"), ("KEY_NEXT", "new")], &[])
2765 .unwrap()
2766 .unwrap();
2767 assert_eq!(
2768 labels(&set),
2769 [Some(CURRENT_KEY_LABEL), Some(NEXT_KEY_LABEL)]
2770 );
2771 assert_eq!(accepts(&set, "old"), Some(Some("current".into())));
2772 assert_eq!(accepts(&set, "new"), Some(Some("next".into())));
2773 assert_eq!(accepts(&set, "other"), None);
2774 let set = tokens(&[("KEY", "new"), ("KEY_NEXT", "new")], &[])
2776 .unwrap()
2777 .unwrap();
2778 assert_eq!(labels(&set), [Some("current")]);
2779 }
2780
2781 #[test]
2782 fn static_tokens_file_forms() {
2783 let set = tokens(
2784 &[("KEY_FILE", "/run/k"), ("KEY_NEXT_FILE", "/run/k_next")],
2785 &[("/run/k", "old\n"), ("/run/k_next", "new\n")],
2786 )
2787 .unwrap()
2788 .unwrap();
2789 assert_eq!(accepts(&set, "old"), Some(Some("current".into())));
2790 assert_eq!(accepts(&set, "new"), Some(Some("next".into())));
2791 let set = tokens(
2793 &[("KEY", "old"), ("KEY_NEXT_FILE", "/run/k_next")],
2794 &[("/run/k_next", "new\n")],
2795 )
2796 .unwrap()
2797 .unwrap();
2798 assert_eq!(labels(&set), [Some("current"), Some("next")]);
2799 }
2800
2801 #[test]
2802 fn static_tokens_both_forms_set_is_an_error() {
2803 let err = tokens(&[("KEY", "a"), ("KEY_FILE", "/run/k")], &[("/run/k", "b")]).unwrap_err();
2804 assert!(
2805 matches!(&err, EnvError::BothSet { var, .. } if var == "KEY"),
2806 "{err:?}"
2807 );
2808 let err = tokens(
2809 &[("KEY", "a"), ("KEY_NEXT", "b"), ("KEY_NEXT_FILE", "/run/n")],
2810 &[("/run/n", "c")],
2811 )
2812 .unwrap_err();
2813 assert!(
2814 matches!(&err, EnvError::BothSet { var, .. } if var == "KEY_NEXT"),
2815 "{err:?}"
2816 );
2817 }
2818
2819 #[test]
2820 fn static_tokens_empty_file_is_an_error() {
2821 let err = tokens(&[("KEY_FILE", "/run/k")], &[("/run/k", " \n")]).unwrap_err();
2822 assert!(
2823 matches!(&err, EnvError::EmptyFile { var, .. } if var == "KEY"),
2824 "{err:?}"
2825 );
2826 let err = tokens(
2827 &[("KEY", "a"), ("KEY_NEXT_FILE", "/run/n")],
2828 &[("/run/n", "")],
2829 )
2830 .unwrap_err();
2831 assert!(
2832 matches!(&err, EnvError::EmptyFile { var, .. } if var == "KEY_NEXT"),
2833 "{err:?}"
2834 );
2835 }
2836
2837 #[test]
2838 fn static_tokens_next_without_current_is_an_error() {
2839 let err = tokens(&[("KEY_NEXT", "new")], &[]).unwrap_err();
2840 assert!(
2841 matches!(&err, EnvError::NextWithoutCurrent { var } if var == "KEY"),
2842 "{err:?}"
2843 );
2844 assert!(err.to_string().contains("KEY_NEXT is set but KEY is not"));
2845 assert!(!err.to_string().contains("new") && !format!("{err:?}").contains("\"new\""));
2846 }
2847
2848 #[test]
2849 fn static_tokens_report_both_failures_at_once() {
2850 let err = tokens(
2851 &[
2852 ("KEY", "s3cret-a"),
2853 ("KEY_FILE", "/run/k"),
2854 ("KEY_NEXT_FILE", "/run/missing"),
2855 ],
2856 &[("/run/k", "s3cret-b")],
2857 )
2858 .unwrap_err();
2859 let EnvError::Several { errors } = &err else {
2860 panic!("expected Several, got {err:?}");
2861 };
2862 assert!(matches!(errors[0], EnvError::BothSet { .. }));
2863 assert!(matches!(errors[1], EnvError::ReadFailed { .. }));
2864 let text = err.to_string();
2865 assert!(text.contains("KEY and KEY_FILE are both set"), "{text}");
2866 assert!(
2867 text.contains("KEY_NEXT_FILE=/run/missing: failed to read secret file: no such file"),
2868 "{text}"
2869 );
2870 assert!(!text.contains("s3cret") && !format!("{err:?}").contains("s3cret"));
2871 }
2872
2873 #[test]
2874 fn static_tokens_debug_prints_labels_only() {
2875 let set = tokens(&[("KEY", "s3cret-a"), ("KEY_NEXT", "s3cret-b")], &[])
2876 .unwrap()
2877 .unwrap();
2878 let rendered = format!("{set:?}");
2879 assert!(
2880 !rendered.contains("s3cret") && rendered.contains("next"),
2881 "{rendered}"
2882 );
2883 }
2884}