mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
fix(auth): harden OIDC trust root retrieval (#3332)
* fix(auth): harden OIDC trust root retrieval Signed-off-by: Mrunal Patel <mrunalp@gmail.com> * fix(e2e): pass OIDC HTTP acknowledgement value Signed-off-by: Mrunal Patel <mrunalp@gmail.com> --------- Signed-off-by: Mrunal Patel <mrunalp@gmail.com>
This commit is contained in:
@@ -256,15 +256,19 @@ Key Helm values:
|
||||
|
||||
### Keycloak OIDC
|
||||
|
||||
One-time setup — only needed once per cluster lifetime:
|
||||
Initial setup — rerun it whenever you want to rotate the development CA:
|
||||
|
||||
```bash
|
||||
mise run keycloak:k8s:setup
|
||||
```
|
||||
|
||||
This deploys Keycloak (`quay.io/keycloak/keycloak:24.0`) into the `keycloak` namespace,
|
||||
imports the openshell realm from `scripts/keycloak-realm.json`, and prints a port-forward
|
||||
command for acquiring tokens from the CLI.
|
||||
imports the openshell realm from `scripts/keycloak-realm.json`, generates a short-lived
|
||||
development TLS certificate, and publishes its trust anchor as the
|
||||
`openshell-keycloak-ca` ConfigMap in the OpenShell namespace. The command prints a
|
||||
port-forward command for acquiring tokens from the CLI. Rerunning setup rotates the
|
||||
development certificate and trust anchor; redeploy the gateway afterward so it reloads
|
||||
the mounted CA bundle.
|
||||
|
||||
Then activate OIDC in the OpenShell Helm chart:
|
||||
1. Uncomment `#- ci/values-keycloak.yaml` in `skaffold.yaml`
|
||||
@@ -355,4 +359,4 @@ for dependencies still declared in `Chart.yaml`.
|
||||
| `deploy/helm/openshell/ci/values-tls-disabled.yaml` | Lint-only: TLS + auth disabled (reverse-proxy edge termination) |
|
||||
| `deploy/kube/manifests/envoy-gateway-openshell.yaml` | GatewayClass for Envoy Gateway (`mise run helm:gateway:apply`) |
|
||||
| `tasks/scripts/helm-k3s-local.sh` | k3d cluster create/delete/start/stop/status |
|
||||
| `tasks/scripts/keycloak-k8s-setup.sh` | Keycloak deploy + realm import |
|
||||
| `tasks/scripts/keycloak-k8s-setup.sh` | Keycloak deploy, realm import, and development TLS trust anchor |
|
||||
|
||||
@@ -215,7 +215,7 @@ Supported auth modes:
|
||||
| Plaintext | Local development or a trusted reverse proxy boundary. |
|
||||
| Unauthenticated local users | Trusted Kubernetes dev or fully trusted proxy deployments only. |
|
||||
| Cloudflare JWT | Edge-authenticated deployments where Cloudflare Access supplies identity. |
|
||||
| OIDC | Bearer-token auth for users, with browser or device-code PKCE and client credentials login. JWKS validation accepts RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, and EdDSA (Ed25519) signing keys. |
|
||||
| OIDC | Bearer-token auth for users, with browser or device-code PKCE and client credentials login. Discovery and JWKS retrieval require HTTPS, reject redirects, and pin JWKS to the issuer origin or an explicit origin allowlist. JWKS validation accepts RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, and EdDSA (Ed25519) signing keys. |
|
||||
|
||||
The CLI persists the scopes requested during OIDC login in gateway metadata and
|
||||
reuses them when refreshing an access token. This preserves the intended API
|
||||
|
||||
@@ -319,9 +319,21 @@ pub struct TlsConfig {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OidcConfig {
|
||||
/// OIDC issuer URL (e.g., `http://localhost:8180/realms/openshell`).
|
||||
/// OIDC issuer URL (e.g., `https://idp.example.com/realms/openshell`).
|
||||
pub issuer: String,
|
||||
|
||||
/// Permit cleartext OIDC metadata and JWKS requests to numeric loopback
|
||||
/// addresses. This is a development-only escape hatch and never permits
|
||||
/// cleartext requests to hostnames or non-loopback addresses.
|
||||
#[serde(default)]
|
||||
pub dangerously_allow_insecure_http: bool,
|
||||
|
||||
/// Additional origins from which JWKS may be loaded. Entries must be
|
||||
/// origins such as `https://www.googleapis.com`, without a path, query,
|
||||
/// credentials, or fragment. The issuer origin is always allowed.
|
||||
#[serde(default)]
|
||||
pub jwks_allowed_origins: Vec<String>,
|
||||
|
||||
/// Expected audience (`aud`) claim. Typically the OIDC client ID.
|
||||
pub audience: String,
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ use openshell_core::OidcConfig;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::net::IpAddr;
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -52,6 +53,11 @@ const STALE_KEY_GRACE_MULTIPLIER: u32 = 3;
|
||||
/// against the issuer's JWKS endpoint.
|
||||
const KID_MISS_REFRESH_COOLDOWN: Duration = Duration::from_secs(1);
|
||||
|
||||
/// OIDC metadata is small. These limits bound memory consumption before JSON
|
||||
/// deserialization while leaving ample room for large enterprise key sets.
|
||||
const OIDC_DISCOVERY_MAX_BYTES: usize = 64 * 1024;
|
||||
const JWKS_MAX_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// Cached JWKS key set fetched from the OIDC issuer.
|
||||
///
|
||||
/// A `refresh_mutex` ensures that only one refresh runs at a time,
|
||||
@@ -92,6 +98,176 @@ struct OidcDiscovery {
|
||||
jwks_uri: String,
|
||||
}
|
||||
|
||||
fn is_numeric_loopback(host: &str) -> bool {
|
||||
match host.parse::<IpAddr>() {
|
||||
Ok(IpAddr::V4(address)) => address.is_loopback(),
|
||||
Ok(IpAddr::V6(address)) => {
|
||||
address.is_loopback()
|
||||
|| address
|
||||
.to_ipv4_mapped()
|
||||
.is_some_and(|mapped| mapped.is_loopback())
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_oidc_url(
|
||||
value: &str,
|
||||
description: &str,
|
||||
dangerously_allow_insecure_http: bool,
|
||||
) -> Result<url::Url, String> {
|
||||
let parsed = url::Url::parse(value)
|
||||
.map_err(|error| format!("{description} must be an absolute URL: {error}"))?;
|
||||
|
||||
if parsed.host_str().is_none() || !parsed.username().is_empty() || parsed.password().is_some() {
|
||||
return Err(format!(
|
||||
"{description} must include a host and must not include credentials"
|
||||
));
|
||||
}
|
||||
|
||||
match parsed.scheme() {
|
||||
"https" => Ok(parsed),
|
||||
"http"
|
||||
if dangerously_allow_insecure_http
|
||||
&& parsed.host_str().is_some_and(is_numeric_loopback) =>
|
||||
{
|
||||
Ok(parsed)
|
||||
}
|
||||
"http" if dangerously_allow_insecure_http => Err(format!(
|
||||
"{description} may use insecure HTTP only with a numeric loopback address"
|
||||
)),
|
||||
"http" => Err(format!(
|
||||
"{description} must use HTTPS; numeric-loopback HTTP requires the development-only dangerously_allow_insecure_http acknowledgement"
|
||||
)),
|
||||
scheme => Err(format!(
|
||||
"{description} must use HTTPS, not the '{scheme}' scheme"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_jwks_origin(
|
||||
value: &str,
|
||||
dangerously_allow_insecure_http: bool,
|
||||
) -> Result<url::Url, String> {
|
||||
let origin = validate_oidc_url(
|
||||
value,
|
||||
"OIDC JWKS allowed origin",
|
||||
dangerously_allow_insecure_http,
|
||||
)?;
|
||||
if origin.path() != "/" || origin.query().is_some() || origin.fragment().is_some() {
|
||||
return Err(format!(
|
||||
"OIDC JWKS allowed origin '{value}' must not include a path, query, or fragment"
|
||||
));
|
||||
}
|
||||
Ok(origin)
|
||||
}
|
||||
|
||||
fn validate_jwks_url(
|
||||
value: &str,
|
||||
issuer: &url::Url,
|
||||
config: &OidcConfig,
|
||||
) -> Result<url::Url, String> {
|
||||
let jwks = validate_oidc_url(
|
||||
value,
|
||||
"OIDC discovery jwks_uri",
|
||||
config.dangerously_allow_insecure_http,
|
||||
)?;
|
||||
if jwks.fragment().is_some() {
|
||||
return Err("OIDC discovery jwks_uri must not include a fragment".to_string());
|
||||
}
|
||||
if jwks.origin() == issuer.origin() {
|
||||
return Ok(jwks);
|
||||
}
|
||||
|
||||
for allowed in &config.jwks_allowed_origins {
|
||||
let allowed = validate_jwks_origin(allowed, config.dangerously_allow_insecure_http)?;
|
||||
if jwks.origin() == allowed.origin() {
|
||||
return Ok(jwks);
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"OIDC discovery jwks_uri origin '{}' does not match issuer origin '{}'; add the JWKS origin to jwks_allowed_origins only if it is trusted",
|
||||
jwks.origin().ascii_serialization(),
|
||||
issuer.origin().ascii_serialization(),
|
||||
))
|
||||
}
|
||||
|
||||
fn is_json_content_type(value: &reqwest::header::HeaderValue) -> bool {
|
||||
let Ok(value) = value.to_str() else {
|
||||
return false;
|
||||
};
|
||||
let media_type = value.split(';').next().map(str::trim).unwrap_or_default();
|
||||
media_type.eq_ignore_ascii_case("application/json")
|
||||
|| media_type
|
||||
.to_ascii_lowercase()
|
||||
.strip_prefix("application/")
|
||||
.is_some_and(|subtype| subtype.ends_with("+json"))
|
||||
}
|
||||
|
||||
async fn fetch_bounded_json<T: serde::de::DeserializeOwned>(
|
||||
client: &Client,
|
||||
url: &url::Url,
|
||||
description: &str,
|
||||
max_bytes: usize,
|
||||
) -> Result<T, String> {
|
||||
let mut response = client
|
||||
.get(url.clone())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("{description} request failed: {error}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"{description} request returned HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
if !response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.is_some_and(is_json_content_type)
|
||||
{
|
||||
return Err(format!(
|
||||
"{description} response must use an application/json or application/*+json content type"
|
||||
));
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > max_bytes as u64)
|
||||
{
|
||||
return Err(format!(
|
||||
"{description} response exceeds the {max_bytes}-byte limit"
|
||||
));
|
||||
}
|
||||
|
||||
let initial_capacity = response
|
||||
.content_length()
|
||||
.and_then(|length| usize::try_from(length).ok())
|
||||
.unwrap_or_default()
|
||||
.min(max_bytes);
|
||||
let mut body = Vec::with_capacity(initial_capacity);
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|error| format!("{description} response body failed: {error}"))?
|
||||
{
|
||||
let next_length = body
|
||||
.len()
|
||||
.checked_add(chunk.len())
|
||||
.ok_or_else(|| format!("{description} response length overflow"))?;
|
||||
if next_length > max_bytes {
|
||||
return Err(format!(
|
||||
"{description} response exceeds the {max_bytes}-byte limit"
|
||||
));
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
serde_json::from_slice(&body)
|
||||
.map_err(|error| format!("{description} response parse failed: {error}"))
|
||||
}
|
||||
|
||||
/// JWKS key set.
|
||||
#[derive(Deserialize)]
|
||||
struct JwkSet {
|
||||
@@ -399,6 +575,17 @@ impl JwksCache {
|
||||
/// Create a new JWKS cache, discovering the JWKS URI and fetching the
|
||||
/// initial key set.
|
||||
pub async fn new(config: &OidcConfig) -> Result<Self, String> {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let http = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|e| format!("failed to create HTTP client: {e}"))?;
|
||||
|
||||
Self::new_with_client(config, http).await
|
||||
}
|
||||
|
||||
async fn new_with_client(config: &OidcConfig, http: Client) -> Result<Self, String> {
|
||||
if config.jwks_ttl_secs == 0 {
|
||||
return Err(
|
||||
"jwks_ttl_secs must be greater than zero (0 would refresh on every request and \
|
||||
@@ -407,27 +594,35 @@ impl JwksCache {
|
||||
);
|
||||
}
|
||||
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let http = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| format!("failed to create HTTP client: {e}"))?;
|
||||
let issuer_url = validate_oidc_url(
|
||||
&config.issuer,
|
||||
"OIDC issuer",
|
||||
config.dangerously_allow_insecure_http,
|
||||
)?;
|
||||
if issuer_url.query().is_some() || issuer_url.fragment().is_some() {
|
||||
return Err("OIDC issuer must not include a query or fragment".to_string());
|
||||
}
|
||||
// Validate every configured exception at startup, even when the
|
||||
// discovery document ultimately uses the issuer origin.
|
||||
for allowed in &config.jwks_allowed_origins {
|
||||
validate_jwks_origin(allowed, config.dangerously_allow_insecure_http)?;
|
||||
}
|
||||
|
||||
// Discover JWKS URI from the OIDC discovery endpoint.
|
||||
let discovery_url = format!(
|
||||
let discovery_url = url::Url::parse(&format!(
|
||||
"{}/.well-known/openid-configuration",
|
||||
config.issuer.trim_end_matches('/')
|
||||
);
|
||||
))
|
||||
.map_err(|error| format!("failed to construct OIDC discovery URL: {error}"))?;
|
||||
info!(url = %discovery_url, "Discovering OIDC configuration");
|
||||
|
||||
let discovery: OidcDiscovery = http
|
||||
.get(&discovery_url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("OIDC discovery request failed: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("OIDC discovery response parse failed: {e}"))?;
|
||||
let discovery: OidcDiscovery = fetch_bounded_json(
|
||||
&http,
|
||||
&discovery_url,
|
||||
"OIDC discovery",
|
||||
OIDC_DISCOVERY_MAX_BYTES,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Validate the discovery document's issuer matches our configured issuer.
|
||||
let expected = config.issuer.trim_end_matches('/');
|
||||
@@ -438,11 +633,13 @@ impl JwksCache {
|
||||
));
|
||||
}
|
||||
|
||||
info!(jwks_uri = %discovery.jwks_uri, "OIDC JWKS URI discovered");
|
||||
let jwks_uri = validate_jwks_url(&discovery.jwks_uri, &issuer_url, config)?;
|
||||
|
||||
info!(jwks_uri = %jwks_uri, "OIDC JWKS URI discovered");
|
||||
|
||||
let cache = Self {
|
||||
keys: Arc::new(RwLock::new(HashMap::new())),
|
||||
jwks_uri: discovery.jwks_uri,
|
||||
jwks_uri: jwks_uri.to_string(),
|
||||
ttl: Duration::from_secs(config.jwks_ttl_secs),
|
||||
last_refresh: Arc::new(RwLock::new(
|
||||
Instant::now()
|
||||
@@ -474,15 +671,10 @@ impl JwksCache {
|
||||
async fn refresh_keys(&self) -> Result<(), String> {
|
||||
debug!(uri = %self.jwks_uri, "Refreshing JWKS keys");
|
||||
|
||||
let jwk_set: JwkSet = self
|
||||
.http
|
||||
.get(&self.jwks_uri)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("JWKS fetch failed: {e}"))?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("JWKS parse failed: {e}"))?;
|
||||
let jwks_uri = url::Url::parse(&self.jwks_uri)
|
||||
.map_err(|error| format!("cached JWKS URI became invalid: {error}"))?;
|
||||
let jwk_set: JwkSet =
|
||||
fetch_bounded_json(&self.http, &jwks_uri, "JWKS", JWKS_MAX_BYTES).await?;
|
||||
|
||||
let mut new_keys = HashMap::new();
|
||||
let mut poisoned_kids = HashSet::new();
|
||||
@@ -716,6 +908,292 @@ impl Authenticator for OidcAuthenticator {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn transport_test_config(issuer: impl Into<String>) -> OidcConfig {
|
||||
OidcConfig {
|
||||
issuer: issuer.into(),
|
||||
dangerously_allow_insecure_http: false,
|
||||
jwks_allowed_origins: Vec::new(),
|
||||
audience: "test-audience".to_string(),
|
||||
jwks_ttl_secs: 3600,
|
||||
roles_claim: "roles".to_string(),
|
||||
admin_role: "admin".to_string(),
|
||||
user_role: "user".to_string(),
|
||||
scopes_claim: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oidc_rejects_http_issuer_without_development_acknowledgement() {
|
||||
let error = JwksCache::new(&transport_test_config("http://127.0.0.1:9/issuer"))
|
||||
.await
|
||||
.expect_err("cleartext issuers must fail before discovery");
|
||||
|
||||
assert!(
|
||||
error.contains("must use HTTPS"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oidc_rejects_non_loopback_http_even_when_acknowledged() {
|
||||
let mut config = transport_test_config("http://192.0.2.1/issuer");
|
||||
config.dangerously_allow_insecure_http = true;
|
||||
|
||||
let error = JwksCache::new(&config)
|
||||
.await
|
||||
.expect_err("the escape hatch must remain limited to numeric loopback");
|
||||
|
||||
assert!(
|
||||
error.contains("only with a numeric loopback"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_rejects_http_jwks_from_https_discovery() {
|
||||
let issuer = url::Url::parse("https://idp.example.com/issuer").unwrap();
|
||||
let config = transport_test_config(issuer.as_str());
|
||||
|
||||
let error = validate_jwks_url("http://idp.example.com/jwks", &issuer, &config)
|
||||
.expect_err("HTTPS discovery must not establish an HTTP trust root");
|
||||
|
||||
assert!(
|
||||
error.contains("must use HTTPS"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_rejects_cross_origin_jwks_unless_origin_is_allowlisted() {
|
||||
let issuer = url::Url::parse("https://accounts.example.com/issuer").unwrap();
|
||||
let mut config = transport_test_config(issuer.as_str());
|
||||
let jwks = "https://keys.example.com/oauth/jwks";
|
||||
|
||||
let error = validate_jwks_url(jwks, &issuer, &config)
|
||||
.expect_err("untrusted cross-origin JWKS must be rejected");
|
||||
assert!(
|
||||
error.contains("does not match issuer origin"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
|
||||
config.jwks_allowed_origins = vec!["https://keys.example.com".to_string()];
|
||||
assert_eq!(
|
||||
validate_jwks_url(jwks, &issuer, &config)
|
||||
.expect("an explicitly trusted origin should be accepted")
|
||||
.as_str(),
|
||||
jwks
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oidc_json_content_types_are_strict() {
|
||||
use reqwest::header::HeaderValue;
|
||||
|
||||
assert!(is_json_content_type(&HeaderValue::from_static(
|
||||
"application/json; charset=utf-8"
|
||||
)));
|
||||
assert!(is_json_content_type(&HeaderValue::from_static(
|
||||
"application/jwk-set+json"
|
||||
)));
|
||||
assert!(!is_json_content_type(&HeaderValue::from_static(
|
||||
"text/html"
|
||||
)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oidc_loads_discovery_and_jwks_from_tls_idp_with_rotated_local_ca() {
|
||||
use rcgen::{BasicConstraints, CertificateParams, IsCa, KeyPair};
|
||||
use rustls::pki_types::PrivateKeyDer;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let mut old_ca_params = CertificateParams::new(Vec::<String>::new()).unwrap();
|
||||
old_ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
let old_ca_key = KeyPair::generate().unwrap();
|
||||
let old_ca_cert = old_ca_params.self_signed(&old_ca_key).unwrap();
|
||||
|
||||
let mut ca_params = CertificateParams::new(Vec::<String>::new()).unwrap();
|
||||
ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
let ca_key = KeyPair::generate().unwrap();
|
||||
let ca_cert = ca_params.self_signed(&ca_key).unwrap();
|
||||
|
||||
let server_params = CertificateParams::new(vec!["localhost".to_string()]).unwrap();
|
||||
let server_key = KeyPair::generate().unwrap();
|
||||
let server_cert = server_params
|
||||
.signed_by(&server_key, &ca_cert, &ca_key)
|
||||
.unwrap();
|
||||
let server_config = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
vec![server_cert.der().clone()],
|
||||
PrivateKeyDer::Pkcs8(server_key.serialize_der().into()),
|
||||
)
|
||||
.unwrap();
|
||||
let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let issuer = format!("https://localhost:{port}/issuer");
|
||||
let discovery = serde_json::json!({
|
||||
"issuer": issuer,
|
||||
"jwks_uri": format!("{issuer}/jwks"),
|
||||
})
|
||||
.to_string();
|
||||
let jwks = serde_json::json!({
|
||||
"keys": [{
|
||||
"kid": TEST_KID,
|
||||
"kty": "RSA",
|
||||
"n": TEST_RSA_KEY.modulus_b64,
|
||||
"e": TEST_RSA_KEY.exponent_b64,
|
||||
}],
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let server = tokio::spawn(async move {
|
||||
let mut responses_served = 0;
|
||||
while responses_served < 2 {
|
||||
let (stream, _) = listener.accept().await.unwrap();
|
||||
let Ok(mut stream) = acceptor.accept(stream).await else {
|
||||
// A client with the retired CA aborts the TLS handshake.
|
||||
continue;
|
||||
};
|
||||
responses_served += 1;
|
||||
let mut request = Vec::new();
|
||||
let mut chunk = [0_u8; 1024];
|
||||
loop {
|
||||
let read = stream.read(&mut chunk).await.unwrap();
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&chunk[..read]);
|
||||
if request.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8_lossy(&request);
|
||||
let body = if request.starts_with("GET /issuer/.well-known/openid-configuration ") {
|
||||
&discovery
|
||||
} else if request.starts_with("GET /issuer/jwks ") {
|
||||
&jwks
|
||||
} else {
|
||||
panic!("unexpected TLS IdP request: {request}");
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
let old_client = Client::builder()
|
||||
.add_root_certificate(
|
||||
reqwest::Certificate::from_pem(old_ca_cert.pem().as_bytes()).unwrap(),
|
||||
)
|
||||
.build()
|
||||
.unwrap();
|
||||
old_client
|
||||
.get(format!("{issuer}/.well-known/openid-configuration"))
|
||||
.send()
|
||||
.await
|
||||
.expect_err("a client retaining only the retired CA must reject the IdP");
|
||||
|
||||
// Initialization succeeds after the client receives the rotated trust
|
||||
// anchor, and both discovery and JWKS stay on the authenticated channel.
|
||||
let client = Client::builder()
|
||||
.add_root_certificate(reqwest::Certificate::from_pem(ca_cert.pem().as_bytes()).unwrap())
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.unwrap();
|
||||
let config = transport_test_config(issuer.clone());
|
||||
let cache = JwksCache::new_with_client(&config, client)
|
||||
.await
|
||||
.expect("TLS discovery and JWKS should accept the rotated CA");
|
||||
|
||||
let token = mint_rs256(
|
||||
&claims_for(&issuer, "test-audience", now_secs() + 3600),
|
||||
TEST_KID,
|
||||
);
|
||||
cache
|
||||
.validate_token(&token)
|
||||
.await
|
||||
.expect("the key loaded over TLS should validate tokens");
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oidc_rejects_redirected_discovery() {
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/issuer/.well-known/openid-configuration"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(302).append_header("location", "http://127.0.0.1:9"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut config = transport_test_config(format!("{}/issuer", server.uri()));
|
||||
config.dangerously_allow_insecure_http = true;
|
||||
|
||||
let error = JwksCache::new(&config)
|
||||
.await
|
||||
.expect_err("redirects must not move OIDC trust establishment");
|
||||
|
||||
assert!(
|
||||
error.contains("HTTP 302"),
|
||||
"unexpected redirect error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oidc_rejects_oversized_discovery_response() {
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/issuer/.well-known/openid-configuration"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_raw(vec![b' '; OIDC_DISCOVERY_MAX_BYTES + 1], "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut config = transport_test_config(format!("{}/issuer", server.uri()));
|
||||
config.dangerously_allow_insecure_http = true;
|
||||
|
||||
let error = JwksCache::new(&config)
|
||||
.await
|
||||
.expect_err("oversized metadata must be rejected before parsing");
|
||||
|
||||
assert!(
|
||||
error.contains("exceeds the 65536-byte limit"),
|
||||
"unexpected size error: {error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oidc_rejects_non_json_discovery_response() {
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/issuer/.well-known/openid-configuration"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw("{}", "text/html"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
let mut config = transport_test_config(format!("{}/issuer", server.uri()));
|
||||
config.dangerously_allow_insecure_http = true;
|
||||
|
||||
let error = JwksCache::new(&config)
|
||||
.await
|
||||
.expect_err("metadata with an unsafe media type must be rejected");
|
||||
|
||||
assert!(error.contains("content type"), "unexpected error: {error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_is_unauthenticated() {
|
||||
assert!(is_unauthenticated_method("/openshell.v1.OpenShell/Health"));
|
||||
@@ -946,6 +1424,8 @@ mod tests {
|
||||
|
||||
JwksCache::new(&OidcConfig {
|
||||
issuer,
|
||||
dangerously_allow_insecure_http: true,
|
||||
jwks_allowed_origins: Vec::new(),
|
||||
audience: TEST_AUDIENCE.to_owned(),
|
||||
jwks_ttl_secs: 3600,
|
||||
roles_claim: "realm_access.roles".to_owned(),
|
||||
@@ -1283,6 +1763,8 @@ mod tests {
|
||||
fn test_oidc_config(issuer: &str) -> OidcConfig {
|
||||
OidcConfig {
|
||||
issuer: issuer.to_string(),
|
||||
dangerously_allow_insecure_http: true,
|
||||
jwks_allowed_origins: Vec::new(),
|
||||
audience: "test-audience".to_string(),
|
||||
jwks_ttl_secs: 3600,
|
||||
roles_claim: "roles".to_string(),
|
||||
|
||||
@@ -162,6 +162,24 @@ struct RunArgs {
|
||||
#[arg(long, env = "OPENSHELL_OIDC_ISSUER")]
|
||||
oidc_issuer: Option<String>,
|
||||
|
||||
/// Development only: permit OIDC metadata and JWKS over HTTP when the
|
||||
/// endpoint uses a numeric loopback address.
|
||||
#[arg(
|
||||
long,
|
||||
env = "OPENSHELL_OIDC_DANGEROUSLY_ALLOW_INSECURE_HTTP",
|
||||
default_value_t = false,
|
||||
action = ArgAction::Set
|
||||
)]
|
||||
oidc_dangerously_allow_insecure_http: bool,
|
||||
|
||||
/// Additional HTTPS origins allowed to serve the issuer's JWKS.
|
||||
#[arg(
|
||||
long,
|
||||
env = "OPENSHELL_OIDC_JWKS_ALLOWED_ORIGINS",
|
||||
value_delimiter = ','
|
||||
)]
|
||||
oidc_jwks_allowed_origins: Vec<String>,
|
||||
|
||||
/// Enable mTLS client certificate authentication for local single-user gateways.
|
||||
///
|
||||
/// When unset, this defaults on for drivers registered as local
|
||||
@@ -535,6 +553,8 @@ fn prepare_server_config_with_drivers(
|
||||
if let Some(issuer) = args.oidc_issuer.clone() {
|
||||
config = config.with_oidc(openshell_core::OidcConfig {
|
||||
issuer,
|
||||
dangerously_allow_insecure_http: args.oidc_dangerously_allow_insecure_http,
|
||||
jwks_allowed_origins: args.oidc_jwks_allowed_origins.clone(),
|
||||
audience: args.oidc_audience.clone(),
|
||||
jwks_ttl_secs: args.oidc_jwks_ttl,
|
||||
roles_claim: args.oidc_roles_claim.clone(),
|
||||
@@ -1096,6 +1116,15 @@ fn merge_file_into_args(args: &mut RunArgs, file: &GatewayFileSection, matches:
|
||||
if args.oidc_issuer.is_none() && arg_defaulted(matches, "oidc_issuer") {
|
||||
args.oidc_issuer = Some(oidc.issuer.clone());
|
||||
}
|
||||
if arg_defaulted(matches, "oidc_dangerously_allow_insecure_http") {
|
||||
args.oidc_dangerously_allow_insecure_http = oidc.dangerously_allow_insecure_http;
|
||||
}
|
||||
if args.oidc_jwks_allowed_origins.is_empty()
|
||||
&& arg_defaulted(matches, "oidc_jwks_allowed_origins")
|
||||
{
|
||||
args.oidc_jwks_allowed_origins
|
||||
.clone_from(&oidc.jwks_allowed_origins);
|
||||
}
|
||||
if arg_defaulted(matches, "oidc_audience") {
|
||||
args.oidc_audience.clone_from(&oidc.audience);
|
||||
}
|
||||
@@ -1442,6 +1471,25 @@ mod tests {
|
||||
assert!(!cli.run.enable_loopback_service_http);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_parses_oidc_insecure_http_acknowledgement_value() {
|
||||
let _lock = ENV_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _guard = EnvVarGuard::remove("OPENSHELL_OIDC_DANGEROUSLY_ALLOW_INSECURE_HTTP");
|
||||
|
||||
let cli = Cli::try_parse_from([
|
||||
"openshell-gateway",
|
||||
"--db-url",
|
||||
"sqlite::memory:",
|
||||
"--oidc-dangerously-allow-insecure-http",
|
||||
"true",
|
||||
])
|
||||
.expect("launcher-style boolean flag and value should parse");
|
||||
|
||||
assert!(cli.run.oidc_dangerously_allow_insecure_http);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_reads_server_san_from_env() {
|
||||
let _lock = ENV_LOCK
|
||||
@@ -2827,6 +2875,8 @@ compute_driver = "podman"
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _g1 = EnvVarGuard::remove("OPENSHELL_OIDC_ISSUER");
|
||||
let _g2 = EnvVarGuard::remove("OPENSHELL_OIDC_AUDIENCE");
|
||||
let _g3 = EnvVarGuard::remove("OPENSHELL_OIDC_DANGEROUSLY_ALLOW_INSECURE_HTTP");
|
||||
let _g4 = EnvVarGuard::remove("OPENSHELL_OIDC_JWKS_ALLOWED_ORIGINS");
|
||||
|
||||
let (mut args, matches) =
|
||||
parse_with_args(&["openshell-gateway", "--db-url", "sqlite::memory:"]);
|
||||
@@ -2835,12 +2885,19 @@ compute_driver = "podman"
|
||||
[openshell.gateway.oidc]
|
||||
issuer = "https://idp.example.com"
|
||||
audience = "openshell-cli"
|
||||
dangerously_allow_insecure_http = true
|
||||
jwks_allowed_origins = ["https://keys.example.com"]
|
||||
"#,
|
||||
);
|
||||
merge_file_into_args(&mut args, &file.openshell.gateway, &matches);
|
||||
|
||||
assert_eq!(args.oidc_issuer.as_deref(), Some("https://idp.example.com"));
|
||||
assert_eq!(args.oidc_audience, "openshell-cli");
|
||||
assert!(args.oidc_dangerously_allow_insecure_http);
|
||||
assert_eq!(
|
||||
args.oidc_jwks_allowed_origins,
|
||||
["https://keys.example.com".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -745,6 +745,8 @@ client_ca_path = "/etc/openshell/certs/client-ca.pem"
|
||||
[openshell.gateway.oidc]
|
||||
issuer = "https://idp.example.com/realms/openshell"
|
||||
audience = "openshell-cli"
|
||||
jwks_allowed_origins = ["https://keys.example.com"]
|
||||
dangerously_allow_insecure_http = false
|
||||
|
||||
[openshell.drivers.kubernetes]
|
||||
namespace = "agents"
|
||||
@@ -768,7 +770,12 @@ namespace = "agents"
|
||||
Some(openshell_core::PolicyValidationFailureMode::RetainLastValid)
|
||||
);
|
||||
assert!(gw.tls.is_some());
|
||||
assert!(gw.oidc.is_some());
|
||||
let oidc = gw.oidc.as_ref().expect("OIDC config parses");
|
||||
assert!(!oidc.dangerously_allow_insecure_http);
|
||||
assert_eq!(
|
||||
oidc.jwks_allowed_origins,
|
||||
["https://keys.example.com".to_string()]
|
||||
);
|
||||
assert_eq!(
|
||||
gw.credential_drivers.as_deref(),
|
||||
Some(&["kubernetes-secrets".to_string()][..])
|
||||
|
||||
@@ -273,7 +273,9 @@ discovery endpoint or its TLS CA.
|
||||
| server.oidc.adminRole | string | `""` | Role name for admin access. Leave empty (with userRole also empty) for authentication-only mode. Both must be set or both empty. |
|
||||
| server.oidc.audience | string | `"openshell-cli"` | Expected audience claim for the API resource server. This should match the server's --oidc-audience, NOT the CLI client ID. |
|
||||
| server.oidc.caConfigMapName | string | `""` | Name of a ConfigMap containing a CA certificate bundle (key: ca.crt) for verifying the OIDC issuer's TLS certificate. Required when the issuer uses a non-public CA (e.g. OpenShift ingress, private PKI). |
|
||||
| server.oidc.dangerouslyAllowInsecureHttp | bool | `false` | Development only: permit cleartext OIDC requests to numeric loopback addresses. This never permits HTTP to hostnames or non-loopback addresses. |
|
||||
| server.oidc.issuer | string | `""` | OIDC issuer URL (e.g. https://keycloak.example.com/realms/openshell). |
|
||||
| server.oidc.jwksAllowedOrigins | list | `[]` | Additional trusted HTTPS origins allowed to serve JWKS. The issuer origin is always allowed. Entries must not include a path or query. |
|
||||
| server.oidc.jwksTtl | int | `3600` | JWKS key cache TTL in seconds. Must be greater than zero. |
|
||||
| server.oidc.rolesClaim | string | `""` | Dot-separated path to the roles array in the JWT claims. Keycloak: "realm_access.roles", Entra ID: "roles", Okta: "groups". |
|
||||
| server.oidc.scopesClaim | string | `""` | Dot-separated path to the scopes array in the JWT claims. |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
# OIDC configuration overlay for a local Keycloak instance in k3s.
|
||||
#
|
||||
# Run the one-time setup task first:
|
||||
# Run the setup task first (rerun it to rotate the development CA):
|
||||
# mise run keycloak:k8s:setup
|
||||
#
|
||||
# Then layer this file on top of values.yaml when deploying:
|
||||
@@ -15,7 +15,8 @@
|
||||
# Issuer note: the setup task configures Keycloak with KC_HOSTNAME set to the
|
||||
# in-cluster service hostname, so tokens always carry that hostname as `iss`
|
||||
# regardless of how they were obtained (e.g. via a localhost port-forward).
|
||||
# The gateway fetches JWKS from this URL inside the cluster.
|
||||
# The gateway fetches JWKS over TLS from this URL inside the cluster. The setup
|
||||
# task creates the Keycloak development CA ConfigMap referenced below.
|
||||
#
|
||||
# CLI token acquisition: keep a port-forward running while using openshell login:
|
||||
# kubectl -n keycloak port-forward svc/keycloak 9090:80
|
||||
@@ -23,7 +24,8 @@
|
||||
server:
|
||||
oidc:
|
||||
# Must match KC_HOSTNAME set by keycloak:k8s:setup (in-cluster service hostname).
|
||||
issuer: "http://keycloak.keycloak.svc.cluster.local/realms/openshell"
|
||||
issuer: "https://keycloak.keycloak.svc.cluster.local:443/realms/openshell"
|
||||
caConfigMapName: "openshell-keycloak-ca"
|
||||
# Must match the client ID in the imported realm (openshell-cli).
|
||||
audience: "openshell-cli"
|
||||
# Short TTL for dev so JWKS key rotation is picked up quickly.
|
||||
|
||||
@@ -118,9 +118,11 @@ data:
|
||||
{{- if .Values.server.oidc.issuer }}
|
||||
|
||||
[openshell.gateway.oidc]
|
||||
issuer = {{ .Values.server.oidc.issuer | quote }}
|
||||
audience = {{ .Values.server.oidc.audience | quote }}
|
||||
jwks_ttl_secs = {{ .Values.server.oidc.jwksTtl }}
|
||||
issuer = {{ .Values.server.oidc.issuer | quote }}
|
||||
dangerously_allow_insecure_http = {{ .Values.server.oidc.dangerouslyAllowInsecureHttp }}
|
||||
jwks_allowed_origins = {{ .Values.server.oidc.jwksAllowedOrigins | toJson }}
|
||||
audience = {{ .Values.server.oidc.audience | quote }}
|
||||
jwks_ttl_secs = {{ .Values.server.oidc.jwksTtl }}
|
||||
{{- if .Values.server.oidc.rolesClaim }}
|
||||
roles_claim = {{ .Values.server.oidc.rolesClaim | quote }}
|
||||
{{- end }}
|
||||
|
||||
@@ -122,6 +122,20 @@ tests:
|
||||
path: data["gateway.toml"]
|
||||
pattern: '\[openshell\.gateway\.otlp\]'
|
||||
|
||||
- it: renders OIDC transport security settings
|
||||
template: templates/gateway-config.yaml
|
||||
set:
|
||||
server.oidc.issuer: https://issuer.example.com
|
||||
server.oidc.dangerouslyAllowInsecureHttp: false
|
||||
server.oidc.jwksAllowedOrigins[0]: https://keys.example.com
|
||||
asserts:
|
||||
- matchRegex:
|
||||
path: data["gateway.toml"]
|
||||
pattern: '(?m)^dangerously_allow_insecure_http\s*=\s*false$'
|
||||
- matchRegex:
|
||||
path: data["gateway.toml"]
|
||||
pattern: '(?m)^jwks_allowed_origins\s*=\s*\["https://keys.example.com"\]$'
|
||||
|
||||
- it: treats a null OTLP map as disabled
|
||||
template: templates/gateway-config.yaml
|
||||
set:
|
||||
|
||||
@@ -419,6 +419,12 @@ server:
|
||||
oidc:
|
||||
# -- OIDC issuer URL (e.g. https://keycloak.example.com/realms/openshell).
|
||||
issuer: ""
|
||||
# -- Development only: permit cleartext OIDC requests to numeric loopback
|
||||
# addresses. This never permits HTTP to hostnames or non-loopback addresses.
|
||||
dangerouslyAllowInsecureHttp: false
|
||||
# -- Additional trusted HTTPS origins allowed to serve JWKS. The issuer
|
||||
# origin is always allowed. Entries must not include a path or query.
|
||||
jwksAllowedOrigins: []
|
||||
# -- Expected audience claim for the API resource server.
|
||||
# This should match the server's --oidc-audience, NOT the CLI client ID.
|
||||
audience: "openshell-cli"
|
||||
|
||||
@@ -52,6 +52,9 @@ The `audience` value must match the client ID configured in your identity provid
|
||||
| Value | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `server.oidc.issuer` | `""` | OIDC issuer URL. Empty disables OIDC. |
|
||||
| `server.oidc.dangerouslyAllowInsecureHttp` | `false` | Development-only acknowledgement for numeric-loopback HTTP. It does not allow cluster-service or remote HTTP issuers. |
|
||||
| `server.oidc.jwksAllowedOrigins` | `[]` | Additional trusted HTTPS origins allowed to serve JWKS. |
|
||||
| `server.oidc.caConfigMapName` | `""` | ConfigMap containing the private issuer CA in `ca.crt`. |
|
||||
| `server.oidc.audience` | `openshell-cli` | Expected `aud` claim in the JWT. |
|
||||
| `server.oidc.jwksTtl` | `3600` | JWKS key cache TTL in seconds. Must be greater than zero. |
|
||||
| `server.oidc.rolesClaim` | `""` | Dot-separated path to the roles array in JWT claims. |
|
||||
@@ -59,6 +62,11 @@ The `audience` value must match the client ID configured in your identity provid
|
||||
| `server.oidc.userRole` | `""` | Role name that grants standard user access. |
|
||||
| `server.oidc.scopesClaim` | `""` | Dot-separated path to the scopes array in JWT claims. |
|
||||
|
||||
The issuer must use HTTPS. The gateway rejects discovery and JWKS redirects,
|
||||
limits response sizes, requires a JSON media type, and rejects a `jwks_uri` on
|
||||
a different origin unless that origin appears in `jwksAllowedOrigins`. Use
|
||||
`server.oidc.caConfigMapName` for issuers signed by a private CA.
|
||||
|
||||
### Auth-only mode vs. RBAC mode
|
||||
|
||||
Leave both `adminRole` and `userRole` empty to use auth-only mode: any request with a valid JWT from the configured issuer is accepted, but no role distinction is enforced.
|
||||
|
||||
@@ -90,6 +90,8 @@ The same settings are available through environment variables:
|
||||
| Environment variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `OPENSHELL_OIDC_ISSUER` | OIDC issuer URL. The gateway discovers `/.well-known/openid-configuration` from this URL. | None |
|
||||
| `OPENSHELL_OIDC_DANGEROUSLY_ALLOW_INSECURE_HTTP` | Development-only acknowledgement for HTTP issuers on numeric loopback addresses. HTTP hostnames and non-loopback addresses remain rejected. | `false` |
|
||||
| `OPENSHELL_OIDC_JWKS_ALLOWED_ORIGINS` | Comma-separated additional HTTPS origins trusted to serve JWKS. | Empty |
|
||||
| `OPENSHELL_OIDC_AUDIENCE` | Expected JWT `aud` claim. | `openshell-cli` |
|
||||
| `OPENSHELL_OIDC_JWKS_TTL` | JWKS cache TTL in seconds. The gateway also refreshes on an unknown key ID. Must be greater than zero. | `3600` |
|
||||
| `OPENSHELL_OIDC_ROLES_CLAIM` | Dot-separated claim path containing roles. | `realm_access.roles` |
|
||||
@@ -104,12 +106,22 @@ server:
|
||||
oidc:
|
||||
issuer: https://idp.example.com/realms/openshell
|
||||
audience: openshell-cli
|
||||
# Only needed when discovery returns a deliberately separate JWKS origin.
|
||||
jwksAllowedOrigins:
|
||||
- https://keys.example.com
|
||||
rolesClaim: realm_access.roles
|
||||
adminRole: openshell-admin
|
||||
userRole: openshell-user
|
||||
scopesClaim: ""
|
||||
```
|
||||
|
||||
The gateway requires HTTPS for OIDC discovery and JWKS retrieval, rejects
|
||||
redirects, and trusts the issuer origin for JWKS by default. Add a
|
||||
`jwksAllowedOrigins` entry only when the identity provider intentionally serves
|
||||
keys from another trusted origin. For local tests, numeric-loopback HTTP also
|
||||
requires `dangerouslyAllowInsecureHttp: true`; this setting never permits an
|
||||
HTTP hostname or non-loopback address.
|
||||
|
||||
Register an OIDC gateway with the CLI:
|
||||
|
||||
```shell
|
||||
|
||||
@@ -213,13 +213,15 @@ endpoint = "http://otel-collector.observability.svc:4317"
|
||||
service_name = "openshell-gateway"
|
||||
|
||||
[openshell.gateway.oidc]
|
||||
issuer = "https://idp.example.com/realms/openshell"
|
||||
audience = "openshell-cli"
|
||||
jwks_ttl_secs = 3600 # Must be greater than zero.
|
||||
roles_claim = "realm_access.roles"
|
||||
admin_role = "openshell-admin"
|
||||
user_role = "openshell-user"
|
||||
scopes_claim = ""
|
||||
issuer = "https://idp.example.com/realms/openshell"
|
||||
audience = "openshell-cli"
|
||||
jwks_ttl_secs = 3600 # Must be greater than zero.
|
||||
jwks_allowed_origins = ["https://keys.example.com"] # Default: issuer origin only.
|
||||
dangerously_allow_insecure_http = false # Development-only numeric-loopback escape hatch.
|
||||
roles_claim = "realm_access.roles"
|
||||
admin_role = "openshell-admin"
|
||||
user_role = "openshell-user"
|
||||
scopes_claim = ""
|
||||
|
||||
[[openshell.gateway.interceptors]]
|
||||
name = "quota"
|
||||
|
||||
@@ -190,6 +190,11 @@ e2e_write_gateway_oidc_config() {
|
||||
|
||||
printf '[openshell.gateway.oidc]\n'
|
||||
printf 'issuer = %s\n' "$(e2e_toml_string "${issuer}")"
|
||||
case "${issuer}" in
|
||||
http://127.*|http://\[::1\]*)
|
||||
printf 'dangerously_allow_insecure_http = true\n'
|
||||
;;
|
||||
esac
|
||||
printf 'audience = "openshell-cli"\n'
|
||||
printf 'jwks_ttl_secs = 60\n'
|
||||
printf 'roles_claim = "realm_access.roles"\n'
|
||||
|
||||
@@ -572,6 +572,11 @@ if [ "${OIDC_MODE}" = "1" ]; then
|
||||
--oidc-audience openshell-cli
|
||||
--oidc-scopes-claim scope
|
||||
)
|
||||
case "${OIDC_ISSUER}" in
|
||||
http://127.*|http://\[::1\]*)
|
||||
GATEWAY_ARGS+=(--oidc-dangerously-allow-insecure-http true)
|
||||
;;
|
||||
esac
|
||||
else
|
||||
GATEWAY_ARGS+=(
|
||||
--tls-client-ca "${PKI_DIR}/ca.crt"
|
||||
|
||||
@@ -49,7 +49,7 @@ if ! CONTAINER_RUNTIME="$RUNTIME" KEYCLOAK_PORT="$KEYCLOAK_PORT" \
|
||||
"$ROOT_DIR/scripts/keycloak-dev.sh" start
|
||||
fi
|
||||
|
||||
export OPENSHELL_E2E_OIDC_ISSUER="${OPENSHELL_E2E_OIDC_ISSUER:-http://localhost:${KEYCLOAK_PORT}/realms/openshell}"
|
||||
export OPENSHELL_E2E_OIDC_ISSUER="${OPENSHELL_E2E_OIDC_ISSUER:-http://127.0.0.1:${KEYCLOAK_PORT}/realms/openshell}"
|
||||
export OPENSHELL_E2E_OIDC_USERNAME="${OPENSHELL_E2E_OIDC_USERNAME:-admin@test}"
|
||||
export OPENSHELL_E2E_OIDC_PASSWORD="${OPENSHELL_E2E_OIDC_PASSWORD:-admin}"
|
||||
export OPENSHELL_E2E_OIDC_ROLE="${OPENSHELL_E2E_OIDC_ROLE:-openshell-admin}"
|
||||
|
||||
@@ -751,6 +751,11 @@ if [ "${OIDC_MODE}" = "1" ]; then
|
||||
--oidc-audience openshell-cli
|
||||
--oidc-scopes-claim scope
|
||||
)
|
||||
case "${OIDC_ISSUER}" in
|
||||
http://127.*|http://\[::1\]*)
|
||||
GATEWAY_ARGS+=(--oidc-dangerously-allow-insecure-http true)
|
||||
;;
|
||||
esac
|
||||
else
|
||||
GATEWAY_ARGS+=(
|
||||
--tls-client-ca "${PKI_DIR}/ca.crt"
|
||||
|
||||
@@ -104,7 +104,7 @@ cmd_start() {
|
||||
local elapsed=0
|
||||
while [ $elapsed -lt $HEALTH_TIMEOUT ]; do
|
||||
if curl -sf \
|
||||
"http://localhost:${KEYCLOAK_PORT}/realms/openshell/.well-known/openid-configuration" \
|
||||
"http://127.0.0.1:${KEYCLOAK_PORT}/realms/openshell/.well-known/openid-configuration" \
|
||||
>/dev/null 2>&1; then
|
||||
echo "Keycloak is ready."
|
||||
print_info
|
||||
@@ -146,7 +146,7 @@ cmd_status() {
|
||||
}
|
||||
|
||||
print_info() {
|
||||
local issuer="http://localhost:${KEYCLOAK_PORT}/realms/openshell"
|
||||
local issuer="http://127.0.0.1:${KEYCLOAK_PORT}/realms/openshell"
|
||||
echo ""
|
||||
echo " Issuer URL: $issuer"
|
||||
echo " Discovery: ${issuer}/.well-known/openid-configuration"
|
||||
|
||||
@@ -772,6 +772,7 @@ configuration — check that the gateway spawned the driver binary you expect
|
||||
| Vault credential driver returns HTTP 403 / `Vault Kubernetes auth denied the configured role` on provider create | Vault's `auth/kubernetes` method or the gateway login role is not provisioned, or the role is not bound to the gateway service account and namespace | In Vault: `bao auth enable kubernetes` and `bao write auth/kubernetes/config kubernetes_host=... kubernetes_ca_cert=@...`; ensure the login role's `bound_service_account_names`/`bound_service_account_namespaces` match the gateway SA and namespace and its policy grants the credential paths |
|
||||
| CLI TLS error | Local mTLS bundle does not match server cert/CA | Check `~/.config/openshell/gateways/<name>/mtls/` |
|
||||
| Edge or OIDC gateway returns `Unauthenticated` | Stored login expired, audience/scopes mismatch, or gateway auth configuration changed | `openshell gateway info`, `openshell gateway login <name>`, gateway auth logs |
|
||||
| Gateway exits during OIDC initialization | Issuer is not HTTPS, discovery redirected, metadata used a non-JSON media type or exceeded its size limit, or `jwks_uri` uses an untrusted origin | Use an HTTPS issuer; mount a private CA with `server.oidc.caConfigMapName`; keep JWKS on the issuer origin or explicitly add its HTTPS origin to `server.oidc.jwksAllowedOrigins`. Numeric-loopback HTTP is development-only and also requires `server.oidc.dangerouslyAllowInsecureHttp=true` |
|
||||
| Gateway fails before serving health after enabling an interceptor | Interceptor endpoint unavailable or manifest/binding validation failed | Gateway and interceptor logs; interceptor socket; `binding_policy`, phases, and failure policy |
|
||||
| Authenticated interceptor or middleware rejects gateway calls | Private CA or hostname mismatch, expected audience or issuer mismatch, stale/unknown `kid`, or malformed extension token | `tls_ca_cert_path`, registration `audience`, service verifier config and logs; fetch well-known metadata only through the already-trusted gateway TLS endpoint |
|
||||
| Provider profiles disappear after enabling an interceptor catalog | `provider_profile_sources` selected only an authoritative interceptor or returned invalid/duplicate IDs | Inspect source list and interceptor `Describe`/catalog logs; include `builtin` and `user` when intended |
|
||||
|
||||
+5
-2
@@ -16,9 +16,12 @@ description = "Check if the local Keycloak instance is running"
|
||||
run = "scripts/keycloak-dev.sh status"
|
||||
|
||||
["keycloak:k8s:setup"]
|
||||
description = "Install Keycloak in the local k3s cluster and import the openshell realm (one-time setup)"
|
||||
description = "Install TLS-enabled Keycloak in the local k3s cluster and import the openshell realm"
|
||||
run = "tasks/scripts/keycloak-k8s-setup.sh"
|
||||
|
||||
["keycloak:k8s:teardown"]
|
||||
description = "Remove Keycloak from the local k3s cluster"
|
||||
run = "kubectl delete namespace keycloak --ignore-not-found"
|
||||
run = '''
|
||||
kubectl delete namespace keycloak --ignore-not-found
|
||||
kubectl -n "${OPENSHELL_NAMESPACE:-openshell}" delete configmap openshell-keycloak-ca --ignore-not-found
|
||||
'''
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
# One-time Keycloak setup for the local k3s cluster.
|
||||
# TLS-enabled Keycloak setup for the local k3s cluster.
|
||||
# Uses the same quay.io/keycloak/keycloak image and realm JSON as the local
|
||||
# Docker dev setup (scripts/keycloak-dev.sh), deploying via kubectl manifests.
|
||||
#
|
||||
# Idempotent: safe to re-run. The Deployment and ConfigMap are applied with
|
||||
# kubectl apply, and Keycloak's --import-realm flag skips the realm if it
|
||||
# already exists.
|
||||
# Safe to re-run. Each run rotates the development CA and serving certificate;
|
||||
# Keycloak's --import-realm flag skips the realm if it already exists.
|
||||
#
|
||||
# Usage:
|
||||
# mise run keycloak:k8s:setup
|
||||
@@ -30,6 +29,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
|
||||
NAMESPACE="keycloak"
|
||||
GATEWAY_NAMESPACE="${OPENSHELL_NAMESPACE:-openshell}"
|
||||
KEYCLOAK_IMAGE="${KEYCLOAK_IMAGE:-quay.io/keycloak/keycloak:24.0}"
|
||||
ADMIN_USER="${KEYCLOAK_ADMIN_USER:-admin}"
|
||||
ADMIN_PASSWORD="${KEYCLOAK_ADMIN_PASSWORD:-admin}"
|
||||
@@ -43,6 +43,9 @@ HEALTH_TIMEOUT="${KEYCLOAK_HEALTH_TIMEOUT:-120}"
|
||||
# this URL inside the cluster. See values-keycloak.yaml.
|
||||
SVC_HOSTNAME="keycloak.${NAMESPACE}.svc.cluster.local"
|
||||
|
||||
TLS_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${TLS_DIR}"' EXIT
|
||||
|
||||
if [[ ! -f "${REALM_FILE}" ]]; then
|
||||
echo "error: realm file not found: ${REALM_FILE}" >&2
|
||||
exit 1
|
||||
@@ -60,6 +63,49 @@ kubectl -n "${NAMESPACE}" create configmap openshell-realm \
|
||||
--from-file=realm.json="${REALM_FILE}" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
echo "Generating a development TLS certificate for '${SVC_HOSTNAME}'..."
|
||||
openssl req -x509 -newkey rsa:2048 -nodes \
|
||||
-keyout "${TLS_DIR}/ca.key" \
|
||||
-out "${TLS_DIR}/ca.crt" \
|
||||
-days 30 \
|
||||
-subj "/CN=OpenShell development Keycloak CA" \
|
||||
-addext "basicConstraints=critical,CA:TRUE" \
|
||||
-addext "keyUsage=critical,keyCertSign,cRLSign" \
|
||||
>/dev/null 2>&1
|
||||
openssl req -new -newkey rsa:2048 -nodes \
|
||||
-keyout "${TLS_DIR}/tls.key" \
|
||||
-out "${TLS_DIR}/tls.csr" \
|
||||
-subj "/CN=${SVC_HOSTNAME}" \
|
||||
>/dev/null 2>&1
|
||||
printf '%s\n' \
|
||||
"subjectAltName=DNS:${SVC_HOSTNAME}" \
|
||||
"basicConstraints=critical,CA:FALSE" \
|
||||
"keyUsage=critical,digitalSignature,keyEncipherment" \
|
||||
"extendedKeyUsage=serverAuth" \
|
||||
>"${TLS_DIR}/server.ext"
|
||||
openssl x509 -req \
|
||||
-in "${TLS_DIR}/tls.csr" \
|
||||
-CA "${TLS_DIR}/ca.crt" \
|
||||
-CAkey "${TLS_DIR}/ca.key" \
|
||||
-CAcreateserial \
|
||||
-out "${TLS_DIR}/tls.crt" \
|
||||
-days 30 \
|
||||
-extfile "${TLS_DIR}/server.ext" \
|
||||
>/dev/null 2>&1
|
||||
|
||||
kubectl -n "${NAMESPACE}" create secret tls keycloak-tls \
|
||||
--cert="${TLS_DIR}/tls.crt" \
|
||||
--key="${TLS_DIR}/tls.key" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
# The Helm chart mounts OIDC trust bundles from its own namespace. Publish the
|
||||
# development trust anchor there as well; rerunning this task rotates both the
|
||||
# serving certificate and the trusted copy.
|
||||
kubectl create namespace "${GATEWAY_NAMESPACE}" --dry-run=client -o yaml | kubectl apply -f -
|
||||
kubectl -n "${GATEWAY_NAMESPACE}" create configmap openshell-keycloak-ca \
|
||||
--from-file=ca.crt="${TLS_DIR}/ca.crt" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Deployment + Service
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -94,14 +140,24 @@ spec:
|
||||
# used for token acquisition (e.g. a localhost port-forward).
|
||||
- name: KC_HOSTNAME
|
||||
value: "${SVC_HOSTNAME}"
|
||||
# Keycloak listens on 8443 in the container, but clients reach it
|
||||
# through the Service's standard HTTPS port. Keep discovery and
|
||||
# token issuers aligned with the explicit Service URL.
|
||||
- name: KC_HOSTNAME_PORT
|
||||
value: "443"
|
||||
- name: KC_HOSTNAME_STRICT
|
||||
value: "false"
|
||||
- name: KC_HOSTNAME_STRICT_HTTPS
|
||||
value: "false"
|
||||
value: "true"
|
||||
- name: KC_HTTP_ENABLED
|
||||
value: "true"
|
||||
- name: KC_HTTPS_CERTIFICATE_FILE
|
||||
value: "/etc/keycloak-tls/tls.crt"
|
||||
- name: KC_HTTPS_CERTIFICATE_KEY_FILE
|
||||
value: "/etc/keycloak-tls/tls.key"
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
- containerPort: 8443
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /realms/master
|
||||
@@ -118,10 +174,16 @@ spec:
|
||||
volumeMounts:
|
||||
- name: realm
|
||||
mountPath: /opt/keycloak/data/import
|
||||
- name: tls
|
||||
mountPath: /etc/keycloak-tls
|
||||
readOnly: true
|
||||
volumes:
|
||||
- name: realm
|
||||
configMap:
|
||||
name: openshell-realm
|
||||
- name: tls
|
||||
secret:
|
||||
secretName: keycloak-tls
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
@@ -134,8 +196,17 @@ spec:
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
name: http
|
||||
- port: 443
|
||||
targetPort: 8443
|
||||
name: https
|
||||
EOF
|
||||
|
||||
# Reload the freshly generated serving certificate on repeat runs. The
|
||||
# deployment spec itself is otherwise unchanged, so updating the Secret alone
|
||||
# would not restart Keycloak.
|
||||
kubectl -n "${NAMESPACE}" rollout restart deployment/keycloak
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wait for readiness
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -147,7 +218,7 @@ kubectl rollout status deployment/keycloak -n "${NAMESPACE}" --timeout="${HEALTH
|
||||
# Summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ISSUER="http://${SVC_HOSTNAME}/realms/openshell"
|
||||
ISSUER="https://${SVC_HOSTNAME}:443/realms/openshell"
|
||||
|
||||
echo ""
|
||||
echo "Keycloak is ready."
|
||||
|
||||
Reference in New Issue
Block a user