Skip to main content

bitwarden_auth/login/login_via_password/
password_prelogin.rs

1use bitwarden_api_identity::models::PasswordPreloginRequestModel;
2use bitwarden_core::ApiError;
3use bitwarden_error::bitwarden_error;
4use thiserror::Error;
5#[cfg(feature = "wasm")]
6use wasm_bindgen::prelude::*;
7
8use crate::login::{LoginClient, login_via_password::PasswordPreloginResponse};
9
10/// Error type for password prelogin operations
11#[bitwarden_error(flat)]
12#[derive(Debug, Error)]
13pub enum PasswordPreloginError {
14    /// API error occurred during the prelogin request
15    #[error(transparent)]
16    Api(#[from] ApiError),
17
18    /// An unknown error occurred
19    /// This variant ensures the SDK can handle new error types introduced by the server
20    /// without breaking existing client code.
21    #[error("Unknown password prelogin error: {0}")]
22    Unknown(String),
23}
24
25/// Converts MissingFieldError into PasswordPreloginError::Unknown
26/// We need this because we use the !require macro which returns MissingFieldError
27/// to enforce that salt and kdf_settings are present in the response.
28impl From<bitwarden_core::MissingFieldError> for PasswordPreloginError {
29    fn from(err: bitwarden_core::MissingFieldError) -> Self {
30        PasswordPreloginError::Unknown(err.to_string())
31    }
32}
33
34#[cfg_attr(feature = "wasm", wasm_bindgen)]
35impl LoginClient {
36    /// Retrieves the data required before authenticating with a password.
37    /// This includes the user's KDF configuration needed to properly derive the master key.
38    pub async fn get_password_prelogin(
39        &self,
40        email: String,
41    ) -> Result<PasswordPreloginResponse, PasswordPreloginError> {
42        let request_model = PasswordPreloginRequestModel::new(email.clone());
43        let api_configs = self.client.internal.get_api_configurations();
44        let response = api_configs
45            .identity_client
46            .accounts_api()
47            .post_password_prelogin(Some(request_model))
48            .await
49            .map_err(ApiError::from)?;
50
51        Ok(PasswordPreloginResponse::try_from((
52            response,
53            email.as_str(),
54        ))?)
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use std::num::NonZeroU32;
61
62    use bitwarden_api_api::ResponseContent;
63    use bitwarden_api_identity::models::KdfType;
64    use bitwarden_core::{ClientSettings, DeviceType};
65    use bitwarden_crypto::Kdf;
66    use bitwarden_test::start_api_mock;
67    use wiremock::{Mock, ResponseTemplate, matchers};
68
69    use super::*;
70
71    const TEST_EMAIL: &str = "[email protected]";
72    const TEST_SALT_PBKDF2: &str = "test-salt-value";
73    const TEST_SALT_ARGON2: &str = "argon2-salt-value";
74
75    fn make_login_client(mock_server: &wiremock::MockServer) -> LoginClient {
76        let settings = ClientSettings {
77            identity_url: format!("http://{}/identity", mock_server.address()),
78            api_url: format!("http://{}/api", mock_server.address()),
79            user_agent: "Bitwarden Rust-SDK [TEST]".into(),
80            device_type: DeviceType::SDK,
81            device_identifier: None,
82            bitwarden_client_version: None,
83            bitwarden_package_type: None,
84        };
85        LoginClient::new(settings)
86    }
87
88    fn mock_default_pbkdf2_iterations() -> NonZeroU32 {
89        let Kdf::PBKDF2 { iterations } = Kdf::default_pbkdf2() else {
90            panic!("Expected PBKDF2 KDF");
91        };
92        iterations
93    }
94
95    fn mock_default_argon2_params() -> (NonZeroU32, NonZeroU32, NonZeroU32) {
96        let Kdf::Argon2id {
97            iterations,
98            memory,
99            parallelism,
100        } = Kdf::default_argon2()
101        else {
102            panic!("Expected Argon2 KDF");
103        };
104        (iterations, memory, parallelism)
105    }
106
107    #[tokio::test]
108    async fn test_get_password_prelogin_pbkdf2_success() {
109        // Create a mock success response with PBKDF2
110        let raw_success = serde_json::json!({
111            "kdfSettings": {
112                "kdfType": KdfType::PBKDF2_SHA256.as_i64(),
113                "iterations": mock_default_pbkdf2_iterations().get()
114            },
115            "salt": TEST_SALT_PBKDF2
116        });
117
118        let mock = Mock::given(matchers::method("POST"))
119            .and(matchers::path("identity/accounts/prelogin/password"))
120            .and(matchers::header(
121                reqwest::header::CONTENT_TYPE.as_str(),
122                "application/json",
123            ))
124            .respond_with(ResponseTemplate::new(200).set_body_json(raw_success));
125
126        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
127        let login_client = make_login_client(&mock_server);
128
129        let result = login_client
130            .get_password_prelogin(TEST_EMAIL.to_string())
131            .await
132            .unwrap();
133
134        assert_eq!(result.salt, TEST_SALT_PBKDF2);
135        match result.kdf {
136            Kdf::PBKDF2 { iterations } => {
137                assert_eq!(iterations, mock_default_pbkdf2_iterations());
138            }
139            _ => panic!("Expected PBKDF2 KDF type"),
140        }
141    }
142
143    #[tokio::test]
144    async fn test_get_password_prelogin_argon2id_success() {
145        let (default_iterations, default_memory, default_parallelism) =
146            mock_default_argon2_params();
147
148        // Create a mock success response with Argon2id
149        let raw_success = serde_json::json!({
150            "kdfSettings": {
151                "kdfType": KdfType::Argon2id.as_i64(),
152                "iterations": default_iterations.get(),
153                "memory": default_memory.get(),
154                "parallelism": default_parallelism.get(),
155            },
156            "salt": TEST_SALT_ARGON2
157        });
158
159        let mock = Mock::given(matchers::method("POST"))
160            .and(matchers::path("identity/accounts/prelogin/password"))
161            .and(matchers::header(
162                reqwest::header::CONTENT_TYPE.as_str(),
163                "application/json",
164            ))
165            .respond_with(ResponseTemplate::new(200).set_body_json(raw_success));
166
167        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
168        let login_client = make_login_client(&mock_server);
169
170        let result = login_client
171            .get_password_prelogin(TEST_EMAIL.to_string())
172            .await
173            .unwrap();
174
175        assert_eq!(result.salt, TEST_SALT_ARGON2);
176        match result.kdf {
177            Kdf::Argon2id {
178                iterations,
179                memory,
180                parallelism,
181            } => {
182                assert_eq!(iterations, default_iterations);
183                assert_eq!(memory, default_memory);
184                assert_eq!(parallelism, default_parallelism);
185            }
186            _ => panic!("Expected Argon2id KDF type"),
187        }
188    }
189
190    #[tokio::test]
191    async fn test_get_password_prelogin_missing_kdf_settings() {
192        // Create a mock response missing kdf_settings
193        let raw_response = serde_json::json!({
194            "salt": TEST_SALT_PBKDF2
195        });
196
197        let mock = Mock::given(matchers::method("POST"))
198            .and(matchers::path("identity/accounts/prelogin/password"))
199            .respond_with(ResponseTemplate::new(200).set_body_json(raw_response));
200
201        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
202        let login_client = make_login_client(&mock_server);
203
204        let result = login_client
205            .get_password_prelogin(TEST_EMAIL.to_string())
206            .await;
207
208        assert!(result.is_err());
209        match result.unwrap_err() {
210            PasswordPreloginError::Unknown(err) => {
211                assert_eq!(
212                    err,
213                    "The response received was missing a required field: response.kdf_settings"
214                );
215            }
216            other => panic!("Expected MissingField error, got {:?}", other),
217        }
218    }
219
220    #[tokio::test]
221    async fn test_get_password_prelogin_missing_salt() {
222        // Create a mock response missing salt
223        let raw_response = serde_json::json!({
224            "kdfSettings": {
225                "kdfType": KdfType::PBKDF2_SHA256.as_i64(),
226                "iterations": mock_default_pbkdf2_iterations().get(),
227            }
228        });
229
230        let mock = Mock::given(matchers::method("POST"))
231            .and(matchers::path("/identity/accounts/prelogin/password"))
232            .respond_with(ResponseTemplate::new(200).set_body_json(raw_response));
233
234        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
235        let login_client = make_login_client(&mock_server);
236
237        let result = login_client
238            .get_password_prelogin(TEST_EMAIL.to_string())
239            .await;
240
241        assert_eq!(result.unwrap().salt, TEST_EMAIL.to_string());
242    }
243
244    #[tokio::test]
245    async fn test_get_password_prelogin_email_fallback_case_insensitive() {
246        // Create a mock response missing salt
247        let raw_response = serde_json::json!({
248            "kdfSettings": {
249                "kdfType": KdfType::PBKDF2_SHA256.as_i64(),
250                "iterations": mock_default_pbkdf2_iterations().get(),
251            }
252        });
253
254        let case_variant_email = "[email protected]";
255
256        let mock = Mock::given(matchers::method("POST"))
257            .and(matchers::path("/identity/accounts/prelogin/password"))
258            .respond_with(ResponseTemplate::new(200).set_body_json(raw_response));
259
260        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
261        let login_client = make_login_client(&mock_server);
262
263        let result = login_client
264            .get_password_prelogin(case_variant_email.to_string())
265            .await;
266
267        assert_eq!(result.unwrap().salt, TEST_EMAIL.to_string());
268    }
269
270    #[tokio::test]
271    async fn test_get_password_prelogin_api_error() {
272        // Create a mock 500 error
273        let mock = Mock::given(matchers::method("POST"))
274            .and(matchers::path("/identity/accounts/prelogin/password"))
275            .respond_with(ResponseTemplate::new(500));
276
277        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
278        let login_client = make_login_client(&mock_server);
279
280        let result = login_client
281            .get_password_prelogin(TEST_EMAIL.to_string())
282            .await;
283
284        assert!(result.is_err());
285        match result.unwrap_err() {
286            PasswordPreloginError::Api(bitwarden_core::ApiError::Response(ResponseContent {
287                status,
288                message: _,
289            })) => {
290                assert_eq!(status, reqwest::StatusCode::INTERNAL_SERVER_ERROR);
291            }
292            other => panic!("Expected Api Response error, got {:?}", other),
293        }
294    }
295}