bindcar/
auth.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Authentication middleware for Kubernetes ServiceAccount tokens
5//!
6//! This module validates that incoming requests include a valid ServiceAccount token
7//! in the Authorization header.
8//!
9//! ## Token Validation Modes
10//!
11//! ### Basic Mode (default)
12//! - Checks for token presence and format
13//! - Suitable for trusted environments or when using external auth (API gateway, service mesh)
14//!
15//! ### Kubernetes TokenReview Mode (feature: `k8s-token-review`)
16//! - Validates tokens against Kubernetes TokenReview API
17//! - Verifies token authenticity and expiration
18//! - Validates token audience
19//! - Restricts to allowed namespaces and service accounts
20//! - Requires in-cluster configuration or kubeconfig
21//!
22//! ## Security Configuration
23//!
24//! Environment variables for TokenReview mode:
25//! - `BIND_TOKEN_AUDIENCES` - Comma-separated list of expected audiences (default: "bindcar")
26//! - `BIND_ALLOWED_NAMESPACES` - Comma-separated list of allowed namespaces (empty = allow all)
27//! - `BIND_ALLOWED_SERVICE_ACCOUNTS` - Comma-separated list of allowed SA names (empty = allow all)
28
29use axum::{
30    extract::Request,
31    http::{HeaderMap, StatusCode},
32    middleware::Next,
33    response::Response,
34    Json,
35};
36use serde::Serialize;
37use tracing::{debug, warn};
38
39#[cfg(feature = "k8s-token-review")]
40use k8s_openapi::api::authentication::v1::TokenReview;
41#[cfg(feature = "k8s-token-review")]
42use kube::{
43    config::{
44        AuthInfo, Cluster, KubeConfigOptions, Kubeconfig, NamedAuthInfo, NamedCluster, NamedContext,
45    },
46    Api, Client, Config,
47};
48#[cfg(feature = "k8s-token-review")]
49use std::env;
50#[cfg(feature = "k8s-token-review")]
51use tracing::error;
52
53/// Error response for authentication failures
54#[derive(Serialize)]
55pub struct AuthError {
56    pub error: String,
57}
58
59/// Configuration for TokenReview security policies
60#[cfg(feature = "k8s-token-review")]
61#[derive(Debug, Clone)]
62pub struct TokenReviewConfig {
63    /// Expected audiences for token validation
64    pub audiences: Vec<String>,
65    /// Allowed namespaces (empty = allow all)
66    pub allowed_namespaces: Vec<String>,
67    /// Allowed service accounts in format "system:serviceaccount:namespace:name" (empty = allow all)
68    pub allowed_service_accounts: Vec<String>,
69}
70
71#[cfg(feature = "k8s-token-review")]
72impl TokenReviewConfig {
73    /// Load configuration from environment variables
74    pub fn from_env() -> Self {
75        let audiences = match env::var("BIND_TOKEN_AUDIENCES") {
76            Ok(val) if !val.trim().is_empty() => val
77                .split(',')
78                .map(|s| s.trim().to_string())
79                .filter(|s| !s.is_empty())
80                .collect(),
81            _ => vec!["bindcar".to_string()],
82        };
83
84        let allowed_namespaces = env::var("BIND_ALLOWED_NAMESPACES")
85            .unwrap_or_default()
86            .split(',')
87            .map(|s| s.trim().to_string())
88            .filter(|s| !s.is_empty())
89            .collect();
90
91        let allowed_service_accounts = env::var("BIND_ALLOWED_SERVICE_ACCOUNTS")
92            .unwrap_or_default()
93            .split(',')
94            .map(|s| s.trim().to_string())
95            .filter(|s| !s.is_empty())
96            .collect();
97
98        let config = Self {
99            audiences,
100            allowed_namespaces,
101            allowed_service_accounts,
102        };
103
104        debug!("TokenReview config loaded: audiences={:?}, allowed_namespaces={:?}, allowed_service_accounts={:?}",
105            config.audiences, config.allowed_namespaces, config.allowed_service_accounts);
106
107        config
108    }
109
110    /// Check if a namespace is allowed
111    pub(crate) fn is_namespace_allowed(&self, namespace: &str) -> bool {
112        // Empty list means allow all
113        if self.allowed_namespaces.is_empty() {
114            return true;
115        }
116        self.allowed_namespaces.contains(&namespace.to_string())
117    }
118
119    /// Check if a service account is allowed
120    pub(crate) fn is_service_account_allowed(&self, username: &str) -> bool {
121        // Empty list means allow all
122        if self.allowed_service_accounts.is_empty() {
123            return true;
124        }
125        self.allowed_service_accounts
126            .contains(&username.to_string())
127    }
128
129    /// Extract namespace from service account username
130    /// Format: "system:serviceaccount:namespace:name"
131    pub(crate) fn extract_namespace(username: &str) -> Option<String> {
132        let parts: Vec<&str> = username.split(':').collect();
133        if parts.len() != 4 || parts[0] != "system" || parts[1] != "serviceaccount" {
134            return None;
135        }
136        Some(parts[2].to_string())
137    }
138}
139
140/// Describes how the Kubernetes client will be authenticated when performing TokenReview calls.
141///
142/// The mode is determined by environment variables at startup. When all three explicit
143/// vars (`KUBE_API_SERVER`, `KUBE_TOKEN_PATH`, `KUBE_CA_CERT_PATH`) are present,
144/// bindcar builds the client directly from those files. Otherwise it falls back to
145/// `kube::Client::try_default()`, which checks `KUBECONFIG`, `~/.kube/config`, and then
146/// the in-cluster ServiceAccount mount in order.
147#[cfg(feature = "k8s-token-review")]
148#[derive(Debug)]
149pub enum KubeAuthMode {
150    /// All three explicit env vars are set; use them to build the client.
151    Explicit {
152        server: String,
153        token_path: String,
154        ca_cert_path: String,
155    },
156    /// Fall back to `kube::Client::try_default()`.
157    Default,
158}
159
160/// Inspect environment variables and return which Kubernetes auth mode should be used.
161///
162/// Returns `KubeAuthMode::Explicit` only when **all three** of `KUBE_API_SERVER`,
163/// `KUBE_TOKEN_PATH`, and `KUBE_CA_CERT_PATH` are present. If only some are set a
164/// warning is logged and `KubeAuthMode::Default` is returned so that existing
165/// in-cluster and kubeconfig deployments are unaffected.
166#[cfg(feature = "k8s-token-review")]
167pub fn detect_kube_auth_mode() -> KubeAuthMode {
168    let api_server = env::var("KUBE_API_SERVER").ok();
169    let token_path = env::var("KUBE_TOKEN_PATH").ok();
170    let ca_cert_path = env::var("KUBE_CA_CERT_PATH").ok();
171
172    // Warn on partial configuration so operators notice misconfiguration early.
173    let set_count = [&api_server, &token_path, &ca_cert_path]
174        .iter()
175        .filter(|v| v.is_some())
176        .count();
177
178    if set_count > 0 && set_count < 3 {
179        warn!(
180            "Partial KUBE_* env vars set ({}/3 present): \
181             KUBE_API_SERVER={}, KUBE_TOKEN_PATH={}, KUBE_CA_CERT_PATH={}. \
182             All three must be set for explicit auth. Falling back to try_default().",
183            set_count,
184            api_server.as_deref().unwrap_or("(not set)"),
185            token_path.as_deref().unwrap_or("(not set)"),
186            ca_cert_path.as_deref().unwrap_or("(not set)"),
187        );
188    }
189
190    match (api_server, token_path, ca_cert_path) {
191        (Some(server), Some(token_path), Some(ca_cert_path)) => KubeAuthMode::Explicit {
192            server,
193            token_path,
194            ca_cert_path,
195        },
196        _ => KubeAuthMode::Default,
197    }
198}
199
200/// Build a `kube::Client` from explicit file-based credentials.
201///
202/// Reads the token from `token_path` and the CA certificate from `ca_cert_path`,
203/// then constructs an in-memory kubeconfig pointing at `server`.
204///
205/// # Errors
206/// Returns an error string if either file cannot be read, the CA certificate is not
207/// valid PEM, or the kube client cannot be initialized.
208#[cfg(feature = "k8s-token-review")]
209pub(crate) async fn build_explicit_kube_client(
210    server: String,
211    token_path: String,
212    ca_cert_path: String,
213) -> Result<Client, String> {
214    // Validate token file is readable first so callers get a clear error message.
215    // The content is not used here — kube will re-read the file on each API call via
216    // token_file in the kubeconfig, which is correct for rotating SA tokens.
217    tokio::fs::read_to_string(&token_path)
218        .await
219        .map_err(|e| format!("failed to read token file '{}': {}", token_path, e))?;
220
221    // Validate CA certificate file is readable and contains a PEM certificate block.
222    // kube defers TLS setup to the first API call, so we validate eagerly here to
223    // surface misconfiguration at startup rather than on the first request.
224    let ca_bytes = tokio::fs::read(&ca_cert_path).await.map_err(|e| {
225        format!(
226            "failed to read CA certificate file '{}': {}",
227            ca_cert_path, e
228        )
229    })?;
230    let ca_pem = String::from_utf8_lossy(&ca_bytes);
231    if !ca_pem.contains("-----BEGIN CERTIFICATE-----") {
232        return Err(format!(
233            "CA certificate file '{}' does not contain a valid PEM certificate block",
234            ca_cert_path
235        ));
236    }
237
238    // Build an in-memory kubeconfig using file paths. kube will re-read the token
239    // file on each API call (correct for short-lived, rotating SA tokens) and will
240    // parse the CA cert PEM when building the TLS stack.
241    let kubeconfig = Kubeconfig {
242        clusters: vec![NamedCluster {
243            name: "standalone".to_string(),
244            cluster: Some(Cluster {
245                server: Some(server),
246                certificate_authority: Some(ca_cert_path),
247                ..Default::default()
248            }),
249        }],
250        auth_infos: vec![NamedAuthInfo {
251            name: "standalone".to_string(),
252            auth_info: Some(AuthInfo {
253                // Use token_file so the token is re-read on rotation, not embedded.
254                token_file: Some(token_path),
255                ..Default::default()
256            }),
257        }],
258        contexts: vec![NamedContext {
259            name: "standalone".to_string(),
260            context: Some(kube::config::Context {
261                cluster: "standalone".to_string(),
262                user: Some("standalone".to_string()),
263                ..Default::default()
264            }),
265        }],
266        current_context: Some("standalone".to_string()),
267        ..Default::default()
268    };
269
270    let config = Config::from_custom_kubeconfig(kubeconfig, &KubeConfigOptions::default())
271        .await
272        .map_err(|e| format!("failed to build Kubernetes client config: {}", e))?;
273
274    Client::try_from(config).map_err(|e| format!("failed to create Kubernetes client: {}", e))
275}
276
277/// Build a `kube::Client` using the resolved auth mode.
278///
279/// Priority order:
280/// 1. Explicit env vars (`KUBE_API_SERVER` + `KUBE_TOKEN_PATH` + `KUBE_CA_CERT_PATH`)
281/// 2. `KUBECONFIG` env / `~/.kube/config` / in-cluster SA mount (via `try_default`)
282#[cfg(feature = "k8s-token-review")]
283async fn build_kube_client() -> Result<Client, String> {
284    match detect_kube_auth_mode() {
285        KubeAuthMode::Explicit {
286            server,
287            token_path,
288            ca_cert_path,
289        } => {
290            debug!(
291                "Kubernetes auth mode: explicit (KUBE_API_SERVER={})",
292                server
293            );
294            build_explicit_kube_client(server, token_path, ca_cert_path).await
295        }
296        KubeAuthMode::Default => {
297            debug!("Kubernetes auth mode: try_default (KUBECONFIG / ~/.kube/config / in-cluster)");
298            Client::try_default()
299                .await
300                .map_err(|e| format!("Failed to create Kubernetes client: {}", e))
301        }
302    }
303}
304
305/// Authentication middleware
306///
307/// Validates that the request includes a Bearer token in the Authorization header.
308/// This token should be a Kubernetes ServiceAccount token.
309///
310/// # Headers
311/// - `Authorization: Bearer <token>` - Required
312///
313/// # Errors
314/// Returns 401 Unauthorized if:
315/// - No Authorization header is present
316/// - Authorization header is malformed
317/// - Token is invalid (future implementation)
318pub async fn authenticate(
319    headers: HeaderMap,
320    request: Request,
321    next: Next,
322) -> Result<Response, (StatusCode, Json<AuthError>)> {
323    // Extract Authorization header
324    let auth_header = headers
325        .get("authorization")
326        .and_then(|h| h.to_str().ok())
327        .ok_or_else(|| {
328            warn!("Missing Authorization header");
329            (
330                StatusCode::UNAUTHORIZED,
331                Json(AuthError {
332                    error: "Missing Authorization header".to_string(),
333                }),
334            )
335        })?;
336
337    // Check Bearer token format
338    if !auth_header.starts_with("Bearer ") {
339        warn!("Invalid Authorization header format");
340        return Err((
341            StatusCode::UNAUTHORIZED,
342            Json(AuthError {
343                error: "Invalid Authorization header format. Expected: Bearer <token>".to_string(),
344            }),
345        ));
346    }
347
348    let token = &auth_header[7..]; // Skip "Bearer "
349
350    // Basic validation: token should not be empty
351    if token.is_empty() {
352        warn!("Empty token in Authorization header");
353        return Err((
354            StatusCode::UNAUTHORIZED,
355            Json(AuthError {
356                error: "Empty token".to_string(),
357            }),
358        ));
359    }
360
361    // Validate token with Kubernetes TokenReview API if feature is enabled
362    #[cfg(feature = "k8s-token-review")]
363    if let Err(e) = validate_token_with_k8s(token).await {
364        warn!("Token validation failed: {}", e);
365        return Err((
366            StatusCode::UNAUTHORIZED,
367            Json(AuthError {
368                error: format!("Token validation failed: {}", e),
369            }),
370        ));
371    }
372
373    #[cfg(feature = "k8s-token-review")]
374    debug!("Token validated with Kubernetes TokenReview API");
375
376    #[cfg(not(feature = "k8s-token-review"))]
377    debug!("Token validation: basic mode (presence check only)");
378
379    Ok(next.run(request).await)
380}
381
382/// Validate a token using Kubernetes TokenReview API
383///
384/// This function sends the token to the Kubernetes API server for validation.
385/// It verifies that the token is authentic, not expired, and belongs to a valid
386/// service account.
387///
388/// Additionally validates:
389/// - Token audience matches expected audiences
390/// - Service account namespace is in allowed list (if configured)
391/// - Service account name is in allowed list (if configured)
392///
393/// # Arguments
394/// * `token` - The bearer token to validate
395///
396/// # Returns
397/// * `Ok(())` if the token is valid and authorized
398/// * `Err(String)` if validation fails
399#[cfg(feature = "k8s-token-review")]
400pub(crate) async fn validate_token_with_k8s(token: &str) -> Result<(), String> {
401    // Load security configuration
402    let config = TokenReviewConfig::from_env();
403
404    // Create Kubernetes client using the resolved auth mode.
405    let client = build_kube_client().await?;
406
407    // Create TokenReview API client
408    let token_reviews: Api<TokenReview> = Api::all(client);
409
410    // Build TokenReview request with audience validation
411    let audiences = if !config.audiences.is_empty() {
412        Some(config.audiences.clone())
413    } else {
414        None
415    };
416
417    let token_review = TokenReview {
418        metadata: Default::default(),
419        spec: k8s_openapi::api::authentication::v1::TokenReviewSpec {
420            token: Some(token.to_string()),
421            audiences,
422        },
423        status: None,
424    };
425
426    // Submit TokenReview request
427    let result = token_reviews
428        .create(&Default::default(), &token_review)
429        .await
430        .map_err(|e| {
431            error!("TokenReview API call failed: {}", e);
432            format!("Failed to validate token with Kubernetes API: {}", e)
433        })?;
434
435    // Check if token is authenticated
436    let status = result
437        .status
438        .ok_or_else(|| "TokenReview status not available".to_string())?;
439
440    if status.authenticated != Some(true) {
441        let error_msg = status
442            .error
443            .unwrap_or_else(|| "Token not authenticated".to_string());
444        warn!("Token authentication failed: {}", error_msg);
445        return Err(error_msg);
446    }
447
448    debug!("Token authenticated successfully");
449
450    // Validate user information and authorization
451    let user = status.user.ok_or_else(|| {
452        warn!("TokenReview succeeded but no user information returned");
453        "No user information in TokenReview response".to_string()
454    })?;
455
456    let username = user.username.as_deref().unwrap_or("");
457    debug!("Authenticated user: {}", username);
458
459    // Validate namespace restriction
460    if let Some(namespace) = TokenReviewConfig::extract_namespace(username) {
461        if !config.is_namespace_allowed(&namespace) {
462            warn!(
463                "ServiceAccount from unauthorized namespace: {} (from {})",
464                namespace, username
465            );
466            return Err(format!(
467                "ServiceAccount from unauthorized namespace: {}",
468                namespace
469            ));
470        }
471        debug!("Namespace {} is allowed", namespace);
472    }
473
474    // Validate service account allowlist
475    if !config.is_service_account_allowed(username) {
476        warn!("ServiceAccount not in allowlist: {}", username);
477        return Err(format!("ServiceAccount not authorized: {}", username));
478    }
479
480    debug!("ServiceAccount {} is allowed", username);
481    Ok(())
482}