bitwarden_auth/login/api/
send_login_request.rs1use serde::{Serialize, de::DeserializeOwned};
2
3use crate::login::{
4 api::{
5 request::LoginApiRequest,
6 response::{LoginErrorApiResponse, LoginSuccessApiResponse},
7 },
8 models::{LoginResponse, LoginSuccessResponse},
9};
10
11pub(crate) async fn send_login_request(
16 identity_config: &bitwarden_api_identity::apis::configuration::Configuration,
17 api_request: &LoginApiRequest<impl Serialize + DeserializeOwned + std::fmt::Debug>,
18) -> Result<LoginResponse, LoginErrorApiResponse> {
19 let url: String = format!("{}/connect/token", &identity_config.base_path);
20
21 let request: reqwest_middleware::RequestBuilder = identity_config
22 .client
23 .post(url)
24 .header(reqwest::header::ACCEPT, "application/json")
25 .header(reqwest::header::CACHE_CONTROL, "no-store")
29 .header(reqwest::header::PRAGMA, "no-cache")
30 .form(&api_request);
36
37 let response: reqwest::Response = request.send().await?;
38
39 let response_status = response.status();
40
41 if response_status.is_success() {
42 let login_success_api_response: LoginSuccessApiResponse = response.json().await?;
43
44 let login_success_response: LoginSuccessResponse = login_success_api_response.try_into()?;
45
46 let login_response = LoginResponse::Authenticated(login_success_response);
47
48 return Ok(login_response);
49 }
50
51 let login_error_api_response: LoginErrorApiResponse = response.json().await?;
52
53 Err(login_error_api_response)
54}
55
56#[cfg(test)]
57mod tests {
58 use bitwarden_api_identity::apis::configuration::Configuration;
98 use bitwarden_core::DeviceType;
99 use bitwarden_test::start_api_mock;
100 use wiremock::{Mock, ResponseTemplate, matchers};
101
102 use super::*;
103 use crate::{
104 api::enums::GrantType,
105 login::{api::request::LoginApiRequest, models::LoginResponse},
106 };
107
108 const TEST_CLIENT_ID: &str = "test-client";
110 const TEST_DEVICE_ID: &str = "test-device-id";
111 const TEST_DEVICE_NAME: &str = "Test Device";
112
113 #[derive(Serialize, serde::Deserialize, Debug)]
115 struct MockLoginFields {
116 username: String,
117 password: String,
118 }
119
120 fn create_test_login_request() -> LoginApiRequest<MockLoginFields> {
123 LoginApiRequest::new(
124 TEST_CLIENT_ID.to_string(),
125 GrantType::Password,
126 DeviceType::SDK,
127 TEST_DEVICE_ID.to_string(),
128 TEST_DEVICE_NAME.to_string(),
129 None,
130 MockLoginFields {
131 username: "[email protected]".to_string(),
132 password: "hashed-password".to_string(),
133 },
134 )
135 }
136
137 fn create_identity_config(mock_server: &wiremock::MockServer) -> Configuration {
138 let reqwest_client = reqwest::Client::new();
139 let client = reqwest_middleware::ClientBuilder::new(reqwest_client).build();
140
141 Configuration {
142 base_path: format!("http://{}/identity", mock_server.address()),
143 client,
144 }
145 }
146
147 fn add_standard_request_matchers(mock_builder: wiremock::MockBuilder) -> wiremock::MockBuilder {
148 mock_builder
149 .and(matchers::header(
150 reqwest::header::ACCEPT.as_str(),
151 "application/json",
152 ))
153 .and(matchers::header(
154 reqwest::header::CACHE_CONTROL.as_str(),
155 "no-store",
156 ))
157 .and(matchers::header(
158 reqwest::header::PRAGMA.as_str(),
159 "no-cache",
160 ))
161 }
162
163 fn create_mock_success_response() -> serde_json::Value {
164 serde_json::json!({
165 "access_token": "test_access_token_abc123",
166 "expires_in": 3600,
167 "token_type": "Bearer",
168 "refresh_token": "test_refresh_token_xyz789",
169 "scope": "api offline_access",
170 "Key": "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=",
171 "PrivateKey": "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=",
172 "Kdf": 0,
173 "KdfIterations": 600000,
174 "ForcePasswordReset": false,
175 "MasterPasswordPolicy": {
176 "Object": "masterPasswordPolicy"
177 },
178 "UserDecryptionOptions": {
179 "HasMasterPassword": true,
180 "MasterPasswordUnlock": {
181 "Kdf": {
182 "KdfType": 0,
183 "Iterations": 600000
184 },
185 "MasterKeyEncryptedUserKey": "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=",
186 "Salt": "[email protected]"
187 },
188 "Object": "userDecryptionOptions"
189 }
190 })
191 }
192
193 fn assert_login_success_response(login_response: &LoginResponse) {
194 match login_response {
195 LoginResponse::Authenticated(success) => {
196 assert_eq!(success.access_token, "test_access_token_abc123");
197 assert_eq!(success.token_type, "Bearer");
198 assert_eq!(success.expires_in, 3600);
199 assert_eq!(success.scope, "api offline_access");
200 assert_eq!(
201 success.refresh_token,
202 Some("test_refresh_token_xyz789".to_string())
203 );
204 assert_eq!(success.two_factor_token, None);
205 assert_eq!(success.force_password_reset, Some(false));
206 assert_eq!(success.api_use_key_connector, None);
207
208 let decryption_options = &success.user_decryption_options;
210 assert!(decryption_options.master_password_unlock.is_some());
211 let mp_unlock = decryption_options.master_password_unlock.as_ref().unwrap();
212 assert_eq!(
213 mp_unlock.master_key_wrapped_user_key.to_string(),
214 "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE="
215 );
216 assert_eq!(mp_unlock.salt, "[email protected]");
217
218 assert!(success.master_password_policy.is_some());
220 }
221 }
222 }
223
224 #[tokio::test]
227 async fn test_send_login_request_success() {
228 let success_response = create_mock_success_response();
229 let mock = add_standard_request_matchers(
230 Mock::given(matchers::method("POST")).and(matchers::path("/identity/connect/token")),
231 )
232 .respond_with(ResponseTemplate::new(200).set_body_json(success_response));
233
234 let (mock_server, _) = start_api_mock(vec![mock]).await;
235 let identity_config = create_identity_config(&mock_server);
236 let login_request = create_test_login_request();
237
238 let result = send_login_request(&identity_config, &login_request).await;
239
240 assert!(result.is_ok(), "Expected success response");
241 let login_response = result.unwrap();
242 assert_login_success_response(&login_response);
243 }
244
245 #[tokio::test]
248 async fn test_send_login_request_invalid_credentials() {
249 let error_response = serde_json::json!({
250 "error": "invalid_grant",
251 "error_description": "invalid_username_or_password"
252 });
253
254 let mock = Mock::given(matchers::method("POST"))
255 .and(matchers::path("/identity/connect/token"))
256 .respond_with(ResponseTemplate::new(400).set_body_json(error_response));
257
258 let (mock_server, _) = start_api_mock(vec![mock]).await;
259 let identity_config = create_identity_config(&mock_server);
260 let login_request = create_test_login_request();
261
262 let result = send_login_request(&identity_config, &login_request).await;
263
264 assert!(result.is_err(), "Expected error response");
265 let error = result.unwrap_err();
266 match error {
267 LoginErrorApiResponse::OAuth2Error(oauth_error) => {
268 assert!(matches!(
269 oauth_error,
270 crate::login::api::response::OAuth2ErrorApiResponse::InvalidGrant { .. }
271 ));
272 }
273 _ => panic!("Expected OAuth2Error variant"),
274 }
275 }
276
277 #[tokio::test]
278 async fn test_send_login_request_invalid_request() {
279 let error_response = serde_json::json!({
280 "error": "invalid_request",
281 "error_description": "Missing required parameter: password"
282 });
283
284 let mock = Mock::given(matchers::method("POST"))
285 .and(matchers::path("/identity/connect/token"))
286 .respond_with(ResponseTemplate::new(400).set_body_json(error_response));
287
288 let (mock_server, _) = start_api_mock(vec![mock]).await;
289 let identity_config = create_identity_config(&mock_server);
290 let login_request = create_test_login_request();
291
292 let result = send_login_request(&identity_config, &login_request).await;
293
294 assert!(result.is_err(), "Expected error response");
295 let error = result.unwrap_err();
296 match error {
297 LoginErrorApiResponse::OAuth2Error(oauth_error) => {
298 assert!(matches!(
299 oauth_error,
300 crate::login::api::response::OAuth2ErrorApiResponse::InvalidRequest { .. }
301 ));
302 }
303 _ => panic!("Expected OAuth2Error variant"),
304 }
305 }
306
307 #[tokio::test]
308 async fn test_send_login_request_invalid_client() {
309 let error_response = serde_json::json!({
310 "error": "invalid_client",
311 "error_description": "Client authentication failed"
312 });
313
314 let mock = Mock::given(matchers::method("POST"))
315 .and(matchers::path("/identity/connect/token"))
316 .respond_with(ResponseTemplate::new(401).set_body_json(error_response));
317
318 let (mock_server, _) = start_api_mock(vec![mock]).await;
319 let identity_config = create_identity_config(&mock_server);
320 let login_request = create_test_login_request();
321
322 let result = send_login_request(&identity_config, &login_request).await;
323
324 assert!(result.is_err(), "Expected error response");
325 let error = result.unwrap_err();
326 match error {
327 LoginErrorApiResponse::OAuth2Error(oauth_error) => {
328 assert!(matches!(
329 oauth_error,
330 crate::login::api::response::OAuth2ErrorApiResponse::InvalidClient { .. }
331 ));
332 }
333 _ => panic!("Expected OAuth2Error variant"),
334 }
335 }
336
337 #[tokio::test]
344 async fn test_send_login_request_network_error() {
345 let reqwest_client = reqwest::Client::new();
346 let client = reqwest_middleware::ClientBuilder::new(reqwest_client).build();
347
348 let identity_config = Configuration {
351 base_path: "http://127.0.0.1:1/identity".to_string(), client,
353 };
354
355 let login_request = create_test_login_request();
356 let result = send_login_request(&identity_config, &login_request).await;
357
358 assert!(result.is_err(), "Expected error due to network failure");
359 match result.unwrap_err() {
360 LoginErrorApiResponse::UnexpectedError(msg) => {
361 assert!(!msg.is_empty(), "Error message should not be empty");
362 }
363 _ => panic!("Expected UnexpectedError for network failure"),
364 }
365 }
366
367 #[tokio::test]
370 async fn test_send_login_request_incomplete_success_response() {
371 let incomplete_response = serde_json::json!({
375 "access_token": "token_without_required_fields"
376 });
378
379 let mock = Mock::given(matchers::method("POST"))
380 .and(matchers::path("/identity/connect/token"))
381 .respond_with(ResponseTemplate::new(200).set_body_json(incomplete_response));
382
383 let (mock_server, _) = start_api_mock(vec![mock]).await;
384 let identity_config = create_identity_config(&mock_server);
385 let login_request = create_test_login_request();
386
387 let result = send_login_request(&identity_config, &login_request).await;
388
389 assert!(
390 result.is_err(),
391 "Expected error due to incomplete success response"
392 );
393 match result.unwrap_err() {
394 LoginErrorApiResponse::UnexpectedError(msg) => {
395 assert!(!msg.is_empty(), "Error message should not be empty");
396 }
397 _ => panic!("Expected UnexpectedError for incomplete response"),
398 }
399 }
400
401 #[tokio::test]
404 async fn test_send_login_request_verifies_headers() {
405 let success_response = create_mock_success_response();
406 let mock = add_standard_request_matchers(
407 Mock::given(matchers::method("POST")).and(matchers::path("/identity/connect/token")),
408 )
409 .and(matchers::header(
411 reqwest::header::CONTENT_TYPE.as_str(),
412 "application/x-www-form-urlencoded",
413 ))
414 .respond_with(ResponseTemplate::new(200).set_body_json(success_response));
415
416 let (mock_server, _) = start_api_mock(vec![mock]).await;
417 let identity_config = create_identity_config(&mock_server);
418 let login_request = create_test_login_request();
419
420 let result = send_login_request(&identity_config, &login_request).await;
421
422 assert!(
423 result.is_ok(),
424 "Request should succeed with correct headers"
425 );
426 }
427}