Skip to main content

bitwarden_importers/importers/onepassword/access/
two_factor.rs

1//! Two-factor authentication: the callback the caller implements and the TOTP exchange that
2//! drives it.
3//!
4//! Only TOTP (Google Authenticator) is implemented. WebAuthn and Duo extend [`TwoFactorUi`] later.
5
6use async_trait::async_trait;
7use serde::de::IgnoredAny;
8use serde_json::json;
9
10use super::{
11    device::ClientInfo, error::OnePasswordError, opdata::AesKey, rest::RestClient, wire::MfaInfo,
12};
13
14const MFA_ENDPOINT: &str = "v1/auth/mfa";
15
16/// The outcome of a two-factor prompt.
17pub enum TotpResult {
18    /// A passcode entered (or generated) by the user.
19    Code(String),
20    /// The user declined to provide a passcode.
21    Cancel,
22}
23
24/// Callback for interactive two-factor authentication.
25#[async_trait]
26pub trait TwoFactorUi: Send + Sync {
27    /// Provides a TOTP passcode for the given zero-based attempt. Each wrong code restarts the
28    /// login, so `attempt` grows as the user retries.
29    async fn provide_totp(&self, attempt: u32) -> TotpResult;
30}
31
32/// The result of submitting a second factor: verified, or a rejected code that asks for a restart.
33#[derive(Debug)]
34pub(super) enum MfaOutcome {
35    /// The code was accepted and the session is authenticated.
36    Verified,
37    /// The code was rejected. 1Password invalidates the session, so the login has to start over.
38    BadOtp,
39}
40
41/// Prompts for and submits a TOTP code. WebAuthn and Duo are not supported yet.
42pub(super) async fn perform_second_factor_authentication(
43    mfa: &MfaInfo,
44    client_info: &ClientInfo,
45    session_key: &AesKey,
46    attempt: u32,
47    ui: &dyn TwoFactorUi,
48    rest: &RestClient,
49) -> Result<MfaOutcome, OnePasswordError> {
50    if !mfa.totp_enabled() {
51        return Err(OnePasswordError::Unsupported(format!(
52            "account requires an unsupported 2FA method (offered: {})",
53            mfa.enabled_methods().join(", ")
54        )));
55    }
56
57    let passcode = match ui.provide_totp(attempt).await {
58        TotpResult::Code(passcode) => passcode,
59        TotpResult::Cancel => return Err(OnePasswordError::TwoFactorFailed),
60    };
61
62    match submit_totp(client_info, session_key, &passcode, rest).await {
63        Ok(()) => Ok(MfaOutcome::Verified),
64        // 1Password reports a wrong code as a generic auth error; treat it as a retryable bad code.
65        Err(OnePasswordError::BadCredentials) => Ok(MfaOutcome::BadOtp),
66        Err(error) => Err(error),
67    }
68}
69
70/// Submits a TOTP code to `v1/auth/mfa`. The remember-me token in the response is ignored (one-shot
71/// import).
72async fn submit_totp(
73    client_info: &ClientInfo,
74    session_key: &AesKey,
75    passcode: &str,
76    rest: &RestClient,
77) -> Result<(), OnePasswordError> {
78    let params = json!({
79        "sessionID": session_key.id,
80        "client": client_info.client_id(),
81        "totp": { "code": passcode.trim() },
82    });
83    let _: IgnoredAny = rest
84        .post_encrypted_json(MFA_ENDPOINT, params, session_key)
85        .await?;
86    Ok(())
87}
88
89#[cfg(test)]
90mod tests {
91    use bitwarden_api_base::new_http_client;
92    use rand::Rng;
93    use serde_json::Value;
94    use wiremock::{Mock, MockServer, ResponseTemplate, matchers};
95
96    use super::{super::opdata::decode64_loose, *};
97
98    struct ScriptedUi {
99        result: TotpResult,
100    }
101
102    impl ScriptedUi {
103        fn code(passcode: &str) -> ScriptedUi {
104            ScriptedUi {
105                result: TotpResult::Code(passcode.into()),
106            }
107        }
108
109        fn cancel() -> ScriptedUi {
110            ScriptedUi {
111                result: TotpResult::Cancel,
112            }
113        }
114    }
115
116    #[async_trait]
117    impl TwoFactorUi for ScriptedUi {
118        async fn provide_totp(&self, _attempt: u32) -> TotpResult {
119            match &self.result {
120                TotpResult::Code(passcode) => TotpResult::Code(passcode.clone()),
121                TotpResult::Cancel => TotpResult::Cancel,
122            }
123        }
124    }
125
126    fn session_key() -> AesKey {
127        AesKey::new(
128            "SESSION",
129            decode64_loose("WyICHHlP5lPigZUGZYoivbJMqgHjSti86UKwdjCryYM").expect("valid key"),
130        )
131    }
132
133    fn client(server: &MockServer) -> RestClient {
134        RestClient::new(
135            new_http_client(),
136            format!("http://{}/api", server.address()),
137            "client-id",
138            "user-agent",
139            "op-user-agent",
140        )
141        .expect("valid headers")
142    }
143
144    fn mfa(json: &str) -> MfaInfo {
145        serde_json::from_str(json).expect("valid mfa info")
146    }
147
148    /// Encrypts `plaintext` for the session key so the mock can answer like the real server does.
149    fn encrypted_body(key: &AesKey, plaintext: &[u8]) -> Value {
150        let mut iv = [0u8; 12];
151        bitwarden_random::rng().fill_bytes(&mut iv);
152        let envelope = key.encrypt(plaintext, &iv).expect("encrypts");
153        serde_json::to_value(&envelope).expect("serializes")
154    }
155
156    #[tokio::test]
157    async fn submits_the_code_from_the_callback() {
158        let key = session_key();
159        let server = MockServer::start().await;
160        server
161            .register(
162                Mock::given(matchers::path("/api/v1/auth/mfa"))
163                    .and(matchers::method("POST"))
164                    .respond_with(
165                        ResponseTemplate::new(200)
166                            .set_body_json(encrypted_body(&key, br#"{"sessionID":"SESSION"}"#)),
167                    )
168                    .expect(1),
169            )
170            .await;
171
172        let outcome = perform_second_factor_authentication(
173            &mfa(r#"{"totp":{"enabled":true}}"#),
174            &ClientInfo::for_desktop("device-uuid"),
175            &key,
176            0,
177            &ScriptedUi::code("123456"),
178            &client(&server),
179        )
180        .await
181        .expect("2FA completes");
182
183        assert!(matches!(outcome, MfaOutcome::Verified));
184        server.verify().await;
185    }
186
187    #[tokio::test]
188    async fn a_rejected_code_asks_for_a_restart() {
189        let key = session_key();
190        let server = MockServer::start().await;
191        server
192            .register(
193                Mock::given(matchers::path("/api/v1/auth/mfa"))
194                    .respond_with(ResponseTemplate::new(401).set_body_json(
195                        serde_json::json!({"errorCode": 102, "errorMessage": "bad code"}),
196                    ))
197                    .expect(1),
198            )
199            .await;
200
201        let outcome = perform_second_factor_authentication(
202            &mfa(r#"{"totp":{"enabled":true}}"#),
203            &ClientInfo::for_desktop("device-uuid"),
204            &key,
205            1,
206            &ScriptedUi::code("000000"),
207            &client(&server),
208        )
209        .await
210        .expect("a bad code is not a hard failure");
211
212        assert!(matches!(outcome, MfaOutcome::BadOtp));
213        server.verify().await;
214    }
215
216    #[tokio::test]
217    async fn cancelling_the_prompt_fails_the_login() {
218        let server = MockServer::start().await;
219
220        let error = perform_second_factor_authentication(
221            &mfa(r#"{"totp":{"enabled":true}}"#),
222            &ClientInfo::for_desktop("device-uuid"),
223            &session_key(),
224            0,
225            &ScriptedUi::cancel(),
226            &client(&server),
227        )
228        .await
229        .expect_err("the user declined");
230
231        assert!(matches!(error, OnePasswordError::TwoFactorFailed));
232    }
233
234    #[tokio::test]
235    async fn rejects_accounts_without_totp() {
236        let server = MockServer::start().await;
237
238        let error = perform_second_factor_authentication(
239            &mfa(r#"{"totp":{"enabled":false},"duo":{"enabled":true}}"#),
240            &ClientInfo::for_desktop("device-uuid"),
241            &session_key(),
242            0,
243            &ScriptedUi::code("123456"),
244            &client(&server),
245        )
246        .await
247        .expect_err("Duo is not supported");
248
249        assert!(matches!(error, OnePasswordError::Unsupported(_)));
250        assert!(error.to_string().contains("Duo"));
251    }
252}