// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! OIDC Provider Manager
//!
//! Implements the OpenID Connect Authorization Code Flow with PKCE using the
//! `openidconnect` crate for standards-compliant discovery, token exchange,
//! and ID token verification.

use crate::oidc_state::{OidcAuthSession, OidcLogoutSession, OidcStateStore};
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreJsonWebKeySet};
use openidconnect::{
    AsyncHttpClient, Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, DiscoveryError, IssuerUrl,
    JsonWebKeySetUrl, LogoutRequest, Nonce, PkceCodeChallenge, PkceCodeVerifier, PostLogoutRedirectUrl,
    ProviderMetadataWithLogout, RedirectUrl, RequestTokenError, Scope,
};
use reqwest::{Certificate, Client};
use rustfs_config::oidc::*;
use rustfs_config::server_config::{Config as ServerConfig, KVS};
use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState, MAX_OIDC_RESPONSE_SIZE};
use rustfs_policy::policy::{ClaimLookup, get_claim_case_insensitive};
use rustfs_utils::egress::{ENV_OUTBOUND_ALLOW_ORIGINS, OutboundPolicy, find_outbound_dns_policy_rejection};
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::{HashMap, VecDeque};
use std::fmt;
use std::future::Future;
use std::net::IpAddr;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::{LazyLock, Mutex, MutexGuard, RwLock};
use std::time::{Duration as StdDuration, Instant};
use tokio::time::sleep;
use tracing::{debug, error, warn};
use url::Url;

const LOG_COMPONENT_IAM: &str = "iam";
const LOG_SUBSYSTEM_OIDC: &str = "oidc";
const EVENT_OIDC_DIAGNOSTICS: &str = "oidc_diagnostics";
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_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);
const OIDC_PLUGIN_AUTHN_WINDOW: StdDuration = StdDuration::from_secs(60);

#[derive(Debug, Clone, Copy, Default)]
pub struct OidcPluginAuthnMetricsSnapshot {
    pub failed_requests_minute: u64,
    pub last_fail_seconds: u64,
    pub last_succ_seconds: u64,
    pub succ_avg_rtt_ms_minute: u64,
    pub succ_max_rtt_ms_minute: u64,
    pub total_requests_minute: u64,
}

#[derive(Debug, Clone)]
struct OidcPluginAuthnSample {
    observed_at: Instant,
    succeeded: bool,
    rtt_ms: u64,
}

#[derive(Debug, Default)]
struct OidcPluginAuthnMetrics {
    samples: Mutex<VecDeque<OidcPluginAuthnSample>>,
    last_fail_at: Mutex<Option<Instant>>,
    last_succ_at: Mutex<Option<Instant>>,
}

fn lock_oidc_plugin_authn_metrics<'a, T>(mutex: &'a Mutex<T>, metric: &'static str) -> MutexGuard<'a, T> {
    match mutex.lock() {
        Ok(guard) => guard,
        Err(err) => {
            warn!(metric, "Recovering poisoned OIDC authn metrics lock");
            err.into_inner()
        }
    }
}

fn seconds_since(now: Instant, observed_at: Option<Instant>) -> u64 {
    observed_at
        .map(|instant| now.duration_since(instant).as_secs())
        .unwrap_or_default()
}

impl OidcPluginAuthnMetrics {
    fn record(&self, rtt_ms: u64, succeeded: bool) {
        let now = Instant::now();
        let mut samples = lock_oidc_plugin_authn_metrics(&self.samples, "samples");
        samples.push_back(OidcPluginAuthnSample {
            observed_at: now,
            succeeded,
            rtt_ms,
        });
        while samples
            .front()
            .is_some_and(|sample| now.duration_since(sample.observed_at) > OIDC_PLUGIN_AUTHN_WINDOW)
        {
            samples.pop_front();
        }
        drop(samples);

        if succeeded {
            *lock_oidc_plugin_authn_metrics(&self.last_succ_at, "last_succ_at") = Some(now);
        } else {
            *lock_oidc_plugin_authn_metrics(&self.last_fail_at, "last_fail_at") = Some(now);
        }
    }

    fn snapshot(&self) -> OidcPluginAuthnMetricsSnapshot {
        let now = Instant::now();
        let (total_requests_minute, failed_requests_minute, succ_avg_rtt_ms_minute, succ_max_rtt_ms_minute) = {
            let mut samples = lock_oidc_plugin_authn_metrics(&self.samples, "samples");
            while samples
                .front()
                .is_some_and(|sample| now.duration_since(sample.observed_at) > OIDC_PLUGIN_AUTHN_WINDOW)
            {
                samples.pop_front();
            }

            let mut failed_requests_minute = 0u64;
            let mut successful_requests = 0u64;
            let mut successful_rtt_sum = 0u64;
            let mut succ_max_rtt_ms_minute = 0u64;

            for sample in samples.iter() {
                if sample.succeeded {
                    successful_requests += 1;
                    successful_rtt_sum += sample.rtt_ms;
                    succ_max_rtt_ms_minute = succ_max_rtt_ms_minute.max(sample.rtt_ms);
                } else {
                    failed_requests_minute += 1;
                }
            }

            let succ_avg_rtt_ms_minute = successful_rtt_sum.checked_div(successful_requests).unwrap_or_default();

            (
                samples.len() as u64,
                failed_requests_minute,
                succ_avg_rtt_ms_minute,
                succ_max_rtt_ms_minute,
            )
        };

        let last_fail_seconds = seconds_since(now, *lock_oidc_plugin_authn_metrics(&self.last_fail_at, "last_fail_at"));
        let last_succ_seconds = seconds_since(now, *lock_oidc_plugin_authn_metrics(&self.last_succ_at, "last_succ_at"));

        OidcPluginAuthnMetricsSnapshot {
            failed_requests_minute,
            last_fail_seconds,
            last_succ_seconds,
            succ_avg_rtt_ms_minute,
            succ_max_rtt_ms_minute,
            total_requests_minute,
        }
    }
}

static OIDC_PLUGIN_AUTHN_METRICS: LazyLock<OidcPluginAuthnMetrics> = LazyLock::new(OidcPluginAuthnMetrics::default);

pub fn oidc_plugin_authn_metrics_snapshot() -> OidcPluginAuthnMetricsSnapshot {
    OIDC_PLUGIN_AUTHN_METRICS.snapshot()
}

/// Header names whose values may carry OIDC secrets (client credentials, cookies,
/// bearer tokens). Their values are never emitted to logs, only their byte length.
const SENSITIVE_HEADER_NAMES: [&str; 4] = ["authorization", "proxy-authorization", "cookie", "set-cookie"];

fn is_sensitive_header(name: &str) -> bool {
    SENSITIVE_HEADER_NAMES
        .iter()
        .any(|candidate| name.eq_ignore_ascii_case(candidate))
}

fn format_http_headers(headers: &http::HeaderMap) -> String {
    headers
        .iter()
        .map(|(name, value)| {
            if is_sensitive_header(name.as_str()) {
                format!("{}=<redacted len={}>", name.as_str(), value.as_bytes().len())
            } else {
                let value = value.to_str().unwrap_or("<non-utf8>");
                format!("{}={}", name.as_str(), value)
            }
        })
        .collect::<Vec<_>>()
        .join("; ")
}

#[derive(Debug, Default)]
struct TokenResponseBodyShape {
    json_object: bool,
    json_keys: String,
    has_access_token: bool,
    has_id_token: bool,
    has_token_type: bool,
    has_expires_in: bool,
    has_error: bool,
    has_error_description: bool,
    looks_like_html: bool,
}

fn inspect_token_response_body(body: &[u8]) -> TokenResponseBodyShape {
    let mut shape = TokenResponseBodyShape {
        looks_like_html: body
            .iter()
            .copied()
            .find(|byte| !byte.is_ascii_whitespace())
            .is_some_and(|byte| byte == b'<'),
        ..Default::default()
    };

    let Ok(value) = serde_json::from_slice::<serde_json::Value>(body) else {
        return shape;
    };
    let Some(object) = value.as_object() else {
        return shape;
    };

    shape.json_object = true;
    shape.has_access_token = object.contains_key("access_token");
    shape.has_id_token = object.contains_key("id_token");
    shape.has_token_type = object.contains_key("token_type");
    shape.has_expires_in = object.contains_key("expires_in");
    shape.has_error = object.contains_key("error");
    shape.has_error_description = object.contains_key("error_description");

    let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
    keys.sort_unstable();
    keys.truncate(16);
    shape.json_keys = keys.join(",");

    shape
}

fn oidc_http_error_diagnostics(error: &OidcHttpError) -> (&'static str, String) {
    match error {
        OidcHttpError::Reqwest(err) if err.is_timeout() => ("timeout", String::new()),
        OidcHttpError::Reqwest(err) if err.is_connect() => ("connect", String::new()),
        OidcHttpError::Reqwest(err) if err.status().is_some() => {
            ("http_status", err.status().map(|status| status.as_u16().to_string()).unwrap_or_default())
        }
        OidcHttpError::Reqwest(_) => ("request", String::new()),
        OidcHttpError::Http(_) => ("http_build", String::new()),
        OidcHttpError::ExtraRootCa(_) => ("extra_root_ca", String::new()),
        OidcHttpError::ForbiddenOutbound(_) => ("forbidden_outbound", String::new()),
        OidcHttpError::ResponseTooLarge(limit) => ("response_too_large", limit.to_string()),
    }
}

// ---- HTTP Client Adapter ----

/// Error type for the OIDC HTTP client adapter.
#[derive(Debug)]
pub enum OidcHttpError {
    Reqwest(reqwest::Error),
    Http(http::Error),
    ExtraRootCa(String),
    /// The outbound destination was rejected by the shared egress policy before any
    /// connection was attempted (invalid URL, loopback/link-local/metadata/private IP,
    /// or a malformed allow-origins configuration).
    ForbiddenOutbound(String),
    /// The provider response body exceeded [`MAX_OIDC_RESPONSE_SIZE`] and was abandoned
    /// instead of being buffered in full.
    ResponseTooLarge(usize),
}

impl std::fmt::Display for OidcHttpError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Reqwest(e) => write!(f, "{e}"),
            Self::Http(e) => write!(f, "{e}"),
            Self::ExtraRootCa(reason) => write!(f, "failed to load OIDC extra root CA bundle: {reason}"),
            Self::ForbiddenOutbound(reason) => write!(f, "outbound request rejected: {reason}"),
            Self::ResponseTooLarge(limit) => write!(f, "oidc response body exceeds {limit} bytes"),
        }
    }
}

impl std::error::Error for OidcHttpError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Reqwest(e) => Some(e),
            Self::Http(e) => Some(e),
            Self::ExtraRootCa(_) | Self::ForbiddenOutbound(_) | Self::ResponseTooLarge(_) => None,
        }
    }
}

#[derive(Clone, Debug, Default)]
pub struct OidcExtraRootCaMaterial {
    pub generation: u64,
    pub root_ca_pem: Option<Vec<u8>>,
}

type OidcExtraRootCaFuture = Pin<Box<dyn Future<Output = Result<OidcExtraRootCaMaterial, String>> + Send>>;
type OidcExtraRootCaLoader = dyn Fn() -> OidcExtraRootCaFuture + Send + Sync;

#[derive(Clone)]
pub struct OidcExtraRootCaProvider {
    loader: Arc<OidcExtraRootCaLoader>,
}

impl OidcExtraRootCaProvider {
    pub fn new<F, Fut>(loader: F) -> Self
    where
        F: Fn() -> Fut + Send + Sync + 'static,
        Fut: Future<Output = Result<OidcExtraRootCaMaterial, String>> + Send + 'static,
    {
        Self {
            loader: Arc::new(move || Box::pin(loader())),
        }
    }

    async fn load(&self) -> Result<OidcExtraRootCaMaterial, String> {
        (self.loader)().await
    }
}

#[derive(Clone, Default)]
struct CachedOidcExtraRootCerts {
    generation: u64,
    initialized: bool,
    certs: Vec<Certificate>,
}

/// HTTP client adapter bridging reqwest 0.13 to the `openidconnect` `AsyncHttpClient` trait.
///
/// A fresh client is built for every request so the destination is re-validated and the
/// resolved IP is re-classified at connection time. This closes the SSRF / DNS-rebinding
/// gap where a one-shot URL string check is bypassed by a hostname that resolves to an
/// internal address only at connection time, and it also covers endpoints discovered from
/// the provider metadata (JWKS, token) rather than only the operator-configured `config_url`.
pub(crate) struct ReqwestHttpClient {
    /// `None` in production: the process-cached outbound policy from the environment is used.
    /// `Some(..)` only in tests, to explicitly allow a loopback mock endpoint.
    policy_override: Option<OutboundPolicy>,
    extra_root_certs: Arc<RwLock<CachedOidcExtraRootCerts>>,
    extra_root_ca_provider: Option<OidcExtraRootCaProvider>,
    #[cfg(test)]
    dns_resolver_override: Option<Arc<dyn reqwest::dns::Resolve>>,
}

fn parse_oidc_extra_root_certs(source: &str, pem: &[u8]) -> Result<Vec<Certificate>, String> {
    if pem.iter().all(|byte| byte.is_ascii_whitespace()) {
        return Ok(Vec::new());
    }
    Certificate::from_pem_bundle(pem).map_err(|err| format!("failed to parse OIDC extra root CA bundle from {source}: {err}"))
}

fn oidc_extra_root_certs(root_ca_pem: Option<&[u8]>) -> Result<Vec<Certificate>, String> {
    match root_ca_pem {
        Some(pem) => parse_oidc_extra_root_certs("RustFS outbound TLS material", pem),
        None => Ok(Vec::new()),
    }
}

/// Build a reqwest client pinned to the shared outbound egress policy for a single request.
///
/// [`OutboundPolicy::resolver_for`] validates the URL shape and rejects loopback,
/// link-local, metadata, multicast and unauthorized private addresses up front, and the
/// returned `OutboundDnsResolver` re-resolves and re-classifies the host on every new
/// connection so DNS rebinding fails closed. Redirects are not followed: a redirect target
/// would otherwise skip URL-shape re-validation. The timeouts bound how long a slow or
/// stalled provider can pin the calling task.
fn build_oidc_http_client(
    uri: &str,
    policy_override: Option<&OutboundPolicy>,
    extra_root_certs: &[Certificate],
    #[cfg(test)] dns_resolver_override: Option<Arc<dyn reqwest::dns::Resolve>>,
) -> Result<(Client, Url), OidcHttpError> {
    let url = Url::parse(uri).map_err(|_| OidcHttpError::ForbiddenOutbound("invalid outbound OIDC URL".to_string()))?;
    let resolver = match policy_override {
        Some(policy) => policy.resolver_for(&url),
        None => OutboundPolicy::from_env_cached()
            .map_err(|err| OidcHttpError::ForbiddenOutbound(err.to_string()))?
            .resolver_for(&url),
    }
    .map_err(|err| {
        let base = err.to_string();
        let origin = url.origin().ascii_serialization();
        let can_allow_origin =
            OutboundPolicy::from_allowed_origins(&origin).is_ok_and(|allowlisted| allowlisted.validate_url(&url).is_ok());
        oidc_forbidden_outbound_error(&url, base, can_allow_origin)
    })?;
    let bypass_proxy = should_bypass_proxy_for_oidc_uri(uri);
    #[cfg(test)]
    let bypass_proxy = bypass_proxy || dns_resolver_override.is_some();
    #[cfg(test)]
    let resolver: Arc<dyn reqwest::dns::Resolve> = dns_resolver_override.unwrap_or_else(|| Arc::new(resolver));

    let mut builder = reqwest::Client::builder()
        .dns_resolver(resolver)
        .redirect(reqwest::redirect::Policy::none())
        .timeout(OIDC_HTTP_REQUEST_TIMEOUT)
        .connect_timeout(OIDC_HTTP_CONNECT_TIMEOUT);
    if bypass_proxy {
        builder = builder.no_proxy();
    }
    if !extra_root_certs.is_empty() {
        builder = builder.tls_certs_merge(extra_root_certs.iter().cloned());
    }
    builder.build().map(|client| (client, url)).map_err(OidcHttpError::Reqwest)
}

fn oidc_forbidden_outbound_error(url: &Url, base: String, can_allow_origin: bool) -> OidcHttpError {
    let reason = if can_allow_origin {
        let origin = url.origin().ascii_serialization();
        format!(
            "{base}; add {origin} to {ENV_OUTBOUND_ALLOW_ORIGINS} (comma-separated) and restart RustFS to allow this operator-owned OIDC provider (origin only, no path)"
        )
    } else {
        base
    };
    OidcHttpError::ForbiddenOutbound(reason)
}

fn oidc_http_error_from_reqwest(url: &Url, error: reqwest::Error) -> OidcHttpError {
    if let Some(rejection) = find_outbound_dns_policy_rejection(&error) {
        let base = rejection.to_string();
        return oidc_forbidden_outbound_error(url, base, rejection.allow_origin_can_recover());
    }
    OidcHttpError::Reqwest(error)
}

/// Buffer a provider response body, failing closed once `limit` bytes have been seen.
///
/// `Response::bytes` would buffer the whole body unconditionally, so a hostile or compromised
/// provider endpoint could stream an arbitrarily large (or endless) body into memory.
async fn read_bounded_response_body(response: reqwest::Response, limit: usize) -> Result<Vec<u8>, OidcHttpError> {
    if response.content_length().is_some_and(|len| len > limit as u64) {
        return Err(OidcHttpError::ResponseTooLarge(limit));
    }

    let mut response = response;
    let mut body = Vec::new();
    while let Some(chunk) = response.chunk().await.map_err(OidcHttpError::Reqwest)? {
        if body.len() + chunk.len() > limit {
            return Err(OidcHttpError::ResponseTooLarge(limit));
        }
        body.extend_from_slice(&chunk);
    }
    Ok(body)
}

fn should_bypass_proxy_for_oidc_uri(uri: &str) -> bool {
    let Some(host) = Url::parse(uri).ok().and_then(|url| url.host_str().map(str::to_owned)) else {
        return false;
    };
    let host = host.trim_matches(['[', ']']);

    host.eq_ignore_ascii_case("localhost") || host.parse::<IpAddr>().is_ok_and(|addr| addr.is_loopback())
}

impl ReqwestHttpClient {
    fn new() -> Result<Self, String> {
        Self::new_with_extra_root_certs(Vec::new())
    }

