kube_client/client/auth/
mod.rs

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)]
29/// Client auth errors
30pub enum Error {
31    /// Invalid basic auth
32    #[error("invalid basic auth: {0}")]
33    InvalidBasicAuth(#[source] InvalidHeaderValue),
34
35    /// Invalid bearer token
36    #[error("invalid bearer token: {0}")]
37    InvalidBearerToken(#[source] InvalidHeaderValue),
38
39    /// Tried to refresh a token and got a non-refreshable token response
40    #[error("tried to refresh a token and got a non-refreshable token response")]
41    UnrefreshableTokenResponse,
42
43    /// Exec plugin response did not contain a status
44    #[error("exec-plugin response did not contain a status")]
45    ExecPluginFailed,
46
47    /// Malformed token expiration date
48    #[error("malformed token expiration date: {0}")]
49    MalformedTokenExpirationDate(#[source] chrono::ParseError),
50
51    /// Failed to start auth exec
52    #[error("unable to run auth exec: {0}")]
53    AuthExecStart(#[source] std::io::Error),
54
55    /// Failed to run auth exec command
56    #[error("auth exec command '{cmd}' failed with status {status}: {out:?}")]
57    AuthExecRun {
58        /// The failed command
59        cmd: String,
60        /// The exit status or exit code of the failed command
61        status: std::process::ExitStatus,
62        /// Stdout/Stderr of the failed command
63        out: std::process::Output,
64    },
65
66    /// Failed to parse auth exec output
67    #[error("failed to parse auth exec output: {0}")]
68    AuthExecParse(#[source] serde_json::Error),
69
70    /// Fail to serialize input
71    #[error("failed to serialize input: {0}")]
72    AuthExecSerialize(#[source] serde_json::Error),
73
74    /// Failed to exec auth
75    #[error("failed exec auth: {0}")]
76    AuthExec(String),
77
78    /// Failed to read token file
79    #[error("failed to read token file '{1:?}': {0}")]
80    ReadTokenFile(#[source] std::io::Error, PathBuf),
81
82    /// Failed to parse token-key
83    #[error("failed to parse token-key")]
84    ParseTokenKey(#[source] serde_json::Error),
85
86    /// command was missing from exec config
87    #[error("command must be specified to use exec authentication plugin")]
88    MissingCommand,
89
90    /// OAuth error
91    #[cfg(feature = "oauth")]
92    #[cfg_attr(docsrs, doc(cfg(feature = "oauth")))]
93    #[error("failed OAuth: {0}")]
94    OAuth(#[source] OAuthError),
95
96    /// OIDC error
97    #[cfg(feature = "oidc")]
98    #[cfg_attr(docsrs, doc(cfg(feature = "oidc")))]
99    #[error("failed OIDC: {0}")]
100    Oidc(#[source] oidc_errors::Error),
101
102    /// cluster spec missing while `provideClusterInfo` is true
103    #[error("Cluster spec must be populated when `provideClusterInfo` is true")]
104    ExecMissingClusterInfo,
105
106    /// No valid native root CA certificates found
107    #[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),
119}
120
121// Token file reference. Reloads at least once per minute.
122#[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            // Try to reload at least once a minute
137            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    /// Get the cached token. Returns `None` if it's expiring.
146    fn cached_token(&self) -> Option<&str> {
147        (!self.is_expiring()).then(|| self.token.expose_secret())
148    }
149
150    /// Get a token. Reloads from file if the cached token is expiring.
151    fn token(&mut self) -> &str {
152        if self.is_expiring() {
153            // > If reload from file fails, the last-read token should be used to avoid breaking
154            // > clients that make token files available on process start and then remove them to
155            // > limit credential exposure.
156            // > https://github.com/kubernetes/kubernetes/issues/68164
157            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
166// Questionable decisions by chrono: https://github.com/chronotope/chrono/issues/1491
167macro_rules! const_unwrap {
168    ($e:expr) => {
169        match $e {
170            Some(v) => v,
171            None => panic!(),
172        }
173    };
174}
175
176/// Common constant for checking if an auth token is close to expiring
177pub const TEN_SEC: chrono::TimeDelta = const_unwrap!(Duration::try_seconds(10));
178/// Common duration for time between reloads
179const SIXTY_SEC: chrono::TimeDelta = const_unwrap!(Duration::try_seconds(60));
180
181// See https://github.com/kubernetes/kubernetes/tree/master/staging/src/k8s.io/client-go/plugin/pkg/client/auth
182// for the list of auth-plugins supported by client-go.
183// We currently support the following:
184// - exec
185// - token-file refreshed at least once per minute
186// - gcp: command based token source (exec)
187// - gcp: application credential based token source (requires `oauth` feature)
188//
189// Note that the visibility must be `pub` for `impl Layer for AuthLayer`, but this is not exported from the crate.
190// It's not accessible from outside and not shown on docs.
191#[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
201// For use with `AsyncFilterLayer` to add `Authorization` header with a refreshed token.
202impl<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                // Add some wiggle room onto the current timestamp so we don't get any race
226                // conditions where the token expires while we are refreshing
227                if Utc::now() + SIXTY_SEC >= locked_data.1 {
228                    // TODO Improve refreshing exec to avoid `Auth::try_from`
229                    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                        // Unreachable because the token source does not change
244                        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 the read guard before a write lock attempt to prevent deadlock.
261                drop(guard);
262                // Note that `token()` only reloads if the cached token is expiring.
263                // A separate method to conditionally reload minimizes the need for an exclusive access.
264                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    /// Loads the authentication header from the credentials available in the kubeconfig. This supports
293    /// exec plugins as well as specified in
294    /// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins
295    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        // Inline token. Has precedence over `token_file`.
339        if let Some(token) = &auth_info.token {
340            return Ok(Self::Bearer(token.clone()));
341        }
342
343        // Token file reference. Must be reloaded at least once a minute.
344        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            if let (Some(client_certificate_data), Some(client_key_data)) =
354                (status.client_certificate_data, status.client_key_data)
355            {
356                return Ok(Self::Certificate(client_certificate_data, client_key_data.into()));
357            }
358            let expiration = status
359                .expiration_timestamp
360                .map(|ts| ts.parse())
361                .transpose()
362                .map_err(Error::MalformedTokenExpirationDate)?;
363            match (status.token.map(SecretString::from), expiration) {
364                (Some(token), Some(expire)) => Ok(Self::RefreshableToken(RefreshableToken::Exec(Arc::new(
365                    Mutex::new((token, expire, auth_info.clone())),
366                )))),
367                (Some(token), None) => Ok(Self::Bearer(token)),
368                _ => Ok(Self::None),
369            }
370        } else {
371            Ok(Self::None)
372        }
373    }
374}
375
376// We need to differentiate providers because the keys/formats to store token expiration differs.
377enum ProviderToken {
378    #[cfg(feature = "oidc")]
379    Oidc(oidc::Oidc),
380    #[cfg(not(feature = "oidc"))]
381    Oidc(String),
382    // "access-token", "expiry" (RFC3339)
383    GcpCommand(String, Option<DateTime<Utc>>),
384    #[cfg(feature = "oauth")]
385    GcpOauth(oauth::Gcp),
386    // "access-token", "expires-on" (timestamp)
387    // Azure(String, Option<DateTime<Utc>>),
388}
389
390fn token_from_provider(provider: &AuthProviderConfig) -> Result<ProviderToken, Error> {
391    match provider.name.as_ref() {
392        "oidc" => token_from_oidc_provider(provider),
393        "gcp" => token_from_gcp_provider(provider),
394        "azure" => Err(Error::AuthExec(
395            "The azure auth plugin is not supported; use https://github.com/Azure/kubelogin instead".into(),
396        )),
397        _ => Err(Error::AuthExec(format!(
398            "Authentication with provider {:} not supported",
399            provider.name
400        ))),
401    }
402}
403
404#[cfg(feature = "oidc")]
405fn token_from_oidc_provider(provider: &AuthProviderConfig) -> Result<ProviderToken, Error> {
406    oidc::Oidc::from_config(&provider.config)
407        .map_err(Error::Oidc)
408        .map(ProviderToken::Oidc)
409}
410
411#[cfg(not(feature = "oidc"))]
412fn token_from_oidc_provider(provider: &AuthProviderConfig) -> Result<ProviderToken, Error> {
413    match provider.config.get("id-token") {
414        Some(id_token) => Ok(ProviderToken::Oidc(id_token.clone())),
415        None => Err(Error::AuthExec(
416            "No id-token for oidc Authentication provider".into(),
417        )),
418    }
419}
420
421fn token_from_gcp_provider(provider: &AuthProviderConfig) -> Result<ProviderToken, Error> {
422    if let Some(id_token) = provider.config.get("id-token") {
423        return Ok(ProviderToken::GcpCommand(id_token.clone(), None));
424    }
425
426    // Return cached access token if it's still valid
427    if let Some(access_token) = provider.config.get("access-token") {
428        if let Some(expiry) = provider.config.get("expiry") {
429            let expiry_date = expiry
430                .parse::<DateTime<Utc>>()
431                .map_err(Error::MalformedTokenExpirationDate)?;
432            if Utc::now() + SIXTY_SEC < expiry_date {
433                return Ok(ProviderToken::GcpCommand(access_token.clone(), Some(expiry_date)));
434            }
435        }
436    }
437
438    // Command-based token source
439    if let Some(cmd) = provider.config.get("cmd-path") {
440        let params = provider.config.get("cmd-args").cloned().unwrap_or_default();
441        // NB: This property does currently not exist upstream in client-go
442        // See https://github.com/kube-rs/kube/issues/1060
443        let drop_env = provider.config.get("cmd-drop-env").cloned().unwrap_or_default();
444        // TODO splitting args by space is not safe
445        let mut command = Command::new(cmd);
446        // Do not pass the following env vars to the command
447        for env in drop_env.trim().split(' ') {
448            command.env_remove(env);
449        }
450        let output = command
451            .args(params.trim().split(' '))
452            .output()
453            .map_err(|e| Error::AuthExec(format!("Executing {cmd:} failed: {e:?}")))?;
454
455        if !output.status.success() {
456            return Err(Error::AuthExecRun {
457                cmd: format!("{cmd} {params}"),
458                status: output.status,
459                out: output,
460            });
461        }
462
463        if let Some(field) = provider.config.get("token-key") {
464            let json_output: serde_json::Value =
465                serde_json::from_slice(&output.stdout).map_err(Error::ParseTokenKey)?;
466            let token = extract_value(&json_output, "token-key", field)?;
467            if let Some(field) = provider.config.get("expiry-key") {
468                let expiry = extract_value(&json_output, "expiry-key", field)?;
469                let expiry = expiry
470                    .parse::<DateTime<Utc>>()
471                    .map_err(Error::MalformedTokenExpirationDate)?;
472                return Ok(ProviderToken::GcpCommand(token, Some(expiry)));
473            } else {
474                return Ok(ProviderToken::GcpCommand(token, None));
475            }
476        } else {
477            let token = std::str::from_utf8(&output.stdout)
478                .map_err(|e| Error::AuthExec(format!("Result is not a string {e:?} ")))?
479                .to_owned();
480            return Ok(ProviderToken::GcpCommand(token, None));
481        }
482    }
483
484    // Google Application Credentials-based token source
485    #[cfg(feature = "oauth")]
486    {
487        Ok(ProviderToken::GcpOauth(
488            oauth::Gcp::default_credentials_with_scopes(provider.config.get("scopes"))
489                .map_err(Error::OAuth)?,
490        ))
491    }
492    #[cfg(not(feature = "oauth"))]
493    {
494        Err(Error::AuthExec(
495            "Enable oauth feature to use Google Application Credentials-based token source".into(),
496        ))
497    }
498}
499
500fn extract_value(json: &serde_json::Value, context: &str, path: &str) -> Result<String, Error> {
501    let parsed_path = path
502        .trim_matches(|c| c == '"' || c == '{' || c == '}')
503        .parse::<JsonPath>()
504        .map_err(|err| {
505            Error::AuthExec(format!(
506                "Failed to parse {context:?} as a JsonPath: {path}\n
507                 Error: {err}"
508            ))
509        })?;
510
511    let res = parsed_path.find_slice(json);
512
513    let Some(res) = res.into_iter().next() else {
514        return Err(Error::AuthExec(format!(
515            "Target {context:?} value {path:?} not found"
516        )));
517    };
518
519    let jval = res.to_data();
520    let val = jval.as_str().ok_or(Error::AuthExec(format!(
521        "Target {context:?} value {path:?} is not a string"
522    )))?;
523
524    Ok(val.to_string())
525}
526
527/// ExecCredentials is used by exec-based plugins to communicate credentials to
528/// HTTP transports.
529#[derive(Clone, Debug, Serialize, Deserialize)]
530pub struct ExecCredential {
531    pub kind: Option<String>,
532    #[serde(rename = "apiVersion")]
533    pub api_version: Option<String>,
534    pub spec: Option<ExecCredentialSpec>,
535    #[serde(skip_serializing_if = "Option::is_none")]
536    pub status: Option<ExecCredentialStatus>,
537}
538
539/// ExecCredenitalSpec holds request and runtime specific information provided
540/// by transport.
541#[derive(Clone, Debug, Serialize, Deserialize)]
542pub struct ExecCredentialSpec {
543    #[serde(skip_serializing_if = "Option::is_none")]
544    interactive: Option<bool>,
545
546    #[serde(skip_serializing_if = "Option::is_none")]
547    cluster: Option<ExecAuthCluster>,
548}
549
550/// ExecCredentialStatus holds credentials for the transport to use.
551#[derive(Clone, Debug, Serialize, Deserialize)]
552pub struct ExecCredentialStatus {
553    #[serde(rename = "expirationTimestamp")]
554    pub expiration_timestamp: Option<String>,
555    pub token: Option<String>,
556    #[serde(rename = "clientCertificateData")]
557    pub client_certificate_data: Option<String>,
558    #[serde(rename = "clientKeyData")]
559    pub client_key_data: Option<String>,
560}
561
562fn auth_exec(auth: &ExecConfig) -> Result<ExecCredential, Error> {
563    let mut cmd = match &auth.command {
564        Some(cmd) => Command::new(cmd),
565        None => return Err(Error::MissingCommand),
566    };
567
568    if let Some(args) = &auth.args {
569        cmd.args(args);
570    }
571    if let Some(env) = &auth.env {
572        let envs = env
573            .iter()
574            .flat_map(|env| match (env.get("name"), env.get("value")) {
575                (Some(name), Some(value)) => Some((name, value)),
576                _ => None,
577            });
578        cmd.envs(envs);
579    }
580
581    let interactive = auth.interactive_mode != Some(ExecInteractiveMode::Never);
582    if interactive {
583        cmd.stdin(std::process::Stdio::inherit());
584    } else {
585        cmd.stdin(std::process::Stdio::piped());
586    }
587
588    let mut exec_credential_spec = ExecCredentialSpec {
589        interactive: Some(interactive),
590        cluster: None,
591    };
592
593    if auth.provide_cluster_info {
594        exec_credential_spec.cluster = Some(auth.cluster.clone().ok_or(Error::ExecMissingClusterInfo)?);
595    }
596
597    // Provide exec info to child process
598    let exec_info = serde_json::to_string(&ExecCredential {
599        api_version: auth.api_version.clone(),
600        kind: "ExecCredential".to_string().into(),
601        spec: Some(exec_credential_spec),
602        status: None,
603    })
604    .map_err(Error::AuthExecSerialize)?;
605    cmd.env("KUBERNETES_EXEC_INFO", exec_info);
606
607    if let Some(envs) = &auth.drop_env {
608        for env in envs {
609            cmd.env_remove(env);
610        }
611    }
612
613    #[cfg(target_os = "windows")]
614    {
615        const CREATE_NO_WINDOW: u32 = 0x08000000;
616        cmd.creation_flags(CREATE_NO_WINDOW);
617    }
618
619    let out = cmd.output().map_err(Error::AuthExecStart)?;
620    if !out.status.success() {
621        return Err(Error::AuthExecRun {
622            cmd: format!("{cmd:?}"),
623            status: out.status,
624            out,
625        });
626    }
627    let creds = serde_json::from_slice(&out.stdout).map_err(Error::AuthExecParse)?;
628
629    Ok(creds)
630}
631
632#[cfg(test)]
633mod test {
634    use crate::config::Kubeconfig;
635
636    use super::*;
637    #[tokio::test]
638    #[ignore = "fails on windows mysteriously"]
639    async fn exec_auth_command() -> Result<(), Error> {
640        let expiry = (Utc::now() + SIXTY_SEC).to_rfc3339();
641        let test_file = format!(
642            r#"
643        apiVersion: v1
644        clusters:
645        - cluster:
646            certificate-authority-data: XXXXXXX
647            server: https://36.XXX.XXX.XX
648          name: generic-name
649        contexts:
650        - context:
651            cluster: generic-name
652            user: generic-name
653          name: generic-name
654        current-context: generic-name
655        kind: Config
656        preferences: {{}}
657        users:
658        - name: generic-name
659          user:
660            auth-provider:
661              config:
662                cmd-args: '{{"something": "else", "credential": {{"access_token": "my_token", "token_expiry": "{expiry}"}}}}'
663                cmd-path: echo
664                expiry-key: '{{.credential.token_expiry}}'
665                token-key: '{{.credential.access_token}}'
666              name: gcp
667        "#
668        );
669
670        let config: Kubeconfig = serde_yaml::from_str(&test_file).unwrap();
671        let auth_info = config.auth_infos[0].auth_info.as_ref().unwrap();
672        match Auth::try_from(auth_info).unwrap() {
673            Auth::RefreshableToken(RefreshableToken::Exec(refreshable)) => {
674                let (token, _expire, info) = Arc::try_unwrap(refreshable).unwrap().into_inner();
675                assert_eq!(token.expose_secret(), &"my_token".to_owned());
676                let config = info.auth_provider.unwrap().config;
677                assert_eq!(config.get("access-token"), Some(&"my_token".to_owned()));
678            }
679            _ => unreachable!(),
680        }
681        Ok(())
682    }
683
684    #[test]
685    fn token_file() {
686        let file = tempfile::NamedTempFile::new().unwrap();
687        std::fs::write(file.path(), "token1").unwrap();
688        let mut token_file = TokenFile::new(file.path()).unwrap();
689        assert_eq!(token_file.cached_token().unwrap(), "token1");
690        assert!(!token_file.is_expiring());
691        assert_eq!(token_file.token(), "token1");
692        // Doesn't reload unless expiring
693        std::fs::write(file.path(), "token2").unwrap();
694        assert_eq!(token_file.token(), "token1");
695
696        token_file.expires_at = Utc::now();
697        assert!(token_file.is_expiring());
698        assert_eq!(token_file.cached_token(), None);
699        assert_eq!(token_file.token(), "token2");
700        assert!(!token_file.is_expiring());
701        assert_eq!(token_file.cached_token().unwrap(), "token2");
702    }
703}