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}