diff --git a/Cargo.lock b/Cargo.lock index f02676ff7..80f15677c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9999,6 +9999,7 @@ dependencies = [ "rustls-pki-types", "serde", "serde_json", + "serde_with", "serial_test", "sha1 0.11.0", "sha2 0.11.0", diff --git a/Cargo.toml b/Cargo.toml index 01b10ff45..c12edcaba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -191,6 +191,7 @@ rmp = { version = "0.8.15" } rmp-serde = { version = "1.3.1" } serde = { version = "1.0.229" } serde_ignored = { version = "0.1" } +serde_with = { version = "3", default-features = false, features = ["macros", "std"] } serde_json = { version = "1.0.151" } serde_urlencoded = "0.7.1" diff --git a/crates/iam/Cargo.toml b/crates/iam/Cargo.toml index 5d8116af6..49ad6203b 100644 --- a/crates/iam/Cargo.toml +++ b/crates/iam/Cargo.toml @@ -86,6 +86,7 @@ rustfs-ecstore = { workspace = true } rustfs-storage-api = { workspace = true } rustfs-policy.workspace = true serde_json = { workspace = true, features = ["raw_value"] } +serde_with = { workspace = true } async-trait.workspace = true thiserror.workspace = true arc-swap = { workspace = true } diff --git a/crates/iam/src/oidc.rs b/crates/iam/src/oidc.rs index fc287bb4a..27fc2db50 100644 --- a/crates/iam/src/oidc.rs +++ b/crates/iam/src/oidc.rs @@ -19,11 +19,13 @@ //! and ID token verification. use crate::oidc_state::{OidcAuthSession, OidcLogoutSession, OidcStateStore}; -use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreJsonWebKeySet}; +use openidconnect::core::{ + CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreIdTokenVerifier, CoreJsonWebKeySet, CoreJwsSigningAlgorithm, +}; use openidconnect::{ AsyncHttpClient, Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, DiscoveryError, IssuerUrl, JsonWebKeySetUrl, LogoutRequest, Nonce, PkceCodeChallenge, PkceCodeVerifier, PostLogoutRedirectUrl, - ProviderMetadataWithLogout, RedirectUrl, RequestTokenError, Scope, + ProviderMetadataWithLogout, RedirectUrl, RequestTokenError, Scope, TokenUrl, }; use reqwest::{Certificate, Client}; use rustfs_config::oidc::*; @@ -52,6 +54,7 @@ const EVENT_OIDC_HTTP: &str = "oidc_http"; const OIDC_JWKS_REFRESH_INTERVAL: StdDuration = StdDuration::from_secs(24 * 60 * 60); const OIDC_DISCOVERY_TRANSPORT_RETRIES: usize = 3; const OIDC_DISCOVERY_TRANSPORT_RETRY_DELAY: StdDuration = StdDuration::from_millis(50); +const OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY: &str = "JWKS request blocked by outbound policy"; const OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY: &str = "OIDC provider discovery blocked by outbound policy"; const OIDC_HTTP_REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(10); const OIDC_HTTP_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(3); @@ -753,7 +756,7 @@ pub struct SourcedOidcProviderConfig { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct OidcProviderValidationResult { pub issuer: String, - pub authorization_endpoint: String, + pub authorization_endpoint: Option, pub token_endpoint: Option, } @@ -783,10 +786,142 @@ pub struct OidcClaims { /// on-the-fly from metadata when needed. #[derive(Clone)] struct ProviderState { - metadata: ProviderMetadataWithLogout, + metadata: DiscoveredProviderMetadata, discovered_at: Instant, } +// Workload issuers do not implement the browser authorization flow. Keep their +// verification metadata separate rather than inventing an authorization URL. +#[serde_with::serde_as] +#[derive(Clone, Deserialize)] +struct WorkloadProviderMetadata { + issuer: IssuerUrl, + jwks_uri: JsonWebKeySetUrl, + token_endpoint: Option, + #[serde_as(as = "serde_with::VecSkipError<_>")] + id_token_signing_alg_values_supported: Vec, + #[serde(skip)] + jwks: CoreJsonWebKeySet, + // Discovery is extensible; report unsupported fields without logging values. + #[serde(flatten)] + additional_fields: HashMap, +} + +#[derive(Clone)] +enum DiscoveredProviderMetadata { + Console(Box), + Workload(Box), +} + +impl DiscoveredProviderMetadata { + fn parse(body: &[u8], hide_from_ui: bool) -> Result { + let document: serde_json::Value = serde_json::from_slice(body).map_err(|err| err.to_string())?; + if hide_from_ui + && document + .as_object() + .is_some_and(|fields| !fields.contains_key("authorization_endpoint")) + { + let mut metadata: WorkloadProviderMetadata = serde_json::from_slice(body).map_err(|err| err.to_string())?; + if !metadata.additional_fields.is_empty() { + warn!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + result = "workload_discovery_additional_fields", + field_count = metadata.additional_fields.len(), + "workload discovery contains additional fields" + ); + metadata.additional_fields.clear(); + } + Ok(Self::Workload(Box::new(metadata))) + } else { + serde_json::from_slice(body) + .map(|metadata| Self::Console(Box::new(metadata))) + .map_err(|err| err.to_string()) + } + } + + fn console(&self) -> Result<&ProviderMetadataWithLogout, String> { + match self { + Self::Console(metadata) => Ok(metadata), + Self::Workload(_) => Err("OIDC provider has no authorization endpoint; only web identity is supported".into()), + } + } + + fn issuer(&self) -> &IssuerUrl { + match self { + Self::Console(metadata) => metadata.issuer(), + Self::Workload(metadata) => &metadata.issuer, + } + } + + fn jwks_uri(&self) -> &JsonWebKeySetUrl { + match self { + Self::Console(metadata) => metadata.jwks_uri(), + Self::Workload(metadata) => &metadata.jwks_uri, + } + } + + fn set_jwks(self, jwks: CoreJsonWebKeySet) -> Self { + match self { + Self::Console(metadata) => Self::Console(Box::new(metadata.set_jwks(jwks))), + Self::Workload(mut metadata) => { + metadata.jwks = jwks; + Self::Workload(metadata) + } + } + } + + fn authorization_endpoint(&self) -> Option { + match self { + Self::Console(metadata) => Some(metadata.authorization_endpoint().to_string()), + Self::Workload(_) => None, + } + } + + fn token_endpoint(&self) -> Option<&TokenUrl> { + match self { + Self::Console(metadata) => metadata.token_endpoint(), + Self::Workload(metadata) => metadata.token_endpoint.as_ref(), + } + } + + fn verifier(&self, config: &OidcProviderConfig) -> CoreIdTokenVerifier<'static> { + let client_id = ClientId::new(config.client_id.clone()); + let secret = config.client_secret.as_ref().map(|secret| ClientSecret::new(secret.clone())); + let (issuer, jwks, algorithms) = match self { + Self::Console(metadata) => (metadata.issuer(), metadata.jwks(), metadata.id_token_signing_alg_values_supported()), + Self::Workload(metadata) => (&metadata.issuer, &metadata.jwks, &metadata.id_token_signing_alg_values_supported), + }; + let verifier = match secret { + Some(secret) => CoreIdTokenVerifier::new_confidential_client(client_id, secret, issuer.clone(), jwks.clone()), + None => CoreIdTokenVerifier::new_public_client(client_id, issuer.clone(), jwks.clone()), + }; + verifier.set_allowed_algs(algorithms.clone()) + } +} + +// This adapter is used only for discovery/JWKS fetches, never token exchange. +struct JwksAcceptClient<'a> { + inner: &'a ReqwestHttpClient, + discovery_url: Option, +} + +impl<'c> AsyncHttpClient<'c> for JwksAcceptClient<'_> { + type Error = OidcHttpError; + type Future = >::Future; + + fn call(&'c self, mut request: http::Request>) -> Self::Future { + if !self.discovery_url.as_ref().is_some_and(|url| request.uri() == url.as_str()) { + request.headers_mut().insert( + http::header::ACCEPT, + http::HeaderValue::from_static("application/json, application/jwk-set+json"), + ); + } + self.inner.call(request) + } +} + impl ProviderState { fn is_stale(&self) -> bool { self.discovered_at.elapsed() >= OIDC_JWKS_REFRESH_INTERVAL @@ -932,7 +1067,7 @@ impl OidcSys { let redirect = RedirectUrl::new(redirect_uri.to_string()).map_err(|e| format!("invalid redirect URI: {e}"))?; let client = CoreClient::from_provider_metadata( - state.metadata.clone(), + state.metadata.console()?.clone(), ClientId::new(config.client_id.clone()), config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())), ) @@ -994,7 +1129,7 @@ impl OidcSys { // Construct CoreClient on-the-fly with JWKS from discovery let client = CoreClient::from_provider_metadata( - provider_state.metadata.clone(), + provider_state.metadata.console()?.clone(), ClientId::new(config.client_id.clone()), config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())), ) @@ -1230,7 +1365,7 @@ impl OidcSys { ); let client = CoreClient::from_provider_metadata( - refreshed_state.metadata, + refreshed_state.metadata.console()?.clone(), ClientId::new(config.client_id.clone()), config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())), ) @@ -1320,7 +1455,7 @@ impl OidcSys { .get(&session.provider_id) .ok_or_else(|| format!("unknown OIDC provider: {}", session.provider_id))?; let state = self.ensure_provider_state(&session.provider_id, config).await?; - let Some(end_session_endpoint) = state.metadata.additional_metadata().end_session_endpoint.clone() else { + let Some(end_session_endpoint) = state.metadata.console()?.additional_metadata().end_session_endpoint.clone() else { return Ok(None); }; @@ -1460,14 +1595,6 @@ impl OidcSys { state = self.ensure_provider_state_if_stale(&provider_id, &config, &state).await?; - // Reconstruct CoreClient from provider metadata - let client = CoreClient::from_provider_metadata( - state.metadata.clone(), - ClientId::new(config.client_id.clone()), - config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())), - ) - .set_auth_type(AuthType::RequestBody); - // Parse raw JWT string into CoreIdToken let id_token: CoreIdToken = jwt .parse() @@ -1475,8 +1602,9 @@ impl OidcSys { // Verify the token (signature, issuer, audience, expiry) — skip nonce // (nonce is only required for the authorization code flow) - let verifier = client - .id_token_verifier() + let verifier = state + .metadata + .verifier(&config) .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); if let Err(e) = id_token.claims(&verifier, |_: Option<&Nonce>| Ok(())) { state = self @@ -1486,14 +1614,9 @@ impl OidcSys { format!("ID token verification failed: {e}; failed to refresh provider metadata: {refresh_err}") })?; - let client = CoreClient::from_provider_metadata( - state.metadata, - ClientId::new(config.client_id.clone()), - config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())), - ) - .set_auth_type(AuthType::RequestBody); - let verifier = client - .id_token_verifier() + let verifier = state + .metadata + .verifier(&config) .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); id_token .claims(&verifier, |_: Option<&Nonce>| Ok(())) @@ -1868,18 +1991,39 @@ impl OidcSys { let issuer_url = IssuerUrl::new(candidate_issuer.clone()).map_err(|e| format!("invalid issuer URL: {e}"))?; for attempt in 0..OIDC_DISCOVERY_TRANSPORT_RETRIES { - match ProviderMetadataWithLogout::discover_async(issuer_url.clone(), http_client).await { - Ok(metadata) => { - return Ok(ProviderState { - metadata, + let discovered = if config.hide_from_ui { + Self::discover_provider_from_config_url(config, candidate_issuer, http_client).await + } else { + let client = JwksAcceptClient { + inner: http_client, + discovery_url: Some( + issuer_url + .join(".well-known/openid-configuration") + .map_err(|err| err.to_string())?, + ), + }; + ProviderMetadataWithLogout::discover_async(issuer_url.clone(), &client) + .await + .map(|metadata| ProviderState { + metadata: DiscoveredProviderMetadata::Console(Box::new(metadata)), discovered_at: Instant::now(), - }); - } - Err(DiscoveryError::Request(OidcHttpError::ForbiddenOutbound(reason))) => { - return Err(format!("{OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY}: {reason}")); + }) + .map_err(|err| match err { + DiscoveryError::Request(OidcHttpError::ForbiddenOutbound(reason)) => { + format!("{OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY}: {reason}") + } + err => format!("discovery failed: {err}"), + }) + }; + match discovered { + Ok(state) => return Ok(state), + Err(error) + if error.starts_with(OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY) + || error.starts_with(OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY) => + { + return Err(error); } Err(error) => { - let error = format!("discovery failed: {error}"); let is_transient_transport = error.contains("Request failed"); let should_retry = is_transient_transport && attempt + 1 < OIDC_DISCOVERY_TRANSPORT_RETRIES; if should_retry { @@ -1933,7 +2077,14 @@ impl OidcSys { 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 explicit_issuer = config.issuer.as_deref().is_some_and(|issuer| !issuer.trim().is_empty()); + let discovery_url = if explicit_issuer { + discovery_url_from_config_url(&config.config_url)? + } else { + issuer_url + .join(".well-known/openid-configuration") + .map_err(|err| err.to_string())? + }; let request = http::Request::builder() .uri(discovery_url.to_string()) .method(http::Method::GET) @@ -1946,13 +2097,25 @@ impl OidcSys { Err(OidcHttpError::ForbiddenOutbound(reason)) => { return Err(format!("{OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY}: {reason}")); } - Err(err) => return Err(format!("discovery request failed: {err}")), + Err(err) => return Err(format!("discovery request failed: 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()) + if !explicit_issuer + && let Some(content_type) = response.headers().get(http::header::CONTENT_TYPE) + && !content_type.to_str().ok().is_some_and(|value| { + value + .split(';') + .next() + .is_some_and(|essence| essence.eq_ignore_ascii_case("application/json")) + }) + { + return Err("Unexpected response Content-Type: expected application/json".into()); + } + + let provider_metadata = DiscoveredProviderMetadata::parse(response.body(), config.hide_from_ui) .map_err(|err| format!("failed to parse discovery response: {err}"))?; if provider_metadata.issuer() != &issuer_url { return Err(format!( @@ -1962,11 +2125,23 @@ impl OidcSys { )); } - let jwks_url = jwks_url_from_config_url(&config.config_url, &issuer_url, provider_metadata.jwks_uri())?; - let jwks = match CoreJsonWebKeySet::fetch_async(&jwks_url, http_client).await { + let jwks_url = if explicit_issuer { + jwks_url_from_config_url(&config.config_url, &issuer_url, provider_metadata.jwks_uri())? + } else { + provider_metadata.jwks_uri().clone() + }; + let jwks = match CoreJsonWebKeySet::fetch_async( + &jwks_url, + &JwksAcceptClient { + inner: http_client, + discovery_url: None, + }, + ) + .await + { Ok(jwks) => jwks, Err(DiscoveryError::Request(OidcHttpError::ForbiddenOutbound(reason))) => { - return Err(format!("JWKS request blocked by outbound policy: {reason}")); + return Err(format!("{OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY}: {reason}")); } Err(err) => return Err(format!("failed to fetch JWKS: {err}")), }; @@ -2038,7 +2213,7 @@ pub async fn validate_oidc_provider_config_with_extra_root_ca( Ok(OidcProviderValidationResult { issuer: state.metadata.issuer().to_string(), - authorization_endpoint: state.metadata.authorization_endpoint().to_string(), + authorization_endpoint: state.metadata.authorization_endpoint(), token_endpoint: state.metadata.token_endpoint().map(ToString::to_string), }) } @@ -2598,7 +2773,7 @@ mod tests { } } - fn read_mock_oidc_request_path(stream: &mut impl std::io::Read) -> String { + fn read_mock_oidc_request(stream: &mut impl std::io::Read) -> String { let mut request_bytes = Vec::new(); let mut buffer = [0u8; 4096]; loop { @@ -2615,8 +2790,11 @@ mod tests { break; } } - let request = String::from_utf8_lossy(&request_bytes); - request + String::from_utf8_lossy(&request_bytes).into_owned() + } + + fn read_mock_oidc_request_path(stream: &mut impl std::io::Read) -> String { + read_mock_oidc_request(stream) .lines() .next() .unwrap_or("") @@ -2627,7 +2805,7 @@ mod tests { } fn mock_oidc_response(path: &str, discovery_body: &str, expected_jwks_path: &str, jwks_body: &str) -> String { - let (status, body) = if path.contains("/.well-known/openid-configuration") { + let (status, body) = if path.ends_with("/.well-known/openid-configuration") { (200, discovery_body) } else if path == expected_jwks_path { (200, jwks_body) @@ -2646,6 +2824,7 @@ mod tests { build_discovery_issuer: F, max_requests: usize, signing_alg: &'static str, + workload: bool, jwks_response: J, ) -> Option<(String, std::thread::JoinHandle<()>)> where @@ -2670,7 +2849,7 @@ mod tests { }; let base = format!("http://{}", listener.local_addr().expect("listener local address should be available")); let (discovery_issuer, discovery_jwks_uri, expected_jwks_path) = build_discovery_issuer(&base); - let discovery_body = serde_json::json!({ + let mut discovery_document = serde_json::json!({ "issuer": discovery_issuer, "authorization_endpoint": format!("{base}/authorize"), "token_endpoint": format!("{base}/token"), @@ -2679,8 +2858,14 @@ mod tests { "response_modes_supported": ["query"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": [signing_alg], - }) - .to_string(); + }); + if workload { + let fields = discovery_document.as_object_mut().expect("mock metadata is an object"); + fields.remove("authorization_endpoint"); + fields.remove("token_endpoint"); + fields.insert("response_types_supported".into(), serde_json::json!(["id_token"])); + } + let discovery_body = discovery_document.to_string(); let (ready_tx, ready_rx) = mpsc::channel(); let handle = std::thread::spawn(move || { @@ -2722,12 +2907,41 @@ mod tests { .set_read_timeout(Some(Duration::from_secs(1))) .expect("failed to set discovery mock read timeout"); - let path = read_mock_oidc_request_path(&mut stream); + let request = read_mock_oidc_request(&mut stream); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or(""); let jwks_body = jwks_response(jwks_fetches); if path == expected_jwks_path { jwks_fetches += 1; } - let response = mock_oidc_response(&path, &discovery_body, &expected_jwks_path, &jwks_body); + let mut response = mock_oidc_response(path, &discovery_body, &expected_jwks_path, &jwks_body); + if path.contains("/.well-known/openid-configuration") { + assert!( + request + .lines() + .filter_map(|line| line.split_once(':')) + .any(|(name, value)| { name.eq_ignore_ascii_case("accept") && value.trim() == "application/json" }), + "discovery Accept must remain unchanged" + ); + } + if path == expected_jwks_path { + let expected_type = if workload { + "application/jwk-set+json" + } else { + "application/json" + }; + let accepts_type = request.lines().filter_map(|line| line.split_once(':')).any(|(name, value)| { + name.eq_ignore_ascii_case("accept") && value.split(',').any(|item| item.trim() == expected_type) + }); + if !accepts_type { + response = "HTTP/1.1 406 Not Acceptable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into(); + } else if workload { + response = response.replace("Content-Type: application/json", "Content-Type: application/jwk-set+json"); + } + } let _ = stream.write_all(response.as_bytes()); let _ = stream.flush(); let _ = stream.shutdown(Shutdown::Both); @@ -2752,7 +2966,7 @@ mod tests { where F: Fn(&str) -> (String, String, String) + Send + 'static, { - start_mock_oidc_discovery_server_with_jwks(build_discovery_issuer, max_requests, "RS256", |_| { + start_mock_oidc_discovery_server_with_jwks(build_discovery_issuer, max_requests, "RS256", false, |_| { r#"{"keys":[]}"#.to_string() }) } @@ -2779,6 +2993,7 @@ mod tests { |base| (base.to_string(), format!("{base}/jwks"), "/jwks".to_string()), 4, "ES256", + false, move |fetch| { if fetch == 0 { initial_jwks.clone() @@ -2833,6 +3048,391 @@ mod tests { handle.join().expect("rotating JWKS mock server should exit cleanly"); } + #[tokio::test] + async fn complete_provider_console_login_preserves_hidden_and_issuer_modes() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + for hidden in [false, true] { + for explicit in [false, true] { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let issuer = if explicit { + "https://issuer.example.com".to_string() + } else { + base.clone() + }; + let mut config = + build_mocked_oidc_provider_config("console", &format!("{base}/.well-known/openid-configuration")); + config.hide_from_ui = hidden; + config.issuer = explicit.then(|| issuer.clone()); + config.client_secret = Some(Nonce::new_random().secret().clone()); + let server_config = config.clone(); + let server_base = base.clone(); + let redirect = "https://console.example.com/oauth_callback"; + let (key, jwk) = oidc_es256_key_and_jwk("console"); + let (auth_tx, auth_rx) = tokio::sync::oneshot::channel::>(); + let server = tokio::spawn(async move { + let mut auth_rx = Some(auth_rx); + for expected_path in ["/.well-known/openid-configuration", "/jwks", "/token"] { + let (mut stream, _) = tokio::time::timeout(StdDuration::from_secs(10), listener.accept()) + .await + .unwrap() + .unwrap(); + let mut bytes = Vec::new(); + let header_end = loop { + bytes.push(stream.read_u8().await.unwrap()); + assert!(bytes.len() < 8192); + if bytes.ends_with(b"\r\n\r\n") { + break bytes.len(); + } + }; + let headers = String::from_utf8(bytes).unwrap(); + let request_line = headers.lines().next().unwrap(); + assert_eq!(request_line.split_whitespace().nth(1), Some(expected_path)); + let headers_map: HashMap<_, _> = headers + .lines() + .skip(1) + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_string())) + .collect(); + let body = match expected_path { + "/.well-known/openid-configuration" => { + assert!(request_line.starts_with("GET ")); + assert_eq!(headers_map["accept"], "application/json"); + serde_json::json!({ + "issuer": issuer, "authorization_endpoint": format!("{server_base}/authorize"), + "token_endpoint": format!("{server_base}/token"), "jwks_uri": format!("{server_base}/jwks"), + "response_types_supported": ["code"], "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["ES256"] + }) + } + "/jwks" => { + assert!(request_line.starts_with("GET ")); + assert!(headers_map["accept"].contains("application/json")); + serde_json::json!({"keys": [jwk]}) + } + "/token" => { + assert!(request_line.starts_with("POST ")); + assert_eq!(headers_map["accept"], "application/json"); + assert!(headers_map["content-type"].starts_with("application/x-www-form-urlencoded")); + let length: usize = headers_map["content-length"].parse().unwrap(); + assert!(header_end + length < 16384); + let mut body = vec![0; length]; + stream.read_exact(&mut body).await.unwrap(); + let form: HashMap = url::form_urlencoded::parse(&body).into_owned().collect(); + assert_eq!(form["grant_type"], "authorization_code"); + assert_eq!(form["code"], "test-authorization-code"); + assert_eq!(form["client_id"], server_config.client_id); + assert_eq!(Some(&form["client_secret"]), server_config.client_secret.as_ref()); + assert_eq!(form["redirect_uri"], redirect); + let auth = auth_rx.take().unwrap().await.unwrap(); + let challenge = PkceCodeChallenge::from_code_verifier_sha256(&PkceCodeVerifier::new(form["code_verifier"].clone())); + assert_eq!(challenge.as_str(), auth["code_challenge"]); + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some("console".into()); + let token = jsonwebtoken::encode(&header, &serde_json::json!({ + "iss": issuer, "sub": "existing-user", "aud": server_config.client_id, + "iat": now, "exp": now + 300, "nonce": auth["nonce"], + "email": "user@example.com", "groups": ["readwrite"] + }), &key).unwrap(); + serde_json::json!({"access_token": "test-access-token", "token_type": "Bearer", "id_token": token}) + } + _ => unreachable!(), + }.to_string(); + stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + } + }); + let http_client = ReqwestHttpClient::with_policy(OutboundPolicy::from_allowed_origins(&base).unwrap()); + let discovered = OidcSys::discover_provider(&config, &http_client).await.unwrap(); + let sys = OidcSys { + configs: HashMap::from([(config.id.clone(), config)]), + provider_states: RwLock::new(HashMap::from([("console".into(), discovered)])), + state_store: OidcStateStore::new(), + http_client, + }; + let auth_url = sys.authorize_url("console", redirect, Some("/buckets".into())).await.unwrap(); + let auth_url = Url::parse(&auth_url).unwrap(); + assert_eq!(auth_url.as_str().split('?').next(), Some(format!("{base}/authorize").as_str())); + let auth: HashMap = auth_url.query_pairs().into_owned().collect(); + assert_eq!(auth["response_type"], "code"); + assert_eq!(auth["client_id"], "rustfs-oidc-test"); + assert!(!auth["nonce"].is_empty()); + assert!(!auth["state"].is_empty()); + assert_eq!(auth["redirect_uri"], redirect); + assert_eq!(auth["code_challenge_method"], "S256"); + assert!(auth["scope"].split_whitespace().any(|scope| scope == "openid")); + let state = auth["state"].clone(); + auth_tx.send(auth).unwrap(); + let (claims, provider, session, _) = sys + .exchange_code(&state, "test-authorization-code", redirect) + .await + .unwrap_or_else(|err| panic!("hidden={hidden}, explicit={explicit}: {err}")); + assert_eq!(provider, "console"); + assert_eq!(claims.sub, "existing-user"); + assert_eq!(claims.email, "user@example.com"); + assert_eq!(claims.groups, vec!["readwrite"]); + assert_eq!(session.redirect_after.as_deref(), Some("/buckets")); + assert!(matches!(sys.exchange_code(&state, "test-authorization-code", redirect).await, + Err(error) if error == "invalid or expired OIDC state")); + server.await.unwrap(); + } + } + } + + #[test] + fn workload_metadata_requires_hidden_provider_and_valid_verification_fields() { + let document = serde_json::json!({ + "issuer": "https://issuer.example.com", + "jwks_uri": "https://issuer.example.com/jwks", + "response_types_supported": ["id_token"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["ES256"], + }); + let parse = + |value: &serde_json::Value, hidden| DiscoveredProviderMetadata::parse(&serde_json::to_vec(value).unwrap(), hidden); + let metadata = parse(&document, true).expect("hidden workload metadata should parse"); + assert!(metadata.authorization_endpoint().is_none()); + assert!(metadata.console().err().unwrap().contains("only web identity")); + assert!(parse(&document, false).err().unwrap().contains("authorization_endpoint")); + for field in ["issuer", "jwks_uri", "id_token_signing_alg_values_supported"] { + let mut invalid = document.clone(); + invalid.as_object_mut().unwrap().remove(field); + assert!(parse(&invalid, true).err().unwrap().contains(field), "missing {field}"); + } + for endpoint in [serde_json::Value::Null, serde_json::json!(""), serde_json::json!("not a URL")] { + let mut invalid = document.clone(); + invalid["authorization_endpoint"] = endpoint; + assert!(parse(&invalid, true).is_err(), "invalid endpoint must not select workload metadata"); + } + let mut complete = document; + complete["authorization_endpoint"] = serde_json::json!("https://issuer.example.com/authorize"); + for hidden in [true, false] { + let metadata = parse(&complete, hidden).expect("full providers keep the existing parser"); + assert!(metadata.console().is_ok()); + assert!(metadata.token_endpoint().is_none(), "token endpoint remains optional"); + } + } + + #[test] + fn workload_metadata_rejects_duplicate_fields() { + for hidden in [false, true] { + let document = format!( + r#"{{"issuer":"https://wrong.example.com","issuer":"https://issuer.example.com",{}"jwks_uri":"https://issuer.example.com/jwks","response_types_supported":["id_token"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["ES256"]}}"#, + if hidden { + "" + } else { + r#""authorization_endpoint":"https://issuer.example.com/authorize","# + }, + ); + let error = DiscoveredProviderMetadata::parse(document.as_bytes(), hidden) + .err() + .expect("duplicate issuer must fail"); + assert!(error.contains("duplicate field"), "{error}"); + } + } + + #[test] + fn workload_verifier_preserves_algorithm_secret_and_audience_policy() { + let secret = Nonce::new_random().secret().to_string(); + let mut config = build_mocked_oidc_provider_config("workload", "https://issuer.example.com"); + config.client_secret = Some(secret.clone()); + config.other_audiences = vec!["additional-audience".into()]; + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let payload = serde_json::json!({ + "iss": config.config_url, "sub": "repo:example/project:ref:refs/heads/main", + "aud": [config.client_id, "additional-audience"], "iat": now, "exp": now + 300, + }); + let signed = + jsonwebtoken::encode(&Header::new(Algorithm::HS256), &payload, &EncodingKey::from_secret(secret.as_bytes())).unwrap(); + let token: CoreIdToken = signed.parse().unwrap(); + for workload in [false, true] { + for (algorithms, accepted) in [ + (serde_json::json!(["HS256", "unsupported-future-algorithm"]), true), + (serde_json::json!(["ES256"]), false), + (serde_json::json!(["unsupported-future-algorithm"]), false), + ] { + let mut document = serde_json::json!({ + "issuer": config.config_url, "jwks_uri": "https://issuer.example.com/jwks", + "response_types_supported": ["id_token"], "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": algorithms, + }); + if !workload { + document["authorization_endpoint"] = serde_json::json!("https://issuer.example.com/authorize"); + } + let metadata = DiscoveredProviderMetadata::parse(&serde_json::to_vec(&document).unwrap(), true).unwrap(); + let verifier = metadata + .verifier(&config) + .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); + assert_eq!( + token.claims(&verifier, |_: Option<&Nonce>| Ok(())).is_ok(), + accepted, + "workload={workload}, algorithms={algorithms}" + ); + if accepted { + assert!( + token.claims(&metadata.verifier(&config), |_: Option<&Nonce>| Ok(())).is_err(), + "additional audiences require explicit trust" + ); + let mut wrong_secret = config.clone(); + wrong_secret.client_secret = Some(Nonce::new_random().secret().to_string()); + let verifier = metadata + .verifier(&wrong_secret) + .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud)); + assert!( + token.claims(&verifier, |_: Option<&Nonce>| Ok(())).is_err(), + "incorrect client secret must fail" + ); + } + } + } + } + + #[tokio::test] + async fn workload_discovery_stops_after_forbidden_jwks() { + let requests = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = Arc::clone(&requests); + let (base, handle) = start_mock_oidc_discovery_server_with_jwks( + |base| (base.to_string(), "http://192.168.65.254:8080/jwks".into(), "/jwks".into()), + 2, + "ES256", + true, + move |_| { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + serde_json::json!({"keys": []}).to_string() + }, + ) + .expect("workload discovery mock must bind"); + let mut config = build_mocked_oidc_provider_config("workload", &base); + config.hide_from_ui = true; + let client = ReqwestHttpClient::with_policy(OutboundPolicy::from_allowed_origins(&base).unwrap()); + let error = OidcSys::discover_provider(&config, &client) + .await + .err() + .expect("private JWKS must be blocked"); + assert!(error.starts_with(OIDC_JWKS_BLOCKED_BY_OUTBOUND_POLICY), "{error}"); + handle.join().unwrap(); + assert_eq!( + requests.load(std::sync::atomic::Ordering::SeqCst), + 1, + "a policy denial must not retry discovery" + ); + } + + #[tokio::test] + async fn workload_config_validation_reports_absent_console_endpoints() { + let (base, handle) = start_mock_oidc_discovery_server_with_jwks( + |base| (base.to_string(), format!("{base}/jwks"), "/jwks".into()), + 2, + "ES256", + true, + |_| serde_json::json!({"keys": []}).to_string(), + ) + .expect("workload discovery mock must bind"); + let mut config = build_mocked_oidc_provider_config("workload", &base); + config.hide_from_ui = true; + // Inferred issuers keep the library's discovery URL construction. + config.config_url = format!("{base}/.well-known/openid-configuration?ignored=1#ignored"); + let result = validate_mocked_oidc_provider_config(&config) + .await + .expect("hidden workload configuration should validate"); + assert_eq!(result.issuer, base); + assert!(result.authorization_endpoint.is_none()); + assert!(result.token_endpoint.is_none()); + handle.join().unwrap(); + } + + #[tokio::test] + async fn workload_discovery_verification_and_rotation() { + for explicit_issuer in [false, true] { + let (_, initial_jwk) = oidc_es256_key_and_jwk("initial"); + let (key, rotated_jwk) = oidc_es256_key_and_jwk("rotated"); + let initial_jwks = serde_json::json!({"keys": [initial_jwk]}).to_string(); + let rotated_jwks = serde_json::json!({"keys": [rotated_jwk]}).to_string(); + let (base, handle) = start_mock_oidc_discovery_server_with_jwks( + |base| (base.to_string(), format!("{base}/jwks"), "/jwks".into()), + 4, + "ES256", + true, + move |fetch| { + if fetch == 0 { + initial_jwks.clone() + } else { + rotated_jwks.clone() + } + }, + ) + .expect("workload discovery mock must bind"); + let mut config = build_mocked_oidc_provider_config("workload", &base); + config.hide_from_ui = true; + if explicit_issuer { + config.issuer = Some(base.clone()); + } + let http_client = ReqwestHttpClient::with_policy(OutboundPolicy::from_allowed_origins(&base).unwrap()); + let state = OidcSys::discover_provider(&config, &http_client) + .await + .expect("workload discovery must succeed"); + assert!(state.metadata.authorization_endpoint().is_none()); + let sys = OidcSys { + configs: HashMap::from([(config.id.clone(), config.clone())]), + provider_states: RwLock::new(HashMap::from([(config.id.clone(), state)])), + state_store: OidcStateStore::new(), + http_client, + }; + assert!(sys.has_providers()); + assert!(sys.list_visible_providers().is_empty()); + let error = sys + .authorize_url(&config.id, "https://console.example.com/callback", None) + .await + .unwrap_err(); + assert!(error.contains("only web identity")); + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + let payload = serde_json::json!({ + "iss": base, "sub": "system:serviceaccount:default:reader", "aud": [config.client_id], + "iat": now, "exp": now + 300, "groups": ["readonly"], + "kubernetes.io": {"namespace": "default", "serviceaccount": {"name": "reader"}}, + }); + let mut header = Header::new(Algorithm::ES256); + header.kid = Some("rotated".into()); + let token = jsonwebtoken::encode(&header, &payload, &key).unwrap(); + let (claims, provider) = sys + .verify_web_identity_token(&token) + .await + .expect("rotation must retain workload discovery support"); + assert_eq!(provider, config.id); + assert_eq!(claims.sub, "system:serviceaccount:default:reader"); + assert_eq!(claims.groups, ["readonly"]); + // Repeat after the mock exits: the verified snapshot must be cached. + handle.join().unwrap(); + assert!(sys.verify_web_identity_token(&token).await.is_ok()); + for (field, value, expected) in [ + ("iss", serde_json::json!("https://wrong.example.com"), "issuer"), + ("aud", serde_json::json!("wrong-audience"), "audience"), + ("exp", serde_json::json!(now - 60), "expired"), + ] { + let mut invalid = payload.clone(); + invalid[field] = value; + let token = jsonwebtoken::encode(&header, &invalid, &key).unwrap(); + let error = sys + .verify_web_identity_token(&token) + .await + .expect_err("invalid workload token must fail"); + assert!(error.to_lowercase().contains(expected), "{field}: {error}"); + } + let (wrong_key, _) = oidc_es256_key_and_jwk("wrong"); + let invalid = jsonwebtoken::encode(&header, &payload, &wrong_key).unwrap(); + let error = sys + .verify_web_identity_token(&invalid) + .await + .expect_err("wrong signature must fail"); + assert!(error.to_lowercase().contains("signature"), "{error}"); + assert!( + sys.verify_web_identity_token(&token).await.is_ok(), + "failed refresh must preserve the cached keys" + ); + } + } + fn start_mock_oidc_tls_discovery_server( build_discovery_issuer: F, max_requests: usize, @@ -2958,7 +3558,7 @@ mod tests { Ok(OidcProviderValidationResult { issuer: state.metadata.issuer().to_string(), - authorization_endpoint: state.metadata.authorization_endpoint().to_string(), + authorization_endpoint: state.metadata.authorization_endpoint(), token_endpoint: state.metadata.token_endpoint().map(ToString::to_string), }) } @@ -3558,7 +4158,7 @@ mod tests { provider_states: RwLock::new(HashMap::from([( provider_id.to_string(), ProviderState { - metadata, + metadata: DiscoveredProviderMetadata::Console(Box::new(metadata)), discovered_at: Instant::now(), }, )])), diff --git a/docs/operations/oidc-console-integration.md b/docs/operations/oidc-console-integration.md index d4aec6274..3206b7800 100644 --- a/docs/operations/oidc-console-integration.md +++ b/docs/operations/oidc-console-integration.md @@ -39,7 +39,7 @@ Every provider key can be set as `RUSTFS_IDENTITY_OPENID_` in the process e | `email_claim`, `username_claim` | `RUSTFS_IDENTITY_OPENID_EMAIL_CLAIM`, `RUSTFS_IDENTITY_OPENID_USERNAME_CLAIM` | Identity claims shown in the Console. | | `role_policy` | `RUSTFS_IDENTITY_OPENID_ROLE_POLICY` | One fixed policy for every login from this provider. Connectivity testing only. | | `display_name` | `RUSTFS_IDENTITY_OPENID_DISPLAY_NAME` | Login button label. | -| `hide_from_ui` | `RUSTFS_IDENTITY_OPENID_HIDE_FROM_UI` | Hides the provider from `/oidc/providers`. | +| `hide_from_ui` | `RUSTFS_IDENTITY_OPENID_HIDE_FROM_UI` | Hides the provider from `/oidc/providers`. Required (`on`) for STS workload issuers whose discovery document omits `authorization_endpoint`; see [workload provider requirements](oidc-provider-requirements.md#sts-workload-providers). Complete hidden providers still support direct Console login. | Process-level settings (environment only, never suffixed per provider): diff --git a/docs/operations/oidc-provider-requirements.md b/docs/operations/oidc-provider-requirements.md index 11653fc04..1882df3da 100644 --- a/docs/operations/oidc-provider-requirements.md +++ b/docs/operations/oidc-provider-requirements.md @@ -7,6 +7,8 @@ RustFS is a standard OpenID Connect relying party using the authorization-code f ## Requirements +The table below describes Console login. For endpoint-free STS issuers, use the [workload contract](#sts-workload-providers) below. + | # | Requirement | Details | Code anchor | | --- | --- | --- | --- | | 1 | Discovery document | `RUSTFS_IDENTITY_OPENID_CONFIG_URL` names the provider (issuer base or full discovery URL); RustFS fetches `{issuer}/.well-known/openid-configuration` and needs `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri`, and the standard `*_supported` arrays. When `RUSTFS_IDENTITY_OPENID_ISSUER` is set, the document's `issuer` must equal it exactly; otherwise RustFS tries the issuer candidates derived from the config URL. | `crates/iam/src/oidc.rs` `discover_provider`, `discover_provider_from_config_url` | @@ -20,6 +22,16 @@ RustFS is a standard OpenID Connect relying party using the authorization-code f | 9 | Registered redirect URI | The provider must accept the callback `{public-origin}/rustfs/admin/v3/oidc/callback/{provider_id}`. RustFS picks the origin in this order: the provider's `redirect_uri` (`RUSTFS_IDENTITY_OPENID_REDIRECT_URI`), then `RUSTFS_BROWSER_REDIRECT_URL`, then the request's own scheme and host — the last only when `RUSTFS_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC` is enabled. | `rustfs/src/admin/handlers/oidc.rs` `derive_callback_uri_with_provider_config`, `browser_redirect_url` | | 10 | Logout endpoint (optional) | When discovery advertises `end_session_endpoint`, RustFS builds an RP-initiated logout URL with `id_token_hint`, `client_id`, and `post_logout_redirect_uri`. Without it, logout falls back to the console login page. | `crates/iam/src/oidc.rs` `build_logout_url` (reads `end_session_endpoint` from `ProviderMetadataWithLogout`) | +## STS workload providers + +For workload identity tokens exchanged through `AssumeRoleWithWebIdentity` (for example Kubernetes service-account tokens), set `RUSTFS_IDENTITY_OPENID_HIDE_FROM_UI=on` (`hide_from_ui=on` in persisted configuration, or `hide_from_ui: true` in the admin JSON API) when discovery omits `authorization_endpoint`. The default is off; without this setting, RustFS applies the Console discovery contract and rejects the missing endpoint. + +Endpoint-free workload discovery requires `issuer`, `jwks_uri`, and `id_token_signing_alg_values_supported`. `token_endpoint` is optional; browser authorization endpoints, `response_types_supported`, and `subject_types_supported` are not required for this path. An explicitly null, empty, or malformed `authorization_endpoint` is rejected rather than treated as absent. Configuration validation returns `authorization_endpoint: null` for an accepted workload-only provider. + +Set `RUSTFS_IDENTITY_OPENID_CONFIG_URL` to the issuer/discovery URL and `RUSTFS_IDENTITY_OPENID_CLIENT_ID` to the intended token audience. Configure policy claims or `RUSTFS_IDENTITY_OPENID_ROLE_POLICY` to grant the required RustFS permissions. Signature, issuer, audience, and expiration verification remain enforced, and private endpoints still require the outbound allowlist. JWKS requests accept both `application/json` and `application/jwk-set+json`. + +Workload-only providers cannot perform Console authorization-code login. Hiding a provider with complete discovery metadata only hides its listing; its existing direct Console login remains available. + ## Deployment notes - Behind a load balancer, authorize and callback requests must reach the same RustFS node while the `state` is in flight, or set `RUSTFS_BROWSER_REDIRECT_URL` so the callback URL is stable; the callback error text names both remedies. diff --git a/rustfs/src/admin/handlers/oidc.rs b/rustfs/src/admin/handlers/oidc.rs index 19fc7f951..9ed5afdc4 100644 --- a/rustfs/src/admin/handlers/oidc.rs +++ b/rustfs/src/admin/handlers/oidc.rs @@ -465,7 +465,7 @@ impl Operation for ValidateOidcConfigHandler { valid: true, message: "OIDC configuration is valid".to_string(), issuer: Some(validation.issuer), - authorization_endpoint: Some(validation.authorization_endpoint), + authorization_endpoint: validation.authorization_endpoint, token_endpoint: validation.token_endpoint, }, ) @@ -1296,6 +1296,101 @@ mod tests { use http::{Extensions, HeaderMap, HeaderValue, Uri}; use temp_env::with_var; + #[tokio::test] + #[serial_test::serial] + async fn validate_handler_preserves_workload_null_and_console_endpoints() { + use crate::admin::runtime_sources::{AppContext, publish_test_app_context}; + use http_body_util::BodyExt as _; + use rustfs_iam::store::{Store as _, object::IAM_CONFIG_PREFIX}; + use std::sync::Arc; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + // The admin URL boundary rejects literal loopback hosts. A local proxy + // serves the public-shaped test origin without external DNS or traffic. + let proxy = format!("http://{}", listener.local_addr().unwrap()); + let base = "http://oidc-handler.example.invalid".to_string(); + temp_env::async_with_vars( + [("RUSTFS_OUTBOUND_ALLOW_ORIGINS", Some(base.as_str())), + ("HTTP_PROXY", Some(proxy.as_str())), ("http_proxy", Some(proxy.as_str())), + ("HTTPS_PROXY", None), ("https_proxy", None), ("ALL_PROXY", None), ("all_proxy", None), + ("NO_PROXY", Some("")), ("no_proxy", Some(""))], + async { + let _ = rustfs_credentials::init_global_action_credentials(Some("OIDCVALIDATEROOT".into()), Some("oidcValidateRootSecret123".into())); + let env = rustfs_test_utils::TestECStoreEnv::builder().prefix("oidc_validate_handler") + .disk_count(1).init_bucket_metadata(false).build().await; + rustfs_iam::store::object::ObjectStore::new(Arc::clone(&env.ecstore)) + .save_iam_config(serde_json::json!({"version": 1}), format!("{}/format.json", *IAM_CONFIG_PREFIX)).await.unwrap(); + let iam = rustfs_iam::init_iam_sys(Arc::clone(&env.ecstore)).await.unwrap(); + publish_test_app_context(Arc::new(AppContext::with_default_interfaces( + Arc::clone(&env.ecstore), iam, Arc::new(rustfs_kms::KmsServiceManager::new()), + ))); + let server_base = base.clone(); + let server = tokio::spawn(async move { + for path in ["/.well-known/openid-configuration", "/jwks", "/.well-known/openid-configuration", "/complete/.well-known/openid-configuration", "/complete/jwks"] { + let (mut stream, _) = tokio::time::timeout(std::time::Duration::from_secs(15), listener.accept()).await.unwrap().unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + request.push(stream.read_u8().await.unwrap()); + assert!(request.len() < 8192); + } + let request = String::from_utf8(request).unwrap(); + let target = Url::parse(request.lines().next().unwrap().split_whitespace().nth(1).unwrap()).unwrap(); + assert_eq!(target.origin().ascii_serialization(), server_base); + assert_eq!(target.path(), path); + let mut body = if path.ends_with("/jwks") { serde_json::json!({"keys": []}) } else { + serde_json::json!({"issuer": server_base, "jwks_uri": format!("{server_base}/jwks"), "id_token_signing_alg_values_supported": ["RS256"]}) + }; + if path == "/complete/.well-known/openid-configuration" { + body["authorization_endpoint"] = serde_json::json!(format!("{server_base}/authorize")); + body["token_endpoint"] = serde_json::json!(format!("{server_base}/token")); + body["response_types_supported"] = serde_json::json!(["code"]); + body["subject_types_supported"] = serde_json::json!(["public"]); + } + let body = body.to_string(); + stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap(); + } + }); + for (hidden, complete) in [(true, false), (false, false), (false, true)] { + let document = serde_json::json!({"provider_id": "workload", "client_id": "rustfs-test", "issuer": base, + "config_url": format!("{base}{}/.well-known/openid-configuration", if complete { "/complete" } else { "" }), "hide_from_ui": hidden}); + let request = || { + let mut req = build_oidc_request("/rustfs/admin/v3/oidc/validate", None, None); + req.method = Method::POST; + req.input = Body::from(document.to_string()); + req + }; + let denied = ValidateOidcConfigHandler {}.call(request(), Params::new()).await.unwrap_err(); + assert_eq!(denied.code(), &S3ErrorCode::InvalidRequest); + assert_eq!(denied.message(), Some("authentication required")); + let mut req = request(); + req.credentials = Some(s3s::auth::Credentials { access_key: "OIDCVALIDATEROOT".into(), secret_key: "oidcValidateRootSecret123".into() }); + let result = ValidateOidcConfigHandler {}.call(req, Params::new()).await; + if !hidden && !complete { + let err = result.unwrap_err(); + assert_eq!(err.code(), &S3ErrorCode::InvalidRequest); + assert!(err.message().unwrap().contains("authorization_endpoint")); + continue; + } + let (status, body) = result.unwrap().output; + assert_eq!(status, StatusCode::OK); + let body = body.collect().await.unwrap().to_bytes(); + let response: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(response["valid"], true); + assert_eq!(response["issuer"], base); + if complete { + assert_eq!(response["authorization_endpoint"], format!("{base}/authorize")); + assert_eq!(response["token_endpoint"], format!("{base}/token")); + } else { + assert_eq!(response.get("authorization_endpoint"), Some(&serde_json::Value::Null)); + assert_eq!(response.get("token_endpoint"), Some(&serde_json::Value::Null)); + } + } + server.await.unwrap(); + }, + ).await; + } + fn build_oidc_request( uri: &'static str, host: Option<&'static str>,