spiral-auth 0.1.7

Spiral credentials: bearer tokens and the providers that obtain them
Documentation
use std::env;
use std::fmt::Debug;

use reqwest::Client;
use tracing::debug;
use url::Url;

use super::jwt_bearer;
use crate::http::shared_client;
use crate::{CredentialResult, Token};

/// Authenticates a workload with a Kubernetes projected ServiceAccount token, as a JWT-bearer
/// grant at the authorization server. The token's audience must be the authorization server's
/// token endpoint, and its cluster's issuer must publish OpenID Connect discovery.
///
/// This provider is **opt-in**: it only activates when `SPIRAL_K8S_SA_TOKEN_PATH` is explicitly
/// set to a non-empty value. When the env var is absent or empty, `token()` immediately returns
/// `Ok(None)` so the fallback chain continues uninterrupted. This prevents the provider from
/// auto-firing on any k8s pod (where the default SA token path is always present) and aborting
/// auth fallback for GCP/AWS workloads.
///
/// When opted in, re-reads the token file on every call so kubelet token rotation is picked up
/// automatically.
#[derive(Debug)]
pub struct KubernetesAuthProvider {
    client: Client,
    token_endpoint: Url,
    workload_id: String,
}

impl KubernetesAuthProvider {
    /// Obtain the workload's tokens at `token_endpoint`, the authorization server's.
    pub fn new(token_endpoint: Url, workload_id: String) -> Self {
        KubernetesAuthProvider {
            client: shared_client(),
            token_endpoint,
            workload_id,
        }
    }

    /// Returns the configured token path only when `SPIRAL_K8S_SA_TOKEN_PATH` is explicitly
    /// set to a non-empty value. Returns `None` otherwise (provider is inactive).
    fn configured_token_path() -> Option<String> {
        env::var("SPIRAL_K8S_SA_TOKEN_PATH")
            .ok()
            .filter(|s| !s.is_empty())
    }

    fn read_subject_token_from(path: &str) -> Option<String> {
        std::fs::read_to_string(path)
            .ok()
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
    }
}

impl KubernetesAuthProvider {
    /// A usable token, or `None` when this provider has no credentials to offer.
    pub async fn token(&self) -> CredentialResult<Option<Token>> {
        let Some(path) = Self::configured_token_path() else {
            return Ok(None);
        };

        let Some(sa_token) = Self::read_subject_token_from(&path) else {
            return Ok(None);
        };

        let token = jwt_bearer(
            &self.client,
            &self.token_endpoint,
            self.workload_id.clone(),
            sa_token,
            None,
        )
        .await?;

        debug!("KubernetesAuthProvider obtained a workload token");
        Ok(Some(token))
    }
}

impl_token_source!(KubernetesAuthProvider);

#[cfg(test)]
mod tests {
    use std::io::Write;

    use super::*;

    // All SPIRAL_K8S_SA_TOKEN_PATH env-var cases in one test to avoid parallel-test races.
    // Safety: process-wide env mutation; this test must not run concurrently with others that
    // touch the same var. Combining all cases into one test guarantees sequential execution.
    #[test]
    fn test_configured_token_path() {
        unsafe {
            // unset -> None
            env::remove_var("SPIRAL_K8S_SA_TOKEN_PATH");
            assert!(KubernetesAuthProvider::configured_token_path().is_none());

            // empty string -> None
            env::set_var("SPIRAL_K8S_SA_TOKEN_PATH", "");
            assert!(KubernetesAuthProvider::configured_token_path().is_none());

            // non-empty -> Some(path)
            env::set_var("SPIRAL_K8S_SA_TOKEN_PATH", "/custom/path/token");
            assert_eq!(
                KubernetesAuthProvider::configured_token_path().as_deref(),
                Some("/custom/path/token")
            );

            // clean up
            env::remove_var("SPIRAL_K8S_SA_TOKEN_PATH");
        }
    }

    #[test]
    fn test_read_subject_token_absent_path_returns_none() {
        assert!(
            KubernetesAuthProvider::read_subject_token_from("/nonexistent/spiral/xyz").is_none()
        );
    }

    #[test]
    fn test_read_subject_token_reads_and_trims_file() {
        let dir = std::env::temp_dir();
        let path = dir.join("spiral_k8s_test_token");
        let mut f = std::fs::File::create(&path).unwrap();
        f.write_all(b"  my-sa-token\n").unwrap();
        let got = KubernetesAuthProvider::read_subject_token_from(path.to_str().unwrap());
        assert_eq!(got.as_deref(), Some("my-sa-token"));
    }

    #[test]
    fn test_read_subject_token_empty_file_returns_none() {
        let dir = std::env::temp_dir();
        let path = dir.join("spiral_k8s_test_token_empty");
        std::fs::write(&path, b"   \n").unwrap();
        assert!(KubernetesAuthProvider::read_subject_token_from(path.to_str().unwrap()).is_none());
    }
}