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
50        Ok(PasswordPreloginResponse::try_from((
51            response,
52            email.as_str(),
53        ))?)
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use std::num::NonZeroU32;
60
61    use bitwarden_api_api::ResponseContent;
62    use bitwarden_api_identity::models::KdfType;
63    use bitwarden_core::{ClientSettings, DeviceType};
64    use bitwarden_crypto::Kdf;
65    use bitwarden_test::start_api_mock;
66    use wiremock::{Mock, ResponseTemplate, matchers};
67
68    use super::*;
69
70    const TEST_EMAIL: &str = "[email protected]";
71    const TEST_SALT_PBKDF2: &str = "test-salt-value";
72    const TEST_SALT_ARGON2: &str = "argon2-salt-value";
73
74    fn make_login_client(mock_server: &wiremock::MockServer) -> LoginClient {
75        let settings = ClientSettings {
76            identity_url: format!("http://{}/identity", mock_server.address()),
77            api_url: format!("http://{}/api", mock_server.address()),
78            user_agent: "Bitwarden Rust-SDK [TEST]".into(),
79            device_type: DeviceType::SDK,
80            device_identifier: None,
81            bitwarden_client_version: None,
82            bitwarden_package_type: None,
83        };
84        LoginClient::new(settings)
85    }
86
87    fn mock_default_pbkdf2_iterations() -> NonZeroU32 {
88        let Kdf::PBKDF2 { iterations } = Kdf::default_pbkdf2() else {
89            panic!("Expected PBKDF2 KDF");
90        };
91        iterations
92    }
93
94    fn mock_default_argon2_params() -> (NonZeroU32, NonZeroU32, NonZeroU32) {
95        let Kdf::Argon2id {
96            iterations,
97            memory,
98            parallelism,
99        } = Kdf::default_argon2()
100        else {
101            panic!("Expected Argon2 KDF");
102        };
103        (iterations, memory, parallelism)
104    }
105
106    #[tokio::test]
107    async fn test_get_password_prelogin_pbkdf2_success() {
108        // Create a mock success response with PBKDF2
109        let raw_success = serde_json::json!({
110            "kdfSettings": {
111                "kdfType": KdfType::PBKDF2_SHA256.as_i64(),
112                "iterations": mock_default_pbkdf2_iterations().get()
113            },
114            "salt": TEST_SALT_PBKDF2
115        });
116
117        let mock = Mock::given(matchers::method("POST"))
118            .and(matchers::path("identity/accounts/prelogin/password"))
119            .and(matchers::header(
120                reqwest::header::CONTENT_TYPE.as_str(),
121                "application/json",
122            ))
123            .respond_with(ResponseTemplate::new(200).set_body_json(raw_success));
124
125        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
126        let login_client = make_login_client(&mock_server);
127
128        let result = login_client
129            .get_password_prelogin(TEST_EMAIL.to_string())
130            .await
131            .unwrap();
132
133        assert_eq!(result.salt, TEST_SALT_PBKDF2);
134        match result.kdf {
135            Kdf::PBKDF2 { iterations } => {
136                assert_eq!(iterations, mock_default_pbkdf2_iterations());
137            }
138            _ => panic!("Expected PBKDF2 KDF type"),
139        }
140    }
141
142    #[tokio::test]
143    async fn test_get_password_prelogin_argon2id_success() {
144        let (default_iterations, default_memory, default_parallelism) =
145            mock_default_argon2_params();
146
147        // Create a mock success response with Argon2id
148        let raw_success = serde_json::json!({
149            "kdfSettings": {
150                "kdfType": KdfType::Argon2id.as_i64(),
151                "iterations": default_iterations.get(),
152                "memory": default_memory.get(),
153                "parallelism": default_parallelism.get(),
154            },
155            "salt": TEST_SALT_ARGON2
156        });
157
158        let mock = Mock::given(matchers::method("POST"))
159            .and(matchers::path("identity/accounts/prelogin/password"))
160            .and(matchers::header(
161                reqwest::header::CONTENT_TYPE.as_str(),
162                "application/json",
163            ))
164            .respond_with(ResponseTemplate::new(200).set_body_json(raw_success));
165
166        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
167        let login_client = make_login_client(&mock_server);
168
169        let result = login_client
170            .get_password_prelogin(TEST_EMAIL.to_string())
171            .await
172            .unwrap();
173
174        assert_eq!(result.salt, TEST_SALT_ARGON2);
175        match result.kdf {
176            Kdf::Argon2id {
177                iterations,
178                memory,
179                parallelism,
180            } => {
181                assert_eq!(iterations, default_iterations);
182                assert_eq!(memory, default_memory);
183                assert_eq!(parallelism, default_parallelism);
184            }
185            _ => panic!("Expected Argon2id KDF type"),
186        }
187    }
188
189    #[tokio::test]
190    async fn test_get_password_prelogin_missing_kdf_settings() {
191        // Create a mock response missing kdf_settings
192        let raw_response = serde_json::json!({
193            "salt": TEST_SALT_PBKDF2
194        });
195
196        let mock = Mock::given(matchers::method("POST"))
197            .and(matchers::path("identity/accounts/prelogin/password"))
198            .respond_with(ResponseTemplate::new(200).set_body_json(raw_response));
199
200        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
201        let login_client = make_login_client(&mock_server);
202
203        let result = login_client
204            .get_password_prelogin(TEST_EMAIL.to_string())
205            .await;
206
207        assert!(result.is_err());
208        match result.unwrap_err() {
209            PasswordPreloginError::Unknown(err) => {
210                assert_eq!(
211                    err,
212                    "The response received was missing a required field: response.kdf_settings"
213                );
214            }
215            other => panic!("Expected MissingField error, got {:?}", other),
216        }
217    }
218
219    #[tokio::test]
220    async fn test_get_password_prelogin_missing_salt() {
221        // Create a mock response missing salt
222        let raw_response = serde_json::json!({
223            "kdfSettings": {
224                "kdfType": KdfType::PBKDF2_SHA256.as_i64(),
225                "iterations": mock_default_pbkdf2_iterations().get(),
226            }
227        });
228
229        let mock = Mock::given(matchers::method("POST"))
230            .and(matchers::path("/identity/accounts/prelogin/password"))
231            .respond_with(ResponseTemplate::new(200).set_body_json(raw_response));
232
233        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
234        let login_client = make_login_client(&mock_server);
235
236        let result = login_client
237            .get_password_prelogin(TEST_EMAIL.to_string())
238            .await;
239
240        assert_eq!(result.unwrap().salt, TEST_EMAIL.to_string());
241    }
242
243    #[tokio::test]
244    async fn test_get_password_prelogin_email_fallback_case_insensitive() {
245        // Create a mock response missing salt
246        let raw_response = serde_json::json!({
247            "kdfSettings": {
248                "kdfType": KdfType::PBKDF2_SHA256.as_i64(),
249                "iterations": mock_default_pbkdf2_iterations().get(),
250            }
251        });
252
253        let case_variant_email = "[email protected]";
254
255        let mock = Mock::given(matchers::method("POST"))
256            .and(matchers::path("/identity/accounts/prelogin/password"))
257            .respond_with(ResponseTemplate::new(200).set_body_json(raw_response));
258
259        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
260        let login_client = make_login_client(&mock_server);
261
262        let result = login_client
263            .get_password_prelogin(case_variant_email.to_string())
264            .await;
265
266        assert_eq!(result.unwrap().salt, TEST_EMAIL.to_string());
267    }
268
269    #[tokio::test]
270    async fn test_get_password_prelogin_api_error() {
271        // Create a mock 500 error
272        let mock = Mock::given(matchers::method("POST"))
273            .and(matchers::path("/identity/accounts/prelogin/password"))
274            .respond_with(ResponseTemplate::new(500));
275
276        let (mock_server, _api_config) = start_api_mock(vec![mock]).await;
277        let login_client = make_login_client(&mock_server);
278
279        let result = login_client
280            .get_password_prelogin(TEST_EMAIL.to_string())
281            .await;
282
283        assert!(result.is_err());
284        match result.unwrap_err() {
285            PasswordPreloginError::Api(bitwarden_core::ApiError::Response(ResponseContent {
286                status,
287                message: _,
288            })) => {
289                assert_eq!(status, reqwest::StatusCode::INTERNAL_SERVER_ERROR);
290            }
291            other => panic!("Expected Api Response error, got {:?}", other),
292        }
293    }
294}