Skip to main content

rhood_core/auth/
auth_state.rs

1//! Authentication state machine for the Robinhood login flow.
2//!
3//! [`AuthState`] models every phase of the multi-step authentication process,
4//! from unauthenticated through various challenge types to fully authenticated.
5
6use secrecy::{ExposeSecret, SecretString};
7
8use crate::ChallengeType;
9
10/// Represents the current phase of Robinhood authentication.
11///
12/// Transitions follow the pattern:
13/// `Unauthenticated` -> (`Challenged` | `MfaRequired` | `DeviceVerification`) -> `Authenticated`.
14#[derive(Clone)]
15pub enum AuthState {
16    /// Indicates that no authentication attempt has been made or credentials have been cleared.
17    Unauthenticated,
18    /// Indicates the server issued a challenge that must be answered before authentication can proceed.
19    Challenged {
20        /// The type of challenge issued (e.g., SMS or email).
21        challenge_type: ChallengeType,
22        /// The server-assigned identifier for this challenge.
23        challenge_id: String,
24    },
25    /// Indicates the server requires a multi-factor authentication code (e.g., TOTP).
26    MfaRequired,
27    /// Indicates the server requires device verification before proceeding.
28    DeviceVerification {
29        /// The server-assigned workflow identifier for the device verification flow.
30        workflow_id: String,
31    },
32    /// Indicates successful authentication with valid OAuth tokens.
33    Authenticated {
34        /// The OAuth access token used to authorize API requests.
35        access_token: SecretString,
36        /// The token type prefix for the `Authorization` header (typically `"Bearer"`).
37        token_type: String,
38        /// The OAuth refresh token used to obtain a new access token when the current one expires.
39        refresh_token: SecretString,
40    },
41}
42
43impl std::fmt::Debug for AuthState {
44    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            Self::Unauthenticated => write!(formatter, "Unauthenticated"),
47            Self::Challenged { challenge_type, .. } => formatter
48                .debug_struct("Challenged")
49                .field("challenge_type", challenge_type)
50                .finish(),
51            Self::MfaRequired => write!(formatter, "MfaRequired"),
52            Self::DeviceVerification { workflow_id } => formatter
53                .debug_struct("DeviceVerification")
54                .field("workflow_id", workflow_id)
55                .finish(),
56            Self::Authenticated { token_type, .. } => formatter
57                .debug_struct("Authenticated")
58                .field("access_token", &"[REDACTED]")
59                .field("token_type", token_type)
60                .field("refresh_token", &"[REDACTED]")
61                .finish(),
62        }
63    }
64}
65
66impl AuthState {
67    /// Returns `true` if the state is [`AuthState::Authenticated`].
68    pub fn is_authenticated(&self) -> bool {
69        matches!(self, Self::Authenticated { .. })
70    }
71
72    /// Returns the formatted `Authorization` header value (e.g., `"Bearer <token>"`),
73    /// or `None` if the state is not [`AuthState::Authenticated`].
74    pub fn authorization_header(&self) -> Option<String> {
75        match self {
76            Self::Authenticated {
77                access_token,
78                token_type,
79                ..
80            } => Some(format!("{token_type} {}", access_token.expose_secret())),
81            _ => None,
82        }
83    }
84
85    /// Returns a reference to the OAuth refresh token, or `None` if the state
86    /// is not [`AuthState::Authenticated`].
87    pub fn refresh_token(&self) -> Option<&SecretString> {
88        match self {
89            Self::Authenticated { refresh_token, .. } => Some(refresh_token),
90            _ => None,
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn unauthenticated_state() {
101        let state = AuthState::Unauthenticated;
102        assert!(!state.is_authenticated());
103        assert!(state.authorization_header().is_none());
104        assert!(state.refresh_token().is_none());
105    }
106
107    #[test]
108    fn authenticated_state() {
109        let state = AuthState::Authenticated {
110            access_token: SecretString::from("tok123"),
111            token_type: "Bearer".into(),
112            refresh_token: SecretString::from("ref456"),
113        };
114        assert!(state.is_authenticated());
115        assert_eq!(state.authorization_header().unwrap(), "Bearer tok123");
116        assert_eq!(state.refresh_token().unwrap().expose_secret(), "ref456");
117    }
118
119    #[test]
120    fn challenged_state() {
121        let state = AuthState::Challenged {
122            challenge_type: ChallengeType::Sms,
123            challenge_id: "abc".into(),
124        };
125        assert!(!state.is_authenticated());
126        assert!(state.authorization_header().is_none());
127    }
128
129    #[test]
130    fn mfa_required_state() {
131        let state = AuthState::MfaRequired;
132        assert!(!state.is_authenticated());
133    }
134
135    #[test]
136    fn device_verification_state() {
137        let state = AuthState::DeviceVerification {
138            workflow_id: "wf-123".into(),
139        };
140        assert!(!state.is_authenticated());
141        let debug = format!("{state:?}");
142        assert!(debug.contains("wf-123"));
143    }
144
145    #[test]
146    fn debug_redacts_tokens() {
147        let state = AuthState::Authenticated {
148            access_token: SecretString::from("super_secret"),
149            token_type: "Bearer".into(),
150            refresh_token: SecretString::from("refresh_secret"),
151        };
152        let debug = format!("{state:?}");
153        assert!(!debug.contains("super_secret"));
154        assert!(!debug.contains("refresh_secret"));
155        assert!(debug.contains("[REDACTED]"));
156    }
157}