1use super::{DEFAULT_TOKEN_TYPE, RobinhoodClient};
7use crate::api::paths;
8use crate::auth::{AuthState, CachedToken};
9use crate::models::auth::{
10 ChallengeResponsePayload, ChallengeResponseResult, LoginPayload, OAuthResponse,
11 RefreshTokenPayload,
12};
13use crate::{ChallengeType, Result, RhoodError};
14use chrono::Utc;
15use secrecy::{ExposeSecret, SecretString};
16use std::time::{SystemTime, SystemTimeError, UNIX_EPOCH};
17
18impl RobinhoodClient {
19 pub async fn login(
48 &self,
49 username: &str,
50 password: &str,
51 mfa_secret: Option<&str>,
52 ) -> Result<()> {
53 if let Some(cached) = self.token_cache.load()? {
55 tracing::debug!("Found cached token, restoring auth state");
56 self.device_token
57 .write()
58 .await
59 .clone_from(&cached.device_token);
60 *self.auth_state.write().await = AuthState::Authenticated {
61 access_token: cached.access_token.clone(),
62 token_type: cached.token_type.clone(),
63 refresh_token: cached.refresh_token.clone(),
64 };
65
66 match self.validate_token().await {
68 Ok(true) => {
69 tracing::debug!("Cached token validated successfully");
70 return Ok(());
71 }
72 Ok(false) => {
73 tracing::debug!("Cached token rejected by server, trying refresh");
74 }
75 Err(err) => {
76 tracing::warn!(%err, "Token validation failed with error, trying refresh");
77 }
78 }
79
80 match self.try_refresh_token().await {
82 Ok(true) => {
83 tracing::debug!("Token refresh succeeded");
84 return Ok(());
85 }
86 Ok(false) => {
87 tracing::debug!("Token refresh failed, falling through to headless login");
88 }
89 Err(err) => {
90 tracing::warn!(%err, "Token refresh error, falling through to headless login");
91 }
92 }
93 }
94
95 tracing::debug!("Attempting headless login");
97 *self.auth_state.write().await = AuthState::Unauthenticated;
98 self.login_headless(username, password, mfa_secret).await
99 }
100
101 pub async fn login_from_cache(&self) -> Result<bool> {
112 let Some(cached) = self.token_cache.load()? else {
113 tracing::debug!("No cached token found");
114 return Ok(false);
115 };
116 tracing::debug!("Found cached token, validating");
117 self.device_token
118 .write()
119 .await
120 .clone_from(&cached.device_token);
121 *self.auth_state.write().await = AuthState::Authenticated {
122 access_token: cached.access_token.clone(),
123 token_type: cached.token_type.clone(),
124 refresh_token: cached.refresh_token.clone(),
125 };
126
127 match self.validate_token().await {
129 Ok(true) => {
130 tracing::debug!("Cached token is valid");
131 return Ok(true);
132 }
133 Ok(false) => {
134 tracing::debug!("Cached token validation failed, attempting refresh");
135 }
136 Err(err) => {
137 tracing::debug!(%err, "Token validation error, attempting refresh");
138 }
139 }
140
141 if self.try_refresh_token().await? {
143 tracing::debug!("Token refresh succeeded");
144 return Ok(true);
145 }
146
147 tracing::debug!("Token refresh failed, clearing auth state");
148 *self.auth_state.write().await = AuthState::Unauthenticated;
149 Ok(false)
150 }
151
152 pub async fn validate_token(&self) -> Result<bool> {
161 let auth = match self.auth_state.read().await.authorization_header() {
162 Some(header) => header,
163 None => return Ok(false),
164 };
165 let url = self.api_url(paths::POSITIONS);
166 let res = self
167 .http
168 .get(&url)
169 .header("Authorization", &auth)
170 .query(&[("nonzero", "true")])
171 .send()
172 .await?;
173 let status = res.status().as_u16();
174 Ok(status != 401 && status != 403 && res.status().is_success())
175 }
176
177 async fn try_refresh_token(&self) -> Result<bool> {
184 let refresh_token = match self.auth_state.read().await.refresh_token() {
185 Some(rt) if !rt.expose_secret().is_empty() => rt.clone(),
186 _ => return Ok(false),
187 };
188
189 let payload = RefreshTokenPayload {
190 client_id: self.config.auth.client_id.clone(),
191 grant_type: "refresh_token",
192 refresh_token: refresh_token.expose_secret().to_string(),
193 scope: "internal",
194 device_token: self.device_token.read().await.clone(),
195 };
196
197 let token_url = self.api_url(paths::TOKEN);
198 tracing::debug!("Attempting token refresh");
199 let res = self.http.post(&token_url).form(&payload).send().await?;
200 let status = res.status();
201 let body = res.text().await.unwrap_or_default();
202 tracing::debug!(
203 status = status.as_u16(),
204 body_len = body.len(),
205 "Token refresh response"
206 );
207
208 if !status.is_success() {
209 return Ok(false);
210 }
211
212 let data: OAuthResponse = serde_json::from_str(&body).map_err(|err| RhoodError::Api {
213 status: status.as_u16(),
214 message: format!("Failed to parse token refresh response: {err}"),
215 })?;
216
217 if data.verification_workflow.is_some() {
221 tracing::debug!("Refresh token expired (verification_workflow in response)");
222 return Ok(false);
223 }
224
225 match self.extract_tokens(&data).await {
226 Ok(()) => Ok(true),
227 Err(_) => Ok(false),
228 }
229 }
230
231 pub async fn login_headless(
234 &self,
235 username: &str,
236 password: &str,
237 mfa_secret: Option<&str>,
238 ) -> Result<()> {
239 if self.login_from_cache().await? {
240 return Ok(());
241 }
242
243 let mfa_code = if let Some(secret) = mfa_secret {
244 let totp = totp_rs::Builder::new()
245 .with_algorithm(totp_rs::Algorithm::SHA1)
246 .with_digits(6)
247 .with_skew(1)
248 .with_step_duration(30)
249 .with_secret(totp_rs::Secret::try_from_base32(secret).map_err(|err| {
250 RhoodError::InvalidParameter(format!("Invalid MFA secret: {err}"))
251 })?)
252 .build()
253 .map_err(|err| RhoodError::InvalidParameter(format!("TOTP error: {err}")))?;
254 let now = SystemTime::now()
258 .duration_since(UNIX_EPOCH)
259 .map_err(|err: SystemTimeError| {
260 RhoodError::InvalidParameter(format!("TOTP generation failed: {err}"))
261 })?
262 .as_secs();
263 Some(totp.generate(now).to_string())
264 } else {
265 None
266 };
267
268 let payload = LoginPayload {
269 client_id: self.config.auth.client_id.clone(),
270 expires_in: self.config.auth.token_expiry_secs.to_string(),
271 grant_type: "password",
272 username: username.to_string(),
273 password: password.to_string(),
274 scope: "internal",
275 device_token: self.device_token.read().await.clone(),
276 try_passkeys: "false",
277 token_request_path: "/login",
278 create_read_only_secondary_token: "true",
279 mfa_code,
280 };
281
282 let token_url = self.api_url(paths::TOKEN);
283 tracing::debug!(url = %token_url, "Sending login request");
284 let res = self.http.post(&token_url).form(&payload).send().await?;
285 let status = res.status();
286 let body = res.text().await.unwrap_or_default();
287 tracing::debug!(
288 status = status.as_u16(),
289 body_len = body.len(),
290 "Login response"
291 );
292
293 let data: OAuthResponse = serde_json::from_str(&body).map_err(|err| {
294 tracing::error!(status = status.as_u16(), "Failed to parse login response");
295 RhoodError::Api {
296 status: status.as_u16(),
297 message: format!("Failed to parse login response: {err}"),
298 }
299 })?;
300
301 if !status.is_success() {
304 if let Some(detail) = &data.detail {
305 return Err(RhoodError::Api {
306 status: status.as_u16(),
307 message: detail.clone(),
308 });
309 }
310 return Err(RhoodError::Api {
311 status: status.as_u16(),
312 message: format!(
313 "Login failed with no actionable response: {}",
314 super::transport::redacted_response_body_message(&body)
315 ),
316 });
317 }
318
319 if data.access_token.is_none()
321 && data.mfa_required.is_none()
322 && data.verification_workflow.is_none()
323 && data.challenge.is_none()
324 && let Some(detail) = &data.detail
325 {
326 return Err(RhoodError::Api {
327 status: status.as_u16(),
328 message: detail.clone(),
329 });
330 }
331
332 if let Some(workflow) = &data.verification_workflow {
334 let workflow_id = workflow.id.clone();
335 tracing::info!("Device verification required, approve on your Robinhood app");
336 self.handle_device_verification(&workflow_id).await?;
337
338 tracing::info!("Device verified, now completing login");
340 let res = self.http.post(&token_url).form(&payload).send().await?;
341 let retry_status = res.status();
342 let retry_body = res.text().await.unwrap_or_default();
343 tracing::debug!(
344 status = retry_status.as_u16(),
345 body_len = retry_body.len(),
346 "Login retry response"
347 );
348 let data: OAuthResponse =
349 serde_json::from_str(&retry_body).map_err(|err| RhoodError::Api {
350 status: retry_status.as_u16(),
351 message: format!("Failed to parse login retry response: {err}"),
352 })?;
353 if !retry_status.is_success() {
354 if let Some(detail) = &data.detail {
355 return Err(RhoodError::Api {
356 status: retry_status.as_u16(),
357 message: detail.clone(),
358 });
359 }
360 return Err(RhoodError::Api {
361 status: retry_status.as_u16(),
362 message: format!(
363 "Login failed with no actionable response: {}",
364 super::transport::redacted_response_body_message(&retry_body)
365 ),
366 });
367 }
368 return self
369 .handle_login_response(&data, mfa_secret.is_none())
370 .await;
371 }
372
373 self.handle_login_response(&data, mfa_secret.is_none())
374 .await
375 }
376
377 async fn handle_login_response(
379 &self,
380 data: &OAuthResponse,
381 mfa_secret_absent: bool,
382 ) -> Result<()> {
383 if let Some(workflow) = &data.verification_workflow {
386 tracing::debug!(workflow_id = %workflow.id, "Device verification required");
387 *self.auth_state.write().await = AuthState::DeviceVerification {
388 workflow_id: workflow.id.clone(),
389 };
390 return Err(RhoodError::DeviceVerificationRequired);
391 }
392
393 if data.mfa_required == Some(true) {
395 tracing::debug!("MFA required");
396 *self.auth_state.write().await = AuthState::MfaRequired;
397 if mfa_secret_absent {
398 return Err(RhoodError::InvalidParameter(
399 "MFA required but no mfa_secret provided".into(),
400 ));
401 }
402 }
403
404 if let Some(challenge) = &data.challenge {
406 tracing::debug!(
407 challenge_type = %challenge.challenge_type,
408 challenge_id = %challenge.id,
409 "Challenge required"
410 );
411 let challenge_type = match challenge.challenge_type.as_str() {
412 "sms" => ChallengeType::Sms,
413 "email" => ChallengeType::Email,
414 _ => ChallengeType::Prompt,
415 };
416 *self.auth_state.write().await = AuthState::Challenged {
417 challenge_type: challenge_type.clone(),
418 challenge_id: challenge.id.clone(),
419 };
420 return Err(RhoodError::ChallengeRequired(challenge_type));
421 }
422
423 tracing::debug!("Extracting tokens from login response");
424 self.extract_tokens(data).await
425 }
426
427 async fn extract_tokens(&self, data: &OAuthResponse) -> Result<()> {
430 let access_token =
431 SecretString::from(
432 data.access_token
433 .as_deref()
434 .ok_or_else(|| RhoodError::Api {
435 status: 401,
436 message: "No access_token in response".into(),
437 })?,
438 );
439 let token_type = data
440 .token_type
441 .as_deref()
442 .unwrap_or(DEFAULT_TOKEN_TYPE)
443 .to_string();
444 let refresh_token = SecretString::from(data.refresh_token.as_deref().unwrap_or(""));
445
446 *self.auth_state.write().await = AuthState::Authenticated {
447 access_token: access_token.clone(),
448 token_type: token_type.clone(),
449 refresh_token: refresh_token.clone(),
450 };
451
452 #[expect(
453 clippy::arithmetic_side_effects,
454 reason = "the externally configured AuthConfig::token_expiry_secs value is assumed to fit a signed Unix timestamp; no in-code bound enforces this"
455 )]
456 let cached = CachedToken {
457 access_token,
458 refresh_token,
459 token_type,
460 device_token: self.device_token.read().await.clone(),
461 expires_at: Some({
462 Utc::now().timestamp() + self.config.auth.token_expiry_secs.cast_signed()
463 }),
464 };
465 self.token_cache.save(&cached)?;
466 Ok(())
467 }
468
469 pub async fn respond_to_challenge(&self, code: &str) -> Result<()> {
472 let challenge_id = match &*self.auth_state.read().await {
473 AuthState::Challenged { challenge_id, .. } => challenge_id.clone(),
474 _ => {
475 return Err(RhoodError::InvalidParameter(
476 "No pending challenge to respond to".into(),
477 ));
478 }
479 };
480
481 let url = format!("{}{challenge_id}/respond/", self.api_url(paths::CHALLENGE));
482 let payload = ChallengeResponsePayload {
483 response: code.to_string(),
484 };
485
486 let res = self.http.post(&url).form(&payload).send().await?;
487 let data: ChallengeResponseResult = res.json().await?;
488 tracing::debug!(body = ?data, "Challenge response");
489
490 if data.status.as_deref() == Some("validated") {
491 *self.auth_state.write().await = AuthState::Unauthenticated;
493 Ok(())
494 } else {
495 Err(RhoodError::Api {
496 status: 400,
497 message: "Challenge response not validated".into(),
498 })
499 }
500 }
501
502 pub async fn submit_challenge_response(
519 &self,
520 challenge_id: &str,
521 code: &str,
522 username: &str,
523 password: &str,
524 mfa_secret: Option<&str>,
525 ) -> Result<()> {
526 let url = format!("{}{challenge_id}/respond/", self.api_url(paths::CHALLENGE));
528 let payload = ChallengeResponsePayload {
529 response: code.to_string(),
530 };
531
532 let res = self.http.post(&url).form(&payload).send().await?;
533 let data: ChallengeResponseResult = res.json().await?;
534 tracing::debug!(body = ?data, "Challenge response");
535
536 if data.status.as_deref() != Some("validated") {
537 return Err(RhoodError::Api {
538 status: 400,
539 message: "Challenge response not validated".into(),
540 });
541 }
542
543 tracing::debug!("Challenge validated, re-attempting login");
545 *self.auth_state.write().await = AuthState::Unauthenticated;
546 self.login_headless(username, password, mfa_secret).await
547 }
548}
549
550#[cfg(test)]
551#[expect(
552 clippy::let_underscore_must_use,
553 reason = "compile-only async helpers prove public method signatures without executing requests"
554)]
555mod tests {
556 use super::super::{default_oauth_response, test_config, test_config_with_tempdir};
557 use super::*;
558 use crate::models::auth::{ChallengeDetail, VerificationWorkflow};
559 use secrecy::ExposeSecret;
560 use wiremock::matchers::{body_string_contains, header, method, path, query_param};
561 use wiremock::{Mock, MockServer, ResponseTemplate};
562
563 #[cfg(unix)]
564 fn set_mode(path: &std::path::Path, mode: u32) {
565 use std::os::unix::fs::PermissionsExt;
566
567 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
568 }
569
570 async fn client_for_server(base_url: &str) -> (tempfile::TempDir, RobinhoodClient) {
571 let dir = tempfile::tempdir().unwrap();
572 let mut config = test_config_with_tempdir(&dir);
573 config.api.base_url = base_url.to_string();
574 let client = RobinhoodClient::with_config(config).unwrap();
575 (dir, client)
576 }
577
578 async fn authenticated_client_for_server(
579 base_url: &str,
580 refresh_token: &str,
581 ) -> (tempfile::TempDir, RobinhoodClient) {
582 let (dir, client) = client_for_server(base_url).await;
583 *client.auth_state.write().await = AuthState::Authenticated {
584 access_token: SecretString::from("old-access"),
585 token_type: "Bearer".to_string(),
586 refresh_token: SecretString::from(refresh_token),
587 };
588 (dir, client)
589 }
590
591 #[tokio::test]
592 async fn handle_login_response_device_verification() {
593 let dir = tempfile::tempdir().unwrap();
594 let client = RobinhoodClient::with_config(test_config_with_tempdir(&dir)).unwrap();
595 let data = OAuthResponse {
596 verification_workflow: Some(VerificationWorkflow {
597 id: "wf-abc".into(),
598 _workflow_status: None,
599 }),
600 ..default_oauth_response()
601 };
602 let err = client.handle_login_response(&data, true).await.unwrap_err();
603 assert!(matches!(err, RhoodError::DeviceVerificationRequired));
604 assert!(matches!(
605 client.auth_state().await,
606 AuthState::DeviceVerification { workflow_id } if workflow_id == "wf-abc"
607 ));
608 }
609
610 #[tokio::test]
611 async fn handle_login_response_mfa_required() {
612 let dir = tempfile::tempdir().unwrap();
613 let client = RobinhoodClient::with_config(test_config_with_tempdir(&dir)).unwrap();
614 let data = OAuthResponse {
615 mfa_required: Some(true),
616 ..default_oauth_response()
617 };
618 let err = client.handle_login_response(&data, true).await.unwrap_err();
619 assert!(matches!(err, RhoodError::InvalidParameter(_)));
620 assert!(matches!(client.auth_state().await, AuthState::MfaRequired));
621 }
622
623 #[tokio::test]
624 async fn handle_login_response_challenge() {
625 let dir = tempfile::tempdir().unwrap();
626 let client = RobinhoodClient::with_config(test_config_with_tempdir(&dir)).unwrap();
627 let data = OAuthResponse {
628 challenge: Some(ChallengeDetail {
629 id: "ch-123".into(),
630 challenge_type: "sms".into(),
631 _status: Some("issued".into()),
632 }),
633 ..default_oauth_response()
634 };
635 let err = client.handle_login_response(&data, true).await.unwrap_err();
636 assert!(matches!(
637 err,
638 RhoodError::ChallengeRequired(ChallengeType::Sms)
639 ));
640 assert!(matches!(
641 client.auth_state().await,
642 AuthState::Challenged {
643 challenge_type: ChallengeType::Sms,
644 ..
645 }
646 ));
647 }
648
649 #[test]
650 fn login_method_signature_exists() {
651 async fn _assert_login(client: &RobinhoodClient) {
652 let _ = client.login("user", "pass", None).await;
653 }
654 }
655
656 #[test]
657 fn submit_challenge_response_signature_exists() {
658 async fn _assert_method_exists(client: &RobinhoodClient) {
659 let _ = client
660 .submit_challenge_response("test-id", "123456", "user", "pass", None)
661 .await;
662 }
663 }
664
665 #[test]
666 fn refresh_response_with_verification_workflow_is_detected() {
667 let data = OAuthResponse {
668 verification_workflow: Some(VerificationWorkflow {
669 id: "wf-expired".into(),
670 _workflow_status: None,
671 }),
672 ..default_oauth_response()
673 };
674 assert!(data.verification_workflow.is_some());
675 assert!(data.access_token.is_none());
676 }
677
678 #[tokio::test]
679 async fn respond_to_challenge_requires_challenged_state() {
680 let dir = tempfile::tempdir().unwrap();
681 let client = RobinhoodClient::with_config(test_config_with_tempdir(&dir)).unwrap();
682 let err = client.respond_to_challenge("123456").await.unwrap_err();
683 assert!(matches!(err, RhoodError::InvalidParameter(_)));
684 }
685
686 #[test]
687 fn submit_challenge_response_requires_credentials() {
688 async fn _check(client: &RobinhoodClient) {
689 let _ = client
691 .submit_challenge_response("id", "code", "user", "pass", None)
692 .await;
693 }
694 }
695
696 #[test]
697 fn login_cascade_method_exists() {
698 async fn _check(client: &RobinhoodClient) {
699 let _ = client.login("user", "pass", Some("secret")).await;
700 let _ = client.login("user", "pass", None).await;
701 }
702 }
703
704 #[tokio::test]
705 async fn validate_token_returns_false_when_unauthenticated() {
706 let dir = tempfile::tempdir().unwrap();
707 let client = RobinhoodClient::with_config(test_config_with_tempdir(&dir)).unwrap();
708 let result = client.validate_token().await.unwrap();
709 assert!(
710 !result,
711 "validate_token should return false when unauthenticated"
712 );
713 }
714
715 #[tokio::test]
716 async fn validate_token_returns_true_for_successful_positions_probe() {
717 let server = MockServer::start().await;
718 Mock::given(method("GET"))
719 .and(path("/positions/"))
720 .and(query_param("nonzero", "true"))
721 .and(header("Authorization", "Bearer old-access"))
722 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
723 "results": []
724 })))
725 .mount(&server)
726 .await;
727 let (_dir, client) = authenticated_client_for_server(&server.uri(), "refresh").await;
728
729 assert!(client.validate_token().await.unwrap());
730 }
731
732 #[tokio::test]
733 async fn validate_token_returns_false_for_unauthorized_probe() {
734 let server = MockServer::start().await;
735 Mock::given(method("GET"))
736 .and(path("/positions/"))
737 .and(query_param("nonzero", "true"))
738 .respond_with(ResponseTemplate::new(401).set_body_string("unauthorized"))
739 .mount(&server)
740 .await;
741 let (_dir, client) = authenticated_client_for_server(&server.uri(), "refresh").await;
742
743 assert!(!client.validate_token().await.unwrap());
744 }
745
746 #[tokio::test]
747 async fn try_refresh_token_returns_false_without_refresh_token() {
748 let server = MockServer::start().await;
749 let (_dir, client) = authenticated_client_for_server(&server.uri(), "").await;
750
751 assert!(!client.try_refresh_token().await.unwrap());
752 }
753
754 #[tokio::test]
755 async fn try_refresh_token_updates_auth_state_and_cache_on_success() {
756 let server = MockServer::start().await;
757 Mock::given(method("POST"))
758 .and(path("/oauth2/token/"))
759 .and(body_string_contains("grant_type=refresh_token"))
760 .and(body_string_contains("refresh_token=old-refresh"))
761 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
762 "access_token": "new-access",
763 "token_type": "Token",
764 "refresh_token": "new-refresh"
765 })))
766 .mount(&server)
767 .await;
768 let (dir, client) = authenticated_client_for_server(&server.uri(), "old-refresh").await;
769
770 assert!(client.try_refresh_token().await.unwrap());
771 let state = client.auth_state().await;
772 assert_eq!(
773 state.authorization_header().as_deref(),
774 Some("Token new-access")
775 );
776
777 let cache = client.token_cache.load().unwrap().unwrap();
778 assert_eq!(cache.access_token.expose_secret(), "new-access");
779 drop(dir);
780 }
781
782 #[tokio::test]
783 async fn try_refresh_token_returns_false_on_server_rejection() {
784 let server = MockServer::start().await;
785 Mock::given(method("POST"))
786 .and(path("/oauth2/token/"))
787 .respond_with(ResponseTemplate::new(400).set_body_string("bad refresh"))
788 .mount(&server)
789 .await;
790 let (_dir, client) = authenticated_client_for_server(&server.uri(), "old-refresh").await;
791
792 assert!(!client.try_refresh_token().await.unwrap());
793 assert_eq!(
794 client.auth_state().await.authorization_header().as_deref(),
795 Some("Bearer old-access")
796 );
797 }
798
799 #[tokio::test]
800 async fn try_refresh_token_returns_false_when_refresh_requires_verification() {
801 let server = MockServer::start().await;
802 Mock::given(method("POST"))
803 .and(path("/oauth2/token/"))
804 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
805 "verification_workflow": {
806 "id": "wf-expired",
807 "workflow_status": "issued"
808 }
809 })))
810 .mount(&server)
811 .await;
812 let (_dir, client) = authenticated_client_for_server(&server.uri(), "old-refresh").await;
813
814 assert!(!client.try_refresh_token().await.unwrap());
815 }
816
817 #[tokio::test]
818 async fn login_from_cache_restores_valid_cached_token() {
819 let server = MockServer::start().await;
820 Mock::given(method("GET"))
821 .and(path("/positions/"))
822 .and(query_param("nonzero", "true"))
823 .and(header("Authorization", "Bearer cached-access"))
824 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
825 "results": []
826 })))
827 .mount(&server)
828 .await;
829 let dir = tempfile::tempdir().unwrap();
830 let cache_path = dir.path().join("token.json");
831 let mut config = test_config(cache_path.to_str().unwrap());
832 config.api.base_url = server.uri();
833 let client = RobinhoodClient::with_config(config).unwrap();
834 client
835 .token_cache
836 .save(&CachedToken {
837 access_token: SecretString::from("cached-access"),
838 refresh_token: SecretString::from("cached-refresh"),
839 token_type: "Bearer".into(),
840 device_token: "cached-device".into(),
841 expires_at: Some(Utc::now().timestamp() + 60),
842 })
843 .unwrap();
844
845 assert!(client.login_from_cache().await.unwrap());
846 assert_eq!(
847 client.auth_state().await.authorization_header().as_deref(),
848 Some("Bearer cached-access")
849 );
850 assert_eq!(&*client.device_token.read().await, "cached-device");
851 }
852
853 #[cfg(unix)]
854 #[tokio::test]
855 async fn login_restores_owner_only_cached_token() {
856 let server = MockServer::start().await;
857 Mock::given(method("GET"))
858 .and(path("/positions/"))
859 .and(query_param("nonzero", "true"))
860 .and(header("Authorization", "Bearer cached-access"))
861 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
862 "results": []
863 })))
864 .mount(&server)
865 .await;
866 let dir = tempfile::tempdir().unwrap();
867 let cache_path = dir.path().join("token.json");
868 let mut config = test_config(cache_path.to_str().unwrap());
869 config.api.base_url = server.uri();
870 let client = RobinhoodClient::with_config(config).unwrap();
871 client
872 .token_cache
873 .save(&CachedToken {
874 access_token: SecretString::from("cached-access"),
875 refresh_token: SecretString::from("cached-refresh"),
876 token_type: "Bearer".into(),
877 device_token: "cached-device".into(),
878 expires_at: Some(Utc::now().timestamp() + 60),
879 })
880 .unwrap();
881 set_mode(&cache_path, 0o600);
882
883 client.login("user", "pass", None).await.unwrap();
884
885 assert_eq!(
886 client.auth_state().await.authorization_header().as_deref(),
887 Some("Bearer cached-access")
888 );
889 }
890
891 #[cfg(unix)]
892 #[tokio::test]
893 async fn login_rejects_world_readable_cached_token() {
894 let dir = tempfile::tempdir().unwrap();
895 let cache_path = dir.path().join("token.json");
896 let client =
897 RobinhoodClient::with_config(test_config(cache_path.to_str().unwrap())).unwrap();
898 client
899 .token_cache
900 .save(&CachedToken {
901 access_token: SecretString::from("cached-access"),
902 refresh_token: SecretString::from("cached-refresh"),
903 token_type: "Bearer".into(),
904 device_token: "cached-device".into(),
905 expires_at: Some(Utc::now().timestamp() + 60),
906 })
907 .unwrap();
908 set_mode(&cache_path, 0o644);
909
910 let error = client.login("user", "pass", None).await.unwrap_err();
911
912 assert!(
913 error
914 .to_string()
915 .contains(&cache_path.display().to_string())
916 );
917 assert!(error.to_string().contains("chmod 600"));
918 }
919
920 #[tokio::test]
921 async fn login_falls_through_when_cache_is_absent() {
922 let server = MockServer::start().await;
923 Mock::given(method("POST"))
924 .and(path("/oauth2/token/"))
925 .and(body_string_contains("grant_type=password"))
926 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
927 "access_token": "login-access",
928 "token_type": "Bearer",
929 "refresh_token": "login-refresh"
930 })))
931 .mount(&server)
932 .await;
933 let dir = tempfile::tempdir().unwrap();
934 let cache_path = dir.path().join("token.json");
935 let mut config = test_config(cache_path.to_str().unwrap());
936 config.api.base_url = server.uri();
937 let client = RobinhoodClient::with_config(config).unwrap();
938
939 client.login("user", "pass", None).await.unwrap();
940
941 assert_eq!(
942 client.auth_state().await.authorization_header().as_deref(),
943 Some("Bearer login-access")
944 );
945 }
946
947 #[tokio::test]
948 async fn login_headless_surfaces_api_detail_when_no_actionable_fields_exist() {
949 let server = MockServer::start().await;
950 Mock::given(method("POST"))
951 .and(path("/oauth2/token/"))
952 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
953 "detail": "invalid login"
954 })))
955 .mount(&server)
956 .await;
957 let (_dir, client) = client_for_server(&server.uri()).await;
958
959 let err = client
960 .login_headless("user", "pass", None)
961 .await
962 .unwrap_err();
963
964 assert!(matches!(
965 err,
966 RhoodError::Api {
967 status: 400,
968 message
969 } if message == "invalid login"
970 ));
971 }
972
973 #[tokio::test]
974 async fn login_headless_rejects_non_success_response_with_access_token() {
975 let server = MockServer::start().await;
976 Mock::given(method("POST"))
977 .and(path("/oauth2/token/"))
978 .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({
979 "access_token": "server-error-token"
980 })))
981 .mount(&server)
982 .await;
983 let (_dir, client) = client_for_server(&server.uri()).await;
984
985 let err = client
986 .login_headless("user", "pass", None)
987 .await
988 .unwrap_err();
989
990 assert!(matches!(
991 err,
992 RhoodError::Api {
993 status: 500,
994 message
995 } if message.contains("Login failed with no actionable response")
996 && !message.contains("server-error-token")
997 ));
998 assert!(!client.is_authenticated().await);
999 }
1000
1001 #[tokio::test]
1002 async fn login_headless_handles_mfa_required_on_successful_response() {
1003 let server = MockServer::start().await;
1004 Mock::given(method("POST"))
1005 .and(path("/oauth2/token/"))
1006 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1007 "mfa_required": true
1008 })))
1009 .mount(&server)
1010 .await;
1011 let (_dir, client) = client_for_server(&server.uri()).await;
1012
1013 let err = client
1014 .login_headless("user", "pass", None)
1015 .await
1016 .unwrap_err();
1017
1018 assert!(matches!(err, RhoodError::InvalidParameter(_)));
1019 assert!(matches!(client.auth_state().await, AuthState::MfaRequired));
1020 }
1021
1022 #[tokio::test]
1023 async fn login_headless_handles_challenge_on_successful_password_grant() {
1024 let server = MockServer::start().await;
1025 Mock::given(method("POST"))
1026 .and(path("/oauth2/token/"))
1027 .and(body_string_contains("grant_type=password"))
1028 .and(body_string_contains("username=user"))
1029 .and(body_string_contains("password=pass"))
1030 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1031 "challenge": {
1032 "id": "ch-login-1",
1033 "type": "sms",
1034 "status": "issued"
1035 }
1036 })))
1037 .mount(&server)
1038 .await;
1039 let (_dir, client) = client_for_server(&server.uri()).await;
1040
1041 let err = client
1042 .login_headless("user", "pass", None)
1043 .await
1044 .unwrap_err();
1045
1046 assert!(matches!(
1047 err,
1048 RhoodError::ChallengeRequired(ChallengeType::Sms)
1049 ));
1050 assert!(matches!(
1051 client.auth_state().await,
1052 AuthState::Challenged {
1053 challenge_type: ChallengeType::Sms,
1054 challenge_id,
1055 } if challenge_id == "ch-login-1"
1056 ));
1057 }
1058
1059 #[tokio::test]
1060 async fn login_headless_rejects_non_success_retry_with_access_token() {
1061 let server = MockServer::start().await;
1062 Mock::given(method("POST"))
1063 .and(path("/oauth2/token/"))
1064 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1065 "verification_workflow": {
1066 "id": "wf-1",
1067 "workflow_status": "issued"
1068 }
1069 })))
1070 .up_to_n_times(1)
1071 .mount(&server)
1072 .await;
1073 Mock::given(method("POST"))
1074 .and(path("/pathfinder/user_machine/"))
1075 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1076 "id": "machine-1"
1077 })))
1078 .mount(&server)
1079 .await;
1080 Mock::given(method("GET"))
1081 .and(path("/pathfinder/inquiries/machine-1/user_view/"))
1082 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1083 "context": {
1084 "sheriff_challenge": {
1085 "id": "challenge-1",
1086 "type": "prompt",
1087 "status": "validated"
1088 }
1089 },
1090 "type_context": null
1091 })))
1092 .mount(&server)
1093 .await;
1094 Mock::given(method("POST"))
1095 .and(path("/pathfinder/inquiries/machine-1/user_view/"))
1096 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1097 "context": null,
1098 "type_context": {
1099 "result": "workflow_status_approved"
1100 }
1101 })))
1102 .mount(&server)
1103 .await;
1104 Mock::given(method("POST"))
1105 .and(path("/oauth2/token/"))
1106 .respond_with(ResponseTemplate::new(502).set_body_json(serde_json::json!({
1107 "access_token": "retry-error-token"
1108 })))
1109 .mount(&server)
1110 .await;
1111 let (_dir, client) = client_for_server(&server.uri()).await;
1112
1113 let err = client
1114 .login_headless("user", "pass", None)
1115 .await
1116 .unwrap_err();
1117
1118 assert!(matches!(
1119 err,
1120 RhoodError::Api {
1121 status: 502,
1122 message
1123 } if message.contains("Login failed with no actionable response")
1124 && !message.contains("retry-error-token")
1125 ));
1126 assert!(!client.is_authenticated().await);
1127 }
1128
1129 #[tokio::test]
1130 async fn login_headless_extracts_tokens_on_successful_password_grant() {
1131 let server = MockServer::start().await;
1132 Mock::given(method("POST"))
1133 .and(path("/oauth2/token/"))
1134 .and(body_string_contains("grant_type=password"))
1135 .and(body_string_contains("username=user"))
1136 .and(body_string_contains("password=pass"))
1137 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1138 "access_token": "login-access",
1139 "token_type": "Bearer",
1140 "refresh_token": "login-refresh"
1141 })))
1142 .mount(&server)
1143 .await;
1144 let (_dir, client) = client_for_server(&server.uri()).await;
1145
1146 client.login_headless("user", "pass", None).await.unwrap();
1147
1148 assert_eq!(
1149 client.auth_state().await.authorization_header().as_deref(),
1150 Some("Bearer login-access")
1151 );
1152 }
1153
1154 #[tokio::test]
1155 async fn respond_to_challenge_validated_resets_to_unauthenticated() {
1156 let server = MockServer::start().await;
1157 Mock::given(method("POST"))
1158 .and(path("/challenge/ch-1/respond/"))
1159 .and(body_string_contains("response=123456"))
1160 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1161 "status": "validated"
1162 })))
1163 .mount(&server)
1164 .await;
1165 let (_dir, client) = client_for_server(&server.uri()).await;
1166 *client.auth_state.write().await = AuthState::Challenged {
1167 challenge_type: ChallengeType::Sms,
1168 challenge_id: "ch-1".into(),
1169 };
1170
1171 client.respond_to_challenge("123456").await.unwrap();
1172
1173 assert!(matches!(
1174 client.auth_state().await,
1175 AuthState::Unauthenticated
1176 ));
1177 }
1178
1179 #[tokio::test]
1180 async fn submit_challenge_response_rejects_unvalidated_status_before_retrying_login() {
1181 let server = MockServer::start().await;
1182 Mock::given(method("POST"))
1183 .and(path("/challenge/ch-1/respond/"))
1184 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
1185 "status": "pending"
1186 })))
1187 .mount(&server)
1188 .await;
1189 let (_dir, client) = client_for_server(&server.uri()).await;
1190
1191 let err = client
1192 .submit_challenge_response("ch-1", "123456", "user", "pass", None)
1193 .await
1194 .unwrap_err();
1195
1196 assert!(matches!(
1197 err,
1198 RhoodError::Api {
1199 status: 400,
1200 message
1201 } if message == "Challenge response not validated"
1202 ));
1203 }
1204
1205 #[tokio::test]
1206 async fn handle_login_response_email_challenge() {
1207 let dir = tempfile::tempdir().unwrap();
1208 let client = RobinhoodClient::with_config(test_config_with_tempdir(&dir)).unwrap();
1209 let data = OAuthResponse {
1210 challenge: Some(ChallengeDetail {
1211 id: "ch-email-1".into(),
1212 challenge_type: "email".into(),
1213 _status: Some("issued".into()),
1214 }),
1215 ..default_oauth_response()
1216 };
1217 let err = client.handle_login_response(&data, true).await.unwrap_err();
1218 assert!(matches!(
1219 err,
1220 RhoodError::ChallengeRequired(ChallengeType::Email)
1221 ));
1222 assert!(matches!(
1223 client.auth_state().await,
1224 AuthState::Challenged {
1225 challenge_type: ChallengeType::Email,
1226 challenge_id,
1227 } if challenge_id == "ch-email-1"
1228 ));
1229 }
1230
1231 #[tokio::test]
1232 async fn handle_login_response_prompt_challenge() {
1233 let dir = tempfile::tempdir().unwrap();
1234 let client = RobinhoodClient::with_config(test_config_with_tempdir(&dir)).unwrap();
1235 let data = OAuthResponse {
1236 challenge: Some(ChallengeDetail {
1237 id: "ch-prompt-1".into(),
1238 challenge_type: "prompt".into(),
1239 _status: Some("issued".into()),
1240 }),
1241 ..default_oauth_response()
1242 };
1243 let err = client.handle_login_response(&data, true).await.unwrap_err();
1244 assert!(matches!(
1245 err,
1246 RhoodError::ChallengeRequired(ChallengeType::Prompt)
1247 ));
1248 }
1249
1250 #[tokio::test]
1251 async fn extract_tokens_success() {
1252 let dir = tempfile::tempdir().unwrap();
1253 let cache_path = dir.path().join("tokens.json");
1254 let client =
1255 RobinhoodClient::with_config(test_config(cache_path.to_str().unwrap())).unwrap();
1256
1257 let data = OAuthResponse {
1258 access_token: Some("access123".into()),
1259 token_type: Some("Bearer".into()),
1260 refresh_token: Some("refresh456".into()),
1261 ..default_oauth_response()
1262 };
1263 client.extract_tokens(&data).await.unwrap();
1264
1265 assert!(client.is_authenticated().await);
1266 assert_eq!(
1267 client.auth_state().await.authorization_header().unwrap(),
1268 "Bearer access123"
1269 );
1270 assert!(cache_path.exists());
1272 }
1273
1274 #[tokio::test]
1275 async fn extract_tokens_missing_access_token() {
1276 let dir = tempfile::tempdir().unwrap();
1277 let client = RobinhoodClient::with_config(test_config_with_tempdir(&dir)).unwrap();
1278 let data = default_oauth_response();
1279 let err = client.extract_tokens(&data).await.unwrap_err();
1280 assert!(matches!(err, RhoodError::Api { status: 401, .. }));
1281 }
1282
1283 #[tokio::test]
1284 async fn extract_tokens_default_token_type() {
1285 let dir = tempfile::tempdir().unwrap();
1286 let cache_path = dir.path().join("tokens.json");
1287 let client =
1288 RobinhoodClient::with_config(test_config(cache_path.to_str().unwrap())).unwrap();
1289
1290 let data = OAuthResponse {
1291 access_token: Some("tok".into()),
1292 token_type: None, refresh_token: Some("ref".into()),
1294 ..default_oauth_response()
1295 };
1296 client.extract_tokens(&data).await.unwrap();
1297 assert_eq!(
1298 client.auth_state().await.authorization_header().unwrap(),
1299 "Bearer tok"
1300 );
1301 }
1302}