use axum::{
extract::Request,
http::{HeaderMap, StatusCode},
middleware::Next,
response::Response,
Json,
};
use serde::Serialize;
use tracing::{debug, warn};
#[cfg(feature = "k8s-token-review")]
use k8s_openapi::api::authentication::v1::TokenReview;
#[cfg(feature = "k8s-token-review")]
use kube::{
config::{
AuthInfo, Cluster, KubeConfigOptions, Kubeconfig, NamedAuthInfo, NamedCluster, NamedContext,
},
Api, Client, Config,
};
#[cfg(feature = "k8s-token-review")]
use std::env;
#[cfg(feature = "k8s-token-review")]
use tracing::error;
#[derive(Serialize)]
pub struct AuthError {
pub error: String,
}
#[cfg(feature = "k8s-token-review")]
#[derive(Debug, Clone)]
pub struct TokenReviewConfig {
pub audiences: Vec<String>,
pub allowed_namespaces: Vec<String>,
pub allowed_service_accounts: Vec<String>,
}
#[cfg(feature = "k8s-token-review")]
impl TokenReviewConfig {
pub fn from_env() -> Self {
let audiences = match env::var("BIND_TOKEN_AUDIENCES") {
Ok(val) if !val.trim().is_empty() => val
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
_ => vec!["bindcar".to_string()],
};
let allowed_namespaces = env::var("BIND_ALLOWED_NAMESPACES")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let allowed_service_accounts = env::var("BIND_ALLOWED_SERVICE_ACCOUNTS")
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let config = Self {
audiences,
allowed_namespaces,
allowed_service_accounts,
};
debug!("TokenReview config loaded: audiences={:?}, allowed_namespaces={:?}, allowed_service_accounts={:?}",
config.audiences, config.allowed_namespaces, config.allowed_service_accounts);
config
}
pub(crate) fn is_namespace_allowed(&self, namespace: &str) -> bool {
if self.allowed_namespaces.is_empty() {
return true;
}
self.allowed_namespaces.contains(&namespace.to_string())
}
pub(crate) fn is_service_account_allowed(&self, username: &str) -> bool {
if self.allowed_service_accounts.is_empty() {
return true;
}
self.allowed_service_accounts
.contains(&username.to_string())
}
pub(crate) fn extract_namespace(username: &str) -> Option<String> {
let parts: Vec<&str> = username.split(':').collect();
if parts.len() != 4 || parts[0] != "system" || parts[1] != "serviceaccount" {
return None;
}
Some(parts[2].to_string())
}
}
#[cfg(feature = "k8s-token-review")]
#[derive(Debug)]
pub enum KubeAuthMode {
Explicit {
server: String,
token_path: String,
ca_cert_path: String,
},
Default,
}
#[cfg(feature = "k8s-token-review")]
pub fn detect_kube_auth_mode() -> KubeAuthMode {
let api_server = env::var("KUBE_API_SERVER").ok();
let token_path = env::var("KUBE_TOKEN_PATH").ok();
let ca_cert_path = env::var("KUBE_CA_CERT_PATH").ok();
let set_count = [&api_server, &token_path, &ca_cert_path]
.iter()
.filter(|v| v.is_some())
.count();
if set_count > 0 && set_count < 3 {
warn!(
"Partial KUBE_* env vars set ({}/3 present): \
KUBE_API_SERVER={}, KUBE_TOKEN_PATH={}, KUBE_CA_CERT_PATH={}. \
All three must be set for explicit auth. Falling back to try_default().",
set_count,
api_server.as_deref().unwrap_or("(not set)"),
token_path.as_deref().unwrap_or("(not set)"),
ca_cert_path.as_deref().unwrap_or("(not set)"),
);
}
match (api_server, token_path, ca_cert_path) {
(Some(server), Some(token_path), Some(ca_cert_path)) => KubeAuthMode::Explicit {
server,
token_path,
ca_cert_path,
},
_ => KubeAuthMode::Default,
}
}
#[cfg(feature = "k8s-token-review")]
pub(crate) async fn build_explicit_kube_client(
server: String,
token_path: String,
ca_cert_path: String,
) -> Result<Client, String> {
tokio::fs::read_to_string(&token_path)
.await
.map_err(|e| format!("failed to read token file '{}': {}", token_path, e))?;
let ca_bytes = tokio::fs::read(&ca_cert_path).await.map_err(|e| {
format!(
"failed to read CA certificate file '{}': {}",
ca_cert_path, e
)
})?;
let ca_pem = String::from_utf8_lossy(&ca_bytes);
if !ca_pem.contains("-----BEGIN CERTIFICATE-----") {
return Err(format!(
"CA certificate file '{}' does not contain a valid PEM certificate block",
ca_cert_path
));
}
let kubeconfig = Kubeconfig {
clusters: vec![NamedCluster {
name: "standalone".to_string(),
cluster: Some(Cluster {
server: Some(server),
certificate_authority: Some(ca_cert_path),
..Default::default()
}),
}],
auth_infos: vec![NamedAuthInfo {
name: "standalone".to_string(),
auth_info: Some(AuthInfo {
token_file: Some(token_path),
..Default::default()
}),
}],
contexts: vec![NamedContext {
name: "standalone".to_string(),
context: Some(kube::config::Context {
cluster: "standalone".to_string(),
user: Some("standalone".to_string()),
..Default::default()
}),
}],
current_context: Some("standalone".to_string()),
..Default::default()
};
let config = Config::from_custom_kubeconfig(kubeconfig, &KubeConfigOptions::default())
.await
.map_err(|e| format!("failed to build Kubernetes client config: {}", e))?;
Client::try_from(config).map_err(|e| format!("failed to create Kubernetes client: {}", e))
}
#[cfg(feature = "k8s-token-review")]
async fn build_kube_client() -> Result<Client, String> {
match detect_kube_auth_mode() {
KubeAuthMode::Explicit {
server,
token_path,
ca_cert_path,
} => {
debug!(
"Kubernetes auth mode: explicit (KUBE_API_SERVER={})",
server
);
build_explicit_kube_client(server, token_path, ca_cert_path).await
}
KubeAuthMode::Default => {
debug!("Kubernetes auth mode: try_default (KUBECONFIG / ~/.kube/config / in-cluster)");
Client::try_default()
.await
.map_err(|e| format!("Failed to create Kubernetes client: {}", e))
}
}
}
pub async fn authenticate(
headers: HeaderMap,
request: Request,
next: Next,
) -> Result<Response, (StatusCode, Json<AuthError>)> {
let auth_header = headers
.get("authorization")
.and_then(|h| h.to_str().ok())
.ok_or_else(|| {
warn!("Missing Authorization header");
(
StatusCode::UNAUTHORIZED,
Json(AuthError {
error: "Missing Authorization header".to_string(),
}),
)
})?;
if !auth_header.starts_with("Bearer ") {
warn!("Invalid Authorization header format");
return Err((
StatusCode::UNAUTHORIZED,
Json(AuthError {
error: "Invalid Authorization header format. Expected: Bearer <token>".to_string(),
}),
));
}
let token = &auth_header[7..];
if token.is_empty() {
warn!("Empty token in Authorization header");
return Err((
StatusCode::UNAUTHORIZED,
Json(AuthError {
error: "Empty token".to_string(),
}),
));
}
#[cfg(feature = "k8s-token-review")]
if let Err(e) = validate_token_with_k8s(token).await {
warn!("Token validation failed: {}", e);
return Err((
StatusCode::UNAUTHORIZED,
Json(AuthError {
error: format!("Token validation failed: {}", e),
}),
));
}
#[cfg(feature = "k8s-token-review")]
debug!("Token validated with Kubernetes TokenReview API");
#[cfg(not(feature = "k8s-token-review"))]
debug!("Token validation: basic mode (presence check only)");
Ok(next.run(request).await)
}
#[cfg(feature = "k8s-token-review")]
pub(crate) async fn validate_token_with_k8s(token: &str) -> Result<(), String> {
let config = TokenReviewConfig::from_env();
let client = build_kube_client().await?;
let token_reviews: Api<TokenReview> = Api::all(client);
let audiences = if !config.audiences.is_empty() {
Some(config.audiences.clone())
} else {
None
};
let token_review = TokenReview {
metadata: Default::default(),
spec: k8s_openapi::api::authentication::v1::TokenReviewSpec {
token: Some(token.to_string()),
audiences,
},
status: None,
};
let result = token_reviews
.create(&Default::default(), &token_review)
.await
.map_err(|e| {
error!("TokenReview API call failed: {}", e);
format!("Failed to validate token with Kubernetes API: {}", e)
})?;
let status = result
.status
.ok_or_else(|| "TokenReview status not available".to_string())?;
if status.authenticated != Some(true) {
let error_msg = status
.error
.unwrap_or_else(|| "Token not authenticated".to_string());
warn!("Token authentication failed: {}", error_msg);
return Err(error_msg);
}
debug!("Token authenticated successfully");
let user = status.user.ok_or_else(|| {
warn!("TokenReview succeeded but no user information returned");
"No user information in TokenReview response".to_string()
})?;
let username = user.username.as_deref().unwrap_or("");
debug!("Authenticated user: {}", username);
if let Some(namespace) = TokenReviewConfig::extract_namespace(username) {
if !config.is_namespace_allowed(&namespace) {
warn!(
"ServiceAccount from unauthorized namespace: {} (from {})",
namespace, username
);
return Err(format!(
"ServiceAccount from unauthorized namespace: {}",
namespace
));
}
debug!("Namespace {} is allowed", namespace);
}
if !config.is_service_account_allowed(username) {
warn!("ServiceAccount not in allowlist: {}", username);
return Err(format!("ServiceAccount not authorized: {}", username));
}
debug!("ServiceAccount {} is allowed", username);
Ok(())
}