    fn extra_root_cert_cache(certs: Vec<Certificate>) -> Arc<RwLock<CachedOidcExtraRootCerts>> {
        Arc::new(RwLock::new(CachedOidcExtraRootCerts {
            generation: 0,
            initialized: true,
            certs,
        }))
    }

    fn new_with_extra_root_certs(extra_root_certs: Vec<Certificate>) -> Result<Self, String> {
        Ok(Self {
            policy_override: None,
            extra_root_certs: Self::extra_root_cert_cache(extra_root_certs),
            extra_root_ca_provider: None,
            #[cfg(test)]
            dns_resolver_override: None,
        })
    }

    fn new_with_extra_root_ca_provider(extra_root_ca_provider: OidcExtraRootCaProvider) -> Result<Self, String> {
        Ok(Self {
            policy_override: None,
            extra_root_certs: Arc::new(RwLock::new(CachedOidcExtraRootCerts::default())),
            extra_root_ca_provider: Some(extra_root_ca_provider),
            #[cfg(test)]
            dns_resolver_override: None,
        })
    }

    async fn current_extra_root_certs(&self) -> Result<Vec<Certificate>, OidcHttpError> {
        let Some(provider) = self.extra_root_ca_provider.as_ref() else {
            return self
                .extra_root_certs
                .read()
                .map(|cache| cache.certs.clone())
                .map_err(|e| OidcHttpError::ExtraRootCa(format!("extra root certificate cache lock poisoned: {e}")));
        };

        let material = provider.load().await.map_err(OidcHttpError::ExtraRootCa)?;
        if let Ok(cache) = self.extra_root_certs.read()
            && cache.initialized
            && cache.generation == material.generation
        {
            return Ok(cache.certs.clone());
        }

        let certs = oidc_extra_root_certs(material.root_ca_pem.as_deref()).map_err(OidcHttpError::ExtraRootCa)?;
        let mut cache = self
            .extra_root_certs
            .write()
            .map_err(|e| OidcHttpError::ExtraRootCa(format!("extra root certificate cache lock poisoned: {e}")))?;
        cache.generation = material.generation;
        cache.initialized = true;
        cache.certs = certs.clone();
        Ok(certs)
    }

    /// Test-only constructor that pins outbound requests to an explicit policy, so a
    /// loopback mock server can be reached without depending on process-wide environment.
    #[cfg(test)]
    fn with_policy(policy: OutboundPolicy) -> Self {
        Self {
            policy_override: Some(policy),
            extra_root_certs: Self::extra_root_cert_cache(Vec::new()),
            extra_root_ca_provider: None,
            dns_resolver_override: None,
        }
    }

    #[cfg(test)]
    fn with_policy_and_extra_root_certs(policy: OutboundPolicy, extra_root_certs: Vec<Certificate>) -> Self {
        Self {
            policy_override: Some(policy),
            extra_root_certs: Self::extra_root_cert_cache(extra_root_certs),
            extra_root_ca_provider: None,
            dns_resolver_override: None,
        }
    }

    #[cfg(test)]
    fn with_policy_and_extra_root_ca_provider(policy: OutboundPolicy, extra_root_ca_provider: OidcExtraRootCaProvider) -> Self {
        Self {
            policy_override: Some(policy),
            extra_root_certs: Arc::new(RwLock::new(CachedOidcExtraRootCerts::default())),
            extra_root_ca_provider: Some(extra_root_ca_provider),
            dns_resolver_override: None,
        }
    }

    #[cfg(test)]
    fn with_policy_and_dns_resolver(policy: OutboundPolicy, resolver: Arc<dyn reqwest::dns::Resolve>) -> Self {
        Self {
            policy_override: Some(policy),
            extra_root_certs: Self::extra_root_cert_cache(Vec::new()),
            extra_root_ca_provider: None,
            dns_resolver_override: Some(resolver),
        }
    }
}

impl<'c> AsyncHttpClient<'c> for ReqwestHttpClient {
    type Error = OidcHttpError;
    type Future = Pin<Box<dyn Future<Output = Result<http::Response<Vec<u8>>, Self::Error>> + Send + 'c>>;

    fn call(&'c self, request: http::Request<Vec<u8>>) -> Self::Future {
        Box::pin(async move {
            let started_at = Instant::now();
            let (parts, body) = request.into_parts();
            let method = parts.method.clone();
            let uri = parts.uri.to_string();
            if tracing::enabled!(tracing::Level::DEBUG) {
                let request_headers = format_http_headers(&parts.headers);
                debug!(
                    event = EVENT_OIDC_HTTP,
                    component = LOG_COMPONENT_IAM,
                    subsystem = LOG_SUBSYSTEM_OIDC,
                    result = "request",
                    method = %method,
                    uri = %uri,
                    request_headers = %request_headers,
                    request_body_len = body.len(),
                    "oidc outbound http"
                );
            }

            let extra_root_certs = self.current_extra_root_certs().await?;
            let (client, url) = build_oidc_http_client(
                &uri,
                self.policy_override.as_ref(),
                &extra_root_certs,
                #[cfg(test)]
                self.dns_resolver_override.clone(),
            )?;
            let response = client
                .request(parts.method, uri.clone())
                .headers(parts.headers)
                .body(body)
                .send()
                .await;

            let elapsed_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64;
            let succeeded = response.as_ref().is_ok_and(|resp| resp.status().is_success());
            OIDC_PLUGIN_AUTHN_METRICS.record(elapsed_ms, succeeded);

            let response = response.map_err(|err| {
                let error = oidc_http_error_from_reqwest(&url, err);
                error!(
                    event = EVENT_OIDC_HTTP,
                    component = LOG_COMPONENT_IAM,
                    subsystem = LOG_SUBSYSTEM_OIDC,
                    result = "request_failed",
                    method = %method,
                    uri = %uri,
                    elapsed_ms,
                    error = %error,
                    "oidc outbound http"
                );
                error
            })?;

            let status = response.status();
            let headers = response.headers().clone();
            let body_bytes = read_bounded_response_body(response, MAX_OIDC_RESPONSE_SIZE)
                .await
                .map_err(|err| {
                    error!(
                        event = EVENT_OIDC_HTTP,
                        component = LOG_COMPONENT_IAM,
                        subsystem = LOG_SUBSYSTEM_OIDC,
                        result = "response_body_failed",
                        method = %method,
                        uri = %uri,
                        status = status.as_u16(),
                        elapsed_ms,
                        error = %err,
                        "oidc outbound http"
                    );
                    err
                })?;
            if tracing::enabled!(tracing::Level::DEBUG) {
                let response_headers = format_http_headers(&headers);
                debug!(
                    event = EVENT_OIDC_HTTP,
                    component = LOG_COMPONENT_IAM,
                    subsystem = LOG_SUBSYSTEM_OIDC,
                    result = "response",
                    method = %method,
                    uri = %uri,
                    status = status.as_u16(),
                    status_success = status.is_success(),
                    elapsed_ms,
                    response_headers = %response_headers,
                    response_body_len = body_bytes.len(),
                    "oidc outbound http"
                );
            }

            let mut http_response = http::Response::builder()
                .status(status)
                .body(body_bytes)
                .map_err(OidcHttpError::Http)?;
            *http_response.headers_mut() = headers;

            Ok(http_response)
        })
    }
}

// ---- Public types (unchanged API) ----

const REDACTED_SECRET: &str = "***redacted***";

fn redacted_optional_secret(value: Option<&str>) -> &'static str {
    value.filter(|secret| !secret.is_empty()).map_or("", |_| REDACTED_SECRET)
}

/// Parsed configuration for a single OIDC provider.
#[derive(Clone, PartialEq, Eq)]
pub struct OidcProviderConfig {
    pub id: String,
    pub enabled: bool,
    pub config_url: String,
    pub issuer: Option<String>,
    pub client_id: String,
    pub client_secret: Option<String>,
    pub scopes: Vec<String>,
    pub other_audiences: Vec<String>,
    pub redirect_uri: Option<String>,
    pub redirect_uri_dynamic: bool,
    pub claim_name: String,
    pub claim_prefix: String,
    pub role_policy: String,
    pub display_name: String,
    pub groups_claim: String,
    pub roles_claim: String,
    pub email_claim: String,
    pub username_claim: String,
    pub hide_from_ui: bool,
}

impl fmt::Debug for OidcProviderConfig {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("OidcProviderConfig")
            .field("id", &self.id)
            .field("enabled", &self.enabled)
            .field("config_url", &self.config_url)
            .field("issuer", &self.issuer)
            .field("client_id", &self.client_id)
            .field("client_secret", &redacted_optional_secret(self.client_secret.as_deref()))
            .field("scopes", &self.scopes)
            .field("other_audiences", &self.other_audiences)
            .field("redirect_uri", &self.redirect_uri)
            .field("redirect_uri_dynamic", &self.redirect_uri_dynamic)
            .field("claim_name", &self.claim_name)
            .field("claim_prefix", &self.claim_prefix)
            .field("role_policy", &self.role_policy)
            .field("display_name", &self.display_name)
            .field("groups_claim", &self.groups_claim)
            .field("roles_claim", &self.roles_claim)
            .field("email_claim", &self.email_claim)
            .field("username_claim", &self.username_claim)
            .field("hide_from_ui", &self.hide_from_ui)
            .finish()
    }
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum OidcProviderConfigSource {
    Env,
    Persisted,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourcedOidcProviderConfig {
    pub config: OidcProviderConfig,
    pub source: OidcProviderConfigSource,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct OidcProviderValidationResult {
    pub issuer: String,
    pub authorization_endpoint: String,
    pub token_endpoint: Option<String>,
}

/// Summary info about a provider, returned to the console.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcProviderSummary {
    pub provider_id: String,
    pub display_name: String,
}

/// Claims extracted from an OIDC ID token.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct OidcClaims {
    pub sub: String,
    pub email: String,
    pub username: String,
    pub groups: Vec<String>,
    pub raw: HashMap<String, serde_json::Value>,
}

// ---- Internal provider state ----

/// Discovered OIDC provider metadata.
/// We store metadata (which includes JWKS after discovery) separately rather than
/// a `CoreClient` because the crate uses type-state generics that make storing
/// the configured client in a HashMap impractical. The client is reconstructed
/// on-the-fly from metadata when needed.
#[derive(Clone)]
struct ProviderState {
    metadata: ProviderMetadataWithLogout,
    discovered_at: Instant,
}

impl ProviderState {
    fn is_stale(&self) -> bool {
        self.discovered_at.elapsed() >= OIDC_JWKS_REFRESH_INTERVAL
    }
}

// ---- Core OIDC system ----

/// Global OIDC manager for all configured providers.
pub struct OidcSys {
    configs: HashMap<String, OidcProviderConfig>,
    provider_states: RwLock<HashMap<String, ProviderState>>,
    state_store: OidcStateStore,
    http_client: ReqwestHttpClient,
}

fn trusted_aud(other_audiences: &[String], audience: &Audience) -> bool {
    for aud in other_audiences {
        if audience.as_str() == aud.as_str() {
            return true;
        }
    }
    false
}

impl OidcSys {
    /// Parse environment variables and discover all configured OIDC providers.
    pub async fn new() -> Result<Self, String> {
        Self::new_with_extra_root_ca(None).await
    }

    /// Parse environment variables and discover providers with an additional outbound root CA bundle.
    pub(crate) async fn new_with_extra_root_ca(root_ca_pem: Option<&[u8]>) -> Result<Self, String> {
        let http_client = ReqwestHttpClient::new_with_extra_root_certs(oidc_extra_root_certs(root_ca_pem)?)?;
        Self::new_with_http_client(http_client).await
    }

    pub(crate) async fn new_with_extra_root_ca_provider(extra_root_ca_provider: OidcExtraRootCaProvider) -> Result<Self, String> {
        let http_client = ReqwestHttpClient::new_with_extra_root_ca_provider(extra_root_ca_provider)?;
        http_client.current_extra_root_certs().await.map_err(|err| err.to_string())?;
        Self::new_with_http_client(http_client).await
    }

    async fn new_with_http_client(http_client: ReqwestHttpClient) -> Result<Self, String> {
        let server_config = crate::server_config::current_server_config();
        let parsed_configs = load_effective_oidc_provider_configs(server_config.as_ref());
        let mut configs = HashMap::new();
        let mut provider_states = HashMap::new();

        for sourced_config in parsed_configs {
            let config = sourced_config.config;
            if !config.enabled {
                debug!(provider = %config.id, "OIDC provider disabled");
                continue;
            }

            match Self::discover_provider(&config, &http_client).await {
                Ok(state) => {
                    debug!(provider = %config.id, "OIDC provider discovered");
                    provider_states.insert(config.id.clone(), state);
                    configs.insert(config.id.clone(), config);
                }
                Err(e) => {
                    error!(
                        event = EVENT_OIDC_DIAGNOSTICS,
                        component = LOG_COMPONENT_IAM,
                        subsystem = LOG_SUBSYSTEM_OIDC,
                        result = "provider_discovery_failed",
                        provider_id = %config.id,
                        config_url = %config.config_url,
                        client_id = %config.client_id,
                        scopes = ?config.scopes,
                        redirect_uri = %config.redirect_uri.as_deref().unwrap_or(""),
                        redirect_uri_dynamic = config.redirect_uri_dynamic,
                        error = %e,
                        "oidc provider discovery failed"
                    );
                }
            }
        }

        Ok(Self {
            configs,
            provider_states: RwLock::new(provider_states),
            state_store: OidcStateStore::new(),
            http_client,
        })
    }

    /// Create an OidcSys with no providers (useful for when OIDC is not configured).
    pub fn empty() -> Result<Self, String> {
        Ok(Self {
            configs: HashMap::new(),
            provider_states: RwLock::new(HashMap::new()),
            state_store: OidcStateStore::new(),
            http_client: ReqwestHttpClient::new()?,
        })
    }

    /// Return true if any OIDC providers are configured and enabled.
    pub fn has_providers(&self) -> bool {
        !self.configs.is_empty()
    }

    /// List all providers (including hidden ones). Used by site-replication and admin config.
    pub fn list_providers(&self) -> Vec<OidcProviderSummary> {
        self.configs
            .values()
            .map(|c| OidcProviderSummary {
                provider_id: c.id.clone(),
                display_name: c.display_name.clone(),
            })
            .collect()
    }

    /// List only visible providers (excludes those with `hide_from_ui = true`).
    pub fn list_visible_providers(&self) -> Vec<OidcProviderSummary> {
        self.configs
            .values()
            .filter(|c| !c.hide_from_ui)
            .map(|c| OidcProviderSummary {
                provider_id: c.id.clone(),
                display_name: c.display_name.clone(),
            })
            .collect()
    }

    /// Build the PKCE authorization URL for a provider, store state in the state store.
    pub async fn authorize_url(
        &self,
        provider_id: &str,
        redirect_uri: &str,
        redirect_after: Option<String>,
    ) -> Result<String, String> {
        let config = self
            .configs
            .get(provider_id)
            .ok_or_else(|| format!("unknown OIDC provider: {provider_id}"))?;
        let state = self.ensure_provider_state(provider_id, config).await?;

        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();

        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(),
            ClientId::new(config.client_id.clone()),
            config.client_secret.as_ref().map(|s| ClientSecret::new(s.clone())),
        )
        .set_auth_type(AuthType::RequestBody);

        let mut auth_req =
            client.authorize_url(CoreAuthenticationFlow::AuthorizationCode, CsrfToken::new_random, Nonce::new_random);
        auth_req = auth_req.set_redirect_uri(Cow::Owned(redirect));

        for scope in &config.scopes {
            auth_req = auth_req.add_scope(Scope::new(scope.clone()));
        }

        auth_req = auth_req.set_pkce_challenge(pkce_challenge);

        let (auth_url, csrf_token, nonce) = auth_req.url();

        // Store the state for callback validation
        self.state_store
            .insert(
                csrf_token.secret().clone(),
                OidcAuthSession {
                    provider_id: provider_id.to_string(),
                    pkce_verifier: pkce_verifier.secret().clone(),
                    nonce: nonce.secret().clone(),
                    redirect_after,
                },
            )
            .await;

