diff --git a/crates/config/src/constants/oidc.rs b/crates/config/src/constants/oidc.rs index 99b13d7ce..6b6721624 100644 --- a/crates/config/src/constants/oidc.rs +++ b/crates/config/src/constants/oidc.rs @@ -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, diff --git a/crates/ecstore/src/config/oidc.rs b/crates/ecstore/src/config/oidc.rs index bcf8e6125..0beadf2e2 100644 --- a/crates/ecstore/src/config/oidc.rs +++ b/crates/ecstore/src/config/oidc.rs @@ -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 = 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(), diff --git a/crates/iam/src/oidc.rs b/crates/iam/src/oidc.rs index 55358e13e..83948ce7a 100644 --- a/crates/iam/src/oidc.rs +++ b/crates/iam/src/oidc.rs @@ -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, pub client_id: String, pub client_secret: Option, pub scopes: Vec, @@ -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 { + 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 { + 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::(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 { @@ -1754,6 +1805,34 @@ fn normalize_config_url(config_url: &str) -> Result { Ok(issuer) } +fn discovery_url_from_config_url(config_url: &str) -> Result { + 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 { 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()], diff --git a/crates/utils/src/envs.rs b/crates/utils/src/envs.rs index 82e93c5b7..2d039dec5 100644 --- a/crates/utils/src/envs.rs +++ b/crates/utils/src/envs.rs @@ -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", diff --git a/rustfs/src/admin/handlers/config_admin.rs b/rustfs/src/admin/handlers/config_admin.rs index c11f18266..a43ccc0d0 100644 --- a/rustfs/src/admin/handlers/config_admin.rs +++ b/rustfs/src/admin/handlers/config_admin.rs @@ -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(), diff --git a/rustfs/src/admin/handlers/oidc.rs b/rustfs/src/admin/handlers/oidc.rs index 53ed47470..bed7593be 100644 --- a/rustfs/src/admin/handlers/oidc.rs +++ b/rustfs/src/admin/handlers/oidc.rs @@ -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, client_id: String, client_secret_configured: bool, scopes: Vec, @@ -202,6 +204,7 @@ struct OidcConfigUpsertRequest { enabled: bool, display_name: String, config_url: String, + issuer: Option, client_id: String, client_secret: Option, scopes: Vec, @@ -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, client_id: String, client_secret: Option, scopes: Vec, @@ -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"); + } }