Skip to main content

kube_client/client/auth/
mod.rs

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)]
28/// Client auth errors
29pub enum Error {
30    /// Invalid basic auth
31    #[error("invalid basic auth: {0}")]
32    InvalidBasicAuth(#[source] InvalidHeaderValue),
33
34    /// Invalid bearer token
35    #[error("invalid bearer token: {0}")]
36    InvalidBearerToken(#[source] InvalidHeaderValue),
37
38    /// Tried to refresh a token and got a non-refreshable token response
39    #[error("tried to refresh a token and got a non-refreshable token response")]
40    UnrefreshableTokenResponse,
41
42    /// Exec plugin response did not contain a status
43    #[error("exec-plugin response did not contain a status")]
44    ExecPluginFailed,
45
46    /// Malformed token expiration date
47    #[error("malformed token expiration date: {0}")]
48    MalformedTokenExpirationDate(#[source] jiff::Error),
49
50    /// Failed to start auth exec
51    #[error("unable to run auth exec: {0}")]
52    AuthExecStart(#[source] std::io::Error),
53
54    /// Failed to run auth exec command
55    #[error("auth exec command '{cmd}' failed with status {status}: {out:?}")]
56    AuthExecRun {
57        /// The failed command
58        cmd: String,
59        /// The exit status or exit code of the failed command
60        status: std::process::ExitStatus,
61        /// Stdout/Stderr of the failed command
62        out: std::process::Output,
63    },
64
65    /// Failed to parse auth exec output
66    #[error("failed to parse auth exec output: {0}")]
67    AuthExecParse(#[source] serde_json::Error),
68
69    /// Fail to serialize input
70    #[error("failed to serialize input: {0}")]
71    AuthExecSerialize(#[source] serde_json::Error),
72
73    /// Failed to exec auth
74    #[error("failed exec auth: {0}")]
75    AuthExec(String),
76
77    /// Failed to read token file
78    #[error("failed to read token file '{1:?}': {0}")]
79    ReadTokenFile(#[source] std::io::Error, PathBuf),
80
81    /// Failed to parse token-key
82    #[error("failed to parse token-key")]
83    ParseTokenKey(#[source] serde_json::Error),
84
85    /// command was missing from exec config
86    #[error("command must be specified to use exec authentication plugin")]
87    MissingCommand,
88
89    /// OAuth error
90    #[cfg(feature = "oauth")]
91    #[cfg_attr(docsrs, doc(cfg(feature = "oauth")))]
92    #[error("failed OAuth: {0}")]
93    OAuth(#[source] OAuthError),
94
95    /// OIDC error
96    #[cfg(feature = "oidc")]
97    #[cfg_attr(docsrs, doc(cfg(feature = "oidc")))]
98    #[error("failed OIDC: {0}")]
99    Oidc(#[source] oidc_errors::Error),
100
101    /// cluster spec missing while `provideClusterInfo` is true
102    #[error("Cluster spec must be populated when `provideClusterInfo` is true")]
103    ExecMissingClusterInfo,
104
105    /// No valid native root CA certificates found
106    #[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// Token file reference. Reloads at least once per minute.
121#[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            // Try to reload at least once a minute
136            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    /// Get the cached token. Returns `None` if it's expiring.
145    fn cached_token(&self) -> Option<&str> {
146        (!self.is_expiring()).then(|| self.token.expose_secret())
147    }
148
149    /// Get a token. Reloads from file if the cached token is expiring.
150    fn token(&mut self) -> &str {
151        if self.is_expiring() {
152            // > If reload from file fails, the last-read token should be used to avoid breaking
153            // > clients that make token files available on process start and then remove them to
154            // > limit credential exposure.
155            // > https://github.com/kubernetes/kubernetes/issues/68164
156            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
165/// Common constant for checking if an auth token is close to expiring
166pub const TEN_SEC: SignedDuration = SignedDuration::from_secs(10);
167/// Common duration for time between reloads
168const SIXTY_SEC: SignedDuration = SignedDuration::from_secs(60);
169
170// See https://github.com/kubernetes/kubernetes/tree/master/staging/src/k8s.io/client-go/plugin/pkg/client/auth
171// for the list of auth-plugins supported by client-go.
172// We currently support the following:
173// - exec
174// - token-file refreshed at least once per minute
175// - gcp: command based token source (exec)
176// - gcp: application credential based token source (requires `oauth` feature)
177//
178// Note that the visibility must be `pub` for `impl Layer for AuthLayer`, but this is not exported from the crate.
179// It's not accessible from outside and not shown on docs.
180#[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
190// For use with `AsyncFilterLayer` to add `Authorization` header with a refreshed token.
191impl<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                // Add some wiggle room onto the current timestamp so we don't get any race
215                // conditions where the token expires while we are refreshing
216                if Timestamp::now() + SIXTY_SEC >= locked_data.1 {
217                    // TODO Improve refreshing exec to avoid `Auth::try_from`
218                    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                        // Unreachable because the token source does not change
233                        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 the read guard before a write lock attempt to prevent deadlock.
250                drop(guard);
251                // Note that `token()` only reloads if the cached token is expiring.
252                // A separate method to conditionally reload minimizes the need for an exclusive access.
253                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    /// Loads the authentication header from the credentials available in the kubeconfig. This supports
282    /// exec plugins as well as specified in
283    /// https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins
284    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                    // `jiff::Timestamp` provides RFC3339 via `Display`, docs: https://docs.rs/jiff/latest/jiff/struct.Timestamp.html#impl-Display-for-Timestamp
304                    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        // Inline token. Has precedence over `token_file`.
329        if let Some(token) = &auth_info.token {
330            return Ok(Self::Bearer(token.clone()));
331        }
332
333        // Token file reference. Must be reloaded at least once a minute.
334        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
372// We need to differentiate providers because the keys/formats to store token expiration differs.
373enum ProviderToken {
374    #[cfg(feature = "oidc")]
375    Oidc(oidc::Oidc),
376    #[cfg(not(feature = "oidc"))]
377    Oidc(String),
378    // "access-token", "expiry" (RFC3339)
379    GcpCommand(String, Option<Timestamp>),
380    #[cfg(feature = "oauth")]
381    GcpOauth(oauth::Gcp),
382    // "access-token", "expires-on" (timestamp)
383    // Azure(String, Option<DateTime<Utc>>),
384}
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    // Return cached access token if it's still valid
423    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    // Command-based token source
435    if let Some(cmd) = provider.config.get("cmd-path") {
436        let params = provider.config.get("cmd-args").cloned().unwrap_or_default();
437        // NB: This property does currently not exist upstream in client-go
438        // See https://github.com/kube-rs/kube/issues/1060
439        let drop_env = provider.config.get("cmd-drop-env").cloned().unwrap_or_default();
440        // TODO splitting args by space is not safe
441        let mut command = Command::new(cmd);
442        // Do not pass the following env vars to the command
443        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    // Google Application Credentials-based token source
481    #[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/// ExecCredentials is used by exec-based plugins to communicate credentials to
529/// HTTP transports.
530#[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/// ExecCredenitalSpec holds request and runtime specific information provided
541/// by transport.
542#[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/// ExecCredentialStatus holds credentials for the transport to use.
552#[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    // Provide exec info to child process
600    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        // Doesn't reload unless expiring
695        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}