fix(oidc): support workload discovery and JWKS negotiation (#7348)

* fix(oidc): support workload discovery and JWKS negotiation

* test(oidc): cover existing Console authorization code flows

* test(oidc): cover admin validation and document workload setup
This commit is contained in:
GatewayJ
2026-09-07 14:38:04 +08:00
committed by GitHub
parent ad0c44dc63
commit c9c6bb7a24
7 changed files with 766 additions and 56 deletions
Generated
+1
View File
@@ -9999,6 +9999,7 @@ dependencies = [
"rustls-pki-types",
"serde",
"serde_json",
"serde_with",
"serial_test",
"sha1 0.11.0",
"sha2 0.11.0",
+1
View File
@@ -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"
+1
View File
@@ -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 }
+654 -54
View File
@@ -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<String>,
pub token_endpoint: Option<String>,
}
@@ -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<TokenUrl>,
#[serde_as(as = "serde_with::VecSkipError<_>")]
id_token_signing_alg_values_supported: Vec<CoreJwsSigningAlgorithm>,
#[serde(skip)]
jwks: CoreJsonWebKeySet,
// Discovery is extensible; report unsupported fields without logging values.
#[serde(flatten)]
additional_fields: HashMap<String, serde_json::Value>,
}
#[derive(Clone)]
enum DiscoveredProviderMetadata {
Console(Box<ProviderMetadataWithLogout>),
Workload(Box<WorkloadProviderMetadata>),
}
impl DiscoveredProviderMetadata {
fn parse(body: &[u8], hide_from_ui: bool) -> Result<Self, String> {
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<String> {
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<Url>,
}
impl<'c> AsyncHttpClient<'c> for JwksAcceptClient<'_> {
type Error = OidcHttpError;
type Future = <ReqwestHttpClient as AsyncHttpClient<'c>>::Future;
fn call(&'c self, mut request: http::Request<Vec<u8>>) -> 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<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 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::<ProviderMetadataWithLogout>(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::<HashMap<String, String>>();
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<String, String> = 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<String, String> = 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<F>(
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(),
},
)])),
+1 -1
View File
@@ -39,7 +39,7 @@ Every provider key can be set as `RUSTFS_IDENTITY_OPENID_<KEY>` 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):
@@ -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.
+96 -1
View File
@@ -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>,