fix(oidc): support separate discovery issuer (#5149)

This commit is contained in:
Zhengchao An
2026-07-23 10:44:32 +08:00
committed by GitHub
parent 1c8088d0b2
commit d9e0a25174
6 changed files with 243 additions and 7 deletions
+5 -1
View File
@@ -14,6 +14,7 @@
// OIDC configuration field keys (used in KVS)
pub const OIDC_CONFIG_URL: &str = "config_url";
pub const OIDC_ISSUER: &str = "issuer";
pub const OIDC_CLIENT_ID: &str = "client_id";
pub const OIDC_CLIENT_SECRET: &str = "client_secret";
pub const OIDC_SCOPES: &str = "scopes";
@@ -33,6 +34,7 @@ pub const OIDC_HIDE_FROM_UI: &str = "hide_from_ui";
// Environment variable names for OIDC
pub const ENV_IDENTITY_OPENID_ENABLE: &str = "RUSTFS_IDENTITY_OPENID_ENABLE";
pub const ENV_IDENTITY_OPENID_CONFIG_URL: &str = "RUSTFS_IDENTITY_OPENID_CONFIG_URL";
pub const ENV_IDENTITY_OPENID_ISSUER: &str = "RUSTFS_IDENTITY_OPENID_ISSUER";
pub const ENV_IDENTITY_OPENID_CLIENT_ID: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_ID";
pub const ENV_IDENTITY_OPENID_CLIENT_SECRET: &str = "RUSTFS_IDENTITY_OPENID_CLIENT_SECRET";
pub const ENV_IDENTITY_OPENID_SCOPES: &str = "RUSTFS_IDENTITY_OPENID_SCOPES";
@@ -50,9 +52,10 @@ pub const ENV_IDENTITY_OPENID_USERNAME_CLAIM: &str = "RUSTFS_IDENTITY_OPENID_USE
pub const ENV_IDENTITY_OPENID_HIDE_FROM_UI: &str = "RUSTFS_IDENTITY_OPENID_HIDE_FROM_UI";
/// List of all environment variable keys for an OIDC provider.
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 17] = &[
pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 18] = &[
ENV_IDENTITY_OPENID_ENABLE,
ENV_IDENTITY_OPENID_CONFIG_URL,
ENV_IDENTITY_OPENID_ISSUER,
ENV_IDENTITY_OPENID_CLIENT_ID,
ENV_IDENTITY_OPENID_CLIENT_SECRET,
ENV_IDENTITY_OPENID_SCOPES,
@@ -74,6 +77,7 @@ pub const ENV_IDENTITY_OPENID_KEYS: &[&str; 17] = &[
pub const IDENTITY_OPENID_KEYS: &[&str] = &[
crate::ENABLE_KEY,
OIDC_CONFIG_URL,
OIDC_ISSUER,
OIDC_CLIENT_ID,
OIDC_CLIENT_SECRET,
OIDC_SCOPES,
+6 -1
View File
@@ -18,7 +18,7 @@ use rustfs_config::{
oidc::{
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_OTHER_AUDIENCES,
OIDC_DEFAULT_USERNAME_CLAIM, OIDC_DISPLAY_NAME, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM, OIDC_ISSUER, OIDC_OTHER_AUDIENCES,
OIDC_REDIRECT_URI, OIDC_REDIRECT_URI_DYNAMIC, OIDC_ROLE_POLICY, OIDC_ROLES_CLAIM, OIDC_SCOPES, OIDC_USERNAME_CLAIM,
},
};
@@ -37,6 +37,11 @@ pub static DEFAULT_IDENTITY_OPENID_KVS: LazyLock<KVS> = LazyLock::new(|| {
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: OIDC_ISSUER.to_owned(),
value: "".to_owned(),
hidden_if_empty: false,
},
KV {
key: OIDC_CLIENT_ID.to_owned(),
value: "".to_owned(),
+158 -1
View File
@@ -19,7 +19,7 @@
//! and ID token verification.
use crate::oidc_state::{OidcAuthSession, OidcLogoutSession, OidcStateStore};
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken};
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreJsonWebKeySet};
use openidconnect::{
AsyncHttpClient, Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, LogoutRequest, Nonce,
PkceCodeChallenge, PkceCodeVerifier, PostLogoutRedirectUrl, ProviderMetadataWithLogout, RedirectUrl, RequestTokenError,
@@ -436,6 +436,7 @@ pub struct OidcProviderConfig {
pub id: String,
pub enabled: bool,
pub config_url: String,
pub issuer: Option<String>,
pub client_id: String,
pub client_secret: Option<String>,
pub scopes: Vec<String>,
@@ -459,6 +460,7 @@ impl fmt::Debug for OidcProviderConfig {
.field("id", &self.id)
.field("enabled", &self.enabled)
.field("config_url", &self.config_url)
.field("issuer", &self.issuer)
.field("client_id", &self.client_id)
.field("client_secret", &redacted_optional_secret(self.client_secret.as_deref()))
.field("scopes", &self.scopes)
@@ -1409,6 +1411,7 @@ impl OidcSys {
let enable_val = get_env(ENV_IDENTITY_OPENID_ENABLE);
let config_url = get_env(ENV_IDENTITY_OPENID_CONFIG_URL);
let issuer = get_env(ENV_IDENTITY_OPENID_ISSUER);
// Skip if no config URL
if config_url.is_empty() {
@@ -1485,6 +1488,7 @@ impl OidcSys {
id: id.to_string(),
enabled,
config_url,
issuer: if issuer.is_empty() { None } else { Some(issuer) },
client_id: get_env(ENV_IDENTITY_OPENID_CLIENT_ID),
client_secret,
scopes,
@@ -1553,6 +1557,7 @@ impl OidcSys {
id: id.to_string(),
enabled,
config_url,
issuer: kvs.lookup(OIDC_ISSUER).filter(|v| !v.is_empty()),
client_id: kvs.get(OIDC_CLIENT_ID),
client_secret,
scopes,
@@ -1574,6 +1579,10 @@ impl OidcSys {
/// Perform OIDC discovery for a provider.
/// `discover_async` fetches the discovery document and JWKS in one step.
async fn discover_provider(config: &OidcProviderConfig, http_client: &ReqwestHttpClient) -> Result<ProviderState, String> {
if let Some(issuer) = config.issuer.as_deref().filter(|issuer| !issuer.trim().is_empty()) {
return Self::discover_provider_from_config_url(config, issuer, http_client).await;
}
// The openidconnect crate expects the issuer URL (base), not the
// .well-known/openid-configuration URL.
let base_issuer = normalize_config_url(&config.config_url)?;
@@ -1641,6 +1650,48 @@ impl OidcSys {
last_errors.join("; ")
))
}
async fn discover_provider_from_config_url(
config: &OidcProviderConfig,
issuer: &str,
http_client: &ReqwestHttpClient,
) -> Result<ProviderState, String> {
let issuer_url = IssuerUrl::new(issuer.trim().to_string()).map_err(|e| format!("invalid issuer URL: {e}"))?;
let discovery_url = discovery_url_from_config_url(&config.config_url)?;
let request = http::Request::builder()
.uri(discovery_url.to_string())
.method(http::Method::GET)
.header(http::header::ACCEPT, "application/json")
.body(Vec::new())
.map_err(|err| format!("failed to prepare discovery request: {err}"))?;
let response = http_client
.call(request)
.await
.map_err(|err| format!("discovery request failed: {err}"))?;
if response.status() != http::StatusCode::OK {
return Err(format!("discovery failed: HTTP status code {} at {}", response.status(), discovery_url));
}
let provider_metadata = serde_json::from_slice::<ProviderMetadataWithLogout>(response.body())
.map_err(|err| format!("failed to parse discovery response: {err}"))?;
if provider_metadata.issuer() != &issuer_url {
return Err(format!(
"unexpected issuer URI `{}` (expected `{}`)",
provider_metadata.issuer().as_str(),
issuer_url.as_str()
));
}
let jwks = CoreJsonWebKeySet::fetch_async(provider_metadata.jwks_uri(), http_client)
.await
.map_err(|err| format!("failed to fetch JWKS: {err}"))?;
Ok(ProviderState {
metadata: provider_metadata.set_jwks(jwks),
discovered_at: Instant::now(),
})
}
}
pub fn load_oidc_provider_configs_from_env() -> Vec<OidcProviderConfig> {
@@ -1754,6 +1805,34 @@ fn normalize_config_url(config_url: &str) -> Result<String, String> {
Ok(issuer)
}
fn discovery_url_from_config_url(config_url: &str) -> Result<Url, String> {
let mut url = Url::parse(config_url.trim()).map_err(|e| format!("invalid config_url: {e}"))?;
if url.scheme() != "http" && url.scheme() != "https" {
return Err(format!("invalid config_url scheme: {}", url.scheme()));
}
if url.host_str().is_none() {
return Err("config_url missing host".to_string());
}
let path = url.path().to_string();
let without_trailing_slash = path.strip_suffix('/').unwrap_or(&path);
if without_trailing_slash.ends_with("/.well-known/openid-configuration") {
url.set_path(without_trailing_slash);
return Ok(url);
}
if without_trailing_slash.contains("/.well-known/") {
return Err("config_url uses an unsupported .well-known discovery URL".into());
}
let discovery_path = if without_trailing_slash.is_empty() || without_trailing_slash == "/" {
"/.well-known/openid-configuration".to_string()
} else {
format!("{without_trailing_slash}/.well-known/openid-configuration")
};
url.set_path(&discovery_path);
Ok(url)
}
fn issuer_candidates(base: &str) -> Vec<String> {
let original = base.trim();
let mut variants = Vec::with_capacity(2);
@@ -2082,6 +2161,23 @@ mod tests {
assert!(normalize_config_url("not-a-url").is_err());
}
#[test]
fn test_discovery_url_from_config_url() {
assert_eq!(
discovery_url_from_config_url("https://idp.example.com/.well-known/openid-configuration")
.expect("config URL should parse")
.as_str(),
"https://idp.example.com/.well-known/openid-configuration"
);
assert_eq!(
discovery_url_from_config_url("https://idp.example.com/realms/app")
.expect("issuer URL should derive discovery URL")
.as_str(),
"https://idp.example.com/realms/app/.well-known/openid-configuration"
);
assert!(discovery_url_from_config_url("https://idp.example.com/.well-known/not-openid").is_err());
}
#[test]
fn test_issuer_candidates() {
assert_eq!(
@@ -2109,6 +2205,7 @@ mod tests {
id: id.to_string(),
enabled: true,
config_url: config_url.to_string(),
issuer: None,
client_id: "rustfs-oidc-test".to_string(),
client_secret: None,
scopes: vec!["openid".to_string()],
@@ -2289,6 +2386,43 @@ mod tests {
assert!(handle.join().is_ok());
}
#[tokio::test]
async fn test_validate_oidc_provider_config_accepts_separate_issuer() {
let Some((base, handle)) = start_mock_oidc_discovery_server(|_| "https://public.example.com/realms/app".to_string(), 2)
else {
return;
};
let mut config =
build_mocked_oidc_provider_config("default", &format!("{base}/internal/realms/app/.well-known/openid-configuration"));
config.issuer = Some("https://public.example.com/realms/app".to_string());
let validation_result = validate_mocked_oidc_provider_config(&config)
.await
.expect("OIDC provider validation should succeed");
assert_eq!(validation_result.issuer, "https://public.example.com/realms/app");
assert!(handle.join().is_ok());
}
#[tokio::test]
async fn test_validate_oidc_provider_config_rejects_separate_issuer_mismatch() {
let Some((base, handle)) = start_mock_oidc_discovery_server(|_| "https://public.example.com/realms/other".to_string(), 1)
else {
return;
};
let mut config =
build_mocked_oidc_provider_config("default", &format!("{base}/internal/realms/app/.well-known/openid-configuration"));
config.issuer = Some("https://public.example.com/realms/app".to_string());
let err = validate_mocked_oidc_provider_config(&config)
.await
.expect_err("OIDC provider validation should fail");
assert!(err.contains("unexpected issuer URI"));
assert!(err.contains("https://public.example.com/realms/app"));
assert!(handle.join().is_ok());
}
#[tokio::test]
async fn test_validate_oidc_provider_config_returns_detailed_errors() {
let Some((base, handle)) = start_mock_oidc_discovery_server(|base| format!("{base}/application/o/other"), 8) else {
@@ -2399,6 +2533,25 @@ mod tests {
assert!(config.is_none());
}
#[test]
fn test_parse_single_provider_reads_issuer() {
temp_env::with_vars(
[
(
ENV_IDENTITY_OPENID_CONFIG_URL,
Some("http://keycloak.ns.svc.cluster.local:8080/realms/app/.well-known/openid-configuration"),
),
(ENV_IDENTITY_OPENID_ISSUER, Some("https://app.local/realms/app")),
(ENV_IDENTITY_OPENID_CLIENT_ID, Some("console")),
],
|| {
let config = OidcSys::parse_single_provider("", "default").expect("provider config should parse");
assert_eq!(config.issuer.as_deref(), Some("https://app.local/realms/app"));
},
);
}
#[test]
fn test_parse_persisted_provider_config() {
let mut cfg = ServerConfig::new();
@@ -2425,6 +2578,7 @@ mod tests {
);
kvs.insert(OIDC_CLIENT_ID.to_string(), "console".to_string());
kvs.insert(ENABLE_KEY.to_string(), EnableState::On.to_string());
kvs.insert(OIDC_ISSUER.to_string(), "https://issuer.example".to_string());
kvs.insert(OIDC_ROLES_CLAIM.to_string(), "app_roles".to_string());
cfg.0
@@ -2436,6 +2590,7 @@ mod tests {
assert_eq!(parsed.len(), 1);
assert_eq!(parsed[0].id, "default");
assert_eq!(parsed[0].client_id, "console");
assert_eq!(parsed[0].issuer.as_deref(), Some("https://issuer.example"));
assert!(parsed[0].enabled);
assert_eq!(parsed[0].roles_claim, "app_roles");
}
@@ -2528,6 +2683,7 @@ mod tests {
id: id.to_string(),
enabled: true,
config_url: format!("https://example.com/{id}/.well-known/openid-configuration"),
issuer: None,
client_id: "client-id".to_string(),
client_secret: None,
scopes: vec!["openid".to_string()],
@@ -2830,6 +2986,7 @@ mod tests {
id: "test".to_string(),
enabled: true,
config_url: "https://example.com/.well-known/openid-configuration".to_string(),
issuer: None,
client_id: "my-client".to_string(),
client_secret: Some("secret".to_string()),
scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()],
+1
View File
@@ -144,6 +144,7 @@ const EXTERNAL_COMPATIBLE_SUFFIXES: &[&str] = &[
"IDENTITY_OPENID_CLIENT_SECRET",
"IDENTITY_OPENID_CONFIG_URL",
"IDENTITY_OPENID_DISPLAY_NAME",
"IDENTITY_OPENID_ISSUER",
"IDENTITY_OPENID_REDIRECT_URI",
"IDENTITY_OPENID_SCOPES",
"ILM_EXPIRATION_WORKERS",
+9 -2
View File
@@ -55,10 +55,10 @@ use rustfs_config::notify::{
use rustfs_config::oidc::{
ENV_IDENTITY_OPENID_CLAIM_NAME, ENV_IDENTITY_OPENID_CLAIM_PREFIX, ENV_IDENTITY_OPENID_CLIENT_ID,
ENV_IDENTITY_OPENID_CLIENT_SECRET, ENV_IDENTITY_OPENID_CONFIG_URL, ENV_IDENTITY_OPENID_DISPLAY_NAME,
ENV_IDENTITY_OPENID_EMAIL_CLAIM, ENV_IDENTITY_OPENID_ENABLE, ENV_IDENTITY_OPENID_GROUPS_CLAIM,
ENV_IDENTITY_OPENID_EMAIL_CLAIM, ENV_IDENTITY_OPENID_ENABLE, ENV_IDENTITY_OPENID_GROUPS_CLAIM, ENV_IDENTITY_OPENID_ISSUER,
ENV_IDENTITY_OPENID_REDIRECT_URI, ENV_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC, ENV_IDENTITY_OPENID_ROLE_POLICY,
ENV_IDENTITY_OPENID_SCOPES, ENV_IDENTITY_OPENID_USERNAME_CLAIM, IDENTITY_OPENID_SUB_SYS, OIDC_CLAIM_NAME, OIDC_CLAIM_PREFIX,
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_CONFIG_URL, OIDC_DISPLAY_NAME, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM,
OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_CONFIG_URL, OIDC_DISPLAY_NAME, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM, OIDC_ISSUER,
OIDC_REDIRECT_URI, OIDC_REDIRECT_URI_DYNAMIC, OIDC_ROLE_POLICY, OIDC_SCOPES, OIDC_USERNAME_CLAIM,
};
use rustfs_config::server_config::{Config as ServerConfig, DEFAULT_KVS, KV, KVS};
@@ -313,6 +313,12 @@ const OIDC_HELP_KEYS: &[HelpKeyMetadata] = &[
description: "openid discovery document URL e.g. \"https://accounts.google.com/.well-known/openid-configuration\"",
optional: false,
},
HelpKeyMetadata {
key: OIDC_ISSUER,
type_name: "url",
description: "expected OpenID issuer URL when it differs from config_url",
optional: true,
},
HelpKeyMetadata {
key: OIDC_CLIENT_ID,
type_name: "string",
@@ -1374,6 +1380,7 @@ fn env_help_key(sub_system: &str, key: &str) -> String {
(HEAL_SUB_SYS, HEAL_BITROT_CYCLE) => ENV_SCANNER_BITROT_CYCLE_SECS.to_string(),
(IDENTITY_OPENID_SUB_SYS, ENABLE_KEY) => ENV_IDENTITY_OPENID_ENABLE.to_string(),
(IDENTITY_OPENID_SUB_SYS, OIDC_CONFIG_URL) => ENV_IDENTITY_OPENID_CONFIG_URL.to_string(),
(IDENTITY_OPENID_SUB_SYS, OIDC_ISSUER) => ENV_IDENTITY_OPENID_ISSUER.to_string(),
(IDENTITY_OPENID_SUB_SYS, OIDC_CLIENT_ID) => ENV_IDENTITY_OPENID_CLIENT_ID.to_string(),
(IDENTITY_OPENID_SUB_SYS, OIDC_CLIENT_SECRET) => ENV_IDENTITY_OPENID_CLIENT_SECRET.to_string(),
(IDENTITY_OPENID_SUB_SYS, OIDC_SCOPES) => ENV_IDENTITY_OPENID_SCOPES.to_string(),
+64 -2
View File
@@ -31,8 +31,9 @@ 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_OTHER_AUDIENCES,
OIDC_REDIRECT_URI, OIDC_REDIRECT_URI_DYNAMIC, OIDC_ROLE_POLICY, OIDC_ROLES_CLAIM, OIDC_SCOPES, OIDC_USERNAME_CLAIM,
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};
@@ -164,6 +165,7 @@ struct OidcConfigView {
enabled: bool,
display_name: String,
config_url: String,
issuer: Option<String>,
client_id: String,
client_secret_configured: bool,
scopes: Vec<String>,
@@ -202,6 +204,7 @@ struct OidcConfigUpsertRequest {
enabled: bool,
display_name: String,
config_url: String,
issuer: Option<String>,
client_id: String,
client_secret: Option<String>,
scopes: Vec<String>,
@@ -224,6 +227,7 @@ impl Default for OidcConfigUpsertRequest {
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(),
@@ -249,6 +253,7 @@ struct OidcConfigValidateRequest {
enabled: bool,
display_name: String,
config_url: String,
issuer: Option<String>,
client_id: String,
client_secret: Option<String>,
scopes: Vec<String>,
@@ -272,6 +277,7 @@ impl Default for OidcConfigValidateRequest {
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(),
@@ -328,6 +334,7 @@ impl Operation for GetOidcConfigHandler {
enabled: provider.config.enabled,
display_name: provider.config.display_name.clone(),
config_url: provider.config.config_url.clone(),
issuer: provider.config.issuer.clone(),
client_id: provider.config.client_id.clone(),
client_secret_configured: provider.config.client_secret.is_some(),
scopes: provider.config.scopes.clone(),
@@ -990,6 +997,16 @@ fn validate_absolute_http_url(value: &str, field_name: &str) -> S3Result<()> {
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"));
@@ -998,6 +1015,9 @@ fn validate_provider_config_fields(config: &rustfs_iam::oidc::OidcProviderConfig
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"));
@@ -1031,6 +1051,7 @@ fn or_default(value: &str, default: &str) -> 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);
@@ -1059,6 +1080,7 @@ fn build_provider_config_from_upsert(
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,
@@ -1088,6 +1110,7 @@ fn build_provider_config_from_validate(
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,
@@ -1132,6 +1155,7 @@ fn upsert_persisted_provider_config(config: &mut ServerConfig, provider_config:
},
);
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(","));
@@ -1321,6 +1345,7 @@ mod tests {
id: "default".to_string(),
enabled: true,
config_url: "https://idp.example.com/.well-known/openid-configuration".to_string(),
issuer: None,
client_id: "rustfs-console".to_string(),
client_secret: None,
scopes: vec!["openid".to_string()],
@@ -1718,6 +1743,7 @@ mod tests {
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()],
@@ -1748,6 +1774,7 @@ mod tests {
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()],
@@ -1785,4 +1812,39 @@ mod tests {
.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");
}
}