        Ok(auth_url.to_string())
    }

    /// Exchange an authorization code for tokens and extract claims.
    pub async fn exchange_code(
        &self,
        state: &str,
        code: &str,
        redirect_uri: &str,
    ) -> Result<(OidcClaims, String, OidcAuthSession, String), String> {
        // Retrieve and consume the state (single-use)
        let session = self
            .state_store
            .take(state)
            .await
            .ok_or_else(|| "invalid or expired OIDC state".to_string())?;

        let config = self
            .configs
            .get(&session.provider_id)
            .ok_or_else(|| format!("unknown provider: {}", session.provider_id))?;
        let provider_state = self.get_provider_state(&session.provider_id)?;
        let issuer = provider_state.metadata.issuer().to_string();
        let token_endpoint = provider_state
            .metadata
            .token_endpoint()
            .map(ToString::to_string)
            .unwrap_or_default();

        // Construct CoreClient on-the-fly with JWKS from discovery
        let client = CoreClient::from_provider_metadata(
            provider_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);

        let redirect = RedirectUrl::new(redirect_uri.to_string()).map_err(|e| format!("invalid redirect URI: {e}"))?;

        // Exchange code for tokens
        let token_response = client
            .exchange_code(AuthorizationCode::new(code.to_string()))
            .map_err(|e| {
                error!(
                    event = EVENT_OIDC_DIAGNOSTICS,
                    component = LOG_COMPONENT_IAM,
                    subsystem = LOG_SUBSYSTEM_OIDC,
                    result = "token_endpoint_missing",
                    provider_id = %session.provider_id,
                    config_url = %config.config_url,
                    issuer = %issuer,
                    client_id = %config.client_id,
                    redirect_uri = %redirect_uri,
                    scopes = ?config.scopes,
                    error = %e,
                    "oidc token exchange failed"
                );
                format!(
                    "token endpoint not configured: {e}: provider_id={}, config_url={}, issuer={}, redirect_uri={}, client_id={}",
                    session.provider_id, config.config_url, issuer, redirect_uri, config.client_id
                )
            })?
            .set_pkce_verifier(PkceCodeVerifier::new(session.pkce_verifier.clone()))
            .set_redirect_uri(Cow::Owned(redirect))
            .request_async(&self.http_client)
            .await
            .map_err(|e| match &e {
                RequestTokenError::ServerResponse(response) => {
                    error!(
                        event = EVENT_OIDC_DIAGNOSTICS,
                        component = LOG_COMPONENT_IAM,
                        subsystem = LOG_SUBSYSTEM_OIDC,
                        result = "token_server_response",
                        provider_id = %session.provider_id,
                        config_url = %config.config_url,
                        issuer = %issuer,
                        token_endpoint = %token_endpoint,
                        client_id = %config.client_id,
                        client_secret_configured = config.client_secret.as_deref().is_some_and(|secret| !secret.is_empty()),
                        redirect_uri = %redirect_uri,
                        scopes = ?config.scopes,
                        oauth_error = %response.error(),
                        oauth_error_description = %response.error_description().map(String::as_str).unwrap_or(""),
                        oauth_error_uri = %response.error_uri().map(String::as_str).unwrap_or(""),
                        error = %e,
                        "oidc token exchange failed"
                    );
                    format!(
                        "token exchange failed: {e}: stage=token_server_response, provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}, oauth_error={}, oauth_error_description={}",
                        session.provider_id,
                        config.config_url,
                        issuer,
                        token_endpoint,
                        redirect_uri,
                        config.client_id,
                        response.error(),
                        response.error_description().map(String::as_str).unwrap_or("")
                    )
                }
                RequestTokenError::Request(err) => {
                    let (request_error_kind, request_error_status) = oidc_http_error_diagnostics(err);
                    error!(
                        event = EVENT_OIDC_DIAGNOSTICS,
                        component = LOG_COMPONENT_IAM,
                        subsystem = LOG_SUBSYSTEM_OIDC,
                        result = "token_request_failed",
                        provider_id = %session.provider_id,
                        config_url = %config.config_url,
                        issuer = %issuer,
                        token_endpoint = %token_endpoint,
                        client_id = %config.client_id,
                        redirect_uri = %redirect_uri,
                        request_error_kind = %request_error_kind,
                        request_error_status = %request_error_status,
                        error = %err,
                        "oidc token exchange failed"
                    );
                    format!(
                        "token exchange failed: stage=token_request_failed, provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}, request_error_kind={}, request_error_status={}, request_error={}",
                        session.provider_id,
                        config.config_url,
                        issuer,
                        token_endpoint,
                        redirect_uri,
                        config.client_id,
                        request_error_kind,
                        request_error_status,
                        err
                    )
                }
                RequestTokenError::Parse(parse_err, body) => {
                    let shape = inspect_token_response_body(body);
                    error!(
                        event = EVENT_OIDC_DIAGNOSTICS,
                        component = LOG_COMPONENT_IAM,
                        subsystem = LOG_SUBSYSTEM_OIDC,
                        result = "token_response_parse_failed",
                        provider_id = %session.provider_id,
                        config_url = %config.config_url,
                        issuer = %issuer,
                        token_endpoint = %token_endpoint,
                        client_id = %config.client_id,
                        redirect_uri = %redirect_uri,
                        parse_error_path = %parse_err.path(),
                        response_body_len = body.len(),
                        response_json_object = shape.json_object,
                        response_json_keys = %shape.json_keys,
                        response_has_access_token = shape.has_access_token,
                        response_has_id_token = shape.has_id_token,
                        response_has_token_type = shape.has_token_type,
                        response_has_expires_in = shape.has_expires_in,
                        response_has_error = shape.has_error,
                        response_has_error_description = shape.has_error_description,
                        response_looks_like_html = shape.looks_like_html,
                        error = %e,
                        "oidc token exchange failed"
                    );
                    format!(
                        "token exchange failed: {e}: stage=token_response_parse_failed, provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}, parse_error_path={}, response_body_len={}, response_json_keys={}, response_has_id_token={}, response_has_error={}, response_looks_like_html={}",
                        session.provider_id,
                        config.config_url,
                        issuer,
                        token_endpoint,
                        redirect_uri,
                        config.client_id,
                        parse_err.path(),
                        body.len(),
                        shape.json_keys,
                        shape.has_id_token,
                        shape.has_error,
                        shape.looks_like_html
                    )
                }
                RequestTokenError::Other(message) => {
                    error!(
                        event = EVENT_OIDC_DIAGNOSTICS,
                        component = LOG_COMPONENT_IAM,
                        subsystem = LOG_SUBSYSTEM_OIDC,
                        result = "token_exchange_other_error",
                        provider_id = %session.provider_id,
                        config_url = %config.config_url,
                        issuer = %issuer,
                        token_endpoint = %token_endpoint,
                        client_id = %config.client_id,
                        redirect_uri = %redirect_uri,
                        error = %message,
                        "oidc token exchange failed"
                    );
                    format!(
                        "token exchange failed: {e}: stage=token_exchange_other_error, provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}",
                        session.provider_id, config.config_url, issuer, token_endpoint, redirect_uri, config.client_id
                    )
                }
            })?;

        // Verify the ID token (signature, issuer, audience, expiry, nonce)
        let id_token = token_response
            .extra_fields()
            .id_token()
            .ok_or_else(|| {
                error!(
                    event = EVENT_OIDC_DIAGNOSTICS,
                    component = LOG_COMPONENT_IAM,
                    subsystem = LOG_SUBSYSTEM_OIDC,
                    result = "token_response_missing_id_token",
                    provider_id = %session.provider_id,
                    config_url = %config.config_url,
                    issuer = %issuer,
                    token_endpoint = %token_endpoint,
                    client_id = %config.client_id,
                    redirect_uri = %redirect_uri,
                    scopes = ?config.scopes,
                    "oidc token exchange failed"
                );
                format!(
                    "no id_token in token response: provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}, scopes={}",
                    session.provider_id,
                    config.config_url,
                    issuer,
                    token_endpoint,
                    redirect_uri,
                    config.client_id,
                    config.scopes.join(",")
                )
            })?;

        let verifier = client
            .id_token_verifier()
            .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud));
        let verified = id_token.claims(&verifier, &Nonce::new(session.nonce.clone()));
        if let Err(e) = verified {
            let verification_error = e.to_string();
            warn!(
                event = EVENT_OIDC_DIAGNOSTICS,
                component = LOG_COMPONENT_IAM,
                subsystem = LOG_SUBSYSTEM_OIDC,
                result = "id_token_verification_retry",
                provider_id = %session.provider_id,
                config_url = %config.config_url,
                issuer = %issuer,
                token_endpoint = %token_endpoint,
                client_id = %config.client_id,
                other_audiences = ?config.other_audiences,
                error = %verification_error,
                "oidc id token verification failed"
            );
            let refreshed_state = self
                .refresh_provider_state(&session.provider_id, config)
                .await
                .map_err(|refresh_err| {
                    format!(
                        "ID token verification failed: {verification_error}; failed to refresh provider metadata: {refresh_err}"
                    )
                })?;

            warn!(
                event = EVENT_OIDC_DIAGNOSTICS,
                component = LOG_COMPONENT_IAM,
                subsystem = LOG_SUBSYSTEM_OIDC,
                result = "jwks_metadata_refreshed",
                provider_id = %session.provider_id,
                config_url = %config.config_url,
                issuer = %issuer,
                "oidc provider metadata refreshed"
            );

            let client = CoreClient::from_provider_metadata(
                refreshed_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()
                .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud));
            id_token
                .claims(&verifier, &Nonce::new(session.nonce.clone()))
                .map_err(|retry_err| {
                    error!(
                        event = EVENT_OIDC_DIAGNOSTICS,
                        component = LOG_COMPONENT_IAM,
                        subsystem = LOG_SUBSYSTEM_OIDC,
                        result = "id_token_verification_failed",
                        provider_id = %session.provider_id,
                        config_url = %config.config_url,
                        issuer = %issuer,
                        token_endpoint = %token_endpoint,
                        client_id = %config.client_id,
                        other_audiences = ?config.other_audiences,
                        original_error = %verification_error,
                        retry_error = %retry_err,
                        "oidc id token verification failed"
                    );
                    format!(
                        "ID token verification failed after JWKS refresh: {retry_err}; original_error={verification_error}; provider_id={}, config_url={}, issuer={}, token_endpoint={}, client_id={}, other_audiences={}",
                        session.provider_id,
                        config.config_url,
                        issuer,
                        token_endpoint,
                        config.client_id,
                        config.other_audiences.join(",")
                    )
                })?;
        }

        // Extract raw claims from the verified JWT for custom claim support
        // (the crate verifies signature/expiry/nonce; we decode payload for non-standard claims)
        let raw_jwt = id_token.to_string();
        let raw = decode_jwt_payload(&raw_jwt);

        let claims = OidcClaims {
            sub: extract_string_claim(&raw, "sub"),
            email: extract_string_claim(&raw, &config.email_claim),
            username: extract_string_claim(&raw, &config.username_claim),
            groups: extract_canonical_group_values(&raw, &config.groups_claim, &config.roles_claim),
            raw,
        };

        Ok((claims, session.provider_id.clone(), session, raw_jwt))
    }

    /// Store a one-time logout session keyed by an opaque token so the console can
    /// trigger browser logout without persisting the raw ID token.
    pub async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result<String, String> {
        if !self.configs.contains_key(provider_id) {
            return Err(format!("unknown OIDC provider: {provider_id}"));
        }

        let token = CsrfToken::new_random().secret().clone();
        self.state_store
            .insert_logout(
                token.clone(),
                OidcLogoutSession {
                    provider_id: provider_id.to_string(),
                    id_token: id_token.to_string(),
                },
            )
            .await;

        Ok(token)
    }

    /// Build the RP-initiated logout URL for a previously issued logout token.
    /// Returns `Ok(None)` when the provider does not advertise an end-session endpoint.
    pub async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result<Option<String>, String> {
        let session = self
            .state_store
            .take_logout(logout_token)
            .await
            .ok_or_else(|| "invalid or expired OIDC logout token".to_string())?;

        let config = self
            .configs
            .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 {
            return Ok(None);
        };

        let id_token: CoreIdToken = session
            .id_token
            .parse()
            .map_err(|e: serde_json::Error| format!("failed to parse ID token for logout: {e}"))?;
        let post_logout_redirect_uri = PostLogoutRedirectUrl::new(post_logout_redirect_uri.to_string())
            .map_err(|e| format!("invalid post logout redirect URI: {e}"))?;

        let logout_url = LogoutRequest::from(end_session_endpoint)
            .set_id_token_hint(&id_token)
            .set_client_id(ClientId::new(config.client_id.clone()))
            .set_post_logout_redirect_uri(post_logout_redirect_uri)
            .http_get_url()
            .to_string();

        Ok(Some(logout_url))
    }

    /// Map OIDC claims to rustfs policy names.
    pub fn map_claims_to_policies(&self, provider_id: &str, claims: &OidcClaims) -> (Vec<String>, Vec<String>) {
        let config = match self.configs.get(provider_id) {
            Some(c) => c,
            None => return (vec![], vec![]),
        };

        let mut policies = Vec::new();
        let mut groups = Vec::new();

        // Role-policy and claim-based authorization are separate OIDC modes. When a
        // role policy is configured, group claims still provide group context but
        // must not also become policy names.
        let has_role_policy = !config.role_policy.trim().is_empty();
        if has_role_policy {
            for policy in config.role_policy.split(',') {
                let policy = policy.trim();
                if !policy.is_empty() {
                    policies.push(policy.to_string());
                }
            }
        }

        for group in &claims.groups {
            groups.push(group.clone());
            if !has_role_policy {
                let policy_name = if config.claim_prefix.is_empty() {
                    group.clone()
                } else {
                    format!("{}{}", config.claim_prefix, group)
                };
                policies.push(policy_name);
            }
        }

        if !has_role_policy && config.claim_name != config.groups_claim {
            for val in extract_groups_claim(&claims.raw, &config.claim_name) {
                let policy_name = if config.claim_prefix.is_empty() {
                    val
                } else {
                    format!("{}{}", config.claim_prefix, val)
                };
                policies.push(policy_name);
            }
        }

        // Deduplicate
        policies.sort();
        policies.dedup();
        groups.sort();
        groups.dedup();

        let mut raw_claim_keys: Vec<&str> = claims.raw.keys().map(String::as_str).collect();
        raw_claim_keys.sort_unstable();
        let (claim_name_lookup, claim_name_raw_value) = claim_lookup_for_log(&claims.raw, &config.claim_name);
        let (groups_claim_lookup, groups_claim_raw_value) = claim_lookup_for_log(&claims.raw, &config.groups_claim);
        let (roles_claim_lookup, roles_claim_raw_value) = claim_lookup_for_log(&claims.raw, &config.roles_claim);
        let claim_name_values = extract_groups_claim(&claims.raw, &config.claim_name);
        let groups_claim_values = extract_groups_claim(&claims.raw, &config.groups_claim);
        let roles_claim_values = extract_groups_claim(&claims.raw, &config.roles_claim);

        debug!(
            event = EVENT_OIDC_DIAGNOSTICS,
            component = LOG_COMPONENT_IAM,
            subsystem = LOG_SUBSYSTEM_OIDC,
            result = "claims_policy_mapped",
            provider_id = %provider_id,
            claim_name = %config.claim_name,
            claim_prefix = %config.claim_prefix,
            groups_claim = %config.groups_claim,
            roles_claim = %config.roles_claim,
            role_policy = %config.role_policy,
            policy_count = policies.len(),
            group_count = groups.len(),
            policies = ?policies,
            groups = ?groups,
            raw_claim_keys = ?raw_claim_keys,
            raw_claims = ?claims.raw,
            claim_name_lookup = %claim_name_lookup,
            claim_name_type = claim_value_type_for_log(claim_name_raw_value),
            claim_name_value = ?claim_name_raw_value,
            claim_name_values = ?claim_name_values,
            groups_claim_lookup = %groups_claim_lookup,
            groups_claim_type = claim_value_type_for_log(groups_claim_raw_value),
            groups_claim_value = ?groups_claim_raw_value,
            groups_claim_values = ?groups_claim_values,
            roles_claim_lookup = %roles_claim_lookup,
            roles_claim_type = claim_value_type_for_log(roles_claim_raw_value),
            roles_claim_value = ?roles_claim_raw_value,
            roles_claim_values = ?roles_claim_values,
            "oidc claims mapped to policies"
        );

        (policies, groups)
    }

    /// Verify a raw JWT (id_token) for the AssumeRoleWithWebIdentity flow.
    ///
    /// Unlike the authorization code flow, ARWWI receives a raw JWT directly
    /// (not via code exchange). This method:
    /// 1. Decodes the JWT payload to extract the `iss` claim
    /// 2. Finds the OIDC provider whose issuer matches
    /// 3. Verifies signature, issuer, audience, and expiry (nonce is skipped)
    /// 4. Extracts claims using the provider's claim configuration
    pub async fn verify_web_identity_token(&self, jwt: &str) -> Result<(OidcClaims, String /* provider_id */), String> {
        // Decode JWT payload without verification to get the issuer claim
        let raw_claims = decode_jwt_payload(jwt);
        let issuer = raw_claims
            .get("iss")
            .and_then(|v| v.as_str())
            .ok_or_else(|| "JWT missing 'iss' claim".to_string())?;

        // Find matching provider by issuer
        let (provider_id, config, mut state) = self
            .find_provider_by_issuer(issuer)
            .ok_or_else(|| format!("no OIDC provider configured for issuer: {issuer}"))?;

        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()
            .map_err(|e: serde_json::Error| format!("failed to parse JWT as ID token: {e}"))?;

        // Verify the token (signature, issuer, audience, expiry) — skip nonce
        // (nonce is only required for the authorization code flow)
        let verifier = client
            .id_token_verifier()
            .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
                .refresh_provider_state(&provider_id, &config)
                .await
                .map_err(|refresh_err| {
                    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()
                .set_other_audience_verifier_fn(|aud| trusted_aud(&config.other_audiences, aud));
            id_token
                .claims(&verifier, |_: Option<&Nonce>| Ok(()))
                .map_err(|retry_err| format!("ID token verification failed after JWKS refresh: {retry_err}"))?;
        }

        // Extract claims using the provider's claim configuration
        let claims = OidcClaims {
            sub: extract_string_claim(&raw_claims, "sub"),
            email: extract_string_claim(&raw_claims, &config.email_claim),
            username: extract_string_claim(&raw_claims, &config.username_claim),
            groups: extract_canonical_group_values(&raw_claims, &config.groups_claim, &config.roles_claim),
            raw: raw_claims,
        };

        Ok((claims, provider_id.to_string()))
    }

    /// Find a provider whose discovered issuer matches the given JWT issuer string.
    fn find_provider_by_issuer(&self, issuer: &str) -> Option<(String, OidcProviderConfig, ProviderState)> {
        let (issuer_scheme, issuer_host, issuer_port, issuer_path) = normalize_issuer(issuer)?;
        let map = self
            .provider_states
            .read()
            .map_err(|e| format!("provider state lock poisoned: {e}"))
            .ok()?;
        for (id, state) in map.iter() {
            let provider_issuer = state.metadata.issuer().as_str();
            let Some((provider_scheme, provider_host, provider_port, provider_path)) = normalize_issuer(provider_issuer) else {
                continue;
            };

            if issuer_scheme == provider_scheme
                && issuer_host == provider_host
                && issuer_port == provider_port
                && issuer_path == provider_path
                && let Some(config) = self.configs.get(id)
            {
                return Some((id.clone(), config.clone(), state.clone()));
            }
        }
        None
    }

    fn get_provider_state(&self, provider_id: &str) -> Result<ProviderState, String> {
        self.provider_states
            .read()
            .map_err(|e| format!("provider state lock poisoned: {e}"))?
            .get(provider_id)
            .cloned()
            .ok_or_else(|| format!("provider not discovered: {provider_id}"))
    }

    async fn refresh_provider_state(&self, provider_id: &str, config: &OidcProviderConfig) -> Result<ProviderState, String> {
        let state = Self::discover_provider(config, &self.http_client).await?;
        let mut map = self.provider_states.write().map_err(|e| {
            let msg = e.to_string();
            format!("provider state lock poisoned: {msg}")
        })?;
        map.insert(provider_id.to_string(), state.clone());

        Ok(state)
    }

    async fn ensure_provider_state(&self, provider_id: &str, config: &OidcProviderConfig) -> Result<ProviderState, String> {
        let state = self.get_provider_state(provider_id)?;
        if state.is_stale() {
            self.refresh_provider_state(provider_id, config).await.or_else(|refresh_err| {
                warn!(
                    "OIDC provider '{}' JWKS metadata refresh skipped due to transient network issue: {}",
                    provider_id, refresh_err
                );
                Ok(state)
            })
        } else {
            Ok(state)
        }
    }

    async fn ensure_provider_state_if_stale(
        &self,
        provider_id: &str,
        config: &OidcProviderConfig,
        state: &ProviderState,
    ) -> Result<ProviderState, String> {
        if state.is_stale() {
            self.refresh_provider_state(provider_id, config).await.or_else(|refresh_err| {
                warn!(
                    "OIDC provider '{}' JWKS metadata refresh skipped due to transient network issue: {}",
                    provider_id, refresh_err
                );
                Ok(state.clone())
            })
        } else {
            Ok(state.clone())
        }
    }

    /// Get the state store (used by HTTP handlers).
    pub fn state_store(&self) -> &OidcStateStore {
        &self.state_store
    }

    /// Get a provider config by ID.
    pub fn get_provider_config(&self, id: &str) -> Option<&OidcProviderConfig> {
        self.configs.get(id)
    }

    /// Parse all OIDC provider configs from environment variables.
    fn parse_env_configs() -> Vec<OidcProviderConfig> {
        let mut configs = Vec::new();

        // Check for the default provider (no suffix)
        if let Some(config) = Self::parse_single_provider("", "default") {
            configs.push(config);
        }

        // Scan for suffixed providers by checking all OIDC env var prefixes.
        // This allows providers to be discovered without requiring a separate ENABLE_ key.
        let mut provider_ids: Vec<String> = Vec::new();
        let scan_prefixes: Vec<String> = ENV_IDENTITY_OPENID_KEYS.iter().map(|k| format!("{k}_")).collect();
        for (key, _) in std::env::vars() {
            for prefix in &scan_prefixes {
                if let Some(suffix) = key.strip_prefix(prefix.as_str())
                    && !suffix.is_empty()
                    && suffix != "default"
                {
                    provider_ids.push(suffix.to_string());
                    break;
                }
            }
        }

        provider_ids.sort();
        provider_ids.dedup();

        for id in provider_ids {
            let suffix = format!("_{id}");
            if let Some(config) = Self::parse_single_provider(&suffix, &id) {
                configs.push(config);
            }
        }

        configs
    }

    fn parse_persisted_configs(cfg: &ServerConfig) -> Vec<OidcProviderConfig> {
        let Some(subsystem) = cfg.0.get(IDENTITY_OPENID_SUB_SYS) else {
            return Vec::new();
        };

        let mut configs = Vec::new();
        let mut provider_ids: Vec<String> = subsystem.keys().cloned().collect();
        provider_ids.sort();

        for raw_id in provider_ids {
            let Some(kvs) = subsystem.get(&raw_id) else {
                continue;
            };

            let id = if raw_id == DEFAULT_DELIMITER {
                "default"
            } else {
                raw_id.as_str()
            };
            if let Some(config) = Self::parse_single_persisted_provider(kvs, id) {
                configs.push(config);
            }
        }

        configs
    }

    /// Parse a string as an `EnableState` boolean.
    /// Returns `default_if_empty` when the input is empty, and `default_on_error`
    /// when parsing fails.
    fn parse_enable_state(value: &str, default_if_empty: bool, default_on_error: bool) -> bool {
        if value.is_empty() {
            return default_if_empty;
        }
        value
            .parse::<EnableState>()
            .map(|s| s.is_enabled())
            .unwrap_or(default_on_error)
    }

    /// Parse a single provider's config from env vars with the given suffix.
    fn parse_single_provider(env_suffix: &str, id: &str) -> Option<OidcProviderConfig> {
        let get_env = |base: &str| -> String { std::env::var(format!("{base}{env_suffix}")).unwrap_or_default() };

        let enable_val = get_env(ENV_IDENTITY_OPENID_ENABLE);
        let config_url = get_env(ENV_IDENTITY_OPENID_CONFIG_URL);
        let issuer = get_env(ENV_IDENTITY_OPENID_ISSUER);

        // Skip if no config URL
        if config_url.is_empty() {
            return None;
        }

        let enabled = Self::parse_enable_state(&enable_val, true, false);

        let scopes_str = get_env(ENV_IDENTITY_OPENID_SCOPES);
        let scopes = if scopes_str.is_empty() {
            OIDC_DEFAULT_SCOPES.split(',').map(String::from).collect()
        } else {
            scopes_str.split(',').map(|s| s.trim().to_string()).collect()
        };

        let other_audiences_str = get_env(ENV_IDENTITY_OPENID_OTHER_AUDIENCES);
        let other_audiences = other_audiences_str
            .split(',')
            .map(|s| s.trim())
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string())
            .collect();

        let redirect_uri_dynamic = Self::parse_enable_state(&get_env(ENV_IDENTITY_OPENID_REDIRECT_URI_DYNAMIC), true, true);

        let claim_name = {
            let v = get_env(ENV_IDENTITY_OPENID_CLAIM_NAME);
            if v.is_empty() {
                OIDC_DEFAULT_CLAIM_NAME.to_string()
            } else {
                v
            }
        };
        let groups_claim = {
            let v = get_env(ENV_IDENTITY_OPENID_GROUPS_CLAIM);
            if v.is_empty() {
                OIDC_DEFAULT_GROUPS_CLAIM.to_string()
            } else {
                v
            }
        };
        let roles_claim = get_env(ENV_IDENTITY_OPENID_ROLES_CLAIM);
        let email_claim = {
            let v = get_env(ENV_IDENTITY_OPENID_EMAIL_CLAIM);
            if v.is_empty() {
                OIDC_DEFAULT_EMAIL_CLAIM.to_string()
            } else {
                v
            }
        };
        let username_claim = {
            let v = get_env(ENV_IDENTITY_OPENID_USERNAME_CLAIM);
            if v.is_empty() {
                OIDC_DEFAULT_USERNAME_CLAIM.to_string()
            } else {
                v
            }
        };
        let display_name = {
            let v = get_env(ENV_IDENTITY_OPENID_DISPLAY_NAME);
            if v.is_empty() { id.to_string() } else { v }
        };
        let redirect_uri = {
            let v = get_env(ENV_IDENTITY_OPENID_REDIRECT_URI);
            if v.is_empty() { None } else { Some(v) }
        };
        let client_secret = {
            let v = get_env(ENV_IDENTITY_OPENID_CLIENT_SECRET);
            if v.is_empty() { None } else { Some(v) }
        };
        let hide_from_ui = Self::parse_enable_state(&get_env(ENV_IDENTITY_OPENID_HIDE_FROM_UI), false, false);

        Some(OidcProviderConfig {
            id: id.to_string(),
            enabled,
            config_url,
            issuer: if issuer.is_empty() { None } else { Some(issuer) },
            client_id: get_env(ENV_IDENTITY_OPENID_CLIENT_ID),
            client_secret,
            scopes,
            other_audiences,
            redirect_uri,
            redirect_uri_dynamic,
            claim_name,
            claim_prefix: get_env(ENV_IDENTITY_OPENID_CLAIM_PREFIX),
            role_policy: get_env(ENV_IDENTITY_OPENID_ROLE_POLICY),
            display_name,
            groups_claim,
            roles_claim,
            email_claim,
            username_claim,
            hide_from_ui,
        })
    }

    fn parse_single_persisted_provider(kvs: &KVS, id: &str) -> Option<OidcProviderConfig> {
        let config_url = kvs.get(OIDC_CONFIG_URL);
        if config_url.is_empty() {
            return None;
        }

        let enabled = Self::parse_enable_state(&kvs.lookup(ENABLE_KEY).unwrap_or_default(), false, false);

        let scopes_str = kvs.get(OIDC_SCOPES);
        let scopes = if scopes_str.is_empty() {
            OIDC_DEFAULT_SCOPES.split(',').map(String::from).collect()
        } else {
            scopes_str.split(',').map(|s| s.trim().to_string()).collect()
        };

        let other_audiences_str = kvs.get(OIDC_OTHER_AUDIENCES);
        let other_audiences = other_audiences_str
            .split(',')
            .map(|s| s.trim())
            .filter(|s| !s.is_empty())
            .map(|s| s.to_string())
            .collect();

        let redirect_uri_dynamic =
            Self::parse_enable_state(&kvs.lookup(OIDC_REDIRECT_URI_DYNAMIC).unwrap_or_default(), true, true);

        let claim_name = kvs
            .lookup(OIDC_CLAIM_NAME)
            .unwrap_or_else(|| OIDC_DEFAULT_CLAIM_NAME.to_string());
        let groups_claim = kvs
            .lookup(OIDC_GROUPS_CLAIM)
            .unwrap_or_else(|| OIDC_DEFAULT_GROUPS_CLAIM.to_string());
        let roles_claim = kvs
            .lookup(OIDC_ROLES_CLAIM)
            .unwrap_or_else(|| OIDC_DEFAULT_ROLES_CLAIM.to_string());
        let email_claim = kvs
            .lookup(OIDC_EMAIL_CLAIM)
            .unwrap_or_else(|| OIDC_DEFAULT_EMAIL_CLAIM.to_string());
        let username_claim = kvs
            .lookup(OIDC_USERNAME_CLAIM)
            .unwrap_or_else(|| OIDC_DEFAULT_USERNAME_CLAIM.to_string());
        let display_name = kvs.lookup(OIDC_DISPLAY_NAME).unwrap_or_else(|| id.to_string());
        let redirect_uri = kvs.lookup(OIDC_REDIRECT_URI).filter(|v| !v.is_empty());
        let client_secret = kvs.lookup(OIDC_CLIENT_SECRET).filter(|v| !v.is_empty());
        let hide_from_ui = Self::parse_enable_state(&kvs.lookup(OIDC_HIDE_FROM_UI).unwrap_or_default(), false, false);

        Some(OidcProviderConfig {
            id: id.to_string(),
            enabled,
            config_url,
            issuer: kvs.lookup(OIDC_ISSUER).filter(|v| !v.is_empty()),
            client_id: kvs.get(OIDC_CLIENT_ID),
            client_secret,
            scopes,
            other_audiences,
            redirect_uri,
            redirect_uri_dynamic,
            claim_name,
            claim_prefix: kvs.get(OIDC_CLAIM_PREFIX),
            role_policy: kvs.get(OIDC_ROLE_POLICY),
            display_name,
            groups_claim,
            roles_claim,
            email_claim,
            username_claim,
            hide_from_ui,
        })
    }

    /// Perform OIDC discovery for a provider.
    /// `discover_async` fetches the discovery document and JWKS in one step.
    async fn discover_provider(config: &OidcProviderConfig, http_client: &ReqwestHttpClient) -> Result<ProviderState, String> {
        if let Some(issuer) = config.issuer.as_deref().filter(|issuer| !issuer.trim().is_empty()) {
            return Self::discover_provider_from_config_url(config, issuer, http_client).await;
        }

        // The openidconnect crate expects the issuer URL (base), not the
        // .well-known/openid-configuration URL.
        let base_issuer = normalize_config_url(&config.config_url)?;
        let candidates = issuer_candidates(&base_issuer);
        let mut last_errors = Vec::new();

        for candidate_issuer in candidates.iter() {
            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,
                            discovered_at: Instant::now(),
                        });
                    }
                    Err(DiscoveryError::Request(OidcHttpError::ForbiddenOutbound(reason))) => {
                        return Err(format!("{OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY}: {reason}"));
                    }
                    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 {
                            warn!(
                                event = EVENT_OIDC_DIAGNOSTICS,
                                component = LOG_COMPONENT_IAM,
                                subsystem = LOG_SUBSYSTEM_OIDC,
                                result = "provider_discovery_transport_retry",
                                provider_id = %config.id,
                                config_url = %config.config_url,
                                issuer_candidate = %candidate_issuer,
                                attempt = attempt + 1,
                                max_attempts = OIDC_DISCOVERY_TRANSPORT_RETRIES,
                                error = %error,
                                "oidc provider discovery failed"
                            );
                            sleep(OIDC_DISCOVERY_TRANSPORT_RETRY_DELAY).await;
                            continue;
                        }

                        last_errors.push(format!("issuer '{candidate_issuer}': {error}"));
                        warn!(
                            event = EVENT_OIDC_DIAGNOSTICS,
                            component = LOG_COMPONENT_IAM,
                            subsystem = LOG_SUBSYSTEM_OIDC,
                            result = "provider_discovery_candidate_failed",
                            provider_id = %config.id,
                            config_url = %config.config_url,
                            issuer_candidate = %candidate_issuer,
                            attempt = attempt + 1,
                            max_attempts = OIDC_DISCOVERY_TRANSPORT_RETRIES,
                            error = %error,
                            "oidc provider discovery failed"
                        );
                        break;
                    }
                }
            }
        }

        Err(format!(
            "discovery failed for all issuer variants {:?}: {}",
            candidates,
            last_errors.join("; ")
        ))
    }

    async fn discover_provider_from_config_url(
        config: &OidcProviderConfig,
        issuer: &str,
        http_client: &ReqwestHttpClient,
    ) -> Result<ProviderState, String> {
        let issuer_url = IssuerUrl::new(issuer.trim().to_string()).map_err(|e| format!("invalid issuer URL: {e}"))?;
        let discovery_url = discovery_url_from_config_url(&config.config_url)?;
        let request = http::Request::builder()
            .uri(discovery_url.to_string())
            .method(http::Method::GET)
            .header(http::header::ACCEPT, "application/json")
            .body(Vec::new())
            .map_err(|err| format!("failed to prepare discovery request: {err}"))?;

        let response = match http_client.call(request).await {
            Ok(response) => response,
            Err(OidcHttpError::ForbiddenOutbound(reason)) => {
                return Err(format!("{OIDC_DISCOVERY_BLOCKED_BY_OUTBOUND_POLICY}: {reason}"));
            }
            Err(err) => return Err(format!("discovery request failed: {err}")),
        };
        if response.status() != http::StatusCode::OK {
            return Err(format!("discovery failed: HTTP status code {} at {}", response.status(), discovery_url));
        }

        let provider_metadata = serde_json::from_slice::<ProviderMetadataWithLogout>(response.body())
            .map_err(|err| format!("failed to parse discovery response: {err}"))?;
        if provider_metadata.issuer() != &issuer_url {
            return Err(format!(
                "unexpected issuer URI `{}` (expected `{}`)",
                provider_metadata.issuer().as_str(),
                issuer_url.as_str()
            ));
        }

        let jwks_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 {
            Ok(jwks) => jwks,
            Err(DiscoveryError::Request(OidcHttpError::ForbiddenOutbound(reason))) => {
                return Err(format!("JWKS request blocked by outbound policy: {reason}"));
            }
            Err(err) => return Err(format!("failed to fetch JWKS: {err}")),
        };

        Ok(ProviderState {
            metadata: provider_metadata.set_jwks(jwks),
            discovered_at: Instant::now(),
        })
    }
}

