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