feat(admin): add persisted OIDC config APIs (#2267)

Co-authored-by: heihutu <heihutu@gmail.com>
This commit is contained in:
cxymds
2026-03-24 12:13:41 +08:00
committed by GitHub
parent 24d359a867
commit 75e6902f46
4 changed files with 1277 additions and 11 deletions
+335 -4
View File
@@ -12,13 +12,14 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::config::{Config, GLOBAL_STORAGE_CLASS, storageclass};
use crate::config::{Config, GLOBAL_STORAGE_CLASS, KVS, oidc, storageclass};
use crate::disk::{MIGRATING_META_BUCKET, RUSTFS_META_BUCKET};
use crate::error::{Error, Result};
use crate::global::is_first_cluster_node_local;
use crate::store_api::{ObjectInfo, ObjectOptions, PutObjReader, StorageAPI};
use http::HeaderMap;
use rustfs_config::{DEFAULT_DELIMITER, RUSTFS_REGION};
use rustfs_config::oidc::{IDENTITY_OPENID_KEYS, IDENTITY_OPENID_SUB_SYS, OIDC_REDIRECT_URI_DYNAMIC};
use rustfs_config::{COMMENT_KEY, DEFAULT_DELIMITER, ENABLE_KEY, EnableState, RUSTFS_REGION};
use rustfs_utils::path::SLASH_SEPARATOR;
use serde_json::{Map, Value};
use std::collections::{HashMap, HashSet};
@@ -181,6 +182,85 @@ fn parse_inline_block_value(value: &Value) -> Option<String> {
}
}
fn parse_oidc_scalar_value(key: &str, value: &Value) -> Option<String> {
match value {
Value::String(v) => Some(v.trim().to_string()),
Value::Bool(v) if key == ENABLE_KEY || key == OIDC_REDIRECT_URI_DYNAMIC => Some(if *v {
EnableState::On.to_string()
} else {
EnableState::Off.to_string()
}),
Value::Bool(v) => Some(v.to_string()),
Value::Number(v) => Some(v.to_string()),
Value::Array(values) if key == rustfs_config::oidc::OIDC_SCOPES => {
let scopes = values
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|scope| !scope.is_empty())
.collect::<Vec<_>>()
.join(",");
Some(scopes)
}
Value::Null => None,
_ => None,
}
}
fn decode_oidc_provider_object(provider: &Map<String, Value>) -> KVS {
let mut kvs = oidc::DEFAULT_IDENTITY_OPENID_KVS.clone();
for (key, value) in provider {
if !IDENTITY_OPENID_KEYS.contains(&key.as_str()) || key == COMMENT_KEY {
continue;
}
if let Some(parsed) = parse_oidc_scalar_value(key, value) {
kvs.insert(key.clone(), parsed);
}
}
kvs
}
fn apply_external_oidc_map(cfg: &mut Config, root: &Map<String, Value>) -> bool {
let oidc_root = root.get("openid").or_else(|| root.get(IDENTITY_OPENID_SUB_SYS));
let Some(Value::Object(oidc_obj)) = oidc_root else {
return false;
};
if oidc_obj.is_empty() {
return false;
}
let subsystem = cfg.0.entry(IDENTITY_OPENID_SUB_SYS.to_string()).or_default();
let mut applied = false;
for (raw_instance, provider) in oidc_obj {
let instance_key = if raw_instance == "default" {
DEFAULT_DELIMITER.to_string()
} else {
raw_instance.to_string()
};
match provider {
Value::Object(provider_obj) => {
subsystem.insert(instance_key, decode_oidc_provider_object(provider_obj));
applied = true;
}
Value::Array(_) => {
if let Ok(kvs) = serde_json::from_value::<KVS>(provider.clone()) {
subsystem.insert(instance_key, kvs);
applied = true;
}
}
_ => {}
}
}
applied
}
fn apply_external_storage_class_map(cfg: &mut Config, root: &Map<String, Value>) -> bool {
let sc = root.get("storageclass").or_else(|| root.get("storage_class"));
let Some(Value::Object(sc_obj)) = sc else {
@@ -224,8 +304,9 @@ fn decode_server_config_blob(data: &[u8]) -> Result<Config> {
let mut cfg = Config::new();
let has_storage = apply_external_storage_class_map(&mut cfg, &root);
let has_oidc = apply_external_oidc_map(&mut cfg, &root);
let has_header = root.contains_key("version") || root.contains_key("region") || root.contains_key("credential");
if !has_storage && !has_header {
if !has_storage && !has_oidc && !has_header {
return Err(Error::other("unrecognized external server config shape"));
}
Ok(cfg)
@@ -255,6 +336,119 @@ fn build_storageclass_object(cfg: &Config) -> Map<String, Value> {
sc_obj
}
fn build_oidc_provider_object(kvs: &KVS) -> Map<String, Value> {
let mut provider = Map::new();
for kv in &kvs.0 {
if kv.key == COMMENT_KEY || (kv.hidden_if_empty && kv.value.trim().is_empty()) {
continue;
}
if kv.value.trim().is_empty() {
continue;
}
if kv.key == ENABLE_KEY || kv.key == OIDC_REDIRECT_URI_DYNAMIC {
let enabled = kv
.value
.parse::<EnableState>()
.map(|state| state.is_enabled())
.unwrap_or(false);
provider.insert(kv.key.clone(), Value::Bool(enabled));
continue;
}
if kv.key == rustfs_config::oidc::OIDC_SCOPES {
let scopes = kv
.value
.split(',')
.map(str::trim)
.filter(|scope| !scope.is_empty())
.map(|scope| Value::String(scope.to_string()))
.collect::<Vec<_>>();
provider.insert(kv.key.clone(), Value::Array(scopes));
continue;
}
provider.insert(kv.key.clone(), Value::String(kv.value.clone()));
}
provider
}
fn build_oidc_object(cfg: &Config) -> Map<String, Value> {
let Some(subsystem) = cfg.0.get(IDENTITY_OPENID_SUB_SYS) else {
return Map::new();
};
let mut providers = subsystem.iter().collect::<Vec<_>>();
providers.sort_by(|(lhs, _), (rhs, _)| lhs.cmp(rhs));
let mut oidc_obj = Map::new();
for (instance_key, kvs) in providers {
if kvs
.lookup(rustfs_config::oidc::OIDC_CONFIG_URL)
.unwrap_or_default()
.trim()
.is_empty()
{
continue;
}
let provider = build_oidc_provider_object(kvs);
if provider.is_empty() {
continue;
}
let external_key = if instance_key == DEFAULT_DELIMITER {
"default".to_string()
} else {
instance_key.clone()
};
oidc_obj.insert(external_key, Value::Object(provider));
}
oidc_obj
}
fn build_semantic_oidc_object(cfg: &Config) -> Map<String, Value> {
let Some(subsystem) = cfg.0.get(IDENTITY_OPENID_SUB_SYS) else {
return Map::new();
};
let mut providers = subsystem.iter().collect::<Vec<_>>();
providers.sort_by(|(lhs, _), (rhs, _)| lhs.cmp(rhs));
let mut oidc_obj = Map::new();
for (instance_key, kvs) in providers {
let mut normalized = oidc::DEFAULT_IDENTITY_OPENID_KVS.clone();
normalized.extend(kvs.clone());
if normalized
.lookup(rustfs_config::oidc::OIDC_CONFIG_URL)
.unwrap_or_default()
.trim()
.is_empty()
{
continue;
}
let provider = build_oidc_provider_object(&normalized);
if provider.is_empty() {
continue;
}
let external_key = if instance_key == DEFAULT_DELIMITER {
"default".to_string()
} else {
instance_key.clone()
};
oidc_obj.insert(external_key, Value::Object(provider));
}
oidc_obj
}
fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result<Vec<u8>> {
let mut root = seed.and_then(parse_object_seed).unwrap_or_default();
@@ -275,6 +469,15 @@ fn encode_server_config_blob(cfg: &Config, seed: Option<&[u8]>) -> Result<Vec<u8
root.insert("storageclass".to_string(), Value::Object(sc_obj));
root.remove("storage_class");
let oidc_obj = build_oidc_object(cfg);
if oidc_obj.is_empty() {
root.remove("openid");
root.remove(IDENTITY_OPENID_SUB_SYS);
} else {
root.insert("openid".to_string(), Value::Object(oidc_obj));
root.remove(IDENTITY_OPENID_SUB_SYS);
}
Ok(serde_json::to_vec(&Value::Object(root))?)
}
@@ -292,6 +495,7 @@ fn is_standard_object_server_config(data: &[u8]) -> bool {
fn configs_semantically_equal(lhs: &Config, rhs: &Config) -> bool {
build_storageclass_object(lhs) == build_storageclass_object(rhs)
&& build_semantic_oidc_object(lhs) == build_semantic_oidc_object(rhs)
}
fn is_object_not_found(err: &Error) -> bool {
@@ -508,7 +712,9 @@ mod tests {
configs_semantically_equal, decode_server_config_blob, encode_server_config_blob, is_standard_object_server_config,
storage_class_kvs_mut,
};
use crate::config::Config;
use crate::config::{Config, oidc};
use rustfs_config::oidc::IDENTITY_OPENID_SUB_SYS;
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState};
use serde_json::Value;
#[test]
@@ -550,6 +756,54 @@ mod tests {
assert_eq!(kvs.get("optimize"), "availability");
}
#[test]
fn test_decode_server_config_reads_openid_providers() {
let input = r#"{
"version":"33",
"storageclass":{"standard":"EC:2","rrs":"EC:1"},
"openid":{
"default":{
"enable":true,
"config_url":"https://example.com/.well-known/openid-configuration",
"client_id":"console",
"client_secret":"secret-value",
"scopes":["openid","profile","email"],
"redirect_uri_dynamic":true,
"display_name":"Default Provider"
},
"smoke":{
"enable":false,
"config_url":"https://issuer.example.com/.well-known/openid-configuration",
"client_id":"smoke-client",
"scopes":["openid"],
"redirect_uri_dynamic":false
}
}
}"#;
let cfg = decode_server_config_blob(input.as_bytes()).expect("decode should succeed");
let default_kvs = cfg
.get_value(IDENTITY_OPENID_SUB_SYS, DEFAULT_DELIMITER)
.expect("default oidc provider should exist");
assert_eq!(
default_kvs.get(rustfs_config::oidc::OIDC_CONFIG_URL),
"https://example.com/.well-known/openid-configuration"
);
assert_eq!(default_kvs.get(rustfs_config::oidc::OIDC_CLIENT_ID), "console");
assert_eq!(default_kvs.get(rustfs_config::oidc::OIDC_SCOPES), "openid,profile,email");
assert_eq!(default_kvs.get(ENABLE_KEY), EnableState::On.to_string());
let smoke_kvs = cfg
.get_value(IDENTITY_OPENID_SUB_SYS, "smoke")
.expect("named oidc provider should exist");
assert_eq!(smoke_kvs.get(rustfs_config::oidc::OIDC_CLIENT_ID), "smoke-client");
assert_eq!(
smoke_kvs.get(rustfs_config::oidc::OIDC_REDIRECT_URI_DYNAMIC),
EnableState::Off.to_string()
);
}
#[test]
fn test_encode_server_config_writes_external_object_shape() {
let mut cfg = Config::new();
@@ -564,6 +818,48 @@ mod tests {
assert!(v.get("storage_class").is_none(), "should not write rustfs map shape");
}
#[test]
fn test_encode_server_config_writes_openid_object_shape() {
let mut cfg = Config::new();
let mut oidc_section = std::collections::HashMap::new();
let mut default_provider = oidc::DEFAULT_IDENTITY_OPENID_KVS.clone();
default_provider.insert(ENABLE_KEY.to_string(), EnableState::On.to_string());
default_provider.insert(
rustfs_config::oidc::OIDC_CONFIG_URL.to_string(),
"https://example.com/.well-known/openid-configuration".to_string(),
);
default_provider.insert(rustfs_config::oidc::OIDC_CLIENT_ID.to_string(), "console".to_string());
default_provider.insert(rustfs_config::oidc::OIDC_SCOPES.to_string(), "openid,profile,email".to_string());
oidc_section.insert(DEFAULT_DELIMITER.to_string(), default_provider);
cfg.0.insert(IDENTITY_OPENID_SUB_SYS.to_string(), oidc_section);
let out = encode_server_config_blob(&cfg, None).expect("encode should succeed");
let v: Value = serde_json::from_slice(&out).expect("output should be json");
let openid = v
.get("openid")
.and_then(Value::as_object)
.expect("output should include openid object");
let default_provider = openid
.get("default")
.and_then(Value::as_object)
.expect("default provider should be encoded");
assert_eq!(
default_provider
.get(rustfs_config::oidc::OIDC_CLIENT_ID)
.and_then(Value::as_str),
Some("console")
);
assert_eq!(
default_provider
.get(rustfs_config::oidc::OIDC_SCOPES)
.and_then(Value::as_array)
.map(|values| values.iter().filter_map(Value::as_str).collect::<Vec<_>>()),
Some(vec!["openid", "profile", "email"])
);
assert_eq!(default_provider.get(ENABLE_KEY).and_then(Value::as_bool), Some(true));
}
#[test]
fn test_is_standard_object_server_config_detection() {
let external = br#"{"version":"33","storageclass":{"standard":"EC:2","rrs":"EC:1"}}"#;
@@ -581,4 +877,39 @@ mod tests {
let rhs = decode_server_config_blob(legacy).expect("decode legacy");
assert!(configs_semantically_equal(&lhs, &rhs));
}
#[test]
fn test_configs_semantically_equal_accounts_for_openid() {
let external = br#"{
"version":"33",
"storageclass":{"standard":"EC:2","rrs":"EC:1","optimize":"availability"},
"openid":{
"default":{
"enable":true,
"config_url":"https://example.com/.well-known/openid-configuration",
"client_id":"console",
"scopes":["openid","profile","email"],
"redirect_uri_dynamic":true
}
}
}"#;
let legacy = br#"{
"storage_class":{"_":[
{"key":"standard","value":"EC:2"},
{"key":"rrs","value":"EC:1"},
{"key":"optimize","value":"availability"}
]},
"identity_openid":{"_":[
{"key":"enable","value":"on"},
{"key":"config_url","value":"https://example.com/.well-known/openid-configuration"},
{"key":"client_id","value":"console"},
{"key":"scopes","value":"openid,profile,email"},
{"key":"redirect_uri_dynamic","value":"on"}
]}
}"#;
let lhs = decode_server_config_blob(external).expect("decode external");
let rhs = decode_server_config_blob(legacy).expect("decode legacy");
assert!(configs_semantically_equal(&lhs, &rhs));
}
}
+226 -3
View File
@@ -25,6 +25,8 @@ use openidconnect::{
PkceCodeVerifier, RedirectUrl, Scope,
};
use rustfs_config::oidc::*;
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState};
use rustfs_ecstore::config::{Config as ServerConfig, KVS, get_global_server_config};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::HashMap;
@@ -101,7 +103,7 @@ impl<'c> AsyncHttpClient<'c> for ReqwestHttpClient {
// ---- Public types (unchanged API) ----
/// Parsed configuration for a single OIDC provider.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OidcProviderConfig {
pub id: String,
pub enabled: bool,
@@ -120,6 +122,26 @@ pub struct OidcProviderConfig {
pub username_claim: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OidcProviderConfigSource {
Env,
Persisted,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourcedOidcProviderConfig {
pub config: OidcProviderConfig,
pub source: OidcProviderConfigSource,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OidcProviderValidationResult {
pub issuer: String,
pub authorization_endpoint: String,
pub token_endpoint: Option<String>,
}
/// Summary info about a provider, returned to the console.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcProviderSummary {
@@ -170,11 +192,12 @@ impl OidcSys {
/// Parse environment variables and discover all configured OIDC providers.
pub async fn new() -> Result<Self, String> {
let http_client = ReqwestHttpClient(reqwest::Client::new());
let parsed_configs = Self::parse_env_configs();
let parsed_configs = load_effective_oidc_provider_configs(get_global_server_config().as_ref());
let mut configs = HashMap::new();
let mut provider_states = HashMap::new();
for config in parsed_configs {
for sourced_config in parsed_configs {
let config = sourced_config.config;
if !config.enabled {
info!("OIDC provider '{}' is disabled, skipping", config.id);
continue;
@@ -620,6 +643,33 @@ impl OidcSys {
configs
}
fn parse_persisted_configs(cfg: &ServerConfig) -> Vec<OidcProviderConfig> {
let Some(subsystem) = cfg.0.get(IDENTITY_OPENID_SUB_SYS) else {
return Vec::new();
};
let mut configs = Vec::new();
let mut provider_ids: Vec<String> = subsystem.keys().cloned().collect();
provider_ids.sort();
for raw_id in provider_ids {
let Some(kvs) = subsystem.get(&raw_id) else {
continue;
};
let id = if raw_id == DEFAULT_DELIMITER {
"default"
} else {
raw_id.as_str()
};
if let Some(config) = Self::parse_single_persisted_provider(kvs, id) {
configs.push(config);
}
}
configs
}
/// Parse a single provider's config from env vars with the given suffix.
fn parse_single_provider(env_suffix: &str, id: &str) -> Option<OidcProviderConfig> {
let get_env = |base: &str| -> String { std::env::var(format!("{base}{env_suffix}")).unwrap_or_default() };
@@ -716,6 +766,68 @@ impl OidcSys {
})
}
fn parse_single_persisted_provider(kvs: &KVS, id: &str) -> Option<OidcProviderConfig> {
let config_url = kvs.get(OIDC_CONFIG_URL);
if config_url.is_empty() {
return None;
}
let enabled = kvs
.lookup(ENABLE_KEY)
.unwrap_or_else(|| EnableState::Off.to_string())
.parse::<EnableState>()
.map(|s| s.is_enabled())
.unwrap_or(false);
let scopes_str = kvs.get(OIDC_SCOPES);
let scopes = if scopes_str.is_empty() {
OIDC_DEFAULT_SCOPES.split(',').map(String::from).collect()
} else {
scopes_str.split(',').map(|s| s.trim().to_string()).collect()
};
let redirect_uri_dynamic = kvs
.lookup(OIDC_REDIRECT_URI_DYNAMIC)
.unwrap_or_else(|| EnableState::On.to_string())
.parse::<EnableState>()
.map(|s| s.is_enabled())
.unwrap_or(true);
let claim_name = kvs
.lookup(OIDC_CLAIM_NAME)
.unwrap_or_else(|| OIDC_DEFAULT_CLAIM_NAME.to_string());
let groups_claim = kvs
.lookup(OIDC_GROUPS_CLAIM)
.unwrap_or_else(|| OIDC_DEFAULT_GROUPS_CLAIM.to_string());
let email_claim = kvs
.lookup(OIDC_EMAIL_CLAIM)
.unwrap_or_else(|| OIDC_DEFAULT_EMAIL_CLAIM.to_string());
let username_claim = kvs
.lookup(OIDC_USERNAME_CLAIM)
.unwrap_or_else(|| OIDC_DEFAULT_USERNAME_CLAIM.to_string());
let display_name = kvs.lookup(OIDC_DISPLAY_NAME).unwrap_or_else(|| id.to_string());
let redirect_uri = kvs.lookup(OIDC_REDIRECT_URI).filter(|v| !v.is_empty());
let client_secret = kvs.lookup(OIDC_CLIENT_SECRET).filter(|v| !v.is_empty());
Some(OidcProviderConfig {
id: id.to_string(),
enabled,
config_url,
client_id: kvs.get(OIDC_CLIENT_ID),
client_secret,
scopes,
redirect_uri,
redirect_uri_dynamic,
claim_name,
claim_prefix: kvs.get(OIDC_CLAIM_PREFIX),
role_policy: kvs.get(OIDC_ROLE_POLICY),
display_name,
groups_claim,
email_claim,
username_claim,
})
}
/// Perform OIDC discovery for a provider.
/// `discover_async` fetches the discovery document and JWKS in one step.
async fn discover_provider(config: &OidcProviderConfig, http_client: &ReqwestHttpClient) -> Result<ProviderState, String> {
@@ -736,6 +848,64 @@ impl OidcSys {
}
}
pub fn load_oidc_provider_configs_from_env() -> Vec<OidcProviderConfig> {
OidcSys::parse_env_configs()
}
pub fn load_oidc_provider_configs_from_server_config(cfg: &ServerConfig) -> Vec<OidcProviderConfig> {
OidcSys::parse_persisted_configs(cfg)
}
pub fn merge_oidc_provider_configs(
env_configs: Vec<OidcProviderConfig>,
persisted_configs: Vec<OidcProviderConfig>,
) -> Vec<SourcedOidcProviderConfig> {
let mut effective = HashMap::new();
for config in persisted_configs {
effective.insert(
config.id.clone(),
SourcedOidcProviderConfig {
config,
source: OidcProviderConfigSource::Persisted,
},
);
}
for config in env_configs {
effective.insert(
config.id.clone(),
SourcedOidcProviderConfig {
config,
source: OidcProviderConfigSource::Env,
},
);
}
let mut configs: Vec<SourcedOidcProviderConfig> = effective.into_values().collect();
configs.sort_by(|lhs, rhs| lhs.config.id.cmp(&rhs.config.id));
configs
}
pub fn load_effective_oidc_provider_configs(server_config: Option<&ServerConfig>) -> Vec<SourcedOidcProviderConfig> {
let env_configs = load_oidc_provider_configs_from_env();
let persisted_configs = server_config
.map(load_oidc_provider_configs_from_server_config)
.unwrap_or_default();
merge_oidc_provider_configs(env_configs, persisted_configs)
}
pub async fn validate_oidc_provider_config(config: &OidcProviderConfig) -> Result<OidcProviderValidationResult, String> {
let http_client = ReqwestHttpClient(reqwest::Client::new());
let state = OidcSys::discover_provider(config, &http_client).await?;
Ok(OidcProviderValidationResult {
issuer: state.metadata.issuer().to_string(),
authorization_endpoint: state.metadata.authorization_endpoint().to_string(),
token_endpoint: state.metadata.token_endpoint().map(ToString::to_string),
})
}
// --- Helper functions ---
fn normalize_issuer(raw: &str) -> Option<(String, String, u16, String)> {
@@ -1019,6 +1189,59 @@ mod tests {
assert!(config.is_none());
}
#[test]
fn test_parse_persisted_provider_config() {
let mut cfg = ServerConfig::new();
let mut kvs = KVS(vec![
rustfs_ecstore::config::KV {
key: ENABLE_KEY.to_string(),
value: EnableState::Off.to_string(),
hidden_if_empty: false,
},
rustfs_ecstore::config::KV {
key: OIDC_CONFIG_URL.to_string(),
value: String::new(),
hidden_if_empty: false,
},
rustfs_ecstore::config::KV {
key: OIDC_CLIENT_ID.to_string(),
value: String::new(),
hidden_if_empty: false,
},
]);
kvs.insert(
OIDC_CONFIG_URL.to_string(),
"https://example.com/.well-known/openid-configuration".to_string(),
);
kvs.insert(OIDC_CLIENT_ID.to_string(), "console".to_string());
kvs.insert(ENABLE_KEY.to_string(), EnableState::On.to_string());
cfg.0
.entry(IDENTITY_OPENID_SUB_SYS.to_string())
.or_default()
.insert(DEFAULT_DELIMITER.to_string(), kvs);
let parsed = OidcSys::parse_persisted_configs(&cfg);
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].id, "default");
assert_eq!(parsed[0].client_id, "console");
assert!(parsed[0].enabled);
}
#[test]
fn test_merge_oidc_provider_configs_prefers_env() {
let mut persisted = test_config("default");
persisted.display_name = "Persisted".to_string();
let mut env = test_config("default");
env.display_name = "Environment".to_string();
let merged = merge_oidc_provider_configs(vec![env], vec![persisted]);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].config.display_name, "Environment");
assert_eq!(merged[0].source, OidcProviderConfigSource::Env);
}
#[test]
fn test_oidc_sys_empty() {
let sys = OidcSys::empty();