mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-27 15:37:02 +00:00
fix(iam): route issuer-relative JWKS through config URL (#5150)
This commit is contained in:
+111
-17
@@ -21,9 +21,9 @@
|
|||||||
use crate::oidc_state::{OidcAuthSession, OidcLogoutSession, OidcStateStore};
|
use crate::oidc_state::{OidcAuthSession, OidcLogoutSession, OidcStateStore};
|
||||||
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreJsonWebKeySet};
|
use openidconnect::core::{CoreAuthenticationFlow, CoreClient, CoreIdToken, CoreJsonWebKeySet};
|
||||||
use openidconnect::{
|
use openidconnect::{
|
||||||
AsyncHttpClient, Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, LogoutRequest, Nonce,
|
AsyncHttpClient, Audience, AuthType, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, JsonWebKeySetUrl,
|
||||||
PkceCodeChallenge, PkceCodeVerifier, PostLogoutRedirectUrl, ProviderMetadataWithLogout, RedirectUrl, RequestTokenError,
|
LogoutRequest, Nonce, PkceCodeChallenge, PkceCodeVerifier, PostLogoutRedirectUrl, ProviderMetadataWithLogout, RedirectUrl,
|
||||||
Scope,
|
RequestTokenError, Scope,
|
||||||
};
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use rustfs_config::oidc::*;
|
use rustfs_config::oidc::*;
|
||||||
@@ -1683,7 +1683,8 @@ impl OidcSys {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
let jwks = CoreJsonWebKeySet::fetch_async(provider_metadata.jwks_uri(), http_client)
|
let jwks_url = jwks_url_from_config_url(&config.config_url, &issuer_url, provider_metadata.jwks_uri())?;
|
||||||
|
let jwks = CoreJsonWebKeySet::fetch_async(&jwks_url, http_client)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| format!("failed to fetch JWKS: {err}"))?;
|
.map_err(|err| format!("failed to fetch JWKS: {err}"))?;
|
||||||
|
|
||||||
@@ -1833,6 +1834,33 @@ fn discovery_url_from_config_url(config_url: &str) -> Result<Url, String> {
|
|||||||
Ok(url)
|
Ok(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn jwks_url_from_config_url(
|
||||||
|
config_url: &str,
|
||||||
|
issuer_url: &IssuerUrl,
|
||||||
|
jwks_url: &JsonWebKeySetUrl,
|
||||||
|
) -> Result<JsonWebKeySetUrl, String> {
|
||||||
|
let issuer = issuer_url.url();
|
||||||
|
let jwks = jwks_url.url();
|
||||||
|
if issuer.origin() != jwks.origin() {
|
||||||
|
return Ok(jwks_url.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let issuer_path = issuer.path().trim_end_matches('/');
|
||||||
|
let Some(suffix) = jwks.path().strip_prefix(issuer_path) else {
|
||||||
|
return Ok(jwks_url.clone());
|
||||||
|
};
|
||||||
|
if !suffix.is_empty() && !suffix.starts_with('/') {
|
||||||
|
return Ok(jwks_url.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut internal_url =
|
||||||
|
Url::parse(&normalize_config_url(config_url)?).map_err(|err| format!("invalid config_url issuer base: {err}"))?;
|
||||||
|
let internal_path = internal_url.path().trim_end_matches('/');
|
||||||
|
internal_url.set_path(&format!("{internal_path}{suffix}"));
|
||||||
|
internal_url.set_query(jwks.query());
|
||||||
|
Ok(JsonWebKeySetUrl::from_url(internal_url))
|
||||||
|
}
|
||||||
|
|
||||||
fn issuer_candidates(base: &str) -> Vec<String> {
|
fn issuer_candidates(base: &str) -> Vec<String> {
|
||||||
let original = base.trim();
|
let original = base.trim();
|
||||||
let mut variants = Vec::with_capacity(2);
|
let mut variants = Vec::with_capacity(2);
|
||||||
@@ -2229,7 +2257,7 @@ mod tests {
|
|||||||
max_requests: usize,
|
max_requests: usize,
|
||||||
) -> Option<(String, std::thread::JoinHandle<()>)>
|
) -> Option<(String, std::thread::JoinHandle<()>)>
|
||||||
where
|
where
|
||||||
F: Fn(&str) -> String + Send + 'static,
|
F: Fn(&str) -> (String, String, String) + Send + 'static,
|
||||||
{
|
{
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
@@ -2249,12 +2277,12 @@ mod tests {
|
|||||||
Err(err) => panic!("test listener should bind: {err}"),
|
Err(err) => panic!("test listener should bind: {err}"),
|
||||||
};
|
};
|
||||||
let base = format!("http://{}", listener.local_addr().expect("listener local address should be available"));
|
let base = format!("http://{}", listener.local_addr().expect("listener local address should be available"));
|
||||||
let discovery_issuer = build_discovery_issuer(&base);
|
let (discovery_issuer, discovery_jwks_uri, expected_jwks_path) = build_discovery_issuer(&base);
|
||||||
let discovery_body = serde_json::json!({
|
let discovery_body = serde_json::json!({
|
||||||
"issuer": discovery_issuer,
|
"issuer": discovery_issuer,
|
||||||
"authorization_endpoint": format!("{base}/authorize"),
|
"authorization_endpoint": format!("{base}/authorize"),
|
||||||
"token_endpoint": format!("{base}/token"),
|
"token_endpoint": format!("{base}/token"),
|
||||||
"jwks_uri": format!("{base}/jwks"),
|
"jwks_uri": discovery_jwks_uri,
|
||||||
"response_types_supported": ["code"],
|
"response_types_supported": ["code"],
|
||||||
"response_modes_supported": ["query"],
|
"response_modes_supported": ["query"],
|
||||||
"subject_types_supported": ["public"],
|
"subject_types_supported": ["public"],
|
||||||
@@ -2325,7 +2353,7 @@ mod tests {
|
|||||||
|
|
||||||
let (status, body) = if path.contains("/.well-known/openid-configuration") {
|
let (status, body) = if path.contains("/.well-known/openid-configuration") {
|
||||||
(200, discovery_body.as_str())
|
(200, discovery_body.as_str())
|
||||||
} else if path.contains("/jwks") {
|
} else if path == expected_jwks_path {
|
||||||
(200, jwks_body)
|
(200, jwks_body)
|
||||||
} else {
|
} else {
|
||||||
(404, r#"{"error":"not found"}"#)
|
(404, r#"{"error":"not found"}"#)
|
||||||
@@ -2373,7 +2401,10 @@ mod tests {
|
|||||||
async fn test_validate_oidc_provider_config_retries_with_issuer_candidates() {
|
async fn test_validate_oidc_provider_config_retries_with_issuer_candidates() {
|
||||||
// Discovery document must advertise the canonical issuer path. The first candidate has no
|
// Discovery document must advertise the canonical issuer path. The first candidate has no
|
||||||
// trailing slash; openidconnect rejects issuer mismatch, then the second variant succeeds.
|
// trailing slash; openidconnect rejects issuer mismatch, then the second variant succeeds.
|
||||||
let Some((base, handle)) = start_mock_oidc_discovery_server(|base| format!("{base}/application/o/rustfs/"), 8) else {
|
let Some((base, handle)) = start_mock_oidc_discovery_server(
|
||||||
|
|base| (format!("{base}/application/o/rustfs/"), format!("{base}/jwks"), "/jwks".to_string()),
|
||||||
|
8,
|
||||||
|
) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let config_url = format!("{base}/application/o/rustfs");
|
let config_url = format!("{base}/application/o/rustfs");
|
||||||
@@ -2387,27 +2418,87 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_validate_oidc_provider_config_accepts_separate_issuer() {
|
async fn test_validate_oidc_provider_config_fetches_issuer_relative_jwks_from_config_url() {
|
||||||
let Some((base, handle)) = start_mock_oidc_discovery_server(|_| "https://public.example.com/realms/app".to_string(), 2)
|
let Some((base, handle)) = start_mock_oidc_discovery_server(
|
||||||
else {
|
|_| {
|
||||||
|
(
|
||||||
|
"http://127.0.0.1:1/public/realms/app".to_string(),
|
||||||
|
"http://127.0.0.1:1/public/realms/app/jwks?version=1".to_string(),
|
||||||
|
"/internal/realms/app/jwks?version=1".to_string(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
2,
|
||||||
|
) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut config =
|
let mut config =
|
||||||
build_mocked_oidc_provider_config("default", &format!("{base}/internal/realms/app/.well-known/openid-configuration"));
|
build_mocked_oidc_provider_config("default", &format!("{base}/internal/realms/app/.well-known/openid-configuration"));
|
||||||
config.issuer = Some("https://public.example.com/realms/app".to_string());
|
config.issuer = Some("http://127.0.0.1:1/public/realms/app".to_string());
|
||||||
|
|
||||||
let validation_result = validate_mocked_oidc_provider_config(&config)
|
let validation_result = validate_mocked_oidc_provider_config(&config)
|
||||||
.await
|
.await
|
||||||
.expect("OIDC provider validation should succeed");
|
.expect("OIDC provider validation should succeed");
|
||||||
|
|
||||||
assert_eq!(validation_result.issuer, "https://public.example.com/realms/app");
|
assert_eq!(validation_result.issuer, "http://127.0.0.1:1/public/realms/app");
|
||||||
assert!(handle.join().is_ok());
|
assert!(handle.join().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_jwks_url_from_config_url_preserves_unrelated_urls() {
|
||||||
|
let issuer = IssuerUrl::new("https://public.example.com/realms/app".to_string()).expect("issuer URL should parse");
|
||||||
|
for raw_jwks_url in [
|
||||||
|
"https://keys.example.com/jwks",
|
||||||
|
"http://public.example.com/realms/app/jwks",
|
||||||
|
"https://public.example.com:8443/realms/app/jwks",
|
||||||
|
"https://public.example.com/keys/jwks",
|
||||||
|
"https://public.example.com/realms/application/jwks",
|
||||||
|
] {
|
||||||
|
let jwks_url = JsonWebKeySetUrl::new(raw_jwks_url.to_string()).expect("JWKS URL should parse");
|
||||||
|
|
||||||
|
let resolved = jwks_url_from_config_url(
|
||||||
|
"http://keycloak.internal/realms/app/.well-known/openid-configuration",
|
||||||
|
&issuer,
|
||||||
|
&jwks_url,
|
||||||
|
)
|
||||||
|
.expect("JWKS URL should resolve");
|
||||||
|
|
||||||
|
assert_eq!(resolved.as_str(), raw_jwks_url);
|
||||||
|
}
|
||||||
|
|
||||||
|
let issuer_root_jwks = JsonWebKeySetUrl::new(issuer.as_str().to_string()).expect("JWKS URL should parse");
|
||||||
|
let resolved = jwks_url_from_config_url(
|
||||||
|
"http://keycloak.internal/realms/app/.well-known/openid-configuration",
|
||||||
|
&issuer,
|
||||||
|
&issuer_root_jwks,
|
||||||
|
)
|
||||||
|
.expect("issuer-root JWKS URL should resolve");
|
||||||
|
assert_eq!(resolved.as_str(), "http://keycloak.internal/realms/app");
|
||||||
|
|
||||||
|
let issuer_with_slash =
|
||||||
|
IssuerUrl::new("https://public.example.com/realms/app/".to_string()).expect("issuer URL should parse");
|
||||||
|
let jwks_url =
|
||||||
|
JsonWebKeySetUrl::new("https://public.example.com/realms/app/jwks".to_string()).expect("JWKS URL should parse");
|
||||||
|
let resolved = jwks_url_from_config_url(
|
||||||
|
"http://keycloak.internal/realms/app/.well-known/openid-configuration",
|
||||||
|
&issuer_with_slash,
|
||||||
|
&jwks_url,
|
||||||
|
)
|
||||||
|
.expect("issuer-relative JWKS URL should resolve");
|
||||||
|
assert_eq!(resolved.as_str(), "http://keycloak.internal/realms/app/jwks");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_validate_oidc_provider_config_rejects_separate_issuer_mismatch() {
|
async fn test_validate_oidc_provider_config_rejects_separate_issuer_mismatch() {
|
||||||
let Some((base, handle)) = start_mock_oidc_discovery_server(|_| "https://public.example.com/realms/other".to_string(), 1)
|
let Some((base, handle)) = start_mock_oidc_discovery_server(
|
||||||
else {
|
|base| {
|
||||||
|
(
|
||||||
|
"https://public.example.com/realms/other".to_string(),
|
||||||
|
format!("{base}/jwks"),
|
||||||
|
"/jwks".to_string(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
1,
|
||||||
|
) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let mut config =
|
let mut config =
|
||||||
@@ -2425,7 +2516,10 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_validate_oidc_provider_config_returns_detailed_errors() {
|
async fn test_validate_oidc_provider_config_returns_detailed_errors() {
|
||||||
let Some((base, handle)) = start_mock_oidc_discovery_server(|base| format!("{base}/application/o/other"), 8) else {
|
let Some((base, handle)) = start_mock_oidc_discovery_server(
|
||||||
|
|base| (format!("{base}/application/o/other"), format!("{base}/jwks"), "/jwks".to_string()),
|
||||||
|
8,
|
||||||
|
) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let config_url = format!("{base}/application/o/rustfs");
|
let config_url = format!("{base}/application/o/rustfs");
|
||||||
|
|||||||
@@ -149,6 +149,16 @@ export RUSTFS_IDENTITY_OPENID_EMAIL_CLAIM="email"
|
|||||||
export RUSTFS_IDENTITY_OPENID_USERNAME_CLAIM="preferred_username"
|
export RUSTFS_IDENTITY_OPENID_USERNAME_CLAIM="preferred_username"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If RustFS reaches Keycloak through an internal URL while tokens use a public issuer, configure both values:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RUSTFS_IDENTITY_OPENID_CONFIG_URL="http://keycloak.keycloak.svc.cluster.local:8080/realms/rustfs/.well-known/openid-configuration"
|
||||||
|
export RUSTFS_IDENTITY_OPENID_ISSUER="https://keycloak.example.com/realms/rustfs"
|
||||||
|
```
|
||||||
|
|
||||||
|
Discovery and issuer-relative JWKS requests use the internal `CONFIG_URL` base. ID token issuer validation still uses `ISSUER`.
|
||||||
|
Use HTTPS with a trusted CA for the internal URL whenever possible. Discovery and JWKS define the token-signing trust root; use HTTP only on a network where DNS and traffic cannot be tampered with, because a compromised response can authorize forged tokens.
|
||||||
|
|
||||||
For short-lived connectivity testing only, you may temporarily add:
|
For short-lived connectivity testing only, you may temporarily add:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -234,7 +244,7 @@ curl -fsS "https://keycloak.example.com/realms/rustfs/.well-known/openid-configu
|
|||||||
|
|
||||||
Check that:
|
Check that:
|
||||||
|
|
||||||
- `issuer` equals `RUSTFS_IDENTITY_OPENID_CONFIG_URL`
|
- `issuer` equals `RUSTFS_IDENTITY_OPENID_ISSUER` when set; otherwise it matches the issuer derived from `RUSTFS_IDENTITY_OPENID_CONFIG_URL`
|
||||||
- `authorization_endpoint`, `token_endpoint`, and `jwks_uri` are present
|
- `authorization_endpoint`, `token_endpoint`, and `jwks_uri` are present
|
||||||
- `code_challenge_methods_supported` includes `S256`
|
- `code_challenge_methods_supported` includes `S256`
|
||||||
- the token endpoint accepts client secret authentication compatible with request-body submission
|
- the token endpoint accepts client secret authentication compatible with request-body submission
|
||||||
|
|||||||
Reference in New Issue
Block a user