mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-07 13:53:12 +00:00
3334 lines
131 KiB
Rust
3334 lines
131 KiB
Rust
// 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, IssuerUrl, JsonWebKeySetUrl,
|
|
LogoutRequest, Nonce, PkceCodeChallenge, PkceCodeVerifier, PostLogoutRedirectUrl, ProviderMetadataWithLogout, RedirectUrl,
|
|
RequestTokenError, Scope,
|
|
};
|
|
use reqwest::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::OutboundPolicy;
|
|
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::{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_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::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),
|
|
/// 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::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::ForbiddenOutbound(_) | Self::ResponseTooLarge(_) => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
/// 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>) -> Result<Client, 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| OidcHttpError::ForbiddenOutbound(err.to_string()))?;
|
|
|
|
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 should_bypass_proxy_for_oidc_uri(uri) {
|
|
builder = builder.no_proxy();
|
|
}
|
|
builder.build().map_err(OidcHttpError::Reqwest)
|
|
}
|
|
|
|
/// 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> {
|
|
Ok(Self { policy_override: None })
|
|
}
|
|
|
|
/// 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),
|
|
}
|
|
}
|
|
}
|
|
|
|
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 client = build_oidc_http_client(&uri, self.policy_override.as_ref())?;
|
|
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| {
|
|
error!(
|
|
event = EVENT_OIDC_HTTP,
|
|
component = LOG_COMPONENT_IAM,
|
|
subsystem = LOG_SUBSYSTEM_OIDC,
|
|
result = "request_failed",
|
|
method = %method,
|
|
uri = %uri,
|
|
elapsed_ms,
|
|
error = %err,
|
|
"oidc outbound http"
|
|
);
|
|
OidcHttpError::Reqwest(err)
|
|
})?;
|
|
|
|
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> {
|
|
let http_client = ReqwestHttpClient::new()?;
|
|
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 = %e,
|
|
"oidc token exchange failed"
|
|
);
|
|
format!(
|
|
"token exchange failed: {e}: stage=token_request_failed, provider_id={}, config_url={}, issuer={}, token_endpoint={}, redirect_uri={}, client_id={}, request_error_kind={}, request_error_status={}",
|
|
session.provider_id,
|
|
config.config_url,
|
|
issuer,
|
|
token_endpoint,
|
|
redirect_uri,
|
|
config.client_id,
|
|
request_error_kind,
|
|
request_error_status
|
|
)
|
|
}
|
|
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
|
|
.map_err(|e| format!("discovery failed: {e}"))
|
|
{
|
|
Ok(metadata) => {
|
|
return Ok(ProviderState {
|
|
metadata,
|
|
discovered_at: Instant::now(),
|
|
});
|
|
}
|
|
Err(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 = http_client
|
|
.call(request)
|
|
.await
|
|
.map_err(|err| format!("discovery request failed: {err}"))?;
|
|
if response.status() != http::StatusCode::OK {
|
|
return Err(format!("discovery failed: HTTP status code {} at {}", response.status(), discovery_url));
|
|
}
|
|
|
|
let provider_metadata = serde_json::from_slice::<ProviderMetadataWithLogout>(response.body())
|
|
.map_err(|err| format!("failed to parse discovery response: {err}"))?;
|
|
if provider_metadata.issuer() != &issuer_url {
|
|
return Err(format!(
|
|
"unexpected issuer URI `{}` (expected `{}`)",
|
|
provider_metadata.issuer().as_str(),
|
|
issuer_url.as_str()
|
|
));
|
|
}
|
|
|
|
let jwks_url = jwks_url_from_config_url(&config.config_url, &issuer_url, provider_metadata.jwks_uri())?;
|
|
let jwks = CoreJsonWebKeySet::fetch_async(&jwks_url, http_client)
|
|
.await
|
|
.map_err(|err| format!("failed to fetch JWKS: {err}"))?;
|
|
|
|
Ok(ProviderState {
|
|
metadata: provider_metadata.set_jwks(jwks),
|
|
discovered_at: Instant::now(),
|
|
})
|
|
}
|
|
}
|
|
|
|
pub fn load_oidc_provider_configs_from_env() -> Vec<OidcProviderConfig> {
|
|
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> {
|
|
let http_client = ReqwestHttpClient::new()?;
|
|
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::*;
|
|
|
|
#[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 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,
|
|
{
|
|
use std::io::Read;
|
|
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": ["RS256"],
|
|
})
|
|
.to_string();
|
|
let jwks_body = r#"{"keys":[]}"#;
|
|
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 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 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);
|
|
let path = request.lines().next().unwrap_or("").split_whitespace().nth(1).unwrap_or("");
|
|
|
|
let (status, body) = if path.contains("/.well-known/openid-configuration") {
|
|
(200, discovery_body.as_str())
|
|
} else if path == expected_jwks_path {
|
|
(200, jwks_body)
|
|
} else {
|
|
(404, r#"{"error":"not found"}"#)
|
|
};
|
|
|
|
let response = 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()
|
|
);
|
|
|
|
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 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 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 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_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),
|
|
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),
|
|
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).is_ok(),
|
|
"public https endpoint should build"
|
|
);
|
|
}
|
|
|
|
#[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)).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)),
|
|
Err(OidcHttpError::ForbiddenOutbound(_))
|
|
),
|
|
"metadata endpoint stays forbidden despite an unrelated allow-list entry"
|
|
);
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|