1#[cfg(feature = "keyring")]
2use crate::storage::sync_storage::KeyringStorage;
3#[cfg(feature = "pem")]
4use crate::storage::sync_storage::PemStorage;
5use crate::{
6 ArcIdentity, AuthClientError,
7 api::AuthResponseSuccess,
8 idle_manager::{IdleManager, IdleManagerOptions},
9 key::{Key, KeyWithRaw},
10 option::{AuthClientLoginOptions, IdleOptions, native::NativeAuthClientCreateOptions},
11 storage::{
12 KEY_STORAGE_DELEGATION, KEY_STORAGE_KEY, StorageError, StoredKey,
13 sync_storage::AuthClientStorage,
14 },
15 util::{callback::OnSuccess, delegation_chain::DelegationChain},
16};
17use base64::prelude::{BASE64_STANDARD, Engine as _};
18use ed25519_dalek::SigningKey;
19use futures::{channel::oneshot, executor::block_on};
20use ic_agent::{
21 export::Principal,
22 identity::{AnonymousIdentity, DelegatedIdentity, DelegationError, Identity, SignedDelegation},
23};
24use parking_lot::Mutex;
25use serde_json::Number;
26use std::{fmt, sync::Arc, thread, time::Duration};
27#[cfg(feature = "pem")]
28use std::{
29 fs,
30 io::ErrorKind,
31 path::{Path, PathBuf},
32};
33use tiny_http::{Response, Server};
34use url::Url;
35
36#[derive(Debug, thiserror::Error)]
42pub enum NativeLoginError {
43 #[error("No free ports available")]
45 NoFreePort,
46 #[error("Server error: {0}")]
48 ServerError(#[from] Box<dyn std::error::Error + Send + Sync + 'static>),
49 #[error("URL parse error: {0}")]
51 UrlParseError(#[from] url::ParseError),
52 #[error("Failed to open browser: {0}")]
54 BrowserOpenError(String),
55 #[error("Server receive timed out")]
57 ServerTimeout,
58 #[error("Server thread panicked")]
60 ServerThreadPanicked,
61 #[error("Failed to receive delegation")]
63 OneshotRecvError,
64 #[error("JSON deserialization error: {0}")]
66 JsonError(#[from] serde_json::Error),
67 #[error("Delegation error: {0}")]
69 DelegationError(#[from] DelegationError),
70 #[error("Missing delegation or error parameter in redirect")]
72 MissingDelegationOrError,
73 #[error("Unexpected request path: {0}")]
75 UnexpectedRequestPath(String),
76 #[error("Custom error: {0}")]
78 Custom(String),
79}
80
81enum CallbackResult {
82 Success(AuthResponseSuccess),
83 Error(NativeLoginError),
84}
85
86pub(super) struct AuthClientInner {
87 pub identity: Arc<Mutex<ArcIdentity>>,
88 pub key: Key,
89 pub storage: Mutex<Box<dyn AuthClientStorage>>,
90 pub chain: Arc<Mutex<Option<DelegationChain>>>,
91 pub idle_manager: Mutex<Option<IdleManager>>,
92 pub idle_options: Option<IdleOptions>,
93}
94
95impl fmt::Debug for AuthClientInner {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 f.debug_struct("AuthClientInner")
98 .field("key", &self.key)
99 .field("idle_options", &self.idle_options)
100 .finish()
101 }
102}
103
104#[derive(Clone, Debug)]
108pub struct NativeAuthClient(Arc<AuthClientInner>);
109
110impl NativeAuthClient {
111 #[cfg(feature = "keyring")]
113 pub fn new<T: AsRef<str>>(service_name: T) -> Result<Self, AuthClientError> {
114 let options = NativeAuthClientCreateOptions::builder()
115 .storage(KeyringStorage::new(service_name.as_ref()))
116 .build();
117 Self::new_with_options(options)
118 }
119
120 #[cfg(feature = "pem")]
122 pub fn new_with_pem_directory<T, P>(
123 service_name: T,
124 directory: P,
125 ) -> Result<Self, AuthClientError>
126 where
127 T: Into<String>,
128 P: Into<PathBuf>,
129 {
130 let service_name = service_name.into();
131 let directory = directory.into();
132
133 let mut storage_dir = directory.clone();
134 storage_dir.push(format!(
135 "ic-auth-client-{}",
136 sanitize_service_name(&service_name)
137 ));
138
139 let mut storage = PemStorage::new(storage_dir);
140
141 let key_exists = storage.get(KEY_STORAGE_KEY)?.is_some();
142
143 if !key_exists {
144 if let Some(pem_path) = find_pem_file_in_directory(&directory)? {
145 storage.import_private_key_from_pem_file(pem_path)?;
146 }
147 }
148
149 let options = NativeAuthClientCreateOptions::builder()
150 .storage(storage)
151 .build();
152 Self::new_with_options(options)
153 }
154
155 fn create_or_load_key(
157 identity: Option<ArcIdentity>,
158 storage: &mut dyn AuthClientStorage,
159 ) -> Result<Key, AuthClientError> {
160 match identity {
161 Some(identity) => Ok(Key::Identity(identity)),
162 None => match storage.get(KEY_STORAGE_KEY) {
163 Ok(Some(stored_key)) => {
164 let private_key = stored_key.decode().map_err(|e| {
165 DelegationError::IdentityError(format!(
166 "Failed to decode private key: {}",
167 e
168 ))
169 })?;
170 Ok(Key::WithRaw(KeyWithRaw::new(private_key)))
171 }
172 Ok(None) => {
173 let mut rng = rand::thread_rng();
174 let private_key = SigningKey::generate(&mut rng).to_bytes();
175 storage.set(KEY_STORAGE_KEY, StoredKey::Raw(private_key))?;
176 Ok(Key::WithRaw(KeyWithRaw::new(private_key)))
177 }
178 Err(e) => Err(e.into()),
179 },
180 }
181 }
182
183 fn get_delegation_data(
185 chain: &Option<DelegationChain>,
186 ) -> Option<(Vec<u8>, Vec<SignedDelegation>)> {
187 if let Some(chain_inner) = chain.as_ref() {
188 if chain_inner.is_delegation_valid(None) {
189 let public_key = chain_inner.public_key.clone();
190 let delegations = chain_inner.delegations.clone();
191 Some((public_key, delegations))
192 } else {
193 None
194 }
195 } else {
196 Some((Vec::new(), Vec::new()))
197 }
198 }
199
200 fn load_delegation_chain(
202 storage: &mut dyn AuthClientStorage,
203 key: &Key,
204 ) -> (Option<DelegationChain>, ArcIdentity) {
205 let mut identity = ArcIdentity::from(key.clone());
206 let mut chain: Option<DelegationChain> = None;
207
208 match storage.get(KEY_STORAGE_DELEGATION) {
209 Ok(Some(chain_stored)) => {
210 let chain_stored = chain_stored.encode();
211 let chain_result = DelegationChain::from_json(&chain_stored);
212 chain = Some(chain_result);
213
214 let delegation_data = Self::get_delegation_data(&chain);
215
216 match delegation_data {
217 Some((public_key, delegations)) => {
218 if !public_key.is_empty() {
219 identity =
220 ArcIdentity::Delegated(Arc::new(DelegatedIdentity::new_unchecked(
221 public_key,
222 Box::new(key.as_arc_identity()),
223 delegations,
224 )));
225 }
226 }
227 None => {
228 #[cfg(feature = "tracing")]
229 info!("Found invalid delegation chain in storage - clearing credentials");
230 let _ = Self::delete_storage_native(storage);
231 identity = ArcIdentity::Anonymous(Arc::new(AnonymousIdentity));
232 chain = None;
233 }
234 }
235 }
236 Ok(None) => (),
237 Err(_e) => {
238 #[cfg(feature = "tracing")]
239 error!("Failed to load delegation chain from storage: {}", _e);
240 }
241 }
242 (chain, identity)
243 }
244
245 fn create_idle_manager(
247 idle_options: &Option<IdleOptions>,
248 chain: &Option<DelegationChain>,
249 identity_is_some: bool,
250 ) -> Option<IdleManager> {
251 if !idle_options
252 .as_ref()
253 .and_then(|o| o.disable_idle)
254 .unwrap_or(false)
255 && (chain.is_some() || identity_is_some)
256 {
257 let idle_manager_options: Option<IdleManagerOptions> = idle_options
258 .as_ref()
259 .map(|o| o.idle_manager_options.clone());
260 Some(IdleManager::new(idle_manager_options))
261 } else {
262 None
263 }
264 }
265
266 pub fn new_with_options(
268 options: NativeAuthClientCreateOptions,
269 ) -> Result<Self, AuthClientError> {
270 let identity = options.identity.clone();
271 let options_identity_is_some = identity.is_some();
272 let mut storage = options.storage;
273
274 let key = Self::create_or_load_key(identity, storage.as_mut())?;
275
276 let (chain, identity) = Self::load_delegation_chain(storage.as_mut(), &key);
277
278 let idle_manager =
279 Self::create_idle_manager(&options.idle_options, &chain, options_identity_is_some);
280
281 Ok(Self(Arc::new(AuthClientInner {
282 identity: Arc::new(Mutex::new(identity)),
283 key,
284 storage: Mutex::new(storage),
285 chain: Arc::new(Mutex::new(chain)),
286 idle_manager: Mutex::new(idle_manager),
287 idle_options: options.idle_options,
288 })))
289 }
290
291 fn register_default_idle_callback_native(&self) {
293 if let Some(options) = self.0.idle_options.as_ref() {
294 if options.disable_default_idle_callback.unwrap_or_default() {
295 return;
296 }
297
298 if options.idle_manager_options.on_idle.lock().is_empty() {
299 if let Some(idle_manager) = self.0.idle_manager.lock().as_ref() {
300 let client = self.clone();
301 let callback = move || {
302 client.logout();
303 };
304 idle_manager.register_callback(callback);
305 }
306 }
307 }
308 }
309
310 pub fn identity(&self) -> Arc<dyn Identity> {
312 self.0.identity.lock().as_arc_identity()
313 }
314
315 pub fn principal(&self) -> Result<Principal, String> {
317 self.identity().sender()
318 }
319
320 pub fn is_authenticated(&self) -> bool {
322 let is_not_anonymous = self
323 .identity()
324 .sender()
325 .map(|s| s != Principal::anonymous())
326 .unwrap_or(false);
327
328 let is_valid_chain = self
329 .0
330 .chain
331 .lock()
332 .as_ref()
333 .is_some_and(|c| c.is_delegation_valid(None));
334
335 is_not_anonymous && is_valid_chain
336 }
337
338 pub fn idle_manager(&self) -> Option<IdleManager> {
340 self.0.idle_manager.lock().clone()
341 }
342
343 fn handle_success(
345 &self,
346 message: AuthResponseSuccess,
347 on_success: Option<OnSuccess>,
348 ) -> Result<(), DelegationError> {
349 let delegations = message.delegations.clone();
350 let user_public_key = message.user_public_key.clone();
351
352 let delegation_chain = DelegationChain {
353 delegations: delegations.clone(),
354 public_key: user_public_key.clone(),
355 };
356
357 self.update_storage_with_delegation(&delegation_chain);
358 self.update_identity_with_delegation(
359 &delegation_chain,
360 user_public_key.clone(),
361 delegations.clone(),
362 );
363
364 self.verify_and_fix_authentication(
365 &user_public_key,
366 &delegations,
367 &delegation_chain.to_json(),
368 );
369
370 self.maybe_create_idle_manager();
371
372 if let Some(on_success_cb) = on_success {
373 on_success_cb.0.lock()(message.clone());
374 }
375
376 Ok(())
377 }
378
379 fn update_storage_with_delegation(&self, delegation_chain: &DelegationChain) {
381 if let Key::WithRaw(key) = &self.0.key {
382 if let Err(_e) = self
383 .0
384 .storage
385 .lock()
386 .set(KEY_STORAGE_KEY, StoredKey::Raw(*key.raw_key()))
387 {
388 #[cfg(feature = "tracing")]
389 error!("Failed to store key: {}", _e);
390 }
391 }
392
393 let chain_json = delegation_chain.to_json();
394 if let Err(_e) = self.0.storage.lock().set(
395 KEY_STORAGE_DELEGATION,
396 StoredKey::String(chain_json.clone()),
397 ) {
398 #[cfg(feature = "tracing")]
399 error!("Failed to store delegation: {}", _e);
400 }
401 }
402
403 fn update_identity_with_delegation(
405 &self,
406 delegation_chain: &DelegationChain,
407 user_public_key: Vec<u8>,
408 delegations: Vec<SignedDelegation>,
409 ) {
410 *self.0.chain.lock() = Some(delegation_chain.clone());
411 *self.0.identity.lock() =
412 ArcIdentity::Delegated(Arc::new(DelegatedIdentity::new_unchecked(
413 user_public_key,
414 Box::new(self.0.key.as_arc_identity()),
415 delegations,
416 )));
417 }
418
419 fn verify_and_fix_authentication(
421 &self,
422 user_public_key: &[u8],
423 delegations: &[SignedDelegation],
424 chain_json: &str,
425 ) {
426 if self.is_authenticated() {
427 return;
428 }
429
430 #[cfg(feature = "tracing")]
431 warn!("CRITICAL: is_authenticated() returned false after successful login");
432
433 let _is_not_anonymous = self
434 .identity()
435 .sender()
436 .map(|s| s != Principal::anonymous())
437 .unwrap_or(false);
438 let _has_chain = self.0.chain.lock().is_some();
439 #[cfg(feature = "tracing")]
440 debug!(
441 "is_authenticated(): is_not_anonymous={}, has_chain={}",
442 _is_not_anonymous, _has_chain
443 );
444
445 *self.0.chain.lock() = Some(DelegationChain::from_json(chain_json));
446
447 let is_auth_retry = self.is_authenticated();
448 #[cfg(feature = "tracing")]
449 debug!("After fix attempt: is_authenticated() = {}", is_auth_retry);
450
451 if !is_auth_retry {
452 if let Ok(_principal) = self.identity().sender() {
453 #[cfg(feature = "tracing")]
454 debug!("Current principal: {}", _principal);
455 }
456
457 *self.0.identity.lock() =
458 ArcIdentity::Delegated(Arc::new(DelegatedIdentity::new_unchecked(
459 user_public_key.to_vec(),
460 Box::new(self.0.key.as_arc_identity()),
461 delegations.to_vec(),
462 )));
463
464 let _final_auth_check = self.is_authenticated();
465 #[cfg(feature = "tracing")]
466 debug!("Final check: is_authenticated() = {}", _final_auth_check);
467 }
468 }
469
470 fn maybe_create_idle_manager(&self) {
472 let disable_idle = self
473 .0
474 .idle_options
475 .as_ref()
476 .and_then(|o| o.disable_idle)
477 .unwrap_or(false);
478
479 if self.0.idle_manager.lock().is_none() && !disable_idle {
480 let idle_manager_options = self
481 .0
482 .idle_options
483 .as_ref()
484 .map(|o| o.idle_manager_options.clone());
485 let new_idle_manager = IdleManager::new(idle_manager_options);
486 *self.0.idle_manager.lock() = Some(new_idle_manager);
487
488 if self.0.idle_manager.lock().is_some() {
489 self.register_default_idle_callback_native();
490 }
491 }
492 }
493
494 fn handle_get_redirect(request: tiny_http::Request, tx: oneshot::Sender<CallbackResult>) {
496 let url = match Url::parse(&format!("http://localhost{}", request.url())) {
497 Ok(url) => url,
498 Err(e) => {
499 Self::respond_with_html_error(
500 request,
501 tx,
502 NativeLoginError::UrlParseError(e),
503 "Login window closed or redirect failed.",
504 );
505 return;
506 }
507 };
508
509 let payload = url
510 .query_pairs()
511 .find(|(key, _)| key == "payload")
512 .map(|(_, value)| value.into_owned());
513
514 let Some(payload_value) = payload else {
515 Self::respond_with_html_error(
516 request,
517 tx,
518 NativeLoginError::MissingDelegationOrError,
519 "Missing authentication payload.",
520 );
521 return;
522 };
523
524 let mut json = match Self::deserialize_payload(&payload_value) {
525 Ok(json) => json,
526 Err(err) => {
527 let message = match err {
528 NativeLoginError::JsonError(_) => "Failed to parse authentication payload.",
529 _ => "Invalid authentication payload.",
530 };
531 Self::respond_with_html_error(request, tx, err, message);
532 return;
533 }
534 };
535
536 if let Err(err) = Self::normalize_delegations(&mut json) {
537 Self::respond_with_html_error(request, tx, err, "Invalid authentication payload.");
538 return;
539 }
540
541 Self::respond_with_callback(request, tx, Self::process_auth_payload(json, true));
542 }
543
544 fn handle_post_callback(request: tiny_http::Request, tx: oneshot::Sender<CallbackResult>) {
546 let mut request = request;
547 let mut content = String::new();
548 if let Err(e) = request.as_reader().read_to_string(&mut content) {
549 let _ = tx.send(CallbackResult::Error(NativeLoginError::ServerError(
550 Box::new(e),
551 )));
552 let _ = request.respond(Self::cors_response("Error reading request body", 500));
553 return;
554 }
555
556 let mut json: serde_json::Value = match serde_json::from_str(&content) {
557 Ok(json) => json,
558 Err(e) => {
559 let _ = tx.send(CallbackResult::Error(NativeLoginError::JsonError(e)));
560 let _ = request.respond(Self::cors_response("Error parsing JSON body", 400));
561 return;
562 }
563 };
564
565 if let Err(err) = Self::normalize_delegations(&mut json) {
566 let _ = tx.send(CallbackResult::Error(err));
567 let _ = request.respond(Self::cors_response("Invalid authentication payload", 400));
568 return;
569 }
570
571 Self::respond_with_callback(request, tx, Self::process_auth_payload(json, false));
572 }
573
574 fn process_auth_payload(
575 json: serde_json::Value,
576 render_html: bool,
577 ) -> (Response<std::io::Cursor<Vec<u8>>>, CallbackResult) {
578 let response_type = json["type"].as_str();
579
580 match response_type {
581 Some("success") => {
582 match serde_json::from_value::<AuthResponseSuccess>(json["data"].clone()) {
583 Ok(success_data) => {
584 let response = if render_html {
585 Self::html_response(
586 "<h1>Login successful</h1><p>You can close this window.</p>",
587 200,
588 )
589 } else {
590 Self::cors_response("OK", 200)
591 };
592 (response, CallbackResult::Success(success_data))
593 }
594 Err(e) => {
595 let response = if render_html {
596 Self::html_response(
597 "<h1>Login failed</h1><p>Invalid success payload.</p>",
598 400,
599 )
600 } else {
601 Self::cors_response("Error parsing success data", 400)
602 };
603 (
604 response,
605 CallbackResult::Error(NativeLoginError::JsonError(e)),
606 )
607 }
608 }
609 }
610 Some("error") => {
611 let error_message = json["data"].as_str().unwrap_or("Unknown error").to_string();
612 let response = if render_html {
613 Self::html_response(
614 &format!("<h1>Login failed</h1><p>{}</p>", error_message),
615 400,
616 )
617 } else {
618 Self::cors_response("Error", 200)
619 };
620 (
621 response,
622 CallbackResult::Error(NativeLoginError::Custom(error_message)),
623 )
624 }
625 _ => {
626 let response = if render_html {
627 Self::html_response("<h1>Login failed</h1><p>Invalid response type.</p>", 400)
628 } else {
629 Self::cors_response("Invalid response type", 400)
630 };
631 (
632 response,
633 CallbackResult::Error(NativeLoginError::Custom(
634 "Invalid response type".to_string(),
635 )),
636 )
637 }
638 }
639 }
640
641 fn respond_with_html_error(
642 request: tiny_http::Request,
643 tx: oneshot::Sender<CallbackResult>,
644 error: NativeLoginError,
645 message: &str,
646 ) {
647 let _ = tx.send(CallbackResult::Error(error));
648 let body = format!("<h1>Login failed</h1><p>{}</p>", message);
649 let _ = request.respond(Self::html_response(&body, 400));
650 }
651
652 fn respond_with_callback(
653 request: tiny_http::Request,
654 tx: oneshot::Sender<CallbackResult>,
655 outcome: (Response<std::io::Cursor<Vec<u8>>>, CallbackResult),
656 ) {
657 let (response, callback_result) = outcome;
658 let _ = tx.send(callback_result);
659 let _ = request.respond(response);
660 }
661
662 fn html_response(body: &str, status_code: u16) -> Response<std::io::Cursor<Vec<u8>>> {
663 Response::from_string(body)
664 .with_status_code(status_code)
665 .with_header(
666 tiny_http::Header::from_bytes(b"Content-Type", b"text/html; charset=utf-8")
667 .unwrap(),
668 )
669 }
670
671 fn cors_response(body: &str, status_code: u16) -> Response<std::io::Cursor<Vec<u8>>> {
672 let mut response = Response::from_string(body).with_status_code(status_code);
673 response = response.with_header(
674 tiny_http::Header::from_bytes(b"Access-Control-Allow-Origin", b"*").unwrap(),
675 );
676 response = response.with_header(
677 tiny_http::Header::from_bytes(b"Access-Control-Allow-Headers", b"Content-Type")
678 .unwrap(),
679 );
680 response = response.with_header(
681 tiny_http::Header::from_bytes(b"Access-Control-Allow-Methods", b"POST, OPTIONS")
682 .unwrap(),
683 );
684 response.with_header(
685 tiny_http::Header::from_bytes(b"Access-Control-Allow-Private-Network", b"true")
686 .unwrap(),
687 )
688 }
689
690 fn deserialize_payload(payload: &str) -> Result<serde_json::Value, NativeLoginError> {
691 let decoded_payload = BASE64_STANDARD
692 .decode(payload.as_bytes())
693 .map_err(|e| NativeLoginError::Custom(format!("Invalid payload encoding: {}", e)))?;
694 let mut json = serde_json::from_slice(&decoded_payload)?;
695 Self::normalize_delegations(&mut json)?;
696 Ok(json)
697 }
698
699 fn normalize_delegations(json: &mut serde_json::Value) -> Result<(), NativeLoginError> {
700 let Some(data) = json.get_mut("data") else {
701 return Ok(());
702 };
703
704 let Some(delegations) = data.get_mut("delegations").and_then(|d| d.as_array_mut()) else {
705 return Ok(());
706 };
707
708 for delegation in delegations.iter_mut() {
709 let Some(expiration_value) = delegation
710 .get_mut("delegation")
711 .and_then(|d| d.get_mut("expiration"))
712 else {
713 continue;
714 };
715
716 if let Some(exp_str) = expiration_value.as_str() {
717 let parsed = exp_str.parse::<u64>().map_err(|e| {
718 NativeLoginError::Custom(format!("Invalid delegation expiration: {}", e))
719 })?;
720 *expiration_value = serde_json::Value::Number(Number::from(parsed));
721 }
722 }
723
724 Ok(())
725 }
726
727 async fn finish_login(
729 &self,
730 rx: oneshot::Receiver<CallbackResult>,
731 on_success: Option<OnSuccess>,
732 ) -> Result<(), NativeLoginError> {
733 let callback_result = rx.await.map_err(|_| NativeLoginError::OneshotRecvError)?;
734
735 match callback_result {
736 CallbackResult::Success(auth_success) => {
737 match self.handle_success(auth_success, on_success) {
738 Ok(_) => Ok(()),
739 Err(e) => Err(NativeLoginError::DelegationError(e)),
740 }
741 }
742 CallbackResult::Error(e) => Err(e),
743 }
744 }
745
746 pub fn login<T: AsRef<str> + Send + 'static>(
748 &self,
749 ii_url: T,
750 options: AuthClientLoginOptions,
751 ) {
752 let client = self.clone();
753 let ii_url = ii_url.as_ref().to_string();
754
755 thread::spawn(move || {
756 let on_error = options.on_error.clone();
757 if let Err(e) = block_on(client.login_task(ii_url, options)) {
758 if let Some(on_error) = on_error {
759 on_error.0.lock()(Some(e.to_string()));
760 }
761 }
762 });
763 }
764
765 fn start_http_server(server: Server, tx: oneshot::Sender<CallbackResult>, timeout: Duration) {
766 thread::spawn(move || {
767 let start_time = std::time::Instant::now();
768
769 while start_time.elapsed() < timeout {
770 let request = match server.recv_timeout(Duration::from_millis(500)) {
771 Ok(Some(request)) => request,
772 Ok(None) => continue,
773 Err(e) => {
774 #[cfg(feature = "tracing")]
775 error!("Server error while receiving request: {}", e);
776 let _ = tx.send(CallbackResult::Error(NativeLoginError::ServerError(
777 Box::new(e),
778 )));
779 return;
780 }
781 };
782
783 if request.method() == &tiny_http::Method::Options
784 && request.url().starts_with("/auth-callback")
785 {
786 let response = Self::cors_response("", 204);
787 if let Err(_e) = request.respond(response) {
788 #[cfg(feature = "tracing")]
789 error!("Failed to respond to OPTIONS request: {}", _e);
790 }
791 continue;
792 }
793
794 let handler: Option<fn(tiny_http::Request, oneshot::Sender<CallbackResult>)> =
795 if request.method() == &tiny_http::Method::Post
796 && request.url().starts_with("/auth-callback")
797 {
798 Some(Self::handle_post_callback)
799 } else if request.method() == &tiny_http::Method::Get
800 && request.url().starts_with("/auth-callback")
801 {
802 Some(Self::handle_get_redirect)
803 } else {
804 None
805 };
806
807 if let Some(handler_fn) = handler {
808 handler_fn(request, tx);
809 return;
810 }
811
812 let response = Response::from_string("").with_status_code(204);
814 if let Err(_e) = request.respond(response) {
815 #[cfg(feature = "tracing")]
816 error!("Failed to respond to unexpected request: {}", _e);
817 }
818 }
819
820 let _ = tx.send(CallbackResult::Error(NativeLoginError::ServerTimeout));
821 });
822 }
823
824 async fn login_task<T: AsRef<str>>(
825 &self,
826 ii_url: T,
827 options: AuthClientLoginOptions,
828 ) -> Result<(), NativeLoginError> {
829 let port = portpicker::pick_unused_port().ok_or(NativeLoginError::NoFreePort)?;
830 let redirect_uri = format!("http://127.0.0.1:{}/auth-callback", port);
831
832 let server = Server::http(format!("127.0.0.1:{}", port))?;
833 let (tx, rx) = oneshot::channel::<CallbackResult>();
834
835 let public_key_hex = hex::encode(self.0.key.public_key().unwrap());
836
837 let mut url = Url::parse(ii_url.as_ref()).map_err(NativeLoginError::UrlParseError)?;
838 Self::set_query_params(&mut url, &options, &redirect_uri, &public_key_hex);
839
840 webbrowser::open(url.as_str())
841 .map_err(|e| NativeLoginError::BrowserOpenError(e.to_string()))?;
842
843 let timeout = options.timeout.unwrap_or(Duration::from_secs(300));
844 Self::start_http_server(server, tx, timeout);
845
846 self.finish_login(rx, options.on_success).await
847 }
848
849 fn set_query_params(
851 url: &mut Url,
852 options: &AuthClientLoginOptions,
853 redirect_uri: &str,
854 public_key_hex: &str,
855 ) {
856 let mut query_pairs = url.query_pairs_mut();
857 query_pairs
858 .append_pair("redirectUri", redirect_uri)
859 .append_pair("pubkey", public_key_hex);
860
861 if let Some(ref identity_provider) = options.identity_provider {
862 query_pairs.append_pair("identityProvider", identity_provider);
863 }
864
865 if let Some(ref max_time_to_live) = options.max_time_to_live {
866 query_pairs.append_pair("maxTimeToLive", &max_time_to_live.to_string());
867 }
868
869 if let Some(ref allow_pin_authentication) = options.allow_pin_authentication {
870 query_pairs.append_pair(
871 "allowPinAuthentication",
872 &allow_pin_authentication.to_string(),
873 );
874 }
875
876 if let Some(ref derivation_origin) = options.derivation_origin {
877 query_pairs.append_pair("derivationOrigin", derivation_origin);
878 }
879
880 if let Some(ref window_opener_features) = options.window_opener_features {
881 query_pairs.append_pair("windowOpenerFeatures", window_opener_features);
882 }
883
884 if let Some(ref custom_values) = options.custom_values {
885 if let Ok(json) = serde_json::to_string(custom_values) {
886 query_pairs.append_pair("customValues", &json);
887 }
888 }
889 }
890
891 fn logout_core(
893 identity: Arc<Mutex<ArcIdentity>>,
894 storage: &mut dyn AuthClientStorage,
895 chain: Arc<Mutex<Option<DelegationChain>>>,
896 ) {
897 if let Err(_e) = Self::delete_storage_native(storage) {
898 #[cfg(feature = "tracing")]
899 error!("Failed to delete storage: {}", _e);
900 }
901
902 *identity.lock() = ArcIdentity::Anonymous(Arc::new(AnonymousIdentity));
904 chain.lock().take();
905 }
906
907 pub fn logout(&self) {
909 if let Some(idle_manager) = self.0.idle_manager.lock().take() {
910 drop(idle_manager);
911 }
912
913 let mut storage_lock = self.0.storage.lock();
914 let storage_ref: &mut dyn AuthClientStorage = &mut **storage_lock;
915 Self::logout_core(self.0.identity.clone(), storage_ref, self.0.chain.clone());
916 }
917
918 fn delete_storage_native(
920 storage: &mut dyn AuthClientStorage,
921 ) -> Result<(), crate::storage::StorageError> {
922 storage.remove(KEY_STORAGE_KEY)?;
923 storage.remove(KEY_STORAGE_DELEGATION)?;
924 Ok(())
925 }
926}
927
928#[cfg(feature = "pem")]
929fn sanitize_service_name(name: &str) -> String {
930 name.chars()
931 .map(|c| {
932 if matches!(c, '/' | '\\' | ':' | '*') {
933 '_'
934 } else {
935 c
936 }
937 })
938 .collect()
939}
940
941#[cfg(feature = "pem")]
942fn find_pem_file_in_directory(directory: &Path) -> Result<Option<PathBuf>, AuthClientError> {
943 let entries = match fs::read_dir(directory) {
944 Ok(entries) => entries,
945 Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
946 Err(e) => return Err(AuthClientError::Storage(StorageError::File(e.to_string()))),
947 };
948
949 for entry in entries {
950 let entry =
951 entry.map_err(|e| AuthClientError::Storage(StorageError::File(e.to_string())))?;
952 let path = entry.path();
953 if path.is_file() {
954 if let Some(ext) = path.extension() {
955 if ext.eq_ignore_ascii_case("pem") {
956 return Ok(Some(path));
957 }
958 }
959 }
960 }
961
962 Ok(None)
963}
964
965#[cfg(test)]
966mod tests {
967 use super::*;
968 use serde_json::json;
969
970 fn has_header(response: &Response<std::io::Cursor<Vec<u8>>>, key: &str, value: &str) -> bool {
971 response.headers().iter().any(|header| {
972 let header_name: &str = header.field.as_str().as_ref();
973 let header_value: &str = header.value.as_str();
974 header_name.eq_ignore_ascii_case(key) && header_value.eq_ignore_ascii_case(value)
975 })
976 }
977
978 #[test]
979 fn cors_response_exposes_private_network_headers() {
980 let response = NativeAuthClient::cors_response("", 204);
981 assert_eq!(response.status_code().0, 204);
982 assert!(has_header(
983 &response,
984 "Access-Control-Allow-Private-Network",
985 "true"
986 ));
987 assert!(has_header(&response, "Access-Control-Allow-Origin", "*"));
988 }
989
990 #[test]
991 fn process_auth_payload_returns_success_and_cors_headers() {
992 let payload = json!({
993 "type": "success",
994 "data": {
995 "delegations": [],
996 "userPublicKey": [],
997 "authnMethod": "native"
998 }
999 });
1000 let (response, callback) = NativeAuthClient::process_auth_payload(payload, false);
1001 assert_eq!(response.status_code().0, 200);
1002 assert!(has_header(&response, "Access-Control-Allow-Origin", "*"));
1003 assert!(has_header(
1004 &response,
1005 "Access-Control-Allow-Private-Network",
1006 "true"
1007 ));
1008
1009 match callback {
1010 CallbackResult::Success(data) => assert_eq!(data.authn_method, "native"),
1011 CallbackResult::Error(err) => panic!("unexpected error: {:?}", err),
1012 }
1013 }
1014
1015 #[test]
1016 fn process_auth_payload_renders_html_when_requested() {
1017 let payload = json!({
1018 "type": "success",
1019 "data": {
1020 "delegations": [],
1021 "userPublicKey": [],
1022 "authnMethod": "redirect"
1023 }
1024 });
1025 let (response, callback) = NativeAuthClient::process_auth_payload(payload, true);
1026 assert_eq!(response.status_code().0, 200);
1027 assert!(has_header(
1028 &response,
1029 "Content-Type",
1030 "text/html; charset=utf-8"
1031 ));
1032 assert!(matches!(callback, CallbackResult::Success(_)));
1033 }
1034
1035 #[test]
1036 fn process_auth_payload_handles_remote_errors() {
1037 let payload = json!({
1038 "type": "error",
1039 "data": "Browser closed"
1040 });
1041 let (response, callback) = NativeAuthClient::process_auth_payload(payload, false);
1042 assert_eq!(response.status_code().0, 200);
1043 match callback {
1044 CallbackResult::Error(NativeLoginError::Custom(message)) => {
1045 assert_eq!(message, "Browser closed");
1046 }
1047 _ => panic!("expected custom error"),
1048 }
1049 }
1050
1051 #[test]
1052 fn deserialize_payload_decodes_base64_json() {
1053 let payload = json!({
1054 "type": "success",
1055 "data": {
1056 "delegations": [],
1057 "userPublicKey": [],
1058 "authnMethod": "redirect"
1059 }
1060 })
1061 .to_string();
1062 let encoded = BASE64_STANDARD.encode(payload.as_bytes());
1063 let json = NativeAuthClient::deserialize_payload(&encoded).expect("decode payload");
1064 assert_eq!(json["type"], "success");
1065 }
1066
1067 #[test]
1068 fn normalize_delegations_handles_string_expiration() {
1069 let mut json = json!({
1070 "type": "success",
1071 "data": {
1072 "delegations": [{
1073 "delegation": {
1074 "expiration": "1763421459179717000",
1075 "pubkey": [],
1076 "targets": []
1077 },
1078 "signature": []
1079 }],
1080 "userPublicKey": [],
1081 "authnMethod": "native"
1082 }
1083 });
1084
1085 NativeAuthClient::normalize_delegations(&mut json).expect("normalize");
1086
1087 assert!(json["data"]["delegations"][0]["delegation"]["expiration"].is_number());
1088 }
1089}