diff --git a/crates/config/src/constants/console.rs b/crates/config/src/constants/console.rs index 617aa020d..a8dd5f764 100644 --- a/crates/config/src/constants/console.rs +++ b/crates/config/src/constants/console.rs @@ -69,15 +69,17 @@ pub const ENV_CONSOLE_RATE_LIMIT_RPM: &str = "RUSTFS_CONSOLE_RATE_LIMIT_RPM"; pub const DEFAULT_CONSOLE_RATE_LIMIT_RPM: u32 = 100; /// Set the console authentication timeout in seconds -/// Specifies how long a console authentication session remains valid +/// Bounds how long the console waits for a single authenticated HTTP request before it is +/// considered timed out. This is a request timeout, not the session lifetime: the Console +/// credential lifetime issued by the OIDC callback is `RUSTFS_CONSOLE_OIDC_SESSION_DURATION`. /// Default: 3600 seconds (1 hour) /// Minimum: 300 seconds (5 minutes) /// Maximum: 86400 seconds (24 hours) pub const ENV_CONSOLE_AUTH_TIMEOUT: &str = "RUSTFS_CONSOLE_AUTH_TIMEOUT"; /// Default console authentication timeout in seconds -/// This is the default timeout for console authentication sessions. -/// After this timeout, users need to re-authenticate to access the console. +/// This is the default timeout for console authentication requests. +/// After this timeout, a pending console request is abandoned. /// Default value: 3600 seconds (1 hour) /// Environment variable: RUSTFS_CONSOLE_AUTH_TIMEOUT /// Command line argument: --console-auth-timeout @@ -85,6 +87,26 @@ pub const ENV_CONSOLE_AUTH_TIMEOUT: &str = "RUSTFS_CONSOLE_AUTH_TIMEOUT"; /// Example: --console-auth-timeout 3600 pub const DEFAULT_CONSOLE_AUTH_TIMEOUT: u64 = 3600; +/// Console credential lifetime, in seconds, for Console credentials issued by the OIDC callback. +/// +/// Process-level, Console-scoped setting: it applies to every OIDC provider and deliberately +/// lives outside the per-provider `RUSTFS_IDENTITY_OPENID_*` namespace, so it is never +/// suffix-scanned into a provider id and has no `identity_openid` KVS equivalent. The same value +/// also sets how long the Console logout token stays valid. +pub const ENV_CONSOLE_OIDC_SESSION_DURATION: &str = "RUSTFS_CONSOLE_OIDC_SESSION_DURATION"; + +/// Default Console credential lifetime in seconds, used when the environment variable is unset, +/// not a positive integer, or zero. Matches the value this path hard-coded before it was +/// parameterised. +pub const DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS: usize = 3600; + +/// Upper bound on the Console credential lifetime in seconds (7 days). +/// +/// Values above this are clamped with a warning. The bound keeps the moka logout cache's +/// `time_to_live` well under its 1000-year assertion, and stops an operator from minting an +/// effectively permanent Console STS credential on every node. +pub const CONSOLE_OIDC_SESSION_DURATION_MAX_SECS: usize = 604_800; + /// Toggle update check /// It controls whether to check for newer versions of rustfs /// Default value: true diff --git a/crates/ecstore/src/store/object.rs b/crates/ecstore/src/store/object.rs index 350099de7..ff37c53b0 100644 --- a/crates/ecstore/src/store/object.rs +++ b/crates/ecstore/src/store/object.rs @@ -10550,8 +10550,9 @@ mod tests { disk.read_all(bucket, &format!("{object}/xl.meta")).await.unwrap(), )); } - for slot in 12..16 { - set.disks.write().await[slot] = None; + for disk in before.iter().skip(12).flatten() { + disk.close().await.expect("stop stale member health monitoring"); + disk.force_offline_for_test(); } for version in historical { store @@ -10592,7 +10593,9 @@ mod tests { .await .unwrap() ); - *set.disks.write().await = before.clone(); + for disk in before.iter().skip(12).flatten() { + disk.reset_health_for_store_init_retry(); + } store .recover_delete_transactions_at_incarnation( bucket, @@ -10611,8 +10614,9 @@ mod tests { .await .expect("durable retirement") ); - for slot in 12..16 { - set.disks.write().await[slot] = None; + for disk in before.iter().skip(12).flatten() { + disk.close().await.expect("stop returning member health monitoring"); + disk.force_offline_for_test(); } store .handle_make_bucket(bucket, &create) @@ -10646,7 +10650,9 @@ mod tests { tokio::fs::write(root.join("xl.meta"), bytes).await.unwrap(); } } - *set.disks.write().await = before.clone(); + for disk in before.iter().skip(12).flatten() { + disk.reset_health_for_store_init_retry(); + } let heal = rustfs_heal_contracts::heal_channel::HealOpts { remove: true, scan_mode: rustfs_heal_contracts::heal_channel::HealScanMode::Deep, @@ -10730,14 +10736,16 @@ mod tests { .unwrap(); } } - set.disks.write().await[15] = None; + let offline = before[15].as_ref().expect("cleanup target must exist"); + offline.close().await.expect("stop cleanup target health monitoring"); + offline.force_offline_for_test(); let incomplete = store .heal_object_with_proof(bucket, object, &marker.to_string(), &heal) .await .unwrap(); assert!(incomplete.error.as_ref().is_some_and(Error::is_retired_marker_deferred)); assert!(incomplete.absence.is_none(), "offline targets cannot be omitted from a cleanup receipt"); - set.disks.write().await[15] = before[15].clone(); + offline.reset_health_for_store_init_retry(); let failure = crate::set_disk::DanglingDeleteFailure::install(bucket, object, 15, crate::disk::error::DiskError::FaultyDisk); let partial = store @@ -10856,9 +10864,13 @@ mod tests { .expect("bucket incarnation"); let set = &store.pools[0].disk_set[0]; let before = set.disks.read().await.clone(); - for slot in 12..16 { - set.disks.write().await[slot] = None; + // Keep the handles attached so reconnect probes inherit the forced + // outage instead of recreating healthy handles for these local paths. + for disk in before.iter().skip(12).flatten() { + disk.close().await.expect("stop stale member health monitoring"); + disk.force_offline_for_test(); } + set.connect_disks().await; store .delete_object( bucket, @@ -10871,8 +10883,6 @@ mod tests { ) .await .expect("acknowledged delete should remove the marker from the online quorum"); - *set.disks.write().await = before.clone(); - for disk in before.iter().take(12).flatten() { assert!(matches!( disk.read_version("", bucket, object, &marker.to_string(), &crate::disk::ReadOptions::default()) @@ -10881,6 +10891,8 @@ mod tests { )); } for disk in before.iter().skip(12).flatten() { + assert_eq!(disk.runtime_state(), crate::disk::health_state::RuntimeDriveHealthState::Offline); + disk.reset_health_for_store_init_retry(); assert!( disk.read_version("", bucket, object, &marker.to_string(), &crate::disk::ReadOptions::default()) .await diff --git a/crates/iam/src/oidc/config.rs b/crates/iam/src/oidc/config.rs index deceb35e1..c8c48bebe 100644 --- a/crates/iam/src/oidc/config.rs +++ b/crates/iam/src/oidc/config.rs @@ -11,15 +11,20 @@ // 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 crate::{EVENT_OIDC_DIAGNOSTICS, LOG_COMPONENT_IAM, LOG_SUBSYSTEM_OIDC}; use base64_simd::URL_SAFE_NO_PAD; use rustfs_config::oidc::*; use rustfs_config::server_config::{Config as ServerConfig, KVS}; -use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY, EnableState}; +use rustfs_config::{ + CONSOLE_OIDC_SESSION_DURATION_MAX_SECS, DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS, DEFAULT_DELIMITER, ENABLE_KEY, + ENV_CONSOLE_OIDC_SESSION_DURATION, EnableState, +}; use rustfs_utils::egress::{OutboundUrlError, validate_outbound_url}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::fmt; +use tracing::warn; use url::Url; pub(super) const REDACTED_SECRET: &str = "***redacted***"; @@ -815,6 +820,38 @@ pub fn load_oidc_provider_configs_from_env() -> Vec { parse_env_configs() } +/// Credential lifetime, in seconds, for Console credentials issued by the OIDC callback. +/// +/// `RUSTFS_CONSOLE_OIDC_SESSION_DURATION` overrides the default for every provider. An unset, +/// unparseable, or zero value keeps [`DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS`], so a +/// misconfigured variable can never issue credentials that are already expired. Values above +/// [`CONSOLE_OIDC_SESSION_DURATION_MAX_SECS`] are clamped to that maximum with a warning. +/// +/// Both the credential lifetime and the Console logout-token retention derive from this single +/// value, so they stay in step whichever way the variable is set. +pub fn console_session_duration_secs() -> usize { + let configured = rustfs_utils::get_env_opt_u64(ENV_CONSOLE_OIDC_SESSION_DURATION) + .and_then(|seconds| usize::try_from(seconds).ok()) + .filter(|seconds| *seconds > 0) + .unwrap_or(DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS); + + if configured > CONSOLE_OIDC_SESSION_DURATION_MAX_SECS { + warn!( + event = EVENT_OIDC_DIAGNOSTICS, + component = LOG_COMPONENT_IAM, + subsystem = LOG_SUBSYSTEM_OIDC, + state = "console_session_duration_clamped", + config_key = ENV_CONSOLE_OIDC_SESSION_DURATION, + configured, + max = CONSOLE_OIDC_SESSION_DURATION_MAX_SECS, + "Console OIDC session duration exceeds the maximum" + ); + return CONSOLE_OIDC_SESSION_DURATION_MAX_SECS; + } + + configured +} + pub fn load_oidc_provider_configs_from_server_config(cfg: &ServerConfig) -> Vec { parse_persisted_configs(cfg) } diff --git a/crates/iam/src/oidc/config_tests.rs b/crates/iam/src/oidc/config_tests.rs index 7f79c7a96..37370a434 100644 --- a/crates/iam/src/oidc/config_tests.rs +++ b/crates/iam/src/oidc/config_tests.rs @@ -17,6 +17,28 @@ use crate::oidc::test_support::*; use rustfs_config::server_config::{Config as ServerConfig, KVS}; use rustfs_config::{DEFAULT_DELIMITER, ENABLE_KEY}; +#[test] +#[serial_test::serial] +fn console_session_duration_follows_the_environment() { + for (configured, expected) in [ + (None, DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS), + (Some("1"), 1), + (Some("7200"), 7200), + (Some("604800"), CONSOLE_OIDC_SESSION_DURATION_MAX_SECS), + (Some("604801"), CONSOLE_OIDC_SESSION_DURATION_MAX_SECS), + (Some("999999999"), CONSOLE_OIDC_SESSION_DURATION_MAX_SECS), + (Some("18446744073709551615"), CONSOLE_OIDC_SESSION_DURATION_MAX_SECS), + (Some("18446744073709551616"), DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS), + (Some("0"), DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS), + (Some("-1"), DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS), + (Some("60s"), DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS), + (Some(""), DEFAULT_CONSOLE_OIDC_SESSION_DURATION_SECS), + ] { + let observed = temp_env::with_var(ENV_CONSOLE_OIDC_SESSION_DURATION, configured, console_session_duration_secs); + assert_eq!(observed, expected, "Console OIDC session duration for {configured:?}"); + } +} + #[test] fn test_parse_single_provider_no_config_url() { let config = parse_single_provider("_TEST_EMPTY", "test_empty"); diff --git a/crates/iam/src/oidc/mod.rs b/crates/iam/src/oidc/mod.rs index 7452dcd19..a171b776e 100644 --- a/crates/iam/src/oidc/mod.rs +++ b/crates/iam/src/oidc/mod.rs @@ -25,9 +25,10 @@ mod test_support; pub use config::{ OidcConfigError, OidcConfigQuery, OidcConfigSnapshot, OidcProviderConfig, OidcProviderConfigInput, OidcProviderConfigSource, OidcProviderValidationInput, OidcProviderValidationResult, OidcSiteReplicationProvider, OidcSiteReplicationSnapshot, - SourcedOidcProviderConfig, build_upsert_provider_config, build_validation_provider_config, delete_persisted_provider_config, - load_oidc_config_snapshot, load_oidc_provider_configs_from_env, load_oidc_provider_configs_from_server_config, - persisted_provider_secret, upsert_persisted_provider_config, validate_mutable_provider_id, + SourcedOidcProviderConfig, build_upsert_provider_config, build_validation_provider_config, console_session_duration_secs, + delete_persisted_provider_config, load_oidc_config_snapshot, load_oidc_provider_configs_from_env, + load_oidc_provider_configs_from_server_config, persisted_provider_secret, upsert_persisted_provider_config, + validate_mutable_provider_id, }; pub use provider::validate_oidc_provider_config_with_extra_root_ca; pub use runtime::{OidcSys, StandardOidcAdapter}; diff --git a/crates/iam/src/oidc/state.rs b/crates/iam/src/oidc/state.rs index fa9b90f3e..48af714b8 100644 --- a/crates/iam/src/oidc/state.rs +++ b/crates/iam/src/oidc/state.rs @@ -18,6 +18,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tracing::warn; +use super::config::console_session_duration_secs; + const OIDC_STATE_CAPACITY: u64 = 10_000; const OIDC_STATE_CAPACITY_WARNING: u64 = 9_000; const OIDC_STATE_CAPACITY_CRITICAL: u64 = 10_000; @@ -57,7 +59,10 @@ impl OidcStateStore { .build(); let logout_cache = Cache::builder() .max_capacity(OIDC_STATE_CAPACITY) - .time_to_live(Duration::from_secs(3600)) // 1 hour TTL to match console OIDC sessions + // Keep the logout token alive for as long as the credentials it belongs to: if it + // expired sooner, a session longer than the old hard-coded hour could no longer + // trigger RP-initiated logout. + .time_to_live(Duration::from_secs(console_session_duration_secs() as u64)) .build(); Self { cache, @@ -140,6 +145,8 @@ impl Default for OidcStateStore { #[cfg(test)] mod tests { use super::*; + use rustfs_config::ENV_CONSOLE_OIDC_SESSION_DURATION; + use serial_test::serial; use std::sync::Arc; use tokio::sync::Barrier; @@ -302,4 +309,26 @@ mod tests { assert!(store.take_logout("logout_abc").await.is_none()); } + + #[test] + #[serial] + fn logout_retention_follows_the_bounded_session_duration() { + for (configured, expected_seconds) in [ + (None, 3600), + (Some("1"), 1), + (Some("7200"), 7200), + (Some("604801"), 604_800), + (Some("18446744073709551615"), 604_800), + ] { + temp_env::with_var(ENV_CONSOLE_OIDC_SESSION_DURATION, configured, || { + let store = OidcStateStore::new(); + assert_eq!( + store.logout_cache.policy().time_to_live(), + Some(Duration::from_secs(expected_seconds)), + "logout retention for {configured:?} must match the issued session" + ); + assert_eq!(store.cache.policy().time_to_live(), Some(Duration::from_secs(300))); + }); + } + } } diff --git a/docs/operations/oidc-console-integration.md b/docs/operations/oidc-console-integration.md index 73b3e4b9f..3aa3b7637 100644 --- a/docs/operations/oidc-console-integration.md +++ b/docs/operations/oidc-console-integration.md @@ -16,7 +16,7 @@ Login flow: 3. The IdP redirects back to `/rustfs/admin/v3/oidc/callback/?code=...&state=...`. 4. RustFS exchanges the code at the token endpoint, sending `client_id` and `client_secret` in the request body (`client_secret_post`) together with the PKCE verifier. 5. RustFS validates the ID token signature (JWKS), issuer, audience, expiry, and nonce. -6. RustFS maps claim values to policy names and issues one-hour STS credentials to the Console. +6. RustFS maps claim values to policy names and issues STS credentials to the Console with the lifetime set by `RUSTFS_CONSOLE_OIDC_SESSION_DURATION` (default 3600 seconds, one hour). In-flight `state` and PKCE verifiers are node-local: the authorize and callback requests must reach the same RustFS node. @@ -47,6 +47,7 @@ Process-level settings (environment only, never suffixed per provider): | --- | --- | | `RUSTFS_BROWSER_REDIRECT_URL` | Public browser origin used for callback generation, Console success redirects, and logout fallback. | | `RUSTFS_OUTBOUND_ALLOW_ORIGINS` | Exact `scheme://host[:port]` origins RustFS may contact for discovery, JWKS, and token requests when the IdP resolves to a private, loopback, or container-network address. See [outbound-connection-policy.md](outbound-connection-policy.md). | +| `RUSTFS_CONSOLE_OIDC_SESSION_DURATION` | Credential lifetime in seconds for Console credentials issued by this callback, for every provider. Default `3600`. Values that are missing, not a positive integer, or zero keep the default; values above `604800` (7 days) are clamped to `604800` with a warning. The same value also sets how long the Console logout token stays valid, so RP-initiated logout keeps working for the whole session. Note that `RUSTFS_CONSOLE_AUTH_TIMEOUT` does not control this: it is the Console HTTP request timeout. | Named providers: to use provider id ``, suffix every provider env var with `_` (for example `RUSTFS_IDENTITY_OPENID_CLIENT_ID_keycloak`) and register the callback `/rustfs/admin/v3/oidc/callback/`. Suffix scanning is `parse_single_provider` in `crates/iam/src/oidc/config.rs`. @@ -137,6 +138,7 @@ curl -fsS "/.well-known/openid-configuration" | jq '{issuer, authorizati | Login succeeds, access denied | no claim value matches a policy name | Emit `groups` or `roles` as a flat array equal to policy names; check for `/` prefixes. | | Console redirects to an internal host | `RUSTFS_BROWSER_REDIRECT_URL` unset or proxy headers wrong | Set `RUSTFS_BROWSER_REDIRECT_URL` to the public origin. | | Invalid or expired OIDC state | callback reached a different node | Configure load-balancer session affinity for authorize and callback. | +| Console session expires after about an hour | callback issues credentials with the default lifetime | Set `RUSTFS_CONSOLE_OIDC_SESSION_DURATION` to the desired lifetime in seconds (max `604800`, 7 days) and restart RustFS. `RUSTFS_CONSOLE_AUTH_TIMEOUT` is a request timeout and does not extend the session. | ## Production checklist diff --git a/rustfs/src/admin/handlers/oidc.rs b/rustfs/src/admin/handlers/oidc.rs index 383ae9d72..c7151b839 100644 --- a/rustfs/src/admin/handlers/oidc.rs +++ b/rustfs/src/admin/handlers/oidc.rs @@ -24,7 +24,7 @@ use hyper::Method; use matchit::Params; use rustfs_config::{ENV_RUSTFS_BROWSER_REDIRECT_URL, MAX_ADMIN_REQUEST_BODY_SIZE}; use rustfs_iam::federation::{FederatedSessionBindingError, FederationError}; -use rustfs_iam::oidc::{OidcProviderConfigInput, OidcProviderValidationInput}; +use rustfs_iam::oidc::{OidcProviderConfigInput, OidcProviderValidationInput, console_session_duration_secs}; use rustfs_policy::policy::action::{Action, AdminAction}; use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error}; use serde::Serialize; @@ -450,8 +450,9 @@ impl Operation for OidcCallbackHandler { let redirect_uri = derive_callback_uri(&req, provider_id)?; + let session_duration_seconds = console_session_duration_secs(); let login = federation - .complete_authorization_code(&state, &code, &redirect_uri, 3600, &DefaultFederatedSessionBinding) + .complete_authorization_code(&state, &code, &redirect_uri, session_duration_seconds, &DefaultFederatedSessionBinding) .await .map_err(|error| { if let FederationError::CodeExchange(message) = &error { @@ -1375,4 +1376,30 @@ mod tests { ); } } + + #[test] + fn console_callback_uses_the_configured_session_duration() { + let production = include_str!("oidc.rs") + .split("\n#[cfg(test)]\n") + .next() + .expect("production source must precede tests"); + let callback = production + .split_once("impl Operation for OidcCallbackHandler") + .expect("the OIDC callback handler must exist") + .1; + let callback = callback.split_whitespace().collect::(); + + assert!( + callback.contains("session_duration_seconds=console_session_duration_secs()"), + "the callback must resolve the Console session duration from the environment" + ); + assert!( + callback.contains(".complete_authorization_code(&state,&code,&redirect_uri,session_duration_seconds,"), + "the callback must hand the resolved session duration to the code exchange" + ); + assert!( + !callback.contains(".complete_authorization_code(&state,&code,&redirect_uri,3600,"), + "the callback must not hard-code the session duration" + ); + } } diff --git a/rustfs/src/admin/service/federated_identity.rs b/rustfs/src/admin/service/federated_identity.rs index 679539079..1641a9447 100644 --- a/rustfs/src/admin/service/federated_identity.rs +++ b/rustfs/src/admin/service/federated_identity.rs @@ -571,6 +571,36 @@ mod tests { ); } + #[test] + #[serial] + fn console_session_duration_bounds_signed_credential_expiration() { + let secret = "console-session-expiration-test-secret"; + for (configured, expected_seconds) in [(None, 3600), (Some("7200"), 7200), (Some("604801"), 604_800)] { + temp_env::with_var(rustfs_config::ENV_CONSOLE_OIDC_SESSION_DURATION, configured, || { + let mut transaction = transaction(); + transaction.duration_seconds = rustfs_iam::oidc::console_session_duration_secs(); + let before = OffsetDateTime::now_utc().unix_timestamp(); + let credentials = issue_credentials(&transaction, &["readonly".to_string()], Some(secret)) + .expect("configured Console session should issue credentials"); + let after = OffsetDateTime::now_utc().unix_timestamp(); + let claims = rustfs_iam::sys::get_claims_from_token_with_secret(&credentials.session_token, secret) + .expect("Console credential signature should verify"); + let expiration = claims["exp"].as_i64().expect("signed expiration should be an integer"); + assert!( + (before + expected_seconds..=after + expected_seconds).contains(&expiration), + "signed expiration must reflect the bounded Console session for {configured:?}" + ); + assert_eq!( + credentials + .expiration + .expect("Console credential must expire") + .unix_timestamp(), + expiration + ); + }); + } + } + #[test] fn issued_credentials_and_replication_item_use_minio_parent_shape() { let transaction = transaction();