pub fn load_oidc_provider_configs_from_env() -> Vec<OidcProviderConfig> {
    OidcSys::parse_env_configs()
}

pub fn load_oidc_provider_configs_from_server_config(cfg: &ServerConfig) -> Vec<OidcProviderConfig> {
    OidcSys::parse_persisted_configs(cfg)
}

pub fn merge_oidc_provider_configs(
    env_configs: Vec<OidcProviderConfig>,
    persisted_configs: Vec<OidcProviderConfig>,
) -> Vec<SourcedOidcProviderConfig> {
    let mut effective = HashMap::new();

    for config in persisted_configs {
        effective.insert(
            config.id.clone(),
            SourcedOidcProviderConfig {
                config,
                source: OidcProviderConfigSource::Persisted,
            },
        );
    }

    for config in env_configs {
        effective.insert(
            config.id.clone(),
            SourcedOidcProviderConfig {
                config,
                source: OidcProviderConfigSource::Env,
            },
        );
    }

    let mut configs: Vec<SourcedOidcProviderConfig> = effective.into_values().collect();
    configs.sort_by(|lhs, rhs| lhs.config.id.cmp(&rhs.config.id));
    configs
}

pub fn load_effective_oidc_provider_configs(server_config: Option<&ServerConfig>) -> Vec<SourcedOidcProviderConfig> {
    let env_configs = load_oidc_provider_configs_from_env();
    let persisted_configs = server_config
        .map(load_oidc_provider_configs_from_server_config)
        .unwrap_or_default();
    merge_oidc_provider_configs(env_configs, persisted_configs)
}

pub async fn validate_oidc_provider_config(config: &OidcProviderConfig) -> Result<OidcProviderValidationResult, String> {
    validate_oidc_provider_config_with_extra_root_ca(config, None).await
}

pub async fn validate_oidc_provider_config_with_extra_root_ca(
    config: &OidcProviderConfig,
    root_ca_pem: Option<&[u8]>,
) -> Result<OidcProviderValidationResult, String> {
    let http_client = ReqwestHttpClient::new_with_extra_root_certs(oidc_extra_root_certs(root_ca_pem)?)?;
    let state = OidcSys::discover_provider(config, &http_client).await?;

    Ok(OidcProviderValidationResult {
        issuer: state.metadata.issuer().to_string(),
        authorization_endpoint: state.metadata.authorization_endpoint().to_string(),
        token_endpoint: state.metadata.token_endpoint().map(ToString::to_string),
    })
}

// --- Helper functions ---

fn normalize_issuer(raw: &str) -> Option<(String, String, u16, String)> {
    let parsed = Url::parse(raw).ok()?;
    if parsed.scheme() != "http" && parsed.scheme() != "https" {
        return None;
    }

    let host = parsed.host_str()?.to_ascii_lowercase();
    let port = parsed.port_or_known_default()?;
    let normalized_path = {
        let path = parsed.path().trim_end_matches('/').to_string();
        if path.is_empty() { "/".to_string() } else { path }
    };

    Some((parsed.scheme().to_string(), host, port, normalized_path))
}

fn normalize_config_url(config_url: &str) -> Result<String, String> {
    let config_url = config_url.trim();
    let url = Url::parse(config_url).map_err(|e| format!("invalid config_url: {e}"))?;
    if url.scheme() != "http" && url.scheme() != "https" {
        return Err(format!("invalid config_url scheme: {}", url.scheme()));
    }
    let host = url.host_str().ok_or_else(|| "config_url missing host".to_string())?;
    let path = url.path();

    // Strip `/.well-known/openid-configuration` (with optional trailing slash) if present.
    // Everything else is preserved exactly so the issuer URL matches the provider's discovery
    // document (e.g. Authentik includes a trailing slash, Keycloak does not).
    let normalized_path = path
        .strip_suffix('/')
        .unwrap_or(path)
        .strip_suffix("/.well-known/openid-configuration")
        .unwrap_or(if path == "/" { "" } else { path });

    if normalized_path.contains("/.well-known/") {
        return Err("config_url uses an unsupported .well-known discovery URL".into());
    }

    let mut issuer = format!("{}://{host}", url.scheme());
    if let Some(port) = url.port() {
        issuer.push(':');
        issuer.push_str(&port.to_string());
    }

    if !normalized_path.is_empty() {
        issuer.push_str(normalized_path);
    }

    Ok(issuer)
}

