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