diff --git a/crates/ecstore/src/config/com.rs b/crates/ecstore/src/config/com.rs index 7a1a10657..488b3e741 100644 --- a/crates/ecstore/src/config/com.rs +++ b/crates/ecstore/src/config/com.rs @@ -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 { } } +fn parse_oidc_scalar_value(key: &str, value: &Value) -> Option { + 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::>() + .join(","); + Some(scopes) + } + Value::Null => None, + _ => None, + } +} + +fn decode_oidc_provider_object(provider: &Map) -> 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) -> 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::(provider.clone()) { + subsystem.insert(instance_key, kvs); + applied = true; + } + } + _ => {} + } + } + + applied +} + fn apply_external_storage_class_map(cfg: &mut Config, root: &Map) -> 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 { 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 { sc_obj } +fn build_oidc_provider_object(kvs: &KVS) -> Map { + 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::() + .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::>(); + 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 { + let Some(subsystem) = cfg.0.get(IDENTITY_OPENID_SUB_SYS) else { + return Map::new(); + }; + + let mut providers = subsystem.iter().collect::>(); + 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 { + let Some(subsystem) = cfg.0.get(IDENTITY_OPENID_SUB_SYS) else { + return Map::new(); + }; + + let mut providers = subsystem.iter().collect::>(); + 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> { 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 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::>()), + 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)); + } } diff --git a/crates/iam/src/oidc.rs b/crates/iam/src/oidc.rs index cd4f48b99..b3d68016a 100644 --- a/crates/iam/src/oidc.rs +++ b/crates/iam/src/oidc.rs @@ -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, +} + /// 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 { 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 { + let Some(subsystem) = cfg.0.get(IDENTITY_OPENID_SUB_SYS) else { + return Vec::new(); + }; + + let mut configs = Vec::new(); + let mut provider_ids: Vec = 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 { 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 { + 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::() + .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::() + .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 { @@ -736,6 +848,64 @@ impl OidcSys { } } +pub fn load_oidc_provider_configs_from_env() -> Vec { + OidcSys::parse_env_configs() +} + +pub fn load_oidc_provider_configs_from_server_config(cfg: &ServerConfig) -> Vec { + OidcSys::parse_persisted_configs(cfg) +} + +pub fn merge_oidc_provider_configs( + env_configs: Vec, + persisted_configs: Vec, +) -> Vec { + 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 = 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 { + 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 { + 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(); diff --git a/rustfs/src/admin/handlers/oidc.rs b/rustfs/src/admin/handlers/oidc.rs index b2fd5bc82..2bd3b1aa0 100644 --- a/rustfs/src/admin/handlers/oidc.rs +++ b/rustfs/src/admin/handlers/oidc.rs @@ -13,17 +13,34 @@ // limitations under the License. use super::sts::create_oidc_sts_credentials; +use crate::admin::auth::validate_admin_request; use crate::admin::router::{AdminOperation, Operation, S3Router}; -use crate::server::ADMIN_PREFIX; +use crate::auth::{check_key_valid, get_session_token}; +use crate::server::{ADMIN_PREFIX, MINIO_ADMIN_PREFIX, RemoteAddr}; use http::StatusCode; use hyper::Method; use matchit::Params; +use rustfs_config::oidc::{ + IDENTITY_OPENID_SUB_SYS, OIDC_CLAIM_NAME, OIDC_CLAIM_PREFIX, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_CONFIG_URL, + OIDC_DEFAULT_CLAIM_NAME, OIDC_DEFAULT_EMAIL_CLAIM, OIDC_DEFAULT_GROUPS_CLAIM, OIDC_DEFAULT_SCOPES, + OIDC_DEFAULT_USERNAME_CLAIM, OIDC_DISPLAY_NAME, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM, OIDC_REDIRECT_URI, + OIDC_REDIRECT_URI_DYNAMIC, OIDC_ROLE_POLICY, OIDC_SCOPES, OIDC_USERNAME_CLAIM, +}; +use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE}; +use rustfs_ecstore::config::com::{read_config_without_migrate, save_server_config}; +use rustfs_ecstore::config::{Config as ServerConfig, get_global_server_config}; +use rustfs_ecstore::new_object_layer_fn; +use rustfs_policy::policy::action::{Action, AdminAction}; use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use tracing::{error, info, warn}; use url::Url; -const OIDC_PATH_PREFIX: &str = "/rustfs/admin/v3/oidc"; +const OIDC_PUBLIC_PROVIDERS_SUFFIX: &str = "/v3/oidc/providers"; +const OIDC_AUTHORIZE_SUFFIX: &str = "/v3/oidc/authorize/"; +const OIDC_CALLBACK_SUFFIX: &str = "/v3/oidc/callback/"; /// Validate that a provider ID contains only safe characters (alphanumeric, underscore, hyphen). fn is_valid_provider_id(id: &str) -> bool { @@ -57,13 +74,164 @@ pub fn register_oidc_route(r: &mut S3Router) -> std::io::Result< &format!("{ADMIN_PREFIX}/v3/oidc/callback/{{provider_id}}"), AdminOperation(&OidcCallbackHandler {}), )?; + r.insert( + Method::GET, + &format!("{ADMIN_PREFIX}/v3/oidc/config"), + AdminOperation(&GetOidcConfigHandler {}), + )?; + r.insert( + Method::PUT, + &format!("{ADMIN_PREFIX}/v3/oidc/config/{{provider_id}}"), + AdminOperation(&PutOidcConfigHandler {}), + )?; + r.insert( + Method::DELETE, + &format!("{ADMIN_PREFIX}/v3/oidc/config/{{provider_id}}"), + AdminOperation(&DeleteOidcConfigHandler {}), + )?; + r.insert( + Method::POST, + &format!("{ADMIN_PREFIX}/v3/oidc/validate"), + AdminOperation(&ValidateOidcConfigHandler {}), + )?; Ok(()) } /// Returns true if the given path is an OIDC endpoint (requires unauthenticated access). pub fn is_oidc_path(path: &str) -> bool { - path.starts_with(OIDC_PATH_PREFIX) + let public_prefixes = [ADMIN_PREFIX, MINIO_ADMIN_PREFIX]; + + public_prefixes.iter().any(|prefix| { + path == format!("{prefix}{OIDC_PUBLIC_PROVIDERS_SUFFIX}") + || path.starts_with(&format!("{prefix}{OIDC_AUTHORIZE_SUFFIX}")) + || path.starts_with(&format!("{prefix}{OIDC_CALLBACK_SUFFIX}")) + }) +} + +#[derive(Debug, Serialize)] +struct OidcConfigListResponse { + providers: Vec, + restart_required: bool, +} + +#[derive(Debug, Serialize)] +struct OidcConfigView { + provider_id: String, + source: rustfs_iam::oidc::OidcProviderConfigSource, + editable: bool, + enabled: bool, + display_name: String, + config_url: String, + client_id: String, + client_secret_configured: bool, + scopes: Vec, + redirect_uri: Option, + redirect_uri_dynamic: bool, + claim_name: String, + claim_prefix: String, + role_policy: String, + groups_claim: String, + email_claim: String, + username_claim: String, +} + +#[derive(Debug, Serialize)] +struct OidcMutationResponse { + success: bool, + message: String, + restart_required: bool, +} + +#[derive(Debug, Serialize)] +struct OidcValidationResponse { + valid: bool, + message: String, + issuer: Option, + authorization_endpoint: Option, + token_endpoint: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +struct OidcConfigUpsertRequest { + enabled: bool, + display_name: String, + config_url: String, + client_id: String, + client_secret: Option, + scopes: Vec, + redirect_uri: Option, + redirect_uri_dynamic: bool, + claim_name: String, + claim_prefix: String, + role_policy: String, + groups_claim: String, + email_claim: String, + username_claim: String, +} + +impl Default for OidcConfigUpsertRequest { + fn default() -> Self { + Self { + enabled: true, + display_name: String::new(), + config_url: String::new(), + client_id: String::new(), + client_secret: None, + scopes: OIDC_DEFAULT_SCOPES.split(',').map(ToString::to_string).collect(), + redirect_uri: None, + redirect_uri_dynamic: true, + claim_name: OIDC_DEFAULT_CLAIM_NAME.to_string(), + claim_prefix: String::new(), + role_policy: String::new(), + groups_claim: OIDC_DEFAULT_GROUPS_CLAIM.to_string(), + email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(), + username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(), + } + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +struct OidcConfigValidateRequest { + provider_id: String, + enabled: bool, + display_name: String, + config_url: String, + client_id: String, + client_secret: Option, + scopes: Vec, + redirect_uri: Option, + redirect_uri_dynamic: bool, + claim_name: String, + claim_prefix: String, + role_policy: String, + groups_claim: String, + email_claim: String, + username_claim: String, +} + +impl Default for OidcConfigValidateRequest { + fn default() -> Self { + Self { + provider_id: "default".to_string(), + enabled: true, + display_name: String::new(), + config_url: String::new(), + client_id: String::new(), + client_secret: None, + scopes: OIDC_DEFAULT_SCOPES.split(',').map(ToString::to_string).collect(), + redirect_uri: None, + redirect_uri_dynamic: true, + claim_name: OIDC_DEFAULT_CLAIM_NAME.to_string(), + claim_prefix: String::new(), + role_policy: String::new(), + groups_claim: OIDC_DEFAULT_GROUPS_CLAIM.to_string(), + email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(), + username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(), + } + } } /// Handler: GET /rustfs/admin/v3/oidc/providers @@ -86,6 +254,146 @@ impl Operation for ListOidcProvidersHandler { } } +pub struct GetOidcConfigHandler {} + +#[async_trait::async_trait] +impl Operation for GetOidcConfigHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + authorize_oidc_config_request(&req, AdminAction::ServerInfoAdminAction).await?; + + let config = load_server_config_from_store().await?; + let restart_required = oidc_restart_required(&config); + let providers = rustfs_iam::oidc::load_effective_oidc_provider_configs(Some(&config)) + .into_iter() + .map(|provider| OidcConfigView { + provider_id: provider.config.id.clone(), + source: provider.source, + editable: provider.source != rustfs_iam::oidc::OidcProviderConfigSource::Env, + enabled: provider.config.enabled, + display_name: provider.config.display_name.clone(), + config_url: provider.config.config_url.clone(), + client_id: provider.config.client_id.clone(), + client_secret_configured: provider.config.client_secret.is_some(), + scopes: provider.config.scopes.clone(), + redirect_uri: provider.config.redirect_uri.clone(), + redirect_uri_dynamic: provider.config.redirect_uri_dynamic, + claim_name: provider.config.claim_name.clone(), + claim_prefix: provider.config.claim_prefix.clone(), + role_policy: provider.config.role_policy.clone(), + groups_claim: provider.config.groups_claim.clone(), + email_claim: provider.config.email_claim.clone(), + username_claim: provider.config.username_claim.clone(), + }) + .collect(); + + json_response( + StatusCode::OK, + &OidcConfigListResponse { + providers, + restart_required, + }, + ) + } +} + +pub struct PutOidcConfigHandler {} + +#[async_trait::async_trait] +impl Operation for PutOidcConfigHandler { + async fn call(&self, mut req: S3Request, params: Params<'_, '_>) -> S3Result> { + authorize_oidc_config_request(&req, AdminAction::ConfigUpdateAdminAction).await?; + + let provider_id = params + .get("provider_id") + .ok_or_else(|| s3_error!(InvalidRequest, "missing provider_id"))?; + if !is_valid_provider_id(provider_id) { + return Err(s3_error!(InvalidRequest, "invalid provider_id")); + } + if is_env_managed_provider(provider_id) { + return Err(s3_error!(AccessDenied, "provider is managed by environment variables")); + } + + let request: OidcConfigUpsertRequest = parse_json_body(&mut req).await?; + let mut config = load_server_config_from_store().await?; + let existing_secret = persisted_provider_secret(&config, provider_id); + let provider_config = build_provider_config_from_upsert(provider_id, request, existing_secret)?; + upsert_persisted_provider_config(&mut config, &provider_config); + save_server_config_to_store(&config).await?; + + json_response( + StatusCode::OK, + &OidcMutationResponse { + success: true, + message: "OIDC provider saved".to_string(), + restart_required: true, + }, + ) + } +} + +pub struct DeleteOidcConfigHandler {} + +#[async_trait::async_trait] +impl Operation for DeleteOidcConfigHandler { + async fn call(&self, req: S3Request, params: Params<'_, '_>) -> S3Result> { + authorize_oidc_config_request(&req, AdminAction::ConfigUpdateAdminAction).await?; + + let provider_id = params + .get("provider_id") + .ok_or_else(|| s3_error!(InvalidRequest, "missing provider_id"))?; + if !is_valid_provider_id(provider_id) { + return Err(s3_error!(InvalidRequest, "invalid provider_id")); + } + if is_env_managed_provider(provider_id) { + return Err(s3_error!(AccessDenied, "provider is managed by environment variables")); + } + + let mut config = load_server_config_from_store().await?; + delete_persisted_provider_config(&mut config, provider_id)?; + save_server_config_to_store(&config).await?; + + json_response( + StatusCode::OK, + &OidcMutationResponse { + success: true, + message: "OIDC provider deleted".to_string(), + restart_required: true, + }, + ) + } +} + +pub struct ValidateOidcConfigHandler {} + +#[async_trait::async_trait] +impl Operation for ValidateOidcConfigHandler { + async fn call(&self, mut req: S3Request, _params: Params<'_, '_>) -> S3Result> { + authorize_oidc_config_request(&req, AdminAction::ServerInfoAdminAction).await?; + + let request: OidcConfigValidateRequest = parse_json_body(&mut req).await?; + let provider_id = if request.provider_id.trim().is_empty() { + "default".to_string() + } else { + request.provider_id.trim().to_string() + }; + let provider_config = build_provider_config_from_validate(request, &provider_id)?; + let validation = rustfs_iam::oidc::validate_oidc_provider_config(&provider_config) + .await + .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("validation failed: {e}")))?; + + json_response( + StatusCode::OK, + &OidcValidationResponse { + valid: true, + message: "OIDC configuration is valid".to_string(), + issuer: Some(validation.issuer), + authorization_endpoint: Some(validation.authorization_endpoint), + token_endpoint: validation.token_endpoint, + }, + ) + } +} + /// Handler: GET /rustfs/admin/v3/oidc/authorize/:provider_id /// Generates PKCE challenge, stores state, and returns 302 redirect to IdP. pub struct OidcAuthorizeHandler {} @@ -299,6 +607,326 @@ fn build_console_redirect( Ok(format!("{scheme}://{host}{console_prefix}/auth/oidc-callback/#{fragment}")) } +async fn authorize_oidc_config_request(req: &S3Request, action: AdminAction) -> S3Result<()> { + let Some(input_cred) = &req.credentials else { + return Err(s3_error!(InvalidRequest, "authentication required")); + }; + + let (cred, owner) = + check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &input_cred.access_key).await?; + + validate_admin_request( + &req.headers, + &cred, + owner, + false, + vec![Action::AdminAction(action)], + req.extensions.get::>().and_then(|opt| opt.map(|a| a.0)), + ) + .await +} + +async fn parse_json_body(req: &mut S3Request) -> S3Result { + let body = req + .input + .store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE) + .await + .map_err(|e| s3_error!(InvalidRequest, "failed to read request body: {}", e))?; + + if body.is_empty() { + return Err(s3_error!(InvalidRequest, "request body is required")); + } + + serde_json::from_slice(&body).map_err(|e| s3_error!(InvalidRequest, "invalid JSON: {}", e)) +} + +fn json_response(status: StatusCode, payload: &T) -> S3Result> { + let body = serde_json::to_vec(payload) + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize error: {e}")))?; + + let mut resp = S3Response::new((status, Body::from(body))); + resp.headers + .insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("application/json")); + Ok(resp) +} + +async fn load_server_config_from_store() -> S3Result { + let Some(store) = new_object_layer_fn() else { + return Err(s3_error!(InternalError, "storage layer not initialized")); + }; + + read_config_without_migrate(store) + .await + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to load server config: {e}"))) +} + +async fn save_server_config_to_store(config: &ServerConfig) -> S3Result<()> { + let Some(store) = new_object_layer_fn() else { + return Err(s3_error!(InternalError, "storage layer not initialized")); + }; + + save_server_config(store, config) + .await + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to save server config: {e}"))) +} + +fn is_env_managed_provider(provider_id: &str) -> bool { + rustfs_iam::oidc::load_oidc_provider_configs_from_env() + .iter() + .any(|config| config.id == provider_id) +} + +fn provider_instance_key(provider_id: &str) -> String { + if provider_id == "default" { + DEFAULT_DELIMITER.to_string() + } else { + provider_id.to_string() + } +} + +fn oidc_restart_required(config: &ServerConfig) -> bool { + let active_config = get_global_server_config(); + oidc_restart_required_from_active_config(config, active_config.as_ref()) +} + +fn oidc_restart_required_from_active_config(config: &ServerConfig, active_config: Option<&ServerConfig>) -> bool { + rustfs_iam::oidc::load_effective_oidc_provider_configs(Some(config)) + != rustfs_iam::oidc::load_effective_oidc_provider_configs(active_config) +} + +fn default_oidc_kvs() -> s3s::S3Result { + ServerConfig::new() + .get_value(IDENTITY_OPENID_SUB_SYS, DEFAULT_DELIMITER) + .ok_or_else(|| s3_error!(InternalError, "default OIDC configuration missing")) +} + +fn set_kvs_value(kvs: &mut rustfs_ecstore::config::KVS, key: &str, value: String) { + if let Some(existing) = kvs.0.iter_mut().find(|kv| kv.key == key) { + existing.value = value; + return; + } + + kvs.insert(key.to_string(), value); +} + +fn normalize_scopes(scopes: &[String]) -> Vec { + scopes + .iter() + .map(|scope| scope.trim().to_string()) + .filter(|scope| !scope.is_empty()) + .collect() +} + +fn normalize_optional(value: Option) -> Option { + value.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) +} + +fn validate_absolute_http_url(value: &str, field_name: &str) -> S3Result<()> { + let parsed = Url::parse(value).map_err(|_| s3_error!(InvalidRequest, "{} must be an absolute http/https URL", field_name))?; + + if !is_valid_scheme(parsed.scheme()) || parsed.host_str().is_none() { + return Err(s3_error!(InvalidRequest, "{} must be an absolute http/https URL", field_name)); + } + + Ok(()) +} + +fn validate_provider_config_fields(config: &rustfs_iam::oidc::OidcProviderConfig) -> S3Result<()> { + if !is_valid_provider_id(&config.id) { + return Err(s3_error!(InvalidRequest, "invalid provider_id")); + } + if config.config_url.trim().is_empty() { + return Err(s3_error!(InvalidRequest, "config_url is required")); + } + validate_absolute_http_url(&config.config_url, "config_url")?; + + if config.client_id.trim().is_empty() { + return Err(s3_error!(InvalidRequest, "client_id is required")); + } + + if !config.redirect_uri_dynamic { + let redirect_uri = config + .redirect_uri + .as_deref() + .ok_or_else(|| s3_error!(InvalidRequest, "redirect_uri is required when redirect_uri_dynamic is off"))?; + validate_absolute_http_url(redirect_uri, "redirect_uri")?; + } else if let Some(redirect_uri) = config.redirect_uri.as_deref() { + validate_absolute_http_url(redirect_uri, "redirect_uri")?; + } + + if !config.scopes.iter().any(|scope| scope == "openid") { + return Err(s3_error!(InvalidRequest, "scopes must include openid")); + } + + Ok(()) +} + +fn build_provider_config_from_upsert( + provider_id: &str, + request: OidcConfigUpsertRequest, + existing_secret: Option, +) -> S3Result { + let scopes = normalize_scopes(&request.scopes); + let client_secret = match request.client_secret { + Some(value) if !value.trim().is_empty() => Some(value), + _ => existing_secret.filter(|value| !value.trim().is_empty()), + }; + + let config = rustfs_iam::oidc::OidcProviderConfig { + id: provider_id.to_string(), + enabled: request.enabled, + config_url: request.config_url.trim().to_string(), + client_id: request.client_id.trim().to_string(), + client_secret, + scopes, + redirect_uri: normalize_optional(request.redirect_uri), + redirect_uri_dynamic: request.redirect_uri_dynamic, + claim_name: if request.claim_name.trim().is_empty() { + OIDC_DEFAULT_CLAIM_NAME.to_string() + } else { + request.claim_name.trim().to_string() + }, + claim_prefix: request.claim_prefix.trim().to_string(), + role_policy: request.role_policy.trim().to_string(), + display_name: if request.display_name.trim().is_empty() { + provider_id.to_string() + } else { + request.display_name.trim().to_string() + }, + groups_claim: if request.groups_claim.trim().is_empty() { + OIDC_DEFAULT_GROUPS_CLAIM.to_string() + } else { + request.groups_claim.trim().to_string() + }, + email_claim: if request.email_claim.trim().is_empty() { + OIDC_DEFAULT_EMAIL_CLAIM.to_string() + } else { + request.email_claim.trim().to_string() + }, + username_claim: if request.username_claim.trim().is_empty() { + OIDC_DEFAULT_USERNAME_CLAIM.to_string() + } else { + request.username_claim.trim().to_string() + }, + }; + + validate_provider_config_fields(&config)?; + Ok(config) +} + +fn build_provider_config_from_validate( + request: OidcConfigValidateRequest, + provider_id: &str, +) -> S3Result { + let config = rustfs_iam::oidc::OidcProviderConfig { + id: provider_id.to_string(), + enabled: request.enabled, + config_url: request.config_url.trim().to_string(), + client_id: request.client_id.trim().to_string(), + client_secret: request.client_secret.filter(|value| !value.trim().is_empty()), + scopes: normalize_scopes(&request.scopes), + redirect_uri: normalize_optional(request.redirect_uri), + redirect_uri_dynamic: request.redirect_uri_dynamic, + claim_name: if request.claim_name.trim().is_empty() { + OIDC_DEFAULT_CLAIM_NAME.to_string() + } else { + request.claim_name.trim().to_string() + }, + claim_prefix: request.claim_prefix.trim().to_string(), + role_policy: request.role_policy.trim().to_string(), + display_name: if request.display_name.trim().is_empty() { + provider_id.to_string() + } else { + request.display_name.trim().to_string() + }, + groups_claim: if request.groups_claim.trim().is_empty() { + OIDC_DEFAULT_GROUPS_CLAIM.to_string() + } else { + request.groups_claim.trim().to_string() + }, + email_claim: if request.email_claim.trim().is_empty() { + OIDC_DEFAULT_EMAIL_CLAIM.to_string() + } else { + request.email_claim.trim().to_string() + }, + username_claim: if request.username_claim.trim().is_empty() { + OIDC_DEFAULT_USERNAME_CLAIM.to_string() + } else { + request.username_claim.trim().to_string() + }, + }; + + validate_provider_config_fields(&config)?; + Ok(config) +} + +fn persisted_provider_secret(config: &ServerConfig, provider_id: &str) -> Option { + config + .0 + .get(IDENTITY_OPENID_SUB_SYS) + .and_then(|subsystem| subsystem.get(&provider_instance_key(provider_id))) + .and_then(|kvs| kvs.lookup(OIDC_CLIENT_SECRET)) + .filter(|value| !value.trim().is_empty()) +} + +fn upsert_persisted_provider_config(config: &mut ServerConfig, provider_config: &rustfs_iam::oidc::OidcProviderConfig) { + let instance_key = provider_instance_key(&provider_config.id); + let mut kvs = default_oidc_kvs().unwrap_or_default(); + + set_kvs_value( + &mut kvs, + ENABLE_KEY, + if provider_config.enabled { + EnableState::On.to_string() + } else { + EnableState::Off.to_string() + }, + ); + set_kvs_value(&mut kvs, OIDC_CONFIG_URL, provider_config.config_url.clone()); + set_kvs_value(&mut kvs, OIDC_CLIENT_ID, provider_config.client_id.clone()); + set_kvs_value(&mut kvs, OIDC_CLIENT_SECRET, provider_config.client_secret.clone().unwrap_or_default()); + set_kvs_value(&mut kvs, OIDC_SCOPES, provider_config.scopes.join(",")); + set_kvs_value(&mut kvs, OIDC_REDIRECT_URI, provider_config.redirect_uri.clone().unwrap_or_default()); + set_kvs_value( + &mut kvs, + OIDC_REDIRECT_URI_DYNAMIC, + if provider_config.redirect_uri_dynamic { + EnableState::On.to_string() + } else { + EnableState::Off.to_string() + }, + ); + set_kvs_value(&mut kvs, OIDC_CLAIM_NAME, provider_config.claim_name.clone()); + set_kvs_value(&mut kvs, OIDC_CLAIM_PREFIX, provider_config.claim_prefix.clone()); + set_kvs_value(&mut kvs, OIDC_ROLE_POLICY, provider_config.role_policy.clone()); + set_kvs_value(&mut kvs, OIDC_DISPLAY_NAME, provider_config.display_name.clone()); + set_kvs_value(&mut kvs, OIDC_GROUPS_CLAIM, provider_config.groups_claim.clone()); + set_kvs_value(&mut kvs, OIDC_EMAIL_CLAIM, provider_config.email_claim.clone()); + set_kvs_value(&mut kvs, OIDC_USERNAME_CLAIM, provider_config.username_claim.clone()); + + config + .0 + .entry(IDENTITY_OPENID_SUB_SYS.to_string()) + .or_default() + .insert(instance_key, kvs); +} + +fn delete_persisted_provider_config(config: &mut ServerConfig, provider_id: &str) -> S3Result<()> { + let Some(subsystem) = config.0.get_mut(IDENTITY_OPENID_SUB_SYS) else { + return Err(s3_error!(InvalidRequest, "provider not found")); + }; + + if subsystem.remove(&provider_instance_key(provider_id)).is_none() { + return Err(s3_error!(InvalidRequest, "provider not found")); + } + + if subsystem.is_empty() { + config.0.remove(IDENTITY_OPENID_SUB_SYS); + } + + Ok(()) +} + fn extract_request_scheme(req: &S3Request) -> S3Result { let raw_scheme = req .headers @@ -364,6 +992,13 @@ mod tests { assert!(is_oidc_path("/rustfs/admin/v3/oidc/providers")); assert!(is_oidc_path("/rustfs/admin/v3/oidc/authorize/okta")); assert!(is_oidc_path("/rustfs/admin/v3/oidc/callback/okta")); + assert!(is_oidc_path("/minio/admin/v3/oidc/providers")); + assert!(is_oidc_path("/minio/admin/v3/oidc/authorize/okta")); + assert!(is_oidc_path("/minio/admin/v3/oidc/callback/okta")); + assert!(!is_oidc_path("/rustfs/admin/v3/oidc/config")); + assert!(!is_oidc_path("/rustfs/admin/v3/oidc/config/default")); + assert!(!is_oidc_path("/rustfs/admin/v3/oidc/validate")); + assert!(!is_oidc_path("/minio/admin/v3/oidc/config")); assert!(!is_oidc_path("/rustfs/admin/v3/users")); assert!(!is_oidc_path("/health")); } @@ -459,4 +1094,65 @@ mod tests { assert!(!is_valid_scheme("javascript")); assert!(!is_valid_scheme("")); } + + #[test] + fn test_provider_instance_key() { + assert_eq!(provider_instance_key("default"), "_"); + assert_eq!(provider_instance_key("okta"), "okta"); + } + + #[test] + fn test_build_provider_config_requires_openid_scope() { + let req = OidcConfigUpsertRequest { + scopes: vec!["profile".to_string()], + config_url: "https://example.com/.well-known/openid-configuration".to_string(), + client_id: "client-id".to_string(), + ..Default::default() + }; + + assert!(build_provider_config_from_upsert("default", req, None).is_err()); + } + + #[test] + fn test_build_provider_config_preserves_existing_secret_when_request_is_empty() { + let req = OidcConfigUpsertRequest { + config_url: "https://example.com/.well-known/openid-configuration".to_string(), + client_id: "client-id".to_string(), + client_secret: Some("".to_string()), + ..Default::default() + }; + + let config = + build_provider_config_from_upsert("default", req, Some("existing-secret".to_string())).expect("config should build"); + + assert_eq!(config.client_secret.as_deref(), Some("existing-secret")); + } + + #[test] + fn test_oidc_restart_required_detects_persisted_changes() { + let active_config = ServerConfig::new(); + let mut persisted_config = ServerConfig::new(); + let provider_config = rustfs_iam::oidc::OidcProviderConfig { + id: "default".to_string(), + enabled: true, + config_url: "https://example.com/.well-known/openid-configuration".to_string(), + client_id: "console".to_string(), + client_secret: Some("secret".to_string()), + scopes: vec!["openid".to_string(), "profile".to_string()], + redirect_uri: None, + redirect_uri_dynamic: true, + claim_name: OIDC_DEFAULT_CLAIM_NAME.to_string(), + claim_prefix: String::new(), + role_policy: String::new(), + display_name: "default".to_string(), + groups_claim: OIDC_DEFAULT_GROUPS_CLAIM.to_string(), + email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(), + username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(), + }; + + upsert_persisted_provider_config(&mut persisted_config, &provider_config); + + assert!(oidc_restart_required_from_active_config(&persisted_config, Some(&active_config))); + assert!(!oidc_restart_required_from_active_config(&persisted_config, Some(&persisted_config))); + } } diff --git a/rustfs/src/admin/route_registration_test.rs b/rustfs/src/admin/route_registration_test.rs index fe904eede..c6821e5ac 100644 --- a/rustfs/src/admin/route_registration_test.rs +++ b/rustfs/src/admin/route_registration_test.rs @@ -13,7 +13,9 @@ // limitations under the License. use crate::admin::{ - handlers::{bucket_meta, heal, health, kms, pools, profile_admin, quota, rebalance, replication, sts, system, tier, user}, + handlers::{ + bucket_meta, heal, health, kms, oidc, pools, profile_admin, quota, rebalance, replication, sts, system, tier, user, + }, router::{AdminOperation, S3Router}, }; use crate::server::{ADMIN_PREFIX, HEALTH_PREFIX, HEALTH_READY_PATH, MINIO_ADMIN_PREFIX, PROFILE_CPU_PATH, PROFILE_MEMORY_PATH}; @@ -53,6 +55,7 @@ fn test_register_routes_cover_representative_admin_paths() { replication::register_replication_route(&mut router).expect("register replication route"); profile_admin::register_profiling_route(&mut router).expect("register profile route"); kms::register_kms_route(&mut router).expect("register kms route"); + oidc::register_oidc_route(&mut router).expect("register oidc route"); assert_route(&router, Method::GET, HEALTH_PREFIX); assert_route(&router, Method::HEAD, HEALTH_PREFIX); assert_route(&router, Method::GET, HEALTH_READY_PATH); @@ -114,6 +117,13 @@ fn test_register_routes_cover_representative_admin_paths() { assert_route(&router, Method::POST, &admin_path("/v3/kms/keys")); assert_route(&router, Method::GET, &admin_path("/v3/kms/keys")); assert_route(&router, Method::GET, &admin_path("/v3/kms/keys/test-key")); + assert_route(&router, Method::GET, &admin_path("/v3/oidc/providers")); + assert_route(&router, Method::GET, &admin_path("/v3/oidc/config")); + assert_route(&router, Method::PUT, &admin_path("/v3/oidc/config/default")); + assert_route(&router, Method::DELETE, &admin_path("/v3/oidc/config/default")); + assert_route(&router, Method::POST, &admin_path("/v3/oidc/validate")); + assert_route(&router, Method::GET, &admin_path("/v3/oidc/authorize/default")); + assert_route(&router, Method::GET, &admin_path("/v3/oidc/callback/default")); assert!( !router.contains_route(Method::GET, "/rustfs/rpc/read_file_stream"), @@ -132,6 +142,7 @@ fn test_admin_alias_paths_match_existing_admin_routes() { pools::register_pool_route(&mut router).expect("register pool route"); rebalance::register_rebalance_route(&mut router).expect("register rebalance route"); quota::register_quota_route(&mut router).expect("register quota route"); + oidc::register_oidc_route(&mut router).expect("register oidc route"); for (method, path) in [ (Method::GET, compat_admin_alias_path("/v3/is-admin")), @@ -149,6 +160,11 @@ fn test_admin_alias_paths_match_existing_admin_routes() { (Method::POST, compat_admin_alias_path("/v3/idp/builtin/policy/detach")), (Method::GET, compat_admin_alias_path("/v3/idp/builtin/policy-entities")), (Method::POST, compat_admin_alias_path("/v3/rebalance/start")), + (Method::GET, compat_admin_alias_path("/v3/oidc/providers")), + (Method::GET, compat_admin_alias_path("/v3/oidc/authorize/default")), + (Method::GET, compat_admin_alias_path("/v3/oidc/callback/default")), + (Method::GET, compat_admin_alias_path("/v3/oidc/config")), + (Method::PUT, compat_admin_alias_path("/v3/oidc/config/default")), ] { assert!( router.contains_compatible_route(method.clone(), &path),