fn discovery_url_from_config_url(config_url: &str) -> Result<Url, String> {
    let mut url = Url::parse(config_url.trim()).map_err(|e| format!("invalid config_url: {e}"))?;
    if url.scheme() != "http" && url.scheme() != "https" {
        return Err(format!("invalid config_url scheme: {}", url.scheme()));
    }
    if url.host_str().is_none() {
        return Err("config_url missing host".to_string());
    }

    let path = url.path().to_string();
    let without_trailing_slash = path.strip_suffix('/').unwrap_or(&path);
    if without_trailing_slash.ends_with("/.well-known/openid-configuration") {
        url.set_path(without_trailing_slash);
        return Ok(url);
    }
    if without_trailing_slash.contains("/.well-known/") {
        return Err("config_url uses an unsupported .well-known discovery URL".into());
    }

    let discovery_path = if without_trailing_slash.is_empty() || without_trailing_slash == "/" {
        "/.well-known/openid-configuration".to_string()
    } else {
        format!("{without_trailing_slash}/.well-known/openid-configuration")
    };
    url.set_path(&discovery_path);
    Ok(url)
}

fn jwks_url_from_config_url(
    config_url: &str,
    issuer_url: &IssuerUrl,
    jwks_url: &JsonWebKeySetUrl,
) -> Result<JsonWebKeySetUrl, String> {
    let issuer = issuer_url.url();
    let jwks = jwks_url.url();
    if issuer.origin() != jwks.origin() {
        return Ok(jwks_url.clone());
    }

    let issuer_path = issuer.path().trim_end_matches('/');
    let Some(suffix) = jwks.path().strip_prefix(issuer_path) else {
        return Ok(jwks_url.clone());
    };
    if !suffix.is_empty() && !suffix.starts_with('/') {
        return Ok(jwks_url.clone());
    }

    let mut internal_url =
        Url::parse(&normalize_config_url(config_url)?).map_err(|err| format!("invalid config_url issuer base: {err}"))?;
    let internal_path = internal_url.path().trim_end_matches('/');
    internal_url.set_path(&format!("{internal_path}{suffix}"));
    internal_url.set_query(jwks.query());
    Ok(JsonWebKeySetUrl::from_url(internal_url))
}

fn issuer_candidates(base: &str) -> Vec<String> {
    let original = base.trim();
    let mut variants = Vec::with_capacity(2);
    variants.push(original.to_string());

    let toggled = if original.ends_with('/') {
        original.trim_end_matches('/').to_string()
    } else {
        format!("{original}/")
    };
    variants.push(toggled);

    variants
}

/// Decode the payload section of a JWT without validation (token must already be verified).
pub(crate) fn decode_jwt_payload(token: &str) -> HashMap<String, serde_json::Value> {
    let parts: Vec<&str> = token.split('.').collect();
    if parts.len() < 2 {
        return HashMap::new();
    }
    let payload_bytes = base64_simd::URL_SAFE_NO_PAD.decode_to_vec(parts[1]);
    match payload_bytes {
        Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
        Err(_) => HashMap::new(),
    }
}

/// Extract a string claim from raw claims with case-insensitive fallback.
fn extract_string_claim(claims: &HashMap<String, serde_json::Value>, key: &str) -> String {
    match get_claim_case_insensitive(claims, key) {
        ClaimLookup::Found(value) => value.as_str().unwrap_or_default().to_string(),
        ClaimLookup::Missing | ClaimLookup::Ambiguous => String::new(),
    }
}

/// Extract a groups/array claim from raw claims with case-insensitive fallback. Handles both string arrays and single strings.
fn extract_groups_claim(claims: &HashMap<String, serde_json::Value>, key: &str) -> Vec<String> {
    match get_claim_case_insensitive(claims, key) {
        ClaimLookup::Found(serde_json::Value::Array(arr)) => arr.iter().filter_map(|v| v.as_str().map(String::from)).collect(),
        ClaimLookup::Found(serde_json::Value::String(s)) => s.split(',').map(|s| s.trim().to_string()).collect(),
        _ => vec![],
    }
}

fn extract_canonical_group_values(
    claims: &HashMap<String, serde_json::Value>,
    groups_claim: &str,
    roles_claim: &str,
) -> Vec<String> {
    let mut groups = extract_groups_claim(claims, groups_claim);
    if !roles_claim.is_empty() && roles_claim != groups_claim {
        groups.extend(extract_groups_claim(claims, roles_claim));
    }
    groups.retain(|g| !g.is_empty());
    groups.sort();
    groups.dedup();
    groups
}

fn claim_lookup_for_log<'a>(
    claims: &'a HashMap<String, serde_json::Value>,
    key: &str,
) -> (&'static str, Option<&'a serde_json::Value>) {
    match get_claim_case_insensitive(claims, key) {
        ClaimLookup::Found(value) => ("found", Some(value)),
        ClaimLookup::Missing => ("missing", None),
        ClaimLookup::Ambiguous => ("ambiguous", None),
    }
}

fn claim_value_type_for_log(value: Option<&serde_json::Value>) -> &'static str {
    match value {
        Some(serde_json::Value::Null) => "null",
        Some(serde_json::Value::Bool(_)) => "bool",
        Some(serde_json::Value::Number(_)) => "number",
        Some(serde_json::Value::String(_)) => "string",
        Some(serde_json::Value::Array(_)) => "array",
        Some(serde_json::Value::Object(_)) => "object",
        None => "none",
    }
}

#[cfg(test)]
pub(crate) fn make_test_sys(configs: Vec<OidcProviderConfig>) -> OidcSys {
    let configs = configs.into_iter().map(|config| (config.id.clone(), config)).collect();
    OidcSys {
        configs,
        provider_states: RwLock::new(HashMap::new()),
        state_store: OidcStateStore::new(),
        http_client: ReqwestHttpClient::new().expect("failed to initialize OIDC HTTP clients"),
    }
}

