use std::io;
use crate::config::{ConfigError, KeyNaming, OAuthConfig, 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}: secret file is empty")]
#[non_exhaustive]
EmptyFile {
var: String,
path: String,
},
}
pub fn secret_from_env(var: &str) -> Result<Option<String>, EnvError> {
secret_from_lookup(
var,
|v| std::env::var(v).ok(),
|path| std::fs::read_to_string(path),
)
}
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> {
let file_var = format!("{var}_FILE");
let direct = lookup(var).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(v.trim().to_string())),
(None, Some(path)) => {
let raw = read_file(&path).map_err(|source| EnvError::ReadFailed {
var: var.to_string(),
path: path.clone(),
source,
})?;
let value = raw.trim().to_string();
if value.is_empty() {
return Err(EnvError::EmptyFile {
var: var.to_string(),
path,
});
}
Ok(Some(value))
}
(None, None) => Ok(None),
}
}
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) -> String {
format!(
"{} {value:?} must be \"true\" or \"false\"",
naming.key(field)
)
}
fn env_problem(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 take(result: Result<Option<String>, EnvError>, problems: &mut Vec<String>) -> 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(),
|path| std::fs::read_to_string(path),
)
}
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, PartialEq, Eq)]
#[non_exhaustive]
pub struct EnvOAuthConfig {
pub config: OAuthConfig,
pub problems: Vec<String>,
prefix: String,
}
impl EnvOAuthConfig {
pub fn prefix(&self) -> &str {
&self.prefix
}
pub fn resolve(self) -> Result<Option<ResolvedOAuthConfig>, ConfigError> {
let Self {
config,
mut problems,
prefix,
} = self;
let naming = KeyNaming::Env(&prefix);
match config.resolve(naming) {
Ok(resolved) if problems.is_empty() => Ok(resolved),
Ok(_) => Err(ConfigError::new(naming, problems)),
Err(resolve_err) => {
problems.extend(resolve_err.problems);
Err(ConfigError::new(naming, problems))
}
}
}
}
pub fn unresolved_oauth_config_from_env(prefix: &str) -> Option<EnvOAuthConfig> {
unresolved_oauth_config_from_lookup(
prefix,
|v| std::env::var(v).ok(),
|path| std::fs::read_to_string(path),
)
}
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<String> = 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 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(format!(
"{} {v:?} is not a valid non-negative integer",
naming.key("leeway_secs")
)),
}
}
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,
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 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)
);
}
}