mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-13 08:02:03 +00:00
refactor(oidc): isolate config updates and replication view (#8446)
This commit is contained in:
@@ -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<String>,
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub scopes: Vec<String>,
|
||||
pub other_audiences: Vec<String>,
|
||||
pub redirect_uri: Option<String>,
|
||||
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<String>,
|
||||
pub client_id: String,
|
||||
pub client_secret: Option<String>,
|
||||
pub scopes: Vec<String>,
|
||||
pub other_audiences: Vec<String>,
|
||||
pub redirect_uri: Option<String>,
|
||||
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<OidcProviderValidationInput> 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<OidcSiteReplicationProvider>,
|
||||
}
|
||||
|
||||
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<String>) -> Option<String> {
|
||||
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<String>,
|
||||
) -> Result<OidcProviderConfig, OidcConfigError> {
|
||||
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<String>,
|
||||
) -> Result<OidcProviderConfig, OidcConfigError> {
|
||||
build_provider_config(provider_id, input, existing_secret)
|
||||
}
|
||||
|
||||
pub fn build_validation_provider_config(input: OidcProviderValidationInput) -> Result<OidcProviderConfig, OidcConfigError> {
|
||||
let (provider_id, input) = input.into();
|
||||
build_provider_config(&provider_id, input, None)
|
||||
}
|
||||
|
||||
pub fn persisted_provider_secret(config: &ServerConfig, provider_id: &str) -> Option<String> {
|
||||
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.
|
||||
|
||||
@@ -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::<OidcProviderConfigInput>(r#"{"unexpected_field":true}"#).is_err());
|
||||
assert!(serde_json::from_str::<OidcProviderValidationInput>(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("")), "");
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct OidcConfigUpsertRequest {
|
||||
enabled: bool,
|
||||
display_name: String,
|
||||
config_url: String,
|
||||
issuer: Option<String>,
|
||||
client_id: String,
|
||||
client_secret: Option<String>,
|
||||
scopes: Vec<String>,
|
||||
other_audiences: Vec<String>,
|
||||
redirect_uri: Option<String>,
|
||||
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<String>,
|
||||
client_id: String,
|
||||
client_secret: Option<String>,
|
||||
scopes: Vec<String>,
|
||||
other_audiences: Vec<String>,
|
||||
redirect_uri: Option<String>,
|
||||
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<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
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<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
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<T: DeserializeOwned>(req: &mut S3Request<Body>) -> S3Re
|
||||
serde_json::from_slice(&body).map_err(|e| s3_error!(InvalidRequest, "invalid JSON: {}", e))
|
||||
}
|
||||
|
||||
async fn load_server_config_from_store() -> S3Result<ServerConfig> {
|
||||
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<std::sync::Arc<crate::admin::storage_api::runtime::ECStore>> {
|
||||
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<F>(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<rustfs_config::server_config::KVS> {
|
||||
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<String> {
|
||||
scopes
|
||||
.iter()
|
||||
.map(|scope| scope.trim().to_string())
|
||||
.filter(|scope| !scope.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn normalize_optional(value: Option<String>) -> Option<String> {
|
||||
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<String>,
|
||||
) -> S3Result<rustfs_iam::oidc::OidcProviderConfig> {
|
||||
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<rustfs_iam::oidc::OidcProviderConfig> {
|
||||
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<String> {
|
||||
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<Body>) -> S3Result<String> {
|
||||
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::<OidcConfigUpsertRequest>(
|
||||
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::<OidcConfigValidateRequest>(
|
||||
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
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
["default", "corp"]
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<T> = std::result::Result<T, OidcAdminConfigError>;
|
||||
|
||||
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<std::sync::Arc<crate::admin::storage_api::runtime::ECStore>> {
|
||||
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<ServerConfig> {
|
||||
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<OidcConfigList> {
|
||||
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<F>(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<OidcProviderValidationResult> {
|
||||
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)));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user