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