mirror of
https://github.com/rustfs/rustfs.git
synced 2026-07-26 08:18:18 +00:00
fix(oidc): support separate discovery issuer (#5149)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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()],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user