mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-07 12:35:54 +00:00
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:
Generated
+1
@@ -9999,6 +9999,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"serial_test",
|
||||
"sha1 0.11.0",
|
||||
"sha2 0.11.0",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
},
|
||||
)])),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user