use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::TeamsError;
use crate::policy::{DEFAULT_CLOCK_SKEW_SECS, MAX_ATTEMPTS, MAX_KEY_AGE_SECS, RetryRule};
pub const SECTION: &str = "teams";
pub const ENV_PREFIX: &str = "AUTUMN_TEAMS__";
pub const MAX_INVOKE_TIMEOUT_MS: u64 = 4_500;
pub const MAX_BODY_BYTES: usize = 4 * 1024 * 1024;
pub const MAX_WAIT_MS: u64 = 60_000;
pub const MULTI_TENANT: &str = "botframework.com";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct TeamsConfig {
pub app_id_env: String,
pub app_password_env: String,
pub tenant_id_env: String,
pub login_base_url: String,
pub oauth_scope: String,
pub openid_metadata_url: String,
pub issuers: Vec<String>,
pub clock_skew_secs: u64,
pub service_url_hosts: Vec<String>,
pub allowed_channels: Vec<String>,
pub allowed_tenants: Vec<String>,
pub invoke_timeout_ms: u64,
pub max_body_bytes: usize,
pub dedup_window_secs: u64,
pub drain_timeout_secs: u64,
pub error_text: String,
pub keys: KeysConfig,
pub api: ApiConfig,
pub health: HealthConfig,
}
impl Default for TeamsConfig {
fn default() -> Self {
Self {
app_id_env: "TEAMS_APP_ID".to_owned(),
app_password_env: "TEAMS_APP_PASSWORD".to_owned(),
tenant_id_env: "TEAMS_TENANT_ID".to_owned(),
login_base_url: "https://login.microsoftonline.com/".to_owned(),
oauth_scope: "https://api.botframework.com/.default".to_owned(),
openid_metadata_url:
"https://login.botframework.com/v1/.well-known/openidconfiguration".to_owned(),
issuers: vec!["https://api.botframework.com".to_owned()],
clock_skew_secs: DEFAULT_CLOCK_SKEW_SECS,
service_url_hosts: vec!["smba.trafficmanager.net".to_owned()],
allowed_channels: vec!["msteams".to_owned()],
allowed_tenants: Vec::new(),
invoke_timeout_ms: 4_000,
max_body_bytes: 1_048_576,
dedup_window_secs: 3_600,
drain_timeout_secs: 10,
error_text: "Sorry, that did not work. Try again later.".to_owned(),
keys: KeysConfig::default(),
api: ApiConfig::default(),
health: HealthConfig::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct KeysConfig {
pub cache_secs: u64,
pub min_refresh_secs: u64,
}
impl Default for KeysConfig {
fn default() -> Self {
Self {
cache_secs: MAX_KEY_AGE_SECS,
min_refresh_secs: 60,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct ApiConfig {
pub max_attempts: u32,
pub initial_backoff_ms: u64,
pub max_wait_ms: u64,
pub timeout_ms: u64,
}
impl Default for ApiConfig {
fn default() -> Self {
Self {
max_attempts: 3,
initial_backoff_ms: 500,
max_wait_ms: 30_000,
timeout_ms: 10_000,
}
}
}
impl ApiConfig {
#[must_use]
pub const fn rule(&self) -> RetryRule {
RetryRule {
max_attempts: self.max_attempts,
initial_backoff_ms: self.initial_backoff_ms,
max_wait_ms: self.max_wait_ms,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
#[non_exhaustive]
pub struct HealthConfig {
pub cache_secs: u64,
}
impl Default for HealthConfig {
fn default() -> Self {
Self { cache_secs: 30 }
}
}
impl TeamsConfig {
pub fn from_toml_str(autumn_toml: &str) -> Result<Self, TeamsError> {
Self::from_layers(Some(autumn_toml), None, std::iter::empty())
}
fn from_layers(
base: Option<&str>,
profile_file: Option<&str>,
env: impl IntoIterator<Item = (String, String)>,
) -> Result<Self, TeamsError> {
let parse = |text: &str| -> Result<toml::Table, TeamsError> {
toml::from_str(text).map_err(|e| TeamsError::Config(format!("toml: {e}")))
};
let mut table = toml::Table::new();
for text in [base, profile_file].into_iter().flatten() {
if let Some(toml::Value::Table(section)) = parse(text)?.get(SECTION) {
merge(&mut table, section.clone());
}
}
let schema = toml::Table::try_from(Self::default())
.map_err(|e| TeamsError::Config(e.to_string()))?;
for (key, value) in env {
let Some(path) = key.strip_prefix(ENV_PREFIX) else {
continue;
};
let path: Vec<String> = path.split("__").map(str::to_ascii_lowercase).collect();
let typed = typed_env_value(&schema, &path, &value)
.ok_or_else(|| TeamsError::Config(format!("{key}: value is not valid")))?;
insert_path(&mut table, &path, typed);
}
let cfg: Self = toml::Value::Table(table)
.try_into()
.map_err(|e| TeamsError::Config(e.to_string()))?;
cfg.validate()?;
Ok(cfg)
}
pub fn load_from_dir(
manifest_dir: &Path,
profile_names: &[String],
env: impl IntoIterator<Item = (String, String)>,
) -> Result<Self, TeamsError> {
let find = |file: &str| {
let candidate = manifest_dir.join(file);
if candidate.exists() {
candidate
} else {
PathBuf::from(file)
}
};
let base = read_optional(&find("autumn.toml"))?;
let mut profile = None;
for name in profile_names {
if let Some(text) = read_optional(&find(&format!("autumn-{name}.toml")))? {
profile = Some(text);
break;
}
}
Self::from_layers(base.as_deref(), profile.as_deref(), env)
}
pub fn load(profile: Option<&str>) -> Result<Self, TeamsError> {
use autumn_web::config::Env as _;
let os = autumn_web::config::OsEnv;
let names = profile
.map(|p| {
let selector = ["AUTUMN_ENV", "AUTUMN_PROFILE"]
.iter()
.find_map(|k| os.var(k).ok().filter(|v| !v.trim().is_empty()))
.or_else(profile_flag)
.map_or_else(|| p.to_owned(), |v| v.trim().to_owned());
autumn_web::config::profile_override_file_lookup_names(p, &selector)
})
.unwrap_or_default();
let dir = os
.var("AUTUMN_MANIFEST_DIR")
.map_or_else(|_| PathBuf::from("."), PathBuf::from);
Self::load_from_dir(&dir, &names, process_env()?)
}
pub fn validate(&self) -> Result<(), TeamsError> {
let bad = |m: String| Err(TeamsError::Config(m));
for (name, value) in [
("app_id_env", &self.app_id_env),
("app_password_env", &self.app_password_env),
("tenant_id_env", &self.tenant_id_env),
("oauth_scope", &self.oauth_scope),
("error_text", &self.error_text),
] {
if value.trim().is_empty() {
return bad(format!("{name} must not be empty"));
}
}
if !valid_url(&self.login_base_url) || !self.login_base_url.ends_with('/') {
return bad(format!(
"login_base_url must be an https URL that ends with / (http only on loopback): {}",
self.login_base_url
));
}
if !valid_url(&self.openid_metadata_url) {
return bad(format!(
"openid_metadata_url must be an https URL (http only on loopback): {}",
self.openid_metadata_url
));
}
for (name, list) in [
("issuers", &self.issuers),
("allowed_channels", &self.allowed_channels),
] {
if list.is_empty() || list.iter().any(|v| v.trim().is_empty()) {
return bad(format!("{name} must list one or more values"));
}
}
if self.allowed_tenants.iter().any(|v| v.trim().is_empty()) {
return bad("allowed_tenants must not hold an empty value".to_owned());
}
if self.service_url_hosts.is_empty()
|| self
.service_url_hosts
.iter()
.any(|h| h.trim().is_empty() || h.contains(['/', ':', '@']))
{
return bad("service_url_hosts must list one or more host names".to_owned());
}
if self.clock_skew_secs > 600 {
return bad(format!(
"clock_skew_secs must be 0 to 600, not {}",
self.clock_skew_secs
));
}
if !(1..=MAX_INVOKE_TIMEOUT_MS).contains(&self.invoke_timeout_ms) {
return bad(format!(
"invoke_timeout_ms must be 1 to {MAX_INVOKE_TIMEOUT_MS}, not {}",
self.invoke_timeout_ms
));
}
if !(1..=MAX_BODY_BYTES).contains(&self.max_body_bytes) {
return bad(format!(
"max_body_bytes must be 1 to {MAX_BODY_BYTES}, not {}",
self.max_body_bytes
));
}
if self.dedup_window_secs == 0 {
return bad("dedup_window_secs must be 1 or more".to_owned());
}
for (name, value) in [
("api.max_wait_ms", self.api.max_wait_ms),
("api.initial_backoff_ms", self.api.initial_backoff_ms),
] {
if value > MAX_WAIT_MS {
return bad(format!("{name} must be {MAX_WAIT_MS} or less, not {value}"));
}
}
if !(60..=MAX_KEY_AGE_SECS).contains(&self.keys.cache_secs) {
return bad(format!(
"keys.cache_secs must be 60 to {MAX_KEY_AGE_SECS}, not {}",
self.keys.cache_secs
));
}
if !(1..=3_600).contains(&self.keys.min_refresh_secs) {
return bad(format!(
"keys.min_refresh_secs must be 1 to 3600, not {}",
self.keys.min_refresh_secs
));
}
if !(1..=MAX_ATTEMPTS).contains(&self.api.max_attempts) {
return bad(format!(
"api.max_attempts must be 1 to {MAX_ATTEMPTS}, not {}",
self.api.max_attempts
));
}
if self.api.timeout_ms == 0 {
return bad("api.timeout_ms must be 1 or more".to_owned());
}
Ok(())
}
pub(crate) fn token_url(&self, tenant_id: Option<&str>) -> String {
format!(
"{}{}/oauth2/v2.0/token",
self.login_base_url,
tenant_id.unwrap_or(MULTI_TENANT)
)
}
}
pub(crate) fn env_var(name: &str) -> Option<String> {
std::env::var(name).ok().or_else(|| {
autumn_web::dotenv::resolve_process_dotenv()
.ok()?
.into_iter()
.find_map(|(k, v)| (k == name).then_some(v))
})
}
fn process_env() -> Result<Vec<(String, String)>, TeamsError> {
let mut vars = autumn_web::dotenv::resolve_process_dotenv()
.map_err(|e| TeamsError::Config(format!(".env: {e}")))?;
vars.extend(
std::env::vars_os()
.filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))),
);
Ok(vars)
}
fn profile_flag() -> Option<String> {
let args: Vec<String> = std::env::args_os()
.filter_map(|a| a.into_string().ok())
.collect();
args.iter()
.enumerate()
.find_map(|(i, a)| {
a.strip_prefix("--profile=").map(str::to_owned).or_else(|| {
(a == "--profile")
.then(|| args.get(i + 1).cloned())
.flatten()
})
})
.filter(|p| !p.trim().is_empty())
}
pub(crate) fn valid_url(url: &str) -> bool {
let Ok(u) = url::Url::parse(url) else {
return false;
};
u.scheme() == "https" || (u.scheme() == "http" && is_loopback(u.host().as_ref()))
}
pub(crate) const fn is_loopback(host: Option<&url::Host<&str>>) -> bool {
match host {
Some(url::Host::Domain(d)) => d.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
}
}
fn read_optional(path: &Path) -> Result<Option<String>, TeamsError> {
match std::fs::read_to_string(path) {
Ok(text) => Ok(Some(text)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(TeamsError::Config(format!("{}: {e}", path.display()))),
}
}
fn merge(into: &mut toml::Table, from: toml::Table) {
for (key, value) in from {
match (into.get_mut(&key), value) {
(Some(toml::Value::Table(dst)), toml::Value::Table(src)) => merge(dst, src),
(_, value) => {
into.insert(key, value);
}
}
}
}
fn insert_path(table: &mut toml::Table, path: &[String], value: toml::Value) {
let Some((last, parents)) = path.split_last() else {
return;
};
let mut cur = table;
for key in parents {
let entry = cur
.entry(key.clone())
.or_insert_with(|| toml::Value::Table(toml::Table::new()));
if !entry.is_table() {
*entry = toml::Value::Table(toml::Table::new());
}
let toml::Value::Table(next) = entry else {
return;
};
cur = next;
}
cur.insert(last.clone(), value);
}
fn typed_env_value(schema: &toml::Table, path: &[String], raw: &str) -> Option<toml::Value> {
let mut node: Option<&toml::Value> = None;
let mut table = Some(schema);
for key in path {
node = table.and_then(|t| t.get(key));
table = node.and_then(toml::Value::as_table);
}
match node {
Some(toml::Value::Integer(_)) => raw.trim().parse().ok().map(toml::Value::Integer),
Some(toml::Value::Boolean(_)) => raw.trim().parse().ok().map(toml::Value::Boolean),
Some(toml::Value::Array(_)) => Some(toml::Value::Array(
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| toml::Value::String(s.to_owned()))
.collect(),
)),
_ => Some(toml::Value::String(raw.to_owned())),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn env(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect()
}
#[test]
fn defaults_are_valid() {
TeamsConfig::default().validate().unwrap();
assert_eq!(TeamsConfig::default().keys.min_refresh_secs, 60);
assert_eq!(
TeamsConfig::from_toml_str("").unwrap(),
TeamsConfig::default()
);
}
#[test]
fn reads_section_and_rejects_unknown_keys() {
let c = TeamsConfig::from_toml_str(
"[teams]\ninvoke_timeout_ms = 900\nallowed_tenants = [\"t1\"]\n[teams.api]\nmax_attempts = 1\n",
)
.unwrap();
assert_eq!(c.invoke_timeout_ms, 900);
assert_eq!(c.allowed_tenants, ["t1"]);
assert_eq!(c.api.max_attempts, 1);
assert!(TeamsConfig::from_toml_str("[teams]\ninvoke_timeout = 9\n").is_err());
assert!(TeamsConfig::from_toml_str("[teams\n").is_err());
TeamsConfig::from_toml_str("[server]\nport = 1\n").unwrap();
}
#[test]
fn env_overlay_types_values() {
let c = TeamsConfig::from_layers(
None,
None,
env(&[
("AUTUMN_TEAMS__INVOKE_TIMEOUT_MS", "100"),
("AUTUMN_TEAMS__APP_ID_ENV", "MY_ID"),
("AUTUMN_TEAMS__ALLOWED_TENANTS", "a, b"),
("AUTUMN_TEAMS__KEYS__MIN_REFRESH_SECS", "5"),
("OTHER", "x"),
]),
)
.unwrap();
assert_eq!(c.invoke_timeout_ms, 100);
assert_eq!(c.app_id_env, "MY_ID");
assert_eq!(c.allowed_tenants, ["a", "b"]);
assert_eq!(c.keys.min_refresh_secs, 5);
let e = TeamsConfig::from_layers(
None,
None,
env(&[("AUTUMN_TEAMS__CLOCK_SKEW_SECS", "soon")]),
)
.unwrap_err();
assert!(
e.to_string().contains("AUTUMN_TEAMS__CLOCK_SKEW_SECS"),
"{e}"
);
assert!(!e.to_string().contains("soon"), "{e}");
}
#[test]
fn validation_ranges() {
let check = |f: fn(&mut TeamsConfig)| {
let mut c = TeamsConfig::default();
f(&mut c);
c.validate().unwrap_err().to_string()
};
assert!(check(|c| c.invoke_timeout_ms = 0).contains("invoke_timeout_ms"));
assert!(check(|c| c.invoke_timeout_ms = 4_501).contains("invoke_timeout_ms"));
assert!(check(|c| c.clock_skew_secs = 601).contains("clock_skew_secs"));
assert!(check(|c| c.keys.cache_secs = 59).contains("keys.cache_secs"));
assert!(check(|c| c.keys.cache_secs = MAX_KEY_AGE_SECS + 1).contains("keys.cache_secs"));
assert!(check(|c| c.keys.min_refresh_secs = 0).contains("keys.min_refresh_secs"));
assert!(check(|c| c.keys.min_refresh_secs = 3_601).contains("keys.min_refresh_secs"));
assert!(check(|c| c.api.max_attempts = 0).contains("max_attempts"));
assert!(check(|c| c.api.max_attempts = MAX_ATTEMPTS + 1).contains("max_attempts"));
assert!(check(|c| c.api.timeout_ms = 0).contains("timeout_ms"));
assert!(check(|c| c.login_base_url = "ftp://x/".into()).contains("login_base_url"));
assert!(
check(|c| c.login_base_url = "https://login.example".into()).contains("login_base_url")
);
assert!(
check(|c| c.openid_metadata_url = "http://x.example/m".into())
.contains("openid_metadata_url")
);
assert!(check(|c| c.issuers.clear()).contains("issuers"));
assert!(check(|c| c.issuers = vec![" ".into()]).contains("issuers"));
assert!(check(|c| c.service_url_hosts.clear()).contains("service_url_hosts"));
assert!(check(|c| c.service_url_hosts = vec!["a/b".into()]).contains("service_url_hosts"));
assert!(check(|c| c.allowed_channels.clear()).contains("allowed_channels"));
assert!(check(|c| c.allowed_tenants = vec![String::new()]).contains("allowed_tenants"));
assert!(check(|c| c.max_body_bytes = 0).contains("max_body_bytes"));
assert!(check(|c| c.max_body_bytes = 4_194_305).contains("max_body_bytes"));
assert!(check(|c| c.dedup_window_secs = 0).contains("dedup_window_secs"));
assert!(check(|c| c.api.max_wait_ms = 60_001).contains("api.max_wait_ms"));
assert!(check(|c| c.api.initial_backoff_ms = 60_001).contains("api.initial_backoff_ms"));
assert!(check(|c| c.app_id_env.clear()).contains("app_id_env"));
assert!(check(|c| c.app_password_env.clear()).contains("app_password_env"));
assert!(check(|c| c.tenant_id_env.clear()).contains("tenant_id_env"));
assert!(check(|c| c.oauth_scope.clear()).contains("oauth_scope"));
assert!(check(|c| c.error_text.clear()).contains("error_text"));
}
#[test]
fn inline_profile_section_is_not_read() {
let base = "[teams]\ninvoke_timeout_ms = 1\n[profile.prod.teams]\ninvoke_timeout_ms = 3\n";
let c = TeamsConfig::from_toml_str(base).unwrap();
assert_eq!(c.invoke_timeout_ms, 1);
}
#[test]
fn url_rules() {
for ok in [
"https://login.microsoftonline.com/",
"http://127.0.0.1:9/x/",
"http://localhost/x",
"http://[::1]:8/x/",
] {
assert!(valid_url(ok), "{ok}");
}
for bad in [
"http://login.example/",
"http://10.0.0.1/",
"ftp://x/",
"nope",
] {
assert!(!valid_url(bad), "{bad}");
}
}
}