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