1use std::{
2 path::{Path, PathBuf},
3 process::Command,
4 sync::Arc,
5};
6
7use chrono::{DateTime, Duration, Utc};
8use futures::future::BoxFuture;
9use http::{
10 header::{InvalidHeaderValue, AUTHORIZATION},
11 HeaderValue, Request,
12};
13use jsonpath_rust::JsonPath;
14use secrecy::{ExposeSecret, SecretString};
15use serde::{Deserialize, Serialize};
16use thiserror::Error;
17use tokio::sync::{Mutex, RwLock};
18use tower::{filter::AsyncPredicate, BoxError};
19
20use crate::config::{AuthInfo, AuthProviderConfig, ExecAuthCluster, ExecConfig, ExecInteractiveMode};
21
22#[cfg(feature = "oauth")] mod oauth;
23#[cfg(feature = "oauth")] pub use oauth::Error as OAuthError;
24#[cfg(feature = "oidc")] mod oidc;
25#[cfg(feature = "oidc")] pub use oidc::errors as oidc_errors;
26#[cfg(target_os = "windows")] use std::os::windows::process::CommandExt;
27
28#[derive(Error, Debug)]
29pub enum Error {
31 #[error("invalid basic auth: {0}")]
33 InvalidBasicAuth(#[source] InvalidHeaderValue),
34
35 #[error("invalid bearer token: {0}")]
37 InvalidBearerToken(#[source] InvalidHeaderValue),
38
39 #[error("tried to refresh a token and got a non-refreshable token response")]
41 UnrefreshableTokenResponse,
42
43 #[error("exec-plugin response did not contain a status")]
45 ExecPluginFailed,
46
47 #[error("malformed token expiration date: {0}")]
49 MalformedTokenExpirationDate(#[source] chrono::ParseError),
50
51 #[error("unable to run auth exec: {0}")]
53 AuthExecStart(#[source] std::io::Error),
54
55 #[error("auth exec command '{cmd}' failed with status {status}: {out:?}")]
57 AuthExecRun {
58 cmd: String,
60 status: std::process::ExitStatus,
62 out: std::process::Output,
64 },
65
66 #[error("failed to parse auth exec output: {0}")]
68 AuthExecParse(#[source] serde_json::Error),
69
70 #[error("failed to serialize input: {0}")]
72 AuthExecSerialize(#[source] serde_json::Error),
73
74 #[error("failed exec auth: {0}")]
76 AuthExec(String),
77
78 #[error("failed to read token file '{1:?}': {0}")]
80 ReadTokenFile(#[source] std::io::Error, PathBuf),
81
82 #[error("failed to parse token-key")]
84 ParseTokenKey(#[source] serde_json::Error),
85
86 #[error("command must be specified to use exec authentication plugin")]
88 MissingCommand,
89
90 #[cfg(feature = "oauth")]
92 #[cfg_attr(docsrs, doc(cfg(feature = "oauth")))]
93 #[error("failed OAuth: {0}")]
94 OAuth(#[source] OAuthError),
95
96 #[cfg(feature = "oidc")]
98 #[cfg_attr(docsrs, doc(cfg(feature = "oidc")))]
99 #[error("failed OIDC: {0}")]
100 Oidc(#[source] oidc_errors::Error),
101
102 #[error("Cluster spec must be populated when `provideClusterInfo` is true")]
104 ExecMissingClusterInfo,
105
106 #[error("No valid native root CA certificates found")]
108 NoValidNativeRootCA(#[source] std::io::Error),
109}
110
111#[derive(Debug, Clone)]
112#[allow(clippy::large_enum_variant)]
113pub(crate) enum Auth {
114 None,
115 Basic(String, SecretString),
116 Bearer(SecretString),
117 RefreshableToken(RefreshableToken),
118 Certificate(String, SecretString, Option<DateTime<Utc>>),
119}
120
121#[derive(Debug)]
123pub struct TokenFile {
124 path: PathBuf,
125 token: SecretString,
126 expires_at: DateTime<Utc>,
127}
128
129impl TokenFile {
130 fn new<P: AsRef<Path>>(path: P) -> Result<TokenFile, Error> {
131 let token = std::fs::read_to_string(&path)
132 .map_err(|source| Error::ReadTokenFile(source, path.as_ref().to_owned()))?;
133 Ok(Self {
134 path: path.as_ref().to_owned(),
135 token: SecretString::from(token),
136 expires_at: Utc::now() + SIXTY_SEC,
138 })
139 }
140
141 fn is_expiring(&self) -> bool {
142 Utc::now() + TEN_SEC > self.expires_at
143 }
144
145 fn cached_token(&self) -> Option<&str> {
147 (!self.is_expiring()).then(|| self.token.expose_secret())
148 }
149
150 fn token(&mut self) -> &str {
152 if self.is_expiring() {
153 if let Ok(token) = std::fs::read_to_string(&self.path) {
158 self.token = SecretString::from(token);
159 }
160 self.expires_at = Utc::now() + SIXTY_SEC;
161 }
162 self.token.expose_secret()
163 }
164}
165
166macro_rules! const_unwrap {
168 ($e:expr) => {
169 match $e {
170 Some(v) => v,
171 None => panic!(),
172 }
173 };
174}
175
176pub const TEN_SEC: chrono::TimeDelta = const_unwrap!(Duration::try_seconds(10));
178const SIXTY_SEC: chrono::TimeDelta = const_unwrap!(Duration::try_seconds(60));
180
181#[derive(Debug, Clone)]
192pub enum RefreshableToken {
193 Exec(Arc<Mutex<(SecretString, DateTime<Utc>, AuthInfo)>>),
194 File(Arc<RwLock<TokenFile>>),
195 #[cfg(feature = "oauth")]
196 GcpOauth(Arc<Mutex<oauth::Gcp>>),
197 #[cfg(feature = "oidc")]
198 Oidc(Arc<Mutex<oidc::Oidc>>),
199}
200
201impl<B> AsyncPredicate<Request<B>> for RefreshableToken
203where
204 B: http_body::Body + Send + 'static,
205{
206 type Future = BoxFuture<'static, Result<Request<B>, BoxError>>;
207 type Request = Request<B>;
208
209 fn check(&mut self, mut request: Self::Request) -> Self::Future {
210 let refreshable = self.clone();
211 Box::pin(async move {
212 refreshable.to_header().await.map_err(Into::into).map(|value| {
213 request.headers_mut().insert(AUTHORIZATION, value);
214 request
215 })
216 })
217 }
218}
219
220impl RefreshableToken {
221 async fn to_header(&self) -> Result<HeaderValue, Error> {
222 match self {
223 RefreshableToken::Exec(data) => {
224 let mut locked_data = data.lock().await;
225 if Utc::now() + SIXTY_SEC >= locked_data.1 {
228 match Auth::try_from(&locked_data.2)? {
230 Auth::None | Auth::Basic(_, _) | Auth::Bearer(_) | Auth::Certificate(_, _, _) => {
231 return Err(Error::UnrefreshableTokenResponse);
232 }
233
234 Auth::RefreshableToken(RefreshableToken::Exec(d)) => {
235 let (new_token, new_expire, new_info) = Arc::try_unwrap(d)
236 .expect("Unable to unwrap Arc, this is likely a programming error")
237 .into_inner();
238 locked_data.0 = new_token;
239 locked_data.1 = new_expire;
240 locked_data.2 = new_info;
241 }
242
243 Auth::RefreshableToken(RefreshableToken::File(_)) => unreachable!(),
245 #[cfg(feature = "oauth")]
246 Auth::RefreshableToken(RefreshableToken::GcpOauth(_)) => unreachable!(),
247 #[cfg(feature = "oidc")]
248 Auth::RefreshableToken(RefreshableToken::Oidc(_)) => unreachable!(),
249 }
250 }
251
252 bearer_header(locked_data.0.expose_secret())
253 }
254
255 RefreshableToken::File(token_file) => {
256 let guard = token_file.read().await;
257 if let Some(header) = guard.cached_token().map(bearer_header) {
258 return header;
259 }
260 drop(guard);
262 bearer_header(token_file.write().await.token())
265 }
266
267 #[cfg(feature = "oauth")]
268 RefreshableToken::GcpOauth(data) => {
269 let gcp_oauth = data.lock().await;
270 let token = (*gcp_oauth).token().await.map_err(Error::OAuth)?;
271 bearer_header(&token.access_token)
272 }
273
274 #[cfg(feature = "oidc")]
275 RefreshableToken::Oidc(oidc) => {
276 let token = oidc.lock().await.id_token().await.map_err(Error::Oidc)?;
277 bearer_header(&token)
278 }
279 }
280 }
281}
282
283fn bearer_header(token: &str) -> Result<HeaderValue, Error> {
284 let mut value = HeaderValue::try_from(format!("Bearer {token}")).map_err(Error::InvalidBearerToken)?;
285 value.set_sensitive(true);
286 Ok(value)
287}
288
289impl TryFrom<&AuthInfo> for Auth {
290 type Error = Error;
291
292 fn try_from(auth_info: &AuthInfo) -> Result<Self, Self::Error> {
296 if let Some(provider) = &auth_info.auth_provider {
297 match token_from_provider(provider)? {
298 #[cfg(feature = "oidc")]
299 ProviderToken::Oidc(oidc) => {
300 return Ok(Self::RefreshableToken(RefreshableToken::Oidc(Arc::new(
301 Mutex::new(oidc),
302 ))));
303 }
304
305 #[cfg(not(feature = "oidc"))]
306 ProviderToken::Oidc(token) => {
307 return Ok(Self::Bearer(SecretString::from(token)));
308 }
309
310 ProviderToken::GcpCommand(token, Some(expiry)) => {
311 let mut info = auth_info.clone();
312 let mut provider = provider.clone();
313 provider.config.insert("access-token".into(), token.clone());
314 provider.config.insert("expiry".into(), expiry.to_rfc3339());
315 info.auth_provider = Some(provider);
316 return Ok(Self::RefreshableToken(RefreshableToken::Exec(Arc::new(
317 Mutex::new((SecretString::from(token), expiry, info)),
318 ))));
319 }
320
321 ProviderToken::GcpCommand(token, None) => {
322 return Ok(Self::Bearer(SecretString::from(token)));
323 }
324
325 #[cfg(feature = "oauth")]
326 ProviderToken::GcpOauth(gcp) => {
327 return Ok(Self::RefreshableToken(RefreshableToken::GcpOauth(Arc::new(
328 Mutex::new(gcp),
329 ))));
330 }
331 }
332 }
333
334 if let (Some(u), Some(p)) = (&auth_info.username, &auth_info.password) {
335 return Ok(Self::Basic(u.to_owned(), p.to_owned()));
336 }
337
338 if let Some(token) = &auth_info.token {
340 return Ok(Self::Bearer(token.clone()));
341 }
342
343 if let Some(file) = &auth_info.token_file {
345 return Ok(Self::RefreshableToken(RefreshableToken::File(Arc::new(
346 RwLock::new(TokenFile::new(file)?),
347 ))));
348 }
349
350 if let Some(exec) = &auth_info.exec {
351 let creds = auth_exec(exec)?;
352 let status = creds.status.ok_or(Error::ExecPluginFailed)?;
353 let expiration = status
354 .expiration_timestamp
355 .map(|ts| ts.parse())
356 .transpose()
357 .map_err(Error::MalformedTokenExpirationDate)?;
358
359
360 if let (Some(client_certificate_data), Some(client_key_data)) =
361 (status.client_certificate_data, status.client_key_data)
362 {
363 return Ok(Self::Certificate(
364 client_certificate_data,
365 client_key_data.into(),
366 expiration,
367 ));
368 }
369
370 match (status.token.map(SecretString::from), expiration) {
371 (Some(token), Some(expire)) => Ok(Self::RefreshableToken(RefreshableToken::Exec(Arc::new(
372 Mutex::new((token, expire, auth_info.clone())),
373 )))),
374 (Some(token), None) => Ok(Self::Bearer(token)),
375 _ => Ok(Self::None),
376 }
377 } else {
378 Ok(Self::None)
379 }
380 }
381}
382
383enum ProviderToken {
385 #[cfg(feature = "oidc")]
386 Oidc(oidc::Oidc),
387 #[cfg(not(feature = "oidc"))]
388 Oidc(String),
389 GcpCommand(String, Option<DateTime<Utc>>),
391 #[cfg(feature = "oauth")]
392 GcpOauth(oauth::Gcp),
393 }
396
397fn token_from_provider(provider: &AuthProviderConfig) -> Result<ProviderToken, Error> {
398 match provider.name.as_ref() {
399 "oidc" => token_from_oidc_provider(provider),
400 "gcp" => token_from_gcp_provider(provider),
401 "azure" => Err(Error::AuthExec(
402 "The azure auth plugin is not supported; use https://github.com/Azure/kubelogin instead".into(),
403 )),
404 _ => Err(Error::AuthExec(format!(
405 "Authentication with provider {:} not supported",
406 provider.name
407 ))),
408 }
409}
410
411#[cfg(feature = "oidc")]
412fn token_from_oidc_provider(provider: &AuthProviderConfig) -> Result<ProviderToken, Error> {
413 oidc::Oidc::from_config(&provider.config)
414 .map_err(Error::Oidc)
415 .map(ProviderToken::Oidc)
416}
417
418#[cfg(not(feature = "oidc"))]
419fn token_from_oidc_provider(provider: &AuthProviderConfig) -> Result<ProviderToken, Error> {
420 match provider.config.get("id-token") {
421 Some(id_token) => Ok(ProviderToken::Oidc(id_token.clone())),
422 None => Err(Error::AuthExec(
423 "No id-token for oidc Authentication provider".into(),
424 )),
425 }
426}
427
428fn token_from_gcp_provider(provider: &AuthProviderConfig) -> Result<ProviderToken, Error> {
429 if let Some(id_token) = provider.config.get("id-token") {
430 return Ok(ProviderToken::GcpCommand(id_token.clone(), None));
431 }
432
433 if let Some(access_token) = provider.config.get("access-token") {
435 if let Some(expiry) = provider.config.get("expiry") {
436 let expiry_date = expiry
437 .parse::<DateTime<Utc>>()
438 .map_err(Error::MalformedTokenExpirationDate)?;
439 if Utc::now() + SIXTY_SEC < expiry_date {
440 return Ok(ProviderToken::GcpCommand(access_token.clone(), Some(expiry_date)));
441 }
442 }
443 }
444
445 if let Some(cmd) = provider.config.get("cmd-path") {
447 let params = provider.config.get("cmd-args").cloned().unwrap_or_default();
448 let drop_env = provider.config.get("cmd-drop-env").cloned().unwrap_or_default();
451 let mut command = Command::new(cmd);
453 for env in drop_env.trim().split(' ') {
455 command.env_remove(env);
456 }
457 let output = command
458 .args(params.trim().split(' '))
459 .output()
460 .map_err(|e| Error::AuthExec(format!("Executing {cmd:} failed: {e:?}")))?;
461
462 if !output.status.success() {
463 return Err(Error::AuthExecRun {
464 cmd: format!("{cmd} {params}"),
465 status: output.status,
466 out: output,
467 });
468 }
469
470 if let Some(field) = provider.config.get("token-key") {
471 let json_output: serde_json::Value =
472 serde_json::from_slice(&output.stdout).map_err(Error::ParseTokenKey)?;
473 let token = extract_value(&json_output, "token-key", field)?;
474 if let Some(field) = provider.config.get("expiry-key") {
475 let expiry = extract_value(&json_output, "expiry-key", field)?;
476 let expiry = expiry
477 .parse::<DateTime<Utc>>()
478 .map_err(Error::MalformedTokenExpirationDate)?;
479 return Ok(ProviderToken::GcpCommand(token, Some(expiry)));
480 } else {
481 return Ok(ProviderToken::GcpCommand(token, None));
482 }
483 } else {
484 let token = std::str::from_utf8(&output.stdout)
485 .map_err(|e| Error::AuthExec(format!("Result is not a string {e:?} ")))?
486 .to_owned();
487 return Ok(ProviderToken::GcpCommand(token, None));
488 }
489 }
490
491 #[cfg(feature = "oauth")]
493 {
494 Ok(ProviderToken::GcpOauth(
495 oauth::Gcp::default_credentials_with_scopes(provider.config.get("scopes"))
496 .map_err(Error::OAuth)?,
497 ))
498 }
499 #[cfg(not(feature = "oauth"))]
500 {
501 Err(Error::AuthExec(
502 "Enable oauth feature to use Google Application Credentials-based token source".into(),
503 ))
504 }
505}
506
507fn extract_value(json: &serde_json::Value, context: &str, path: &str) -> Result<String, Error> {
508 let parsed_path = path
509 .trim_matches(|c| c == '"' || c == '{' || c == '}')
510 .parse::<JsonPath>()
511 .map_err(|err| {
512 Error::AuthExec(format!(
513 "Failed to parse {context:?} as a JsonPath: {path}\n
514 Error: {err}"
515 ))
516 })?;
517
518 let res = parsed_path.find_slice(json);
519
520 let Some(res) = res.into_iter().next() else {
521 return Err(Error::AuthExec(format!(
522 "Target {context:?} value {path:?} not found"
523 )));
524 };
525
526 let jval = res.to_data();
527 let val = jval.as_str().ok_or(Error::AuthExec(format!(
528 "Target {context:?} value {path:?} is not a string"
529 )))?;
530
531 Ok(val.to_string())
532}
533
534#[derive(Clone, Debug, Serialize, Deserialize)]
537pub struct ExecCredential {
538 pub kind: Option<String>,
539 #[serde(rename = "apiVersion")]
540 pub api_version: Option<String>,
541 pub spec: Option<ExecCredentialSpec>,
542 #[serde(skip_serializing_if = "Option::is_none")]
543 pub status: Option<ExecCredentialStatus>,
544}
545
546#[derive(Clone, Debug, Serialize, Deserialize)]
549pub struct ExecCredentialSpec {
550 #[serde(skip_serializing_if = "Option::is_none")]
551 interactive: Option<bool>,
552
553 #[serde(skip_serializing_if = "Option::is_none")]
554 cluster: Option<ExecAuthCluster>,
555}
556
557#[derive(Clone, Debug, Serialize, Deserialize)]
559pub struct ExecCredentialStatus {
560 #[serde(rename = "expirationTimestamp")]
561 pub expiration_timestamp: Option<String>,
562 pub token: Option<String>,
563 #[serde(rename = "clientCertificateData")]
564 pub client_certificate_data: Option<String>,
565 #[serde(rename = "clientKeyData")]
566 pub client_key_data: Option<String>,
567}
568
569fn auth_exec(auth: &ExecConfig) -> Result<ExecCredential, Error> {
570 let mut cmd = match &auth.command {
571 Some(cmd) => Command::new(cmd),
572 None => return Err(Error::MissingCommand),
573 };
574
575 if let Some(args) = &auth.args {
576 cmd.args(args);
577 }
578 if let Some(env) = &auth.env {
579 let envs = env
580 .iter()
581 .flat_map(|env| match (env.get("name"), env.get("value")) {
582 (Some(name), Some(value)) => Some((name, value)),
583 _ => None,
584 });
585 cmd.envs(envs);
586 }
587
588 let interactive = auth.interactive_mode != Some(ExecInteractiveMode::Never);
589 if interactive {
590 cmd.stdin(std::process::Stdio::inherit());
591 } else {
592 cmd.stdin(std::process::Stdio::piped());
593 }
594
595 let mut exec_credential_spec = ExecCredentialSpec {
596 interactive: Some(interactive),
597 cluster: None,
598 };
599
600 if auth.provide_cluster_info {
601 exec_credential_spec.cluster = Some(auth.cluster.clone().ok_or(Error::ExecMissingClusterInfo)?);
602 }
603
604 let exec_info = serde_json::to_string(&ExecCredential {
606 api_version: auth.api_version.clone(),
607 kind: "ExecCredential".to_string().into(),
608 spec: Some(exec_credential_spec),
609 status: None,
610 })
611 .map_err(Error::AuthExecSerialize)?;
612 cmd.env("KUBERNETES_EXEC_INFO", exec_info);
613
614 if let Some(envs) = &auth.drop_env {
615 for env in envs {
616 cmd.env_remove(env);
617 }
618 }
619
620 #[cfg(target_os = "windows")]
621 {
622 const CREATE_NO_WINDOW: u32 = 0x08000000;
623 cmd.creation_flags(CREATE_NO_WINDOW);
624 }
625
626 let out = cmd.output().map_err(Error::AuthExecStart)?;
627 if !out.status.success() {
628 return Err(Error::AuthExecRun {
629 cmd: format!("{cmd:?}"),
630 status: out.status,
631 out,
632 });
633 }
634 let creds = serde_json::from_slice(&out.stdout).map_err(Error::AuthExecParse)?;
635
636 Ok(creds)
637}
638
639#[cfg(test)]
640mod test {
641 use crate::config::Kubeconfig;
642
643 use super::*;
644 #[tokio::test]
645 #[ignore = "fails on windows mysteriously"]
646 async fn exec_auth_command() -> Result<(), Error> {
647 let expiry = (Utc::now() + SIXTY_SEC).to_rfc3339();
648 let test_file = format!(
649 r#"
650 apiVersion: v1
651 clusters:
652 - cluster:
653 certificate-authority-data: XXXXXXX
654 server: https://36.XXX.XXX.XX
655 name: generic-name
656 contexts:
657 - context:
658 cluster: generic-name
659 user: generic-name
660 name: generic-name
661 current-context: generic-name
662 kind: Config
663 preferences: {{}}
664 users:
665 - name: generic-name
666 user:
667 auth-provider:
668 config:
669 cmd-args: '{{"something": "else", "credential": {{"access_token": "my_token", "token_expiry": "{expiry}"}}}}'
670 cmd-path: echo
671 expiry-key: '{{.credential.token_expiry}}'
672 token-key: '{{.credential.access_token}}'
673 name: gcp
674 "#
675 );
676
677 let config: Kubeconfig = serde_yaml::from_str(&test_file).unwrap();
678 let auth_info = config.auth_infos[0].auth_info.as_ref().unwrap();
679 match Auth::try_from(auth_info).unwrap() {
680 Auth::RefreshableToken(RefreshableToken::Exec(refreshable)) => {
681 let (token, _expire, info) = Arc::try_unwrap(refreshable).unwrap().into_inner();
682 assert_eq!(token.expose_secret(), &"my_token".to_owned());
683 let config = info.auth_provider.unwrap().config;
684 assert_eq!(config.get("access-token"), Some(&"my_token".to_owned()));
685 }
686 _ => unreachable!(),
687 }
688 Ok(())
689 }
690
691 #[test]
692 fn token_file() {
693 let file = tempfile::NamedTempFile::new().unwrap();
694 std::fs::write(file.path(), "token1").unwrap();
695 let mut token_file = TokenFile::new(file.path()).unwrap();
696 assert_eq!(token_file.cached_token().unwrap(), "token1");
697 assert!(!token_file.is_expiring());
698 assert_eq!(token_file.token(), "token1");
699 std::fs::write(file.path(), "token2").unwrap();
701 assert_eq!(token_file.token(), "token1");
702
703 token_file.expires_at = Utc::now();
704 assert!(token_file.is_expiring());
705 assert_eq!(token_file.cached_token(), None);
706 assert_eq!(token_file.token(), "token2");
707 assert!(!token_file.is_expiring());
708 assert_eq!(token_file.cached_token().unwrap(), "token2");
709 }
710}