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 user_agent: Some("test_user_agent".to_owned()),
145 oauth_access_token: None,
146 }
147 }
148
149 fn add_standard_request_matchers(mock_builder: wiremock::MockBuilder) -> wiremock::MockBuilder {
150 mock_builder
151 .and(matchers::header(
152 reqwest::header::ACCEPT.as_str(),
153 "application/json",
154 ))
155 .and(matchers::header(
156 reqwest::header::CACHE_CONTROL.as_str(),
157 "no-store",
158 ))
159 .and(matchers::header(
160 reqwest::header::PRAGMA.as_str(),
161 "no-cache",
162 ))
163 }
164
165 fn create_mock_success_response() -> serde_json::Value {
166 serde_json::json!({
167 "access_token": "test_access_token_abc123",
168 "expires_in": 3600,
169 "token_type": "Bearer",
170 "refresh_token": "test_refresh_token_xyz789",
171 "scope": "api offline_access",
172 "Key": "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=",
173 "PrivateKey": "2.pMS6/icTQABtulw52pq2lg==|XXbxKxDTh+mWiN1HjH2N1w==|Q6PkuT+KX/axrgN9ubD5Ajk2YNwxQkgs3WJM0S0wtG8=",
174 "Kdf": 0,
175 "KdfIterations": 600000,
176 "ForcePasswordReset": false,
177 "MasterPasswordPolicy": {
178 "Object": "masterPasswordPolicy"
179 },
180 "UserDecryptionOptions": {
181 "HasMasterPassword": true,
182 "MasterPasswordUnlock": {
183 "Kdf": {
184 "KdfType": 0,
185 "Iterations": 600000
186 },
187 "MasterKeyEncryptedUserKey": "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=",
188 "Salt": "[email protected]"
189 },
190 "Object": "userDecryptionOptions"
191 }
192 })
193 }
194
195 fn assert_login_success_response(login_response: &LoginResponse) {
196 match login_response {
197 LoginResponse::Authenticated(success) => {
198 assert_eq!(success.access_token, "test_access_token_abc123");
199 assert_eq!(success.token_type, "Bearer");
200 assert_eq!(success.expires_in, 3600);
201 assert_eq!(success.scope, "api offline_access");
202 assert_eq!(
203 success.refresh_token,
204 Some("test_refresh_token_xyz789".to_string())
205 );
206 assert_eq!(success.two_factor_token, None);
207 assert_eq!(success.force_password_reset, Some(false));
208 assert_eq!(success.api_use_key_connector, None);
209
210 let decryption_options = &success.user_decryption_options;
212 assert!(decryption_options.master_password_unlock.is_some());
213 let mp_unlock = decryption_options.master_password_unlock.as_ref().unwrap();
214 assert_eq!(
215 mp_unlock.master_key_wrapped_user_key.to_string(),
216 "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE="
217 );
218 assert_eq!(mp_unlock.salt, "[email protected]");
219
220 assert!(success.master_password_policy.is_some());
222 }
223 }
224 }
225
226 #[tokio::test]
229 async fn test_send_login_request_success() {
230 let success_response = create_mock_success_response();
231 let mock = add_standard_request_matchers(
232 Mock::given(matchers::method("POST")).and(matchers::path("/identity/connect/token")),
233 )
234 .respond_with(ResponseTemplate::new(200).set_body_json(success_response));
235
236 let (mock_server, _) = start_api_mock(vec![mock]).await;
237 let identity_config = create_identity_config(&mock_server);
238 let login_request = create_test_login_request();
239
240 let result = send_login_request(&identity_config, &login_request).await;
241
242 assert!(result.is_ok(), "Expected success response");
243 let login_response = result.unwrap();
244 assert_login_success_response(&login_response);
245 }
246
247 #[tokio::test]
250 async fn test_send_login_request_invalid_credentials() {
251 let error_response = serde_json::json!({
252 "error": "invalid_grant",
253 "error_description": "invalid_username_or_password"
254 });
255
256 let mock = Mock::given(matchers::method("POST"))
257 .and(matchers::path("/identity/connect/token"))
258 .respond_with(ResponseTemplate::new(400).set_body_json(error_response));
259
260 let (mock_server, _) = start_api_mock(vec![mock]).await;
261 let identity_config = create_identity_config(&mock_server);
262 let login_request = create_test_login_request();
263
264 let result = send_login_request(&identity_config, &login_request).await;
265
266 assert!(result.is_err(), "Expected error response");
267 let error = result.unwrap_err();
268 match error {
269 LoginErrorApiResponse::OAuth2Error(oauth_error) => {
270 assert!(matches!(
271 oauth_error,
272 crate::login::api::response::OAuth2ErrorApiResponse::InvalidGrant { .. }
273 ));
274 }
275 _ => panic!("Expected OAuth2Error variant"),
276 }
277 }
278
279 #[tokio::test]
280 async fn test_send_login_request_invalid_request() {
281 let error_response = serde_json::json!({
282 "error": "invalid_request",
283 "error_description": "Missing required parameter: password"
284 });
285
286 let mock = Mock::given(matchers::method("POST"))
287 .and(matchers::path("/identity/connect/token"))
288 .respond_with(ResponseTemplate::new(400).set_body_json(error_response));
289
290 let (mock_server, _) = start_api_mock(vec![mock]).await;
291 let identity_config = create_identity_config(&mock_server);
292 let login_request = create_test_login_request();
293
294 let result = send_login_request(&identity_config, &login_request).await;
295
296 assert!(result.is_err(), "Expected error response");
297 let error = result.unwrap_err();
298 match error {
299 LoginErrorApiResponse::OAuth2Error(oauth_error) => {
300 assert!(matches!(
301 oauth_error,
302 crate::login::api::response::OAuth2ErrorApiResponse::InvalidRequest { .. }
303 ));
304 }
305 _ => panic!("Expected OAuth2Error variant"),
306 }
307 }
308
309 #[tokio::test]
310 async fn test_send_login_request_invalid_client() {
311 let error_response = serde_json::json!({
312 "error": "invalid_client",
313 "error_description": "Client authentication failed"
314 });
315
316 let mock = Mock::given(matchers::method("POST"))
317 .and(matchers::path("/identity/connect/token"))
318 .respond_with(ResponseTemplate::new(401).set_body_json(error_response));
319
320 let (mock_server, _) = start_api_mock(vec![mock]).await;
321 let identity_config = create_identity_config(&mock_server);
322 let login_request = create_test_login_request();
323
324 let result = send_login_request(&identity_config, &login_request).await;
325
326 assert!(result.is_err(), "Expected error response");
327 let error = result.unwrap_err();
328 match error {
329 LoginErrorApiResponse::OAuth2Error(oauth_error) => {
330 assert!(matches!(
331 oauth_error,
332 crate::login::api::response::OAuth2ErrorApiResponse::InvalidClient { .. }
333 ));
334 }
335 _ => panic!("Expected OAuth2Error variant"),
336 }
337 }
338
339 #[tokio::test]
346 async fn test_send_login_request_network_error() {
347 let reqwest_client = reqwest::Client::new();
348 let client = reqwest_middleware::ClientBuilder::new(reqwest_client).build();
349
350 let identity_config = Configuration {
353 base_path: "http://127.0.0.1:1/identity".to_string(), client,
355 user_agent: Some("test_user_agent".to_owned()),
356 oauth_access_token: None,
357 };
358
359 let login_request = create_test_login_request();
360 let result = send_login_request(&identity_config, &login_request).await;
361
362 assert!(result.is_err(), "Expected error due to network failure");
363 match result.unwrap_err() {
364 LoginErrorApiResponse::UnexpectedError(msg) => {
365 assert!(!msg.is_empty(), "Error message should not be empty");
366 }
367 _ => panic!("Expected UnexpectedError for network failure"),
368 }
369 }
370
371 #[tokio::test]
374 async fn test_send_login_request_incomplete_success_response() {
375 let incomplete_response = serde_json::json!({
379 "access_token": "token_without_required_fields"
380 });
382
383 let mock = Mock::given(matchers::method("POST"))
384 .and(matchers::path("/identity/connect/token"))
385 .respond_with(ResponseTemplate::new(200).set_body_json(incomplete_response));
386
387 let (mock_server, _) = start_api_mock(vec![mock]).await;
388 let identity_config = create_identity_config(&mock_server);
389 let login_request = create_test_login_request();
390
391 let result = send_login_request(&identity_config, &login_request).await;
392
393 assert!(
394 result.is_err(),
395 "Expected error due to incomplete success response"
396 );
397 match result.unwrap_err() {
398 LoginErrorApiResponse::UnexpectedError(msg) => {
399 assert!(!msg.is_empty(), "Error message should not be empty");
400 }
401 _ => panic!("Expected UnexpectedError for incomplete response"),
402 }
403 }
404
405 #[tokio::test]
408 async fn test_send_login_request_verifies_headers() {
409 let success_response = create_mock_success_response();
410 let mock = add_standard_request_matchers(
411 Mock::given(matchers::method("POST")).and(matchers::path("/identity/connect/token")),
412 )
413 .and(matchers::header(
415 reqwest::header::CONTENT_TYPE.as_str(),
416 "application/x-www-form-urlencoded",
417 ))
418 .respond_with(ResponseTemplate::new(200).set_body_json(success_response));
419
420 let (mock_server, _) = start_api_mock(vec![mock]).await;
421 let identity_config = create_identity_config(&mock_server);
422 let login_request = create_test_login_request();
423
424 let result = send_login_request(&identity_config, &login_request).await;
425
426 assert!(
427 result.is_ok(),
428 "Request should succeed with correct headers"
429 );
430 }
431}