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 let key_id = jwsc.kid()?;
51
52 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 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 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 let file = match File::open(&token_path) {
134 Ok(f) => f,
135 Err(e) => {
136 match e.kind() {
137 ErrorKind::PermissionDenied => {
138 error!(
140 "Permission denied reading token store file {:?}",
141 &token_path
142 );
143 return Err(());
144 }
145 _ => {
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 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 #[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
218fn 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 #[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 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 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 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 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 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 }
425
426 let mut tokens = read_tokens(&client.get_token_cache_path()).unwrap_or_default();
428
429 let n_lookup = instance_name.clone().unwrap_or_default();
431 let token_instance = tokens.instances.entry(n_lookup).or_default();
432
433 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 let pub_jwk = if let Some(pub_jwk) = token_instance.keys.get(key_id).cloned() {
451 pub_jwk
452 } else {
453 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 (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 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 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 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 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 let mut _tmp_username = String::new();
591 match &opt.username {
592 Some(value) => value.clone(),
593 None => {
594 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 std::process::exit(0);
618 }
619 Err(ToClientError::NeedReauth(_, _))
620 | Err(ToClientError::ReadOnly)
621 | Err(ToClientError::Other) => {
622 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 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 if let Err(e) = client.logout().await {
684 error!("Failed to logout - {:?}", e);
685 std::process::exit(1);
686 }
687
688 uat.spn
691 };
692
693 if token_instance.tokens.remove(&spn).is_some() {
695 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 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}