From 2d2679a7106b84fb433c636d1f45b8754dbe47f0 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Sun, 11 Oct 2026 23:53:29 +0800 Subject: [PATCH] refactor(oidc): isolate config updates and replication view (#8446) --- crates/iam/src/oidc/config.rs | 443 ++++++++++- crates/iam/src/oidc/config_tests.rs | 146 ++++ crates/iam/src/oidc/mod.rs | 8 +- crates/iam/src/oidc/runtime.rs | 8 +- rustfs/src/admin/handlers/oidc.rs | 701 ++---------------- rustfs/src/admin/handlers/site_replication.rs | 38 +- rustfs/src/admin/service/mod.rs | 1 + rustfs/src/admin/service/oidc_config.rs | 158 ++++ rustfs/src/site_replication/tests.rs | 5 - rustfs/src/site_replication/transport.rs | 10 - 10 files changed, 816 insertions(+), 702 deletions(-) create mode 100644 rustfs/src/admin/service/oidc_config.rs diff --git a/crates/iam/src/oidc/config.rs b/crates/iam/src/oidc/config.rs index 6169ea60b..deceb35e1 100644 --- a/crates/iam/src/oidc/config.rs +++ b/crates/iam/src/oidc/config.rs @@ -11,12 +11,16 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +use base64_simd::URL_SAFE_NO_PAD; use rustfs_config::oidc::*; use rustfs_config::server_config::{Config as ServerConfig, KVS}; use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState}; +use rustfs_utils::egress::{OutboundUrlError, validate_outbound_url}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::fmt; +use url::Url; pub(super) const REDACTED_SECRET: &str = "***redacted***"; @@ -48,6 +52,162 @@ pub struct OidcProviderConfig { pub hide_from_ui: bool, } +#[derive(Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct OidcProviderConfigInput { + pub enabled: bool, + pub display_name: String, + pub config_url: String, + pub issuer: Option, + pub client_id: String, + pub client_secret: Option, + pub scopes: Vec, + pub other_audiences: Vec, + pub redirect_uri: Option, + pub redirect_uri_dynamic: bool, + pub claim_name: String, + pub claim_prefix: String, + pub role_policy: String, + pub groups_claim: String, + pub roles_claim: String, + pub email_claim: String, + pub username_claim: String, + pub hide_from_ui: bool, +} + +impl Default for OidcProviderConfigInput { + fn default() -> Self { + Self { + enabled: true, + display_name: String::new(), + config_url: String::new(), + issuer: None, + client_id: String::new(), + client_secret: None, + scopes: OIDC_DEFAULT_SCOPES.split(',').map(ToString::to_string).collect(), + other_audiences: Vec::new(), + 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(), + roles_claim: OIDC_DEFAULT_ROLES_CLAIM.to_string(), + email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(), + username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(), + hide_from_ui: false, + } + } +} + +#[derive(Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct OidcProviderValidationInput { + pub provider_id: String, + pub enabled: bool, + pub display_name: String, + pub config_url: String, + pub issuer: Option, + pub client_id: String, + pub client_secret: Option, + pub scopes: Vec, + pub other_audiences: Vec, + pub redirect_uri: Option, + pub redirect_uri_dynamic: bool, + pub claim_name: String, + pub claim_prefix: String, + pub role_policy: String, + pub groups_claim: String, + pub roles_claim: String, + pub email_claim: String, + pub username_claim: String, + pub hide_from_ui: bool, +} + +impl Default for OidcProviderValidationInput { + fn default() -> Self { + let input = OidcProviderConfigInput::default(); + Self { + provider_id: "default".to_string(), + enabled: input.enabled, + display_name: input.display_name, + config_url: input.config_url, + issuer: input.issuer, + client_id: input.client_id, + client_secret: input.client_secret, + scopes: input.scopes, + other_audiences: input.other_audiences, + redirect_uri: input.redirect_uri, + redirect_uri_dynamic: input.redirect_uri_dynamic, + claim_name: input.claim_name, + claim_prefix: input.claim_prefix, + role_policy: input.role_policy, + groups_claim: input.groups_claim, + roles_claim: input.roles_claim, + email_claim: input.email_claim, + username_claim: input.username_claim, + hide_from_ui: input.hide_from_ui, + } + } +} + +impl From for (String, OidcProviderConfigInput) { + fn from(input: OidcProviderValidationInput) -> Self { + let provider_id = if input.provider_id.trim().is_empty() { + "default".to_string() + } else { + input.provider_id.trim().to_string() + }; + let config = OidcProviderConfigInput { + enabled: input.enabled, + display_name: input.display_name, + config_url: input.config_url, + issuer: input.issuer, + client_id: input.client_id, + client_secret: input.client_secret, + scopes: input.scopes, + other_audiences: input.other_audiences, + redirect_uri: input.redirect_uri, + redirect_uri_dynamic: input.redirect_uri_dynamic, + claim_name: input.claim_name, + claim_prefix: input.claim_prefix, + role_policy: input.role_policy, + groups_claim: input.groups_claim, + roles_claim: input.roles_claim, + email_claim: input.email_claim, + username_claim: input.username_claim, + hide_from_ui: input.hide_from_ui, + }; + (provider_id, config) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum OidcConfigError { + #[error("invalid provider_id")] + InvalidProviderId, + #[error("provider is managed by environment variables")] + EnvironmentManaged, + #[error("provider not found")] + ProviderNotFound, + #[error("config_url is required")] + ConfigUrlRequired, + #[error("client_id is required")] + ClientIdRequired, + #[error("redirect_uri is required when redirect_uri_dynamic is off")] + RedirectUriRequired, + #[error("scopes must include openid")] + OpenidScopeRequired, + #[error("{0} must be an absolute http/https URL")] + InvalidAbsoluteUrl(&'static str), + #[error("{field} is not allowed: {source}")] + ForbiddenOutbound { + field: &'static str, + #[source] + source: OutboundUrlError, + }, +} + impl fmt::Debug for OidcProviderConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("OidcProviderConfig") @@ -122,12 +282,285 @@ pub struct OidcProviderValidationResult { /// Compatibility name for the provider summary returned by OIDC list APIs. pub(crate) type OidcProviderSummary = crate::federation::FederatedProviderView; -/// Read-only access to the active standard OIDC configuration. -/// -/// This interface stays separate from authentication and generic provider -/// views because its snapshots include OIDC-specific fields and secrets. +/// OIDC fields required to compare identity providers across replicated sites. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OidcSiteReplicationProvider { + pub provider_id: String, + pub claim_name: String, + pub role_policy: String, + pub client_id: String, + pub hashed_client_secret: String, +} + +/// Active OIDC settings exposed to site replication without raw credentials. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OidcSiteReplicationSnapshot { + providers: Vec, +} + +impl OidcSiteReplicationSnapshot { + pub fn from_config_snapshot(snapshot: &OidcConfigSnapshot) -> Self { + let providers = snapshot + .providers() + .iter() + .map(|provider| { + let config = &provider.config; + OidcSiteReplicationProvider { + provider_id: config.id.clone(), + claim_name: config.claim_name.clone(), + role_policy: config.role_policy.clone(), + client_id: config.client_id.clone(), + hashed_client_secret: hash_client_secret(config.client_secret.as_deref()), + } + }) + .collect(); + Self { providers } + } + + pub fn providers(&self) -> &[OidcSiteReplicationProvider] { + &self.providers + } +} + +fn hash_client_secret(secret: Option<&str>) -> String { + let Some(secret) = secret.filter(|secret| !secret.is_empty()) else { + return String::new(); + }; + + let mut hasher = Sha256::new(); + hasher.update(secret.as_bytes()); + URL_SAFE_NO_PAD.encode_to_string(hasher.finalize()) +} + +/// Read-only access to the active OIDC settings needed by site replication. pub trait OidcConfigQuery: Send + Sync { - fn config_snapshot(&self) -> OidcConfigSnapshot; + fn site_replication_snapshot(&self) -> OidcSiteReplicationSnapshot; +} + +pub fn validate_mutable_provider_id(provider_id: &str) -> Result<(), OidcConfigError> { + if !is_valid_provider_id(provider_id) { + return Err(OidcConfigError::InvalidProviderId); + } + if load_oidc_provider_configs_from_env() + .iter() + .any(|config| config.id == provider_id) + { + return Err(OidcConfigError::EnvironmentManaged); + } + Ok(()) +} + +fn is_valid_provider_id(id: &str) -> bool { + !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +fn provider_instance_key(provider_id: &str) -> String { + if provider_id == "default" { + DEFAULT_DELIMITER.to_string() + } else { + provider_id.to_string() + } +} + +fn normalize_optional(value: Option) -> Option { + value.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) +} + +fn or_default(value: &str, default: &str) -> String { + if value.trim().is_empty() { + default.to_string() + } else { + value.trim().to_string() + } +} + +fn validate_absolute_http_url(value: &str, field: &'static str, check_outbound: bool) -> Result<(), OidcConfigError> { + let parsed = Url::parse(value).map_err(|_| OidcConfigError::InvalidAbsoluteUrl(field))?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { + return Err(OidcConfigError::InvalidAbsoluteUrl(field)); + } + if check_outbound { + validate_outbound_url(&parsed).map_err(|source| OidcConfigError::ForbiddenOutbound { field, source })?; + } + Ok(()) +} + +fn normalize_provider_config(mut config: OidcProviderConfig) -> OidcProviderConfig { + config.config_url = config.config_url.trim().to_string(); + config.issuer = normalize_optional(config.issuer); + config.client_id = config.client_id.trim().to_string(); + config.scopes = config + .scopes + .iter() + .map(|scope| scope.trim().to_string()) + .filter(|scope| !scope.is_empty()) + .collect(); + config.redirect_uri = normalize_optional(config.redirect_uri); + config.claim_name = or_default(&config.claim_name, OIDC_DEFAULT_CLAIM_NAME); + config.claim_prefix = config.claim_prefix.trim().to_string(); + config.role_policy = config.role_policy.trim().to_string(); + config.display_name = or_default(&config.display_name, &config.id); + config.groups_claim = or_default(&config.groups_claim, OIDC_DEFAULT_GROUPS_CLAIM); + config.roles_claim = or_default(&config.roles_claim, OIDC_DEFAULT_ROLES_CLAIM); + config.email_claim = or_default(&config.email_claim, OIDC_DEFAULT_EMAIL_CLAIM); + config.username_claim = or_default(&config.username_claim, OIDC_DEFAULT_USERNAME_CLAIM); + config +} + +fn validate_provider_config_fields(config: &OidcProviderConfig) -> Result<(), OidcConfigError> { + if !is_valid_provider_id(&config.id) { + return Err(OidcConfigError::InvalidProviderId); + } + if config.config_url.trim().is_empty() { + return Err(OidcConfigError::ConfigUrlRequired); + } + validate_absolute_http_url(&config.config_url, "config_url", true)?; + if let Some(issuer) = config.issuer.as_deref() { + validate_absolute_http_url(issuer, "issuer", false)?; + } + if config.client_id.trim().is_empty() { + return Err(OidcConfigError::ClientIdRequired); + } + if !config.redirect_uri_dynamic { + let redirect_uri = config.redirect_uri.as_deref().ok_or(OidcConfigError::RedirectUriRequired)?; + validate_absolute_http_url(redirect_uri, "redirect_uri", true)?; + } else if let Some(redirect_uri) = config.redirect_uri.as_deref() { + validate_absolute_http_url(redirect_uri, "redirect_uri", true)?; + } + if !config.scopes.iter().any(|scope| scope == "openid") { + return Err(OidcConfigError::OpenidScopeRequired); + } + Ok(()) +} + +fn build_provider_config( + provider_id: &str, + input: OidcProviderConfigInput, + existing_secret: Option, +) -> Result { + let client_secret = match input.client_secret { + Some(value) if !value.trim().is_empty() => Some(value), + _ => existing_secret.filter(|value| !value.trim().is_empty()), + }; + let config = normalize_provider_config(OidcProviderConfig { + id: provider_id.to_string(), + enabled: input.enabled, + config_url: input.config_url, + issuer: input.issuer, + client_id: input.client_id, + client_secret, + scopes: input.scopes, + other_audiences: input.other_audiences, + redirect_uri: input.redirect_uri, + redirect_uri_dynamic: input.redirect_uri_dynamic, + claim_name: input.claim_name, + claim_prefix: input.claim_prefix, + role_policy: input.role_policy, + display_name: input.display_name, + groups_claim: input.groups_claim, + roles_claim: input.roles_claim, + email_claim: input.email_claim, + username_claim: input.username_claim, + hide_from_ui: input.hide_from_ui, + }); + validate_provider_config_fields(&config)?; + Ok(config) +} + +pub fn build_upsert_provider_config( + provider_id: &str, + input: OidcProviderConfigInput, + existing_secret: Option, +) -> Result { + build_provider_config(provider_id, input, existing_secret) +} + +pub fn build_validation_provider_config(input: OidcProviderValidationInput) -> Result { + let (provider_id, input) = input.into(); + build_provider_config(&provider_id, input, None) +} + +pub 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 set_kvs_value(kvs: &mut 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); +} + +pub fn upsert_persisted_provider_config(config: &mut ServerConfig, provider: &OidcProviderConfig) { + let mut kvs = ServerConfig::new() + .get_value(IDENTITY_OPENID_SUB_SYS, DEFAULT_DELIMITER) + .unwrap_or_default(); + set_kvs_value( + &mut kvs, + ENABLE_KEY, + if provider.enabled { + EnableState::On.to_string() + } else { + EnableState::Off.to_string() + }, + ); + set_kvs_value(&mut kvs, OIDC_CONFIG_URL, provider.config_url.clone()); + set_kvs_value(&mut kvs, OIDC_ISSUER, provider.issuer.clone().unwrap_or_default()); + set_kvs_value(&mut kvs, OIDC_CLIENT_ID, provider.client_id.clone()); + set_kvs_value(&mut kvs, OIDC_CLIENT_SECRET, provider.client_secret.clone().unwrap_or_default()); + set_kvs_value(&mut kvs, OIDC_SCOPES, provider.scopes.join(",")); + set_kvs_value(&mut kvs, OIDC_OTHER_AUDIENCES, provider.other_audiences.join(",")); + set_kvs_value(&mut kvs, OIDC_REDIRECT_URI, provider.redirect_uri.clone().unwrap_or_default()); + set_kvs_value( + &mut kvs, + OIDC_REDIRECT_URI_DYNAMIC, + if provider.redirect_uri_dynamic { + EnableState::On.to_string() + } else { + EnableState::Off.to_string() + }, + ); + set_kvs_value(&mut kvs, OIDC_CLAIM_NAME, provider.claim_name.clone()); + set_kvs_value(&mut kvs, OIDC_CLAIM_PREFIX, provider.claim_prefix.clone()); + set_kvs_value(&mut kvs, OIDC_ROLE_POLICY, provider.role_policy.clone()); + set_kvs_value(&mut kvs, OIDC_DISPLAY_NAME, provider.display_name.clone()); + set_kvs_value(&mut kvs, OIDC_GROUPS_CLAIM, provider.groups_claim.clone()); + set_kvs_value(&mut kvs, OIDC_ROLES_CLAIM, provider.roles_claim.clone()); + set_kvs_value(&mut kvs, OIDC_EMAIL_CLAIM, provider.email_claim.clone()); + set_kvs_value(&mut kvs, OIDC_USERNAME_CLAIM, provider.username_claim.clone()); + set_kvs_value( + &mut kvs, + OIDC_HIDE_FROM_UI, + if provider.hide_from_ui { + EnableState::On.to_string() + } else { + EnableState::Off.to_string() + }, + ); + config + .0 + .entry(IDENTITY_OPENID_SUB_SYS.to_string()) + .or_default() + .insert(provider_instance_key(&provider.id), kvs); +} + +pub fn delete_persisted_provider_config(config: &mut ServerConfig, provider_id: &str) -> Result<(), OidcConfigError> { + let Some(subsystem) = config.0.get_mut(IDENTITY_OPENID_SUB_SYS) else { + return Err(OidcConfigError::ProviderNotFound); + }; + if subsystem.remove(&provider_instance_key(provider_id)).is_none() { + return Err(OidcConfigError::ProviderNotFound); + } + if subsystem.is_empty() { + config.0.remove(IDENTITY_OPENID_SUB_SYS); + } + Ok(()) } /// Parse all OIDC provider configs from environment variables. diff --git a/crates/iam/src/oidc/config_tests.rs b/crates/iam/src/oidc/config_tests.rs index 70936545a..7f79c7a96 100644 --- a/crates/iam/src/oidc/config_tests.rs +++ b/crates/iam/src/oidc/config_tests.rs @@ -305,3 +305,149 @@ fn test_oidc_provider_config_defaults() { assert_eq!(config.scopes.len(), 3); assert!(config.redirect_uri_dynamic); } + +#[test] +fn config_input_preserves_secret_and_defaults() { + let input = OidcProviderConfigInput { + config_url: " https://example.com/.well-known/openid-configuration ".to_string(), + client_id: " client ".to_string(), + client_secret: Some(String::new()), + scopes: vec![" openid ".to_string(), " profile ".to_string()], + roles_claim: "app_roles".to_string(), + ..Default::default() + }; + let config = build_upsert_provider_config("default", input, Some("existing-secret".to_string())) + .expect("valid config should preserve existing secret"); + + assert_eq!(config.client_secret.as_deref(), Some("existing-secret")); + assert_eq!(config.config_url, "https://example.com/.well-known/openid-configuration"); + assert_eq!(config.client_id, "client"); + assert_eq!(config.scopes, ["openid", "profile"]); + assert_eq!(config.roles_claim, "app_roles"); + assert_eq!(config.claim_name, OIDC_DEFAULT_CLAIM_NAME); + + let raw_secret = build_upsert_provider_config( + "default", + OidcProviderConfigInput { + config_url: config.config_url, + client_id: config.client_id, + client_secret: Some(" raw secret ".to_string()), + ..Default::default() + }, + None, + ) + .expect("a nonempty client secret should be accepted"); + assert_eq!(raw_secret.client_secret.as_deref(), Some(" raw secret ")); +} + +#[test] +fn config_input_rejects_invalid_fields() { + let input = OidcProviderConfigInput { + config_url: "https://example.com/.well-known/openid-configuration".to_string(), + client_id: "client".to_string(), + scopes: vec!["profile".to_string()], + ..Default::default() + }; + assert!(matches!( + build_upsert_provider_config("default", input, None), + Err(OidcConfigError::OpenidScopeRequired) + )); + + let input = OidcProviderConfigInput { + config_url: "https://127.0.0.1/.well-known/openid-configuration".to_string(), + client_id: "client".to_string(), + ..Default::default() + }; + assert!(matches!( + build_upsert_provider_config("default", input, None), + Err(OidcConfigError::ForbiddenOutbound { field: "config_url", .. }) + )); + + assert!(serde_json::from_str::(r#"{"unexpected_field":true}"#).is_err()); + assert!(serde_json::from_str::(r#"{"unexpected_field":true}"#).is_err()); + assert!(matches!( + validate_mutable_provider_id("../escape"), + Err(OidcConfigError::InvalidProviderId) + )); +} + +#[test] +fn validation_input_defaults_to_default_provider() { + let input = OidcProviderValidationInput { + provider_id: " ".to_string(), + config_url: "https://example.com/.well-known/openid-configuration".to_string(), + client_id: "client".to_string(), + ..Default::default() + }; + let config = build_validation_provider_config(input).expect("validation input should use default provider"); + assert_eq!(config.id, "default"); +} + +#[test] +fn persisted_config_codec_keeps_instance_keys_and_fields() { + let mut config = ServerConfig::new(); + let mut provider = build_upsert_provider_config( + "default", + OidcProviderConfigInput { + config_url: "https://example.com/.well-known/openid-configuration".to_string(), + issuer: Some("https://issuer.example.com".to_string()), + client_id: "client".to_string(), + client_secret: Some("secret".to_string()), + hide_from_ui: true, + ..Default::default() + }, + None, + ) + .expect("provider should be valid"); + upsert_persisted_provider_config(&mut config, &provider); + + let default = config + .0 + .get(IDENTITY_OPENID_SUB_SYS) + .and_then(|subsystem| subsystem.get(DEFAULT_DELIMITER)) + .expect("default provider KVS should exist"); + assert_eq!(default.get(OIDC_ISSUER), "https://issuer.example.com"); + assert_eq!(default.get(OIDC_HIDE_FROM_UI), EnableState::On.to_string()); + assert_eq!(persisted_provider_secret(&config, "default").as_deref(), Some("secret")); + + provider.hide_from_ui = false; + upsert_persisted_provider_config(&mut config, &provider); + let default = config + .0 + .get(IDENTITY_OPENID_SUB_SYS) + .and_then(|subsystem| subsystem.get(DEFAULT_DELIMITER)) + .expect("updated default provider KVS should exist"); + assert_eq!(default.get(OIDC_HIDE_FROM_UI), EnableState::Off.to_string()); + + delete_persisted_provider_config(&mut config, "default").expect("default provider should be deleted"); + assert!(matches!( + delete_persisted_provider_config(&mut config, "default"), + Err(OidcConfigError::ProviderNotFound) + )); +} + +#[test] +fn site_replication_snapshot_hashes_secret_without_exposing_config() { + let provider = build_upsert_provider_config( + "default", + OidcProviderConfigInput { + config_url: "https://example.com/.well-known/openid-configuration".to_string(), + client_id: "client".to_string(), + client_secret: Some("secret".to_string()), + ..Default::default() + }, + None, + ) + .expect("provider should be valid"); + let snapshot = OidcSiteReplicationSnapshot::from_config_snapshot(&OidcConfigSnapshot::new(vec![SourcedOidcProviderConfig { + config: provider, + source: OidcProviderConfigSource::Persisted, + }])); + assert_eq!( + snapshot.providers()[0].hashed_client_secret, + "K7gNU3sdo-OL0wNhqoVWhr3g6s1xYv72ol_pe_Unols" + ); + assert!(!format!("{snapshot:?}").contains("\"secret\"")); + assert_eq!(hash_client_secret(None), ""); + assert_eq!(hash_client_secret(Some("")), ""); +} diff --git a/crates/iam/src/oidc/mod.rs b/crates/iam/src/oidc/mod.rs index 4acfb6364..7452dcd19 100644 --- a/crates/iam/src/oidc/mod.rs +++ b/crates/iam/src/oidc/mod.rs @@ -23,9 +23,11 @@ mod transport; mod test_support; pub use config::{ - OidcConfigQuery, OidcConfigSnapshot, OidcProviderConfig, OidcProviderConfigSource, OidcProviderValidationResult, - SourcedOidcProviderConfig, load_oidc_config_snapshot, load_oidc_provider_configs_from_env, - load_oidc_provider_configs_from_server_config, + OidcConfigError, OidcConfigQuery, OidcConfigSnapshot, OidcProviderConfig, OidcProviderConfigInput, OidcProviderConfigSource, + OidcProviderValidationInput, OidcProviderValidationResult, OidcSiteReplicationProvider, OidcSiteReplicationSnapshot, + SourcedOidcProviderConfig, build_upsert_provider_config, build_validation_provider_config, delete_persisted_provider_config, + load_oidc_config_snapshot, load_oidc_provider_configs_from_env, load_oidc_provider_configs_from_server_config, + persisted_provider_secret, upsert_persisted_provider_config, validate_mutable_provider_id, }; pub use provider::validate_oidc_provider_config_with_extra_root_ca; pub use runtime::{OidcSys, StandardOidcAdapter}; diff --git a/crates/iam/src/oidc/runtime.rs b/crates/iam/src/oidc/runtime.rs index 01cff8046..3a5797fc1 100644 --- a/crates/iam/src/oidc/runtime.rs +++ b/crates/iam/src/oidc/runtime.rs @@ -13,8 +13,8 @@ // limitations under the License. use super::{ config::{ - OidcConfigQuery, OidcConfigSnapshot, OidcProviderConfig, OidcProviderSummary, SourcedOidcProviderConfig, - load_oidc_config_snapshot, + OidcConfigQuery, OidcConfigSnapshot, OidcProviderConfig, OidcProviderSummary, OidcSiteReplicationSnapshot, + SourcedOidcProviderConfig, load_oidc_config_snapshot, }, provider::{ProviderRuntime, discover_provider}, state::{OidcAuthSession, OidcLogoutSession, OidcStateStore}, @@ -806,8 +806,8 @@ impl StandardOidcAdapter { } impl OidcConfigQuery for StandardOidcAdapter { - fn config_snapshot(&self) -> OidcConfigSnapshot { - self.oidc.config_snapshot() + fn site_replication_snapshot(&self) -> OidcSiteReplicationSnapshot { + OidcSiteReplicationSnapshot::from_config_snapshot(&self.oidc.config_snapshot()) } } diff --git a/rustfs/src/admin/handlers/oidc.rs b/rustfs/src/admin/handlers/oidc.rs index b5183ce05..8a095d2e1 100644 --- a/rustfs/src/admin/handlers/oidc.rs +++ b/rustfs/src/admin/handlers/oidc.rs @@ -13,36 +13,22 @@ // limitations under the License. use crate::admin::auth::authorize_admin_request; -use crate::admin::handlers::supervise_admin_mutation; use crate::admin::router::{AdminOperation, Operation, S3Router}; -use crate::admin::runtime_sources::{ - current_app_context, current_federated_identity_service, current_object_store_handle_for_context, - current_server_config_for_context, -}; +use crate::admin::runtime_sources::current_federated_identity_service; use crate::admin::service::federated_identity::DefaultFederatedSessionBinding; -use crate::admin::storage_api::config::{ - read_admin_config_without_migrate, read_admin_server_config_snapshot, save_admin_server_config_snapshot, -}; +use crate::admin::service::oidc_config; use crate::admin::utils::json_response; use crate::server::{ADMIN_PREFIX, MINIO_ADMIN_PREFIX, console_prefix}; 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_ROLES_CLAIM, OIDC_DEFAULT_SCOPES, - OIDC_DEFAULT_USERNAME_CLAIM, OIDC_DISPLAY_NAME, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM, OIDC_HIDE_FROM_UI, OIDC_ISSUER, - OIDC_OTHER_AUDIENCES, OIDC_REDIRECT_URI, OIDC_REDIRECT_URI_DYNAMIC, OIDC_ROLE_POLICY, OIDC_ROLES_CLAIM, OIDC_SCOPES, - OIDC_USERNAME_CLAIM, -}; -use rustfs_config::server_config::Config as ServerConfig; -use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, ENV_RUSTFS_BROWSER_REDIRECT_URL, EnableState, MAX_ADMIN_REQUEST_BODY_SIZE}; +use rustfs_config::{ENV_RUSTFS_BROWSER_REDIRECT_URL, MAX_ADMIN_REQUEST_BODY_SIZE}; use rustfs_iam::federation::{FederatedSessionBindingError, FederationError}; +use rustfs_iam::oidc::{OidcProviderConfigInput, OidcProviderValidationInput}; use rustfs_policy::policy::action::{Action, AdminAction}; -use rustfs_utils::egress::validate_outbound_url; use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; +use serde::Serialize; use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use tracing::{debug, error, warn}; use url::Url; @@ -78,6 +64,19 @@ fn callback_federation_error(error: FederationError) -> S3Error { } } +fn config_service_error(error: oidc_config::OidcAdminConfigError) -> S3Error { + use oidc_config::OidcAdminConfigError; + let code = match &error { + OidcAdminConfigError::Config(rustfs_iam::oidc::OidcConfigError::EnvironmentManaged) => S3ErrorCode::AccessDenied, + OidcAdminConfigError::Config(_) | OidcAdminConfigError::Validation(_) => S3ErrorCode::InvalidRequest, + OidcAdminConfigError::StorageUnavailable + | OidcAdminConfigError::Load(_) + | OidcAdminConfigError::Save(_) + | OidcAdminConfigError::Task(_) => S3ErrorCode::InternalError, + }; + S3Error::with_message(code, error.to_string()) +} + /// Validate that a provider ID contains only safe characters (alphanumeric, underscore, hyphen). fn is_valid_provider_id(id: &str) -> bool { !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') @@ -198,104 +197,6 @@ struct OidcValidationResponse { token_endpoint: Option, } -#[derive(Debug, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct OidcConfigUpsertRequest { - enabled: bool, - display_name: String, - config_url: String, - issuer: Option, - client_id: String, - client_secret: Option, - scopes: Vec, - other_audiences: Vec, - redirect_uri: Option, - redirect_uri_dynamic: bool, - claim_name: String, - claim_prefix: String, - role_policy: String, - groups_claim: String, - roles_claim: String, - email_claim: String, - username_claim: String, - hide_from_ui: bool, -} - -impl Default for OidcConfigUpsertRequest { - fn default() -> Self { - Self { - enabled: true, - display_name: String::new(), - config_url: String::new(), - issuer: None, - client_id: String::new(), - client_secret: None, - scopes: OIDC_DEFAULT_SCOPES.split(',').map(ToString::to_string).collect(), - other_audiences: Vec::new(), - 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(), - roles_claim: OIDC_DEFAULT_ROLES_CLAIM.to_string(), - email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(), - username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(), - hide_from_ui: false, - } - } -} - -#[derive(Debug, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct OidcConfigValidateRequest { - provider_id: String, - enabled: bool, - display_name: String, - config_url: String, - issuer: Option, - client_id: String, - client_secret: Option, - scopes: Vec, - other_audiences: Vec, - redirect_uri: Option, - redirect_uri_dynamic: bool, - claim_name: String, - claim_prefix: String, - role_policy: String, - groups_claim: String, - roles_claim: String, - email_claim: String, - username_claim: String, - hide_from_ui: bool, -} - -impl Default for OidcConfigValidateRequest { - fn default() -> Self { - Self { - provider_id: "default".to_string(), - enabled: true, - display_name: String::new(), - config_url: String::new(), - issuer: None, - client_id: String::new(), - client_secret: None, - scopes: OIDC_DEFAULT_SCOPES.split(',').map(ToString::to_string).collect(), - other_audiences: Vec::new(), - 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(), - roles_claim: OIDC_DEFAULT_ROLES_CLAIM.to_string(), - email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(), - username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(), - hide_from_ui: false, - } - } -} - /// Handler: GET /rustfs/admin/v3/oidc/providers /// Returns list of configured OIDC providers for the login page. pub struct ListOidcProvidersHandler {} @@ -323,9 +224,9 @@ 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_oidc_config_snapshot(Some(&config)) + let config = oidc_config::list_config().await.map_err(config_service_error)?; + let providers = config + .snapshot .into_providers() .into_iter() .map(|provider| OidcConfigView { @@ -357,7 +258,7 @@ impl Operation for GetOidcConfigHandler { StatusCode::OK, &OidcConfigListResponse { providers, - restart_required, + restart_required: config.restart_required, }, ) } @@ -373,22 +274,13 @@ impl Operation for PutOidcConfigHandler { 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")); - } + oidc_config::ensure_mutable_provider_id(provider_id).map_err(config_service_error)?; let provider_id = provider_id.to_owned(); - let request: OidcConfigUpsertRequest = parse_json_body(&mut req).await?; - update_oidc_server_config(move |config| { - 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(config, &provider_config); - Ok(()) - }) - .await?; + let request: OidcProviderConfigInput = parse_json_body(&mut req).await?; + oidc_config::upsert_config(provider_id, request) + .await + .map_err(config_service_error)?; json_response( StatusCode::OK, @@ -411,19 +303,9 @@ impl Operation for DeleteOidcConfigHandler { 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")); - } + oidc_config::ensure_mutable_provider_id(provider_id).map_err(config_service_error)?; let provider_id = provider_id.to_owned(); - - update_oidc_server_config(move |config| { - delete_persisted_provider_config(config, &provider_id)?; - Ok(()) - }) - .await?; + oidc_config::delete_config(provider_id).await.map_err(config_service_error)?; json_response( StatusCode::OK, @@ -443,22 +325,8 @@ 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 oidc_extra_root_ca = crate::startup_auth::current_oidc_extra_root_ca_material() - .await - .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("validation failed: {e}")))?; - let validation = rustfs_iam::oidc::validate_oidc_provider_config_with_extra_root_ca( - &provider_config, - oidc_extra_root_ca.root_ca_pem.as_deref(), - ) - .await - .map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("validation failed: {e}")))?; + let request: OidcProviderValidationInput = parse_json_body(&mut req).await?; + let validation = oidc_config::validate_config(request).await.map_err(config_service_error)?; json_response( StatusCode::OK, @@ -877,316 +745,6 @@ async fn parse_json_body(req: &mut S3Request) -> S3Re serde_json::from_slice(&body).map_err(|e| s3_error!(InvalidRequest, "invalid JSON: {}", e)) } -async fn load_server_config_from_store() -> S3Result { - let store = oidc_config_store()?; - - read_admin_config_without_migrate(store) - .await - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to load server config: {e}"))) -} - -fn oidc_config_store() -> S3Result> { - let context = current_app_context(); - current_object_store_handle_for_context(context.as_deref()) - .ok_or_else(|| s3_error!(InternalError, "storage layer not initialized")) -} - -async fn update_oidc_server_config(modifier: F) -> S3Result<()> -where - F: FnOnce(&mut ServerConfig) -> S3Result<()> + Send + 'static, -{ - let store = oidc_config_store()?; - supervise_admin_mutation("OIDC config update", async move { - let snapshot = read_admin_server_config_snapshot(store.clone()) - .await - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to load server config: {e}")))?; - let mut config = snapshot.config.clone(); - modifier(&mut config)?; - save_admin_server_config_snapshot(store, &config, &snapshot) - .await - .map(|_| ()) - .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("failed to save server config: {e}"))) - }) - .await -} - -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 context = current_app_context(); - let active_config = current_server_config_for_context(context.as_deref()); - 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_oidc_config_snapshot(Some(config)) != rustfs_iam::oidc::load_oidc_config_snapshot(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_config::server_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)); - } - - validate_outbound_url(&parsed).map_err(|err| s3_error!(InvalidRequest, "{} is not allowed: {}", field_name, err))?; - - Ok(()) -} - -fn validate_absolute_http_url_without_outbound_check(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 let Some(issuer) = config.issuer.as_deref() { - validate_absolute_http_url_without_outbound_check(issuer, "issuer")?; - } - - 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 or_default(value: &str, default: &str) -> String { - if value.trim().is_empty() { - default.to_string() - } else { - value.trim().to_string() - } -} - -/// Normalize an `OidcProviderConfig` by trimming strings and applying defaults. -fn normalize_provider_config(mut config: rustfs_iam::oidc::OidcProviderConfig) -> rustfs_iam::oidc::OidcProviderConfig { - config.config_url = config.config_url.trim().to_string(); - config.issuer = normalize_optional(config.issuer); - config.client_id = config.client_id.trim().to_string(); - config.scopes = normalize_scopes(&config.scopes); - config.redirect_uri = normalize_optional(config.redirect_uri); - config.claim_name = or_default(&config.claim_name, OIDC_DEFAULT_CLAIM_NAME); - config.claim_prefix = config.claim_prefix.trim().to_string(); - config.role_policy = config.role_policy.trim().to_string(); - config.display_name = or_default(&config.display_name, &config.id); - config.groups_claim = or_default(&config.groups_claim, OIDC_DEFAULT_GROUPS_CLAIM); - config.roles_claim = or_default(&config.roles_claim, OIDC_DEFAULT_ROLES_CLAIM); - config.email_claim = or_default(&config.email_claim, OIDC_DEFAULT_EMAIL_CLAIM); - config.username_claim = or_default(&config.username_claim, OIDC_DEFAULT_USERNAME_CLAIM); - config -} - -fn build_provider_config_from_upsert( - provider_id: &str, - request: OidcConfigUpsertRequest, - existing_secret: Option, -) -> S3Result { - 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 = normalize_provider_config(rustfs_iam::oidc::OidcProviderConfig { - id: provider_id.to_string(), - enabled: request.enabled, - config_url: request.config_url, - issuer: request.issuer, - client_id: request.client_id, - client_secret, - scopes: request.scopes, - other_audiences: request.other_audiences, - redirect_uri: request.redirect_uri, - redirect_uri_dynamic: request.redirect_uri_dynamic, - claim_name: request.claim_name, - claim_prefix: request.claim_prefix, - role_policy: request.role_policy, - display_name: request.display_name, - groups_claim: request.groups_claim, - roles_claim: request.roles_claim, - email_claim: request.email_claim, - username_claim: request.username_claim, - hide_from_ui: request.hide_from_ui, - }); - - validate_provider_config_fields(&config)?; - Ok(config) -} - -fn build_provider_config_from_validate( - request: OidcConfigValidateRequest, - provider_id: &str, -) -> S3Result { - let config = normalize_provider_config(rustfs_iam::oidc::OidcProviderConfig { - id: provider_id.to_string(), - enabled: request.enabled, - config_url: request.config_url, - issuer: request.issuer, - client_id: request.client_id, - client_secret: request.client_secret.filter(|value| !value.trim().is_empty()), - scopes: request.scopes, - other_audiences: request.other_audiences, - redirect_uri: request.redirect_uri, - redirect_uri_dynamic: request.redirect_uri_dynamic, - claim_name: request.claim_name, - claim_prefix: request.claim_prefix, - role_policy: request.role_policy, - display_name: request.display_name, - groups_claim: request.groups_claim, - roles_claim: request.roles_claim, - email_claim: request.email_claim, - username_claim: request.username_claim, - hide_from_ui: request.hide_from_ui, - }); - - 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_ISSUER, provider_config.issuer.clone().unwrap_or_default()); - 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_OTHER_AUDIENCES, provider_config.other_audiences.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_ROLES_CLAIM, provider_config.roles_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()); - set_kvs_value( - &mut kvs, - OIDC_HIDE_FROM_UI, - if provider_config.hide_from_ui { - EnableState::On.to_string() - } else { - EnableState::Off.to_string() - }, - ); - - 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 @@ -1739,191 +1297,22 @@ mod tests { } #[test] - fn test_validate_absolute_http_url_rejects_loopback_targets() { - let err = validate_absolute_http_url("https://127.0.0.1/.well-known/openid-configuration", "config_url") - .expect_err("loopback config URL should be rejected"); - assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); - assert!(err.message().unwrap_or_default().contains("not allowed")); - } + fn oidc_config_errors_keep_admin_codes() { + let error = config_service_error(oidc_config::OidcAdminConfigError::Config( + rustfs_iam::oidc::OidcConfigError::EnvironmentManaged, + )); + assert_eq!(error.code(), &S3ErrorCode::AccessDenied); + assert_eq!(error.message(), Some("provider is managed by environment variables")); - #[test] - fn test_provider_instance_key() { - assert_eq!(provider_instance_key("default"), "_"); - assert_eq!(provider_instance_key("okta"), "okta"); - } + let error = config_service_error(oidc_config::OidcAdminConfigError::Config( + rustfs_iam::oidc::OidcConfigError::ProviderNotFound, + )); + assert_eq!(error.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(error.message(), Some("provider not found")); - #[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")); - assert_eq!(config.roles_claim, OIDC_DEFAULT_ROLES_CLAIM); - } - - #[test] - fn test_oidc_config_upsert_request_rejects_unknown_fields() { - let err = serde_json::from_str::( - r#"{"config_url":"https://example.com/.well-known/openid-configuration","client_id":"client","unexpected_field":true}"#, - ) - .expect_err("unknown upsert field should fail"); - - assert!(err.to_string().contains("unknown field")); - } - - #[test] - fn test_oidc_config_validate_request_rejects_unknown_fields() { - let err = serde_json::from_str::( - r#"{"provider_id":"default","config_url":"https://example.com/.well-known/openid-configuration","client_id":"client","unexpected_field":true}"#, - ) - .expect_err("unknown validate field should fail"); - - assert!(err.to_string().contains("unknown field")); - } - - #[test] - fn test_build_provider_config_uses_custom_roles_claim() { - let req = OidcConfigUpsertRequest { - config_url: "https://example.com/.well-known/openid-configuration".to_string(), - client_id: "client-id".to_string(), - roles_claim: "app_roles".to_string(), - ..Default::default() - }; - - let config = build_provider_config_from_upsert("default", req, None).expect("config should build"); - assert_eq!(config.roles_claim, "app_roles"); - } - - #[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(), - issuer: None, - client_id: "console".to_string(), - client_secret: Some("secret".to_string()), - scopes: vec!["openid".to_string(), "profile".to_string()], - other_audiences: vec![], - 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(), - roles_claim: OIDC_DEFAULT_ROLES_CLAIM.to_string(), - email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(), - username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(), - hide_from_ui: false, - }; - - 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))); - } - - #[test] - fn test_upsert_persists_hide_from_ui_on() { - let mut config = ServerConfig::new(); - let mut provider_config = rustfs_iam::oidc::OidcProviderConfig { - id: "kubernetes".to_string(), - enabled: true, - config_url: "https://example.com/.well-known/openid-configuration".to_string(), - issuer: None, - client_id: "test".to_string(), - client_secret: None, - scopes: vec!["openid".to_string()], - other_audiences: vec![], - redirect_uri: None, - redirect_uri_dynamic: true, - claim_name: "sub".to_string(), - claim_prefix: String::new(), - role_policy: String::new(), - display_name: "Kubernetes".to_string(), - groups_claim: OIDC_DEFAULT_GROUPS_CLAIM.to_string(), - roles_claim: OIDC_DEFAULT_ROLES_CLAIM.to_string(), - email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(), - username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(), - hide_from_ui: true, - }; - - upsert_persisted_provider_config(&mut config, &provider_config); - - let kvs = config - .0 - .get(IDENTITY_OPENID_SUB_SYS) - .and_then(|m| m.get("kubernetes")) - .expect("provider KVS should exist"); - assert_eq!(kvs.get(OIDC_HIDE_FROM_UI), EnableState::On.to_string()); - - // Flip to false and verify - provider_config.hide_from_ui = false; - upsert_persisted_provider_config(&mut config, &provider_config); - - let kvs = config - .0 - .get(IDENTITY_OPENID_SUB_SYS) - .and_then(|m| m.get("kubernetes")) - .expect("provider KVS should exist"); - assert_eq!(kvs.get(OIDC_HIDE_FROM_UI), EnableState::Off.to_string()); - } - - #[test] - fn test_upsert_persists_issuer() { - let mut config = ServerConfig::new(); - let provider_config = rustfs_iam::oidc::OidcProviderConfig { - id: "kubernetes".to_string(), - enabled: true, - config_url: "http://keycloak.ns.svc.cluster.local:8080/realms/app/.well-known/openid-configuration".to_string(), - issuer: Some("https://app.local/realms/app".to_string()), - client_id: "test".to_string(), - client_secret: None, - scopes: vec!["openid".to_string()], - other_audiences: vec![], - redirect_uri: None, - redirect_uri_dynamic: true, - claim_name: "sub".to_string(), - claim_prefix: String::new(), - role_policy: String::new(), - display_name: "Kubernetes".to_string(), - groups_claim: OIDC_DEFAULT_GROUPS_CLAIM.to_string(), - roles_claim: OIDC_DEFAULT_ROLES_CLAIM.to_string(), - email_claim: OIDC_DEFAULT_EMAIL_CLAIM.to_string(), - username_claim: OIDC_DEFAULT_USERNAME_CLAIM.to_string(), - hide_from_ui: false, - }; - - upsert_persisted_provider_config(&mut config, &provider_config); - - let kvs = config - .0 - .get(IDENTITY_OPENID_SUB_SYS) - .and_then(|m| m.get("kubernetes")) - .expect("provider KVS should exist"); - assert_eq!(kvs.get(OIDC_ISSUER), "https://app.local/realms/app"); + let error = config_service_error(oidc_config::OidcAdminConfigError::Task("cancelled")); + assert_eq!(error.code(), &S3ErrorCode::InternalError); + assert_eq!(error.message(), Some("OIDC config update task cancelled")); } /// The OIDC config gate now authorizes through the shared admin gate, which diff --git a/rustfs/src/admin/handlers/site_replication.rs b/rustfs/src/admin/handlers/site_replication.rs index 7adaf84b6..ec3abcfa8 100644 --- a/rustfs/src/admin/handlers/site_replication.rs +++ b/rustfs/src/admin/handlers/site_replication.rs @@ -2063,34 +2063,32 @@ fn open_id_settings(providers: Vec<(String, OpenIDProviderSettings)>, region: St settings } -fn open_id_settings_from_snapshot(snapshot: &rustfs_iam::oidc::OidcConfigSnapshot, region: String) -> OpenIDSettings { +fn open_id_settings_from_snapshot(snapshot: &rustfs_iam::oidc::OidcSiteReplicationSnapshot, region: String) -> OpenIDSettings { let providers = snapshot .providers() .iter() .map(|provider| { - let config = &provider.config; - (config.id.clone(), open_id_provider_settings(config)) + ( + provider.provider_id.clone(), + OpenIDProviderSettings { + claim_name: provider.claim_name.clone(), + claim_userinfo_enabled: false, + role_policy: provider.role_policy.clone(), + client_id: provider.client_id.clone(), + hashed_client_secret: provider.hashed_client_secret.clone(), + }, + ) }) .collect(); open_id_settings(providers, region) } -fn open_id_provider_settings(config: &rustfs_iam::oidc::OidcProviderConfig) -> OpenIDProviderSettings { - OpenIDProviderSettings { - claim_name: config.claim_name.clone(), - claim_userinfo_enabled: false, - role_policy: config.role_policy.clone(), - client_id: config.client_id.clone(), - hashed_client_secret: hash_client_secret(config.client_secret.as_deref()), - } -} - fn local_idp_settings() -> IDPSettings { let mut settings = IDPSettings::default(); let region = current_region().map(|region| region.to_string()).unwrap_or_default(); settings.open_id = current_oidc_config_query() - .map(|query| open_id_settings_from_snapshot(&query.config_snapshot(), region)) + .map(|query| open_id_settings_from_snapshot(&query.site_replication_snapshot(), region)) .unwrap_or_default(); let (ldap, ldap_configs) = load_ldap_idp_settings(); @@ -11547,16 +11545,18 @@ mod tests { #[test] fn oidc_config_snapshot_preserves_site_replication_settings() { - let snapshot = rustfs_iam::oidc::OidcConfigSnapshot::new(vec![ - oidc_snapshot_provider("corp", "client-b"), - oidc_snapshot_provider("default", "client-a"), - ]); + let snapshot = rustfs_iam::oidc::OidcSiteReplicationSnapshot::from_config_snapshot( + &rustfs_iam::oidc::OidcConfigSnapshot::new(vec![ + oidc_snapshot_provider("corp", "client-b"), + oidc_snapshot_provider("default", "client-a"), + ]), + ); assert_eq!( snapshot .providers() .iter() - .map(|provider| provider.config.id.as_str()) + .map(|provider| provider.provider_id.as_str()) .collect::>(), ["default", "corp"] ); diff --git a/rustfs/src/admin/service/mod.rs b/rustfs/src/admin/service/mod.rs index 403530f5c..4c1dc8177 100644 --- a/rustfs/src/admin/service/mod.rs +++ b/rustfs/src/admin/service/mod.rs @@ -15,5 +15,6 @@ pub(crate) mod caller_identity; pub mod config; pub(crate) mod federated_identity; +pub(crate) mod oidc_config; pub(crate) mod session_policy; pub mod site_replication; diff --git a/rustfs/src/admin/service/oidc_config.rs b/rustfs/src/admin/service/oidc_config.rs new file mode 100644 index 000000000..df230603f --- /dev/null +++ b/rustfs/src/admin/service/oidc_config.rs @@ -0,0 +1,158 @@ +// Copyright 2024 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::admin::runtime_sources::{ + current_app_context, current_object_store_handle_for_context, current_server_config_for_context, +}; +use crate::admin::storage_api::config::{ + read_admin_config_without_migrate, read_admin_server_config_snapshot, save_admin_server_config_snapshot, +}; +use crate::admin::storage_api::error::Error as StorageError; +use rustfs_config::server_config::Config as ServerConfig; +use rustfs_iam::oidc::{ + OidcConfigError, OidcConfigSnapshot, OidcProviderConfigInput, OidcProviderValidationInput, OidcProviderValidationResult, + build_upsert_provider_config, build_validation_provider_config, delete_persisted_provider_config, load_oidc_config_snapshot, + persisted_provider_secret, upsert_persisted_provider_config, validate_mutable_provider_id, + validate_oidc_provider_config_with_extra_root_ca, +}; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum OidcAdminConfigError { + #[error("storage layer not initialized")] + StorageUnavailable, + #[error("failed to load server config: {0}")] + Load(#[source] StorageError), + #[error("failed to save server config: {0}")] + Save(#[source] StorageError), + #[error("validation failed: {0}")] + Validation(String), + #[error("OIDC config update task {0}")] + Task(&'static str), + #[error(transparent)] + Config(#[from] OidcConfigError), +} + +type Result = std::result::Result; + +pub(crate) struct OidcConfigList { + pub snapshot: OidcConfigSnapshot, + pub restart_required: bool, +} + +pub(crate) fn ensure_mutable_provider_id(provider_id: &str) -> Result<()> { + validate_mutable_provider_id(provider_id)?; + Ok(()) +} + +fn oidc_config_store() -> Result> { + let context = current_app_context(); + current_object_store_handle_for_context(context.as_deref()).ok_or(OidcAdminConfigError::StorageUnavailable) +} + +async fn load_server_config_from_store() -> Result { + let store = oidc_config_store()?; + read_admin_config_without_migrate(store) + .await + .map_err(OidcAdminConfigError::Load) +} + +fn restart_required_from_active_config(config: &ServerConfig, active_config: Option<&ServerConfig>) -> bool { + load_oidc_config_snapshot(Some(config)) != load_oidc_config_snapshot(active_config) +} + +pub(crate) async fn list_config() -> Result { + let config = load_server_config_from_store().await?; + let context = current_app_context(); + let active_config = current_server_config_for_context(context.as_deref()); + Ok(OidcConfigList { + snapshot: load_oidc_config_snapshot(Some(&config)), + restart_required: restart_required_from_active_config(&config, active_config.as_ref()), + }) +} + +async fn update_server_config(modifier: F) -> Result<()> +where + F: FnOnce(&mut ServerConfig) -> Result<()> + Send + 'static, +{ + let store = oidc_config_store()?; + tokio::spawn(async move { + let snapshot = read_admin_server_config_snapshot(store.clone()) + .await + .map_err(OidcAdminConfigError::Load)?; + let mut config = snapshot.config.clone(); + modifier(&mut config)?; + save_admin_server_config_snapshot(store, &config, &snapshot) + .await + .map(|_| ()) + .map_err(OidcAdminConfigError::Save) + }) + .await + .map_err(|error| OidcAdminConfigError::Task(if error.is_cancelled() { "cancelled" } else { "panicked" }))? +} + +pub(crate) async fn upsert_config(provider_id: String, input: OidcProviderConfigInput) -> Result<()> { + ensure_mutable_provider_id(&provider_id)?; + update_server_config(move |config| { + let existing_secret = persisted_provider_secret(config, &provider_id); + let provider = build_upsert_provider_config(&provider_id, input, existing_secret)?; + upsert_persisted_provider_config(config, &provider); + Ok(()) + }) + .await +} + +pub(crate) async fn delete_config(provider_id: String) -> Result<()> { + ensure_mutable_provider_id(&provider_id)?; + update_server_config(move |config| { + delete_persisted_provider_config(config, &provider_id)?; + Ok(()) + }) + .await +} + +pub(crate) async fn validate_config(input: OidcProviderValidationInput) -> Result { + let provider = build_validation_provider_config(input)?; + let extra_root_ca = crate::startup_auth::current_oidc_extra_root_ca_material() + .await + .map_err(|error| OidcAdminConfigError::Validation(error.to_string()))?; + validate_oidc_provider_config_with_extra_root_ca(&provider, extra_root_ca.root_ca_pem.as_deref()) + .await + .map_err(OidcAdminConfigError::Validation) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn restart_required_detects_persisted_changes() { + let active_config = ServerConfig::new(); + let mut persisted_config = ServerConfig::new(); + let provider = build_upsert_provider_config( + "default", + OidcProviderConfigInput { + config_url: "https://example.com/.well-known/openid-configuration".to_string(), + client_id: "console".to_string(), + client_secret: Some("secret".to_string()), + ..Default::default() + }, + None, + ) + .expect("provider config should be valid"); + upsert_persisted_provider_config(&mut persisted_config, &provider); + + assert!(restart_required_from_active_config(&persisted_config, Some(&active_config))); + assert!(!restart_required_from_active_config(&persisted_config, Some(&persisted_config))); + } +} diff --git a/rustfs/src/site_replication/tests.rs b/rustfs/src/site_replication/tests.rs index 4f20ec2b3..e5c38d77e 100644 --- a/rustfs/src/site_replication/tests.rs +++ b/rustfs/src/site_replication/tests.rs @@ -3569,11 +3569,6 @@ fn test_reconcile_site_replication_bucket_targets_allows_peer_on_same_port_as_lo }); } -#[test] -fn test_hash_client_secret_matches_minio_style_base64url_sha256() { - assert_eq!(hash_client_secret(Some("secret")), "K7gNU3sdo-OL0wNhqoVWhr3g6s1xYv72ol_pe_Unols"); -} - #[test] fn test_site_replication_peer_client_cache_hit_generation_mismatch_returns_none() { let cache = Some(SiteReplicationPeerClientCache { diff --git a/rustfs/src/site_replication/transport.rs b/rustfs/src/site_replication/transport.rs index 18d4850b9..8a33c8585 100644 --- a/rustfs/src/site_replication/transport.rs +++ b/rustfs/src/site_replication/transport.rs @@ -374,16 +374,6 @@ pub(crate) fn runtime_tls_enabled() -> bool { runtime_tls_enabled_with(endpoints.as_ref()) } -pub(crate) fn hash_client_secret(secret: Option<&str>) -> String { - let Some(secret) = secret.filter(|secret| !secret.is_empty()) else { - return String::new(); - }; - - let mut hasher = Sha256::new(); - hasher.update(secret.as_bytes()); - URL_SAFE_NO_PAD.encode_to_string(hasher.finalize()) -} - pub(crate) fn loopback_replication_targets_allowed() -> bool { std::env::var(ALLOW_LOOPBACK_REPLICATION_TARGET_ENV) .map(|value| value.eq_ignore_ascii_case("true") || value == "1")