1use std::io::{self, Read};
24
25use zeroize::Zeroizing;
26
27use crate::authenticate::StaticTokens;
28use crate::config::{
29 ConfigError, ConfigProblem, KeyNaming, OAuthConfig, ProblemKind, ResolvedOAuthConfig,
30};
31
32#[derive(Debug, thiserror::Error)]
37#[non_exhaustive]
38pub enum EnvError {
39 #[error("{var} and {var}_FILE are both set (file: {path}) — set exactly one, not both")]
43 #[non_exhaustive]
44 BothSet {
45 var: String,
48 path: String,
50 },
51 #[error("{var}_FILE={path}: failed to read secret file")]
59 #[non_exhaustive]
60 ReadFailed {
61 var: String,
63 path: String,
65 #[source]
67 source: io::Error,
68 },
69 #[error("{var}_FILE={path}: not a regular file")]
73 #[non_exhaustive]
74 NotAFile {
75 var: String,
77 path: String,
79 },
80 #[error("{var}_FILE={path}: secret file is over the 65536-byte limit")]
85 #[non_exhaustive]
86 FileTooLarge {
87 var: String,
89 path: String,
91 },
92 #[error("{var}_FILE={path}: secret file is empty")]
97 #[non_exhaustive]
98 EmptyFile {
99 var: String,
101 path: String,
103 },
104 #[error("{var}_NEXT is set but {var} is not: set the current key in {var} (or {var}_FILE)")]
109 #[non_exhaustive]
110 NextWithoutCurrent {
111 var: String,
113 },
114 #[error("{}", join_errors(errors))]
119 #[non_exhaustive]
120 Several {
121 errors: Vec<EnvError>,
123 },
124}
125
126fn duplicated_claim(json: &str) -> Option<String> {
129 let pairs: Vec<(String, serde::de::IgnoredAny)> = serde_json::from_str::<Pairs>(json).ok()?.0;
130 let mut seen = std::collections::HashSet::new();
131 pairs
132 .into_iter()
133 .map(|(name, _)| name)
134 .find(|name| !seen.insert(name.clone()))
135}
136
137struct Pairs(Vec<(String, serde::de::IgnoredAny)>);
139
140impl<'de> serde::Deserialize<'de> for Pairs {
141 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
142 struct Visit;
143 impl<'de> serde::de::Visitor<'de> for Visit {
144 type Value = Pairs;
145 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 f.write_str("a JSON object")
147 }
148 fn visit_map<A: serde::de::MapAccess<'de>>(
149 self,
150 mut map: A,
151 ) -> Result<Pairs, A::Error> {
152 let mut pairs = Vec::new();
153 while let Some(pair) = map.next_entry()? {
154 pairs.push(pair);
155 }
156 Ok(Pairs(pairs))
157 }
158 }
159 deserializer.deserialize_map(Visit)
160 }
161}
162
163pub const MAX_SECRET_FILE_BYTES: usize = 64 * 1024;
166
167#[derive(Debug, thiserror::Error)]
171enum FileRefused {
172 #[error("not a regular file")]
173 NotAFile,
174 #[error("over the size limit")]
175 TooLarge,
176}
177
178fn read_secret_file(path: &str) -> io::Result<String> {
183 let refused = |why| io::Error::new(io::ErrorKind::InvalidInput, why);
184 if !std::fs::metadata(path)?.is_file() {
185 return Err(refused(FileRefused::NotAFile));
186 }
187 let file = std::fs::File::open(path)?;
188 if !file.metadata()?.is_file() {
189 return Err(refused(FileRefused::NotAFile));
190 }
191 let mut bytes = Zeroizing::new(Vec::new());
192 file.take(MAX_SECRET_FILE_BYTES as u64 + 1)
193 .read_to_end(&mut bytes)?;
194 if bytes.len() > MAX_SECRET_FILE_BYTES {
195 return Err(refused(FileRefused::TooLarge));
196 }
197 std::str::from_utf8(&bytes).map(str::to_owned).map_err(|_| {
198 io::Error::new(
199 io::ErrorKind::InvalidData,
200 "stream did not contain valid UTF-8",
201 )
202 })
203}
204
205fn file_error(var: &str, path: String, source: io::Error) -> EnvError {
209 let var = var.to_string();
210 match source
211 .get_ref()
212 .and_then(|e| e.downcast_ref::<FileRefused>())
213 {
214 Some(FileRefused::NotAFile) => EnvError::NotAFile { var, path },
215 Some(FileRefused::TooLarge) => EnvError::FileTooLarge { var, path },
216 None => EnvError::ReadFailed { var, path, source },
217 }
218}
219
220fn join_errors(errors: &[EnvError]) -> String {
221 errors.iter().map(error_text).collect::<Vec<_>>().join("; ")
224}
225
226pub const CURRENT_KEY_LABEL: &str = "current";
228pub const NEXT_KEY_LABEL: &str = "next";
230
231pub fn secret_from_env(var: &str) -> Result<Option<String>, EnvError> {
279 secret_from_lookup(var, |v| std::env::var(v).ok(), read_secret_file)
280}
281
282pub fn secret_from_lookup(
324 var: &str,
325 lookup: impl Fn(&str) -> Option<String>,
326 read_file: impl Fn(&str) -> io::Result<String>,
327) -> Result<Option<String>, EnvError> {
328 Ok(secret_zeroizing(var, lookup, read_file)?.map(|mut v| std::mem::take(&mut *v)))
331}
332
333fn secret_zeroizing(
337 var: &str,
338 lookup: impl Fn(&str) -> Option<String>,
339 read_file: impl Fn(&str) -> io::Result<String>,
340) -> Result<Option<Zeroizing<String>>, EnvError> {
341 let file_var = format!("{var}_FILE");
342 let direct = lookup(var)
343 .map(Zeroizing::new)
344 .filter(|s| !s.trim().is_empty());
345 let path = lookup(&file_var).filter(|s| !s.trim().is_empty());
346
347 match (direct, path) {
348 (Some(_), Some(path)) => Err(EnvError::BothSet {
349 var: var.to_string(),
350 path,
351 }),
352 (Some(v), None) => Ok(Some(Zeroizing::new(v.trim().to_string()))),
353 (None, Some(path)) => {
354 let raw = Zeroizing::new(
355 read_file(&path).map_err(|source| file_error(var, path.clone(), source))?,
356 );
357 if raw.len() > MAX_SECRET_FILE_BYTES {
359 return Err(EnvError::FileTooLarge {
360 var: var.to_string(),
361 path,
362 });
363 }
364 let value = Zeroizing::new(raw.trim().to_string());
365 if value.is_empty() {
366 return Err(EnvError::EmptyFile {
367 var: var.to_string(),
368 path,
369 });
370 }
371 Ok(Some(value))
372 }
373 (None, None) => Ok(None),
374 }
375}
376
377pub fn static_tokens_from_env(var: &str) -> Result<Option<StaticTokens>, EnvError> {
433 static_tokens_from_lookup(var, |v| std::env::var(v).ok(), read_secret_file)
434}
435
436pub fn static_tokens_from_lookup(
472 var: &str,
473 lookup: impl Fn(&str) -> Option<String>,
474 read_file: impl Fn(&str) -> io::Result<String>,
475) -> Result<Option<StaticTokens>, EnvError> {
476 let current = secret_zeroizing(var, &lookup, &read_file);
479 let next = secret_zeroizing(&format!("{var}_NEXT"), &lookup, &read_file);
480 let (current, next) = match (current, next) {
481 (Ok(current), Ok(next)) => (current, next),
482 (Err(a), Err(b)) => return Err(EnvError::Several { errors: vec![a, b] }),
483 (Err(e), Ok(_)) | (Ok(_), Err(e)) => return Err(e),
484 };
485 match (current, next) {
486 (None, None) => Ok(None),
487 (None, Some(_)) => Err(EnvError::NextWithoutCurrent {
488 var: var.to_string(),
489 }),
490 (Some(current), next) => {
491 let mut tokens = StaticTokens::new();
492 tokens.push_checked(CURRENT_KEY_LABEL, current);
497 if let Some(next) = next
498 && !tokens.contains(&next)
499 {
500 tokens.push_checked(NEXT_KEY_LABEL, next);
501 }
502 Ok(Some(tokens))
503 }
504 }
505}
506
507fn split_list(value: &str) -> Vec<String> {
511 value.split_whitespace().map(str::to_string).collect()
512}
513
514fn parse_strict_bool(value: &str) -> Result<bool, ()> {
519 match value {
520 "true" => Ok(true),
521 "false" => Ok(false),
522 _ => Err(()),
523 }
524}
525
526fn bool_problem(naming: KeyNaming<'_>, field: &str, value: &str) -> ConfigProblem {
530 ConfigProblem::new(
531 ProblemKind::EnvParse,
532 [naming.key(field)],
533 format!(
534 "{} {value:?} must be \"true\" or \"false\"",
535 naming.key(field)
536 ),
537 )
538}
539
540fn env_problem(err: &EnvError) -> ConfigProblem {
545 ConfigProblem::new(ProblemKind::EnvLoad, error_keys(err), error_text(err))
546}
547
548fn error_text(err: &EnvError) -> String {
550 let mut text = err.to_string();
551 let mut source = std::error::Error::source(err);
552 while let Some(cause) = source {
553 text.push_str(": ");
554 text.push_str(&cause.to_string());
555 source = cause.source();
556 }
557 text
558}
559
560fn error_keys(err: &EnvError) -> Vec<String> {
565 match err {
566 EnvError::BothSet { var, .. } => vec![var.clone(), format!("{var}_FILE")],
567 EnvError::ReadFailed { var, .. }
568 | EnvError::EmptyFile { var, .. }
569 | EnvError::NotAFile { var, .. }
570 | EnvError::FileTooLarge { var, .. } => {
571 vec![format!("{var}_FILE")]
572 }
573 EnvError::NextWithoutCurrent { var } => vec![var.clone(), format!("{var}_NEXT")],
574 EnvError::Several { errors } => errors.iter().flat_map(error_keys).collect(),
575 }
576}
577
578fn take(
581 result: Result<Option<String>, EnvError>,
582 problems: &mut Vec<ConfigProblem>,
583) -> Option<String> {
584 result.unwrap_or_else(|e| {
585 problems.push(env_problem(&e));
586 None
587 })
588}
589
590struct IdentifyingVars {
599 issuer: Result<Option<String>, EnvError>,
600 jwks_uri: Result<Option<String>, EnvError>,
601 audience: Result<Option<String>, EnvError>,
602 audiences: Result<Option<String>, EnvError>,
603 resource: Result<Option<String>, EnvError>,
604}
605
606impl IdentifyingVars {
607 fn any_set(&self) -> bool {
613 [
614 &self.issuer,
615 &self.jwks_uri,
616 &self.audience,
617 &self.audiences,
618 &self.resource,
619 ]
620 .into_iter()
621 .any(|r| !matches!(r, Ok(None)))
622 }
623}
624
625pub fn oauth_config_from_env(prefix: &str) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
720 oauth_config_from_lookup(prefix, |v| std::env::var(v).ok(), read_secret_file)
721}
722
723pub fn oauth_config_from_lookup<L, R>(
765 prefix: &str,
766 lookup: L,
767 read_file: R,
768) -> Result<Option<ResolvedOAuthConfig>, ConfigError>
769where
770 L: Fn(&str) -> Option<String>,
771 R: Fn(&str) -> io::Result<String>,
772{
773 match unresolved_oauth_config_from_lookup(prefix, lookup, read_file) {
774 Some(loaded) => loaded.resolve(),
775 None => Ok(None),
776 }
777}
778
779#[derive(Debug, Clone)]
815#[non_exhaustive]
816pub struct EnvOAuthConfig {
817 pub config: OAuthConfig,
821 pub problems: Vec<String>,
831 details: Vec<ConfigProblem>,
832 prefix: String,
833}
834
835impl PartialEq for EnvOAuthConfig {
838 fn eq(&self, other: &Self) -> bool {
839 self.config == other.config
840 && self.problems == other.problems
841 && self.prefix == other.prefix
842 }
843}
844
845impl Eq for EnvOAuthConfig {}
846
847impl EnvOAuthConfig {
848 pub fn problem_details(&self) -> &[ConfigProblem] {
859 &self.details
860 }
861
862 pub fn prefix(&self) -> &str {
865 &self.prefix
866 }
867
868 pub fn resolve(self) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
886 let Self {
887 config,
888 problems,
889 details,
890 prefix,
891 } = self;
892 let naming = KeyNaming::Env(&prefix);
893 let mut all = reconcile(problems, details);
897 match config.resolve(naming) {
898 Ok(resolved) if all.is_empty() => Ok(resolved),
899 Ok(_) => Err(ConfigError::from_problems(naming, all)),
900 Err(resolve_err) => {
901 all.extend(resolve_err.problem_details().iter().cloned());
902 Err(ConfigError::from_problems(naming, all))
903 }
904 }
905 }
906}
907
908fn reconcile(problems: Vec<String>, details: Vec<ConfigProblem>) -> Vec<ConfigProblem> {
911 let mut pool: Vec<Option<ConfigProblem>> = details.into_iter().map(Some).collect();
912 problems
913 .into_iter()
914 .map(|text| {
915 pool.iter_mut()
916 .find(|slot| slot.as_ref().is_some_and(|d| d.message() == text))
917 .and_then(Option::take)
918 .unwrap_or_else(|| ConfigProblem::from(text))
919 })
920 .collect()
921}
922
923pub fn unresolved_oauth_config_from_env(prefix: &str) -> Option<EnvOAuthConfig> {
933 unresolved_oauth_config_from_lookup(prefix, |v| std::env::var(v).ok(), read_secret_file)
934}
935
936pub fn unresolved_oauth_config_from_lookup<L, R>(
946 prefix: &str,
947 lookup: L,
948 read_file: R,
949) -> Option<EnvOAuthConfig>
950where
951 L: Fn(&str) -> Option<String>,
952 R: Fn(&str) -> io::Result<String>,
953{
954 let naming = KeyNaming::Env(prefix);
955 let field = |f: &str| secret_from_lookup(&naming.key(f), &lookup, &read_file);
956 let mut problems: Vec<ConfigProblem> = Vec::new();
957
958 let explicitly_enabled = match field("enabled") {
962 Ok(None) => false,
963 Ok(Some(v)) => match parse_strict_bool(&v) {
964 Ok(false) => return None,
965 Ok(true) => true,
966 Err(()) => {
967 problems.push(bool_problem(naming, "enabled", &v));
968 true
969 }
970 },
971 Err(e) => {
972 problems.push(env_problem(&e));
973 true
974 }
975 };
976
977 let identifying = IdentifyingVars {
978 issuer: field("issuer"),
979 jwks_uri: field("jwks_uri"),
980 audience: field("audience"),
981 audiences: field("audiences"),
982 resource: field("resource"),
983 };
984 if !explicitly_enabled && !identifying.any_set() {
985 return None;
986 }
987
988 let mut cfg = OAuthConfig {
989 enabled: true,
990 ..OAuthConfig::default()
991 };
992 let IdentifyingVars {
993 issuer,
994 jwks_uri,
995 audience,
996 audiences,
997 resource,
998 } = identifying;
999 if let Some(v) = take(issuer, &mut problems) {
1000 cfg.issuer = v;
1001 }
1002 cfg.jwks_uri = take(jwks_uri, &mut problems);
1003 if let Some(v) = take(audience, &mut problems) {
1004 cfg.audience = v;
1005 }
1006 if let Some(v) = take(audiences, &mut problems) {
1007 cfg.audiences = split_list(&v);
1008 }
1009 if let Some(v) = take(resource, &mut problems) {
1010 cfg.resource = v;
1011 }
1012
1013 cfg.required_scope = take(field("required_scope"), &mut problems);
1014 if cfg.required_scope.is_none()
1018 && lookup(&naming.key("required_scope"))
1019 .is_some_and(|v| !v.is_empty() && v.trim().is_empty())
1020 {
1021 cfg.required_scope = Some(String::new());
1022 }
1023 if let Some(v) = take(field("required_scopes"), &mut problems) {
1024 cfg.required_scopes = split_list(&v);
1025 }
1026 cfg.scopes_supported = take(field("scopes_supported"), &mut problems).map(|v| split_list(&v));
1028 if let Some(v) = take(field("scope_claims"), &mut problems) {
1029 cfg.scope_claims = split_list(&v);
1030 }
1031 if let Some(v) = take(field("principal_claims"), &mut problems) {
1032 cfg.principal_claims = split_list(&v);
1033 }
1034 if let Some(v) = take(field("algorithms"), &mut problems) {
1035 cfg.algorithms = split_list(&v);
1036 }
1037 if let Some(v) = take(field("leeway_secs"), &mut problems) {
1038 match v.parse::<u64>() {
1039 Ok(n) => cfg.leeway_secs = n,
1040 Err(_) => problems.push(ConfigProblem::new(
1041 ProblemKind::EnvParse,
1042 [naming.key("leeway_secs")],
1043 format!(
1044 "{} {v:?} is not a valid non-negative integer",
1045 naming.key("leeway_secs")
1046 ),
1047 )),
1048 }
1049 }
1050 if let Some(v) = take(field("allowed_client_ids"), &mut problems) {
1051 cfg.allowed_client_ids = split_list(&v);
1052 }
1053 if let Some(v) = take(field("max_token_age_secs"), &mut problems) {
1054 match v.parse::<u64>() {
1055 Ok(n) => cfg.max_token_age_secs = Some(n),
1056 Err(_) => problems.push(ConfigProblem::new(
1057 ProblemKind::EnvParse,
1058 [naming.key("max_token_age_secs")],
1059 format!(
1060 "{} {v:?} is not a valid non-negative integer",
1061 naming.key("max_token_age_secs")
1062 ),
1063 )),
1064 }
1065 }
1066 if let Some(v) = take(field("required_claims"), &mut problems) {
1067 match serde_json::from_str::<serde_json::Value>(&v) {
1071 Ok(serde_json::Value::Object(map)) => match duplicated_claim(&v) {
1072 Some(name) => problems.push(ConfigProblem::new(
1075 ProblemKind::InvalidRequiredClaim,
1076 [naming.key("required_claims")],
1077 format!(
1078 "{} names {:?} more than once — each claim may appear once",
1079 naming.key("required_claims"),
1080 crate::token::for_log(&name)
1081 ),
1082 )),
1083 None => cfg.required_claims = map.into_iter().collect(),
1084 },
1085 Ok(_) => problems.push(ConfigProblem::new(
1086 ProblemKind::EnvParse,
1087 [naming.key("required_claims")],
1088 format!(
1089 "{} must be a JSON object, e.g. {{\"tid\": \"<tenant id>\"}}",
1090 naming.key("required_claims")
1091 ),
1092 )),
1093 Err(e) => problems.push(ConfigProblem::new(
1094 ProblemKind::EnvParse,
1095 [naming.key("required_claims")],
1096 format!(
1097 "{} is not valid JSON ({e}); it must be a JSON object, e.g. \
1098 {{\"tid\": \"<tenant id>\"}}",
1099 naming.key("required_claims")
1100 ),
1101 )),
1102 }
1103 }
1104 for (name, slot) in [
1105 ("require_at_jwt", &mut cfg.require_at_jwt),
1106 ("allow_unscoped_tokens", &mut cfg.allow_unscoped_tokens),
1107 ("allow_insecure_http", &mut cfg.allow_insecure_http),
1108 ("accept_static_bearer", &mut cfg.accept_static_bearer),
1109 ] {
1110 if let Some(v) = take(field(name), &mut problems) {
1111 match parse_strict_bool(&v) {
1112 Ok(b) => *slot = b,
1113 Err(()) => problems.push(bool_problem(naming, name, &v)),
1114 }
1115 }
1116 }
1117
1118 Some(EnvOAuthConfig {
1119 config: cfg,
1120 problems: problems.iter().map(|p| p.message().to_string()).collect(),
1121 details: problems,
1122 prefix: prefix.to_string(),
1123 })
1124}
1125#[cfg(test)]
1126mod tests {
1127 use super::*;
1128 use std::cell::Cell;
1129 use std::collections::HashMap;
1130
1131 fn lookup_from<'a>(
1134 vars: &'a HashMap<&'static str, &'static str>,
1135 ) -> impl Fn(&str) -> Option<String> + 'a {
1136 move |k| vars.get(k).map(|v| v.to_string())
1137 }
1138
1139 fn files_from<'a>(
1140 files: &'a HashMap<&'static str, &'static str>,
1141 ) -> impl Fn(&str) -> io::Result<String> + 'a {
1142 move |p| {
1143 files
1144 .get(p)
1145 .map(|c| c.to_string())
1146 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no such file"))
1147 }
1148 }
1149
1150 #[test]
1151 fn absent_is_none() {
1152 let vars = HashMap::new();
1153 let files = HashMap::new();
1154 assert_eq!(
1158 secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1159 None
1160 );
1161 }
1162
1163 #[test]
1164 fn direct_value_is_trimmed() {
1165 let vars = HashMap::from([("FOO", " bar ")]);
1166 let files = HashMap::new();
1167 assert_eq!(
1168 secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1169 Some("bar".to_string())
1170 );
1171 }
1172
1173 #[test]
1174 fn direct_value_empty_or_whitespace_is_none() {
1175 for value in ["", " "] {
1176 let vars = HashMap::from([("FOO", value)]);
1177 let files = HashMap::new();
1178 assert_eq!(
1179 secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1180 None,
1181 "{value:?} should read as unset"
1182 );
1183 }
1184 }
1185
1186 #[test]
1187 fn file_value_is_read_and_trimmed() {
1188 let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo")]);
1189 let files = HashMap::from([("/run/secrets/foo", "bar\n")]);
1190 assert_eq!(
1191 secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
1192 Some("bar".to_string())
1193 );
1194 }
1195
1196 const SENTINEL: &str = "s3cr3t-sentinel";
1198
1199 fn assert_no_leak(err: &EnvError) {
1201 let display = err.to_string();
1202 let debug = format!("{err:?}");
1203 assert!(!display.contains(SENTINEL), "Display leaks: {display}");
1204 assert!(!debug.contains(SENTINEL), "Debug leaks: {debug}");
1205 }
1206
1207 #[test]
1208 fn both_set_is_an_error_naming_var_and_path_only() {
1209 let vars = HashMap::from([("FOO", SENTINEL), ("FOO_FILE", "/run/secrets/foo")]);
1210 let files = HashMap::from([("/run/secrets/foo", SENTINEL)]);
1211 let err = secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap_err();
1212 let text = err.to_string();
1213 assert!(text.contains("FOO"), "{text}");
1214 assert!(text.contains("FOO_FILE"), "{text}");
1215 assert!(text.contains("/run/secrets/foo"), "{text}");
1216 assert!(matches!(err, EnvError::BothSet { .. }));
1217 assert_no_leak(&err);
1218 }
1219
1220 #[test]
1221 fn unreadable_file_is_an_error_naming_the_path() {
1222 let vars = HashMap::from([("FOO_FILE", "/run/secrets/missing")]);
1223 let files = HashMap::new();
1224 let err = secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap_err();
1225 assert!(matches!(err, EnvError::ReadFailed { .. }));
1226 let text = err.to_string();
1227 assert!(text.contains("FOO_FILE"), "{text}");
1228 assert!(text.contains("/run/secrets/missing"), "{text}");
1229 assert!(!text.contains("no such file"), "{text}");
1232 let source = std::error::Error::source(&err).expect("the I/O error is the source");
1233 assert_eq!(source.to_string(), "no such file");
1234 }
1235
1236 #[test]
1239 fn a_read_failure_problem_carries_its_io_cause_once() {
1240 let vars = HashMap::from([
1241 ("APP_OAUTH_ISSUER_FILE", "/run/secrets/missing"),
1242 ("APP_OAUTH_AUDIENCE", "client-id"),
1243 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1244 ]);
1245 let files = HashMap::new();
1246 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1247 .unwrap_err();
1248 assert_eq!(
1249 err.problems[0],
1250 "APP_OAUTH_ISSUER_FILE=/run/secrets/missing: failed to read secret file: no such file"
1251 );
1252 assert_eq!(err.to_string().matches("no such file").count(), 1);
1253 }
1254
1255 #[test]
1260 fn a_file_that_fails_to_decode_does_not_leak_its_contents() {
1261 let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo")]);
1262 let files = HashMap::from([("/run/secrets/foo", SENTINEL)]);
1263 let read_file = |path: &str| -> io::Result<String> {
1264 assert!(files.contains_key(path), "unexpected path {path}");
1265 Err(io::Error::new(
1266 io::ErrorKind::InvalidData,
1267 "stream did not contain valid UTF-8",
1268 ))
1269 };
1270 let err = secret_from_lookup("FOO", lookup_from(&vars), read_file).unwrap_err();
1271 assert!(matches!(err, EnvError::ReadFailed { .. }));
1272 assert_no_leak(&err);
1273 }
1274
1275 #[test]
1276 fn empty_file_is_an_error_not_none() {
1277 let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo"), ("FOO_TOKEN", SENTINEL)]);
1280 let files = HashMap::from([("/run/secrets/foo", " \n")]);
1281 let err = secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap_err();
1282 assert!(matches!(err, EnvError::EmptyFile { .. }));
1283 let text = err.to_string();
1284 assert!(text.contains("FOO_FILE"), "{text}");
1285 assert!(text.contains("/run/secrets/foo"), "{text}");
1286 assert_no_leak(&err);
1287 }
1288
1289 #[test]
1290 fn secret_from_env_wraps_the_real_environment() {
1291 assert_eq!(
1294 secret_from_env("OAUTH_RESOURCE_SERVER_ENV_RS_TEST_UNSET_VAR_9f3c").unwrap(),
1295 None
1296 );
1297 }
1298
1299 struct RecordingLookup<'a> {
1305 vars: &'a HashMap<&'static str, &'static str>,
1306 calls: Cell<Vec<String>>,
1307 }
1308
1309 impl<'a> RecordingLookup<'a> {
1310 fn new(vars: &'a HashMap<&'static str, &'static str>) -> Self {
1311 Self {
1312 vars,
1313 calls: Cell::new(Vec::new()),
1314 }
1315 }
1316
1317 fn call(&self, var: &str) -> Option<String> {
1318 let mut calls = self.calls.take();
1319 calls.push(var.to_string());
1320 self.calls.set(calls);
1321 self.vars.get(var).map(|v| v.to_string())
1322 }
1323 }
1324
1325 #[test]
1326 fn nothing_set_is_none_and_touches_only_identifying_variables() {
1327 let vars = HashMap::new();
1328 let files = HashMap::new();
1329 let recorder = RecordingLookup::new(&vars);
1330 let result =
1331 oauth_config_from_lookup("APP_OAUTH_", |v| recorder.call(v), files_from(&files));
1332 assert_eq!(result, Ok(None));
1333 let calls = recorder.calls.take();
1334 for var in &calls {
1335 assert!(
1336 var.starts_with("APP_OAUTH_")
1337 && (var.ends_with("ENABLED")
1338 || var.ends_with("ENABLED_FILE")
1339 || var.ends_with("ISSUER")
1340 || var.ends_with("ISSUER_FILE")
1341 || var.ends_with("JWKS_URI")
1342 || var.ends_with("JWKS_URI_FILE")
1343 || var.ends_with("AUDIENCE")
1344 || var.ends_with("AUDIENCE_FILE")
1345 || var.ends_with("AUDIENCES")
1346 || var.ends_with("AUDIENCES_FILE")
1347 || var.ends_with("RESOURCE")
1348 || var.ends_with("RESOURCE_FILE")),
1349 "unexpected variable consulted while OAuth is unconfigured: {var} (all: {calls:?})"
1350 );
1351 }
1352 assert!(!calls.is_empty(), "the identifying vars must be checked");
1353 }
1354
1355 #[test]
1360 fn a_typical_prefixed_variable_set_resolves_verbatim() {
1361 let vars = HashMap::from([
1362 (
1363 "MYAPP_OAUTH_ISSUER",
1364 "https://idp.example.test/application/o/myapp/",
1365 ),
1366 (
1367 "MYAPP_OAUTH_JWKS_URI",
1368 "https://idp.example.test/application/o/myapp/jwks/",
1369 ),
1370 ("MYAPP_OAUTH_AUDIENCE", "myapp-client-id"),
1371 ("MYAPP_OAUTH_RESOURCE", "https://myapp.example.test/mcp"),
1372 ("MYAPP_OAUTH_REQUIRED_SCOPE", "myapp:read"),
1373 ("MYAPP_OAUTH_SCOPES_SUPPORTED", "myapp:read myapp:write"),
1374 ]);
1375 let files = HashMap::new();
1376 let resolved =
1377 oauth_config_from_lookup("MYAPP_OAUTH_", lookup_from(&vars), files_from(&files))
1378 .unwrap()
1379 .expect("identifying vars are set");
1380
1381 assert_eq!(
1382 resolved.issuer,
1383 "https://idp.example.test/application/o/myapp/"
1384 );
1385 assert_eq!(
1386 resolved.jwks_uri.as_deref(),
1387 Some("https://idp.example.test/application/o/myapp/jwks/")
1388 );
1389 assert_eq!(resolved.audience, "myapp-client-id");
1390 assert_eq!(resolved.resource, "https://myapp.example.test/mcp");
1391 assert_eq!(resolved.required_scopes, ["myapp:read"]);
1392 assert_eq!(resolved.scopes_supported, ["myapp:read", "myapp:write"]);
1393 assert_eq!(resolved.scope_claims, ["scope", "scp"]);
1395 assert!(!resolved.require_at_jwt);
1396 assert!(resolved.accept_static_bearer);
1397 }
1398
1399 #[test]
1400 fn jwks_uri_alone_counts_as_identifying_even_though_resolve_does_not_require_it() {
1401 let vars = HashMap::from([("APP_OAUTH_JWKS_URI", "https://idp.example.test/jwks")]);
1402 let files = HashMap::new();
1403 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1404 .unwrap_err();
1405 assert!(err.problems.iter().any(|p| p.contains("APP_OAUTH_ISSUER")));
1408 assert!(
1409 !err.problems
1410 .iter()
1411 .any(|p| p.contains("APP_OAUTH_JWKS_URI"))
1412 );
1413 }
1414
1415 #[test]
1416 fn partial_set_is_an_error_listing_what_is_missing() {
1417 let vars = HashMap::from([("APP_OAUTH_ISSUER", "https://idp.example.test/")]);
1418 let files = HashMap::new();
1419 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1420 .unwrap_err();
1421 let text = err.to_string();
1422 assert!(text.contains("APP_OAUTH_AUDIENCE"), "{text}");
1423 assert!(text.contains("APP_OAUTH_RESOURCE"), "{text}");
1424 }
1425
1426 #[test]
1427 fn a_both_set_error_on_an_identifying_variable_still_counts_as_configured() {
1428 let vars = HashMap::from([
1429 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1430 ("APP_OAUTH_ISSUER_FILE", "/run/secrets/issuer"),
1431 ]);
1432 let files = HashMap::from([("/run/secrets/issuer", "https://idp.example.test/")]);
1433 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1434 .unwrap_err();
1435 assert!(
1437 err.problems[0].contains("APP_OAUTH_ISSUER") && err.problems[0].contains("both set"),
1438 "{:?}",
1439 err.problems
1440 );
1441 assert!(
1446 err.problems[1..]
1447 .iter()
1448 .any(|p| p.starts_with("these required settings are empty")
1449 && p.contains("APP_OAUTH_ISSUER")),
1450 "{:?}",
1451 err.problems
1452 );
1453 }
1454
1455 #[test]
1458 fn scopes_supported_defaults_to_the_required_scope() {
1459 let vars = HashMap::from([
1460 ("MYAPP_OAUTH_ISSUER", "https://idp.example.test/"),
1461 ("MYAPP_OAUTH_JWKS_URI", "http://127.0.0.1:1/jwks"),
1462 ("MYAPP_OAUTH_AUDIENCE", "myapp-client-id"),
1463 ("MYAPP_OAUTH_RESOURCE", "https://myapp.example.test/mcp"),
1464 ("MYAPP_OAUTH_REQUIRED_SCOPE", "myapp:read"),
1465 ]);
1466 let files = HashMap::new();
1467 let resolved =
1468 oauth_config_from_lookup("MYAPP_OAUTH_", lookup_from(&vars), files_from(&files))
1469 .unwrap()
1470 .unwrap();
1471 assert_eq!(resolved.required_scopes, ["myapp:read"]);
1472 assert_eq!(resolved.scopes_supported, ["myapp:read"]);
1473 }
1474
1475 #[test]
1476 fn scopes_supported_default_unions_required_scopes_in_order_without_duplicates() {
1477 let vars = HashMap::from([
1478 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1479 ("APP_OAUTH_AUDIENCE", "client-id"),
1480 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1481 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1482 ("APP_OAUTH_REQUIRED_SCOPES", "api:write api:read"),
1483 ]);
1484 let files = HashMap::new();
1485 let resolved =
1486 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1487 .unwrap()
1488 .unwrap();
1489 assert_eq!(resolved.scopes_supported, ["api:read", "api:write"]);
1490 }
1491
1492 #[test]
1493 fn scopes_supported_stays_empty_with_no_required_scope() {
1494 let vars = HashMap::from([
1495 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1496 ("APP_OAUTH_AUDIENCE", "client-id"),
1497 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1498 ("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", "true"),
1500 ("APP_OAUTH_SCOPES_SUPPORTED", " "),
1502 ]);
1503 let files = HashMap::new();
1504 let resolved =
1505 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1506 .unwrap()
1507 .unwrap();
1508 assert!(resolved.required_scopes.is_empty());
1509 assert!(resolved.scopes_supported.is_empty());
1510 }
1511
1512 #[test]
1513 fn explicit_scopes_supported_is_not_replaced_by_the_default() {
1514 let vars = HashMap::from([
1515 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1516 ("APP_OAUTH_AUDIENCE", "client-id"),
1517 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1518 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1519 ("APP_OAUTH_SCOPES_SUPPORTED", "api:admin"),
1520 ]);
1521 let files = HashMap::new();
1522 let resolved =
1523 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1524 .unwrap()
1525 .unwrap();
1526 assert_eq!(resolved.scopes_supported, ["api:admin"]);
1527 }
1528
1529 #[test]
1530 fn enabled_true_alone_turns_oauth_on_and_reports_what_is_missing() {
1531 let vars = HashMap::from([("APP_OAUTH_ENABLED", "true")]);
1532 let files = HashMap::new();
1533 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1534 .unwrap_err();
1535 let text = err.to_string();
1536 assert!(text.contains("APP_OAUTH_ISSUER"), "{text}");
1537 assert!(text.contains("APP_OAUTH_AUDIENCE"), "{text}");
1538 assert!(text.contains("APP_OAUTH_RESOURCE"), "{text}");
1539 }
1540
1541 #[test]
1542 fn enabled_true_with_a_complete_set_resolves() {
1543 let vars = HashMap::from([
1544 ("APP_OAUTH_ENABLED", "true"),
1545 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1546 ("APP_OAUTH_AUDIENCE", "client-id"),
1547 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1548 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1549 ]);
1550 let files = HashMap::new();
1551 let resolved =
1552 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files)).unwrap();
1553 assert!(resolved.is_some());
1554 }
1555
1556 #[test]
1557 fn enabled_false_turns_oauth_off_without_reading_anything_else() {
1558 let vars = HashMap::from([
1559 ("APP_OAUTH_ENABLED", "false"),
1560 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1561 ("APP_OAUTH_AUDIENCE", "client-id"),
1562 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1563 ("APP_OAUTH_LEEWAY_SECS", "not-a-number"),
1565 ]);
1566 let files = HashMap::new();
1567 let recorder = RecordingLookup::new(&vars);
1568 let result =
1569 oauth_config_from_lookup("APP_OAUTH_", |v| recorder.call(v), files_from(&files));
1570 assert_eq!(result, Ok(None));
1571 let calls = recorder.calls.take();
1572 assert_eq!(calls, ["APP_OAUTH_ENABLED", "APP_OAUTH_ENABLED_FILE"]);
1573 }
1574
1575 #[test]
1576 fn enabled_from_a_file_is_honoured() {
1577 let vars = HashMap::from([
1578 ("APP_OAUTH_ENABLED_FILE", "/run/secrets/enabled"),
1579 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1580 ]);
1581 let files = HashMap::from([("/run/secrets/enabled", "false\n")]);
1582 assert_eq!(
1583 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files)),
1584 Ok(None)
1585 );
1586 }
1587
1588 #[test]
1589 fn an_unparsable_enabled_is_reported_and_treated_as_on() {
1590 let vars = HashMap::from([("APP_OAUTH_ENABLED", "yes")]);
1591 let files = HashMap::new();
1592 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1593 .unwrap_err();
1594 assert!(
1595 err.problems[0].contains("APP_OAUTH_ENABLED")
1596 && err.problems[0].contains("must be \"true\" or \"false\""),
1597 "{:?}",
1598 err.problems
1599 );
1600 assert!(err.to_string().contains("APP_OAUTH_ISSUER"));
1602 }
1603
1604 #[test]
1605 fn an_enabled_that_fails_to_load_is_reported_and_treated_as_on() {
1606 let vars = HashMap::from([("APP_OAUTH_ENABLED_FILE", "/run/secrets/missing")]);
1607 let files = HashMap::new();
1608 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1609 .unwrap_err();
1610 assert!(
1611 err.problems[0].contains("APP_OAUTH_ENABLED_FILE"),
1612 "{:?}",
1613 err.problems
1614 );
1615 assert!(err.to_string().contains("APP_OAUTH_ISSUER"));
1616 }
1617
1618 #[test]
1619 fn loader_problems_carry_env_load_and_env_parse_kinds() {
1620 let vars = HashMap::from([
1621 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1622 ("APP_OAUTH_ISSUER_FILE", "/run/secrets/issuer"),
1623 ("APP_OAUTH_AUDIENCE_FILE", "/run/secrets/missing"),
1624 ("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
1625 ("APP_OAUTH_REQUIRE_AT_JWT", "yes"),
1626 ("APP_OAUTH_LEEWAY_SECS", "soon"),
1627 ("APP_OAUTH_ALLOW_UNSCOPED_TOKENS_FILE", "/run/secrets/empty"),
1628 ]);
1629 let files = HashMap::from([("/run/secrets/empty", " ")]);
1630 let loaded = unresolved_oauth_config_from_lookup(
1631 "APP_OAUTH_",
1632 lookup_from(&vars),
1633 files_from(&files),
1634 )
1635 .unwrap();
1636 let got: Vec<_> = loaded
1637 .problem_details()
1638 .iter()
1639 .map(|p| (p.kind(), p.keys().to_vec()))
1640 .collect();
1641 let env = |v: &str| vec![v.to_string()];
1642 assert_eq!(
1643 got,
1644 [
1645 (
1646 ProblemKind::EnvLoad,
1647 vec![
1648 "APP_OAUTH_ISSUER".to_string(),
1649 "APP_OAUTH_ISSUER_FILE".to_string()
1650 ],
1651 ),
1652 (ProblemKind::EnvLoad, env("APP_OAUTH_AUDIENCE_FILE")),
1653 (ProblemKind::EnvParse, env("APP_OAUTH_LEEWAY_SECS")),
1654 (ProblemKind::EnvParse, env("APP_OAUTH_REQUIRE_AT_JWT")),
1655 (
1656 ProblemKind::EnvLoad,
1657 env("APP_OAUTH_ALLOW_UNSCOPED_TOKENS_FILE")
1658 ),
1659 ]
1660 );
1661 let texts: Vec<&str> = loaded
1663 .problem_details()
1664 .iter()
1665 .map(ConfigProblem::message)
1666 .collect();
1667 assert_eq!(texts, loaded.problems);
1668
1669 let err = loaded.resolve().unwrap_err();
1671 assert_eq!(err.problems.len(), err.problem_details().len());
1672 assert_eq!(err.problem_details()[0].kind(), ProblemKind::EnvLoad);
1673 assert_eq!(err.problem_details()[2].kind(), ProblemKind::EnvParse);
1674 let texts: Vec<&str> = err
1675 .problem_details()
1676 .iter()
1677 .map(ConfigProblem::message)
1678 .collect();
1679 assert_eq!(texts, err.problems);
1680 }
1681
1682 #[test]
1683 fn env_oauth_config_equality_ignores_the_structured_details() {
1684 let vars = HashMap::from([
1685 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1686 ("APP_OAUTH_ALLOW_INSECURE_HTTP", "maybe"),
1687 ]);
1688 let files = HashMap::new();
1689 let load = || {
1690 unresolved_oauth_config_from_lookup(
1691 "APP_OAUTH_",
1692 lookup_from(&vars),
1693 files_from(&files),
1694 )
1695 .unwrap()
1696 };
1697 let a = load();
1698 let mut b = load();
1699 assert_eq!(a, b);
1700 b.details = vec![ConfigProblem::from(a.problems[0].clone())];
1702 assert_eq!(a, b);
1703 b.problems.push("extra".into());
1705 assert_ne!(a, b);
1706 }
1707
1708 #[test]
1709 fn an_enabled_that_is_not_a_bool_is_an_env_parse_problem() {
1710 let vars = HashMap::from([("APP_OAUTH_ENABLED", "maybe")]);
1711 let files = HashMap::new();
1712 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1713 .unwrap_err();
1714 let p = &err.problem_details()[0];
1715 assert_eq!(p.kind(), ProblemKind::EnvParse);
1716 assert_eq!(p.keys(), ["APP_OAUTH_ENABLED"]);
1717 assert!(
1719 err.problem_details()
1720 .iter()
1721 .any(|p| p.kind() == ProblemKind::MissingRequired
1722 && p.keys().contains(&"APP_OAUTH_ISSUER".to_string()))
1723 );
1724 }
1725
1726 #[test]
1727 fn edited_problems_still_reach_resolve_as_other_and_matches_keep_their_kind() {
1728 let vars = HashMap::from([
1729 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1730 ("APP_OAUTH_AUDIENCE", "client-id"),
1731 ("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
1732 ("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", "maybe"),
1733 ]);
1734 let files = HashMap::new();
1735 let mut loaded = unresolved_oauth_config_from_lookup(
1736 "APP_OAUTH_",
1737 lookup_from(&vars),
1738 files_from(&files),
1739 )
1740 .unwrap();
1741 loaded.problems.insert(0, "app-side problem".into());
1742 assert_eq!(loaded.problem_details().len(), 1);
1744 let err = loaded.resolve().unwrap_err();
1745 assert_eq!(err.problems[0], "app-side problem");
1746 assert_eq!(err.problem_details()[0].kind(), ProblemKind::Other);
1747 assert_eq!(err.problem_details()[1].kind(), ProblemKind::EnvParse);
1748 }
1749
1750 #[test]
1751 fn whitespace_lists_are_split() {
1752 let vars = HashMap::from([
1753 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1754 ("APP_OAUTH_AUDIENCE", "client-id"),
1755 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1756 ("APP_OAUTH_REQUIRED_SCOPES", " api:read api:write "),
1757 ("APP_OAUTH_AUDIENCES", "extra-aud another-aud"),
1758 ("APP_OAUTH_ALGORITHMS", "RS256 ES256"),
1759 ]);
1760 let files = HashMap::new();
1761 let resolved =
1762 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1763 .unwrap()
1764 .unwrap();
1765 assert_eq!(resolved.required_scopes, ["api:read", "api:write"]);
1766 assert_eq!(
1767 resolved.accepted_audiences(),
1768 ["client-id", "extra-aud", "another-aud"]
1769 );
1770 assert!(resolved.algorithms.contains(&crate::Algorithm::RS256));
1771 assert!(resolved.algorithms.contains(&crate::Algorithm::ES256));
1772 }
1773
1774 #[test]
1775 fn parse_errors_for_bool_and_integer_fields_are_aggregated_with_other_problems() {
1776 let vars = HashMap::from([
1777 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1778 ("APP_OAUTH_AUDIENCE", "client-id"),
1779 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1780 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1781 ("APP_OAUTH_LEEWAY_SECS", "not-a-number"),
1782 ("APP_OAUTH_REQUIRE_AT_JWT", "yes"),
1783 ("APP_OAUTH_ACCEPT_STATIC_BEARER", "0"),
1784 ]);
1785 let files = HashMap::new();
1786 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1787 .unwrap_err();
1788 assert_eq!(err.problems.len(), 3, "{:?}", err.problems);
1789 let text = err.to_string();
1790 assert!(text.contains("APP_OAUTH_LEEWAY_SECS"), "{text}");
1791 assert!(text.contains("not-a-number"), "{text}");
1792 assert!(text.contains("APP_OAUTH_REQUIRE_AT_JWT"), "{text}");
1793 assert!(text.contains("APP_OAUTH_ACCEPT_STATIC_BEARER"), "{text}");
1794 }
1795
1796 #[test]
1797 fn strict_bool_parsing_accepts_only_true_and_false() {
1798 for good in ["true", "false"] {
1799 let vars = HashMap::from([
1800 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1801 ("APP_OAUTH_AUDIENCE", "client-id"),
1802 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1803 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1804 ("APP_OAUTH_REQUIRE_AT_JWT", good),
1805 ]);
1806 let files = HashMap::new();
1807 let resolved =
1808 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1809 .unwrap()
1810 .unwrap();
1811 assert_eq!(resolved.require_at_jwt, good == "true");
1812 }
1813 for bad in ["True", "FALSE", "1", "0", "yes"] {
1814 let vars = HashMap::from([
1815 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1816 ("APP_OAUTH_AUDIENCE", "client-id"),
1817 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1818 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1819 ("APP_OAUTH_REQUIRE_AT_JWT", bad),
1820 ]);
1821 let files = HashMap::new();
1822 let result =
1823 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files));
1824 assert!(result.is_err(), "{bad:?} should be rejected");
1825 }
1826 let vars = HashMap::from([
1828 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1829 ("APP_OAUTH_AUDIENCE", "client-id"),
1830 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1831 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
1832 ("APP_OAUTH_REQUIRE_AT_JWT", ""),
1833 ]);
1834 let files = HashMap::new();
1835 let resolved =
1836 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1837 .unwrap()
1838 .unwrap();
1839 assert!(!resolved.require_at_jwt);
1840 }
1841
1842 #[test]
1843 fn the_explicit_opt_ins_are_read_as_strict_booleans() {
1844 let files = HashMap::new();
1845 let vars = HashMap::from([
1846 ("APP_OAUTH_ISSUER", "http://idp.internal.test/"),
1847 ("APP_OAUTH_AUDIENCE", "client-id"),
1848 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1849 ("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", "true"),
1850 ("APP_OAUTH_ALLOW_INSECURE_HTTP", "true"),
1851 ]);
1852 let resolved =
1853 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1854 .unwrap()
1855 .unwrap();
1856 assert!(resolved.allow_unscoped_tokens && resolved.allow_insecure_http);
1857
1858 let mut vars = vars;
1860 vars.remove("APP_OAUTH_ALLOW_UNSCOPED_TOKENS");
1861 vars.insert("APP_OAUTH_ALLOW_INSECURE_HTTP", "yes");
1862 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
1863 .unwrap_err();
1864 let text = err.to_string();
1865 assert!(
1866 text.contains("APP_OAUTH_ALLOW_INSECURE_HTTP \"yes\" must be"),
1867 "{text}"
1868 );
1869 assert!(
1870 text.contains("APP_OAUTH_ISSUER \"http://idp.internal.test/\" uses plain http"),
1871 "{text}"
1872 );
1873 assert!(
1874 text.contains("set APP_OAUTH_ALLOW_UNSCOPED_TOKENS"),
1875 "{text}"
1876 );
1877 }
1878
1879 #[test]
1880 fn problem_messages_are_named_with_the_configured_prefix() {
1881 let vars = HashMap::from([("MYAPP_OAUTH_ISSUER", "https://idp.example.test/")]);
1882 let files = HashMap::new();
1883 let err = oauth_config_from_lookup("MYAPP_OAUTH_", lookup_from(&vars), files_from(&files))
1884 .unwrap_err();
1885 let text = err.to_string();
1886 assert!(text.starts_with(
1887 "OAuth is configured through MYAPP_OAUTH_* but the config is not usable:"
1888 ));
1889 assert!(text.contains("MYAPP_OAUTH_AUDIENCE"), "{text}");
1890 assert!(text.contains("MYAPP_OAUTH_RESOURCE"), "{text}");
1891 assert!(!text.contains("mcp.oauth"), "{text}");
1892 }
1893
1894 fn base_vars() -> HashMap<&'static str, &'static str> {
1898 HashMap::from([
1899 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
1900 ("APP_OAUTH_AUDIENCE", "client-id"),
1901 ("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
1902 ])
1903 }
1904
1905 fn with_app_scope_default(mut loaded: EnvOAuthConfig) -> EnvOAuthConfig {
1908 let cfg = &mut loaded.config;
1909 if cfg.required_scope.is_none() && cfg.required_scopes.is_empty() {
1910 cfg.required_scope = Some("mcp:read".into());
1911 }
1912 loaded
1913 }
1914
1915 #[test]
1916 fn unresolved_is_none_exactly_when_oauth_is_off() {
1917 let files = HashMap::new();
1918 let nothing = HashMap::new();
1919 assert_eq!(
1920 unresolved_oauth_config_from_lookup(
1921 "APP_OAUTH_",
1922 lookup_from(¬hing),
1923 files_from(&files)
1924 ),
1925 None
1926 );
1927 let mut disabled = base_vars();
1928 disabled.insert("APP_OAUTH_ENABLED", "false");
1929 assert_eq!(
1930 unresolved_oauth_config_from_lookup(
1931 "APP_OAUTH_",
1932 lookup_from(&disabled),
1933 files_from(&files)
1934 ),
1935 None
1936 );
1937 let loaded = unresolved_oauth_config_from_lookup(
1938 "APP_OAUTH_",
1939 lookup_from(&base_vars()),
1940 files_from(&files),
1941 )
1942 .expect("identifying vars are set");
1943 assert!(loaded.config.enabled);
1944 assert_eq!(loaded.config.issuer, "https://idp.example.test/");
1945 assert_eq!(
1946 loaded.config.scopes_supported, None,
1947 "default not yet applied"
1948 );
1949 assert!(loaded.problems.is_empty());
1950 assert_eq!(loaded.prefix(), "APP_OAUTH_");
1951 }
1952
1953 #[test]
1954 fn an_app_scope_default_is_enforced_and_advertised() {
1955 let files = HashMap::new();
1956 let loaded = unresolved_oauth_config_from_lookup(
1957 "APP_OAUTH_",
1958 lookup_from(&base_vars()),
1959 files_from(&files),
1960 )
1961 .unwrap();
1962 let resolved = with_app_scope_default(loaded).resolve().unwrap().unwrap();
1963 assert_eq!(resolved.required_scopes, ["mcp:read"]);
1964 assert_eq!(resolved.scopes_supported, ["mcp:read"]);
1967 }
1968
1969 #[test]
1970 fn an_operator_scope_wins_over_the_app_default() {
1971 let mut vars = base_vars();
1972 vars.insert("APP_OAUTH_REQUIRED_SCOPE", "api:read");
1973 let files = HashMap::new();
1974 let loaded = unresolved_oauth_config_from_lookup(
1975 "APP_OAUTH_",
1976 lookup_from(&vars),
1977 files_from(&files),
1978 )
1979 .unwrap();
1980 let resolved = with_app_scope_default(loaded).resolve().unwrap().unwrap();
1981 assert_eq!(resolved.required_scopes, ["api:read"]);
1982 assert_eq!(resolved.scopes_supported, ["api:read"]);
1983 }
1984
1985 #[test]
1986 fn an_app_default_goes_through_resolves_validation() {
1987 let files = HashMap::new();
1988 let mut loaded = unresolved_oauth_config_from_lookup(
1989 "APP_OAUTH_",
1990 lookup_from(&base_vars()),
1991 files_from(&files),
1992 )
1993 .unwrap();
1994 loaded.config.required_scope = Some("mcp:read mcp:write".into());
1995 let err = loaded.resolve().unwrap_err();
1996 assert!(
1997 err.problems
1998 .iter()
1999 .any(|p| p.contains("APP_OAUTH_REQUIRED_SCOPE") && p.contains("single scope")),
2000 "{:?}",
2001 err.problems
2002 );
2003 }
2004
2005 #[test]
2006 fn an_app_set_explicit_empty_scopes_supported_is_kept() {
2007 let mut vars = base_vars();
2008 vars.insert("APP_OAUTH_REQUIRED_SCOPE", "api:read");
2009 let files = HashMap::new();
2010 let mut loaded = unresolved_oauth_config_from_lookup(
2011 "APP_OAUTH_",
2012 lookup_from(&vars),
2013 files_from(&files),
2014 )
2015 .unwrap();
2016 loaded.config.scopes_supported = Some(vec![]);
2017 let resolved = loaded.resolve().unwrap().unwrap();
2018 assert!(resolved.scopes_supported.is_empty());
2019 }
2020
2021 #[test]
2022 fn loader_problems_are_carried_and_reported_first_by_resolve() {
2023 let mut vars = base_vars();
2024 vars.insert("APP_OAUTH_LEEWAY_SECS", "soon");
2025 vars.remove("APP_OAUTH_RESOURCE");
2026 let files = HashMap::new();
2027 let loaded = unresolved_oauth_config_from_lookup(
2028 "APP_OAUTH_",
2029 lookup_from(&vars),
2030 files_from(&files),
2031 )
2032 .unwrap();
2033 assert_eq!(loaded.problems.len(), 1, "{:?}", loaded.problems);
2034 assert!(loaded.problems[0].contains("APP_OAUTH_LEEWAY_SECS"));
2035 let err = loaded.resolve().unwrap_err();
2036 assert!(
2037 err.problems[0].contains("APP_OAUTH_LEEWAY_SECS"),
2038 "{:?}",
2039 err.problems
2040 );
2041 assert!(
2042 err.problems[1..]
2043 .iter()
2044 .any(|p| p.contains("APP_OAUTH_RESOURCE")),
2045 "{:?}",
2046 err.problems
2047 );
2048 }
2049
2050 #[test]
2051 fn loader_problems_fail_resolve_even_when_the_config_is_otherwise_valid() {
2052 let mut vars = base_vars();
2053 vars.insert("APP_OAUTH_REQUIRED_SCOPE", "api:read");
2054 vars.insert("APP_OAUTH_REQUIRE_AT_JWT", "yes");
2055 let files = HashMap::new();
2056 let loaded = unresolved_oauth_config_from_lookup(
2057 "APP_OAUTH_",
2058 lookup_from(&vars),
2059 files_from(&files),
2060 )
2061 .unwrap();
2062 let err = loaded.resolve().unwrap_err();
2063 assert_eq!(err.problems.len(), 1, "{:?}", err.problems);
2064 assert!(err.problems[0].contains("APP_OAUTH_REQUIRE_AT_JWT"));
2065 }
2066
2067 #[test]
2068 fn unresolved_oauth_config_from_env_wraps_the_real_environment() {
2069 assert_eq!(
2070 unresolved_oauth_config_from_env("OAUTH_RESOURCE_SERVER_ENV_RS_TEST_UNSET_9f3c_"),
2071 None
2072 );
2073 }
2074
2075 #[test]
2076 fn oauth_config_from_env_wraps_the_real_environment() {
2077 assert_eq!(
2080 oauth_config_from_env("OAUTH_RESOURCE_SERVER_ENV_RS_TEST_UNSET_9f3c_"),
2081 Ok(None)
2082 );
2083 }
2084
2085 fn policy_vars() -> HashMap<&'static str, &'static str> {
2088 HashMap::from([
2089 ("APP_OAUTH_ISSUER", "https://idp.example.test/"),
2090 ("APP_OAUTH_AUDIENCE", "client-a"),
2091 ("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
2092 ("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
2093 ])
2094 }
2095
2096 #[test]
2097 fn the_claim_policy_settings_load_from_their_variables() {
2098 let mut vars = policy_vars();
2099 vars.insert("APP_OAUTH_ALLOWED_CLIENT_IDS", "client-a client-b");
2100 vars.insert("APP_OAUTH_MAX_TOKEN_AGE_SECS", "3600");
2101 vars.insert(
2102 "APP_OAUTH_REQUIRED_CLAIMS",
2103 r#"{"tid": "tenant-1", "level": 2, "mfa": true}"#,
2104 );
2105 let files = HashMap::new();
2106 let resolved =
2107 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2108 .unwrap()
2109 .unwrap();
2110 assert_eq!(resolved.allowed_client_ids, ["client-a", "client-b"]);
2111 assert_eq!(resolved.max_token_age_secs, Some(3600));
2112 assert_eq!(
2113 resolved.required_claims,
2114 [
2115 ("level".to_string(), serde_json::json!(2)),
2116 ("mfa".to_string(), serde_json::json!(true)),
2117 ("tid".to_string(), serde_json::json!("tenant-1")),
2118 ]
2119 .into_iter()
2120 .collect()
2121 );
2122
2123 let vars = policy_vars();
2125 let resolved =
2126 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2127 .unwrap()
2128 .unwrap();
2129 assert!(resolved.allowed_client_ids.is_empty());
2130 assert_eq!(resolved.max_token_age_secs, None);
2131 assert!(resolved.required_claims.is_empty());
2132 }
2133
2134 #[test]
2135 fn claim_policy_parse_and_resolve_problems_are_reported_together() {
2136 let mut vars = policy_vars();
2137 vars.insert("APP_OAUTH_MAX_TOKEN_AGE_SECS", "an hour");
2138 vars.insert("APP_OAUTH_REQUIRED_CLAIMS", r#"["tid"]"#);
2139 let files = HashMap::new();
2140 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2141 .unwrap_err();
2142 let got: Vec<_> = err
2143 .problem_details()
2144 .iter()
2145 .map(|p| (p.kind(), p.keys().to_vec()))
2146 .collect();
2147 assert_eq!(
2148 got,
2149 [
2150 (
2151 ProblemKind::EnvParse,
2152 vec!["APP_OAUTH_MAX_TOKEN_AGE_SECS".to_string()]
2153 ),
2154 (
2155 ProblemKind::EnvParse,
2156 vec!["APP_OAUTH_REQUIRED_CLAIMS".to_string()]
2157 ),
2158 ]
2159 );
2160 assert!(err.problems[1].contains("must be a JSON object"), "{err}");
2161
2162 let mut vars = policy_vars();
2164 vars.insert("APP_OAUTH_REQUIRED_CLAIMS", "tid=tenant-1");
2165 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2166 .unwrap_err();
2167 assert_eq!(err.problem_details()[0].kind(), ProblemKind::EnvParse);
2168 assert!(err.problems[0].contains("is not valid JSON"), "{err}");
2169
2170 let mut vars = policy_vars();
2173 vars.insert("APP_OAUTH_ALLOWED_CLIENT_IDS", "client-a");
2174 vars.insert("APP_OAUTH_MAX_TOKEN_AGE_SECS", "0");
2175 vars.insert(
2176 "APP_OAUTH_REQUIRED_CLAIMS",
2177 r#"{"aud": "x", "org": {"id": 1}}"#,
2178 );
2179 let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2180 .unwrap_err();
2181 let got: Vec<_> = err
2182 .problem_details()
2183 .iter()
2184 .map(|p| (p.kind(), p.keys().to_vec()))
2185 .collect();
2186 assert_eq!(
2187 got,
2188 [
2189 (
2190 ProblemKind::TokenAgeOutOfRange,
2191 vec!["APP_OAUTH_MAX_TOKEN_AGE_SECS".to_string()]
2192 ),
2193 (
2194 ProblemKind::InvalidRequiredClaim,
2195 vec!["APP_OAUTH_REQUIRED_CLAIMS".to_string()]
2196 ),
2197 (
2198 ProblemKind::InvalidRequiredClaim,
2199 vec!["APP_OAUTH_REQUIRED_CLAIMS".to_string()]
2200 ),
2201 ]
2202 );
2203 }
2204
2205 fn temp_path(name: &str) -> std::path::PathBuf {
2209 std::env::temp_dir().join(format!(
2210 "oauth-resource-server-{}-{name}",
2211 std::process::id()
2212 ))
2213 }
2214
2215 #[test]
2216 fn a_file_var_naming_a_directory_is_not_a_file_and_is_never_read() {
2217 let dir = std::env::temp_dir();
2218 let dir = dir.to_str().unwrap().to_string();
2219 let lookup = |name: &str| (name == "KEY_FILE").then(|| dir.clone());
2220 match secret_from_lookup("KEY", lookup, read_secret_file) {
2221 Err(EnvError::NotAFile { var, path }) => {
2222 assert_eq!((var.as_str(), path.as_str()), ("KEY", dir.as_str()));
2223 }
2224 other => panic!("{other:?}"),
2225 }
2226 let err = secret_from_lookup("KEY", lookup, read_secret_file).unwrap_err();
2227 assert_eq!(
2228 err.to_string(),
2229 format!("KEY_FILE={dir}: not a regular file")
2230 );
2231 assert_eq!(error_keys(&err), ["KEY_FILE"]);
2232 }
2233
2234 #[test]
2235 fn a_file_over_the_cap_is_refused_and_one_at_the_cap_is_read() {
2236 let over = temp_path("over-cap");
2237 let at = temp_path("at-cap");
2238 std::fs::write(&over, "x".repeat(MAX_SECRET_FILE_BYTES + 1)).unwrap();
2239 std::fs::write(&at, "y".repeat(MAX_SECRET_FILE_BYTES)).unwrap();
2240 let over_s = over.to_str().unwrap().to_string();
2241 let at_s = at.to_str().unwrap().to_string();
2242 let result_over = secret_from_lookup(
2243 "KEY",
2244 |n: &str| (n == "KEY_FILE").then(|| over_s.clone()),
2245 read_secret_file,
2246 );
2247 let result_at = secret_from_lookup(
2248 "KEY",
2249 |n: &str| (n == "KEY_FILE").then(|| at_s.clone()),
2250 read_secret_file,
2251 );
2252 let _ = std::fs::remove_file(&over);
2253 let _ = std::fs::remove_file(&at);
2254 match result_over {
2255 Err(EnvError::FileTooLarge { var, path }) => {
2256 assert_eq!((var.as_str(), path.as_str()), ("KEY", over_s.as_str()));
2257 }
2258 other => panic!("{:?}", other.map(|v| v.map(|s| s.len()))),
2259 }
2260 assert_eq!(result_at.unwrap().unwrap().len(), MAX_SECRET_FILE_BYTES);
2261
2262 let big = "z".repeat(MAX_SECRET_FILE_BYTES + 1);
2264 let err = secret_from_lookup(
2265 "KEY",
2266 |n: &str| (n == "KEY_FILE").then(|| "/run/secrets/key".to_string()),
2267 |_: &str| Ok(big.clone()),
2268 )
2269 .unwrap_err();
2270 assert!(matches!(err, EnvError::FileTooLarge { .. }), "{err:?}");
2271 let vars = HashMap::from([
2273 ("APP_OAUTH_ISSUER_FILE", "/run/secrets/issuer"),
2274 ("APP_OAUTH_AUDIENCE", "client-a"),
2275 ("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
2276 ]);
2277 let err =
2278 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), |_: &str| Ok(big.clone()))
2279 .unwrap_err();
2280 assert_eq!(err.problem_details()[0].kind(), ProblemKind::EnvLoad);
2281 assert_eq!(err.problem_details()[0].keys(), ["APP_OAUTH_ISSUER_FILE"]);
2282 }
2283
2284 #[test]
2285 fn a_readable_small_file_still_reads_and_trims() {
2286 let path = temp_path("small");
2287 std::fs::write(&path, "s3cret\n").unwrap();
2288 let p = path.to_str().unwrap().to_string();
2289 let got = secret_from_lookup(
2290 "KEY",
2291 |n: &str| (n == "KEY_FILE").then(|| p.clone()),
2292 read_secret_file,
2293 );
2294 let missing = secret_from_lookup(
2295 "KEY",
2296 |n: &str| (n == "KEY_FILE").then(|| format!("{p}-missing")),
2297 read_secret_file,
2298 );
2299 let _ = std::fs::remove_file(&path);
2300 assert_eq!(got.unwrap().as_deref(), Some("s3cret"));
2301 assert!(
2302 matches!(missing, Err(EnvError::ReadFailed { .. })),
2303 "{missing:?}"
2304 );
2305 }
2306
2307 #[test]
2310 fn a_claim_named_twice_in_required_claims_is_refused() {
2311 let files = HashMap::new();
2312 for json in [
2313 r#"{"tid": "good", "tid": "evil"}"#,
2314 r#"{"a": 1, "tid": "good", "b": true, "tid": "good"}"#,
2315 ] {
2316 let mut vars = policy_vars();
2317 vars.insert("APP_OAUTH_REQUIRED_CLAIMS", json);
2318 let err =
2319 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2320 .unwrap_err();
2321 let details = err.problem_details();
2322 assert_eq!(details.len(), 1, "{err}");
2323 assert_eq!(details[0].kind(), ProblemKind::InvalidRequiredClaim);
2324 assert_eq!(details[0].keys(), ["APP_OAUTH_REQUIRED_CLAIMS"]);
2325 assert!(err.problems[0].contains("\"tid\" more than once"), "{err}");
2326 assert!(!err.problems[0].contains("evil"), "{err}");
2327 }
2328 }
2329
2330 #[test]
2334 fn a_blank_required_scope_variable_is_an_error_not_unset() {
2335 let files = HashMap::new();
2336 for allow_unscoped in [None, Some("true")] {
2337 let mut vars = policy_vars();
2338 vars.insert("APP_OAUTH_REQUIRED_SCOPE", " ");
2339 if let Some(v) = allow_unscoped {
2340 vars.insert("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", v);
2341 }
2342 let err =
2343 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2344 .unwrap_err();
2345 let kinds: Vec<_> = err.problem_details().iter().map(|p| p.kind()).collect();
2346 assert!(kinds.contains(&ProblemKind::BlankRequiredScope), "{err}");
2347 assert!(
2348 err.problem_details()
2349 .iter()
2350 .any(|p| p.keys() == ["APP_OAUTH_REQUIRED_SCOPE"]),
2351 "{err}"
2352 );
2353 }
2354 let mut vars = policy_vars();
2356 vars.insert("APP_OAUTH_REQUIRED_SCOPE", "");
2357 vars.insert("APP_OAUTH_REQUIRE_AT_JWT", "true");
2358 assert!(
2359 oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
2360 .unwrap()
2361 .unwrap()
2362 .required_scopes
2363 .is_empty()
2364 );
2365 }
2366
2367 fn tokens(
2370 vars: &[(&'static str, &'static str)],
2371 files: &[(&'static str, &'static str)],
2372 ) -> Result<Option<StaticTokens>, EnvError> {
2373 let vars: HashMap<_, _> = vars.iter().copied().collect();
2374 let files: HashMap<_, _> = files.iter().copied().collect();
2375 static_tokens_from_lookup("KEY", lookup_from(&vars), files_from(&files))
2376 }
2377
2378 fn labels(set: &StaticTokens) -> Vec<Option<&str>> {
2379 set.labels().collect()
2380 }
2381
2382 fn accepts(set: &StaticTokens, candidate: &str) -> Option<Option<String>> {
2384 let rt = tokio::runtime::Builder::new_current_thread()
2385 .build()
2386 .unwrap();
2387 rt.block_on(crate::authenticate_with_static_tokens(
2388 [candidate],
2389 Some(set),
2390 None,
2391 ))
2392 .ok()
2393 .and_then(|(_, m)| m)
2394 .map(|m| m.label().map(str::to_string))
2395 }
2396
2397 #[test]
2398 fn static_tokens_absent_is_none() {
2399 assert!(tokens(&[], &[]).unwrap().is_none());
2400 assert!(
2401 tokens(&[("KEY", " "), ("KEY_NEXT", "")], &[])
2402 .unwrap()
2403 .is_none()
2404 );
2405 }
2406
2407 #[test]
2408 fn static_tokens_var_only_is_the_current_key() {
2409 let set = tokens(&[("KEY", " old\n")], &[]).unwrap().unwrap();
2410 assert_eq!(labels(&set), [Some("current")]);
2411 assert_eq!(accepts(&set, "old"), Some(Some("current".into())));
2412 assert_eq!(accepts(&set, " old\n"), None, "trimmed, as secret_from_env");
2413 }
2414
2415 #[test]
2416 fn static_tokens_var_and_next_are_both_accepted() {
2417 let set = tokens(&[("KEY", "old"), ("KEY_NEXT", "new")], &[])
2418 .unwrap()
2419 .unwrap();
2420 assert_eq!(
2421 labels(&set),
2422 [Some(CURRENT_KEY_LABEL), Some(NEXT_KEY_LABEL)]
2423 );
2424 assert_eq!(accepts(&set, "old"), Some(Some("current".into())));
2425 assert_eq!(accepts(&set, "new"), Some(Some("next".into())));
2426 assert_eq!(accepts(&set, "other"), None);
2427 let set = tokens(&[("KEY", "new"), ("KEY_NEXT", "new")], &[])
2429 .unwrap()
2430 .unwrap();
2431 assert_eq!(labels(&set), [Some("current")]);
2432 }
2433
2434 #[test]
2435 fn static_tokens_file_forms() {
2436 let set = tokens(
2437 &[("KEY_FILE", "/run/k"), ("KEY_NEXT_FILE", "/run/k_next")],
2438 &[("/run/k", "old\n"), ("/run/k_next", "new\n")],
2439 )
2440 .unwrap()
2441 .unwrap();
2442 assert_eq!(accepts(&set, "old"), Some(Some("current".into())));
2443 assert_eq!(accepts(&set, "new"), Some(Some("next".into())));
2444 let set = tokens(
2446 &[("KEY", "old"), ("KEY_NEXT_FILE", "/run/k_next")],
2447 &[("/run/k_next", "new\n")],
2448 )
2449 .unwrap()
2450 .unwrap();
2451 assert_eq!(labels(&set), [Some("current"), Some("next")]);
2452 }
2453
2454 #[test]
2455 fn static_tokens_both_forms_set_is_an_error() {
2456 let err = tokens(&[("KEY", "a"), ("KEY_FILE", "/run/k")], &[("/run/k", "b")]).unwrap_err();
2457 assert!(
2458 matches!(&err, EnvError::BothSet { var, .. } if var == "KEY"),
2459 "{err:?}"
2460 );
2461 let err = tokens(
2462 &[("KEY", "a"), ("KEY_NEXT", "b"), ("KEY_NEXT_FILE", "/run/n")],
2463 &[("/run/n", "c")],
2464 )
2465 .unwrap_err();
2466 assert!(
2467 matches!(&err, EnvError::BothSet { var, .. } if var == "KEY_NEXT"),
2468 "{err:?}"
2469 );
2470 }
2471
2472 #[test]
2473 fn static_tokens_empty_file_is_an_error() {
2474 let err = tokens(&[("KEY_FILE", "/run/k")], &[("/run/k", " \n")]).unwrap_err();
2475 assert!(
2476 matches!(&err, EnvError::EmptyFile { var, .. } if var == "KEY"),
2477 "{err:?}"
2478 );
2479 let err = tokens(
2480 &[("KEY", "a"), ("KEY_NEXT_FILE", "/run/n")],
2481 &[("/run/n", "")],
2482 )
2483 .unwrap_err();
2484 assert!(
2485 matches!(&err, EnvError::EmptyFile { var, .. } if var == "KEY_NEXT"),
2486 "{err:?}"
2487 );
2488 }
2489
2490 #[test]
2491 fn static_tokens_next_without_current_is_an_error() {
2492 let err = tokens(&[("KEY_NEXT", "new")], &[]).unwrap_err();
2493 assert!(
2494 matches!(&err, EnvError::NextWithoutCurrent { var } if var == "KEY"),
2495 "{err:?}"
2496 );
2497 assert!(err.to_string().contains("KEY_NEXT is set but KEY is not"));
2498 assert!(!err.to_string().contains("new") && !format!("{err:?}").contains("\"new\""));
2499 }
2500
2501 #[test]
2502 fn static_tokens_report_both_failures_at_once() {
2503 let err = tokens(
2504 &[
2505 ("KEY", "s3cret-a"),
2506 ("KEY_FILE", "/run/k"),
2507 ("KEY_NEXT_FILE", "/run/missing"),
2508 ],
2509 &[("/run/k", "s3cret-b")],
2510 )
2511 .unwrap_err();
2512 let EnvError::Several { errors } = &err else {
2513 panic!("expected Several, got {err:?}");
2514 };
2515 assert!(matches!(errors[0], EnvError::BothSet { .. }));
2516 assert!(matches!(errors[1], EnvError::ReadFailed { .. }));
2517 let text = err.to_string();
2518 assert!(text.contains("KEY and KEY_FILE are both set"), "{text}");
2519 assert!(
2520 text.contains("KEY_NEXT_FILE=/run/missing: failed to read secret file: no such file"),
2521 "{text}"
2522 );
2523 assert!(!text.contains("s3cret") && !format!("{err:?}").contains("s3cret"));
2524 }
2525
2526 #[test]
2527 fn static_tokens_debug_prints_labels_only() {
2528 let set = tokens(&[("KEY", "s3cret-a"), ("KEY_NEXT", "s3cret-b")], &[])
2529 .unwrap()
2530 .unwrap();
2531 let rendered = format!("{set:?}");
2532 assert!(
2533 !rendered.contains("s3cret") && rendered.contains("next"),
2534 "{rendered}"
2535 );
2536 }
2537}