kanidm_cli/
common.rs

1use compact_jwt::{traits::JwsVerifiable, JwsCompact, JwsEs256Verifier, JwsVerifier, JwtError};
2use dialoguer::theme::ColorfulTheme;
3use dialoguer::{Confirm, Select};
4use kanidm_client::{KanidmClient, KanidmClientBuilder};
5use kanidm_proto::cli::OpType;
6use kanidm_proto::constants::{DEFAULT_CLIENT_CONFIG_PATH, DEFAULT_CLIENT_CONFIG_PATH_HOME};
7use kanidm_proto::internal::UserAuthToken;
8use time::format_description::well_known::Rfc3339;
9use time::OffsetDateTime;
10
11use crate::session::{process_auth_state, read_tokens};
12use crate::{KanidmClientParser, LoginOpt};
13
14#[derive(Debug)]
15#[allow(clippy::large_enum_variant)]
16pub enum ToClientError {
17    NeedLogin(String),
18    NeedReauth(String, KanidmClient),
19    Other,
20}
21
22impl KanidmClientParser {
23    pub fn to_unauth_client(&self) -> KanidmClient {
24        let config_path: String = shellexpand::tilde(DEFAULT_CLIENT_CONFIG_PATH_HOME).into_owned();
25
26        let instance_name: Option<&str> = self.instance.as_deref();
27
28        let client_builder = KanidmClientBuilder::new()
29            .read_options_from_optional_instance_config(DEFAULT_CLIENT_CONFIG_PATH, instance_name)
30            .map_err(|e| {
31                error!(
32                    "Failed to parse config ({:?}) -- {:?}",
33                    DEFAULT_CLIENT_CONFIG_PATH, e
34                );
35                e
36            })
37            .and_then(|cb| {
38                cb.read_options_from_optional_instance_config(&config_path, instance_name)
39                    .map_err(|e| {
40                        error!("Failed to parse config ({:?}) -- {:?}", config_path, e);
41                        e
42                    })
43            })
44            .unwrap_or_else(|_e| {
45                std::process::exit(1);
46            });
47        debug!(
48            "Successfully loaded configuration, looked in {} and {} - client builder state: {:?}",
49            DEFAULT_CLIENT_CONFIG_PATH, DEFAULT_CLIENT_CONFIG_PATH_HOME, &client_builder
50        );
51
52        let client_builder = match &self.addr {
53            Some(a) => client_builder.address(a.to_string()),
54            None => client_builder,
55        };
56
57        let ca_path: Option<&str> = self.ca_path.as_ref().and_then(|p| p.to_str());
58        let client_builder = match ca_path {
59            Some(p) => {
60                debug!("Adding trusted CA cert {:?}", p);
61                let client_builder = client_builder
62                    .add_root_certificate_filepath(p)
63                    .unwrap_or_else(|e| {
64                        error!("Failed to add ca certificate -- {:?}", e);
65                        std::process::exit(1);
66                    });
67
68                debug!(
69                    "After attempting to add trusted CA cert, client builder state: {:?}",
70                    client_builder
71                );
72                client_builder
73            }
74            None => client_builder,
75        };
76
77        let client_builder = match self.skip_hostname_verification {
78            true => {
79                warn!(
80                    "Accepting invalid hostnames on the certificate for {:?}",
81                    &self.addr
82                );
83                client_builder.danger_accept_invalid_hostnames(true)
84            }
85            false => client_builder,
86        };
87
88        let client_builder = match self.accept_invalid_certs {
89            true => {
90                warn!(
91                    "TLS Certificate Verification disabled!!! This can lead to credential and account compromise!!!"
92                );
93                client_builder.danger_accept_invalid_certs(true)
94            }
95            false => client_builder,
96        };
97
98        let client_builder = client_builder.set_token_cache_path(self.token_cache_path.clone());
99
100        client_builder.build().unwrap_or_else(|e| {
101            error!("Failed to build client instance -- {:?}", e);
102            std::process::exit(1);
103        })
104    }
105
106    pub(crate) async fn try_to_client(
107        &self,
108        optype: OpType,
109    ) -> Result<KanidmClient, ToClientError> {
110        let client = self.to_unauth_client();
111
112        // Read the token file.
113        let token_store = match read_tokens(&client.get_token_cache_path()) {
114            Ok(t) => t,
115            Err(_e) => {
116                error!("Error retrieving authentication token store");
117                return Err(ToClientError::Other);
118            }
119        };
120
121        let Some(token_instance) = token_store.instances(&self.instance) else {
122            error!(
123                "No valid authentication tokens found. Please login with the 'login' subcommand."
124            );
125            return Err(ToClientError::Other);
126        };
127
128        // If we have a username, use that to select tokens
129        let (spn, jwsc) = match &self.username {
130            Some(filter_username) => {
131                let possible_token = if filter_username.contains('@') {
132                    // If there is an @, it's an spn so just get the token directly.
133                    token_instance
134                        .tokens()
135                        .get(filter_username)
136                        .map(|t| (filter_username.clone(), t.clone()))
137                } else {
138                    // first we try to find user@hostname
139                    let filter_username_with_hostname = format!(
140                        "{}@{}",
141                        filter_username,
142                        client.get_origin().host_str().unwrap_or("localhost")
143                    );
144                    debug!(
145                        "Looking for tokens matching {}",
146                        filter_username_with_hostname
147                    );
148
149                    let mut token_refs: Vec<_> = token_instance
150                        .tokens()
151                        .iter()
152                        .filter(|(t, _)| *t == &filter_username_with_hostname)
153                        .map(|(k, v)| (k.clone(), v.clone()))
154                        .collect();
155
156                    if token_refs.len() == 1 {
157                        // return the token
158                        token_refs.pop()
159                    } else {
160                        // otherwise let's try the fallback
161                        let filter_username = format!("{filter_username}@");
162                        // Filter for tokens that match the pattern
163                        let mut token_refs: Vec<_> = token_instance
164                            .tokens()
165                            .iter()
166                            .filter(|(t, _)| t.starts_with(&filter_username))
167                            .map(|(s, j)| (s.clone(), j.clone()))
168                            .collect();
169
170                        match token_refs.len() {
171                            0 => None,
172                            1 => token_refs.pop(),
173                            _ => {
174                                error!("Multiple authentication tokens found for {}. Please specify the full spn to proceed", filter_username);
175                                return Err(ToClientError::Other);
176                            }
177                        }
178                    }
179                };
180
181                // Is it in the store?
182                match possible_token {
183                    Some(t) => t,
184                    None => {
185                        error!(
186                            "No valid authentication tokens found for {}.",
187                            filter_username
188                        );
189                        return Err(ToClientError::NeedLogin(filter_username.clone()));
190                    }
191                }
192            }
193            None => {
194                if token_instance.tokens().len() == 1 {
195                    #[allow(clippy::expect_used)]
196                    let (f_uname, f_token) = token_instance
197                        .tokens()
198                        .iter()
199                        .next()
200                        .expect("Memory Corruption");
201                    // else pick the first token
202                    debug!("Using cached token for name {}", f_uname);
203                    (f_uname.clone(), f_token.clone())
204                } else {
205                    // Unable to automatically select the user because multiple tokens exist
206                    // so we'll prompt the user to select one
207                    match prompt_for_username_get_values(
208                        &client.get_token_cache_path(),
209                        &self.instance,
210                    ) {
211                        Ok(tuple) => tuple,
212                        Err(msg) => {
213                            error!("Error: {}", msg);
214                            std::process::exit(1);
215                        }
216                    }
217                }
218            }
219        };
220
221        let Some(key_id) = jwsc.kid() else {
222            error!("token invalid, not key id associated");
223            return Err(ToClientError::Other);
224        };
225
226        let Some(pub_jwk) = token_instance.keys().get(key_id) else {
227            error!("token invalid, no cached jwk available");
228            return Err(ToClientError::Other);
229        };
230
231        // Is the token (probably) valid?
232        let jws_verifier = match JwsEs256Verifier::try_from(pub_jwk) {
233            Ok(verifier) => verifier,
234            Err(err) => {
235                error!(?err, "Unable to configure jws verifier");
236                return Err(ToClientError::Other);
237            }
238        };
239
240        match jws_verifier.verify(&jwsc).and_then(|jws| {
241            jws.from_json::<UserAuthToken>().map_err(|serde_err| {
242                error!(?serde_err);
243                JwtError::InvalidJwt
244            })
245        }) {
246            Ok(uat) => {
247                let now_utc = time::OffsetDateTime::now_utc();
248                if let Some(exp) = uat.expiry {
249                    if now_utc >= exp {
250                        error!(
251                            "Session has expired for {} - you may need to login again.",
252                            uat.spn
253                        );
254                        return Err(ToClientError::NeedLogin(spn));
255                    }
256                }
257
258                // It's probably valid, set into the client
259                client.set_token(jwsc.to_string()).await;
260
261                // Check what we are doing based on op.
262                match optype {
263                    OpType::Read => {}
264                    OpType::Write => {
265                        if !uat.purpose_readwrite_active(now_utc + time::Duration::new(20, 0)) {
266                            error!(
267                                "Privileges have expired for {} - you need to re-authenticate again.",
268                                uat.spn
269                            );
270                            return Err(ToClientError::NeedReauth(spn, client));
271                        }
272                    }
273                }
274            }
275            Err(e) => {
276                error!("Unable to read token for requested user - you may need to login again.");
277                debug!(?e, "JWT Error");
278                return Err(ToClientError::NeedLogin(spn));
279            }
280        };
281
282        Ok(client)
283    }
284
285    pub async fn to_client(&self, optype: OpType) -> KanidmClient {
286        loop {
287            match self.try_to_client(optype).await {
288                Ok(c) => break c,
289                Err(ToClientError::NeedLogin(username)) => {
290                    if !Confirm::new()
291                        .with_prompt("Would you like to login again?")
292                        .default(true)
293                        .interact()
294                        .expect("Failed to interact with interactive session")
295                    {
296                        std::process::exit(1);
297                    }
298
299                    let copt = Self {
300                        username: Some(username),
301                        ..self.to_owned()
302                    };
303
304                    let login_opt = LoginOpt {};
305
306                    // Okay, try again ...
307                    login_opt.exec(copt).await;
308                    continue;
309                }
310
311                Err(ToClientError::NeedReauth(username, _client)) => {
312                    if !Confirm::new()
313                        .with_prompt("Would you like to re-authenticate?")
314                        .default(true)
315                        .interact()
316                        .expect("Failed to interact with interactive session")
317                    {
318                        std::process::exit(1);
319                    }
320
321                    let copt = Self {
322                        username: Some(username),
323                        ..self.to_owned()
324                    };
325                    Box::pin(copt.reauth(optype)).await;
326
327                    // Okay, re-auth should have passed, lets loop
328                    continue;
329                }
330                Err(ToClientError::Other) => {
331                    std::process::exit(1);
332                }
333            }
334        }
335    }
336
337    pub(crate) async fn reauth(&self, optype: OpType) {
338        let client = self.to_client(optype).await;
339
340        let allowed = client.reauth_begin().await.unwrap_or_else(|e| {
341            error!("Error during reauthentication begin phase: {:?}", e);
342            std::process::exit(1);
343        });
344
345        process_auth_state(allowed, client, &self.password, &self.instance).await;
346    }
347}
348
349/// This parses the token store and prompts the user to select their username, returns the username/token as a tuple of Strings
350///
351/// Used to reduce duplication in implementing [prompt_for_username_get_username] and `prompt_for_username_get_token`
352pub fn prompt_for_username_get_values(
353    token_cache_path: &str,
354    instance_name: &Option<String>,
355) -> Result<(String, JwsCompact), String> {
356    let token_store = match read_tokens(token_cache_path) {
357        Ok(value) => value,
358        _ => return Err("Error retrieving authentication token store".to_string()),
359    };
360
361    let Some(token_instance) = token_store.instances(instance_name) else {
362        error!("No tokens in store, quitting!");
363        std::process::exit(1);
364    };
365
366    if token_instance.tokens().is_empty() {
367        error!("No tokens in store, quitting!");
368        std::process::exit(1);
369    }
370    let mut options = Vec::new();
371    for option in token_instance.tokens().iter() {
372        options.push(String::from(option.0));
373    }
374    let user_select = Select::with_theme(&ColorfulTheme::default())
375        .with_prompt("Multiple authentication tokens exist. Please select one")
376        .default(0)
377        .items(&options)
378        .interact();
379    let selection = match user_select {
380        Err(error) => {
381            error!("Failed to handle user input: {:?}", error);
382            std::process::exit(1);
383        }
384        Ok(value) => value,
385    };
386    debug!("Index of the chosen menu item: {:?}", selection);
387
388    match token_instance.tokens().iter().nth(selection) {
389        Some(value) => {
390            let (f_uname, f_token) = value;
391            debug!("Using cached token for name {}", f_uname);
392            debug!(
393                "First ten chars of cached token: {}",
394                &f_token.to_string()[..10]
395            );
396            Ok((f_uname.to_string(), f_token.clone()))
397        }
398        None => {
399            error!("Memory corruption trying to read token store, quitting!");
400            std::process::exit(1);
401        }
402    }
403}
404
405/// This parses the token store and prompts the user to select their username, returns the username as a String
406///
407/// Powered by [prompt_for_username_get_values]
408pub fn prompt_for_username_get_username(
409    token_cache_path: &str,
410    instance_name: &Option<String>,
411) -> Result<String, String> {
412    match prompt_for_username_get_values(token_cache_path, instance_name) {
413        Ok(value) => {
414            let (f_user, _) = value;
415            Ok(f_user)
416        }
417        Err(err) => Err(err),
418    }
419}
420
421/*
422/// This parses the token store and prompts the user to select their username, returns the token as a String
423///
424/// Powered by [prompt_for_username_get_values]
425pub fn prompt_for_username_get_token() -> Result<String, String> {
426    match prompt_for_username_get_values() {
427        Ok(value) => {
428            let (_, f_token) = value;
429            Ok(f_token)
430        }
431        Err(err) => Err(err),
432    }
433}
434*/
435
436/// This parses the input for the person/service-account expire-at CLI commands
437///
438/// If it fails, return error, if it needs to *clear* the result, return Ok(None),
439/// otherwise return Ok(Some(String)) which is the new value to set.
440pub(crate) fn try_expire_at_from_string(input: &str) -> Result<Option<String>, ()> {
441    match input {
442        "any" | "never" | "clear" => Ok(None),
443        "now" => match OffsetDateTime::now_utc().format(&Rfc3339) {
444            Ok(s) => Ok(Some(s)),
445            Err(e) => {
446                error!(err = ?e, "Unable to format current time to rfc3339");
447                Err(())
448            }
449        },
450        "epoch" => match OffsetDateTime::UNIX_EPOCH.format(&Rfc3339) {
451            Ok(val) => Ok(Some(val)),
452            Err(err) => {
453                error!("Failed to format epoch timestamp as RFC3339: {:?}", err);
454                Err(())
455            }
456        },
457        _ => {
458            // fall back to parsing it as a date
459            match OffsetDateTime::parse(input, &Rfc3339) {
460                Ok(_) => Ok(Some(input.to_string())),
461                Err(err) => {
462                    error!("Failed to parse supplied timestamp: {:?}", err);
463                    Err(())
464                }
465            }
466        }
467    }
468}