mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-31 01:09:23 +00:00
fix(oidc): add federated logout flow (#2667)
Co-authored-by: GatewayJ <835269233@qq.com>
This commit is contained in:
+64
-8
@@ -18,11 +18,11 @@
|
|||||||
//! `openidconnect` crate for standards-compliant discovery, token exchange,
|
//! `openidconnect` crate for standards-compliant discovery, token exchange,
|
||||||
//! and ID token verification.
|
//! and ID token verification.
|
||||||
|
|
||||||
use crate::oidc_state::{OidcAuthSession, OidcStateStore};
|
use crate::oidc_state::{OidcAuthSession, OidcLogoutSession, OidcStateStore};
|
||||||
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreProviderMetadata};
|
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken};
|
||||||
use openidconnect::{
|
use openidconnect::{
|
||||||
AsyncHttpClient, Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce,
|
AsyncHttpClient, Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, LogoutRequest, Nonce,
|
||||||
PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
|
PkceCodeChallenge, PkceCodeVerifier, PostLogoutRedirectUrl, ProviderMetadataWithLogout, RedirectUrl, Scope,
|
||||||
};
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use rustfs_config::oidc::*;
|
use rustfs_config::oidc::*;
|
||||||
@@ -216,7 +216,7 @@ pub struct OidcClaims {
|
|||||||
/// on-the-fly from metadata when needed.
|
/// on-the-fly from metadata when needed.
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ProviderState {
|
struct ProviderState {
|
||||||
metadata: CoreProviderMetadata,
|
metadata: ProviderMetadataWithLogout,
|
||||||
discovered_at: Instant,
|
discovered_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +364,7 @@ impl OidcSys {
|
|||||||
state: &str,
|
state: &str,
|
||||||
code: &str,
|
code: &str,
|
||||||
redirect_uri: &str,
|
redirect_uri: &str,
|
||||||
) -> Result<(OidcClaims, String, OidcAuthSession), String> {
|
) -> Result<(OidcClaims, String, OidcAuthSession, String), String> {
|
||||||
// Retrieve and consume the state (single-use)
|
// Retrieve and consume the state (single-use)
|
||||||
let session = self
|
let session = self
|
||||||
.state_store
|
.state_store
|
||||||
@@ -449,7 +449,63 @@ impl OidcSys {
|
|||||||
raw,
|
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<String, String> {
|
||||||
|
if !self.configs.contains_key(provider_id) {
|
||||||
|
return Err(format!("unknown OIDC provider: {provider_id}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = CsrfToken::new_random().secret().clone();
|
||||||
|
self.state_store
|
||||||
|
.insert_logout(
|
||||||
|
token.clone(),
|
||||||
|
OidcLogoutSession {
|
||||||
|
provider_id: provider_id.to_string(),
|
||||||
|
id_token: id_token.to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the RP-initiated logout URL for a previously issued logout token.
|
||||||
|
/// Returns `Ok(None)` when the provider does not advertise an end-session endpoint.
|
||||||
|
pub async fn build_logout_url(&self, logout_token: &str, post_logout_redirect_uri: &str) -> Result<Option<String>, String> {
|
||||||
|
let session = self
|
||||||
|
.state_store
|
||||||
|
.take_logout(logout_token)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| "invalid or expired OIDC logout token".to_string())?;
|
||||||
|
|
||||||
|
let config = self
|
||||||
|
.configs
|
||||||
|
.get(&session.provider_id)
|
||||||
|
.ok_or_else(|| format!("unknown OIDC provider: {}", session.provider_id))?;
|
||||||
|
let state = self.ensure_provider_state(&session.provider_id, config).await?;
|
||||||
|
let Some(end_session_endpoint) = state.metadata.additional_metadata().end_session_endpoint.clone() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let id_token: CoreIdToken = session
|
||||||
|
.id_token
|
||||||
|
.parse()
|
||||||
|
.map_err(|e: serde_json::Error| format!("failed to parse ID token for logout: {e}"))?;
|
||||||
|
let post_logout_redirect_uri = PostLogoutRedirectUrl::new(post_logout_redirect_uri.to_string())
|
||||||
|
.map_err(|e| format!("invalid post logout redirect URI: {e}"))?;
|
||||||
|
|
||||||
|
let logout_url = LogoutRequest::from(end_session_endpoint)
|
||||||
|
.set_id_token_hint(&id_token)
|
||||||
|
.set_client_id(ClientId::new(config.client_id.clone()))
|
||||||
|
.set_post_logout_redirect_uri(post_logout_redirect_uri)
|
||||||
|
.http_get_url()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
Ok(Some(logout_url))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map OIDC claims to rustfs policy names.
|
/// 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}"))?;
|
let issuer_url = IssuerUrl::new(candidate_issuer.clone()).map_err(|e| format!("invalid issuer URL: {e}"))?;
|
||||||
|
|
||||||
for attempt in 0..OIDC_DISCOVERY_TRANSPORT_RETRIES {
|
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
|
.await
|
||||||
.map_err(|e| format!("discovery failed: {e}"))
|
.map_err(|e| format!("discovery failed: {e}"))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,11 +32,20 @@ pub struct OidcAuthSession {
|
|||||||
pub redirect_after: Option<String>,
|
pub redirect_after: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
/// 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).
|
/// Entries expire after 5 minutes and are single-use (removed on retrieval).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct OidcStateStore {
|
pub struct OidcStateStore {
|
||||||
cache: Cache<String, OidcAuthSession>,
|
cache: Cache<String, OidcAuthSession>,
|
||||||
|
logout_cache: Cache<String, OidcLogoutSession>,
|
||||||
last_capacity_log_at: Arc<AtomicU64>,
|
last_capacity_log_at: Arc<AtomicU64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,8 +55,13 @@ impl OidcStateStore {
|
|||||||
.max_capacity(OIDC_STATE_CAPACITY)
|
.max_capacity(OIDC_STATE_CAPACITY)
|
||||||
.time_to_live(Duration::from_secs(300)) // 5 minute TTL
|
.time_to_live(Duration::from_secs(300)) // 5 minute TTL
|
||||||
.build();
|
.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 {
|
Self {
|
||||||
cache,
|
cache,
|
||||||
|
logout_cache,
|
||||||
last_capacity_log_at: Arc::new(AtomicU64::new(0)),
|
last_capacity_log_at: Arc::new(AtomicU64::new(0)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,6 +112,21 @@ impl OidcStateStore {
|
|||||||
pub async fn contains(&self, state: &str) -> bool {
|
pub async fn contains(&self, state: &str) -> bool {
|
||||||
self.cache.get(state).await.is_some()
|
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<OidcLogoutSession> {
|
||||||
|
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 {
|
impl Default for OidcStateStore {
|
||||||
@@ -167,4 +196,24 @@ mod tests {
|
|||||||
assert!(store.take(&format!("state_{i}")).await.is_none());
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ use url::Url;
|
|||||||
const OIDC_PUBLIC_PROVIDERS_SUFFIX: &str = "/v3/oidc/providers";
|
const OIDC_PUBLIC_PROVIDERS_SUFFIX: &str = "/v3/oidc/providers";
|
||||||
const OIDC_AUTHORIZE_SUFFIX: &str = "/v3/oidc/authorize/";
|
const OIDC_AUTHORIZE_SUFFIX: &str = "/v3/oidc/authorize/";
|
||||||
const OIDC_CALLBACK_SUFFIX: &str = "/v3/oidc/callback/";
|
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).
|
/// Validate that a provider ID contains only safe characters (alphanumeric, underscore, hyphen).
|
||||||
fn is_valid_provider_id(id: &str) -> bool {
|
fn is_valid_provider_id(id: &str) -> bool {
|
||||||
@@ -74,6 +75,11 @@ pub fn register_oidc_route(r: &mut S3Router<AdminOperation>) -> std::io::Result<
|
|||||||
&format!("{ADMIN_PREFIX}/v3/oidc/callback/{{provider_id}}"),
|
&format!("{ADMIN_PREFIX}/v3/oidc/callback/{{provider_id}}"),
|
||||||
AdminOperation(&OidcCallbackHandler {}),
|
AdminOperation(&OidcCallbackHandler {}),
|
||||||
)?;
|
)?;
|
||||||
|
r.insert(
|
||||||
|
Method::GET,
|
||||||
|
&format!("{ADMIN_PREFIX}{OIDC_LOGOUT_SUFFIX}"),
|
||||||
|
AdminOperation(&OidcLogoutHandler {}),
|
||||||
|
)?;
|
||||||
r.insert(
|
r.insert(
|
||||||
Method::GET,
|
Method::GET,
|
||||||
&format!("{ADMIN_PREFIX}/v3/oidc/config"),
|
&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 == format!("{prefix}{OIDC_PUBLIC_PROVIDERS_SUFFIX}")
|
||||||
|| path.starts_with(&format!("{prefix}{OIDC_AUTHORIZE_SUFFIX}"))
|
|| path.starts_with(&format!("{prefix}{OIDC_AUTHORIZE_SUFFIX}"))
|
||||||
|| path.starts_with(&format!("{prefix}{OIDC_CALLBACK_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)?;
|
let redirect_uri = derive_callback_uri(&req, provider_id)?;
|
||||||
|
|
||||||
// Exchange authorization code for tokens and extract claims
|
// 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| {
|
let (claims, actual_provider_id, session, id_token) =
|
||||||
error!("OIDC code exchange failed: {}", e);
|
oidc_sys.exchange_code(&state, &code, &redirect_uri).await.map_err(|e| {
|
||||||
S3Error::with_message(S3ErrorCode::AccessDenied, format!("code exchange failed: {e}"))
|
error!("OIDC code exchange failed: {}", e);
|
||||||
})?;
|
S3Error::with_message(S3ErrorCode::AccessDenied, format!("code exchange failed: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"OIDC login successful: username='{}', email='{}', sub='{}' (provider: {})",
|
"OIDC login successful: username='{}', email='{}', sub='{}' (provider: {})",
|
||||||
@@ -508,6 +516,11 @@ impl Operation for OidcCallbackHandler {
|
|||||||
// through AssumeRoleWithWebIdentity.
|
// through AssumeRoleWithWebIdentity.
|
||||||
let new_cred = create_oidc_sts_credentials(&claims, &actual_provider_id, &policies, &groups, 3600, None).await?;
|
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
|
// Build redirect URL to console with credentials in the fragment
|
||||||
let console_redirect = build_console_redirect(
|
let console_redirect = build_console_redirect(
|
||||||
&req,
|
&req,
|
||||||
@@ -516,6 +529,7 @@ impl Operation for OidcCallbackHandler {
|
|||||||
&new_cred.session_token,
|
&new_cred.session_token,
|
||||||
new_cred.expiration,
|
new_cred.expiration,
|
||||||
session.redirect_after.as_deref(),
|
session.redirect_after.as_deref(),
|
||||||
|
Some(logout_token.as_str()),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let mut resp = S3Response::new((StatusCode::FOUND, Body::empty()));
|
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<Body>, _params: Params<'_, '_>) -> S3Result<S3Response<(StatusCode, Body)>> {
|
||||||
|
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.
|
/// Derive the OIDC callback URI.
|
||||||
/// Uses the provider's configured redirect_uri if set, otherwise derives dynamically
|
/// Uses the provider's configured redirect_uri if set, otherwise derives dynamically
|
||||||
/// from request headers. For production deployments behind a reverse proxy, configuring
|
/// from request headers. For production deployments behind a reverse proxy, configuring
|
||||||
@@ -589,25 +632,20 @@ fn extract_safe_redirect_after(uri: &http::Uri) -> S3Result<Option<String>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Build the console redirect URL with STS credentials in the hash fragment.
|
/// Build the console redirect URL with STS credentials in the hash fragment.
|
||||||
fn build_console_redirect(
|
fn build_console_callback_fragment(
|
||||||
req: &S3Request<Body>,
|
|
||||||
access_key: &str,
|
access_key: &str,
|
||||||
secret_key: &str,
|
secret_key: &str,
|
||||||
session_token: &str,
|
session_token: &str,
|
||||||
expiration: Option<OffsetDateTime>,
|
expiration: Option<OffsetDateTime>,
|
||||||
redirect_after: Option<&str>,
|
redirect_after: Option<&str>,
|
||||||
) -> S3Result<String> {
|
logout_token: Option<&str>,
|
||||||
let scheme = extract_request_scheme(req)?;
|
) -> String {
|
||||||
let host = extract_request_host(req)?;
|
|
||||||
|
|
||||||
let console_prefix = "/rustfs/console";
|
|
||||||
let page = redirect_after.filter(|p| is_safe_redirect_path(p)).unwrap_or("/");
|
let page = redirect_after.filter(|p| is_safe_redirect_path(p)).unwrap_or("/");
|
||||||
|
|
||||||
let exp_str = expiration
|
let exp_str = expiration
|
||||||
.map(|e| e.format(&time::format_description::well_known::Rfc3339).unwrap_or_default())
|
.map(|e| e.format(&time::format_description::well_known::Rfc3339).unwrap_or_default())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let fragment = format!(
|
let mut fragment = format!(
|
||||||
"accessKey={}&secretKey={}&sessionToken={}&expiration={}&redirect={}",
|
"accessKey={}&secretKey={}&sessionToken={}&expiration={}&redirect={}",
|
||||||
urlencoding::encode(access_key),
|
urlencoding::encode(access_key),
|
||||||
urlencoding::encode(secret_key),
|
urlencoding::encode(secret_key),
|
||||||
@@ -616,9 +654,50 @@ fn build_console_redirect(
|
|||||||
urlencoding::encode(page),
|
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<Body>,
|
||||||
|
access_key: &str,
|
||||||
|
secret_key: &str,
|
||||||
|
session_token: &str,
|
||||||
|
expiration: Option<OffsetDateTime>,
|
||||||
|
redirect_after: Option<&str>,
|
||||||
|
logout_token: Option<&str>,
|
||||||
|
) -> S3Result<String> {
|
||||||
|
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}"))
|
Ok(format!("{scheme}://{host}{console_prefix}/auth/oidc-callback/#{fragment}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_console_login_redirect(req: &S3Request<Body>) -> S3Result<String> {
|
||||||
|
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<S3Response<(StatusCode, Body)>> {
|
||||||
|
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<Body>, action: AdminAction) -> S3Result<()> {
|
async fn authorize_oidc_config_request(req: &S3Request<Body>, action: AdminAction) -> S3Result<()> {
|
||||||
let Some(input_cred) = &req.credentials else {
|
let Some(input_cred) = &req.credentials else {
|
||||||
return Err(s3_error!(InvalidRequest, "authentication required"));
|
return Err(s3_error!(InvalidRequest, "authentication required"));
|
||||||
@@ -1087,6 +1166,22 @@ mod tests {
|
|||||||
assert!(extract_safe_redirect_after(&uri).is_err());
|
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]
|
#[test]
|
||||||
fn test_is_valid_provider_id() {
|
fn test_is_valid_provider_id() {
|
||||||
assert!(is_valid_provider_id("AUTHENTIK"));
|
assert!(is_valid_provider_id("AUTHENTIK"));
|
||||||
|
|||||||
@@ -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::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/authorize/default"));
|
||||||
assert_route(&router, Method::GET, &admin_path("/v3/oidc/callback/default"));
|
assert_route(&router, Method::GET, &admin_path("/v3/oidc/callback/default"));
|
||||||
|
assert_route(&router, Method::GET, &admin_path("/v3/oidc/logout"));
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!router.contains_route(Method::GET, "/rustfs/rpc/read_file_stream"),
|
!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/providers")),
|
||||||
(Method::GET, compat_admin_alias_path("/v3/oidc/authorize/default")),
|
(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/callback/default")),
|
||||||
|
(Method::GET, compat_admin_alias_path("/v3/oidc/logout")),
|
||||||
(Method::GET, compat_admin_alias_path("/v3/oidc/config")),
|
(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/oidc/config/default")),
|
||||||
(Method::PUT, compat_admin_alias_path("/v3/site-replication/add")),
|
(Method::PUT, compat_admin_alias_path("/v3/site-replication/add")),
|
||||||
|
|||||||
Reference in New Issue
Block a user