kanidm_cli/
session.rs

1use crate::common::prompt_for_username_get_username;
2use crate::common::ToClientError;
3use crate::OpType;
4use crate::{KanidmClientParser, LoginOpt, LogoutOpt, SessionOpt};
5use compact_jwt::{
6    traits::JwsVerifiable, Jwk, JwsCompact, JwsEs256Verifier, JwsVerifier, JwtError,
7};
8use dialoguer::theme::ColorfulTheme;
9use dialoguer::Select;
10use kanidm_client::{ClientError, KanidmClient};
11use kanidm_proto::internal::UserAuthToken;
12use kanidm_proto::v1::{AuthAllowed, AuthResponse, AuthState};
13use serde::{Deserialize, Serialize};
14use std::cmp::Reverse;
15use std::collections::BTreeMap;
16use std::fs::{create_dir, File};
17use std::io::{self, BufReader, BufWriter, ErrorKind, IsTerminal, Write};
18use std::path::PathBuf;
19use std::str::FromStr;
20use webauthn_authenticator_rs::prelude::RequestChallengeResponse;
21
22#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
23use crate::webauthn::get_authenticator;
24
25#[cfg(target_family = "unix")]
26use libc::umask;
27
28static TOKEN_DIR: &str = "~/.cache";
29
30#[derive(Debug, Serialize, Clone, Deserialize, Default)]
31pub struct TokenInstance {
32    keys: BTreeMap<String, Jwk>,
33    tokens: BTreeMap<String, JwsCompact>,
34}
35
36impl TokenInstance {
37    pub fn tokens(&self) -> &BTreeMap<String, JwsCompact> {
38        &self.tokens
39    }
40
41    pub fn keys(&self) -> &BTreeMap<String, Jwk> {
42        &self.keys
43    }
44
45    pub fn valid_uats(&self) -> BTreeMap<String, UserAuthToken> {
46        self.tokens
47            .iter()
48            .filter_map(|(u, jwsc)| {
49                // Ignore if it has no key id.
50                let key_id = jwsc.kid()?;
51
52                // Ignore if we can't verify
53                let pub_jwk = self.keys.get(key_id)?;
54
55                let jws_verifier = JwsEs256Verifier::try_from(pub_jwk)
56                    .map_err(|e| {
57                        error!(?e, "Unable to configure jws verifier");
58                    })
59                    .ok()?;
60
61                jws_verifier
62                    .verify(jwsc)
63                    .and_then(|jws| {
64                        jws.from_json::<UserAuthToken>().map_err(|serde_err| {
65                            error!(?serde_err);
66                            JwtError::InvalidJwt
67                        })
68                    })
69                    .map_err(|e| {
70                        error!(?e, "Unable to verify token signature, may be corrupt");
71                    })
72                    .map(|uat| (u.clone(), uat))
73                    .ok()
74            })
75            .collect()
76    }
77
78    pub fn cleanup(&mut self, now: time::OffsetDateTime) -> usize {
79        // It's not optimal to do this in this way, but we can't double borrow.
80        let retain = self.valid_uats();
81
82        let start_len = self.tokens.len();
83
84        self.tokens.retain(|spn, _tonk| {
85            if let Some(uat) = retain.get(spn) {
86                if let Some(exp) = uat.expiry {
87                    // Retain if expiry is in future aka greater than now
88                    exp > now
89                } else {
90                    true
91                }
92            } else {
93                false
94            }
95        });
96
97        start_len - self.tokens.len()
98    }
99}
100
101#[derive(Debug, Serialize, Clone, Deserialize, Default)]
102pub struct TokenStore {
103    instances: BTreeMap<String, TokenInstance>,
104}
105
106impl TokenStore {
107    pub fn instances(&self, name: &Option<String>) -> Option<&TokenInstance> {
108        let n_lookup = name.clone().unwrap_or_default();
109
110        self.instances.get(&n_lookup)
111    }
112
113    pub fn instances_mut(&mut self, name: &Option<String>) -> Option<&mut TokenInstance> {
114        let n_lookup = name.clone().unwrap_or_default();
115
116        self.instances.get_mut(&n_lookup)
117    }
118}
119
120#[allow(clippy::result_unit_err)]
121pub fn read_tokens(token_path: &str) -> Result<TokenStore, ()> {
122    let token_path = PathBuf::from(shellexpand::tilde(token_path).into_owned());
123    if !token_path.exists() {
124        debug!(
125            "Token cache file path {:?} does not exist, returning an empty token store.",
126            token_path
127        );
128        return Ok(Default::default());
129    }
130
131    debug!("Attempting to read tokens from {:?}", &token_path);
132    // If the file does not exist, return Ok<map>
133    let file = match File::open(&token_path) {
134        Ok(f) => f,
135        Err(e) => {
136            match e.kind() {
137                ErrorKind::PermissionDenied => {
138                    // we bail here because you won't be able to write them back...
139                    error!(
140                        "Permission denied reading token store file {:?}",
141                        &token_path
142                    );
143                    return Err(());
144                }
145                // other errors are OK to continue past
146                _ => {
147                    warn!(
148                        "Cannot read tokens from {} due to error: {:?} ... continuing.",
149                        token_path.display(),
150                        e
151                    );
152                    return Ok(Default::default());
153                }
154            };
155        }
156    };
157    let reader = BufReader::new(file);
158
159    // Else try to read
160    serde_json::from_reader(reader).map_err(|e| {
161        warn!(
162            "JSON/IO error reading tokens from {:?} -> {:?}",
163            &token_path, e
164        );
165    })
166}
167
168#[allow(clippy::result_unit_err)]
169pub fn write_tokens(tokens: &TokenStore, token_path: &str) -> Result<(), ()> {
170    let token_dir = PathBuf::from(shellexpand::tilde(TOKEN_DIR).into_owned());
171    let token_path = PathBuf::from(shellexpand::tilde(token_path).into_owned());
172
173    token_dir
174        .parent()
175        .ok_or_else(|| {
176            error!(
177                "Parent directory to {} is invalid (root directory?).",
178                TOKEN_DIR
179            );
180        })
181        .and_then(|parent_dir| {
182            if parent_dir.exists() {
183                Ok(())
184            } else {
185                error!("Parent directory to {} does not exist.", TOKEN_DIR);
186                Err(())
187            }
188        })?;
189
190    if !token_dir.exists() {
191        create_dir(token_dir).map_err(|e| {
192            error!("Unable to create directory - {} {:?}", TOKEN_DIR, e);
193        })?;
194    }
195
196    // Take away group/everyone read/write
197    #[cfg(target_family = "unix")]
198    let before = unsafe { umask(0o177) };
199
200    let file = File::create(&token_path).map_err(|e| {
201        #[cfg(target_family = "unix")]
202        let _ = unsafe { umask(before) };
203        error!("Can not write to {} -> {:?}", token_path.display(), e);
204    })?;
205
206    #[cfg(target_family = "unix")]
207    let _ = unsafe { umask(before) };
208
209    let writer = BufWriter::new(file);
210    serde_json::to_writer_pretty(writer, tokens).map_err(|e| {
211        error!(
212            "JSON/IO error writing tokens to file {:?} -> {:?}",
213            &token_path, e
214        );
215    })
216}
217
218/// An interactive dialog to choose from given options
219fn get_index_choice_dialoguer(msg: &str, options: &[String]) -> usize {
220    let user_select = Select::with_theme(&ColorfulTheme::default())
221        .with_prompt(msg)
222        .default(0)
223        .items(options)
224        .interact();
225
226    let selection = match user_select {
227        Err(error) => {
228            error!("Failed to handle user input: {:?}", error);
229            std::process::exit(1);
230        }
231        Ok(value) => value,
232    };
233    debug!("Index of the chosen menu item: {:?}", selection);
234
235    selection
236}
237
238async fn do_password(
239    client: &mut KanidmClient,
240    password: &Option<String>,
241) -> Result<AuthResponse, ClientError> {
242    let password = match password {
243        Some(password) => {
244            trace!("User provided password directly, don't need to prompt.");
245            password.to_owned()
246        }
247        None => dialoguer::Password::new()
248            .with_prompt("Enter password")
249            .interact()
250            .unwrap_or_else(|e| {
251                error!("Failed to create password prompt -- {:?}", e);
252                std::process::exit(1);
253            }),
254    };
255    client.auth_step_password(password.as_str()).await
256}
257
258async fn do_backup_code(client: &mut KanidmClient) -> Result<AuthResponse, ClientError> {
259    print!("Enter Backup Code: ");
260    // We flush stdout so it'll write the buffer to screen, continuing operation. Without it, the application halts.
261    #[allow(clippy::unwrap_used)]
262    io::stdout().flush().unwrap();
263    let mut backup_code = String::new();
264    loop {
265        if let Err(e) = io::stdin().read_line(&mut backup_code) {
266            error!("Failed to read from stdin -> {:?}", e);
267            return Err(ClientError::SystemError);
268        };
269        if !backup_code.trim().is_empty() {
270            break;
271        };
272    }
273    client.auth_step_backup_code(backup_code.trim()).await
274}
275
276async fn do_totp(client: &mut KanidmClient) -> Result<AuthResponse, ClientError> {
277    let totp = loop {
278        print!("Enter TOTP: ");
279        // We flush stdout so it'll write the buffer to screen, continuing operation. Without it, the application halts.
280        if let Err(e) = io::stdout().flush() {
281            error!("Somehow we failed to flush stdout: {:?}", e);
282        };
283        let mut buffer = String::new();
284        if let Err(e) = io::stdin().read_line(&mut buffer) {
285            error!("Failed to read from stdin -> {:?}", e);
286            return Err(ClientError::SystemError);
287        };
288
289        let response = buffer.trim();
290        match response.parse::<u32>() {
291            Ok(i) => break i,
292            Err(_) => eprintln!("Invalid Number"),
293        };
294    };
295    client.auth_step_totp(totp).await
296}
297
298#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
299async fn do_passkey(
300    _client: &mut KanidmClient,
301    _pkr: RequestChallengeResponse,
302) -> Result<AuthResponse, ClientError> {
303    eprintln!("Passkey authentication is not supported on this platform");
304    return Err(ClientError::SystemError);
305}
306
307#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
308async fn do_passkey(
309    client: &mut KanidmClient,
310    pkr: RequestChallengeResponse,
311) -> Result<AuthResponse, ClientError> {
312    let mut wa = get_authenticator();
313    println!("If your authenticator is not attached, attach it now.");
314    println!("Your authenticator will then flash/prompt for confirmation.");
315    #[cfg(target_os = "macos")]
316    println!("Note: TouchID is not currently supported on the CLI 🫤");
317    let auth = wa
318        .do_authentication(client.get_origin().clone(), pkr)
319        .map(Box::new)
320        .unwrap_or_else(|e| {
321            error!("Failed to interact with webauthn device. -- {:?}", e);
322            std::process::exit(1);
323        });
324
325    client.auth_step_passkey_complete(auth).await
326}
327
328#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
329async fn do_securitykey(
330    _client: &mut KanidmClient,
331    _pkr: RequestChallengeResponse,
332) -> Result<AuthResponse, ClientError> {
333    eprintln!("Security Key authentication is not supported on this platform");
334    return Err(ClientError::SystemError);
335}
336
337#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
338async fn do_securitykey(
339    client: &mut KanidmClient,
340    pkr: RequestChallengeResponse,
341) -> Result<AuthResponse, ClientError> {
342    let mut wa = get_authenticator();
343    println!("Your authenticator will now flash for you to interact with it.");
344    let auth = wa
345        .do_authentication(client.get_origin().clone(), pkr)
346        .map(Box::new)
347        .unwrap_or_else(|e| {
348            error!("Failed to interact with webauthn device. -- {:?}", e);
349            std::process::exit(1);
350        });
351
352    client.auth_step_securitykey_complete(auth).await
353}
354
355pub(crate) async fn process_auth_state(
356    mut allowed: Vec<AuthAllowed>,
357    mut client: KanidmClient,
358    maybe_password: &Option<String>,
359    instance_name: &Option<String>,
360) {
361    loop {
362        debug!("Allowed mechanisms -> {:?}", allowed);
363        // What auth can proceed?
364        let choice = match allowed.len() {
365            0 => {
366                error!("Error during authentication phase: Server offered no method to proceed");
367                std::process::exit(1);
368            }
369            1 =>
370            {
371                #[allow(clippy::expect_used)]
372                allowed
373                    .first()
374                    .expect("can not fail - bounds already checked.")
375            }
376            _ => {
377                let mut options = Vec::new();
378                // because we want them in "most secure to least secure" order.
379                allowed.sort_unstable_by(|a, b| Reverse(a).cmp(&Reverse(b)));
380                for val in allowed.iter() {
381                    options.push(val.to_string());
382                }
383                let msg = "Please choose which credential to provide:";
384                let selection = get_index_choice_dialoguer(msg, &options);
385
386                #[allow(clippy::expect_used)]
387                allowed
388                    .get(selection)
389                    .expect("Failed to select an authentication option!")
390            }
391        };
392
393        let res = match choice {
394            AuthAllowed::Anonymous => client.auth_step_anonymous().await,
395            AuthAllowed::Password => do_password(&mut client, maybe_password).await,
396            AuthAllowed::BackupCode => do_backup_code(&mut client).await,
397            AuthAllowed::Totp => do_totp(&mut client).await,
398            AuthAllowed::Passkey(chal) => do_passkey(&mut client, chal.clone()).await,
399            AuthAllowed::SecurityKey(chal) => do_securitykey(&mut client, chal.clone()).await,
400        };
401
402        // Now update state.
403        let state = res
404            .unwrap_or_else(|e| {
405                error!("Error in authentication phase: {:?}", e);
406                std::process::exit(1);
407            })
408            .state;
409
410        // What auth state are we in?
411        allowed = match &state {
412            AuthState::Continue(allowed) => allowed.to_vec(),
413            AuthState::Success(_token) => break,
414            AuthState::Denied(reason) => {
415                error!("Authentication Denied: {:?}", reason);
416                std::process::exit(1);
417            }
418            _ => {
419                error!("Error in authentication phase: invalid authstate");
420                std::process::exit(1);
421            }
422        };
423        // Loop again.
424    }
425
426    // Read the current tokens. If we can't read them, IGNORE!!!
427    let mut tokens = read_tokens(&client.get_token_cache_path()).unwrap_or_default();
428
429    // Select our token instance. Create it if empty.
430    let n_lookup = instance_name.clone().unwrap_or_default();
431    let token_instance = tokens.instances.entry(n_lookup).or_default();
432
433    // Add our new one
434    let (spn, tonk) = match client.get_token().await {
435        Some(t) => {
436            let jwsc = match JwsCompact::from_str(&t) {
437                Ok(j) => j,
438                Err(err) => {
439                    error!(?err, "Unable to parse token");
440                    std::process::exit(1);
441                }
442            };
443
444            let Some(key_id) = jwsc.kid() else {
445                error!("JWS invalid, not key id associated");
446                std::process::exit(1);
447            };
448
449            // Okay, lets check the jwk now.
450            let pub_jwk = if let Some(pub_jwk) = token_instance.keys.get(key_id).cloned() {
451                pub_jwk
452            } else {
453                // Get it from the server.
454                let pub_jwk = match client.get_public_jwk(key_id).await {
455                    Ok(pj) => pj,
456                    Err(err) => {
457                        error!(?err, "Unable to retrieve jwk from server");
458                        std::process::exit(1);
459                    }
460                };
461                token_instance
462                    .keys
463                    .insert(key_id.to_string(), pub_jwk.clone());
464                pub_jwk
465            };
466
467            let jws_verifier = match JwsEs256Verifier::try_from(&pub_jwk) {
468                Ok(verifier) => verifier,
469                Err(err) => {
470                    error!(?err, "Unable to configure jws verifier");
471                    std::process::exit(1);
472                }
473            };
474
475            let tonk = match jws_verifier.verify(&jwsc).and_then(|jws| {
476                jws.from_json::<UserAuthToken>().map_err(|serde_err| {
477                    error!(?serde_err);
478                    JwtError::InvalidJwt
479                })
480            }) {
481                Ok(uat) => uat,
482                Err(err) => {
483                    error!(?err, "Unable to verify token signature");
484                    std::process::exit(1);
485                }
486            };
487
488            let spn = tonk.spn;
489            // Return the original jws
490            (spn, jwsc)
491        }
492        None => {
493            error!("Error retrieving client session");
494            std::process::exit(1);
495        }
496    };
497
498    token_instance.tokens.insert(spn.clone(), tonk);
499
500    // write them out.
501    if write_tokens(&tokens, &client.get_token_cache_path()).is_err() {
502        trace!(?tokens);
503        error!("Error persisting authentication token store");
504        std::process::exit(1);
505    };
506
507    // Success!
508    println!("Login Success for {spn}");
509}
510
511impl LoginOpt {
512    pub async fn exec(&self, opt: KanidmClientParser) {
513        let client = opt.to_unauth_client();
514        let username = match opt.username.as_deref() {
515            Some(val) => val,
516            None => {
517                error!("Please specify a username with -D <USERNAME> to login.");
518                std::process::exit(1);
519            }
520        };
521
522        // What auth mechanisms exist?
523        let mut mechs: Vec<_> = client
524            .auth_step_init(username)
525            .await
526            .unwrap_or_else(|e| {
527                error!("Error during authentication init phase: {:?}", e);
528                std::process::exit(1);
529            })
530            .into_iter()
531            .collect();
532
533        mechs.sort_unstable_by(|a, b| Reverse(a).cmp(&Reverse(b)));
534
535        let mech = match mechs.len() {
536            0 => {
537                error!("Error during authentication init phase: Server offered no authentication mechanisms");
538                std::process::exit(1);
539            }
540            1 =>
541            {
542                #[allow(clippy::expect_used)]
543                mechs
544                    .first()
545                    .expect("can not fail - bounds already checked.")
546            }
547            _ => {
548                let mut options = Vec::new();
549                for val in mechs.iter() {
550                    options.push(val.to_string());
551                }
552                let msg = "Please choose how you want to authenticate:";
553                let selection = get_index_choice_dialoguer(msg, &options);
554
555                #[allow(clippy::expect_used)]
556                mechs
557                    .get(selection)
558                    .expect("can not fail - bounds already checked.")
559            }
560        };
561
562        let allowed = client
563            .auth_step_begin((*mech).clone())
564            .await
565            .unwrap_or_else(|e| {
566                error!("Error during authentication begin phase: {:?}", e);
567                std::process::exit(1);
568            });
569
570        // We now have the first auth state, so we can proceed until complete.
571        process_auth_state(allowed, client, &opt.password, &opt.instance).await;
572    }
573}
574
575impl LogoutOpt {
576    pub async fn exec(&self, opt: KanidmClientParser) {
577        let mut tokens = read_tokens(&opt.get_token_cache_path()).unwrap_or_else(|_| {
578            error!("Error retrieving authentication token store");
579            std::process::exit(1);
580        });
581
582        let n_lookup = opt.instance.clone().unwrap_or_default();
583        let Some(token_instance) = tokens.instances.get_mut(&n_lookup) else {
584            println!("No sessions for instance {n_lookup}");
585            return;
586        };
587
588        let spn: String = if self.local_only {
589            // For now we just remove this from the token store.
590            let mut _tmp_username = String::new();
591            match &opt.username {
592                Some(value) => value.clone(),
593                None => {
594                    // check if we're in a tty
595                    if std::io::stdin().is_terminal() {
596                        match prompt_for_username_get_username(
597                            &opt.get_token_cache_path(),
598                            &opt.instance,
599                        ) {
600                            Ok(value) => value,
601                            Err(msg) => {
602                                error!("{}", msg);
603                                std::process::exit(1);
604                            }
605                        }
606                    } else {
607                        eprintln!("Not running in interactive mode and no username specified, can't continue!");
608                        return;
609                    }
610                }
611            }
612        } else {
613            let client = match opt.try_to_client(OpType::Read).await {
614                Ok(c) => c,
615                Err(ToClientError::NeedLogin(_)) => {
616                    // There are no session tokens, so return a success.
617                    std::process::exit(0);
618                }
619                Err(ToClientError::NeedReauth(_, _))
620                | Err(ToClientError::ReadOnly)
621                | Err(ToClientError::Other) => {
622                    // This can only occur in bad cases, so fail.
623                    std::process::exit(1);
624                }
625            };
626
627            let token = match client.get_token().await {
628                Some(t) => t,
629                None => {
630                    error!("Client token store is empty/corrupt");
631                    std::process::exit(1);
632                }
633            };
634
635            // Parse it for the SPN. Annoying but it's what we have to do
636            // because we don't know what token was used in the lower to client calls.
637            let jwsc = match JwsCompact::from_str(&token) {
638                Ok(j) => j,
639                Err(err) => {
640                    error!(?err, "Unable to parse token");
641                    info!("The token can be removed locally with `--local-only`");
642                    std::process::exit(1);
643                }
644            };
645
646            let Some(key_id) = jwsc.kid() else {
647                error!("Invalid token, missing KeyID");
648                info!("The token can be removed locally with `--local-only`");
649                std::process::exit(1);
650            };
651
652            let Some(pub_jwk) = token_instance.keys().get(key_id) else {
653                error!("Invalid instance, no signing keys are available");
654                info!("The token can be removed locally with `--local-only`");
655                std::process::exit(1);
656            };
657
658            let jws_verifier = match JwsEs256Verifier::try_from(pub_jwk) {
659                Ok(verifier) => verifier,
660                Err(err) => {
661                    error!(?err, "Unable to configure jws verifier");
662                    info!("The token can be removed locally with `--local-only`");
663                    std::process::exit(1);
664                }
665            };
666
667            let uat = match jws_verifier.verify(&jwsc).and_then(|jws| {
668                jws.from_json::<UserAuthToken>().map_err(|serde_err| {
669                    error!(?serde_err);
670                    info!("The token can be removed locally with `--local-only`");
671                    JwtError::InvalidJwt
672                })
673            }) {
674                Ok(uat) => uat,
675                Err(e) => {
676                    error!(?e, "Unable to verify token signature, may be corrupt");
677                    info!("The token can be removed locally with `--local-only`");
678                    std::process::exit(1);
679                }
680            };
681
682            // Now we know we have a valid(ish) token, call the server to do the logout.
683            if let Err(e) = client.logout().await {
684                error!("Failed to logout - {:?}", e);
685                std::process::exit(1);
686            }
687
688            // Server acked the logout, lets proceed with the local cleanup now, return
689            // the spn so the outer parts know what to remove.
690            uat.spn
691        };
692
693        // Remove our old one
694        if token_instance.tokens.remove(&spn).is_some() {
695            // write them out.
696            if let Err(_e) = write_tokens(&tokens, &opt.get_token_cache_path()) {
697                error!("Error persisting authentication token store");
698                std::process::exit(1);
699            };
700            opt.output_mode
701                .print_message(format!("Removed session for {spn}"));
702        } else {
703            opt.output_mode
704                .print_message(format!("No sessions for {spn}"));
705        }
706    }
707}
708
709impl SessionOpt {
710    pub async fn exec(&self, opt: KanidmClientParser) {
711        match self {
712            SessionOpt::List => {
713                let token_store = read_tokens(&opt.get_token_cache_path()).unwrap_or_else(|_| {
714                    error!("Error retrieving authentication token store");
715                    std::process::exit(1);
716                });
717
718                let Some(token_instance) = token_store.instances(&opt.instance) else {
719                    return;
720                };
721
722                for (_, uat) in token_instance.valid_uats() {
723                    println!("---");
724                    println!("{uat}");
725                }
726            }
727            SessionOpt::Cleanup => {
728                let mut token_store =
729                    read_tokens(&opt.get_token_cache_path()).unwrap_or_else(|_| {
730                        error!("Error retrieving authentication token store");
731                        std::process::exit(1);
732                    });
733
734                let instance_name = &opt.instance;
735
736                let Some(token_instance) = token_store.instances_mut(instance_name) else {
737                    error!("No tokens for instance");
738                    std::process::exit(1);
739                };
740
741                #[allow(clippy::disallowed_methods)]
742                // Allowed as this should represent the current time from the callers machine.
743                let now = time::OffsetDateTime::now_utc();
744                let change = token_instance.cleanup(now);
745
746                if let Err(_e) = write_tokens(&token_store, &opt.get_token_cache_path()) {
747                    error!("Error persisting authentication token store");
748                    std::process::exit(1);
749                };
750
751                println!("Removed {change} sessions");
752            }
753        }
754    }
755}