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:
Jeff Poegel
2026-02-25 22:03:17 -08:00
committed by GitHub
parent 0f8bc461d6
commit c35ef84a8c
22 changed files with 2584 additions and 139 deletions
+44 -3
View File
@@ -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,
}
}
+14
View File
@@ -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)
+27 -4
View File
@@ -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,
+1
View File
@@ -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;
+372
View File
@@ -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(""));
}
}
+325 -95
View File
@@ -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("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
_ => 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>"), "&lt;script&gt;");
assert_eq!(xml_escape("a&b"), "a&amp;b");
assert_eq!(xml_escape("\"quoted\""), "&quot;quoted&quot;");
assert_eq!(xml_escape("it's"), "it&apos;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
}
}
+4 -1
View File
@@ -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)
}
+30
View File
@@ -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
View File
@@ -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);