use std::io::{self, Read};
use zeroize::Zeroizing;
use crate::authenticate::StaticTokens;
use crate::config::{
ConfigError, ConfigProblem, KeyNaming, OAuthConfig, ProblemKind, ResolvedOAuthConfig,
};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum EnvError {
#[error("{var} and {var}_FILE are both set (file: {path}) — set exactly one, not both")]
#[non_exhaustive]
BothSet {
var: String,
path: String,
},
#[error("{var}_FILE={path}: failed to read secret file")]
#[non_exhaustive]
ReadFailed {
var: String,
path: String,
#[source]
source: io::Error,
},
#[error("{var}_FILE={path}: not a regular file")]
#[non_exhaustive]
NotAFile {
var: String,
path: String,
},
#[error("{var}_FILE={path}: secret file is over the 65536-byte limit")]
#[non_exhaustive]
FileTooLarge {
var: String,
path: String,
},
#[error("{var}_FILE={path}: secret file is empty")]
#[non_exhaustive]
EmptyFile {
var: String,
path: String,
},
#[error("{var}_NEXT is set but {var} is not: set the current key in {var} (or {var}_FILE)")]
#[non_exhaustive]
NextWithoutCurrent {
var: String,
},
#[error("{}", join_errors(errors))]
#[non_exhaustive]
Several {
errors: Vec<EnvError>,
},
}
fn duplicated_claim(json: &str) -> Option<String> {
let pairs: Vec<(String, serde::de::IgnoredAny)> = serde_json::from_str::<Pairs>(json).ok()?.0;
let mut seen = std::collections::HashSet::new();
pairs
.into_iter()
.map(|(name, _)| name)
.find(|name| !seen.insert(name.clone()))
}
struct Pairs(Vec<(String, serde::de::IgnoredAny)>);
impl<'de> serde::Deserialize<'de> for Pairs {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Visit;
impl<'de> serde::de::Visitor<'de> for Visit {
type Value = Pairs;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("a JSON object")
}
fn visit_map<A: serde::de::MapAccess<'de>>(
self,
mut map: A,
) -> Result<Pairs, A::Error> {
let mut pairs = Vec::new();
while let Some(pair) = map.next_entry()? {
pairs.push(pair);
}
Ok(Pairs(pairs))
}
}
deserializer.deserialize_map(Visit)
}
}
pub const MAX_SECRET_FILE_BYTES: usize = 64 * 1024;
#[derive(Debug, thiserror::Error)]
enum FileRefused {
#[error("not a regular file")]
NotAFile,
#[error("over the size limit")]
TooLarge,
}
fn read_secret_file(path: &str) -> io::Result<String> {
let refused = |why| io::Error::new(io::ErrorKind::InvalidInput, why);
if !std::fs::metadata(path)?.is_file() {
return Err(refused(FileRefused::NotAFile));
}
let file = std::fs::File::open(path)?;
if !file.metadata()?.is_file() {
return Err(refused(FileRefused::NotAFile));
}
let mut bytes = Zeroizing::new(Vec::new());
file.take(MAX_SECRET_FILE_BYTES as u64 + 1)
.read_to_end(&mut bytes)?;
if bytes.len() > MAX_SECRET_FILE_BYTES {
return Err(refused(FileRefused::TooLarge));
}
std::str::from_utf8(&bytes).map(str::to_owned).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"stream did not contain valid UTF-8",
)
})
}
fn file_error(var: &str, path: String, source: io::Error) -> EnvError {
let var = var.to_string();
match source
.get_ref()
.and_then(|e| e.downcast_ref::<FileRefused>())
{
Some(FileRefused::NotAFile) => EnvError::NotAFile { var, path },
Some(FileRefused::TooLarge) => EnvError::FileTooLarge { var, path },
None => EnvError::ReadFailed { var, path, source },
}
}
fn join_errors(errors: &[EnvError]) -> String {
errors.iter().map(error_text).collect::<Vec<_>>().join("; ")
}
pub const CURRENT_KEY_LABEL: &str = "current";
pub const NEXT_KEY_LABEL: &str = "next";
pub fn secret_from_env(var: &str) -> Result<Option<String>, EnvError> {
secret_from_lookup(var, |v| std::env::var(v).ok(), read_secret_file)
}
pub fn secret_from_lookup(
var: &str,
lookup: impl Fn(&str) -> Option<String>,
read_file: impl Fn(&str) -> io::Result<String>,
) -> Result<Option<String>, EnvError> {
Ok(secret_zeroizing(var, lookup, read_file)?.map(|mut v| std::mem::take(&mut *v)))
}
fn secret_zeroizing(
var: &str,
lookup: impl Fn(&str) -> Option<String>,
read_file: impl Fn(&str) -> io::Result<String>,
) -> Result<Option<Zeroizing<String>>, EnvError> {
let file_var = format!("{var}_FILE");
let direct = lookup(var)
.map(Zeroizing::new)
.filter(|s| !s.trim().is_empty());
let path = lookup(&file_var).filter(|s| !s.trim().is_empty());
match (direct, path) {
(Some(_), Some(path)) => Err(EnvError::BothSet {
var: var.to_string(),
path,
}),
(Some(v), None) => Ok(Some(Zeroizing::new(v.trim().to_string()))),
(None, Some(path)) => {
let raw = Zeroizing::new(
read_file(&path).map_err(|source| file_error(var, path.clone(), source))?,
);
if raw.len() > MAX_SECRET_FILE_BYTES {
return Err(EnvError::FileTooLarge {
var: var.to_string(),
path,
});
}
let value = Zeroizing::new(raw.trim().to_string());
if value.is_empty() {
return Err(EnvError::EmptyFile {
var: var.to_string(),
path,
});
}
Ok(Some(value))
}
(None, None) => Ok(None),
}
}
pub fn static_tokens_from_env(var: &str) -> Result<Option<StaticTokens>, EnvError> {
static_tokens_from_lookup(var, |v| std::env::var(v).ok(), read_secret_file)
}
pub fn static_tokens_from_lookup(
var: &str,
lookup: impl Fn(&str) -> Option<String>,
read_file: impl Fn(&str) -> io::Result<String>,
) -> Result<Option<StaticTokens>, EnvError> {
let current = secret_zeroizing(var, &lookup, &read_file);
let next = secret_zeroizing(&format!("{var}_NEXT"), &lookup, &read_file);
let (current, next) = match (current, next) {
(Ok(current), Ok(next)) => (current, next),
(Err(a), Err(b)) => return Err(EnvError::Several { errors: vec![a, b] }),
(Err(e), Ok(_)) | (Ok(_), Err(e)) => return Err(e),
};
match (current, next) {
(None, None) => Ok(None),
(None, Some(_)) => Err(EnvError::NextWithoutCurrent {
var: var.to_string(),
}),
(Some(current), next) => {
let mut tokens = StaticTokens::new();
tokens.push_checked(CURRENT_KEY_LABEL, current);
if let Some(next) = next
&& !tokens.contains(&next)
{
tokens.push_checked(NEXT_KEY_LABEL, next);
}
Ok(Some(tokens))
}
}
}
fn split_list(value: &str) -> Vec<String> {
value.split_whitespace().map(str::to_string).collect()
}
fn parse_strict_bool(value: &str) -> Result<bool, ()> {
match value {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(()),
}
}
fn bool_problem(naming: KeyNaming<'_>, field: &str, value: &str) -> ConfigProblem {
ConfigProblem::new(
ProblemKind::EnvParse,
[naming.key(field)],
format!(
"{} {value:?} must be \"true\" or \"false\"",
naming.key(field)
),
)
}
fn env_problem(err: &EnvError) -> ConfigProblem {
ConfigProblem::new(ProblemKind::EnvLoad, error_keys(err), error_text(err))
}
fn error_text(err: &EnvError) -> String {
let mut text = err.to_string();
let mut source = std::error::Error::source(err);
while let Some(cause) = source {
text.push_str(": ");
text.push_str(&cause.to_string());
source = cause.source();
}
text
}
fn error_keys(err: &EnvError) -> Vec<String> {
match err {
EnvError::BothSet { var, .. } => vec![var.clone(), format!("{var}_FILE")],
EnvError::ReadFailed { var, .. }
| EnvError::EmptyFile { var, .. }
| EnvError::NotAFile { var, .. }
| EnvError::FileTooLarge { var, .. } => {
vec![format!("{var}_FILE")]
}
EnvError::NextWithoutCurrent { var } => vec![var.clone(), format!("{var}_NEXT")],
EnvError::Several { errors } => errors.iter().flat_map(error_keys).collect(),
}
}
fn take(
result: Result<Option<String>, EnvError>,
problems: &mut Vec<ConfigProblem>,
) -> Option<String> {
result.unwrap_or_else(|e| {
problems.push(env_problem(&e));
None
})
}
struct IdentifyingVars {
issuer: Result<Option<String>, EnvError>,
jwks_uri: Result<Option<String>, EnvError>,
audience: Result<Option<String>, EnvError>,
audiences: Result<Option<String>, EnvError>,
resource: Result<Option<String>, EnvError>,
}
impl IdentifyingVars {
fn any_set(&self) -> bool {
[
&self.issuer,
&self.jwks_uri,
&self.audience,
&self.audiences,
&self.resource,
]
.into_iter()
.any(|r| !matches!(r, Ok(None)))
}
}
pub fn oauth_config_from_env(prefix: &str) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
oauth_config_from_lookup(prefix, |v| std::env::var(v).ok(), read_secret_file)
}
pub fn oauth_config_from_lookup<L, R>(
prefix: &str,
lookup: L,
read_file: R,
) -> Result<Option<ResolvedOAuthConfig>, ConfigError>
where
L: Fn(&str) -> Option<String>,
R: Fn(&str) -> io::Result<String>,
{
match unresolved_oauth_config_from_lookup(prefix, lookup, read_file) {
Some(loaded) => loaded.resolve(),
None => Ok(None),
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct EnvOAuthConfig {
pub config: OAuthConfig,
pub problems: Vec<String>,
details: Vec<ConfigProblem>,
prefix: String,
}
impl PartialEq for EnvOAuthConfig {
fn eq(&self, other: &Self) -> bool {
self.config == other.config
&& self.problems == other.problems
&& self.prefix == other.prefix
}
}
impl Eq for EnvOAuthConfig {}
impl EnvOAuthConfig {
pub fn problem_details(&self) -> &[ConfigProblem] {
&self.details
}
pub fn prefix(&self) -> &str {
&self.prefix
}
pub fn resolve(self) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
let Self {
config,
problems,
details,
prefix,
} = self;
let naming = KeyNaming::Env(&prefix);
let mut all = reconcile(problems, details);
match config.resolve(naming) {
Ok(resolved) if all.is_empty() => Ok(resolved),
Ok(_) => Err(ConfigError::from_problems(naming, all)),
Err(resolve_err) => {
all.extend(resolve_err.problem_details().iter().cloned());
Err(ConfigError::from_problems(naming, all))
}
}
}
}
fn reconcile(problems: Vec<String>, details: Vec<ConfigProblem>) -> Vec<ConfigProblem> {
let mut pool: Vec<Option<ConfigProblem>> = details.into_iter().map(Some).collect();
problems
.into_iter()
.map(|text| {
pool.iter_mut()
.find(|slot| slot.as_ref().is_some_and(|d| d.message() == text))
.and_then(Option::take)
.unwrap_or_else(|| ConfigProblem::from(text))
})
.collect()
}
pub fn unresolved_oauth_config_from_env(prefix: &str) -> Option<EnvOAuthConfig> {
unresolved_oauth_config_from_lookup(prefix, |v| std::env::var(v).ok(), read_secret_file)
}
pub fn unresolved_oauth_config_from_lookup<L, R>(
prefix: &str,
lookup: L,
read_file: R,
) -> Option<EnvOAuthConfig>
where
L: Fn(&str) -> Option<String>,
R: Fn(&str) -> io::Result<String>,
{
let naming = KeyNaming::Env(prefix);
let field = |f: &str| secret_from_lookup(&naming.key(f), &lookup, &read_file);
let mut problems: Vec<ConfigProblem> = Vec::new();
let explicitly_enabled = match field("enabled") {
Ok(None) => false,
Ok(Some(v)) => match parse_strict_bool(&v) {
Ok(false) => return None,
Ok(true) => true,
Err(()) => {
problems.push(bool_problem(naming, "enabled", &v));
true
}
},
Err(e) => {
problems.push(env_problem(&e));
true
}
};
let identifying = IdentifyingVars {
issuer: field("issuer"),
jwks_uri: field("jwks_uri"),
audience: field("audience"),
audiences: field("audiences"),
resource: field("resource"),
};
if !explicitly_enabled && !identifying.any_set() {
return None;
}
let mut cfg = OAuthConfig {
enabled: true,
..OAuthConfig::default()
};
let IdentifyingVars {
issuer,
jwks_uri,
audience,
audiences,
resource,
} = identifying;
if let Some(v) = take(issuer, &mut problems) {
cfg.issuer = v;
}
cfg.jwks_uri = take(jwks_uri, &mut problems);
if let Some(v) = take(audience, &mut problems) {
cfg.audience = v;
}
if let Some(v) = take(audiences, &mut problems) {
cfg.audiences = split_list(&v);
}
if let Some(v) = take(resource, &mut problems) {
cfg.resource = v;
}
cfg.required_scope = take(field("required_scope"), &mut problems);
if cfg.required_scope.is_none()
&& lookup(&naming.key("required_scope"))
.is_some_and(|v| !v.is_empty() && v.trim().is_empty())
{
cfg.required_scope = Some(String::new());
}
if let Some(v) = take(field("required_scopes"), &mut problems) {
cfg.required_scopes = split_list(&v);
}
cfg.scopes_supported = take(field("scopes_supported"), &mut problems).map(|v| split_list(&v));
if let Some(v) = take(field("scope_claims"), &mut problems) {
cfg.scope_claims = split_list(&v);
}
if let Some(v) = take(field("principal_claims"), &mut problems) {
cfg.principal_claims = split_list(&v);
}
if let Some(v) = take(field("algorithms"), &mut problems) {
cfg.algorithms = split_list(&v);
}
if let Some(v) = take(field("leeway_secs"), &mut problems) {
match v.parse::<u64>() {
Ok(n) => cfg.leeway_secs = n,
Err(_) => problems.push(ConfigProblem::new(
ProblemKind::EnvParse,
[naming.key("leeway_secs")],
format!(
"{} {v:?} is not a valid non-negative integer",
naming.key("leeway_secs")
),
)),
}
}
if let Some(v) = take(field("allowed_client_ids"), &mut problems) {
cfg.allowed_client_ids = split_list(&v);
}
if let Some(v) = take(field("max_token_age_secs"), &mut problems) {
match v.parse::<u64>() {
Ok(n) => cfg.max_token_age_secs = Some(n),
Err(_) => problems.push(ConfigProblem::new(
ProblemKind::EnvParse,
[naming.key("max_token_age_secs")],
format!(
"{} {v:?} is not a valid non-negative integer",
naming.key("max_token_age_secs")
),
)),
}
}
if let Some(v) = take(field("required_claims"), &mut problems) {
match serde_json::from_str::<serde_json::Value>(&v) {
Ok(serde_json::Value::Object(map)) => match duplicated_claim(&v) {
Some(name) => problems.push(ConfigProblem::new(
ProblemKind::InvalidRequiredClaim,
[naming.key("required_claims")],
format!(
"{} names {:?} more than once — each claim may appear once",
naming.key("required_claims"),
crate::token::for_log(&name)
),
)),
None => cfg.required_claims = map.into_iter().collect(),
},
Ok(_) => problems.push(ConfigProblem::new(
ProblemKind::EnvParse,
[naming.key("required_claims")],
format!(
"{} must be a JSON object, e.g. {{\"tid\": \"<tenant id>\"}}",
naming.key("required_claims")
),
)),
Err(e) => problems.push(ConfigProblem::new(
ProblemKind::EnvParse,
[naming.key("required_claims")],
format!(
"{} is not valid JSON ({e}); it must be a JSON object, e.g. \
{{\"tid\": \"<tenant id>\"}}",
naming.key("required_claims")
),
)),
}
}
for (name, slot) in [
("require_at_jwt", &mut cfg.require_at_jwt),
("allow_unscoped_tokens", &mut cfg.allow_unscoped_tokens),
("allow_insecure_http", &mut cfg.allow_insecure_http),
("accept_static_bearer", &mut cfg.accept_static_bearer),
] {
if let Some(v) = take(field(name), &mut problems) {
match parse_strict_bool(&v) {
Ok(b) => *slot = b,
Err(()) => problems.push(bool_problem(naming, name, &v)),
}
}
}
Some(EnvOAuthConfig {
config: cfg,
problems: problems.iter().map(|p| p.message().to_string()).collect(),
details: problems,
prefix: prefix.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::collections::HashMap;
fn lookup_from<'a>(
vars: &'a HashMap<&'static str, &'static str>,
) -> impl Fn(&str) -> Option<String> + 'a {
move |k| vars.get(k).map(|v| v.to_string())
}
fn files_from<'a>(
files: &'a HashMap<&'static str, &'static str>,
) -> impl Fn(&str) -> io::Result<String> + 'a {
move |p| {
files
.get(p)
.map(|c| c.to_string())
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no such file"))
}
}
#[test]
fn absent_is_none() {
let vars = HashMap::new();
let files = HashMap::new();
assert_eq!(
secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
None
);
}
#[test]
fn direct_value_is_trimmed() {
let vars = HashMap::from([("FOO", " bar ")]);
let files = HashMap::new();
assert_eq!(
secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
Some("bar".to_string())
);
}
#[test]
fn direct_value_empty_or_whitespace_is_none() {
for value in ["", " "] {
let vars = HashMap::from([("FOO", value)]);
let files = HashMap::new();
assert_eq!(
secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
None,
"{value:?} should read as unset"
);
}
}
#[test]
fn file_value_is_read_and_trimmed() {
let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo")]);
let files = HashMap::from([("/run/secrets/foo", "bar\n")]);
assert_eq!(
secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap(),
Some("bar".to_string())
);
}
const SENTINEL: &str = "s3cr3t-sentinel";
fn assert_no_leak(err: &EnvError) {
let display = err.to_string();
let debug = format!("{err:?}");
assert!(!display.contains(SENTINEL), "Display leaks: {display}");
assert!(!debug.contains(SENTINEL), "Debug leaks: {debug}");
}
#[test]
fn both_set_is_an_error_naming_var_and_path_only() {
let vars = HashMap::from([("FOO", SENTINEL), ("FOO_FILE", "/run/secrets/foo")]);
let files = HashMap::from([("/run/secrets/foo", SENTINEL)]);
let err = secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap_err();
let text = err.to_string();
assert!(text.contains("FOO"), "{text}");
assert!(text.contains("FOO_FILE"), "{text}");
assert!(text.contains("/run/secrets/foo"), "{text}");
assert!(matches!(err, EnvError::BothSet { .. }));
assert_no_leak(&err);
}
#[test]
fn unreadable_file_is_an_error_naming_the_path() {
let vars = HashMap::from([("FOO_FILE", "/run/secrets/missing")]);
let files = HashMap::new();
let err = secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap_err();
assert!(matches!(err, EnvError::ReadFailed { .. }));
let text = err.to_string();
assert!(text.contains("FOO_FILE"), "{text}");
assert!(text.contains("/run/secrets/missing"), "{text}");
assert!(!text.contains("no such file"), "{text}");
let source = std::error::Error::source(&err).expect("the I/O error is the source");
assert_eq!(source.to_string(), "no such file");
}
#[test]
fn a_read_failure_problem_carries_its_io_cause_once() {
let vars = HashMap::from([
("APP_OAUTH_ISSUER_FILE", "/run/secrets/missing"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
]);
let files = HashMap::new();
let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
assert_eq!(
err.problems[0],
"APP_OAUTH_ISSUER_FILE=/run/secrets/missing: failed to read secret file: no such file"
);
assert_eq!(err.to_string().matches("no such file").count(), 1);
}
#[test]
fn a_file_that_fails_to_decode_does_not_leak_its_contents() {
let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo")]);
let files = HashMap::from([("/run/secrets/foo", SENTINEL)]);
let read_file = |path: &str| -> io::Result<String> {
assert!(files.contains_key(path), "unexpected path {path}");
Err(io::Error::new(
io::ErrorKind::InvalidData,
"stream did not contain valid UTF-8",
))
};
let err = secret_from_lookup("FOO", lookup_from(&vars), read_file).unwrap_err();
assert!(matches!(err, EnvError::ReadFailed { .. }));
assert_no_leak(&err);
}
#[test]
fn empty_file_is_an_error_not_none() {
let vars = HashMap::from([("FOO_FILE", "/run/secrets/foo"), ("FOO_TOKEN", SENTINEL)]);
let files = HashMap::from([("/run/secrets/foo", " \n")]);
let err = secret_from_lookup("FOO", lookup_from(&vars), files_from(&files)).unwrap_err();
assert!(matches!(err, EnvError::EmptyFile { .. }));
let text = err.to_string();
assert!(text.contains("FOO_FILE"), "{text}");
assert!(text.contains("/run/secrets/foo"), "{text}");
assert_no_leak(&err);
}
#[test]
fn secret_from_env_wraps_the_real_environment() {
assert_eq!(
secret_from_env("OAUTH_RESOURCE_SERVER_ENV_RS_TEST_UNSET_VAR_9f3c").unwrap(),
None
);
}
struct RecordingLookup<'a> {
vars: &'a HashMap<&'static str, &'static str>,
calls: Cell<Vec<String>>,
}
impl<'a> RecordingLookup<'a> {
fn new(vars: &'a HashMap<&'static str, &'static str>) -> Self {
Self {
vars,
calls: Cell::new(Vec::new()),
}
}
fn call(&self, var: &str) -> Option<String> {
let mut calls = self.calls.take();
calls.push(var.to_string());
self.calls.set(calls);
self.vars.get(var).map(|v| v.to_string())
}
}
#[test]
fn nothing_set_is_none_and_touches_only_identifying_variables() {
let vars = HashMap::new();
let files = HashMap::new();
let recorder = RecordingLookup::new(&vars);
let result =
oauth_config_from_lookup("APP_OAUTH_", |v| recorder.call(v), files_from(&files));
assert_eq!(result, Ok(None));
let calls = recorder.calls.take();
for var in &calls {
assert!(
var.starts_with("APP_OAUTH_")
&& (var.ends_with("ENABLED")
|| var.ends_with("ENABLED_FILE")
|| var.ends_with("ISSUER")
|| var.ends_with("ISSUER_FILE")
|| var.ends_with("JWKS_URI")
|| var.ends_with("JWKS_URI_FILE")
|| var.ends_with("AUDIENCE")
|| var.ends_with("AUDIENCE_FILE")
|| var.ends_with("AUDIENCES")
|| var.ends_with("AUDIENCES_FILE")
|| var.ends_with("RESOURCE")
|| var.ends_with("RESOURCE_FILE")),
"unexpected variable consulted while OAuth is unconfigured: {var} (all: {calls:?})"
);
}
assert!(!calls.is_empty(), "the identifying vars must be checked");
}
#[test]
fn a_typical_prefixed_variable_set_resolves_verbatim() {
let vars = HashMap::from([
(
"MYAPP_OAUTH_ISSUER",
"https://idp.example.test/application/o/myapp/",
),
(
"MYAPP_OAUTH_JWKS_URI",
"https://idp.example.test/application/o/myapp/jwks/",
),
("MYAPP_OAUTH_AUDIENCE", "myapp-client-id"),
("MYAPP_OAUTH_RESOURCE", "https://myapp.example.test/mcp"),
("MYAPP_OAUTH_REQUIRED_SCOPE", "myapp:read"),
("MYAPP_OAUTH_SCOPES_SUPPORTED", "myapp:read myapp:write"),
]);
let files = HashMap::new();
let resolved =
oauth_config_from_lookup("MYAPP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap()
.expect("identifying vars are set");
assert_eq!(
resolved.issuer,
"https://idp.example.test/application/o/myapp/"
);
assert_eq!(
resolved.jwks_uri.as_deref(),
Some("https://idp.example.test/application/o/myapp/jwks/")
);
assert_eq!(resolved.audience, "myapp-client-id");
assert_eq!(resolved.resource, "https://myapp.example.test/mcp");
assert_eq!(resolved.required_scopes, ["myapp:read"]);
assert_eq!(resolved.scopes_supported, ["myapp:read", "myapp:write"]);
assert_eq!(resolved.scope_claims, ["scope", "scp"]);
assert!(!resolved.require_at_jwt);
assert!(resolved.accept_static_bearer);
}
#[test]
fn jwks_uri_alone_counts_as_identifying_even_though_resolve_does_not_require_it() {
let vars = HashMap::from([("APP_OAUTH_JWKS_URI", "https://idp.example.test/jwks")]);
let files = HashMap::new();
let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
assert!(err.problems.iter().any(|p| p.contains("APP_OAUTH_ISSUER")));
assert!(
!err.problems
.iter()
.any(|p| p.contains("APP_OAUTH_JWKS_URI"))
);
}
#[test]
fn partial_set_is_an_error_listing_what_is_missing() {
let vars = HashMap::from([("APP_OAUTH_ISSUER", "https://idp.example.test/")]);
let files = HashMap::new();
let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
let text = err.to_string();
assert!(text.contains("APP_OAUTH_AUDIENCE"), "{text}");
assert!(text.contains("APP_OAUTH_RESOURCE"), "{text}");
}
#[test]
fn a_both_set_error_on_an_identifying_variable_still_counts_as_configured() {
let vars = HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_ISSUER_FILE", "/run/secrets/issuer"),
]);
let files = HashMap::from([("/run/secrets/issuer", "https://idp.example.test/")]);
let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
assert!(
err.problems[0].contains("APP_OAUTH_ISSUER") && err.problems[0].contains("both set"),
"{:?}",
err.problems
);
assert!(
err.problems[1..]
.iter()
.any(|p| p.starts_with("these required settings are empty")
&& p.contains("APP_OAUTH_ISSUER")),
"{:?}",
err.problems
);
}
#[test]
fn scopes_supported_defaults_to_the_required_scope() {
let vars = HashMap::from([
("MYAPP_OAUTH_ISSUER", "https://idp.example.test/"),
("MYAPP_OAUTH_JWKS_URI", "http://127.0.0.1:1/jwks"),
("MYAPP_OAUTH_AUDIENCE", "myapp-client-id"),
("MYAPP_OAUTH_RESOURCE", "https://myapp.example.test/mcp"),
("MYAPP_OAUTH_REQUIRED_SCOPE", "myapp:read"),
]);
let files = HashMap::new();
let resolved =
oauth_config_from_lookup("MYAPP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap()
.unwrap();
assert_eq!(resolved.required_scopes, ["myapp:read"]);
assert_eq!(resolved.scopes_supported, ["myapp:read"]);
}
#[test]
fn scopes_supported_default_unions_required_scopes_in_order_without_duplicates() {
let vars = HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
("APP_OAUTH_REQUIRED_SCOPES", "api:write api:read"),
]);
let files = HashMap::new();
let resolved =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap()
.unwrap();
assert_eq!(resolved.scopes_supported, ["api:read", "api:write"]);
}
#[test]
fn scopes_supported_stays_empty_with_no_required_scope() {
let vars = HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", "true"),
("APP_OAUTH_SCOPES_SUPPORTED", " "),
]);
let files = HashMap::new();
let resolved =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap()
.unwrap();
assert!(resolved.required_scopes.is_empty());
assert!(resolved.scopes_supported.is_empty());
}
#[test]
fn explicit_scopes_supported_is_not_replaced_by_the_default() {
let vars = HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
("APP_OAUTH_SCOPES_SUPPORTED", "api:admin"),
]);
let files = HashMap::new();
let resolved =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap()
.unwrap();
assert_eq!(resolved.scopes_supported, ["api:admin"]);
}
#[test]
fn enabled_true_alone_turns_oauth_on_and_reports_what_is_missing() {
let vars = HashMap::from([("APP_OAUTH_ENABLED", "true")]);
let files = HashMap::new();
let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
let text = err.to_string();
assert!(text.contains("APP_OAUTH_ISSUER"), "{text}");
assert!(text.contains("APP_OAUTH_AUDIENCE"), "{text}");
assert!(text.contains("APP_OAUTH_RESOURCE"), "{text}");
}
#[test]
fn enabled_true_with_a_complete_set_resolves() {
let vars = HashMap::from([
("APP_OAUTH_ENABLED", "true"),
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
]);
let files = HashMap::new();
let resolved =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files)).unwrap();
assert!(resolved.is_some());
}
#[test]
fn enabled_false_turns_oauth_off_without_reading_anything_else() {
let vars = HashMap::from([
("APP_OAUTH_ENABLED", "false"),
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
("APP_OAUTH_LEEWAY_SECS", "not-a-number"),
]);
let files = HashMap::new();
let recorder = RecordingLookup::new(&vars);
let result =
oauth_config_from_lookup("APP_OAUTH_", |v| recorder.call(v), files_from(&files));
assert_eq!(result, Ok(None));
let calls = recorder.calls.take();
assert_eq!(calls, ["APP_OAUTH_ENABLED", "APP_OAUTH_ENABLED_FILE"]);
}
#[test]
fn enabled_from_a_file_is_honoured() {
let vars = HashMap::from([
("APP_OAUTH_ENABLED_FILE", "/run/secrets/enabled"),
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
]);
let files = HashMap::from([("/run/secrets/enabled", "false\n")]);
assert_eq!(
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files)),
Ok(None)
);
}
#[test]
fn an_unparsable_enabled_is_reported_and_treated_as_on() {
let vars = HashMap::from([("APP_OAUTH_ENABLED", "yes")]);
let files = HashMap::new();
let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
assert!(
err.problems[0].contains("APP_OAUTH_ENABLED")
&& err.problems[0].contains("must be \"true\" or \"false\""),
"{:?}",
err.problems
);
assert!(err.to_string().contains("APP_OAUTH_ISSUER"));
}
#[test]
fn an_enabled_that_fails_to_load_is_reported_and_treated_as_on() {
let vars = HashMap::from([("APP_OAUTH_ENABLED_FILE", "/run/secrets/missing")]);
let files = HashMap::new();
let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
assert!(
err.problems[0].contains("APP_OAUTH_ENABLED_FILE"),
"{:?}",
err.problems
);
assert!(err.to_string().contains("APP_OAUTH_ISSUER"));
}
#[test]
fn loader_problems_carry_env_load_and_env_parse_kinds() {
let vars = HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_ISSUER_FILE", "/run/secrets/issuer"),
("APP_OAUTH_AUDIENCE_FILE", "/run/secrets/missing"),
("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
("APP_OAUTH_REQUIRE_AT_JWT", "yes"),
("APP_OAUTH_LEEWAY_SECS", "soon"),
("APP_OAUTH_ALLOW_UNSCOPED_TOKENS_FILE", "/run/secrets/empty"),
]);
let files = HashMap::from([("/run/secrets/empty", " ")]);
let loaded = unresolved_oauth_config_from_lookup(
"APP_OAUTH_",
lookup_from(&vars),
files_from(&files),
)
.unwrap();
let got: Vec<_> = loaded
.problem_details()
.iter()
.map(|p| (p.kind(), p.keys().to_vec()))
.collect();
let env = |v: &str| vec![v.to_string()];
assert_eq!(
got,
[
(
ProblemKind::EnvLoad,
vec![
"APP_OAUTH_ISSUER".to_string(),
"APP_OAUTH_ISSUER_FILE".to_string()
],
),
(ProblemKind::EnvLoad, env("APP_OAUTH_AUDIENCE_FILE")),
(ProblemKind::EnvParse, env("APP_OAUTH_LEEWAY_SECS")),
(ProblemKind::EnvParse, env("APP_OAUTH_REQUIRE_AT_JWT")),
(
ProblemKind::EnvLoad,
env("APP_OAUTH_ALLOW_UNSCOPED_TOKENS_FILE")
),
]
);
let texts: Vec<&str> = loaded
.problem_details()
.iter()
.map(ConfigProblem::message)
.collect();
assert_eq!(texts, loaded.problems);
let err = loaded.resolve().unwrap_err();
assert_eq!(err.problems.len(), err.problem_details().len());
assert_eq!(err.problem_details()[0].kind(), ProblemKind::EnvLoad);
assert_eq!(err.problem_details()[2].kind(), ProblemKind::EnvParse);
let texts: Vec<&str> = err
.problem_details()
.iter()
.map(ConfigProblem::message)
.collect();
assert_eq!(texts, err.problems);
}
#[test]
fn env_oauth_config_equality_ignores_the_structured_details() {
let vars = HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_ALLOW_INSECURE_HTTP", "maybe"),
]);
let files = HashMap::new();
let load = || {
unresolved_oauth_config_from_lookup(
"APP_OAUTH_",
lookup_from(&vars),
files_from(&files),
)
.unwrap()
};
let a = load();
let mut b = load();
assert_eq!(a, b);
b.details = vec![ConfigProblem::from(a.problems[0].clone())];
assert_eq!(a, b);
b.problems.push("extra".into());
assert_ne!(a, b);
}
#[test]
fn an_enabled_that_is_not_a_bool_is_an_env_parse_problem() {
let vars = HashMap::from([("APP_OAUTH_ENABLED", "maybe")]);
let files = HashMap::new();
let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
let p = &err.problem_details()[0];
assert_eq!(p.kind(), ProblemKind::EnvParse);
assert_eq!(p.keys(), ["APP_OAUTH_ENABLED"]);
assert!(
err.problem_details()
.iter()
.any(|p| p.kind() == ProblemKind::MissingRequired
&& p.keys().contains(&"APP_OAUTH_ISSUER".to_string()))
);
}
#[test]
fn edited_problems_still_reach_resolve_as_other_and_matches_keep_their_kind() {
let vars = HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", "maybe"),
]);
let files = HashMap::new();
let mut loaded = unresolved_oauth_config_from_lookup(
"APP_OAUTH_",
lookup_from(&vars),
files_from(&files),
)
.unwrap();
loaded.problems.insert(0, "app-side problem".into());
assert_eq!(loaded.problem_details().len(), 1);
let err = loaded.resolve().unwrap_err();
assert_eq!(err.problems[0], "app-side problem");
assert_eq!(err.problem_details()[0].kind(), ProblemKind::Other);
assert_eq!(err.problem_details()[1].kind(), ProblemKind::EnvParse);
}
#[test]
fn whitespace_lists_are_split() {
let vars = HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
("APP_OAUTH_REQUIRED_SCOPES", " api:read api:write "),
("APP_OAUTH_AUDIENCES", "extra-aud another-aud"),
("APP_OAUTH_ALGORITHMS", "RS256 ES256"),
]);
let files = HashMap::new();
let resolved =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap()
.unwrap();
assert_eq!(resolved.required_scopes, ["api:read", "api:write"]);
assert_eq!(
resolved.accepted_audiences(),
["client-id", "extra-aud", "another-aud"]
);
assert!(resolved.algorithms.contains(&crate::Algorithm::RS256));
assert!(resolved.algorithms.contains(&crate::Algorithm::ES256));
}
#[test]
fn parse_errors_for_bool_and_integer_fields_are_aggregated_with_other_problems() {
let vars = HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
("APP_OAUTH_LEEWAY_SECS", "not-a-number"),
("APP_OAUTH_REQUIRE_AT_JWT", "yes"),
("APP_OAUTH_ACCEPT_STATIC_BEARER", "0"),
]);
let files = HashMap::new();
let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
assert_eq!(err.problems.len(), 3, "{:?}", err.problems);
let text = err.to_string();
assert!(text.contains("APP_OAUTH_LEEWAY_SECS"), "{text}");
assert!(text.contains("not-a-number"), "{text}");
assert!(text.contains("APP_OAUTH_REQUIRE_AT_JWT"), "{text}");
assert!(text.contains("APP_OAUTH_ACCEPT_STATIC_BEARER"), "{text}");
}
#[test]
fn strict_bool_parsing_accepts_only_true_and_false() {
for good in ["true", "false"] {
let vars = HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
("APP_OAUTH_REQUIRE_AT_JWT", good),
]);
let files = HashMap::new();
let resolved =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap()
.unwrap();
assert_eq!(resolved.require_at_jwt, good == "true");
}
for bad in ["True", "FALSE", "1", "0", "yes"] {
let vars = HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
("APP_OAUTH_REQUIRE_AT_JWT", bad),
]);
let files = HashMap::new();
let result =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files));
assert!(result.is_err(), "{bad:?} should be rejected");
}
let vars = HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
("APP_OAUTH_REQUIRE_AT_JWT", ""),
]);
let files = HashMap::new();
let resolved =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap()
.unwrap();
assert!(!resolved.require_at_jwt);
}
#[test]
fn the_explicit_opt_ins_are_read_as_strict_booleans() {
let files = HashMap::new();
let vars = HashMap::from([
("APP_OAUTH_ISSUER", "http://idp.internal.test/"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", "true"),
("APP_OAUTH_ALLOW_INSECURE_HTTP", "true"),
]);
let resolved =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap()
.unwrap();
assert!(resolved.allow_unscoped_tokens && resolved.allow_insecure_http);
let mut vars = vars;
vars.remove("APP_OAUTH_ALLOW_UNSCOPED_TOKENS");
vars.insert("APP_OAUTH_ALLOW_INSECURE_HTTP", "yes");
let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
let text = err.to_string();
assert!(
text.contains("APP_OAUTH_ALLOW_INSECURE_HTTP \"yes\" must be"),
"{text}"
);
assert!(
text.contains("APP_OAUTH_ISSUER \"http://idp.internal.test/\" uses plain http"),
"{text}"
);
assert!(
text.contains("set APP_OAUTH_ALLOW_UNSCOPED_TOKENS"),
"{text}"
);
}
#[test]
fn problem_messages_are_named_with_the_configured_prefix() {
let vars = HashMap::from([("MYAPP_OAUTH_ISSUER", "https://idp.example.test/")]);
let files = HashMap::new();
let err = oauth_config_from_lookup("MYAPP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
let text = err.to_string();
assert!(text.starts_with(
"OAuth is configured through MYAPP_OAUTH_* but the config is not usable:"
));
assert!(text.contains("MYAPP_OAUTH_AUDIENCE"), "{text}");
assert!(text.contains("MYAPP_OAUTH_RESOURCE"), "{text}");
assert!(!text.contains("mcp.oauth"), "{text}");
}
fn base_vars() -> HashMap<&'static str, &'static str> {
HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_AUDIENCE", "client-id"),
("APP_OAUTH_RESOURCE", "https://svc.example.test/api"),
])
}
fn with_app_scope_default(mut loaded: EnvOAuthConfig) -> EnvOAuthConfig {
let cfg = &mut loaded.config;
if cfg.required_scope.is_none() && cfg.required_scopes.is_empty() {
cfg.required_scope = Some("mcp:read".into());
}
loaded
}
#[test]
fn unresolved_is_none_exactly_when_oauth_is_off() {
let files = HashMap::new();
let nothing = HashMap::new();
assert_eq!(
unresolved_oauth_config_from_lookup(
"APP_OAUTH_",
lookup_from(¬hing),
files_from(&files)
),
None
);
let mut disabled = base_vars();
disabled.insert("APP_OAUTH_ENABLED", "false");
assert_eq!(
unresolved_oauth_config_from_lookup(
"APP_OAUTH_",
lookup_from(&disabled),
files_from(&files)
),
None
);
let loaded = unresolved_oauth_config_from_lookup(
"APP_OAUTH_",
lookup_from(&base_vars()),
files_from(&files),
)
.expect("identifying vars are set");
assert!(loaded.config.enabled);
assert_eq!(loaded.config.issuer, "https://idp.example.test/");
assert_eq!(
loaded.config.scopes_supported, None,
"default not yet applied"
);
assert!(loaded.problems.is_empty());
assert_eq!(loaded.prefix(), "APP_OAUTH_");
}
#[test]
fn an_app_scope_default_is_enforced_and_advertised() {
let files = HashMap::new();
let loaded = unresolved_oauth_config_from_lookup(
"APP_OAUTH_",
lookup_from(&base_vars()),
files_from(&files),
)
.unwrap();
let resolved = with_app_scope_default(loaded).resolve().unwrap().unwrap();
assert_eq!(resolved.required_scopes, ["mcp:read"]);
assert_eq!(resolved.scopes_supported, ["mcp:read"]);
}
#[test]
fn an_operator_scope_wins_over_the_app_default() {
let mut vars = base_vars();
vars.insert("APP_OAUTH_REQUIRED_SCOPE", "api:read");
let files = HashMap::new();
let loaded = unresolved_oauth_config_from_lookup(
"APP_OAUTH_",
lookup_from(&vars),
files_from(&files),
)
.unwrap();
let resolved = with_app_scope_default(loaded).resolve().unwrap().unwrap();
assert_eq!(resolved.required_scopes, ["api:read"]);
assert_eq!(resolved.scopes_supported, ["api:read"]);
}
#[test]
fn an_app_default_goes_through_resolves_validation() {
let files = HashMap::new();
let mut loaded = unresolved_oauth_config_from_lookup(
"APP_OAUTH_",
lookup_from(&base_vars()),
files_from(&files),
)
.unwrap();
loaded.config.required_scope = Some("mcp:read mcp:write".into());
let err = loaded.resolve().unwrap_err();
assert!(
err.problems
.iter()
.any(|p| p.contains("APP_OAUTH_REQUIRED_SCOPE") && p.contains("single scope")),
"{:?}",
err.problems
);
}
#[test]
fn an_app_set_explicit_empty_scopes_supported_is_kept() {
let mut vars = base_vars();
vars.insert("APP_OAUTH_REQUIRED_SCOPE", "api:read");
let files = HashMap::new();
let mut loaded = unresolved_oauth_config_from_lookup(
"APP_OAUTH_",
lookup_from(&vars),
files_from(&files),
)
.unwrap();
loaded.config.scopes_supported = Some(vec![]);
let resolved = loaded.resolve().unwrap().unwrap();
assert!(resolved.scopes_supported.is_empty());
}
#[test]
fn loader_problems_are_carried_and_reported_first_by_resolve() {
let mut vars = base_vars();
vars.insert("APP_OAUTH_LEEWAY_SECS", "soon");
vars.remove("APP_OAUTH_RESOURCE");
let files = HashMap::new();
let loaded = unresolved_oauth_config_from_lookup(
"APP_OAUTH_",
lookup_from(&vars),
files_from(&files),
)
.unwrap();
assert_eq!(loaded.problems.len(), 1, "{:?}", loaded.problems);
assert!(loaded.problems[0].contains("APP_OAUTH_LEEWAY_SECS"));
let err = loaded.resolve().unwrap_err();
assert!(
err.problems[0].contains("APP_OAUTH_LEEWAY_SECS"),
"{:?}",
err.problems
);
assert!(
err.problems[1..]
.iter()
.any(|p| p.contains("APP_OAUTH_RESOURCE")),
"{:?}",
err.problems
);
}
#[test]
fn loader_problems_fail_resolve_even_when_the_config_is_otherwise_valid() {
let mut vars = base_vars();
vars.insert("APP_OAUTH_REQUIRED_SCOPE", "api:read");
vars.insert("APP_OAUTH_REQUIRE_AT_JWT", "yes");
let files = HashMap::new();
let loaded = unresolved_oauth_config_from_lookup(
"APP_OAUTH_",
lookup_from(&vars),
files_from(&files),
)
.unwrap();
let err = loaded.resolve().unwrap_err();
assert_eq!(err.problems.len(), 1, "{:?}", err.problems);
assert!(err.problems[0].contains("APP_OAUTH_REQUIRE_AT_JWT"));
}
#[test]
fn unresolved_oauth_config_from_env_wraps_the_real_environment() {
assert_eq!(
unresolved_oauth_config_from_env("OAUTH_RESOURCE_SERVER_ENV_RS_TEST_UNSET_9f3c_"),
None
);
}
#[test]
fn oauth_config_from_env_wraps_the_real_environment() {
assert_eq!(
oauth_config_from_env("OAUTH_RESOURCE_SERVER_ENV_RS_TEST_UNSET_9f3c_"),
Ok(None)
);
}
fn policy_vars() -> HashMap<&'static str, &'static str> {
HashMap::from([
("APP_OAUTH_ISSUER", "https://idp.example.test/"),
("APP_OAUTH_AUDIENCE", "client-a"),
("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
("APP_OAUTH_REQUIRED_SCOPE", "api:read"),
])
}
#[test]
fn the_claim_policy_settings_load_from_their_variables() {
let mut vars = policy_vars();
vars.insert("APP_OAUTH_ALLOWED_CLIENT_IDS", "client-a client-b");
vars.insert("APP_OAUTH_MAX_TOKEN_AGE_SECS", "3600");
vars.insert(
"APP_OAUTH_REQUIRED_CLAIMS",
r#"{"tid": "tenant-1", "level": 2, "mfa": true}"#,
);
let files = HashMap::new();
let resolved =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap()
.unwrap();
assert_eq!(resolved.allowed_client_ids, ["client-a", "client-b"]);
assert_eq!(resolved.max_token_age_secs, Some(3600));
assert_eq!(
resolved.required_claims,
[
("level".to_string(), serde_json::json!(2)),
("mfa".to_string(), serde_json::json!(true)),
("tid".to_string(), serde_json::json!("tenant-1")),
]
.into_iter()
.collect()
);
let vars = policy_vars();
let resolved =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap()
.unwrap();
assert!(resolved.allowed_client_ids.is_empty());
assert_eq!(resolved.max_token_age_secs, None);
assert!(resolved.required_claims.is_empty());
}
#[test]
fn claim_policy_parse_and_resolve_problems_are_reported_together() {
let mut vars = policy_vars();
vars.insert("APP_OAUTH_MAX_TOKEN_AGE_SECS", "an hour");
vars.insert("APP_OAUTH_REQUIRED_CLAIMS", r#"["tid"]"#);
let files = HashMap::new();
let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
let got: Vec<_> = err
.problem_details()
.iter()
.map(|p| (p.kind(), p.keys().to_vec()))
.collect();
assert_eq!(
got,
[
(
ProblemKind::EnvParse,
vec!["APP_OAUTH_MAX_TOKEN_AGE_SECS".to_string()]
),
(
ProblemKind::EnvParse,
vec!["APP_OAUTH_REQUIRED_CLAIMS".to_string()]
),
]
);
assert!(err.problems[1].contains("must be a JSON object"), "{err}");
let mut vars = policy_vars();
vars.insert("APP_OAUTH_REQUIRED_CLAIMS", "tid=tenant-1");
let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
assert_eq!(err.problem_details()[0].kind(), ProblemKind::EnvParse);
assert!(err.problems[0].contains("is not valid JSON"), "{err}");
let mut vars = policy_vars();
vars.insert("APP_OAUTH_ALLOWED_CLIENT_IDS", "client-a");
vars.insert("APP_OAUTH_MAX_TOKEN_AGE_SECS", "0");
vars.insert(
"APP_OAUTH_REQUIRED_CLAIMS",
r#"{"aud": "x", "org": {"id": 1}}"#,
);
let err = oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
let got: Vec<_> = err
.problem_details()
.iter()
.map(|p| (p.kind(), p.keys().to_vec()))
.collect();
assert_eq!(
got,
[
(
ProblemKind::TokenAgeOutOfRange,
vec!["APP_OAUTH_MAX_TOKEN_AGE_SECS".to_string()]
),
(
ProblemKind::InvalidRequiredClaim,
vec!["APP_OAUTH_REQUIRED_CLAIMS".to_string()]
),
(
ProblemKind::InvalidRequiredClaim,
vec!["APP_OAUTH_REQUIRED_CLAIMS".to_string()]
),
]
);
}
fn temp_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"oauth-resource-server-{}-{name}",
std::process::id()
))
}
#[test]
fn a_file_var_naming_a_directory_is_not_a_file_and_is_never_read() {
let dir = std::env::temp_dir();
let dir = dir.to_str().unwrap().to_string();
let lookup = |name: &str| (name == "KEY_FILE").then(|| dir.clone());
match secret_from_lookup("KEY", lookup, read_secret_file) {
Err(EnvError::NotAFile { var, path }) => {
assert_eq!((var.as_str(), path.as_str()), ("KEY", dir.as_str()));
}
other => panic!("{other:?}"),
}
let err = secret_from_lookup("KEY", lookup, read_secret_file).unwrap_err();
assert_eq!(
err.to_string(),
format!("KEY_FILE={dir}: not a regular file")
);
assert_eq!(error_keys(&err), ["KEY_FILE"]);
}
#[test]
fn a_file_over_the_cap_is_refused_and_one_at_the_cap_is_read() {
let over = temp_path("over-cap");
let at = temp_path("at-cap");
std::fs::write(&over, "x".repeat(MAX_SECRET_FILE_BYTES + 1)).unwrap();
std::fs::write(&at, "y".repeat(MAX_SECRET_FILE_BYTES)).unwrap();
let over_s = over.to_str().unwrap().to_string();
let at_s = at.to_str().unwrap().to_string();
let result_over = secret_from_lookup(
"KEY",
|n: &str| (n == "KEY_FILE").then(|| over_s.clone()),
read_secret_file,
);
let result_at = secret_from_lookup(
"KEY",
|n: &str| (n == "KEY_FILE").then(|| at_s.clone()),
read_secret_file,
);
let _ = std::fs::remove_file(&over);
let _ = std::fs::remove_file(&at);
match result_over {
Err(EnvError::FileTooLarge { var, path }) => {
assert_eq!((var.as_str(), path.as_str()), ("KEY", over_s.as_str()));
}
other => panic!("{:?}", other.map(|v| v.map(|s| s.len()))),
}
assert_eq!(result_at.unwrap().unwrap().len(), MAX_SECRET_FILE_BYTES);
let big = "z".repeat(MAX_SECRET_FILE_BYTES + 1);
let err = secret_from_lookup(
"KEY",
|n: &str| (n == "KEY_FILE").then(|| "/run/secrets/key".to_string()),
|_: &str| Ok(big.clone()),
)
.unwrap_err();
assert!(matches!(err, EnvError::FileTooLarge { .. }), "{err:?}");
let vars = HashMap::from([
("APP_OAUTH_ISSUER_FILE", "/run/secrets/issuer"),
("APP_OAUTH_AUDIENCE", "client-a"),
("APP_OAUTH_RESOURCE", "https://kb.example.test/"),
]);
let err =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), |_: &str| Ok(big.clone()))
.unwrap_err();
assert_eq!(err.problem_details()[0].kind(), ProblemKind::EnvLoad);
assert_eq!(err.problem_details()[0].keys(), ["APP_OAUTH_ISSUER_FILE"]);
}
#[test]
fn a_readable_small_file_still_reads_and_trims() {
let path = temp_path("small");
std::fs::write(&path, "s3cret\n").unwrap();
let p = path.to_str().unwrap().to_string();
let got = secret_from_lookup(
"KEY",
|n: &str| (n == "KEY_FILE").then(|| p.clone()),
read_secret_file,
);
let missing = secret_from_lookup(
"KEY",
|n: &str| (n == "KEY_FILE").then(|| format!("{p}-missing")),
read_secret_file,
);
let _ = std::fs::remove_file(&path);
assert_eq!(got.unwrap().as_deref(), Some("s3cret"));
assert!(
matches!(missing, Err(EnvError::ReadFailed { .. })),
"{missing:?}"
);
}
#[test]
fn a_claim_named_twice_in_required_claims_is_refused() {
let files = HashMap::new();
for json in [
r#"{"tid": "good", "tid": "evil"}"#,
r#"{"a": 1, "tid": "good", "b": true, "tid": "good"}"#,
] {
let mut vars = policy_vars();
vars.insert("APP_OAUTH_REQUIRED_CLAIMS", json);
let err =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
let details = err.problem_details();
assert_eq!(details.len(), 1, "{err}");
assert_eq!(details[0].kind(), ProblemKind::InvalidRequiredClaim);
assert_eq!(details[0].keys(), ["APP_OAUTH_REQUIRED_CLAIMS"]);
assert!(err.problems[0].contains("\"tid\" more than once"), "{err}");
assert!(!err.problems[0].contains("evil"), "{err}");
}
}
#[test]
fn a_blank_required_scope_variable_is_an_error_not_unset() {
let files = HashMap::new();
for allow_unscoped in [None, Some("true")] {
let mut vars = policy_vars();
vars.insert("APP_OAUTH_REQUIRED_SCOPE", " ");
if let Some(v) = allow_unscoped {
vars.insert("APP_OAUTH_ALLOW_UNSCOPED_TOKENS", v);
}
let err =
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap_err();
let kinds: Vec<_> = err.problem_details().iter().map(|p| p.kind()).collect();
assert!(kinds.contains(&ProblemKind::BlankRequiredScope), "{err}");
assert!(
err.problem_details()
.iter()
.any(|p| p.keys() == ["APP_OAUTH_REQUIRED_SCOPE"]),
"{err}"
);
}
let mut vars = policy_vars();
vars.insert("APP_OAUTH_REQUIRED_SCOPE", "");
vars.insert("APP_OAUTH_REQUIRE_AT_JWT", "true");
assert!(
oauth_config_from_lookup("APP_OAUTH_", lookup_from(&vars), files_from(&files))
.unwrap()
.unwrap()
.required_scopes
.is_empty()
);
}
fn tokens(
vars: &[(&'static str, &'static str)],
files: &[(&'static str, &'static str)],
) -> Result<Option<StaticTokens>, EnvError> {
let vars: HashMap<_, _> = vars.iter().copied().collect();
let files: HashMap<_, _> = files.iter().copied().collect();
static_tokens_from_lookup("KEY", lookup_from(&vars), files_from(&files))
}
fn labels(set: &StaticTokens) -> Vec<Option<&str>> {
set.labels().collect()
}
fn accepts(set: &StaticTokens, candidate: &str) -> Option<Option<String>> {
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
rt.block_on(crate::authenticate_with_static_tokens(
[candidate],
Some(set),
None,
))
.ok()
.and_then(|(_, m)| m)
.map(|m| m.label().map(str::to_string))
}
#[test]
fn static_tokens_absent_is_none() {
assert!(tokens(&[], &[]).unwrap().is_none());
assert!(
tokens(&[("KEY", " "), ("KEY_NEXT", "")], &[])
.unwrap()
.is_none()
);
}
#[test]
fn static_tokens_var_only_is_the_current_key() {
let set = tokens(&[("KEY", " old\n")], &[]).unwrap().unwrap();
assert_eq!(labels(&set), [Some("current")]);
assert_eq!(accepts(&set, "old"), Some(Some("current".into())));
assert_eq!(accepts(&set, " old\n"), None, "trimmed, as secret_from_env");
}
#[test]
fn static_tokens_var_and_next_are_both_accepted() {
let set = tokens(&[("KEY", "old"), ("KEY_NEXT", "new")], &[])
.unwrap()
.unwrap();
assert_eq!(
labels(&set),
[Some(CURRENT_KEY_LABEL), Some(NEXT_KEY_LABEL)]
);
assert_eq!(accepts(&set, "old"), Some(Some("current".into())));
assert_eq!(accepts(&set, "new"), Some(Some("next".into())));
assert_eq!(accepts(&set, "other"), None);
let set = tokens(&[("KEY", "new"), ("KEY_NEXT", "new")], &[])
.unwrap()
.unwrap();
assert_eq!(labels(&set), [Some("current")]);
}
#[test]
fn static_tokens_file_forms() {
let set = tokens(
&[("KEY_FILE", "/run/k"), ("KEY_NEXT_FILE", "/run/k_next")],
&[("/run/k", "old\n"), ("/run/k_next", "new\n")],
)
.unwrap()
.unwrap();
assert_eq!(accepts(&set, "old"), Some(Some("current".into())));
assert_eq!(accepts(&set, "new"), Some(Some("next".into())));
let set = tokens(
&[("KEY", "old"), ("KEY_NEXT_FILE", "/run/k_next")],
&[("/run/k_next", "new\n")],
)
.unwrap()
.unwrap();
assert_eq!(labels(&set), [Some("current"), Some("next")]);
}
#[test]
fn static_tokens_both_forms_set_is_an_error() {
let err = tokens(&[("KEY", "a"), ("KEY_FILE", "/run/k")], &[("/run/k", "b")]).unwrap_err();
assert!(
matches!(&err, EnvError::BothSet { var, .. } if var == "KEY"),
"{err:?}"
);
let err = tokens(
&[("KEY", "a"), ("KEY_NEXT", "b"), ("KEY_NEXT_FILE", "/run/n")],
&[("/run/n", "c")],
)
.unwrap_err();
assert!(
matches!(&err, EnvError::BothSet { var, .. } if var == "KEY_NEXT"),
"{err:?}"
);
}
#[test]
fn static_tokens_empty_file_is_an_error() {
let err = tokens(&[("KEY_FILE", "/run/k")], &[("/run/k", " \n")]).unwrap_err();
assert!(
matches!(&err, EnvError::EmptyFile { var, .. } if var == "KEY"),
"{err:?}"
);
let err = tokens(
&[("KEY", "a"), ("KEY_NEXT_FILE", "/run/n")],
&[("/run/n", "")],
)
.unwrap_err();
assert!(
matches!(&err, EnvError::EmptyFile { var, .. } if var == "KEY_NEXT"),
"{err:?}"
);
}
#[test]
fn static_tokens_next_without_current_is_an_error() {
let err = tokens(&[("KEY_NEXT", "new")], &[]).unwrap_err();
assert!(
matches!(&err, EnvError::NextWithoutCurrent { var } if var == "KEY"),
"{err:?}"
);
assert!(err.to_string().contains("KEY_NEXT is set but KEY is not"));
assert!(!err.to_string().contains("new") && !format!("{err:?}").contains("\"new\""));
}
#[test]
fn static_tokens_report_both_failures_at_once() {
let err = tokens(
&[
("KEY", "s3cret-a"),
("KEY_FILE", "/run/k"),
("KEY_NEXT_FILE", "/run/missing"),
],
&[("/run/k", "s3cret-b")],
)
.unwrap_err();
let EnvError::Several { errors } = &err else {
panic!("expected Several, got {err:?}");
};
assert!(matches!(errors[0], EnvError::BothSet { .. }));
assert!(matches!(errors[1], EnvError::ReadFailed { .. }));
let text = err.to_string();
assert!(text.contains("KEY and KEY_FILE are both set"), "{text}");
assert!(
text.contains("KEY_NEXT_FILE=/run/missing: failed to read secret file: no such file"),
"{text}"
);
assert!(!text.contains("s3cret") && !format!("{err:?}").contains("s3cret"));
}
#[test]
fn static_tokens_debug_prints_labels_only() {
let set = tokens(&[("KEY", "s3cret-a"), ("KEY_NEXT", "s3cret-b")], &[])
.unwrap()
.unwrap();
let rendered = format!("{set:?}");
assert!(
!rendered.contains("s3cret") && rendered.contains("next"),
"{rendered}"
);
}
}