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/// Environment variable holding a shared API token.
60///
61/// When set to a non-empty value, every request's Bearer token is compared
62/// against it in constant time. This provides real authentication for
63/// deployments that do not use the Kubernetes TokenReview API (`drone` mode,
64/// bare-metal, local testing), closing the "presence-only" gap (B-4) where any
65/// non-empty token was accepted.
66pub const BIND_API_TOKEN_ENV: &str = "BIND_API_TOKEN";
67
68/// Validate the presented Bearer token against the shared secret in
69/// [`BIND_API_TOKEN_ENV`], if configured.
70///
71/// Returns `Ok(())` when the shared-secret mode is **not** configured (the env
72/// var is unset or empty) so this layer is opt-in and composes with the
73/// TokenReview layer. When configured, the comparison is constant-time to avoid
74/// leaking the secret through response timing.
75///
76/// # Errors
77/// Returns `Err` with a generic message when a shared secret is configured but
78/// the presented token does not match.
79pub(crate) fn validate_shared_secret(token: &str) -> Result<(), String> {
80 let expected = std::env::var(BIND_API_TOKEN_ENV)
81 .ok()
82 .filter(|value| !value.is_empty());
83 compare_shared_secret(token, expected.as_deref())
84}
85
86/// Pure constant-time comparison of a presented token against an optional
87/// expected shared secret.
88///
89/// When `expected` is `None` the shared-secret mode is not configured and the
90/// check is a no-op (`Ok`). When `Some`, the comparison is constant-time.
91///
92/// # Errors
93/// Returns `Err` when a shared secret is configured but does not match.
94pub(crate) fn compare_shared_secret(token: &str, expected: Option<&str>) -> Result<(), String> {
95 use sha2::{Digest, Sha256};
96 use subtle::ConstantTimeEq;
97
98 let Some(expected) = expected else {
99 return Ok(());
100 };
101
102 // Compare fixed-size SHA-256 digests rather than the raw bytes, so the
103 // comparison time does not depend on the secret's length (A15). `ct_eq`
104 // short-circuits on unequal lengths, which would otherwise leak whether the
105 // presented token matched the secret's length; equal-length digests remove
106 // that side channel while keeping the byte comparison constant-time.
107 let token_hash = Sha256::digest(token.as_bytes());
108 let expected_hash = Sha256::digest(expected.as_bytes());
109 let matches: bool = token_hash.as_slice().ct_eq(expected_hash.as_slice()).into();
110 if matches {
111 Ok(())
112 } else {
113 Err("Invalid API token".to_string())
114 }
115}
116
117/// Returns `true` if `host` denotes a loopback-only bind address.
118///
119/// Used by the startup guard: binding to loopback means the API is not reachable
120/// from other pods/hosts, so weaker auth is tolerable for local development.
121pub fn is_loopback_host(host: &str) -> bool {
122 if host.eq_ignore_ascii_case("localhost") {
123 return true;
124 }
125 host.parse::<std::net::IpAddr>()
126 .map(|ip| ip.is_loopback())
127 .unwrap_or(false)
128}
129
130/// Decide whether bindcar is allowed to start given its authentication posture.
131///
132/// bindcar must not silently expose an unauthenticated (or presence-only) API on
133/// a non-loopback interface (B-4). It may start when **any** of the following
134/// holds:
135/// - real authentication is active (`auth_enabled && has_real_auth`), or
136/// - the bind address is loopback-only, or
137/// - the operator explicitly accepted the risk (`insecure_override`).
138///
139/// # Arguments
140/// * `auth_enabled` - Whether the auth middleware is applied (`!DISABLE_AUTH`).
141/// * `has_real_auth` - Whether a real authenticator is configured (TokenReview
142/// feature or a shared secret).
143/// * `bind_host` - The host portion of the listen address.
144/// * `insecure_override` - Whether the operator passed the explicit insecure override.
145///
146/// # Errors
147/// Returns `Err` with an operator-facing message when the configuration would
148/// expose an unauthenticated API on a non-loopback interface.
149pub fn check_startup_auth_posture(
150 auth_enabled: bool,
151 has_real_auth: bool,
152 bind_host: &str,
153 insecure_override: bool,
154) -> Result<(), String> {
155 let secure = auth_enabled && has_real_auth;
156 if secure || insecure_override || is_loopback_host(bind_host) {
157 return Ok(());
158 }
159
160 Err(format!(
161 "refusing to start: the API is bound to a non-loopback interface ({bind_host}) without \
162 real authentication. Enable the Kubernetes TokenReview feature, set {BIND_API_TOKEN_ENV}, \
163 bind to loopback, or pass --i-know-this-is-insecure to override."
164 ))
165}
166
167/// Environment variable that explicitly opts into allow-all authorization when
168/// the Kubernetes TokenReview feature is active but no namespace / service-account
169/// allowlist is configured.
170///
171/// See [`check_authorization_posture`] (A2): without an allowlist, every
172/// authenticated ServiceAccount in the cluster is authorized for full DNS
173/// control, so bindcar refuses to start in that posture unless this is set.
174pub const ALLOW_ANY_SERVICE_ACCOUNT_ENV: &str = "BIND_ALLOW_ANY_SERVICEACCOUNT";
175
176/// Decide whether bindcar may start given its TokenReview authorization posture.
177///
178/// With the Kubernetes TokenReview feature active, an empty namespace /
179/// service-account allowlist authorizes **every** authenticated ServiceAccount in
180/// the cluster — a confused-deputy where any compromised pod's token grants full
181/// create/delete/modify over DNS zones (A2). bindcar refuses to start in that
182/// posture unless the operator explicitly accepts it, mirroring the
183/// [`check_startup_auth_posture`] pattern.
184///
185/// # Arguments
186/// * `authorization_restricted` - Whether an allowlist is configured
187/// ([`TokenReviewConfig::is_authorization_restricted`]).
188/// * `allow_any_override` - Whether the operator set [`ALLOW_ANY_SERVICE_ACCOUNT_ENV`].
189///
190/// # Errors
191/// Returns `Err` with an operator-facing message when authorization is
192/// unrestricted and the override was not set.
193#[cfg(feature = "k8s-token-review")]
194pub fn check_authorization_posture(
195 authorization_restricted: bool,
196 allow_any_override: bool,
197) -> Result<(), String> {
198 if authorization_restricted || allow_any_override {
199 return Ok(());
200 }
201
202 Err(format!(
203 "refusing to start: the Kubernetes TokenReview feature is enabled but neither \
204 BIND_ALLOWED_NAMESPACES nor BIND_ALLOWED_SERVICE_ACCOUNTS is set, so ANY authenticated \
205 ServiceAccount in the cluster would be authorized for full DNS control. Configure an \
206 allowlist, or set {ALLOW_ANY_SERVICE_ACCOUNT_ENV}=true to explicitly accept allow-all."
207 ))
208}
209
210/// Returns `true` if a real authenticator is configured at runtime: either the
211/// Kubernetes TokenReview feature is compiled in, or a shared secret is set.
212pub fn has_real_auth() -> bool {
213 cfg!(feature = "k8s-token-review") || shared_secret_configured()
214}
215
216/// Returns `true` when shared-secret authentication is the selected mode — i.e.
217/// [`BIND_API_TOKEN_ENV`] is set to a non-empty value.
218///
219/// Shared-secret and Kubernetes TokenReview are **mutually exclusive**: a single
220/// Bearer token cannot be both the shared secret and a valid ServiceAccount
221/// token. When a shared secret is configured it is the selected mode, so the
222/// TokenReview path is not consulted at request time and the fail-closed
223/// TokenReview authorization posture (A2) is not enforced at startup. TokenReview
224/// is used only when no shared secret is set (and the feature is compiled in).
225pub fn shared_secret_configured() -> bool {
226 std::env::var(BIND_API_TOKEN_ENV)
227 .map(|value| !value.is_empty())
228 .unwrap_or(false)
229}
230
231/// Configuration for TokenReview security policies
232#[cfg(feature = "k8s-token-review")]
233#[derive(Debug, Clone)]
234pub struct TokenReviewConfig {
235 /// Expected audiences for token validation
236 pub audiences: Vec<String>,
237 /// Allowed namespaces (empty = allow all)
238 pub allowed_namespaces: Vec<String>,
239 /// Allowed service accounts in format "system:serviceaccount:namespace:name" (empty = allow all)
240 pub allowed_service_accounts: Vec<String>,
241}
242
243#[cfg(feature = "k8s-token-review")]
244impl TokenReviewConfig {
245 /// Load configuration from environment variables
246 pub fn from_env() -> Self {
247 let audiences = match env::var("BIND_TOKEN_AUDIENCES") {
248 Ok(val) if !val.trim().is_empty() => val
249 .split(',')
250 .map(|s| s.trim().to_string())
251 .filter(|s| !s.is_empty())
252 .collect(),
253 _ => vec!["bindcar".to_string()],
254 };
255
256 let allowed_namespaces = env::var("BIND_ALLOWED_NAMESPACES")
257 .unwrap_or_default()
258 .split(',')
259 .map(|s| s.trim().to_string())
260 .filter(|s| !s.is_empty())
261 .collect();
262
263 let allowed_service_accounts = env::var("BIND_ALLOWED_SERVICE_ACCOUNTS")
264 .unwrap_or_default()
265 .split(',')
266 .map(|s| s.trim().to_string())
267 .filter(|s| !s.is_empty())
268 .collect();
269
270 let config = Self {
271 audiences,
272 allowed_namespaces,
273 allowed_service_accounts,
274 };
275
276 debug!("TokenReview config loaded: audiences={:?}, allowed_namespaces={:?}, allowed_service_accounts={:?}",
277 config.audiences, config.allowed_namespaces, config.allowed_service_accounts);
278
279 config
280 }
281
282 /// Check if a namespace is allowed
283 pub(crate) fn is_namespace_allowed(&self, namespace: &str) -> bool {
284 // Empty list means allow all
285 if self.allowed_namespaces.is_empty() {
286 return true;
287 }
288 self.allowed_namespaces.contains(&namespace.to_string())
289 }
290
291 /// Check if a service account is allowed
292 pub(crate) fn is_service_account_allowed(&self, username: &str) -> bool {
293 // Empty list means allow all
294 if self.allowed_service_accounts.is_empty() {
295 return true;
296 }
297 self.allowed_service_accounts
298 .contains(&username.to_string())
299 }
300
301 /// Returns `true` if this configuration restricts authorization to an explicit
302 /// allowlist (at least one of the namespace / service-account lists is
303 /// non-empty).
304 ///
305 /// When this returns `false` every authenticated ServiceAccount in the cluster
306 /// is authorized (allow-all) — the over-broad default that the startup guard
307 /// [`check_authorization_posture`] refuses unless explicitly overridden (A2).
308 pub fn is_authorization_restricted(&self) -> bool {
309 !self.allowed_namespaces.is_empty() || !self.allowed_service_accounts.is_empty()
310 }
311
312 /// Extract namespace from service account username
313 /// Format: "system:serviceaccount:namespace:name"
314 pub(crate) fn extract_namespace(username: &str) -> Option<String> {
315 let parts: Vec<&str> = username.split(':').collect();
316 if parts.len() != 4 || parts[0] != "system" || parts[1] != "serviceaccount" {
317 return None;
318 }
319 Some(parts[2].to_string())
320 }
321}
322
323/// Describes how the Kubernetes client will be authenticated when performing TokenReview calls.
324///
325/// The mode is determined by environment variables at startup. When all three explicit
326/// vars (`KUBE_API_SERVER`, `KUBE_TOKEN_PATH`, `KUBE_CA_CERT_PATH`) are present,
327/// bindcar builds the client directly from those files. Otherwise it falls back to
328/// `kube::Client::try_default()`, which checks `KUBECONFIG`, `~/.kube/config`, and then
329/// the in-cluster ServiceAccount mount in order.
330#[cfg(feature = "k8s-token-review")]
331#[derive(Debug)]
332pub enum KubeAuthMode {
333 /// All three explicit env vars are set; use them to build the client.
334 Explicit {
335 server: String,
336 token_path: String,
337 ca_cert_path: String,
338 },
339 /// Fall back to `kube::Client::try_default()`.
340 Default,
341}
342
343/// Inspect environment variables and return which Kubernetes auth mode should be used.
344///
345/// Returns `KubeAuthMode::Explicit` only when **all three** of `KUBE_API_SERVER`,
346/// `KUBE_TOKEN_PATH`, and `KUBE_CA_CERT_PATH` are present. If only some are set a
347/// warning is logged and `KubeAuthMode::Default` is returned so that existing
348/// in-cluster and kubeconfig deployments are unaffected.
349#[cfg(feature = "k8s-token-review")]
350pub fn detect_kube_auth_mode() -> KubeAuthMode {
351 let api_server = env::var("KUBE_API_SERVER").ok();
352 let token_path = env::var("KUBE_TOKEN_PATH").ok();
353 let ca_cert_path = env::var("KUBE_CA_CERT_PATH").ok();
354
355 // Warn on partial configuration so operators notice misconfiguration early.
356 let set_count = [&api_server, &token_path, &ca_cert_path]
357 .iter()
358 .filter(|v| v.is_some())
359 .count();
360
361 if set_count > 0 && set_count < 3 {
362 warn!(
363 "Partial KUBE_* env vars set ({}/3 present): \
364 KUBE_API_SERVER={}, KUBE_TOKEN_PATH={}, KUBE_CA_CERT_PATH={}. \
365 All three must be set for explicit auth. Falling back to try_default().",
366 set_count,
367 api_server.as_deref().unwrap_or("(not set)"),
368 token_path.as_deref().unwrap_or("(not set)"),
369 ca_cert_path.as_deref().unwrap_or("(not set)"),
370 );
371 }
372
373 match (api_server, token_path, ca_cert_path) {
374 (Some(server), Some(token_path), Some(ca_cert_path)) => KubeAuthMode::Explicit {
375 server,
376 token_path,
377 ca_cert_path,
378 },
379 _ => KubeAuthMode::Default,
380 }
381}
382
383/// Build a `kube::Client` from explicit file-based credentials.
384///
385/// Reads the token from `token_path` and the CA certificate from `ca_cert_path`,
386/// then constructs an in-memory kubeconfig pointing at `server`.
387///
388/// # Errors
389/// Returns an error string if either file cannot be read, the CA certificate is not
390/// valid PEM, or the kube client cannot be initialized.
391#[cfg(feature = "k8s-token-review")]
392pub(crate) async fn build_explicit_kube_client(
393 server: String,
394 token_path: String,
395 ca_cert_path: String,
396) -> Result<Client, String> {
397 // Validate token file is readable first so callers get a clear error message.
398 // The content is not used here — kube will re-read the file on each API call via
399 // token_file in the kubeconfig, which is correct for rotating SA tokens.
400 tokio::fs::read_to_string(&token_path)
401 .await
402 .map_err(|e| format!("failed to read token file '{}': {}", token_path, e))?;
403
404 // Validate CA certificate file is readable and contains a PEM certificate block.
405 // kube defers TLS setup to the first API call, so we validate eagerly here to
406 // surface misconfiguration at startup rather than on the first request.
407 let ca_bytes = tokio::fs::read(&ca_cert_path).await.map_err(|e| {
408 format!(
409 "failed to read CA certificate file '{}': {}",
410 ca_cert_path, e
411 )
412 })?;
413 let ca_pem = String::from_utf8_lossy(&ca_bytes);
414 if !ca_pem.contains("-----BEGIN CERTIFICATE-----") {
415 return Err(format!(
416 "CA certificate file '{}' does not contain a valid PEM certificate block",
417 ca_cert_path
418 ));
419 }
420
421 // Build an in-memory kubeconfig using file paths. kube will re-read the token
422 // file on each API call (correct for short-lived, rotating SA tokens) and will
423 // parse the CA cert PEM when building the TLS stack.
424 let kubeconfig = Kubeconfig {
425 clusters: vec![NamedCluster {
426 name: "standalone".to_string(),
427 cluster: Some(Cluster {
428 server: Some(server),
429 certificate_authority: Some(ca_cert_path),
430 ..Default::default()
431 }),
432 // kube 4.0 added `other` (flattened unknown kubeconfig fields) to the
433 // Named* wrappers; default it so we stay forward-compatible.
434 ..Default::default()
435 }],
436 auth_infos: vec![NamedAuthInfo {
437 name: "standalone".to_string(),
438 auth_info: Some(AuthInfo {
439 // Use token_file so the token is re-read on rotation, not embedded.
440 token_file: Some(token_path),
441 ..Default::default()
442 }),
443 ..Default::default()
444 }],
445 contexts: vec![NamedContext {
446 name: "standalone".to_string(),
447 context: Some(kube::config::Context {
448 cluster: "standalone".to_string(),
449 user: Some("standalone".to_string()),
450 ..Default::default()
451 }),
452 ..Default::default()
453 }],
454 current_context: Some("standalone".to_string()),
455 ..Default::default()
456 };
457
458 let config = Config::from_custom_kubeconfig(kubeconfig, &KubeConfigOptions::default())
459 .await
460 .map_err(|e| format!("failed to build Kubernetes client config: {}", e))?;
461
462 Client::try_from(config).map_err(|e| format!("failed to create Kubernetes client: {}", e))
463}
464
465/// Process-wide cached `kube::Client`, built once on first use.
466///
467/// Rebuilding the client on every request re-read the token/CA files and
468/// reconstructed the whole TLS stack, amplifying auth-path load onto the API
469/// server (A8). `kube::Client` is cheaply cloneable (an `Arc` internally), so a
470/// single shared instance is reused. A failed first init is not cached, so a
471/// transient startup error can still be retried on the next request.
472#[cfg(feature = "k8s-token-review")]
473static KUBE_CLIENT: tokio::sync::OnceCell<Client> = tokio::sync::OnceCell::const_new();
474
475/// Return the shared `kube::Client`, building it once on first call (A8).
476#[cfg(feature = "k8s-token-review")]
477async fn cached_kube_client() -> Result<Client, String> {
478 KUBE_CLIENT
479 .get_or_try_init(build_kube_client)
480 .await
481 .cloned()
482}
483
484/// Build a `kube::Client` using the resolved auth mode.
485///
486/// Priority order:
487/// 1. Explicit env vars (`KUBE_API_SERVER` + `KUBE_TOKEN_PATH` + `KUBE_CA_CERT_PATH`)
488/// 2. `KUBECONFIG` env / `~/.kube/config` / in-cluster SA mount (via `try_default`)
489#[cfg(feature = "k8s-token-review")]
490async fn build_kube_client() -> Result<Client, String> {
491 match detect_kube_auth_mode() {
492 KubeAuthMode::Explicit {
493 server,
494 token_path,
495 ca_cert_path,
496 } => {
497 debug!(
498 "Kubernetes auth mode: explicit (KUBE_API_SERVER={})",
499 server
500 );
501 build_explicit_kube_client(server, token_path, ca_cert_path).await
502 }
503 KubeAuthMode::Default => {
504 debug!("Kubernetes auth mode: try_default (KUBECONFIG / ~/.kube/config / in-cluster)");
505 Client::try_default()
506 .await
507 .map_err(|e| format!("Failed to create Kubernetes client: {}", e))
508 }
509 }
510}
511
512/// Authentication middleware
513///
514/// Validates that the request includes a Bearer token in the Authorization header.
515/// This token should be a Kubernetes ServiceAccount token.
516///
517/// # Headers
518/// - `Authorization: Bearer <token>` - Required
519///
520/// # Errors
521/// Returns 401 Unauthorized if:
522/// - No Authorization header is present
523/// - Authorization header is malformed
524/// - Token is invalid (future implementation)
525pub async fn authenticate(
526 headers: HeaderMap,
527 request: Request,
528 next: Next,
529) -> Result<Response, (StatusCode, Json<AuthError>)> {
530 // Extract Authorization header
531 let auth_header = headers
532 .get("authorization")
533 .and_then(|h| h.to_str().ok())
534 .ok_or_else(|| {
535 warn!("Missing Authorization header");
536 (
537 StatusCode::UNAUTHORIZED,
538 Json(AuthError {
539 error: "Missing Authorization header".to_string(),
540 }),
541 )
542 })?;
543
544 // Check Bearer token format
545 if !auth_header.starts_with("Bearer ") {
546 warn!("Invalid Authorization header format");
547 return Err((
548 StatusCode::UNAUTHORIZED,
549 Json(AuthError {
550 error: "Invalid Authorization header format. Expected: Bearer <token>".to_string(),
551 }),
552 ));
553 }
554
555 let token = &auth_header[7..]; // Skip "Bearer "
556
557 // Basic validation: token should not be empty
558 if token.is_empty() {
559 warn!("Empty token in Authorization header");
560 return Err((
561 StatusCode::UNAUTHORIZED,
562 Json(AuthError {
563 error: "Empty token".to_string(),
564 }),
565 ));
566 }
567
568 // Shared-secret validation (constant-time). No-op unless BIND_API_TOKEN is
569 // set, in which case the presented token MUST match — this closes the
570 // presence-only gap (B-4) for non-TokenReview deployments.
571 if let Err(e) = validate_shared_secret(token) {
572 warn!("Shared-secret token validation failed");
573 return Err((StatusCode::UNAUTHORIZED, Json(AuthError { error: e })));
574 }
575
576 // Validate token with Kubernetes TokenReview API — but only when TokenReview
577 // is the active mode. A configured shared secret (BIND_API_TOKEN) selects
578 // shared-secret auth, which is mutually exclusive with TokenReview (see
579 // `shared_secret_configured`), so we do not also require the Bearer token to
580 // be a valid ServiceAccount token.
581 //
582 // The detailed reason (namespace/SA/api error) is logged server-side only; the
583 // client gets a single generic "Unauthorized" so it cannot distinguish
584 // "valid token, wrong namespace" from "invalid token" from "apiserver
585 // unreachable" — an authorization/identity-enumeration oracle (A7).
586 #[cfg(feature = "k8s-token-review")]
587 if !shared_secret_configured() {
588 if let Err(e) = validate_token_with_k8s(token).await {
589 warn!("Token validation failed: {}", e);
590 return Err((
591 StatusCode::UNAUTHORIZED,
592 Json(AuthError {
593 error: "Unauthorized".to_string(),
594 }),
595 ));
596 }
597 debug!("Token validated with Kubernetes TokenReview API");
598 }
599
600 #[cfg(not(feature = "k8s-token-review"))]
601 debug!("Token validation: basic mode (presence check only)");
602
603 Ok(next.run(request).await)
604}
605
606/// Returns `true` if the audiences echoed back by a TokenReview
607/// (`status.audiences`) are compatible with the audiences bindcar requested
608/// (`spec.audiences`) — i.e. their intersection is non-empty.
609///
610/// The Kubernetes TokenReview contract states that a client which sets
611/// `spec.audiences` **must** verify that a compatible identifier is returned in
612/// `status.audiences`; otherwise it cannot tell whether the authenticator is
613/// audience-aware. Trusting `status.authenticated == true` alone (A1) lets a
614/// token minted for a *different* audience through — notably the
615/// apiserver-audience ServiceAccount token auto-mounted into every pod, which
616/// would otherwise defeat the `BIND_TOKEN_AUDIENCES` scoping entirely
617/// (confused-deputy). An empty returned set is therefore treated as incompatible.
618#[cfg(feature = "k8s-token-review")]
619pub(crate) fn audiences_compatible(requested: &[String], returned: &[String]) -> bool {
620 requested.iter().any(|a| returned.contains(a))
621}
622
623/// Validate a token using Kubernetes TokenReview API
624///
625/// This function sends the token to the Kubernetes API server for validation.
626/// It verifies that the token is authentic, not expired, and belongs to a valid
627/// service account.
628///
629/// Additionally validates:
630/// - Token audience matches expected audiences
631/// - Service account namespace is in allowed list (if configured)
632/// - Service account name is in allowed list (if configured)
633///
634/// # Arguments
635/// * `token` - The bearer token to validate
636///
637/// # Returns
638/// * `Ok(())` if the token is valid and authorized
639/// * `Err(String)` if validation fails
640#[cfg(feature = "k8s-token-review")]
641pub(crate) async fn validate_token_with_k8s(token: &str) -> Result<(), String> {
642 // Load security configuration
643 let config = TokenReviewConfig::from_env();
644
645 // Reuse the process-wide cached client (built once) rather than rebuilding
646 // the TLS stack on every request (A8).
647 let client = cached_kube_client().await?;
648
649 // Create TokenReview API client
650 let token_reviews: Api<TokenReview> = Api::all(client);
651
652 // Build TokenReview request with audience validation
653 let audiences = if !config.audiences.is_empty() {
654 Some(config.audiences.clone())
655 } else {
656 None
657 };
658
659 let token_review = TokenReview {
660 metadata: Default::default(),
661 spec: k8s_openapi::api::authentication::v1::TokenReviewSpec {
662 token: Some(token.to_string()),
663 audiences,
664 },
665 status: None,
666 };
667
668 // Submit TokenReview request
669 let result = token_reviews
670 .create(&Default::default(), &token_review)
671 .await
672 .map_err(|e| {
673 error!("TokenReview API call failed: {}", e);
674 format!("Failed to validate token with Kubernetes API: {}", e)
675 })?;
676
677 // Check if token is authenticated
678 let status = result
679 .status
680 .ok_or_else(|| "TokenReview status not available".to_string())?;
681
682 if status.authenticated != Some(true) {
683 let error_msg = status
684 .error
685 .unwrap_or_else(|| "Token not authenticated".to_string());
686 warn!("Token authentication failed: {}", error_msg);
687 return Err(error_msg);
688 }
689
690 // Enforce audience binding (A1). Because we set `spec.audiences`, the
691 // TokenReview contract requires confirming that a compatible audience is
692 // echoed back in `status.audiences`; relying on `authenticated == true` alone
693 // would accept a token minted for another audience (e.g. any pod's default
694 // ServiceAccount token) and silently defeat BIND_TOKEN_AUDIENCES scoping.
695 let returned_audiences = status.audiences.clone().unwrap_or_default();
696 if !audiences_compatible(&config.audiences, &returned_audiences) {
697 warn!(
698 "TokenReview returned incompatible audiences (requested={:?}, returned={:?})",
699 config.audiences, returned_audiences
700 );
701 return Err("Token audience is not valid for bindcar".to_string());
702 }
703
704 debug!("Token authenticated successfully");
705
706 // Validate user information and authorization
707 let user = status.user.ok_or_else(|| {
708 warn!("TokenReview succeeded but no user information returned");
709 "No user information in TokenReview response".to_string()
710 })?;
711
712 let username = user.username.as_deref().unwrap_or("");
713 debug!("Authenticated user: {}", username);
714
715 // Validate namespace restriction
716 if let Some(namespace) = TokenReviewConfig::extract_namespace(username) {
717 if !config.is_namespace_allowed(&namespace) {
718 warn!(
719 "ServiceAccount from unauthorized namespace: {} (from {})",
720 namespace, username
721 );
722 return Err(format!(
723 "ServiceAccount from unauthorized namespace: {}",
724 namespace
725 ));
726 }
727 debug!("Namespace {} is allowed", namespace);
728 }
729
730 // Validate service account allowlist
731 if !config.is_service_account_allowed(username) {
732 warn!("ServiceAccount not in allowlist: {}", username);
733 return Err(format!("ServiceAccount not authorized: {}", username));
734 }
735
736 debug!("ServiceAccount {} is allowed", username);
737 Ok(())
738}