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::{Api, Client};
43#[cfg(feature = "k8s-token-review")]
44use std::env;
45#[cfg(feature = "k8s-token-review")]
46use tracing::error;
47
48/// Error response for authentication failures
49#[derive(Serialize)]
50pub struct AuthError {
51    pub error: String,
52}
53
54/// Configuration for TokenReview security policies
55#[cfg(feature = "k8s-token-review")]
56#[derive(Debug, Clone)]
57pub struct TokenReviewConfig {
58    /// Expected audiences for token validation
59    pub audiences: Vec<String>,
60    /// Allowed namespaces (empty = allow all)
61    pub allowed_namespaces: Vec<String>,
62    /// Allowed service accounts in format "system:serviceaccount:namespace:name" (empty = allow all)
63    pub allowed_service_accounts: Vec<String>,
64}
65
66#[cfg(feature = "k8s-token-review")]
67impl TokenReviewConfig {
68    /// Load configuration from environment variables
69    pub fn from_env() -> Self {
70        let audiences = match env::var("BIND_TOKEN_AUDIENCES") {
71            Ok(val) if !val.trim().is_empty() => val
72                .split(',')
73                .map(|s| s.trim().to_string())
74                .filter(|s| !s.is_empty())
75                .collect(),
76            _ => vec!["bindcar".to_string()],
77        };
78
79        let allowed_namespaces = env::var("BIND_ALLOWED_NAMESPACES")
80            .unwrap_or_default()
81            .split(',')
82            .map(|s| s.trim().to_string())
83            .filter(|s| !s.is_empty())
84            .collect();
85
86        let allowed_service_accounts = env::var("BIND_ALLOWED_SERVICE_ACCOUNTS")
87            .unwrap_or_default()
88            .split(',')
89            .map(|s| s.trim().to_string())
90            .filter(|s| !s.is_empty())
91            .collect();
92
93        let config = Self {
94            audiences,
95            allowed_namespaces,
96            allowed_service_accounts,
97        };
98
99        debug!("TokenReview config loaded: audiences={:?}, allowed_namespaces={:?}, allowed_service_accounts={:?}",
100            config.audiences, config.allowed_namespaces, config.allowed_service_accounts);
101
102        config
103    }
104
105    /// Check if a namespace is allowed
106    pub(crate) fn is_namespace_allowed(&self, namespace: &str) -> bool {
107        // Empty list means allow all
108        if self.allowed_namespaces.is_empty() {
109            return true;
110        }
111        self.allowed_namespaces.contains(&namespace.to_string())
112    }
113
114    /// Check if a service account is allowed
115    pub(crate) fn is_service_account_allowed(&self, username: &str) -> bool {
116        // Empty list means allow all
117        if self.allowed_service_accounts.is_empty() {
118            return true;
119        }
120        self.allowed_service_accounts
121            .contains(&username.to_string())
122    }
123
124    /// Extract namespace from service account username
125    /// Format: "system:serviceaccount:namespace:name"
126    pub(crate) fn extract_namespace(username: &str) -> Option<String> {
127        let parts: Vec<&str> = username.split(':').collect();
128        if parts.len() != 4 || parts[0] != "system" || parts[1] != "serviceaccount" {
129            return None;
130        }
131        Some(parts[2].to_string())
132    }
133}
134
135/// Authentication middleware
136///
137/// Validates that the request includes a Bearer token in the Authorization header.
138/// This token should be a Kubernetes ServiceAccount token.
139///
140/// # Headers
141/// - `Authorization: Bearer <token>` - Required
142///
143/// # Errors
144/// Returns 401 Unauthorized if:
145/// - No Authorization header is present
146/// - Authorization header is malformed
147/// - Token is invalid (future implementation)
148pub async fn authenticate(
149    headers: HeaderMap,
150    request: Request,
151    next: Next,
152) -> Result<Response, (StatusCode, Json<AuthError>)> {
153    // Extract Authorization header
154    let auth_header = headers
155        .get("authorization")
156        .and_then(|h| h.to_str().ok())
157        .ok_or_else(|| {
158            warn!("Missing Authorization header");
159            (
160                StatusCode::UNAUTHORIZED,
161                Json(AuthError {
162                    error: "Missing Authorization header".to_string(),
163                }),
164            )
165        })?;
166
167    // Check Bearer token format
168    if !auth_header.starts_with("Bearer ") {
169        warn!("Invalid Authorization header format");
170        return Err((
171            StatusCode::UNAUTHORIZED,
172            Json(AuthError {
173                error: "Invalid Authorization header format. Expected: Bearer <token>".to_string(),
174            }),
175        ));
176    }
177
178    let token = &auth_header[7..]; // Skip "Bearer "
179
180    // Basic validation: token should not be empty
181    if token.is_empty() {
182        warn!("Empty token in Authorization header");
183        return Err((
184            StatusCode::UNAUTHORIZED,
185            Json(AuthError {
186                error: "Empty token".to_string(),
187            }),
188        ));
189    }
190
191    // Validate token with Kubernetes TokenReview API if feature is enabled
192    #[cfg(feature = "k8s-token-review")]
193    if let Err(e) = validate_token_with_k8s(token).await {
194        warn!("Token validation failed: {}", e);
195        return Err((
196            StatusCode::UNAUTHORIZED,
197            Json(AuthError {
198                error: format!("Token validation failed: {}", e),
199            }),
200        ));
201    }
202
203    #[cfg(feature = "k8s-token-review")]
204    debug!("Token validated with Kubernetes TokenReview API");
205
206    #[cfg(not(feature = "k8s-token-review"))]
207    debug!("Token validation: basic mode (presence check only)");
208
209    Ok(next.run(request).await)
210}
211
212/// Validate a token using Kubernetes TokenReview API
213///
214/// This function sends the token to the Kubernetes API server for validation.
215/// It verifies that the token is authentic, not expired, and belongs to a valid
216/// service account.
217///
218/// Additionally validates:
219/// - Token audience matches expected audiences
220/// - Service account namespace is in allowed list (if configured)
221/// - Service account name is in allowed list (if configured)
222///
223/// # Arguments
224/// * `token` - The bearer token to validate
225///
226/// # Returns
227/// * `Ok(())` if the token is valid and authorized
228/// * `Err(String)` if validation fails
229#[cfg(feature = "k8s-token-review")]
230pub(crate) async fn validate_token_with_k8s(token: &str) -> Result<(), String> {
231    // Load security configuration
232    let config = TokenReviewConfig::from_env();
233
234    // Create Kubernetes client
235    let client = Client::try_default()
236        .await
237        .map_err(|e| format!("Failed to create Kubernetes client: {}", e))?;
238
239    // Create TokenReview API client
240    let token_reviews: Api<TokenReview> = Api::all(client);
241
242    // Build TokenReview request with audience validation
243    let audiences = if !config.audiences.is_empty() {
244        Some(config.audiences.clone())
245    } else {
246        None
247    };
248
249    let token_review = TokenReview {
250        metadata: Default::default(),
251        spec: k8s_openapi::api::authentication::v1::TokenReviewSpec {
252            token: Some(token.to_string()),
253            audiences,
254        },
255        status: None,
256    };
257
258    // Submit TokenReview request
259    let result = token_reviews
260        .create(&Default::default(), &token_review)
261        .await
262        .map_err(|e| {
263            error!("TokenReview API call failed: {}", e);
264            format!("Failed to validate token with Kubernetes API: {}", e)
265        })?;
266
267    // Check if token is authenticated
268    let status = result
269        .status
270        .ok_or_else(|| "TokenReview status not available".to_string())?;
271
272    if status.authenticated != Some(true) {
273        let error_msg = status
274            .error
275            .unwrap_or_else(|| "Token not authenticated".to_string());
276        warn!("Token authentication failed: {}", error_msg);
277        return Err(error_msg);
278    }
279
280    debug!("Token authenticated successfully");
281
282    // Validate user information and authorization
283    let user = status.user.ok_or_else(|| {
284        warn!("TokenReview succeeded but no user information returned");
285        "No user information in TokenReview response".to_string()
286    })?;
287
288    let username = user.username.as_deref().unwrap_or("");
289    debug!("Authenticated user: {}", username);
290
291    // Validate namespace restriction
292    if let Some(namespace) = TokenReviewConfig::extract_namespace(username) {
293        if !config.is_namespace_allowed(&namespace) {
294            warn!(
295                "ServiceAccount from unauthorized namespace: {} (from {})",
296                namespace, username
297            );
298            return Err(format!(
299                "ServiceAccount from unauthorized namespace: {}",
300                namespace
301            ));
302        }
303        debug!("Namespace {} is allowed", namespace);
304    }
305
306    // Validate service account allowlist
307    if !config.is_service_account_allowed(username) {
308        warn!("ServiceAccount not in allowlist: {}", username);
309        return Err(format!("ServiceAccount not authorized: {}", username));
310    }
311
312    debug!("ServiceAccount {} is allowed", username);
313    Ok(())
314}