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 let key_id = jwsc.kid()?;
53
54 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 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 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 let file = match File::open(&token_path) {
136 Ok(f) => f,
137 Err(e) => {
138 match e.kind() {
139 ErrorKind::PermissionDenied => {
140 error!(
142 "Permission denied reading token store file {:?}",
143 &token_path
144 );
145 return Err(());
146 }
147 _ => {
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 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 #[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
220fn 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 #[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 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 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 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 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 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 }
427
428 let mut tokens = read_tokens(&client.get_token_cache_path()).unwrap_or_default();
430
431 let n_lookup = instance_name.clone().unwrap_or_default();
433 let token_instance = tokens.instances.entry(n_lookup).or_default();
434
435 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 let pub_jwk = if let Some(pub_jwk) = token_instance.keys.get(key_id).cloned() {
453 pub_jwk
454 } else {
455 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 (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 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 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 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 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 let mut _tmp_username = String::new();
593 match &opt.username {
594 Some(value) => value.clone(),
595 None => {
596 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 std::process::exit(0);
620 }
621 Err(ToClientError::NeedReauth(_, _))
622 | Err(ToClientError::ReadOnly)
623 | Err(ToClientError::Other) => {
624 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 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 if let Err(e) = client.logout().await {
686 error!("Failed to logout - {:?}", e);
687 std::process::exit(1);
688 }
689
690 uat.spn
693 };
694
695 if token_instance.tokens.remove(&spn).is_some() {
697 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 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}