#[cfg(test)]
pub(crate) fn test_config(id: &str) -> OidcProviderConfig {
    OidcProviderConfig {
        id: id.to_string(),
        enabled: true,
        config_url: format!("https://example.com/{id}/.well-known/openid-configuration"),
        issuer: None,
        client_id: "client-id".to_string(),
        client_secret: None,
        scopes: vec!["openid".to_string()],
        other_audiences: vec![],
        redirect_uri: None,
        redirect_uri_dynamic: true,
        claim_name: "groups".to_string(),
        claim_prefix: String::new(),
        role_policy: String::new(),
        display_name: id.to_string(),
        groups_claim: "groups".to_string(),
        roles_claim: String::new(),
        email_claim: "email".to_string(),
        username_claim: "preferred_username".to_string(),
        hide_from_ui: false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use jsonwebtoken::{Algorithm, EncodingKey, Header};
    use rustfs_utils::egress::OutboundDnsPolicyRejection;
    use std::sync::atomic::{AtomicUsize, Ordering};

    #[derive(Clone)]
    struct RejectingDnsResolver {
        allow_origin_can_recover: bool,
        calls: Option<Arc<AtomicUsize>>,
    }

    impl reqwest::dns::Resolve for RejectingDnsResolver {
        fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
            if let Some(calls) = &self.calls {
                calls.fetch_add(1, Ordering::Relaxed);
            }
            let host = name.as_str().to_string();
            let rejection = OutboundDnsPolicyRejection::new(host, self.allow_origin_can_recover);
            Box::pin(async move { Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, rejection).into()) })
        }
    }

    #[test]
    fn test_extract_string_claim() {
        let mut claims = HashMap::new();
        claims.insert("email".to_string(), serde_json::json!("user@example.com"));
        claims.insert("sub".to_string(), serde_json::json!("12345"));

        assert_eq!(extract_string_claim(&claims, "email"), "user@example.com");
        assert_eq!(extract_string_claim(&claims, "sub"), "12345");
        assert_eq!(extract_string_claim(&claims, "missing"), "");
    }

    #[test]
    fn format_http_headers_redacts_sensitive_values() {
        let mut headers = http::HeaderMap::new();
        headers.insert(http::header::AUTHORIZATION, "Basic Y2xpZW50OnNlY3JldA==".parse().unwrap());
        headers.insert(http::header::CONTENT_TYPE, "application/json".parse().unwrap());
        headers.insert(http::header::COOKIE, "session=super-secret".parse().unwrap());

        let rendered = format_http_headers(&headers);

        // Sensitive header values never appear; only their length is emitted.
        assert!(!rendered.contains("Y2xpZW50OnNlY3JldA=="), "authorization value leaked: {rendered}");
        assert!(!rendered.contains("super-secret"), "cookie value leaked: {rendered}");
        assert!(
            rendered.contains("authorization=<redacted len="),
            "expected redacted authorization: {rendered}"
        );
        assert!(rendered.contains("cookie=<redacted len="), "expected redacted cookie: {rendered}");
        // Non-sensitive header values are preserved for diagnostics.
        assert!(
            rendered.contains("content-type=application/json"),
            "content-type should be visible: {rendered}"
        );
    }

    #[test]
    fn is_sensitive_header_is_case_insensitive() {
        assert!(is_sensitive_header("Authorization"));
        assert!(is_sensitive_header("PROXY-AUTHORIZATION"));
        assert!(is_sensitive_header("Set-Cookie"));
        assert!(!is_sensitive_header("content-type"));
        assert!(!is_sensitive_header("x-request-id"));
    }

    #[test]
    fn test_extract_groups_claim_array() {
        let mut claims = HashMap::new();
        claims.insert("groups".to_string(), serde_json::json!(["admin", "developers", "readonly"]));

        let groups = extract_groups_claim(&claims, "groups");
        assert_eq!(groups, vec!["admin", "developers", "readonly"]);
    }

    #[test]
    fn test_extract_groups_claim_string() {
        let mut claims = HashMap::new();
        claims.insert("groups".to_string(), serde_json::json!("admin,developers"));

        let groups = extract_groups_claim(&claims, "groups");
        assert_eq!(groups, vec!["admin", "developers"]);
    }

    #[test]
    fn test_extract_groups_claim_missing() {
        let claims = HashMap::new();
        let groups = extract_groups_claim(&claims, "groups");
        assert!(groups.is_empty());
    }

    #[test]
    fn test_extract_groups_claim_number() {
        let mut claims = HashMap::new();
        claims.insert("groups".to_string(), serde_json::json!(42));
        let groups = extract_groups_claim(&claims, "groups");
        assert!(groups.is_empty());
    }

    #[test]
    fn test_extract_canonical_group_values_merges_groups_and_roles() {
        let mut claims = HashMap::new();
        claims.insert("groups".to_string(), serde_json::json!(["devs", "admins"]));
        claims.insert("roles".to_string(), serde_json::json!(["admins", "consoleAdmin"]));

        let merged = extract_canonical_group_values(&claims, "groups", "roles");
        assert_eq!(merged, vec!["admins", "consoleAdmin", "devs"]);
    }

    #[test]
    fn test_extract_canonical_group_values_skips_duplicate_claim_name() {
        let mut claims = HashMap::new();
        claims.insert("roles".to_string(), serde_json::json!(["consoleAdmin"]));

        let merged = extract_canonical_group_values(&claims, "roles", "roles");
        assert_eq!(merged, vec!["consoleAdmin"]);
    }

    #[test]
    fn test_extract_canonical_group_values_roles_only() {
        let mut claims = HashMap::new();
        claims.insert("roles".to_string(), serde_json::json!(["consoleAdmin", "bucket-reader"]));

        let merged = extract_canonical_group_values(&claims, "groups", "roles");
        assert_eq!(merged, vec!["bucket-reader", "consoleAdmin"]);
    }

    #[test]
    fn test_extract_string_claim_case_insensitive() {
        let mut claims = HashMap::new();
        claims.insert("policyminio".to_string(), serde_json::json!("consoleAdmin"));

        assert_eq!(extract_string_claim(&claims, "policyMinio"), "consoleAdmin");
        assert_eq!(extract_string_claim(&claims, "POLICYMINIO"), "consoleAdmin");
        assert_eq!(extract_string_claim(&claims, "policyminio"), "consoleAdmin");
    }

    #[test]
    fn test_extract_groups_claim_case_insensitive() {
        let mut claims = HashMap::new();
        claims.insert("policyminio".to_string(), serde_json::json!(["consoleAdmin", "readwrite"]));

        let groups = extract_groups_claim(&claims, "policyMinio");
        assert_eq!(groups, vec!["consoleAdmin", "readwrite"]);

        let groups = extract_groups_claim(&claims, "POLICYMINIO");
        assert_eq!(groups, vec!["consoleAdmin", "readwrite"]);

        let groups = extract_groups_claim(&claims, "policyminio");
        assert_eq!(groups, vec!["consoleAdmin", "readwrite"]);
    }

    #[test]
    fn test_extract_groups_claim_exact_match_preferred() {
        let mut claims = HashMap::new();
        claims.insert("Policy".to_string(), serde_json::json!(["exact_match"]));
        claims.insert("policy".to_string(), serde_json::json!(["lowercase"]));

        let groups = extract_groups_claim(&claims, "Policy");
        assert_eq!(groups, vec!["exact_match"]);
    }

    #[test]
    fn test_extract_string_claim_ambiguous_case_insensitive_match_returns_empty() {
        let mut claims = HashMap::new();
        claims.insert("Policy".to_string(), serde_json::json!("exact_match"));
        claims.insert("policy".to_string(), serde_json::json!("lowercase"));

        assert_eq!(extract_string_claim(&claims, "POLICY"), "");
    }

    #[test]
    fn test_extract_groups_claim_ambiguous_case_insensitive_match_returns_empty() {
        let mut claims = HashMap::new();
        claims.insert("Policy".to_string(), serde_json::json!(["exact_match"]));
        claims.insert("policy".to_string(), serde_json::json!(["lowercase"]));

        let groups = extract_groups_claim(&claims, "POLICY");
        assert!(groups.is_empty());
    }

    #[test]
    fn test_decode_jwt_payload() {
        let payload = r#"{"sub":"user123","email":"user@example.com"}"#;
        let payload_b64 = base64_simd::URL_SAFE_NO_PAD.encode_to_string(payload.as_bytes());
        let token = format!("eyJhbGciOiJSUzI1NiJ9.{payload_b64}.signature");

        let claims = decode_jwt_payload(&token);
        assert_eq!(claims.get("sub").and_then(|v| v.as_str()), Some("user123"));
        assert_eq!(claims.get("email").and_then(|v| v.as_str()), Some("user@example.com"));
    }

    #[test]
    fn test_normalize_issuer_matches() {
        let lhs = normalize_issuer("https://idp.example.com/.well-known/openid-configuration/").unwrap();
        let rhs = normalize_issuer("https://idp.example.com/.well-known/openid-configuration").unwrap();
        assert_eq!(lhs, rhs);
        assert_eq!(
            lhs,
            (
                "https".to_string(),
                "idp.example.com".to_string(),
                443,
                "/.well-known/openid-configuration".to_string()
            )
        );
    }

    #[test]
    fn test_normalize_config_url() {
        // --- Well-known suffix stripping ---
        // Bare well-known URL → stripped to just the host
        assert_eq!(
            normalize_config_url("https://idp.example.com/.well-known/openid-configuration").unwrap(),
            "https://idp.example.com"
        );
        // Trailing slash after well-known suffix is also stripped
        assert_eq!(
            normalize_config_url("https://idp.example.com/.well-known/openid-configuration/").unwrap(),
            "https://idp.example.com"
        );
        // Well-known under a sub-path (Keycloak realms)
        assert_eq!(
            normalize_config_url("https://keycloak.example.com/realms/myrealm/.well-known/openid-configuration").unwrap(),
            "https://keycloak.example.com/realms/myrealm"
        );

        // --- Providers WITHOUT trailing slash (Keycloak, Auth0, Okta, Google) ---
        assert_eq!(
            normalize_config_url("https://keycloak.example.com/realms/myrealm").unwrap(),
            "https://keycloak.example.com/realms/myrealm"
        );
        assert_eq!(
            normalize_config_url("https://idp.example.com/custom/realm").unwrap(),
            "https://idp.example.com/custom/realm"
        );

        // --- Providers WITH trailing slash (Authentik) ---
        assert_eq!(
            normalize_config_url("https://auth.example.com/application/o/myapp/").unwrap(),
            "https://auth.example.com/application/o/myapp/"
        );

        // --- Root-level issuer (bare host) ---
        assert_eq!(normalize_config_url("https://idp.example.com").unwrap(), "https://idp.example.com");
        assert_eq!(normalize_config_url("https://idp.example.com/").unwrap(), "https://idp.example.com");

        // --- Custom port ---
        assert_eq!(
            normalize_config_url("https://idp.example.com:8443/auth/realms/test").unwrap(),
            "https://idp.example.com:8443/auth/realms/test"
        );
        assert_eq!(
            normalize_config_url("http://localhost:8080/application/o/app/").unwrap(),
            "http://localhost:8080/application/o/app/"
        );

        // --- Error cases ---
        assert!(normalize_config_url("https://idp.example.com/.well-known/invalid").is_err());
        assert!(normalize_config_url("gopher://idp.example.com").is_err());
        assert!(normalize_config_url("not-a-url").is_err());
    }

    #[test]
    fn test_discovery_url_from_config_url() {
        assert_eq!(
            discovery_url_from_config_url("https://idp.example.com/.well-known/openid-configuration")
                .expect("config URL should parse")
                .as_str(),
            "https://idp.example.com/.well-known/openid-configuration"
        );
        assert_eq!(
            discovery_url_from_config_url("https://idp.example.com/realms/app")
                .expect("issuer URL should derive discovery URL")
                .as_str(),
            "https://idp.example.com/realms/app/.well-known/openid-configuration"
        );
        assert!(discovery_url_from_config_url("https://idp.example.com/.well-known/not-openid").is_err());
    }

    #[test]
    fn test_issuer_candidates() {
        assert_eq!(
            issuer_candidates("https://idp.example.com/realm"),
            vec![
                "https://idp.example.com/realm".to_string(),
                "https://idp.example.com/realm/".to_string()
            ]
        );
        assert_eq!(
            issuer_candidates("https://idp.example.com/realm/"),
            vec![
                "https://idp.example.com/realm/".to_string(),
                "https://idp.example.com/realm".to_string()
            ]
        );
        assert_eq!(
            issuer_candidates("https://idp.example.com"),
            vec!["https://idp.example.com".to_string(), "https://idp.example.com/".to_string()]
        );
    }

    fn build_mocked_oidc_provider_config(id: &str, config_url: &str) -> OidcProviderConfig {
        OidcProviderConfig {
            id: id.to_string(),
            enabled: true,
            config_url: config_url.to_string(),
            issuer: None,
            client_id: "rustfs-oidc-test".to_string(),
            client_secret: None,
            scopes: vec!["openid".to_string()],
            other_audiences: vec![],
            redirect_uri: None,
            redirect_uri_dynamic: false,
            claim_name: "sub".to_string(),
            claim_prefix: "oidc".to_string(),
            role_policy: String::new(),
            display_name: "mock-oidc".to_string(),
            groups_claim: "groups".to_string(),
            roles_claim: String::new(),
            email_claim: "email".to_string(),
            username_claim: "username".to_string(),
            hide_from_ui: false,
        }
    }

    fn read_mock_oidc_request_path(stream: &mut impl std::io::Read) -> String {
        let mut request_bytes = Vec::new();
        let mut buffer = [0u8; 4096];
        loop {
            match stream.read(&mut buffer) {
                Ok(0) => break,
                Ok(n) => request_bytes.extend_from_slice(&buffer[..n]),
                Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => break,
                Err(_) => break,
            }
            if request_bytes.windows(4).any(|w| w == b"\r\n\r\n") {
                break;
            }
            if request_bytes.len() >= 8192 {
                break;
            }
        }
        let request = String::from_utf8_lossy(&request_bytes);
        request
            .lines()
            .next()
            .unwrap_or("")
            .split_whitespace()
            .nth(1)
            .unwrap_or("")
            .to_string()
    }

    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") {
            (200, discovery_body)
        } else if path == expected_jwks_path {
            (200, jwks_body)
        } else {
            (404, r#"{"error":"not found"}"#)
        };

        format!(
            "HTTP/1.1 {status} {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
            if status == 200 { "OK" } else { "Not Found" },
            body.len()
        )
    }

    fn start_mock_oidc_discovery_server_with_jwks<F, J>(
        build_discovery_issuer: F,
        max_requests: usize,
        signing_alg: &'static str,
        jwks_response: J,
    ) -> Option<(String, std::thread::JoinHandle<()>)>
    where
        F: Fn(&str) -> (String, String, String) + Send + 'static,
        J: Fn(usize) -> String + Send + 'static,
    {
        use std::io::Write;
        use std::net::{Shutdown, TcpListener};
        use std::sync::mpsc;
        use std::time::{Duration, Instant};

        // After the last completed response, exit if no new connection arrives within this window.
        // Keep the mock server alive long enough for slower CI/macOS test environments to finish
        // discovery + JWKS requests without racing the shutdown timer.
        const IDLE_SHUTDOWN: Duration = Duration::from_secs(1);
        const ABSOLUTE_CAP: Duration = Duration::from_secs(5);

        let listener = match TcpListener::bind("127.0.0.1:0") {
            Ok(listener) => listener,
            Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None,
            Err(err) => panic!("test listener should bind: {err}"),
        };
        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!({
            "issuer": discovery_issuer,
            "authorization_endpoint": format!("{base}/authorize"),
            "token_endpoint": format!("{base}/token"),
            "jwks_uri": discovery_jwks_uri,
            "response_types_supported": ["code"],
            "response_modes_supported": ["query"],
            "subject_types_supported": ["public"],
            "id_token_signing_alg_values_supported": [signing_alg],
        })
        .to_string();
        let (ready_tx, ready_rx) = mpsc::channel();

        let handle = std::thread::spawn(move || {
            listener
                .set_nonblocking(true)
                .expect("failed to set discovery mock listener non-blocking");
            let _ = ready_tx.send(());

            let mut seen = 0usize;
            let mut jwks_fetches = 0usize;
            let start = Instant::now();
            let mut last_completed = Instant::now();

            loop {
                if seen > 0 && last_completed.elapsed() >= IDLE_SHUTDOWN {
                    break;
                }
                if start.elapsed() >= ABSOLUTE_CAP {
                    break;
                }

                let mut stream = match listener.accept() {
                    Ok((stream, _)) => stream,
                    Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
                        std::thread::sleep(Duration::from_millis(5));
                        continue;
                    }
                    Err(_) => break,
                };
                stream
                    .set_nonblocking(false)
                    .expect("failed to set discovery mock stream blocking");

                seen += 1;
                stream
                    .set_nonblocking(false)
                    .expect("failed to set discovery mock stream blocking");
                stream
                    .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 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 _ = stream.write_all(response.as_bytes());
                let _ = stream.flush();
                let _ = stream.shutdown(Shutdown::Both);
                last_completed = Instant::now();

                if seen >= max_requests {
                    break;
                }
            }
        });
        ready_rx
            .recv_timeout(Duration::from_millis(100))
            .expect("mock OIDC discovery server should become ready");

        Some((base, handle))
    }

    fn start_mock_oidc_discovery_server<F>(
        build_discovery_issuer: F,
        max_requests: usize,
    ) -> Option<(String, std::thread::JoinHandle<()>)>
    where
        F: Fn(&str) -> (String, String, String) + Send + 'static,
    {
        start_mock_oidc_discovery_server_with_jwks(build_discovery_issuer, max_requests, "RS256", |_| {
            r#"{"keys":[]}"#.to_string()
        })
    }

    fn oidc_es256_key_and_jwk(kid: &str) -> (EncodingKey, serde_json::Value) {
        let certified =
            rcgen::generate_simple_self_signed(vec![format!("{kid}.invalid")]).expect("OIDC signing key should generate");
        let encoding_key =
            EncodingKey::from_ec_pem(certified.signing_key.serialize_pem().as_bytes()).expect("OIDC signing key should encode");
        let mut jwk = jsonwebtoken::jwk::Jwk::from_encoding_key(&encoding_key, Algorithm::ES256)
            .expect("OIDC public JWK should derive from signing key");
        jwk.common.key_id = Some(kid.to_string());
        jwk.common.public_key_use = Some(jsonwebtoken::jwk::PublicKeyUse::Signature);
        (encoding_key, serde_json::to_value(jwk).expect("OIDC JWK should serialize"))
    }

    #[tokio::test]
    async fn web_identity_verification_refreshes_rotated_jwks() {
        let (_, initial_jwk) = oidc_es256_key_and_jwk("initial");
        let (rotated_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 Some((base, handle)) = start_mock_oidc_discovery_server_with_jwks(
            |base| (base.to_string(), format!("{base}/jwks"), "/jwks".to_string()),
            4,
            "ES256",
            move |fetch| {
                if fetch == 0 {
                    initial_jwks.clone()
                } else {
                    rotated_jwks.clone()
                }
            },
        ) else {
            return;
        };

        let config = build_mocked_oidc_provider_config("rotating", &base);
        let policy = OutboundPolicy::from_allowed_origins(&base).expect("loopback origin should be allowed");
        let http_client = ReqwestHttpClient::with_policy(policy);
        let state = OidcSys::discover_provider(&config, &http_client)
            .await
            .expect("initial OIDC discovery should succeed");
        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,
        };

        let now = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .expect("system clock should be after Unix epoch")
            .as_secs();
        let mut header = Header::new(Algorithm::ES256);
        header.kid = Some("rotated".to_string());
        let token = jsonwebtoken::encode(
            &header,
            &serde_json::json!({
                "iss": base,
                "sub": "rotated-user",
                "aud": config.client_id,
                "iat": now,
                "exp": now + 300,
                "groups": ["readwrite"],
            }),
            &rotated_key,
        )
        .expect("rotated OIDC token should sign");

        let (claims, provider_id) = sys
            .verify_web_identity_token(&token)
            .await
            .expect("verification should refresh JWKS and accept the rotated key");
        assert_eq!(provider_id, "rotating");
        assert_eq!(claims.sub, "rotated-user");
        assert_eq!(claims.groups, vec!["readwrite"]);
        handle.join().expect("rotating JWKS mock server should exit cleanly");
    }

    fn start_mock_oidc_tls_discovery_server<F>(
        build_discovery_issuer: F,
        max_requests: usize,
    ) -> Option<(String, String, std::thread::JoinHandle<()>)>
    where
        F: Fn(&str) -> (String, String, String) + Send + 'static,
    {
        use std::io::Write;
        use std::net::{Shutdown, TcpListener};
        use std::sync::mpsc;
        use std::time::{Duration, Instant};

        const IDLE_SHUTDOWN: Duration = Duration::from_secs(1);
        const ABSOLUTE_CAP: Duration = Duration::from_secs(5);

        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
        let certified =
            rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).expect("generate OIDC TLS test certificate");
        let cert_pem = certified.cert.pem();
        let server_config = rustls::ServerConfig::builder()
            .with_no_client_auth()
            .with_single_cert(
                vec![certified.cert.der().clone()],
                rustls_pki_types::PrivateKeyDer::try_from(certified.signing_key.serialize_der())
                    .expect("convert OIDC TLS test private key"),
            )
            .expect("build OIDC TLS mock server config");

        let listener = match TcpListener::bind("127.0.0.1:0") {
            Ok(listener) => listener,
            Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None,
            Err(err) => panic!("test TLS listener should bind: {err}"),
        };
        let base = format!("https://{}", 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!({
            "issuer": discovery_issuer,
            "authorization_endpoint": format!("{base}/authorize"),
            "token_endpoint": format!("{base}/token"),
            "jwks_uri": discovery_jwks_uri,
            "response_types_supported": ["code"],
            "response_modes_supported": ["query"],
            "subject_types_supported": ["public"],
            "id_token_signing_alg_values_supported": ["RS256"],
        })
        .to_string();
        let jwks_body = r#"{"keys":[]}"#;
        let (ready_tx, ready_rx) = mpsc::channel();

        let handle = std::thread::spawn(move || {
            let server_config = Arc::new(server_config);
            listener
                .set_nonblocking(true)
                .expect("failed to set TLS discovery mock listener non-blocking");
            let _ = ready_tx.send(());

            let mut seen = 0usize;
            let start = Instant::now();
            let mut last_completed = Instant::now();

            loop {
                if seen > 0 && last_completed.elapsed() >= IDLE_SHUTDOWN {
                    break;
                }
                if start.elapsed() >= ABSOLUTE_CAP {
                    break;
                }

                let tcp_stream = match listener.accept() {
                    Ok((stream, _)) => stream,
                    Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
                        std::thread::sleep(Duration::from_millis(5));
                        continue;
                    }
                    Err(_) => break,
                };
                tcp_stream
                    .set_nonblocking(false)
                    .expect("failed to set TLS discovery mock stream blocking");
                tcp_stream
                    .set_read_timeout(Some(Duration::from_secs(1)))
                    .expect("failed to set TLS discovery mock read timeout");

                seen += 1;
                let connection = match rustls::ServerConnection::new(server_config.clone()) {
                    Ok(connection) => connection,
                    Err(_) => break,
                };
                let mut stream = rustls::StreamOwned::new(connection, tcp_stream);
                let path = read_mock_oidc_request_path(&mut stream);
                let response = mock_oidc_response(&path, &discovery_body, &expected_jwks_path, jwks_body);
                let _ = stream.write_all(response.as_bytes());
                let _ = stream.flush();
                let _ = stream.sock.shutdown(Shutdown::Both);
                last_completed = Instant::now();

                if seen >= max_requests {
                    break;
                }
            }
        });
        ready_rx
            .recv_timeout(Duration::from_millis(100))
            .expect("mock TLS OIDC discovery server should become ready");

        Some((base, cert_pem, handle))
    }

    fn discovery_error_contains_all_variants(err: &str, base: &str) -> bool {
        err.contains(base) && err.contains(&format!("{base}/")) && err.contains("discovery failed for all issuer variants")
    }

    async fn validate_mocked_oidc_provider_config(config: &OidcProviderConfig) -> Result<OidcProviderValidationResult, String> {
        // The mock discovery/JWKS/token endpoints share the loopback origin of `config_url`.
        // Explicitly allow that origin so the egress policy does not reject the loopback mock.
        let origin = Url::parse(&config.config_url)
            .map_err(|_| "invalid mock config_url".to_string())?
            .origin()
            .ascii_serialization();
        let policy = OutboundPolicy::from_allowed_origins(&origin).map_err(|err| err.to_string())?;
        let http_client = ReqwestHttpClient::with_policy(policy);
        let state = OidcSys::discover_provider(config, &http_client).await?;

        Ok(OidcProviderValidationResult {
            issuer: state.metadata.issuer().to_string(),
            authorization_endpoint: state.metadata.authorization_endpoint().to_string(),
            token_endpoint: state.metadata.token_endpoint().map(ToString::to_string),
        })
    }

    #[tokio::test]
    async fn oidc_discovery_accepts_extra_root_ca_for_https_provider() {
        let Some((base, ca_pem, handle)) = start_mock_oidc_tls_discovery_server(
            |base| (format!("{base}/application/o/rustfs"), format!("{base}/jwks"), "/jwks".to_string()),
            4,
        ) else {
            return;
        };
        let config_url = format!("{base}/application/o/rustfs");
        let config = build_mocked_oidc_provider_config("default", &config_url);
        let origin = Url::parse(&config.config_url)
            .expect("mock config_url should parse")
            .origin()
            .ascii_serialization();
        let policy = OutboundPolicy::from_allowed_origins(&origin).expect("loopback TLS origin should be allowed");
        let extra_root_certs =
            parse_oidc_extra_root_certs("test OIDC TLS CA", ca_pem.as_bytes()).expect("test CA bundle should parse");
        let http_client = ReqwestHttpClient::with_policy_and_extra_root_certs(policy, extra_root_certs);

        let state = OidcSys::discover_provider(&config, &http_client)
            .await
            .expect("OIDC discovery should trust the extra root CA");

        assert_eq!(state.metadata.issuer().to_string(), format!("{base}/application/o/rustfs"));
        assert!(handle.join().is_ok());
    }

    #[tokio::test]
    async fn oidc_discovery_refreshes_extra_root_ca_when_generation_changes() {
        let Some((base_a, ca_pem_a, handle_a)) = start_mock_oidc_tls_discovery_server(
            |base| (format!("{base}/application/o/rustfs-a"), format!("{base}/jwks"), "/jwks".to_string()),
            4,
        ) else {
            return;
        };
        let Some((base_b, ca_pem_b, handle_b)) = start_mock_oidc_tls_discovery_server(
            |base| (format!("{base}/application/o/rustfs-b"), format!("{base}/jwks"), "/jwks".to_string()),
            4,
        ) else {
            assert!(handle_a.join().is_ok());
            return;
        };

        let origin_a = Url::parse(&base_a)
            .expect("mock base A should parse")
            .origin()
            .ascii_serialization();
        let origin_b = Url::parse(&base_b)
            .expect("mock base B should parse")
            .origin()
            .ascii_serialization();
        let allowed_origins = format!("{origin_a},{origin_b}");
        let policy = OutboundPolicy::from_allowed_origins(&allowed_origins).expect("loopback TLS origins should be allowed");
        let material = Arc::new(Mutex::new(OidcExtraRootCaMaterial {
            generation: 1,
            root_ca_pem: Some(ca_pem_a.into_bytes()),
        }));
        let provider = OidcExtraRootCaProvider::new({
            let material = material.clone();
            move || {
                let material = material.clone();
                async move {
                    material
                        .lock()
                        .map(|material| material.clone())
                        .map_err(|e| format!("test OIDC extra CA material lock poisoned: {e}"))
                }
            }
        });
        let http_client = ReqwestHttpClient::with_policy_and_extra_root_ca_provider(policy, provider);

        let config_a = build_mocked_oidc_provider_config("a", &format!("{base_a}/application/o/rustfs-a"));
        let state_a = OidcSys::discover_provider(&config_a, &http_client)
            .await
            .expect("OIDC discovery should trust initial extra root CA");
        assert_eq!(state_a.metadata.issuer().to_string(), format!("{base_a}/application/o/rustfs-a"));

        {
            let mut material = material
                .lock()
                .expect("test OIDC extra CA material lock should not be poisoned");
            material.generation = 2;
            material.root_ca_pem = Some(ca_pem_b.into_bytes());
        }
        let config_b = build_mocked_oidc_provider_config("b", &format!("{base_b}/application/o/rustfs-b"));
        let state_b = OidcSys::discover_provider(&config_b, &http_client)
            .await
            .expect("OIDC discovery should refresh extra root CA after generation change");

        assert_eq!(state_b.metadata.issuer().to_string(), format!("{base_b}/application/o/rustfs-b"));
        assert!(handle_a.join().is_ok());
        assert!(handle_b.join().is_ok());
    }

    #[tokio::test]
    async fn test_validate_oidc_provider_config_retries_with_issuer_candidates() {
        // Discovery document must advertise the canonical issuer path. The first candidate has no
        // trailing slash; openidconnect rejects issuer mismatch, then the second variant succeeds.
        let Some((base, handle)) = start_mock_oidc_discovery_server(
            |base| (format!("{base}/application/o/rustfs/"), format!("{base}/jwks"), "/jwks".to_string()),
            8,
        ) else {
            return;
        };
        let config_url = format!("{base}/application/o/rustfs");
        let config = build_mocked_oidc_provider_config("default", &config_url);

        let result = validate_mocked_oidc_provider_config(&config).await;

        let validation_result = result.expect("OIDC provider validation should succeed");
        assert_eq!(validation_result.issuer, format!("{base}/application/o/rustfs/"));
        assert!(handle.join().is_ok());
    }

    #[tokio::test]
    async fn test_validate_oidc_provider_config_fetches_issuer_relative_jwks_from_config_url() {
        let Some((base, handle)) = start_mock_oidc_discovery_server(
            |_| {
                (
                    "http://127.0.0.1:1/public/realms/app".to_string(),
                    "http://127.0.0.1:1/public/realms/app/jwks?version=1".to_string(),
                    "/internal/realms/app/jwks?version=1".to_string(),
                )
            },
            2,
        ) else {
            return;
        };
        let mut config =
            build_mocked_oidc_provider_config("default", &format!("{base}/internal/realms/app/.well-known/openid-configuration"));
        config.issuer = Some("http://127.0.0.1:1/public/realms/app".to_string());

        let validation_result = validate_mocked_oidc_provider_config(&config)
            .await
            .expect("OIDC provider validation should succeed");

        assert_eq!(validation_result.issuer, "http://127.0.0.1:1/public/realms/app");
        assert!(handle.join().is_ok());
    }

    #[test]
    fn test_jwks_url_from_config_url_preserves_unrelated_urls() {
        let issuer = IssuerUrl::new("https://public.example.com/realms/app".to_string()).expect("issuer URL should parse");
        for raw_jwks_url in [
            "https://keys.example.com/jwks",
            "http://public.example.com/realms/app/jwks",
            "https://public.example.com:8443/realms/app/jwks",
            "https://public.example.com/keys/jwks",
            "https://public.example.com/realms/application/jwks",
        ] {
            let jwks_url = JsonWebKeySetUrl::new(raw_jwks_url.to_string()).expect("JWKS URL should parse");

            let resolved = jwks_url_from_config_url(
                "http://keycloak.internal/realms/app/.well-known/openid-configuration",
                &issuer,
                &jwks_url,
            )
            .expect("JWKS URL should resolve");

            assert_eq!(resolved.as_str(), raw_jwks_url);
        }

        let issuer_root_jwks = JsonWebKeySetUrl::new(issuer.as_str().to_string()).expect("JWKS URL should parse");
        let resolved = jwks_url_from_config_url(
            "http://keycloak.internal/realms/app/.well-known/openid-configuration",
            &issuer,
            &issuer_root_jwks,
        )
        .expect("issuer-root JWKS URL should resolve");
        assert_eq!(resolved.as_str(), "http://keycloak.internal/realms/app");

        let issuer_with_slash =
            IssuerUrl::new("https://public.example.com/realms/app/".to_string()).expect("issuer URL should parse");
        let jwks_url =
            JsonWebKeySetUrl::new("https://public.example.com/realms/app/jwks".to_string()).expect("JWKS URL should parse");
        let resolved = jwks_url_from_config_url(
            "http://keycloak.internal/realms/app/.well-known/openid-configuration",
            &issuer_with_slash,
            &jwks_url,
        )
        .expect("issuer-relative JWKS URL should resolve");
        assert_eq!(resolved.as_str(), "http://keycloak.internal/realms/app/jwks");
    }

    #[tokio::test]
    async fn test_validate_oidc_provider_config_rejects_separate_issuer_mismatch() {
        let Some((base, handle)) = start_mock_oidc_discovery_server(
            |base| {
                (
                    "https://public.example.com/realms/other".to_string(),
                    format!("{base}/jwks"),
                    "/jwks".to_string(),
                )
            },
            1,
        ) else {
            return;
        };
        let mut config =
            build_mocked_oidc_provider_config("default", &format!("{base}/internal/realms/app/.well-known/openid-configuration"));
        config.issuer = Some("https://public.example.com/realms/app".to_string());

        let err = validate_mocked_oidc_provider_config(&config)
            .await
            .expect_err("OIDC provider validation should fail");

        assert!(err.contains("unexpected issuer URI"));
        assert!(err.contains("https://public.example.com/realms/app"));
        assert!(handle.join().is_ok());
    }

    #[tokio::test]
    async fn test_validate_oidc_provider_config_returns_detailed_errors() {
        let Some((base, handle)) = start_mock_oidc_discovery_server(
            |base| (format!("{base}/application/o/other"), format!("{base}/jwks"), "/jwks".to_string()),
            8,
        ) else {
            return;
        };
        let config_url = format!("{base}/application/o/rustfs");
        let config = build_mocked_oidc_provider_config("default", &config_url);

        let err = validate_mocked_oidc_provider_config(&config)
            .await
            .expect_err("OIDC provider validation should fail");
        assert!(discovery_error_contains_all_variants(&err, &base));
        assert!(err.contains("issuer '"));
        assert!(err.contains(&format!("issuer '{base}/application/o/rustfs'")));
        assert!(err.contains(&format!("issuer '{base}/application/o/rustfs/'")));
        assert!(handle.join().is_ok());
    }

    #[test]
    fn test_decode_jwt_payload_invalid() {
        assert!(decode_jwt_payload("not-a-jwt").is_empty());
        assert!(decode_jwt_payload("").is_empty());
    }

    #[test]
    fn test_core_token_response_accepts_rfc3339_updated_at() {
        // Signature verification happens later; this test covers token response deserialization.
        let id_token = "eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20vb2lkYyIsInN1YiI6InVzZXItMSIsImF1ZCI6InJ1c3RmcyIsImV4cCI6MTc4NDQzMjc2OSwiaWF0IjoxNzgzMjIzMTY5LCJ1cGRhdGVkX2F0IjoiMjAyNi0wNy0wM1QwNDo1MDo1MC44MTFaIn0.c2ln";
        let body = serde_json::json!({
            "scope": "openid roles profile email",
            "token_type": "Bearer",
            "access_token": "access-token",
            "expires_in": 1209600,
            "id_token": id_token,
        })
        .to_string();

        let response: openidconnect::core::CoreTokenResponse =
            serde_json::from_str(&body).expect("RFC3339 updated_at should parse in token response");

        assert!(response.extra_fields().id_token().is_some());
    }

    #[test]
    fn test_map_claims_to_policies_no_provider() {
        let sys = OidcSys::empty().expect("failed to initialize empty OIDC system");

        let claims = OidcClaims {
            sub: "user123".to_string(),
            email: "user@example.com".to_string(),
            username: "user".to_string(),
            groups: vec!["admin".to_string(), "devs".to_string()],
            raw: HashMap::new(),
        };

        let (policies, groups) = sys.map_claims_to_policies("nonexistent", &claims);
        assert!(policies.is_empty());
        assert!(groups.is_empty());
    }

    #[test]
    fn test_oidc_claims_default() {
        let claims = OidcClaims::default();
        assert!(claims.sub.is_empty());
        assert!(claims.email.is_empty());
        assert!(claims.username.is_empty());
        assert!(claims.groups.is_empty());
        assert!(claims.raw.is_empty());
    }

    #[test]
    fn test_oidc_claims_serde_roundtrip() {
        let claims = OidcClaims {
            sub: "user123".to_string(),
            email: "user@example.com".to_string(),
            username: "testuser".to_string(),
            groups: vec!["admin".to_string(), "devs".to_string()],
            raw: {
                let mut m = HashMap::new();
                m.insert("custom".to_string(), serde_json::json!("value"));
                m
            },
        };

        let json = serde_json::to_string(&claims).unwrap();
        let deserialized: OidcClaims = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.sub, "user123");
        assert_eq!(deserialized.email, "user@example.com");
        assert_eq!(deserialized.groups.len(), 2);
    }

    #[test]
    fn test_oidc_provider_summary_serde() {
        let summary = OidcProviderSummary {
            provider_id: "okta".to_string(),
            display_name: "Okta SSO".to_string(),
        };

        let json = serde_json::to_string(&summary).unwrap();
        let deserialized: OidcProviderSummary = serde_json::from_str(&json).unwrap();
        assert_eq!(deserialized.provider_id, "okta");
        assert_eq!(deserialized.display_name, "Okta SSO");
    }

    #[test]
    fn test_parse_single_provider_no_config_url() {
        let config = OidcSys::parse_single_provider("_TEST_EMPTY", "test_empty");
        assert!(config.is_none());
    }

    #[test]
    fn test_parse_single_provider_reads_issuer() {
        temp_env::with_vars(
            [
                (
                    ENV_IDENTITY_OPENID_CONFIG_URL,
                    Some("http://keycloak.ns.svc.cluster.local:8080/realms/app/.well-known/openid-configuration"),
                ),
                (ENV_IDENTITY_OPENID_ISSUER, Some("https://app.local/realms/app")),
                (ENV_IDENTITY_OPENID_CLIENT_ID, Some("console")),
            ],
            || {
                let config = OidcSys::parse_single_provider("", "default").expect("provider config should parse");

                assert_eq!(config.issuer.as_deref(), Some("https://app.local/realms/app"));
            },
        );
    }

    #[test]
    fn test_parse_persisted_provider_config() {
        let mut cfg = ServerConfig::new();
        let mut kvs = KVS(vec![
            rustfs_config::server_config::KV {
                key: ENABLE_KEY.to_string(),
                value: EnableState::Off.to_string(),
                hidden_if_empty: false,
            },
            rustfs_config::server_config::KV {
                key: OIDC_CONFIG_URL.to_string(),
                value: String::new(),
                hidden_if_empty: false,
            },
            rustfs_config::server_config::KV {
                key: OIDC_CLIENT_ID.to_string(),
                value: String::new(),
                hidden_if_empty: false,
            },
        ]);
        kvs.insert(
            OIDC_CONFIG_URL.to_string(),
            "https://example.com/.well-known/openid-configuration".to_string(),
        );
        kvs.insert(OIDC_CLIENT_ID.to_string(), "console".to_string());
        kvs.insert(ENABLE_KEY.to_string(), EnableState::On.to_string());
        kvs.insert(OIDC_ISSUER.to_string(), "https://issuer.example".to_string());
        kvs.insert(OIDC_ROLES_CLAIM.to_string(), "app_roles".to_string());

        cfg.0
            .entry(IDENTITY_OPENID_SUB_SYS.to_string())
            .or_default()
            .insert(DEFAULT_DELIMITER.to_string(), kvs);

        let parsed = OidcSys::parse_persisted_configs(&cfg);
        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0].id, "default");
        assert_eq!(parsed[0].client_id, "console");
        assert_eq!(parsed[0].issuer.as_deref(), Some("https://issuer.example"));
        assert!(parsed[0].enabled);
        assert_eq!(parsed[0].roles_claim, "app_roles");
    }

    #[test]
    fn test_parse_persisted_provider_config_omitted_roles_claim_is_empty() {
        let mut cfg = ServerConfig::new();
        let mut kvs = KVS(vec![
            rustfs_config::server_config::KV {
                key: ENABLE_KEY.to_string(),
                value: EnableState::Off.to_string(),
                hidden_if_empty: false,
            },
            rustfs_config::server_config::KV {
                key: OIDC_CONFIG_URL.to_string(),
                value: String::new(),
                hidden_if_empty: false,
            },
            rustfs_config::server_config::KV {
                key: OIDC_CLIENT_ID.to_string(),
                value: String::new(),
                hidden_if_empty: false,
            },
        ]);
        kvs.insert(
            OIDC_CONFIG_URL.to_string(),
            "https://example.com/.well-known/openid-configuration".to_string(),
        );
        kvs.insert(OIDC_CLIENT_ID.to_string(), "console".to_string());
        kvs.insert(ENABLE_KEY.to_string(), EnableState::On.to_string());

        cfg.0
            .entry(IDENTITY_OPENID_SUB_SYS.to_string())
            .or_default()
            .insert(DEFAULT_DELIMITER.to_string(), kvs);

        let parsed = OidcSys::parse_persisted_configs(&cfg);
        assert_eq!(parsed.len(), 1);
        assert_eq!(parsed[0].roles_claim, "");
    }

    #[test]
    fn test_merge_oidc_provider_configs_prefers_env() {
        let mut persisted = test_config("default");
        persisted.display_name = "Persisted".to_string();

        let mut env = test_config("default");
        env.display_name = "Environment".to_string();

        let merged = merge_oidc_provider_configs(vec![env], vec![persisted]);
        assert_eq!(merged.len(), 1);
        assert_eq!(merged[0].config.display_name, "Environment");
        assert_eq!(merged[0].source, OidcProviderConfigSource::Env);
    }

    #[test]
    fn test_oidc_sys_empty() {
        let sys = OidcSys::empty().expect("failed to initialize empty OIDC system");
        assert!(!sys.has_providers());
        assert!(sys.list_providers().is_empty());
    }

    #[test]
    fn build_oidc_http_client_rejects_forbidden_targets_without_allowlist() {
        // Cloud metadata endpoint is never allowed.
        assert!(
            matches!(
                build_oidc_http_client("http://169.254.169.254/latest/meta-data/", None, &[], None),
                Err(OidcHttpError::ForbiddenOutbound(_))
            ),
            "metadata endpoint must be rejected"
        );
        // Loopback is rejected by default (no allow-origins configured).
        assert!(
            matches!(
                build_oidc_http_client("http://127.0.0.1:8080/.well-known/openid-configuration", None, &[], None),
                Err(OidcHttpError::ForbiddenOutbound(_))
            ),
            "loopback must be rejected by default"
        );
        // A public hostname passes the up-front shape/host check; the resolved IP is still
        // re-classified at connection time by the pinned resolver.
        assert!(
            build_oidc_http_client("https://accounts.example.com/.well-known/openid-configuration", None, &[], None).is_ok(),
            "public https endpoint should build"
        );
    }

    #[test]
    fn test_should_bypass_proxy_for_oidc_uri_loopback_only() {
        assert!(should_bypass_proxy_for_oidc_uri("http://127.0.0.1:9000/.well-known/openid-configuration"));
        assert!(should_bypass_proxy_for_oidc_uri("http://localhost:9000/.well-known/openid-configuration"));
        assert!(should_bypass_proxy_for_oidc_uri("http://[::1]:9000/.well-known/openid-configuration"));
        assert!(!should_bypass_proxy_for_oidc_uri(
            "https://idp.example.com/.well-known/openid-configuration"
        ));
        assert!(!should_bypass_proxy_for_oidc_uri("not-a-url"));
    }

    #[test]
    fn build_oidc_http_client_honors_explicit_allowlist_for_loopback() {
        let policy = OutboundPolicy::from_allowed_origins("http://127.0.0.1:8080").expect("origin should parse");
        assert!(
            build_oidc_http_client("http://127.0.0.1:8080/.well-known/openid-configuration", Some(&policy), &[], None).is_ok(),
            "explicitly allow-listed loopback origin should build"
        );
        // A metadata endpoint stays forbidden even when a loopback origin is allow-listed.
        assert!(
            matches!(
                build_oidc_http_client("http://169.254.169.254/", Some(&policy), &[], None),
                Err(OidcHttpError::ForbiddenOutbound(_))
            ),
            "metadata endpoint stays forbidden despite an unrelated allow-list entry"
        );
    }

    #[tokio::test]
    async fn oidc_discovery_reports_forbidden_outbound_without_retrying() {
        let config_url = "http://192.168.65.254:8080/realms/rustfs/.well-known/openid-configuration";
        let config = build_mocked_oidc_provider_config("default", config_url);
        let http_client = ReqwestHttpClient::with_policy(OutboundPolicy::default());

        let error = match OidcSys::discover_provider(&config, &http_client).await {
            Ok(_) => panic!("private OIDC provider should require an explicit allowlist origin"),
            Err(error) => error,
        };

        assert!(error.contains("OIDC provider discovery blocked by outbound policy"));
        assert!(error.contains(&format!("add http://192.168.65.254:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}")));
        assert!(!error.contains("discovery failed for all issuer variants"));
    }

    #[tokio::test]
    async fn oidc_explicit_issuer_reports_forbidden_discovery_endpoint() {
        let config_url = "http://192.168.65.254:8080/realms/rustfs/.well-known/openid-configuration";
        let mut config = build_mocked_oidc_provider_config("default", config_url);
        config.issuer = Some("https://idp.example.com/realms/rustfs".to_string());
        let http_client = ReqwestHttpClient::with_policy(OutboundPolicy::default());

        let error = match OidcSys::discover_provider(&config, &http_client).await {
            Ok(_) => panic!("private OIDC provider should require an explicit allowlist origin"),
            Err(error) => error,
        };

        assert!(error.contains("OIDC provider discovery blocked by outbound policy"));
        assert!(error.contains(&format!("add http://192.168.65.254:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}")));
    }

    #[tokio::test]
    async fn oidc_explicit_issuer_reports_forbidden_jwks_endpoint() {
        let Some((base, handle)) = start_mock_oidc_discovery_server(
            |base| {
                (
                    format!("{base}/realms/rustfs"),
                    "http://192.168.65.254:8080/realms/rustfs/protocol/openid-connect/certs".to_string(),
                    "/unused".to_string(),
                )
            },
            1,
        ) else {
            return;
        };
        let mut config =
            build_mocked_oidc_provider_config("default", &format!("{base}/realms/rustfs/.well-known/openid-configuration"));
        config.issuer = Some(format!("{base}/realms/rustfs"));
        let policy = OutboundPolicy::from_allowed_origins(&base).expect("loopback discovery origin should be allowed");
        let http_client = ReqwestHttpClient::with_policy(policy);

        let error = match OidcSys::discover_provider(&config, &http_client).await {
            Ok(_) => panic!("private JWKS endpoint should require an explicit allowlist origin"),
            Err(error) => error,
        };

        assert!(error.contains("JWKS request blocked by outbound policy"));
        assert!(error.contains(&format!("add http://192.168.65.254:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}")));
        assert!(handle.join().is_ok());
    }

    #[tokio::test]
    async fn oidc_token_exchange_reports_forbidden_token_endpoint() {
        let provider_id = "default";
        let token_endpoint = "http://192.168.65.254:8080/realms/rustfs/protocol/openid-connect/token";
        let metadata = serde_json::from_value::<ProviderMetadataWithLogout>(serde_json::json!({
            "issuer": "https://idp.example.com/realms/rustfs",
            "authorization_endpoint": "https://idp.example.com/realms/rustfs/protocol/openid-connect/auth",
            "token_endpoint": token_endpoint,
            "jwks_uri": "https://idp.example.com/realms/rustfs/protocol/openid-connect/certs",
            "response_types_supported": ["code"],
            "subject_types_supported": ["public"],
            "id_token_signing_alg_values_supported": ["RS256"]
        }))
        .expect("provider metadata should parse");
        let config = build_mocked_oidc_provider_config(
            provider_id,
            "https://idp.example.com/realms/rustfs/.well-known/openid-configuration",
        );
        let state_store = OidcStateStore::new();
        state_store
            .insert(
                "test-state".to_string(),
                OidcAuthSession {
                    provider_id: provider_id.to_string(),
                    pkce_verifier: "test-pkce-verifier".to_string(),
                    nonce: "test-nonce".to_string(),
                    redirect_after: None,
                },
            )
            .await;
        let sys = OidcSys {
            configs: HashMap::from([(provider_id.to_string(), config)]),
            provider_states: RwLock::new(HashMap::from([(
                provider_id.to_string(),
                ProviderState {
                    metadata,
                    discovered_at: Instant::now(),
                },
            )])),
            state_store,
            http_client: ReqwestHttpClient::with_policy(OutboundPolicy::default()),
        };

        let error = match sys
            .exchange_code("test-state", "test-code", "https://console.example.com/oauth_callback")
            .await
        {
            Ok(_) => panic!("private token endpoint should require an explicit allowlist origin"),
            Err(error) => error,
        };

        assert!(error.contains("request_error_kind=forbidden_outbound"));
        assert!(error.contains(&format!("add http://192.168.65.254:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}")));
    }

    #[tokio::test]
    async fn oidc_reqwest_dns_policy_rejection_stays_typed() {
        let calls = Arc::new(AtomicUsize::new(0));
        let config = build_mocked_oidc_provider_config(
            "default",
            "http://keycloak.internal:8080/realms/rustfs/.well-known/openid-configuration",
        );
        let http_client = ReqwestHttpClient::with_policy_and_dns_resolver(
            OutboundPolicy::default(),
            Arc::new(RejectingDnsResolver {
                allow_origin_can_recover: true,
                calls: Some(calls.clone()),
            }),
        );

        let error = match OidcSys::discover_provider(&config, &http_client).await {
            Ok(_) => panic!("private DNS answer should fail discovery"),
            Err(error) => error,
        };

        assert!(error.contains("OIDC provider discovery blocked by outbound policy"));
        assert!(error.contains(&format!("add http://keycloak.internal:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}")));
        assert_eq!(calls.load(Ordering::Relaxed), 1, "policy rejection must not be retried");
    }

    #[tokio::test]
    async fn oidc_explicit_issuer_preserves_dns_policy_rejection() {
        let calls = Arc::new(AtomicUsize::new(0));
        let mut config = build_mocked_oidc_provider_config(
            "default",
            "http://keycloak.internal:8080/realms/rustfs/.well-known/openid-configuration",
        );
        config.issuer = Some("https://idp.example.com/realms/rustfs".to_string());
        let http_client = ReqwestHttpClient::with_policy_and_dns_resolver(
            OutboundPolicy::default(),
            Arc::new(RejectingDnsResolver {
                allow_origin_can_recover: true,
                calls: Some(calls.clone()),
            }),
        );

        let error = match OidcSys::discover_provider(&config, &http_client).await {
            Ok(_) => panic!("private DNS answer should fail discovery"),
            Err(error) => error,
        };

        assert!(error.contains("OIDC provider discovery blocked by outbound policy"));
        assert!(error.contains(&format!("add http://keycloak.internal:8080 to {ENV_OUTBOUND_ALLOW_ORIGINS}")));
        assert_eq!(calls.load(Ordering::Relaxed), 1, "policy rejection must not be retried");
    }

    #[tokio::test]
    async fn oidc_nonrecoverable_dns_policy_rejection_omits_allowlist_hint() {
        let uri = "http://metadata.internal/latest/meta-data";
        let http_client = ReqwestHttpClient::with_policy_and_dns_resolver(
            OutboundPolicy::default(),
            Arc::new(RejectingDnsResolver {
                allow_origin_can_recover: false,
                calls: None,
            }),
        );
        let request = http::Request::builder()
            .uri(uri)
            .body(Vec::new())
            .expect("request should build");

        let error = http_client
            .call(request)
            .await
            .expect_err("metadata DNS answer should be rejected");
        let message = error.to_string();

        assert!(matches!(error, OidcHttpError::ForbiddenOutbound(_)));
        assert!(message.contains("metadata.internal"));
        assert!(!message.contains(&format!("add http://metadata.internal to {ENV_OUTBOUND_ALLOW_ORIGINS}")));
    }

    #[test]
    fn oidc_metadata_endpoint_rejection_does_not_offer_allowlist_bypass() {
        let error =
            build_oidc_http_client("http://169.254.169.254/latest/meta-data/", Some(&OutboundPolicy::default()), &[], None)
                .expect_err("metadata endpoint must remain forbidden");
        let message = error.to_string();

        assert!(message.contains("metadata endpoint"));
        assert!(!message.contains(&format!("add http://169.254.169.254 to {ENV_OUTBOUND_ALLOW_ORIGINS}")));
    }

    /// Serve exactly `body_len` bytes with no `Content-Length`, so the body ends only at EOF
    /// and the size guard cannot rely on an advertised length.
    fn start_unbounded_body_server(body_len: usize) -> Option<(String, std::thread::JoinHandle<()>)> {
        use std::io::{Read, Write};
        use std::net::TcpListener;
        use std::sync::mpsc;
        use std::time::Duration;

        let listener = match TcpListener::bind("127.0.0.1:0") {
            Ok(listener) => listener,
            Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => return None,
            Err(err) => panic!("test listener should bind: {err}"),
        };
        let base = format!("http://{}", listener.local_addr().expect("listener local address should be available"));
        let (ready_tx, ready_rx) = mpsc::channel();

        let handle = std::thread::spawn(move || {
            let _ = ready_tx.send(());
            let Ok((mut stream, _)) = listener.accept() else {
                return;
            };
            let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
            let mut buffer = [0u8; 4096];
            let _ = stream.read(&mut buffer);
            let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n");

            let chunk = vec![b'a'; 64 * 1024];
            let mut written = 0usize;
            while written < body_len {
                let take = chunk.len().min(body_len - written);
                if stream.write_all(&chunk[..take]).is_err() {
                    break;
                }
                written += take;
            }
            let _ = stream.flush();
        });
        ready_rx
            .recv_timeout(Duration::from_millis(100))
            .expect("mock body server should become ready");

        Some((base, handle))
    }

    async fn fetch_oidc_mock_body(base: &str) -> Result<Vec<u8>, OidcHttpError> {
        let policy = OutboundPolicy::from_allowed_origins(base).expect("origin should parse");
        let client = ReqwestHttpClient::with_policy(policy);
        let request = http::Request::builder()
            .method(http::Method::GET)
            .uri(base)
            .body(Vec::new())
            .expect("request should build");
        client.call(request).await.map(http::Response::into_body)
    }

    #[tokio::test]
    async fn oidc_response_body_at_the_limit_is_accepted() {
        let Some((base, handle)) = start_unbounded_body_server(MAX_OIDC_RESPONSE_SIZE) else {
            return;
        };

        let body = fetch_oidc_mock_body(&base)
            .await
            .expect("a body at the limit must be accepted");

        assert_eq!(body.len(), MAX_OIDC_RESPONSE_SIZE);
        handle.join().expect("mock body server thread should exit");
    }

    #[tokio::test]
    async fn oidc_response_body_past_the_limit_is_rejected() {
        let Some((base, handle)) = start_unbounded_body_server(MAX_OIDC_RESPONSE_SIZE + 1) else {
            return;
        };

        let err = fetch_oidc_mock_body(&base)
            .await
            .map(|body| body.len())
            .expect_err("an oversized provider response must fail closed instead of being buffered");

        assert!(
            matches!(err, OidcHttpError::ResponseTooLarge(MAX_OIDC_RESPONSE_SIZE)),
            "unexpected error: {err}"
        );
        handle.join().expect("mock body server thread should exit");
    }

    #[test]
    fn test_oidc_provider_config_debug_redacts_client_secret() {
        let config = OidcProviderConfig {
            client_secret: Some("oidc-client-secret".to_string()),
            ..test_config("default")
        };
        let sourced = SourcedOidcProviderConfig {
            config,
            source: OidcProviderConfigSource::Persisted,
        };

        let rendered = format!("{sourced:?}");

        assert!(!rendered.contains("oidc-client-secret"));
        assert!(rendered.contains(REDACTED_SECRET));
        assert!(rendered.contains("client-id"));
    }

    #[test]
    fn test_parse_enable_state_on() {
        assert!(OidcSys::parse_enable_state("on", false, false));
    }

    #[test]
    fn test_parse_enable_state_off() {
        assert!(!OidcSys::parse_enable_state("off", true, true));
    }

    #[test]
    fn test_parse_enable_state_empty_returns_default() {
        assert!(OidcSys::parse_enable_state("", true, false));
        assert!(!OidcSys::parse_enable_state("", false, true));
    }

    #[test]
    fn test_parse_enable_state_invalid_returns_error_default() {
        assert!(!OidcSys::parse_enable_state("garbage", true, false));
        assert!(OidcSys::parse_enable_state("garbage", false, true));
    }

    #[test]
    fn test_list_visible_providers_hides_hidden_provider() {
        let visible = test_config("dex");
        let mut hidden = test_config("kubernetes");
        hidden.hide_from_ui = true;

        let sys = make_test_sys(vec![visible, hidden]);
        let listed = sys.list_visible_providers();

        assert_eq!(listed.len(), 1);
        assert!(listed.iter().any(|p| p.provider_id == "dex"));
        assert!(!listed.iter().any(|p| p.provider_id == "kubernetes"));
    }

    #[test]
    fn test_hidden_provider_still_resolvable_for_sts() {
        let visible = test_config("dex");
        let mut hidden = test_config("kubernetes");
        hidden.hide_from_ui = true;

        let sys = make_test_sys(vec![visible, hidden]);

        assert!(sys.get_provider_config("kubernetes").is_some());
        assert!(sys.get_provider_config("dex").is_some());
    }

    #[test]
    fn test_list_providers_includes_hidden_for_replication() {
        let visible = test_config("dex");
        let mut hidden = test_config("kubernetes");
        hidden.hide_from_ui = true;

        let sys = make_test_sys(vec![visible, hidden]);

        // Unfiltered list returns all (used by site-replication)
        assert_eq!(sys.list_providers().len(), 2);
        // UI-filtered list hides the hidden one
        assert_eq!(sys.list_visible_providers().len(), 1);
    }

    #[test]
    fn test_list_providers_all_visible_by_default() {
        let a = test_config("okta");
        let b = test_config("dex");

        let sys = make_test_sys(vec![a, b]);
        let listed = sys.list_visible_providers();

        assert_eq!(listed.len(), 2);
    }

    #[test]
    fn test_list_visible_providers_all_hidden() {
        let mut a = test_config("k8s-a");
        a.hide_from_ui = true;
        let mut b = test_config("k8s-b");
        b.hide_from_ui = true;

        let sys = make_test_sys(vec![a, b]);
        let listed = sys.list_visible_providers();

        assert!(listed.is_empty());
        assert!(sys.has_providers());
    }

    #[test]
    fn test_hide_from_ui_default_is_false() {
        let config = test_config("default");
        assert!(!config.hide_from_ui);
    }

    #[test]
    fn test_parse_persisted_hide_from_ui_off_is_false() {
        let mut cfg = ServerConfig::new();
        let mut kvs = KVS(vec![
            rustfs_config::server_config::KV {
                key: ENABLE_KEY.to_string(),
                value: EnableState::On.to_string(),
                hidden_if_empty: false,
            },
            rustfs_config::server_config::KV {
                key: OIDC_CONFIG_URL.to_string(),
                value: "https://example.com/.well-known/openid-configuration".to_string(),
                hidden_if_empty: false,
            },
            rustfs_config::server_config::KV {
                key: OIDC_CLIENT_ID.to_string(),
                value: "console".to_string(),
                hidden_if_empty: false,
            },
        ]);
        kvs.insert(OIDC_HIDE_FROM_UI.to_string(), EnableState::Off.to_string());

        cfg.0
            .entry(IDENTITY_OPENID_SUB_SYS.to_string())
            .or_default()
            .insert(DEFAULT_DELIMITER.to_string(), kvs);

        let parsed = OidcSys::parse_persisted_configs(&cfg);
        assert_eq!(parsed.len(), 1);
        assert!(!parsed[0].hide_from_ui);
    }

    #[test]
    fn test_parse_persisted_hide_from_ui_missing_defaults_false() {
        let mut cfg = ServerConfig::new();
        let kvs = KVS(vec![
            rustfs_config::server_config::KV {
                key: ENABLE_KEY.to_string(),
                value: EnableState::On.to_string(),
                hidden_if_empty: false,
            },
            rustfs_config::server_config::KV {
                key: OIDC_CONFIG_URL.to_string(),
                value: "https://example.com/.well-known/openid-configuration".to_string(),
                hidden_if_empty: false,
            },
            rustfs_config::server_config::KV {
                key: OIDC_CLIENT_ID.to_string(),
                value: "console".to_string(),
                hidden_if_empty: false,
            },
        ]);

        cfg.0
            .entry(IDENTITY_OPENID_SUB_SYS.to_string())
            .or_default()
            .insert(DEFAULT_DELIMITER.to_string(), kvs);

        let parsed = OidcSys::parse_persisted_configs(&cfg);
        assert_eq!(parsed.len(), 1);
        assert!(!parsed[0].hide_from_ui);
    }

    #[test]
    fn test_parse_persisted_hide_from_ui() {
        let mut cfg = ServerConfig::new();
        let mut kvs = KVS(vec![
            rustfs_config::server_config::KV {
                key: ENABLE_KEY.to_string(),
                value: EnableState::On.to_string(),
                hidden_if_empty: false,
            },
            rustfs_config::server_config::KV {
                key: OIDC_CONFIG_URL.to_string(),
                value: "https://example.com/.well-known/openid-configuration".to_string(),
                hidden_if_empty: false,
            },
            rustfs_config::server_config::KV {
                key: OIDC_CLIENT_ID.to_string(),
                value: "console".to_string(),
                hidden_if_empty: false,
            },
        ]);
        kvs.insert(OIDC_HIDE_FROM_UI.to_string(), EnableState::On.to_string());

        cfg.0
            .entry(IDENTITY_OPENID_SUB_SYS.to_string())
            .or_default()
            .insert(DEFAULT_DELIMITER.to_string(), kvs);

        let parsed = OidcSys::parse_persisted_configs(&cfg);
        assert_eq!(parsed.len(), 1);
        assert!(parsed[0].hide_from_ui);
    }

    #[test]
    fn role_policy_does_not_map_groups_as_policies() {
        let mut config = test_config("authentik");
        config.role_policy = "consoleAdmin".to_string();
        config.claim_name = "policy".to_string();

        let sys = make_test_sys(vec![config]);

        let claims = OidcClaims {
            groups: vec!["authentik Admins".to_string(), "users".to_string()],
            raw: HashMap::from([("policy".to_string(), serde_json::json!(["readonly"]))]),
            ..Default::default()
        };

        let (policies, groups) = sys.map_claims_to_policies("authentik", &claims);
        assert_eq!(groups, vec!["authentik Admins", "users"]);
        assert_eq!(policies, vec!["consoleAdmin"]);
    }

    #[test]
    fn test_map_claims_to_policies_with_prefix() {
        let mut config = test_config("azure");
        config.claim_prefix = "oidc-".to_string();
        config.display_name = "Azure AD".to_string();

        let sys = make_test_sys(vec![config]);

        let claims = OidcClaims {
            sub: "user456".to_string(),
            email: "user@corp.com".to_string(),
            username: "user".to_string(),
            groups: vec!["engineers".to_string()],
            raw: HashMap::new(),
        };

        let (policies, groups) = sys.map_claims_to_policies("azure", &claims);
        assert_eq!(groups, vec!["engineers"]);
        assert!(policies.contains(&"oidc-engineers".to_string()));
        assert_eq!(policies.len(), 1);
    }

    #[test]
    fn blank_role_policy_uses_claim_mapping() {
        let mut config = test_config("keycloak");
        config.role_policy = "   ".to_string();

        let sys = make_test_sys(vec![config]);
        let claims = OidcClaims {
            groups: vec!["readonly".to_string()],
            ..Default::default()
        };

        let (policies, groups) = sys.map_claims_to_policies("keycloak", &claims);
        assert_eq!(groups, vec!["readonly"]);
        assert_eq!(policies, vec!["readonly"]);
    }

    #[test]
    fn claim_mapping_keeps_groups_with_distinct_primary_claim() {
        let mut config = test_config("keycloak");
        config.claim_name = "policy".to_string();

        let sys = make_test_sys(vec![config]);
        let claims = OidcClaims {
            groups: vec!["developers".to_string()],
            raw: HashMap::from([("policy".to_string(), serde_json::json!(["readonly"]))]),
            ..Default::default()
        };

        let (policies, groups) = sys.map_claims_to_policies("keycloak", &claims);
        assert_eq!(groups, vec!["developers"]);
        assert_eq!(policies, vec!["developers", "readonly"]);
    }

    #[test]
    fn test_list_providers() {
        let mut config = test_config("keycloak");
        config.display_name = "Keycloak SSO".to_string();

        let sys = make_test_sys(vec![config]);

        assert!(sys.has_providers());
        let summaries = sys.list_providers();
        assert_eq!(summaries.len(), 1);
        assert_eq!(summaries[0].provider_id, "keycloak");
        assert_eq!(summaries[0].display_name, "Keycloak SSO");
    }

    #[test]
    fn test_get_provider_config() {
        let mut config = test_config("test");
        config.client_id = "my-client".to_string();
        config.client_secret = Some("secret".to_string());

        let sys = make_test_sys(vec![config]);

        assert!(sys.get_provider_config("test").is_some());
        assert_eq!(sys.get_provider_config("test").unwrap().client_id, "my-client");
        assert!(sys.get_provider_config("nonexistent").is_none());
    }

    #[test]
    fn test_oidc_provider_config_defaults() {
        let config = OidcProviderConfig {
            id: "test".to_string(),
            enabled: true,
            config_url: "https://example.com/.well-known/openid-configuration".to_string(),
            issuer: None,
            client_id: "my-client".to_string(),
            client_secret: Some("secret".to_string()),
            scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()],
            other_audiences: vec![],
            redirect_uri: None,
            redirect_uri_dynamic: true,
            claim_name: "groups".to_string(),
            claim_prefix: "".to_string(),
            role_policy: "readwrite".to_string(),
            display_name: "Test Provider".to_string(),
            groups_claim: "groups".to_string(),
            roles_claim: String::new(),
            email_claim: "email".to_string(),
            username_claim: "preferred_username".to_string(),
            hide_from_ui: false,
        };

        assert_eq!(config.id, "test");
        assert!(config.enabled);
        assert_eq!(config.scopes.len(), 3);
        assert!(config.redirect_uri_dynamic);
    }
}
