use crate::introspect::IntrospectionResult;
use crate::{
Authentication, Authenticator, Subject,
error::{Error, Result},
};
use k8s_openapi::api::authentication::v1::{
TokenReview, TokenReviewSpec, TokenReviewStatus, UserInfo,
};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use kube::api::PostParams;
#[derive(Clone)]
pub struct KubernetesAuthenticator {
idp_id: Option<String>,
client: kube::client::Client,
audiences: Vec<String>,
issuers: Vec<String>,
}
impl KubernetesAuthenticator {
pub async fn try_new_with_default_client(
idp_id: Option<&str>,
audiences: Vec<String>,
) -> Result<Self> {
Ok(Self {
idp_id: idp_id.map(ToString::to_string),
client: Self::get_client().await?,
audiences,
issuers: vec![],
})
}
pub fn new_with_client(
idp_id: Option<&str>,
audiences: Vec<String>,
client: kube::client::Client,
) -> Result<Self> {
Ok(Self {
idp_id: idp_id.map(ToString::to_string),
client,
audiences,
issuers: vec![],
})
}
pub fn set_issuers(&mut self, issuers: Vec<String>) {
self.issuers = issuers;
}
async fn get_client() -> Result<kube::client::Client> {
kube::client::Client::try_default()
.await
.map_err(Error::KubernetesConfigError)
}
}
impl Authenticator for KubernetesAuthenticator {
async fn authenticate(
&self,
token: &str,
introspection: &IntrospectionResult,
) -> Result<Authentication> {
if !self.issuers.is_empty() {
match introspection {
IntrospectionResult::Unknown => {
return Err(Error::unauthenticated(
"Expected JWT token for Kubernetes Authenticator as issuer is set",
));
}
IntrospectionResult::JWTBearer { iss, .. } => {
if !self.issuers.iter().any(|i| iss.contains(i)) {
return Err(Error::IssuerMismatch {
expected: self.issuers.clone(),
actual: iss.iter().cloned().collect(),
});
}
}
}
}
let api = kube::api::Api::all(self.client.clone());
let review = api
.create(
&PostParams::default(),
&TokenReview {
metadata: ObjectMeta::default(),
spec: TokenReviewSpec {
audiences: Some(self.audiences.clone()),
token: Some(token.to_string()),
},
status: None,
},
)
.await
.map_err(Error::KubernetesTokenReviewError)?;
parse_review_status(review.status, &self.audiences, self.idp_id.as_deref())
}
fn can_handle_token(&self, token: &str, introspection_result: &IntrospectionResult) -> bool {
if token.is_empty() {
return false;
}
match introspection_result {
IntrospectionResult::Unknown => false,
IntrospectionResult::JWTBearer {
iss,
aud,
header: _,
} => {
(self.issuers.is_empty() || self.issuers.iter().any(|i| iss.contains(i)))
&& (self.audiences.is_empty() || self.audiences.iter().any(|a| aud.contains(a)))
}
}
}
fn idp_id(&self) -> Option<&String> {
self.idp_id.as_ref()
}
}
fn parse_review_status(
token_review: Option<TokenReviewStatus>,
audiences: &[String],
idp_id: Option<&str>,
) -> Result<Authentication> {
let token_review: TokenReviewStatus = token_review
.ok_or_else(|| Error::unauthenticated("Kubernetes TokenReview returned no status"))?;
if let Some(error) = token_review.error {
return Err(Error::unauthenticated(format!(
"Kubernetes TokenReview failed: {error}"
)));
}
if token_review.authenticated != Some(true) {
return Err(Error::unauthenticated(
"Kubernetes TokenReview did not authenticate the token",
));
}
let actual_audiences = token_review.audiences.unwrap_or_default();
validate_audience(audiences, &actual_audiences)?;
let user_info: UserInfo = token_review
.user
.ok_or_else(|| Error::unauthenticated("No user in kubernetes token review"))?;
let uid = user_info
.uid
.ok_or_else(|| Error::unauthenticated("No UID in kubernetes token review"))?;
let subject = Subject::new(idp_id.map(ToString::to_string), uid);
let claims = serde_json::to_value(user_info.extra).unwrap_or_default();
Ok(Authentication::builder()
.name(user_info.username)
.email(
claims
.get("email")
.and_then(|v| v.as_str().map(ToString::to_string)),
)
.subject(subject)
.principal_type(Some(crate::PrincipalType::Application))
.token_header(None)
.claims(claims)
.audiences(actual_audiences.into_iter().collect())
.build())
}
impl std::fmt::Debug for KubernetesAuthenticator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut r = f.debug_struct("KubernetesAuthenticator");
let r = r.field("idp_id", &self.idp_id);
r.field("audiences", &self.audiences)
.field("client", &"kube::client::Client")
.field("issuers", &self.issuers)
.finish()
}
}
fn validate_audience(expected: &[String], received: &[String]) -> Result<()> {
if expected.is_empty() {
return Ok(());
}
if !expected.iter().any(|expected| received.contains(expected)) {
return Err(Error::AudienceMismatch {
expected: expected.to_vec(),
actual: received.to_vec(),
});
}
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use std::collections::HashSet;
#[test]
fn test_parse_review_status() {
let status = serde_json::json!({
"audiences": [
"https://kubernetes.default.svc"
],
"authenticated": true,
"user": {
"extra": {
"authentication.kubernetes.io/credential-id": [
"JTI=99f5aae5-3f36-4521-ad75-cb2bab21459a"
],
"authentication.kubernetes.io/node-name": [
"ip-10-16-7-50.eu-central-1.compute.internal"
],
"authentication.kubernetes.io/node-uid": [
"8de0d94d-b5fd-4c3a-a6e8-1eccf22a7b31"
],
"authentication.kubernetes.io/pod-name": [
"my-pod"
],
"authentication.kubernetes.io/pod-uid": [
"e9518537-b347-4264-a6bb-bc82db55ae65"
]
},
"groups": [
"system:serviceaccounts",
"system:serviceaccounts:my-namespace",
"system:authenticated"
],
"uid": "0e79c2ec-32eb-4a46-ab9b-f075fbbfbd43",
"username": "system:serviceaccount:my-namespace:my-serviceaccount"
}});
let token_review_status: TokenReviewStatus = serde_json::from_value(status).unwrap();
parse_review_status(
Some(token_review_status.clone()),
&["nonexistant-audience".to_string()],
Some("kubernetes"),
)
.unwrap_err();
let payload = parse_review_status(
Some(token_review_status),
&["https://kubernetes.default.svc".to_string()],
Some("my-k8s-cluster"),
)
.unwrap();
assert_eq!(
payload.full_name(),
Some("system:serviceaccount:my-namespace:my-serviceaccount")
);
assert_eq!(
payload.subject().subject_in_idp(),
"0e79c2ec-32eb-4a46-ab9b-f075fbbfbd43"
);
assert_eq!(
payload.subject().idp_id(),
Some("my-k8s-cluster".to_string()).as_ref()
);
assert_eq!(
payload.audiences(),
&HashSet::from(["https://kubernetes.default.svc".to_string()])
);
}
#[test]
fn test_parse_review_status_rejects_unauthenticated() {
let status = serde_json::json!({
"authenticated": false,
"user": {
"uid": "0e79c2ec-32eb-4a46-ab9b-f075fbbfbd43",
"username": "system:serviceaccount:my-namespace:my-serviceaccount"
}
});
let token_review_status: TokenReviewStatus = serde_json::from_value(status).unwrap();
parse_review_status(Some(token_review_status), &[], Some("kubernetes")).unwrap_err();
}
}