Skip to main content

rhood_core/client/
auth.rs

1//! Login cascade and OAuth token refresh for [`RobinhoodClient`].
2//!
3//! Owns the multi-step login flow (cache → validate → refresh → headless OAuth),
4//! token extraction/persistence, and SMS/email challenge response handling.
5
6use 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    /// Unified login that cascades through all available authentication strategies.
20    ///
21    /// The cascade order is:
22    /// 1. **Cache** - load token from disk
23    /// 2. **Validate** - confirm the cached token is accepted by the server
24    /// 3. **Refresh** - if validation fails, try refreshing the access token
25    /// 4. **Headless** - if refresh fails, perform a full OAuth password grant
26    ///
27    /// If the headless login encounters a challenge (SMS/email), the error
28    /// [`RhoodError::ChallengeRequired`] is returned with the challenge details.
29    /// The caller should collect the code from the user and call
30    /// [`submit_challenge_response()`](Self::submit_challenge_response) to complete authentication.
31    ///
32    /// # Arguments
33    ///
34    /// * `username` - Robinhood account email/username
35    /// * `password` - Robinhood account password
36    /// * `mfa_secret` - Optional base32-encoded TOTP secret for automated MFA
37    ///
38    /// # Errors
39    ///
40    /// Returns [`RhoodError::ChallengeRequired`] if SMS/email verification is needed.
41    /// Returns [`RhoodError::DeviceVerificationRequired`] if push verification is needed
42    /// (for push challenges, the library polls automatically during `login_headless`).
43    /// Returns cache, transport, or API errors.
44    ///
45    /// In particular, an insecure token-cache file permission mode is returned
46    /// so the caller can correct it before logging in again.
47    pub async fn login(
48        &self,
49        username: &str,
50        password: &str,
51        mfa_secret: Option<&str>,
52    ) -> Result<()> {
53        // Step 1: Try loading from cache
54        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            // Step 2: Validate cached token with a live API call
67            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            // Step 3: Try refreshing the token
81            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        // Step 4: Full headless login
96        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    /// Attempts to restore an authenticated session from the on-disk token cache.
102    ///
103    /// Loads the cached token, validates it with a live API call via
104    /// [`validate_token()`](Self::validate_token), and on failure attempts
105    /// to refresh it. Returns `true` if the client is now authenticated,
106    /// `false` if all recovery strategies failed.
107    ///
108    /// # Errors
109    ///
110    /// Returns an error on I/O failures or HTTP transport errors.
111    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        // Validate with a live API call
128        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        // Token validation failed so try to refresh before giving up
142        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    /// Validates the current access token by making a lightweight API call.
153    ///
154    /// Returns `Ok(true)` if the token is accepted by the server, `Ok(false)`
155    /// if the server returns 401 or 403 (token revoked or invalid), and
156    /// `Err` on network/transport errors.
157    ///
158    /// Uses `GET /positions/?nonzero=true` as the validation endpoint because
159    /// it returns a small payload and is always available for authenticated users.
160    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    /// Attempt to refresh the access token using the stored refresh_token.
178    ///
179    /// Returns `Ok(true)` if refresh succeeded and state is now Authenticated.
180    /// Returns `Ok(false)` if refresh failed gracefully (no refresh token, server
181    /// rejection, or the refresh token itself has expired which is indicated by the
182    /// server returning a `verification_workflow` in the response).
183    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 the refresh response contains a verification_workflow, the refresh
218        // token itself has expired and a full re-authentication is required.
219        // Return false to let the cascade fall through to headless login.
220        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    /// Submit the initial OAuth2 password grant. Sets `auth_state` based on
232    /// the response (Authenticated, MfaRequired, DeviceVerification, or Challenged).
233    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            // `Totp::generate_current` panics if the clock is before the Unix epoch;
255            // reading the clock here keeps that a recoverable error, as it was before
256            // totp-rs 6 moved the fallibility out of the return type.
257            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        // A non-success status is always an API error, regardless of any auth-shaped
302        // fields in the response body. Preserve an API-provided detail when present.
303        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        // Surface API error detail when no actionable auth fields are present
320        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        // Device verification: run the pathfinder flow, then retry login
333        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            // Retry the original login after device is verified
339            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    /// Handle the OAuth2 response, transitioning auth_state appropriately.
378    async fn handle_login_response(
379        &self,
380        data: &OAuthResponse,
381        mfa_secret_absent: bool,
382    ) -> Result<()> {
383        // Device verification required (should not reach here from login_headless,
384        // but kept as a fallback for direct callers)
385        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        // MFA challenge required
394        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        // SMS/email challenge
405        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    /// Extract access/refresh tokens from a successful OAuth2 response,
428    /// transition to Authenticated, and persist to cache.
429    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    /// Respond to an SMS/email challenge with the user-provided code.
470    /// On success, transitions to Authenticated.
471    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            // Challenge validated so the caller should re-attempt login
492            *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    /// Respond to an SMS/email challenge and re-attempt login.
503    ///
504    /// This is the full challenge-response flow:
505    /// 1. POSTs the user-provided code to the challenge endpoint
506    /// 2. If validated, re-attempts login with the provided credentials
507    /// 3. On success, transitions to `Authenticated` and caches tokens
508    ///
509    /// The caller must provide the original login credentials because the
510    /// challenge response only validates the device. A fresh OAuth password
511    /// grant is still required to obtain tokens.
512    ///
513    /// # Errors
514    ///
515    /// Returns [`RhoodError::InvalidParameter`] if no challenge is pending.
516    /// Returns [`RhoodError::Api`] if the challenge response is rejected.
517    /// Returns any login error from the re-attempted `login_headless()` call.
518    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        // Step 1: Submit the challenge response
527        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        // Step 2: Challenge validated so re-attempt login
544        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            // 5 params: challenge_id, code, username, password, mfa_secret
690            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        // Verify token was cached to disk
1271        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, // Should default to "Bearer"
1293            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}