diff --git a/crates/iam/src/oidc.rs b/crates/iam/src/oidc.rs index afc46c2f8..558f0ea7f 100644 --- a/crates/iam/src/oidc.rs +++ b/crates/iam/src/oidc.rs @@ -18,11 +18,11 @@ //! `openidconnect` crate for standards-compliant discovery, token exchange, //! and ID token verification. -use crate::oidc_state::{OidcAuthSession, OidcStateStore}; -use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreProviderMetadata}; +use crate::oidc_state::{OidcAuthSession, OidcLogoutSession, OidcStateStore}; +use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken}; use openidconnect::{ - AsyncHttpClient, Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, - PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, + AsyncHttpClient, Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, LogoutRequest, Nonce, + PkceCodeChallenge, PkceCodeVerifier, PostLogoutRedirectUrl, ProviderMetadataWithLogout, RedirectUrl, Scope, }; use reqwest::Client; use rustfs_config::oidc::*; @@ -216,7 +216,7 @@ pub struct OidcClaims { /// on-the-fly from metadata when needed. #[derive(Clone)] struct ProviderState { - metadata: CoreProviderMetadata, + metadata: ProviderMetadataWithLogout, discovered_at: Instant, } @@ -364,7 +364,7 @@ impl OidcSys { state: &str, code: &str, redirect_uri: &str, - ) -> Result<(OidcClaims, String, OidcAuthSession), String> { + ) -> Result<(OidcClaims, String, OidcAuthSession, String), String> { // Retrieve and consume the state (single-use) let session = self .state_store @@ -449,7 +449,63 @@ impl OidcSys { raw, }; - Ok((claims, session.provider_id.clone(), session)) + Ok((claims, session.provider_id.clone(), session, raw_jwt)) + } + + /// Store a one-time logout session keyed by an opaque token so the console can + /// trigger browser logout without persisting the raw ID token. + pub async fn create_logout_token(&self, provider_id: &str, id_token: &str) -> Result { + if !self.configs.contains_key(provider_id) { + return Err(format!("unknown OIDC provider: {provider_id}")); + } + + let token = CsrfToken::new_random().secret().clone(); + self.state_store + .insert_logout( + token.clone(), + OidcLogoutSession { + provider_id: provider_id.to_string(), + id_token: id_token.to_string(), + }, + ) + .await; + + Ok(token) + } + + /// Build the RP-initiated logout URL for a previously issued logout token. + /// Returns `Ok(None)` when the provider does not advertise an end-session endpoint. + pub async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result, String> { + let session = self + .state_store + .take_logout(logout_token) + .await + .ok_or_else(|| "invalid or expired OIDC logout token".to_string())?; + + let config = self + .configs + .get(&session.provider_id) + .ok_or_else(|| format!("unknown OIDC provider: {}", session.provider_id))?; + let state = self.ensure_provider_state(&session.provider_id, config).await?; + let Some(end_session_endpoint) = state.metadata.additional_metadata().end_session_endpoint.clone() else { + return Ok(None); + }; + + let id_token: CoreIdToken = session + .id_token + .parse() + .map_err(|e: serde_json::Error| format!("failed to parse ID token for logout: {e}"))?; + let post_logout_redirect_uri = PostLogoutRedirectUrl::new(post_logout_redirect_uri.to_string()) + .map_err(|e| format!("invalid post logout redirect URI: {e}"))?; + + let logout_url = LogoutRequest::from(end_session_endpoint) + .set_id_token_hint(&id_token) + .set_client_id(ClientId::new(config.client_id.clone())) + .set_post_logout_redirect_uri(post_logout_redirect_uri) + .http_get_url() + .to_string(); + + Ok(Some(logout_url)) } /// Map OIDC claims to rustfs policy names. @@ -930,7 +986,7 @@ impl OidcSys { let issuer_url = IssuerUrl::new(candidate_issuer.clone()).map_err(|e| format!("invalid issuer URL: {e}"))?; for attempt in 0..OIDC_DISCOVERY_TRANSPORT_RETRIES { - match CoreProviderMetadata::discover_async(issuer_url.clone(), http_client) + match ProviderMetadataWithLogout::discover_async(issuer_url.clone(), http_client) .await .map_err(|e| format!("discovery failed: {e}")) { diff --git a/crates/iam/src/oidc_state.rs b/crates/iam/src/oidc_state.rs index cd838e64a..90b0eea47 100644 --- a/crates/iam/src/oidc_state.rs +++ b/crates/iam/src/oidc_state.rs @@ -32,11 +32,20 @@ pub struct OidcAuthSession { pub redirect_after: Option, } +/// Stores an ID token behind a one-time opaque handle so the console can trigger +/// RP-initiated logout without persisting the raw token in browser storage. +#[derive(Debug, Clone)] +pub struct OidcLogoutSession { + pub provider_id: String, + pub id_token: String, +} + /// TTL cache for OIDC auth state (PKCE verifiers + nonces) during the authorization flow. /// Entries expire after 5 minutes and are single-use (removed on retrieval). #[derive(Clone)] pub struct OidcStateStore { cache: Cache, + logout_cache: Cache, last_capacity_log_at: Arc, } @@ -46,8 +55,13 @@ impl OidcStateStore { .max_capacity(OIDC_STATE_CAPACITY) .time_to_live(Duration::from_secs(300)) // 5 minute TTL .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 + .build(); Self { cache, + logout_cache, last_capacity_log_at: Arc::new(AtomicU64::new(0)), } } @@ -98,6 +112,21 @@ impl OidcStateStore { pub async fn contains(&self, state: &str) -> bool { self.cache.get(state).await.is_some() } + + /// Store a new one-time logout session keyed by an opaque logout token. + pub async fn insert_logout(&self, token: String, session: OidcLogoutSession) { + self.logout_cache.insert(token, session).await; + } + + /// Retrieve and remove a logout session (single-use). Returns None if expired or not found. + pub async fn take_logout(&self, token: &str) -> Option { + self.logout_cache.remove(token).await + } + + /// Check if a logout token exists (without consuming it). + pub async fn contains_logout(&self, token: &str) -> bool { + self.logout_cache.get(token).await.is_some() + } } impl Default for OidcStateStore { @@ -167,4 +196,24 @@ mod tests { assert!(store.take(&format!("state_{i}")).await.is_none()); } } + + #[tokio::test] + async fn test_logout_state_store_insert_and_take() { + let store = OidcStateStore::new(); + let session = OidcLogoutSession { + provider_id: "okta".to_string(), + id_token: "jwt-token".to_string(), + }; + + store.insert_logout("logout_abc".to_string(), session.clone()).await; + assert!(store.contains_logout("logout_abc").await); + + let retrieved = store.take_logout("logout_abc").await; + assert!(retrieved.is_some()); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.provider_id, "okta"); + assert_eq!(retrieved.id_token, "jwt-token"); + + assert!(store.take_logout("logout_abc").await.is_none()); + } } diff --git a/rustfs/src/admin/handlers/oidc.rs b/rustfs/src/admin/handlers/oidc.rs index bd8df94b4..49a19d95e 100644 --- a/rustfs/src/admin/handlers/oidc.rs +++ b/rustfs/src/admin/handlers/oidc.rs @@ -41,6 +41,7 @@ use url::Url; const OIDC_PUBLIC_PROVIDERS_SUFFIX: &str = "/v3/oidc/providers"; const OIDC_AUTHORIZE_SUFFIX: &str = "/v3/oidc/authorize/"; const OIDC_CALLBACK_SUFFIX: &str = "/v3/oidc/callback/"; +const OIDC_LOGOUT_SUFFIX: &str = "/v3/oidc/logout"; /// Validate that a provider ID contains only safe characters (alphanumeric, underscore, hyphen). fn is_valid_provider_id(id: &str) -> bool { @@ -74,6 +75,11 @@ pub fn register_oidc_route(r: &mut S3Router) -> std::io::Result< &format!("{ADMIN_PREFIX}/v3/oidc/callback/{{provider_id}}"), AdminOperation(&OidcCallbackHandler {}), )?; + r.insert( + Method::GET, + &format!("{ADMIN_PREFIX}{OIDC_LOGOUT_SUFFIX}"), + AdminOperation(&OidcLogoutHandler {}), + )?; r.insert( Method::GET, &format!("{ADMIN_PREFIX}/v3/oidc/config"), @@ -106,6 +112,7 @@ pub fn is_oidc_path(path: &str) -> bool { path == format!("{prefix}{OIDC_PUBLIC_PROVIDERS_SUFFIX}") || path.starts_with(&format!("{prefix}{OIDC_AUTHORIZE_SUFFIX}")) || path.starts_with(&format!("{prefix}{OIDC_CALLBACK_SUFFIX}")) + || path == format!("{prefix}{OIDC_LOGOUT_SUFFIX}") }) } @@ -484,10 +491,11 @@ impl Operation for OidcCallbackHandler { 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}")) - })?; + let (claims, actual_provider_id, session, id_token) = + 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: {})", @@ -508,6 +516,11 @@ impl Operation for OidcCallbackHandler { // through AssumeRoleWithWebIdentity. let new_cred = create_oidc_sts_credentials(&claims, &actual_provider_id, &policies, &groups, 3600, None).await?; + let logout_token = oidc_sys + .create_logout_token(&actual_provider_id, &id_token) + .await + .map_err(|e| S3Error::with_message(S3ErrorCode::InternalError, format!("logout session creation failed: {e}")))?; + // Build redirect URL to console with credentials in the fragment let console_redirect = build_console_redirect( &req, @@ -516,6 +529,7 @@ impl Operation for OidcCallbackHandler { &new_cred.session_token, new_cred.expiration, session.redirect_after.as_deref(), + Some(logout_token.as_str()), )?; let mut resp = S3Response::new((StatusCode::FOUND, Body::empty())); @@ -529,6 +543,35 @@ impl Operation for OidcCallbackHandler { } } +/// Handler: GET /rustfs/admin/v3/oidc/logout?logout_token=... +/// Consumes the logout token and redirects either to the IdP end-session URL +/// or back to the console login page when federated logout is unavailable. +pub struct OidcLogoutHandler {} + +#[async_trait::async_trait] +impl Operation for OidcLogoutHandler { + async fn call(&self, req: S3Request, _params: Params<'_, '_>) -> S3Result> { + let fallback_location = build_console_login_redirect(&req)?; + let Some(logout_token) = extract_query_param(&req.uri, "logout_token") else { + return redirect_response(&fallback_location); + }; + + let location = match rustfs_iam::get_oidc() { + Some(oidc_sys) => match oidc_sys.build_logout_url(&logout_token, &fallback_location).await { + Ok(Some(url)) => url, + Ok(None) => fallback_location.clone(), + Err(err) => { + warn!("OIDC logout fallback triggered: {}", err); + fallback_location.clone() + } + }, + None => fallback_location.clone(), + }; + + redirect_response(&location) + } +} + /// 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 @@ -589,25 +632,20 @@ fn extract_safe_redirect_after(uri: &http::Uri) -> S3Result> { } /// Build the console redirect URL with STS credentials in the hash fragment. -fn build_console_redirect( - req: &S3Request, +fn build_console_callback_fragment( access_key: &str, secret_key: &str, session_token: &str, expiration: Option, redirect_after: Option<&str>, -) -> S3Result { - let scheme = extract_request_scheme(req)?; - let host = extract_request_host(req)?; - - let console_prefix = "/rustfs/console"; + logout_token: Option<&str>, +) -> String { 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!( + let mut fragment = format!( "accessKey={}&secretKey={}&sessionToken={}&expiration={}&redirect={}", urlencoding::encode(access_key), urlencoding::encode(secret_key), @@ -616,9 +654,50 @@ fn build_console_redirect( urlencoding::encode(page), ); + if let Some(logout_token) = logout_token.filter(|value| !value.is_empty()) { + fragment.push_str("&logoutToken="); + fragment.push_str(&urlencoding::encode(logout_token)); + } + + fragment +} + +/// Build the console redirect URL with STS credentials in the hash fragment. +fn build_console_redirect( + req: &S3Request, + access_key: &str, + secret_key: &str, + session_token: &str, + expiration: Option, + redirect_after: Option<&str>, + logout_token: Option<&str>, +) -> S3Result { + let scheme = extract_request_scheme(req)?; + let host = extract_request_host(req)?; + let console_prefix = "/rustfs/console"; + let fragment = + build_console_callback_fragment(access_key, secret_key, session_token, expiration, redirect_after, logout_token); + Ok(format!("{scheme}://{host}{console_prefix}/auth/oidc-callback/#{fragment}")) } +fn build_console_login_redirect(req: &S3Request) -> S3Result { + let scheme = extract_request_scheme(req)?; + let host = extract_request_host(req)?; + Ok(format!("{scheme}://{host}/rustfs/console/auth/login")) +} + +fn redirect_response(location: &str) -> S3Result> { + let mut resp = S3Response::new((StatusCode::FOUND, Body::empty())); + resp.headers.insert( + http::header::LOCATION, + location + .parse() + .map_err(|_| s3_error!(InternalError, "failed to construct redirect URL"))?, + ); + Ok(resp) +} + async fn authorize_oidc_config_request(req: &S3Request, action: AdminAction) -> S3Result<()> { let Some(input_cred) = &req.credentials else { return Err(s3_error!(InvalidRequest, "authentication required")); @@ -1087,6 +1166,22 @@ mod tests { assert!(extract_safe_redirect_after(&uri).is_err()); } + #[test] + fn test_build_console_callback_fragment_includes_logout_token() { + let fragment = + build_console_callback_fragment("access", "secret", "token", None, Some("/dashboard"), Some("logout-token")); + + assert!(fragment.contains("accessKey=access")); + assert!(fragment.contains("redirect=%2Fdashboard")); + assert!(fragment.contains("logoutToken=logout-token")); + } + + #[test] + fn test_is_oidc_path_includes_logout() { + assert!(is_oidc_path("/rustfs/admin/v3/oidc/logout")); + assert!(is_oidc_path("/minio/admin/v3/oidc/logout")); + } + #[test] fn test_is_valid_provider_id() { assert!(is_valid_provider_id("AUTHENTIK")); diff --git a/rustfs/src/admin/route_registration_test.rs b/rustfs/src/admin/route_registration_test.rs index 645f801a5..9dbfc1517 100644 --- a/rustfs/src/admin/route_registration_test.rs +++ b/rustfs/src/admin/route_registration_test.rs @@ -159,6 +159,7 @@ fn test_register_routes_cover_representative_admin_paths() { assert_route(&router, Method::POST, &admin_path("/v3/oidc/validate")); assert_route(&router, Method::GET, &admin_path("/v3/oidc/authorize/default")); assert_route(&router, Method::GET, &admin_path("/v3/oidc/callback/default")); + assert_route(&router, Method::GET, &admin_path("/v3/oidc/logout")); assert!( !router.contains_route(Method::GET, "/rustfs/rpc/read_file_stream"), @@ -200,6 +201,7 @@ fn test_admin_alias_paths_match_existing_admin_routes() { (Method::GET, compat_admin_alias_path("/v3/oidc/providers")), (Method::GET, compat_admin_alias_path("/v3/oidc/authorize/default")), (Method::GET, compat_admin_alias_path("/v3/oidc/callback/default")), + (Method::GET, compat_admin_alias_path("/v3/oidc/logout")), (Method::GET, compat_admin_alias_path("/v3/oidc/config")), (Method::PUT, compat_admin_alias_path("/v3/oidc/config/default")), (Method::PUT, compat_admin_alias_path("/v3/site-replication/add")),