mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-08 22:33:22 +00:00
b2a376c2d2
* fix(admin): bound IAM import archive expansion MAX_IAM_IMPORT_SIZE caps the compressed upload at 10 MB, but every member of the archive was then read with read_to_end into an unbounded Vec. Deflate ratios well above 100:1 are easy to construct, so a small authorized upload could expand without limit across the seven members ImportIam reads. Add a shared expansion budget (MAX_IAM_IMPORT_EXPANDED_SIZE, 10x the compressed cap) drawn down by every member, and route all seven reads through one helper that reads a byte past the remaining budget to detect overrun. Sharing the budget bounds the archive as a whole rather than letting each member spend the full limit independently. Covers R03-CAN-024 through R03-CAN-030 plus R04-CAN-077 (backlog #1471) — one fix rather than seven, since all seven call sites were byte-identical. * fix(kms): confine local key paths and refuse silent key replacement Local KMS key identifiers arrive from request input — the `name` tag on CreateKey, the `keyId` body field or query parameter on DeleteKey — and were joined onto `key_dir` with no validation. An identifier such as `../../tmp/evil` escaped the configured directory, making key creation a constrained arbitrary-file write and `DeleteKey` with `force_immediate` a cross-directory delete. Validate in `master_key_path` and make it fallible, so every filesystem path in this backend inherits the guard: decode_stored_key, load_master_key, save_master_key, create_key and delete_key all derive their paths there. The rule is containment rather than a character allowlist, so identifiers already in use keep resolving; only separators, NUL, absolute paths and non-single-component forms are refused. Note `.` and `..` are contained rather than refused — the `.key` suffix turns them into the ordinary filenames `..key` and `...key`. Separately, `LocalKmsBackend::create_key` had no existence check, while the sibling `KmsClient::create_key` has always had one. Since `save_master_key` renames over its destination, creating a key under an existing name silently replaced its material and destroyed the ability to decrypt everything wrapped under it — and the backend path is the one the admin API uses. It now returns KeyAlreadyExists, matching StaticKmsBackend. Covers R03-CAN-072, R03-CAN-073 and R07-CAN-103 (backlog #1475). R03-CAN-073 needed no separate change: delete_key routes both its load and its remove_file through master_key_path. * fix(swift): bound SLO manifest reads to the 2 MiB manifest limit The three Swift SLO handlers that load a stored manifest (handle_slo_get, handle_slo_get_manifest, handle_slo_delete) read the `<object>.slo-manifest` object to EOF with AsyncReadExt::read_to_end. That key is predictable and writable through the ordinary object PUT path, so a tenant can replace the manifest with an arbitrarily large object and then make the server allocate its full size on every SLO GET, multipart-manifest=get, or multipart-manifest=delete request - a memory amplification bounded only by the stored object size (CWE-400 / CWE-770). The 2 MiB manifest limit that handle_slo_put enforces was not applied on the read side. Introduce MAX_SLO_MANIFEST_SIZE (the existing 2 MiB PUT limit, now a named constant) and a shared read_manifest_bytes helper that reads through a `take(limit + 1)` and rejects anything larger, so an oversized manifest is refused instead of being buffered first. All three call sites go through the helper. handle_slo_put now checks the size before parsing the JSON. Regression tests: test_read_manifest_bytes_rejects_oversized_manifest and test_read_manifest_bytes_stops_reading_oversized_manifest (which asserts the reader is not consumed past the limit), plus a boundary test that a manifest at exactly 2 MiB is still accepted. * fix(protocols): authorize every object in FTPS/WebDAV recursive deletes The FTPS and WebDAV gateways authorized only the container before a recursive delete and then destroyed everything inside it without a further check: - FTPS RMD (and DELE on a bucket path ending in '/') cleared s3:DeleteBucket, then delete_bucket_recursively listed the bucket and deleted every object. - WebDAV DELETE on a bucket did the same via its own delete_bucket_recursively. - WebDAV DELETE on a directory cleared s3:DeleteObject for the directory marker key ("dir/") only, then listed that prefix and deleted every child under it. A principal holding s3:DeleteBucket (or s3:DeleteObject on a single marker key) could therefore erase objects it had no s3:DeleteObject permission for, and the operation reported success. Deletion stays recursive - that is the expected behaviour for these protocols - but each object now clears s3:DeleteObject on its own key before it is removed, and the enumeration clears s3:ListBucket. A denial aborts the whole operation with access denied rather than being skipped, so the caller can never be told the delete succeeded while objects were left behind or removed without authorization. The test double gained shared-state cloning, delete_object/delete_bucket call logs, and list/delete queue helpers so the regression tests can observe that nothing is deleted once a deny lands. * fix(server,ecstore): bound TLS handshakes and remote volume RPC waits Three call sites let an unauthenticated client or a misbehaving peer hold server resources with no deadline. TLS listener (R03-CAN-035): process_connection awaited `acceptor.accept(socket)` with no bound. A client that opens a TCP connection and never finishes the handshake parks a Tokio task and a socket forever, and the connection cap (RUSTFS_API_MAX_CONNECTIONS) is unlimited by default, so nothing else sheds it. The handshake now runs under accept_tls_with_deadline(), reusing the existing HTTP/1 header-read budget — the established slow-client bound for the pre-request phase — and the expiry is recorded through the same log/metric path as a handshake error, under a new TIMEOUT failure kind. Remote disk RPCs (R03-CAN-049, R03-CAN-050): list_volumes and delete_volume passed Duration::ZERO, which execute_with_timeout treats as "no deadline", so a peer that accepts the request and never answers stalls the coordinator (and, for delete_volume, the bucket-deletion workflow). Both now pass get_max_timeout_duration(), matching every sibling method in the file. Regression tests: a silent TLS peer must be shed by the handshake deadline; list_volumes/delete_volume against a peer that completes the TCP connect and then goes silent must fail with DiskError::Timeout instead of hanging. * fix(security): stop leaking signed headers and bound OIDC/KMS credentials Three independent hygiene fixes found by the security review. R03-CAN-018 (crates/signer): try_get_canonical_headers and get_signed_headers logged the complete header map at DEBUG before signing. Runtime callers pass session credentials and SSE-C key material through these headers, so anyone able to raise the log level (or read DEBUG logs) recovered X-Amz-Security-Token and SSE-C keys verbatim. The statements were debugging leftovers with no operational value and are deleted rather than redacted. R03-CAN-014 (crates/iam): the OIDC HTTP adapter buffered provider responses with an unbounded Response::bytes(), so a configured, compromised or attacker-pointed IdP endpoint could stream an arbitrarily large or endless body into memory (the ValidateOidcConfig admin handler lets a ServerInfo caller choose the endpoint). Responses are now read incrementally and fail closed past MAX_OIDC_RESPONSE_SIZE, and the already SSRF-hardened client builder gains request and connect timeouts so a stalled provider cannot pin the calling task indefinitely. R07-CAN-105 (helm): the Vault KMS token was serialized into the chart ConfigMap, exposing it to every subject allowed to get ConfigMaps in the namespace. It now renders into a dedicated Secret that the Deployment and StatefulSet consume via envFrom; the Secret is separate from the main credentials Secret so it also works when secret.existingSecret is set. Regression tests: - rustfs-signer: signing_never_logs_signed_header_material - rustfs-iam: oidc_response_body_past_the_limit_is_rejected, oidc_response_body_at_the_limit_is_accepted - scripts/test_helm_templates.sh: KMS token must never render in plaintext * fix(webdav): enforce body limit, request timeout and connection cap The configured WebDAV maximum body size was enforced from Content-Length, so a chunked request declared no length and bypassed it entirely. The configured request timeout was never applied to the connection at all, and the accept loop spawned a task per connection with no bound, so an unauthenticated client could hold resources indefinitely and in unbounded number. Enforce the limit on bytes actually read rather than the declared length, apply the configured timeout to the request, and bound accepted connections with a new RUSTFS_WEBDAV_MAX_CONNECTIONS (default 1024) surfaced in the config report. Covers R03-CAN-051, R03-CAN-052, R03-CAN-067, R04-CAN-089, R05-CAN-094 and R05-CAN-097 (backlog #1471, #1474). * fix(security): stop STS credentials from crossing the parent trust boundary Two related credential-boundary holes let a short-lived STS credential act with the full, unrestricted authority of the long-term user it was minted from. AddUser (R03-CAN-021, CWE-269/863): should_check_deny_only relaxes the admin policy check to deny-only when a Console/STS session targets the IAM user it represents. Nothing then stopped that session from calling AddUser with its own parent's access key, so the handler wrote an attacker-chosen secret key and status over the parent's stored Credentials via create_user -> save_user_identity. A session that expires in minutes became permanent control of the account. AddUser now rejects any temp or service-account requester whose resolved parent equals the target access key, resolving the parent the same way should_check_deny_only does (parent_user field, else the JWT `parent` claim, since some stores persist the parent only in the token). FTPS/SFTP/WebDAV password auth (R04-CAN-086, CWE-287/862): these protocols looked the access key up with check_key, which falls back to the STS account cache, and then compared only the stored secret. An STS access key plus secret therefore authenticated with no session token presented and no session-policy claims applied - the holder got the parent's full permissions. Password authentication now rejects temporary credentials before the secret comparison. The discriminator is is_temp() && !is_service_account(), the same one IamCache::update_user_with_claims uses to route an identity into the STS cache, so service accounts - which resolve policy from stored IAM state rather than a client-presented token - keep working over these protocols. Regression tests cover both predicates and pin the guards to their call sites so neither can be dropped without a test failure.
3305 lines
130 KiB
Rust
3305 lines
130 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();
|
|
|
|
// Add default role policy if configured
|
|
if !config.role_policy.is_empty() {
|
|
for policy in config.role_policy.split(',') {
|
|
let policy = policy.trim();
|
|
if !policy.is_empty() {
|
|
policies.push(policy.to_string());
|
|
}
|
|
}
|
|
}
|
|
|
|
// Map groups claim to policies
|
|
for group in &claims.groups {
|
|
groups.push(group.clone());
|
|
let policy_name = if config.claim_prefix.is_empty() {
|
|
group.clone()
|
|
} else {
|
|
format!("{}{}", config.claim_prefix, group)
|
|
};
|
|
policies.push(policy_name);
|
|
}
|
|
|
|
// Map primary claim (if different from groups)
|
|
if config.claim_name != config.groups_claim {
|
|
let claim_values = extract_groups_claim(&claims.raw, &config.claim_name);
|
|
for val in claim_values {
|
|
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)]
|
|
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");
|
|
}
|
|
|
|
/// Helper to create an OidcSys with configs only (no provider states needed).
|
|
fn make_test_sys(configs: Vec<OidcProviderConfig>) -> OidcSys {
|
|
let mut config_map = HashMap::new();
|
|
for c in configs {
|
|
config_map.insert(c.id.clone(), c);
|
|
}
|
|
OidcSys {
|
|
configs: config_map,
|
|
provider_states: RwLock::new(HashMap::new()),
|
|
state_store: OidcStateStore::new(),
|
|
http_client: ReqwestHttpClient::new().expect("failed to initialize OIDC HTTP clients"),
|
|
}
|
|
}
|
|
|
|
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: "".to_string(),
|
|
role_policy: "".to_string(),
|
|
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,
|
|
}
|
|
}
|
|
|
|
#[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 test_map_claims_to_policies_with_provider() {
|
|
let mut config = test_config("okta");
|
|
config.role_policy = "readwrite".to_string();
|
|
config.display_name = "Okta".to_string();
|
|
|
|
let sys = make_test_sys(vec![config]);
|
|
|
|
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("okta", &claims);
|
|
assert_eq!(groups, vec!["admin", "devs"]);
|
|
assert!(policies.contains(&"readwrite".to_string()));
|
|
assert!(policies.contains(&"admin".to_string()));
|
|
assert!(policies.contains(&"devs".to_string()));
|
|
}
|
|
|
|
#[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 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);
|
|
}
|
|
}
|