mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-20 19:42:17 +00:00
feat(iam): add OpenID Connect SSO with claim-based policy resolution (#1875)
Signed-off-by: houseme <housemecn@gmail.com> Co-authored-by: houseme <housemecn@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: heihutu <30542132+heihutu@users.noreply.github.com> Co-authored-by: loverustfs <hello@rustfs.com> Co-authored-by: 安正超 <anzhengchao@gmail.com>
This commit is contained in:
@@ -63,14 +63,32 @@ async fn static_handler(uri: Uri) -> impl IntoResponse {
|
||||
if path.is_empty() {
|
||||
path = "index.html"
|
||||
}
|
||||
|
||||
// Try the exact path first
|
||||
if let Some(file) = StaticFiles::get(path) {
|
||||
let mime_type = from_path(path).first_or_octet_stream();
|
||||
Response::builder()
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type.to_string())
|
||||
.body(Body::from(file.data))
|
||||
.unwrap()
|
||||
} else if let Some(file) = StaticFiles::get("index.html") {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// For directory paths (trailing slash), try <path>index.html
|
||||
if path.ends_with('/') {
|
||||
let index_path = format!("{path}index.html");
|
||||
if let Some(file) = StaticFiles::get(&index_path) {
|
||||
let mime_type = from_path(&index_path).first_or_octet_stream();
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", mime_type.to_string())
|
||||
.body(Body::from(file.data))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// SPA fallback: serve root index.html for client-side routing
|
||||
if let Some(file) = StaticFiles::get("index.html") {
|
||||
let mime_type = from_path("index.html").first_or_octet_stream();
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
@@ -94,11 +112,33 @@ pub(crate) struct Config {
|
||||
release: Release,
|
||||
license: License,
|
||||
doc: String,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
oidc: Vec<OidcProviderInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
struct OidcProviderInfo {
|
||||
provider_id: String,
|
||||
display_name: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn new(local_ip: IpAddr, port: u16, version: &str, date: &str) -> Self {
|
||||
let http_prefix = rustfs_config::RUSTFS_HTTP_PREFIX;
|
||||
|
||||
// Collect OIDC provider info if available
|
||||
let oidc = rustfs_iam::get_oidc()
|
||||
.map(|sys| {
|
||||
sys.list_providers()
|
||||
.into_iter()
|
||||
.map(|p| OidcProviderInfo {
|
||||
provider_id: p.provider_id,
|
||||
display_name: p.display_name,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Config {
|
||||
port,
|
||||
api: Api {
|
||||
@@ -117,6 +157,7 @@ impl Config {
|
||||
url: rustfs_config::RUSTFS_LICENSE_URL.to_string(),
|
||||
},
|
||||
doc: rustfs_config::RUSTFS_DOCS_URL.to_string(),
|
||||
oidc,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +186,20 @@ impl Operation for AccountInfoHandler {
|
||||
|
||||
let policies = MappedPolicy::new(&policy_name).to_slice();
|
||||
effective_policy = iam_store.get_combined_policy(&policies).await;
|
||||
} else if let Some(claim_policies) = claims.get("policy").and_then(|v| v.as_str()) {
|
||||
// STS/OIDC users: resolve policy names from JWT claims against built-in policies
|
||||
let mut resolved = Vec::new();
|
||||
for policy_name in claim_policies.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
|
||||
for (name, p) in DEFAULT_POLICIES.iter() {
|
||||
if *name == policy_name {
|
||||
resolved.push(p.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !resolved.is_empty() {
|
||||
effective_policy = rustfs_policy::policy::Policy::merge_policies(resolved);
|
||||
}
|
||||
} else {
|
||||
let policies = iam_store
|
||||
.policy_db_get(&account_name, &cred.groups)
|
||||
|
||||
@@ -13,14 +13,17 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::router::Operation;
|
||||
use crate::auth::{check_key_valid, get_session_token};
|
||||
use crate::auth::{check_key_valid, constant_time_eq, get_condition_values, get_session_token};
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use hyper::StatusCode;
|
||||
use matchit::Params;
|
||||
use rustfs_credentials::get_global_action_cred;
|
||||
use rustfs_policy::policy::Args;
|
||||
use rustfs_policy::policy::action::{Action, AdminAction};
|
||||
use s3s::header::CONTENT_TYPE;
|
||||
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct IsAdminResponse {
|
||||
@@ -43,14 +46,34 @@ impl Operation for IsAdminHandler {
|
||||
|
||||
let access_key_to_check = input_cred.access_key.clone();
|
||||
|
||||
// Check if the user is admin by comparing with global credentials
|
||||
// Check if the user is admin: root user check, then evaluate through the policy engine
|
||||
let is_admin = if let Some(sys_cred) = get_global_action_cred() {
|
||||
crate::auth::constant_time_eq(&access_key_to_check, &sys_cred.access_key)
|
||||
|| crate::auth::constant_time_eq(&cred.parent_user, &sys_cred.access_key)
|
||||
constant_time_eq(&access_key_to_check, &sys_cred.access_key)
|
||||
|| constant_time_eq(&cred.parent_user, &sys_cred.access_key)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let is_admin = if is_admin {
|
||||
true
|
||||
} else {
|
||||
let iam_store = rustfs_iam::get().map_err(|_| s3_error!(InternalError, "iam not init"))?;
|
||||
let conditions = get_condition_values(&req.headers, &cred, None, None, None);
|
||||
iam_store
|
||||
.is_allowed(&Args {
|
||||
account: &cred.access_key,
|
||||
groups: &cred.groups,
|
||||
action: Action::AdminAction(AdminAction::AllAdminActions),
|
||||
conditions: &conditions,
|
||||
is_owner: false,
|
||||
claims: cred.claims.as_ref().unwrap_or(&HashMap::new()),
|
||||
deny_only: false,
|
||||
bucket: "",
|
||||
object: "",
|
||||
})
|
||||
.await
|
||||
};
|
||||
|
||||
let response = IsAdminResponse {
|
||||
is_admin,
|
||||
access_key: access_key_to_check,
|
||||
|
||||
@@ -24,6 +24,7 @@ pub mod kms_dynamic;
|
||||
pub mod kms_keys;
|
||||
pub mod kms_management;
|
||||
pub mod metrics;
|
||||
pub mod oidc;
|
||||
pub mod policies;
|
||||
pub mod pools;
|
||||
pub mod profile;
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
// 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.
|
||||
|
||||
use super::sts::create_oidc_sts_credentials;
|
||||
use crate::admin::router::{AdminOperation, Operation, S3Router};
|
||||
use crate::server::ADMIN_PREFIX;
|
||||
use http::StatusCode;
|
||||
use hyper::Method;
|
||||
use matchit::Params;
|
||||
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
const OIDC_PATH_PREFIX: &str = "/rustfs/admin/v3/oidc";
|
||||
|
||||
/// Validate that a provider ID contains only safe characters (alphanumeric, underscore, hyphen).
|
||||
fn is_valid_provider_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
/// Validate that a redirect_after path is a safe relative path (starts with `/`, no scheme).
|
||||
fn is_safe_redirect_path(path: &str) -> bool {
|
||||
path.starts_with('/') && !path.starts_with("//") && !path.contains("://")
|
||||
}
|
||||
|
||||
/// Validate that a scheme is either "http" or "https".
|
||||
fn is_valid_scheme(scheme: &str) -> bool {
|
||||
scheme == "http" || scheme == "https"
|
||||
}
|
||||
|
||||
/// Register OIDC routes on the admin router.
|
||||
pub fn register_oidc_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
|
||||
r.insert(
|
||||
Method::GET,
|
||||
&format!("{ADMIN_PREFIX}/v3/oidc/providers"),
|
||||
AdminOperation(&ListOidcProvidersHandler {}),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::GET,
|
||||
&format!("{ADMIN_PREFIX}/v3/oidc/authorize/{{provider_id}}"),
|
||||
AdminOperation(&OidcAuthorizeHandler {}),
|
||||
)?;
|
||||
r.insert(
|
||||
Method::GET,
|
||||
&format!("{ADMIN_PREFIX}/v3/oidc/callback/{{provider_id}}"),
|
||||
AdminOperation(&OidcCallbackHandler {}),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns true if the given path is an OIDC endpoint (requires unauthenticated access).
|
||||
pub fn is_oidc_path(path: &str) -> bool {
|
||||
path.starts_with(OIDC_PATH_PREFIX)
|
||||
}
|
||||
|
||||
/// Handler: GET /rustfs/admin/v3/oidc/providers
|
||||
/// Returns list of configured OIDC providers for the login page.
|
||||
pub struct ListOidcProvidersHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for ListOidcProvidersHandler {
|
||||
async fn call(&self, _req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let oidc_sys = rustfs_iam::get_oidc().ok_or_else(|| s3_error!(InternalError, "OIDC not initialized"))?;
|
||||
|
||||
let providers = oidc_sys.list_providers();
|
||||
let json_body = serde_json::to_vec(&providers)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("serialize error: {e}")))?;
|
||||
|
||||
Ok(S3Response::new((StatusCode::OK, Body::from(json_body))))
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler: GET /rustfs/admin/v3/oidc/authorize/:provider_id
|
||||
/// Generates PKCE challenge, stores state, and returns 302 redirect to IdP.
|
||||
pub struct OidcAuthorizeHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for OidcAuthorizeHandler {
|
||||
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let provider_id = params
|
||||
.get("provider_id")
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "missing provider_id"))?;
|
||||
|
||||
if !is_valid_provider_id(provider_id) {
|
||||
return Err(s3_error!(InvalidRequest, "invalid provider_id"));
|
||||
}
|
||||
|
||||
let oidc_sys = rustfs_iam::get_oidc().ok_or_else(|| s3_error!(InternalError, "OIDC not initialized"))?;
|
||||
|
||||
// Derive the callback redirect URI from the request
|
||||
let redirect_uri = derive_callback_uri(&req, provider_id)?;
|
||||
|
||||
// Optional: redirect_after query parameter (must be a safe relative path)
|
||||
let redirect_after = extract_query_param(&req.uri, "redirect_after").filter(|p| is_safe_redirect_path(p));
|
||||
|
||||
let auth_url = oidc_sys
|
||||
.authorize_url(provider_id, &redirect_uri, redirect_after)
|
||||
.await
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InvalidRequest, format!("authorize failed: {e}")))?;
|
||||
|
||||
info!("OIDC authorize redirect for provider '{}' to IdP", provider_id);
|
||||
|
||||
// Return 302 redirect
|
||||
let mut resp = S3Response::new((StatusCode::FOUND, Body::empty()));
|
||||
resp.headers.insert(
|
||||
http::header::LOCATION,
|
||||
auth_url
|
||||
.parse()
|
||||
.map_err(|_| s3_error!(InternalError, "failed to construct authorization URL"))?,
|
||||
);
|
||||
Ok(resp)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler: GET /rustfs/admin/v3/oidc/callback/:provider_id?code=...&state=...
|
||||
/// Exchanges authorization code for tokens, maps claims, issues STS credentials.
|
||||
pub struct OidcCallbackHandler {}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Operation for OidcCallbackHandler {
|
||||
async fn call(&self, req: S3Request<Body>, params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let provider_id = params
|
||||
.get("provider_id")
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "missing provider_id"))?;
|
||||
|
||||
if !is_valid_provider_id(provider_id) {
|
||||
return Err(s3_error!(InvalidRequest, "invalid provider_id"));
|
||||
}
|
||||
|
||||
// Extract code and state from query parameters
|
||||
let code =
|
||||
extract_query_param(&req.uri, "code").ok_or_else(|| s3_error!(InvalidRequest, "missing 'code' query parameter"))?;
|
||||
let state =
|
||||
extract_query_param(&req.uri, "state").ok_or_else(|| s3_error!(InvalidRequest, "missing 'state' query parameter"))?;
|
||||
|
||||
// Check for error response from IdP
|
||||
if let Some(error) = extract_query_param(&req.uri, "error") {
|
||||
let desc = extract_query_param(&req.uri, "error_description").unwrap_or_default();
|
||||
warn!("OIDC callback received error from IdP: {} - {}", error, desc);
|
||||
return Err(S3Error::with_message(
|
||||
S3ErrorCode::AccessDenied,
|
||||
format!("OIDC authentication failed: {error} - {desc}"),
|
||||
));
|
||||
}
|
||||
|
||||
let oidc_sys = rustfs_iam::get_oidc().ok_or_else(|| s3_error!(InternalError, "OIDC not initialized"))?;
|
||||
|
||||
let redirect_uri = derive_callback_uri(&req, provider_id)?;
|
||||
|
||||
// Exchange authorization code for tokens and extract claims
|
||||
let (claims, actual_provider_id, session) = oidc_sys.exchange_code(&state, &code, &redirect_uri).await.map_err(|e| {
|
||||
error!("OIDC code exchange failed: {}", e);
|
||||
S3Error::with_message(S3ErrorCode::AccessDenied, format!("code exchange failed: {e}"))
|
||||
})?;
|
||||
|
||||
info!(
|
||||
"OIDC login successful: username='{}', email='{}', sub='{}' (provider: {})",
|
||||
claims.username, claims.email, claims.sub, actual_provider_id
|
||||
);
|
||||
|
||||
// Map claims to policies and groups
|
||||
let (policies, groups) = oidc_sys.map_claims_to_policies(&actual_provider_id, &claims);
|
||||
|
||||
info!(
|
||||
"OIDC claim mapping: user='{}', policies={:?}, groups={:?}",
|
||||
claims.username, policies, groups
|
||||
);
|
||||
|
||||
// Generate STS credentials using the shared helper.
|
||||
// Console/OIDC sessions use a fixed 1-hour duration as a security/UX choice.
|
||||
// Longer-lived credentials (15 min to 12 hours) can be requested via CLI/SDK
|
||||
// through AssumeRoleWithWebIdentity.
|
||||
let new_cred = create_oidc_sts_credentials(&claims, &actual_provider_id, &policies, &groups, 3600, None).await?;
|
||||
|
||||
// Build redirect URL to console with credentials in the fragment
|
||||
let console_redirect = build_console_redirect(
|
||||
&req,
|
||||
&new_cred.access_key,
|
||||
&new_cred.secret_key,
|
||||
&new_cred.session_token,
|
||||
new_cred.expiration,
|
||||
session.redirect_after.as_deref(),
|
||||
);
|
||||
|
||||
let mut resp = S3Response::new((StatusCode::FOUND, Body::empty()));
|
||||
resp.headers.insert(
|
||||
http::header::LOCATION,
|
||||
console_redirect
|
||||
.parse()
|
||||
.map_err(|_| s3_error!(InternalError, "failed to construct console redirect URL"))?,
|
||||
);
|
||||
Ok(resp)
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive the OIDC callback URI.
|
||||
/// Uses the provider's configured redirect_uri if set, otherwise derives dynamically
|
||||
/// from request headers. For production deployments behind a reverse proxy, configuring
|
||||
/// an explicit redirect_uri is recommended to prevent header manipulation.
|
||||
fn derive_callback_uri(req: &S3Request<Body>, provider_id: &str) -> S3Result<String> {
|
||||
// Use explicitly configured redirect_uri if available
|
||||
if let Some(oidc_sys) = rustfs_iam::get_oidc()
|
||||
&& let Some(config) = oidc_sys.get_provider_config(provider_id)
|
||||
&& let Some(ref uri) = config.redirect_uri
|
||||
{
|
||||
return Ok(uri.clone());
|
||||
}
|
||||
let scheme = req
|
||||
.headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or_else(|| req.uri.scheme_str().unwrap_or("http"));
|
||||
|
||||
if !is_valid_scheme(scheme) {
|
||||
return Err(s3_error!(InvalidRequest, "invalid scheme in request"));
|
||||
}
|
||||
|
||||
let host = req
|
||||
.headers
|
||||
.get(http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.or_else(|| req.uri.host())
|
||||
.ok_or_else(|| s3_error!(InvalidRequest, "cannot determine host for redirect URI"))?;
|
||||
|
||||
// Validate host doesn't contain path separators or other injection characters
|
||||
if host.contains('/') || host.contains('\\') {
|
||||
return Err(s3_error!(InvalidRequest, "invalid host header"));
|
||||
}
|
||||
|
||||
Ok(format!("{scheme}://{host}/rustfs/admin/v3/oidc/callback/{provider_id}"))
|
||||
}
|
||||
|
||||
/// Extract a query parameter from the URI.
|
||||
fn extract_query_param(uri: &http::Uri, key: &str) -> Option<String> {
|
||||
uri.query().and_then(|q| {
|
||||
// Parse query string manually without external dependency
|
||||
q.split('&')
|
||||
.filter_map(|pair| {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
let k = parts.next()?;
|
||||
let v = parts.next().unwrap_or("");
|
||||
if k == key {
|
||||
Some(urlencoding::decode(v).unwrap_or_default().into_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.next()
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the console redirect URL with STS credentials in the hash fragment.
|
||||
fn build_console_redirect(
|
||||
req: &S3Request<Body>,
|
||||
access_key: &str,
|
||||
secret_key: &str,
|
||||
session_token: &str,
|
||||
expiration: Option<OffsetDateTime>,
|
||||
redirect_after: Option<&str>,
|
||||
) -> String {
|
||||
let scheme = req
|
||||
.headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|s| is_valid_scheme(s))
|
||||
.unwrap_or_else(|| req.uri.scheme_str().unwrap_or("http"));
|
||||
|
||||
let host = req
|
||||
.headers
|
||||
.get(http::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.filter(|h| !h.contains('/') && !h.contains('\\'))
|
||||
.unwrap_or("localhost");
|
||||
|
||||
let console_prefix = "/rustfs/console";
|
||||
let page = redirect_after.filter(|p| is_safe_redirect_path(p)).unwrap_or("/");
|
||||
|
||||
let exp_str = expiration
|
||||
.map(|e| e.format(&time::format_description::well_known::Rfc3339).unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
|
||||
let fragment = format!(
|
||||
"accessKey={}&secretKey={}&sessionToken={}&expiration={}&redirect={}",
|
||||
urlencoding::encode(access_key),
|
||||
urlencoding::encode(secret_key),
|
||||
urlencoding::encode(session_token),
|
||||
urlencoding::encode(&exp_str),
|
||||
urlencoding::encode(page),
|
||||
);
|
||||
|
||||
format!("{scheme}://{host}{console_prefix}/auth/oidc-callback/#{fragment}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_oidc_path() {
|
||||
assert!(is_oidc_path("/rustfs/admin/v3/oidc/providers"));
|
||||
assert!(is_oidc_path("/rustfs/admin/v3/oidc/authorize/okta"));
|
||||
assert!(is_oidc_path("/rustfs/admin/v3/oidc/callback/okta"));
|
||||
assert!(!is_oidc_path("/rustfs/admin/v3/users"));
|
||||
assert!(!is_oidc_path("/health"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_query_param() {
|
||||
let uri: http::Uri = "http://localhost/callback?code=abc123&state=xyz789".parse().unwrap();
|
||||
assert_eq!(extract_query_param(&uri, "code"), Some("abc123".to_string()));
|
||||
assert_eq!(extract_query_param(&uri, "state"), Some("xyz789".to_string()));
|
||||
assert_eq!(extract_query_param(&uri, "missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_query_param_empty() {
|
||||
let uri: http::Uri = "http://localhost/callback".parse().unwrap();
|
||||
assert_eq!(extract_query_param(&uri, "code"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_query_param_encoded() {
|
||||
let uri: http::Uri = "http://localhost/callback?redirect_after=%2Fdashboard".parse().unwrap();
|
||||
assert_eq!(extract_query_param(&uri, "redirect_after"), Some("/dashboard".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_provider_id() {
|
||||
assert!(is_valid_provider_id("AUTHENTIK"));
|
||||
assert!(is_valid_provider_id("my-provider"));
|
||||
assert!(is_valid_provider_id("okta_prod"));
|
||||
assert!(is_valid_provider_id("Azure123"));
|
||||
assert!(!is_valid_provider_id(""));
|
||||
assert!(!is_valid_provider_id("../evil"));
|
||||
assert!(!is_valid_provider_id("foo bar"));
|
||||
assert!(!is_valid_provider_id("foo/bar"));
|
||||
assert!(!is_valid_provider_id("provider;drop"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_safe_redirect_path() {
|
||||
assert!(is_safe_redirect_path("/"));
|
||||
assert!(is_safe_redirect_path("/dashboard"));
|
||||
assert!(is_safe_redirect_path("/buckets/my-bucket"));
|
||||
assert!(!is_safe_redirect_path("https://evil.com"));
|
||||
assert!(!is_safe_redirect_path("javascript:alert(1)"));
|
||||
assert!(!is_safe_redirect_path("//evil.com/path"));
|
||||
assert!(!is_safe_redirect_path("relative/path"));
|
||||
assert!(!is_safe_redirect_path(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_scheme() {
|
||||
assert!(is_valid_scheme("http"));
|
||||
assert!(is_valid_scheme("https"));
|
||||
assert!(!is_valid_scheme("ftp"));
|
||||
assert!(!is_valid_scheme("javascript"));
|
||||
assert!(!is_valid_scheme(""));
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ use hyper::Method;
|
||||
use matchit::Params;
|
||||
use rustfs_config::MAX_ADMIN_REQUEST_BODY_SIZE;
|
||||
use rustfs_ecstore::bucket::utils::serialize;
|
||||
use rustfs_iam::{manager::get_token_signing_key, sys::SESSION_POLICY_NAME};
|
||||
use rustfs_iam::{manager::get_token_signing_key, oidc::OidcClaims, sys::SESSION_POLICY_NAME};
|
||||
use rustfs_policy::{auth::get_new_credentials_with_metadata, policy::Policy};
|
||||
use s3s::{
|
||||
Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result,
|
||||
@@ -35,9 +35,10 @@ use serde_json::Value;
|
||||
use serde_urlencoded::from_bytes;
|
||||
use std::collections::HashMap;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
use tracing::{error, info, warn};
|
||||
use tracing::{debug, error, info, warn};
|
||||
|
||||
const ASSUME_ROLE_ACTION: &str = "AssumeRole";
|
||||
const ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION: &str = "AssumeRoleWithWebIdentity";
|
||||
const ASSUME_ROLE_VERSION: &str = "2011-06-15";
|
||||
|
||||
pub fn register_admin_auth_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<()> {
|
||||
@@ -62,6 +63,7 @@ pub struct AssumeRoleRequest {
|
||||
pub role_session_name: String,
|
||||
pub policy: String,
|
||||
pub external_id: String,
|
||||
pub web_identity_token: String,
|
||||
}
|
||||
|
||||
pub struct AssumeRoleHandle {}
|
||||
@@ -70,21 +72,6 @@ impl Operation for AssumeRoleHandle {
|
||||
async fn call(&self, req: S3Request<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
warn!("handle AssumeRoleHandle");
|
||||
|
||||
let Some(user) = req.credentials else { return Err(s3_error!(InvalidRequest, "get cred failed")) };
|
||||
|
||||
let session_token = get_session_token(&req.uri, &req.headers);
|
||||
if session_token.is_some() {
|
||||
return Err(s3_error!(InvalidRequest, "AccessDenied1"));
|
||||
}
|
||||
|
||||
let (cred, _owner) =
|
||||
check_key_valid(get_session_token(&req.uri, &req.headers).unwrap_or_default(), &user.access_key).await?;
|
||||
|
||||
// TODO: Check permissions, do not allow STS access
|
||||
if cred.is_temp() || cred.is_service_account() {
|
||||
return Err(s3_error!(InvalidRequest, "AccessDenied"));
|
||||
}
|
||||
|
||||
let mut input = req.input;
|
||||
|
||||
let bytes = match input.store_all_limited(MAX_ADMIN_REQUEST_BODY_SIZE).await {
|
||||
@@ -97,88 +84,282 @@ impl Operation for AssumeRoleHandle {
|
||||
|
||||
let body: AssumeRoleRequest = from_bytes(&bytes).map_err(|_e| s3_error!(InvalidRequest, "invalid STS request format"))?;
|
||||
|
||||
if body.action.as_str() != ASSUME_ROLE_ACTION {
|
||||
return Err(s3_error!(InvalidArgument, "not support action"));
|
||||
match body.action.as_str() {
|
||||
ASSUME_ROLE_ACTION => handle_assume_role(req.credentials, req.uri, req.headers, body).await,
|
||||
ASSUME_ROLE_WITH_WEB_IDENTITY_ACTION => handle_assume_role_with_web_identity(body).await,
|
||||
_ => Err(s3_error!(InvalidArgument, "unsupported Action")),
|
||||
}
|
||||
|
||||
if body.version.as_str() != ASSUME_ROLE_VERSION {
|
||||
return Err(s3_error!(InvalidArgument, "not support version"));
|
||||
}
|
||||
|
||||
let mut claims = cred.claims.unwrap_or_default();
|
||||
|
||||
populate_session_policy(&mut claims, &body.policy)?;
|
||||
|
||||
let exp = {
|
||||
if body.duration_seconds > 0 {
|
||||
body.duration_seconds
|
||||
} else {
|
||||
3600
|
||||
}
|
||||
};
|
||||
|
||||
claims.insert(
|
||||
"exp".to_string(),
|
||||
Value::Number(serde_json::Number::from(OffsetDateTime::now_utc().unix_timestamp() + exp as i64)),
|
||||
);
|
||||
|
||||
claims.insert("parent".to_string(), Value::String(cred.access_key.clone()));
|
||||
|
||||
// warn!("AssumeRole get cred {:?}", &user);
|
||||
// warn!("AssumeRole get body {:?}", &body);
|
||||
|
||||
let Ok(iam_store) = rustfs_iam::get() else {
|
||||
return Err(s3_error!(InvalidRequest, "iam not init"));
|
||||
};
|
||||
|
||||
if let Err(_err) = iam_store.policy_db_get(&cred.access_key, &cred.groups).await {
|
||||
error!(
|
||||
"AssumeRole get policy failed, err: {:?}, access_key: {:?}, groups: {:?}",
|
||||
_err, cred.access_key, cred.groups
|
||||
);
|
||||
return Err(s3_error!(InvalidArgument, "invalid policy arg"));
|
||||
}
|
||||
|
||||
let Some(secret) = get_token_signing_key() else {
|
||||
return Err(s3_error!(InvalidArgument, "global active sk not init"));
|
||||
};
|
||||
|
||||
info!("AssumeRole get claims {:?}", &claims);
|
||||
|
||||
let mut new_cred = get_new_credentials_with_metadata(&claims, &secret)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("get new cred failed {e}")))?;
|
||||
|
||||
new_cred.parent_user = cred.access_key.clone();
|
||||
|
||||
info!("AssumeRole get new_cred {:?}", &new_cred);
|
||||
|
||||
if let Err(_err) = iam_store.set_temp_user(&new_cred.access_key, &new_cred, None).await {
|
||||
return Err(s3_error!(InternalError, "set_temp_user failed"));
|
||||
}
|
||||
|
||||
// TODO: globalSiteReplicationSys
|
||||
|
||||
let resp = AssumeRoleOutput {
|
||||
credentials: Some(Credentials {
|
||||
access_key_id: new_cred.access_key,
|
||||
expiration: Timestamp::from(
|
||||
new_cred
|
||||
.expiration
|
||||
.unwrap_or(OffsetDateTime::now_utc().saturating_add(Duration::seconds(3600))),
|
||||
),
|
||||
secret_access_key: new_cred.secret_key,
|
||||
session_token: new_cred.session_token,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// getAssumeRoleCredentials
|
||||
let output = serialize::<AssumeRoleOutput>(&resp).unwrap();
|
||||
|
||||
Ok(S3Response::new((StatusCode::OK, Body::from(output))))
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the standard AssumeRole action (requires SigV4 credentials).
|
||||
async fn handle_assume_role(
|
||||
credentials: Option<s3s::auth::Credentials>,
|
||||
uri: http::Uri,
|
||||
headers: http::HeaderMap,
|
||||
body: AssumeRoleRequest,
|
||||
) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
let Some(user) = credentials else {
|
||||
return Err(s3_error!(InvalidRequest, "get cred failed"));
|
||||
};
|
||||
|
||||
let session_token = get_session_token(&uri, &headers);
|
||||
if session_token.is_some() {
|
||||
return Err(s3_error!(InvalidRequest, "AccessDenied1"));
|
||||
}
|
||||
|
||||
let (cred, _owner) = check_key_valid(get_session_token(&uri, &headers).unwrap_or_default(), &user.access_key).await?;
|
||||
|
||||
// TODO: Check permissions, do not allow STS access
|
||||
if cred.is_temp() || cred.is_service_account() {
|
||||
return Err(s3_error!(InvalidRequest, "AccessDenied"));
|
||||
}
|
||||
|
||||
if body.version.as_str() != ASSUME_ROLE_VERSION {
|
||||
return Err(s3_error!(InvalidArgument, "not support version"));
|
||||
}
|
||||
|
||||
let mut claims = cred.claims.unwrap_or_default();
|
||||
|
||||
populate_session_policy(&mut claims, &body.policy)?;
|
||||
|
||||
let exp = {
|
||||
if body.duration_seconds > 0 {
|
||||
body.duration_seconds
|
||||
} else {
|
||||
3600
|
||||
}
|
||||
};
|
||||
|
||||
claims.insert(
|
||||
"exp".to_string(),
|
||||
Value::Number(serde_json::Number::from(OffsetDateTime::now_utc().unix_timestamp() + exp as i64)),
|
||||
);
|
||||
|
||||
claims.insert("parent".to_string(), Value::String(cred.access_key.clone()));
|
||||
|
||||
let Ok(iam_store) = rustfs_iam::get() else {
|
||||
return Err(s3_error!(InvalidRequest, "iam not init"));
|
||||
};
|
||||
|
||||
if let Err(_err) = iam_store.policy_db_get(&cred.access_key, &cred.groups).await {
|
||||
error!(
|
||||
"AssumeRole get policy failed, err: {:?}, access_key: {:?}, groups: {:?}",
|
||||
_err, cred.access_key, cred.groups
|
||||
);
|
||||
return Err(s3_error!(InvalidArgument, "invalid policy arg"));
|
||||
}
|
||||
|
||||
let Some(secret) = get_token_signing_key() else {
|
||||
return Err(s3_error!(InvalidArgument, "global active sk not init"));
|
||||
};
|
||||
|
||||
info!("AssumeRole get claims {:?}", &claims);
|
||||
|
||||
let mut new_cred = get_new_credentials_with_metadata(&claims, &secret)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("get new cred failed {e}")))?;
|
||||
|
||||
new_cred.parent_user = cred.access_key.clone();
|
||||
|
||||
debug!("AssumeRole get new_cred {:?}", &new_cred);
|
||||
|
||||
if let Err(_err) = iam_store.set_temp_user(&new_cred.access_key, &new_cred, None).await {
|
||||
return Err(s3_error!(InternalError, "set_temp_user failed"));
|
||||
}
|
||||
|
||||
// TODO: globalSiteReplicationSys
|
||||
|
||||
let resp = AssumeRoleOutput {
|
||||
credentials: Some(Credentials {
|
||||
access_key_id: new_cred.access_key,
|
||||
expiration: Timestamp::from(
|
||||
new_cred
|
||||
.expiration
|
||||
.unwrap_or(OffsetDateTime::now_utc().saturating_add(Duration::seconds(3600))),
|
||||
),
|
||||
secret_access_key: new_cred.secret_key,
|
||||
session_token: new_cred.session_token,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// getAssumeRoleCredentials
|
||||
let output = serialize::<AssumeRoleOutput>(&resp).unwrap();
|
||||
|
||||
Ok(S3Response::new((StatusCode::OK, Body::from(output))))
|
||||
}
|
||||
|
||||
/// Handle the AssumeRoleWithWebIdentity action.
|
||||
/// The JWT (id_token) in the request is the authentication — no SigV4 needed.
|
||||
async fn handle_assume_role_with_web_identity(body: AssumeRoleRequest) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||
if body.web_identity_token.is_empty() {
|
||||
return Err(s3_error!(InvalidArgument, "WebIdentityToken is required"));
|
||||
}
|
||||
|
||||
if body.version.as_str() != ASSUME_ROLE_VERSION {
|
||||
return Err(s3_error!(InvalidArgument, "not support version"));
|
||||
}
|
||||
|
||||
// Verify the JWT and extract claims
|
||||
let oidc_sys = rustfs_iam::get_oidc().ok_or_else(|| s3_error!(InternalError, "OIDC not initialized"))?;
|
||||
|
||||
let (claims, provider_id) = oidc_sys
|
||||
.verify_web_identity_token(&body.web_identity_token)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
warn!("AssumeRoleWithWebIdentity JWT verification failed: {}", e);
|
||||
S3Error::with_message(S3ErrorCode::AccessDenied, format!("token verification failed: {e}"))
|
||||
})?;
|
||||
|
||||
// Map claims to policies and groups
|
||||
let (policies, groups) = oidc_sys.map_claims_to_policies(&provider_id, &claims);
|
||||
|
||||
info!(
|
||||
"AssumeRoleWithWebIdentity: user='{}', provider='{}', policies={:?}, groups={:?}",
|
||||
claims.username, provider_id, policies, groups
|
||||
);
|
||||
|
||||
let mut duration = if body.duration_seconds > 0 {
|
||||
body.duration_seconds
|
||||
} else {
|
||||
3600
|
||||
};
|
||||
|
||||
// Enforce reasonable bounds for STS credentials duration (similar to AWS STS)
|
||||
duration = duration.clamp(900, 43200);
|
||||
// Generate STS credentials using the shared helper
|
||||
let new_cred = create_oidc_sts_credentials(
|
||||
&claims,
|
||||
&provider_id,
|
||||
&policies,
|
||||
&groups,
|
||||
duration,
|
||||
if body.policy.is_empty() { None } else { Some(&body.policy) },
|
||||
)
|
||||
.await?;
|
||||
|
||||
let subject = if !claims.email.is_empty() {
|
||||
claims.email.clone()
|
||||
} else if !claims.username.is_empty() {
|
||||
claims.username.clone()
|
||||
} else if !claims.sub.is_empty() {
|
||||
claims.sub.clone()
|
||||
} else {
|
||||
"oidc-user-unknown".to_string()
|
||||
};
|
||||
|
||||
// Build XML response (AssumeRoleWithWebIdentityResponse)
|
||||
let expiration = new_cred
|
||||
.expiration
|
||||
.unwrap_or(OffsetDateTime::now_utc().saturating_add(Duration::seconds(3600)));
|
||||
let exp_str = expiration
|
||||
.format(&time::format_description::well_known::Rfc3339)
|
||||
.unwrap_or_default();
|
||||
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<AssumeRoleWithWebIdentityResponse xmlns="https://sts.amazonaws.com/doc/2011-06-15/">
|
||||
<AssumeRoleWithWebIdentityResult>
|
||||
<Credentials>
|
||||
<AccessKeyId>{}</AccessKeyId>
|
||||
<SecretAccessKey>{}</SecretAccessKey>
|
||||
<SessionToken>{}</SessionToken>
|
||||
<Expiration>{}</Expiration>
|
||||
</Credentials>
|
||||
<SubjectFromWebIdentityToken>{}</SubjectFromWebIdentityToken>
|
||||
</AssumeRoleWithWebIdentityResult>
|
||||
</AssumeRoleWithWebIdentityResponse>"#,
|
||||
xml_escape(&new_cred.access_key),
|
||||
xml_escape(&new_cred.secret_key),
|
||||
xml_escape(&new_cred.session_token),
|
||||
xml_escape(&exp_str),
|
||||
xml_escape(&subject),
|
||||
);
|
||||
|
||||
let mut resp = S3Response::new((StatusCode::OK, Body::from(xml.into_bytes())));
|
||||
resp.headers
|
||||
.insert(http::header::CONTENT_TYPE, "application/xml".parse().unwrap());
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
/// Shared helper to generate STS credentials from OIDC claims.
|
||||
/// Used by both the OIDC callback handler and AssumeRoleWithWebIdentity.
|
||||
pub async fn create_oidc_sts_credentials(
|
||||
claims: &OidcClaims,
|
||||
provider_id: &str,
|
||||
policies: &[String],
|
||||
groups: &[String],
|
||||
duration_seconds: usize,
|
||||
session_policy: Option<&str>,
|
||||
) -> S3Result<rustfs_credentials::Credentials> {
|
||||
let mut token_claims: HashMap<String, Value> = HashMap::new();
|
||||
token_claims.insert("sub".to_string(), Value::String(claims.sub.clone()));
|
||||
token_claims.insert("iss".to_string(), Value::String("rustfs-oidc".to_string()));
|
||||
token_claims.insert("oidc_provider".to_string(), Value::String(provider_id.to_string()));
|
||||
|
||||
if !claims.email.is_empty() {
|
||||
token_claims.insert("email".to_string(), Value::String(claims.email.clone()));
|
||||
}
|
||||
if !claims.username.is_empty() {
|
||||
token_claims.insert("preferred_username".to_string(), Value::String(claims.username.clone()));
|
||||
}
|
||||
if !groups.is_empty() {
|
||||
token_claims.insert(
|
||||
"groups".to_string(),
|
||||
Value::Array(groups.iter().map(|g| Value::String(g.clone())).collect()),
|
||||
);
|
||||
}
|
||||
|
||||
// Set expiration
|
||||
let exp = OffsetDateTime::now_utc().saturating_add(Duration::seconds(duration_seconds as i64));
|
||||
token_claims.insert("exp".to_string(), Value::Number(serde_json::Number::from(exp.unix_timestamp())));
|
||||
|
||||
// Set the parent user: prefer email, then username, then sub
|
||||
let parent_user = if !claims.email.is_empty() {
|
||||
claims.email.clone()
|
||||
} else if !claims.username.is_empty() {
|
||||
claims.username.clone()
|
||||
} else if !claims.sub.is_empty() {
|
||||
claims.sub.clone()
|
||||
} else {
|
||||
"oidc-user-unknown".to_string()
|
||||
};
|
||||
info!(
|
||||
"OIDC STS credential: parent_user='{}' (email='{}', username='{}', sub='{}')",
|
||||
parent_user, claims.email, claims.username, claims.sub
|
||||
);
|
||||
token_claims.insert("parent".to_string(), Value::String(parent_user.clone()));
|
||||
|
||||
// Set policies as a comma-separated string
|
||||
if !policies.is_empty() {
|
||||
token_claims.insert("policy".to_string(), Value::String(policies.join(",")));
|
||||
}
|
||||
|
||||
// Optionally apply session policy
|
||||
if let Some(policy_str) = session_policy {
|
||||
populate_session_policy(&mut token_claims, policy_str)?;
|
||||
}
|
||||
|
||||
// Generate STS temp credentials
|
||||
let secret = get_token_signing_key().ok_or_else(|| s3_error!(InternalError, "token signing key not initialized"))?;
|
||||
|
||||
let mut new_cred = get_new_credentials_with_metadata(&token_claims, &secret)
|
||||
.map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("credential generation failed: {e}")))?;
|
||||
|
||||
new_cred.parent_user = parent_user;
|
||||
new_cred.groups = Some(groups.to_vec());
|
||||
|
||||
// Store temp user in IAM
|
||||
let iam_store = rustfs_iam::get().map_err(|_| s3_error!(InternalError, "IAM not initialized"))?;
|
||||
|
||||
iam_store
|
||||
.set_temp_user(&new_cred.access_key, &new_cred, None)
|
||||
.await
|
||||
.map_err(|_| s3_error!(InternalError, "failed to store temp user"))?;
|
||||
|
||||
Ok(new_cred)
|
||||
}
|
||||
|
||||
pub fn populate_session_policy(claims: &mut HashMap<String, Value>, policy: &str) -> S3Result<()> {
|
||||
if !policy.is_empty() {
|
||||
let session_policy = Policy::parse_config(policy.as_bytes())
|
||||
@@ -205,3 +386,52 @@ pub fn populate_session_policy(claims: &mut HashMap<String, Value>, policy: &str
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Escape special XML characters in a string.
|
||||
fn xml_escape(s: &str) -> String {
|
||||
// Fast path: if there are no escapable characters, just clone the string.
|
||||
if !s.chars().any(|c| matches!(c, '&' | '<' | '>' | '"' | '\'')) {
|
||||
return s.to_owned();
|
||||
}
|
||||
|
||||
// Slow path: build the escaped string in a single pass.
|
||||
let mut escaped = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
_ => escaped.push(c),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_xml_escape() {
|
||||
assert_eq!(xml_escape("hello"), "hello");
|
||||
assert_eq!(xml_escape("<script>"), "<script>");
|
||||
assert_eq!(xml_escape("a&b"), "a&b");
|
||||
assert_eq!(xml_escape("\"quoted\""), ""quoted"");
|
||||
assert_eq!(xml_escape("it's"), "it's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duration_clamping() {
|
||||
// Simulates the clamping logic from handle_assume_role_with_web_identity
|
||||
let clamp = |d: usize| if d > 0 { d.clamp(900, 43200) } else { 3600 };
|
||||
|
||||
assert_eq!(clamp(0), 3600); // default
|
||||
assert_eq!(clamp(100), 900); // clamped to min
|
||||
assert_eq!(clamp(900), 900); // exact min
|
||||
assert_eq!(clamp(3600), 3600); // normal
|
||||
assert_eq!(clamp(43200), 43200); // exact max
|
||||
assert_eq!(clamp(999999), 43200); // clamped to max
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,9 @@ mod console_test;
|
||||
#[cfg(test)]
|
||||
mod route_registration_test;
|
||||
|
||||
use handlers::{bucket_meta, heal, health, kms, pools, profile_admin, quota, rebalance, replication, sts, system, tier, user};
|
||||
use handlers::{
|
||||
bucket_meta, heal, health, kms, oidc, pools, profile_admin, quota, rebalance, replication, sts, system, tier, user,
|
||||
};
|
||||
use router::{AdminOperation, S3Router};
|
||||
use rpc::register_rpc_route;
|
||||
use s3s::route::S3Route;
|
||||
@@ -58,6 +60,7 @@ pub fn make_admin_route(console_enabled: bool) -> std::io::Result<impl S3Route>
|
||||
replication::register_replication_route(&mut r)?;
|
||||
profile_admin::register_profiling_route(&mut r)?;
|
||||
kms::register_kms_route(&mut r)?;
|
||||
oidc::register_oidc_route(&mut r)?;
|
||||
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// limitations under the License.
|
||||
|
||||
use crate::admin::console::{is_console_path, make_console_server};
|
||||
use crate::admin::handlers::oidc::is_oidc_path;
|
||||
use crate::server::{ADMIN_PREFIX, HEALTH_PREFIX, HEALTH_READY_PATH, PROFILE_CPU_PATH, PROFILE_MEMORY_PATH, RPC_PREFIX};
|
||||
use hyper::HeaderMap;
|
||||
use hyper::Method;
|
||||
@@ -142,6 +143,11 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Allow unauthenticated access to OIDC endpoints (user not yet authenticated)
|
||||
if is_oidc_path(path) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check RPC signature verification
|
||||
if req.uri.path().starts_with(RPC_PREFIX) {
|
||||
// Skip signature verification for HEAD requests (health checks)
|
||||
@@ -154,6 +160,30 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Allow unauthenticated STS requests to POST / (AssumeRoleWithWebIdentity
|
||||
// doesn't use SigV4 — the JWT token in the request body is the authentication).
|
||||
// The handler dispatches on the Action parameter: AssumeRole will reject if
|
||||
// credentials are missing, AssumeRoleWithWebIdentity will validate the JWT.
|
||||
// Require application/x-www-form-urlencoded Content-Type to narrow the bypass.
|
||||
if req.method == Method::POST
|
||||
&& path == "/"
|
||||
&& req.credentials.is_none()
|
||||
&& req
|
||||
.headers
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|ct| {
|
||||
ct.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.eq_ignore_ascii_case("application/x-www-form-urlencoded")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// For non-RPC admin requests, check credentials
|
||||
match req.credentials {
|
||||
Some(_) => Ok(()),
|
||||
|
||||
+6
-1
@@ -62,7 +62,7 @@ use rustfs_ecstore::{
|
||||
use rustfs_heal::{
|
||||
create_ahm_services_cancel_token, heal::storage::ECStoreHealStorage, init_heal_manager, shutdown_ahm_services,
|
||||
};
|
||||
use rustfs_iam::init_iam_sys;
|
||||
use rustfs_iam::{init_iam_sys, init_oidc_sys};
|
||||
use rustfs_metrics::init_metrics_system;
|
||||
use rustfs_obs::{init_obs, set_global_guard};
|
||||
use rustfs_scanner::init_data_scanner;
|
||||
@@ -366,6 +366,11 @@ async fn run(config: config::Config) -> Result<()> {
|
||||
init_iam_sys(store.clone()).await.map_err(Error::other)?;
|
||||
readiness.mark_stage(SystemStage::IamReady);
|
||||
|
||||
// 3b. Initialize OIDC System (non-fatal if no providers configured)
|
||||
if let Err(e) = init_oidc_sys().await {
|
||||
warn!("OIDC initialization failed (non-fatal): {}", e);
|
||||
}
|
||||
|
||||
let iam_interface =
|
||||
rustfs_iam::get().map_err(|e| Error::other(format!("initialize app context IAM dependency failed: {e}")))?;
|
||||
let kms_interface = rustfs_kms::get_global_kms_service_manager().unwrap_or_else(rustfs_kms::init_global_kms_service_manager);
|
||||
|
||||
Reference in New Issue
Block a user