use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Mutex;
use std::time::{Duration, Instant};
use crate::auth::Principal;
const CACHE_TTL: Duration = Duration::from_secs(60);
pub struct KubernetesVerifier {
api: String,
own_token_path: std::path::PathBuf,
agent: ureq::Agent,
audience: Option<String>,
grants: Vec<(String, Principal)>,
cache: Mutex<HashMap<u64, (Option<Principal>, Instant)>>,
}
impl std::fmt::Debug for KubernetesVerifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KubernetesVerifier")
.field("api", &self.api)
.field("grants", &self.grants.len())
.finish_non_exhaustive()
}
}
impl KubernetesVerifier {
pub fn in_cluster(
audience: Option<String>,
grants: Vec<(String, Principal)>,
) -> Result<Self, String> {
let host = std::env::var("KUBERNETES_SERVICE_HOST")
.map_err(|_| "auth.kubernetes is enabled, but KUBERNETES_SERVICE_HOST is not set — this server is not running in a cluster".to_owned())?;
let port = std::env::var("KUBERNETES_SERVICE_PORT").unwrap_or_else(|_| "443".to_owned());
let ca = std::fs::read_to_string("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt")
.map_err(|error| format!("auth.kubernetes: reading the cluster CA: {error}"))?;
let mut roots = Vec::new();
for item in ureq::tls::parse_pem(ca.as_bytes()) {
match item {
Ok(ureq::tls::PemItem::Certificate(certificate)) => roots.push(certificate),
Ok(_) => {}
Err(_) => {
return Err("auth.kubernetes: the cluster CA is not PEM".to_owned());
}
}
}
if roots.is_empty() {
return Err("auth.kubernetes: the cluster CA held no certificate".to_owned());
}
let agent: ureq::Agent = ureq::Agent::config_builder()
.tls_config(
ureq::tls::TlsConfig::builder()
.root_certs(ureq::tls::RootCerts::new_with_certs(&roots))
.build(),
)
.timeout_global(Some(Duration::from_secs(10)))
.build()
.into();
Ok(Self {
api: format!("https://{host}:{port}"),
own_token_path: "/var/run/secrets/kubernetes.io/serviceaccount/token".into(),
agent,
audience,
grants,
cache: Mutex::new(HashMap::new()),
})
}
#[cfg(test)]
#[must_use]
pub fn for_tests(
api: String,
own_token_path: std::path::PathBuf,
grants: Vec<(String, Principal)>,
) -> Self {
Self {
api,
own_token_path,
agent: ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(5)))
.build()
.into(),
audience: None,
grants,
cache: Mutex::new(HashMap::new()),
}
}
pub fn verify(&self, presented: &str) -> Option<Principal> {
let key = {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
presented.hash(&mut hasher);
hasher.finish()
};
if let Some((verdict, at)) = self
.cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&key)
{
if at.elapsed() < CACHE_TTL {
return verdict.clone();
}
}
let verdict = self.review(presented);
self.cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(key, (verdict.clone(), Instant::now()));
verdict
}
fn review(&self, presented: &str) -> Option<Principal> {
let own = std::fs::read_to_string(&self.own_token_path).ok()?;
let mut spec = serde_json::json!({ "token": presented });
if let Some(audience) = &self.audience {
spec["audiences"] = serde_json::json!([audience]);
}
let body = serde_json::json!({
"apiVersion": "authentication.k8s.io/v1",
"kind": "TokenReview",
"spec": spec,
});
let response: serde_json::Value = self
.agent
.post(format!(
"{}/apis/authentication.k8s.io/v1/tokenreviews",
self.api
))
.header("authorization", format!("Bearer {}", own.trim()))
.send_json(&body)
.ok()?
.body_mut()
.read_json()
.ok()?;
if response["status"]["authenticated"] != serde_json::Value::Bool(true) {
return None;
}
let username = response["status"]["user"]["username"].as_str()?;
let subject = username.strip_prefix("system:serviceaccount:")?;
self.grants
.iter()
.find(|(granted, _)| granted == subject)
.map(|(_, principal)| principal.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_granted_service_account_becomes_its_principal() {
let server = tiny_http::Server::http("127.0.0.1:0").expect("binds");
let api = format!("http://{}", server.server_addr());
let handle = std::thread::spawn(move || {
let request = server.recv().expect("a review arrives");
assert_eq!(request.url(), "/apis/authentication.k8s.io/v1/tokenreviews");
let response = serde_json::json!({
"status": {
"authenticated": true,
"user": { "username": "system:serviceaccount:shop:billing" },
},
});
request
.respond(tiny_http::Response::from_string(response.to_string()))
.expect("responds");
});
let token_file = tempfile::NamedTempFile::new().expect("a file");
std::fs::write(token_file.path(), "own-token").expect("written");
let verifier = KubernetesVerifier::for_tests(
api,
token_file.path().to_path_buf(),
vec![(
"shop:billing".to_owned(),
Principal::new("shop/billing", ["shop"]),
)],
);
let principal = verifier.verify("some-projected-token").expect("granted");
assert_eq!(principal.name(), "shop/billing");
assert!(principal.may_read("shop"));
handle.join().expect("one review");
assert!(verifier.verify("some-projected-token").is_some());
}
#[test]
fn an_ungranted_or_unauthenticated_token_is_nobody() {
let server = tiny_http::Server::http("127.0.0.1:0").expect("binds");
let api = format!("http://{}", server.server_addr());
let handle = std::thread::spawn(move || {
for _ in 0..2 {
let request = server.recv().expect("a review arrives");
let response = serde_json::json!({
"status": {
"authenticated": true,
"user": { "username": "system:serviceaccount:other:nobody" },
},
});
request
.respond(tiny_http::Response::from_string(response.to_string()))
.expect("responds");
}
});
let token_file = tempfile::NamedTempFile::new().expect("a file");
std::fs::write(token_file.path(), "own-token").expect("written");
let verifier = KubernetesVerifier::for_tests(
api,
token_file.path().to_path_buf(),
vec![(
"shop:billing".to_owned(),
Principal::new("shop/billing", ["shop"]),
)],
);
assert!(
verifier.verify("token-one").is_none(),
"valid but ungranted"
);
assert!(verifier.verify("token-two").is_none());
handle.join().expect("two reviews");
}
}