Skip to main content

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