bitwarden_auth/login/api/
send_login_request.rs

1use serde::{Serialize, de::DeserializeOwned};
2
3use crate::login::{
4    api::{
5        request::LoginApiRequest,
6        response::{LoginErrorApiResponse, LoginSuccessApiResponse},
7    },
8    models::{LoginResponse, LoginSuccessResponse},
9};
10
11/// A common function to send login requests to the Identity connect/token endpoint.
12/// Returns a common success model which has already been converted from the API response,
13/// or a common error model representing the login error which allows for conversion to specific
14/// error types based on the login method used.
15pub(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        // per OAuth2 spec recommendation for token requests (https://www.rfc-editor.org/rfc/rfc6749.html#section-5.1)
26        // we include no-cache headers to prevent browser caching sensitive token requests /
27        // responses.
28        .header(reqwest::header::CACHE_CONTROL, "no-store")
29        .header(reqwest::header::PRAGMA, "no-cache")
30        // If we run into authN issues, it could be due to https://bitwarden.atlassian.net/browse/PM-29974
31        // not being done yet. In the clients repo, we add credentials: "include" for all
32        // non web clients or any self hosted deployments. However, we want to solve that at the
33        // core client layer and not here.
34        // use form to encode as application/x-www-form-urlencoded
35        .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    //! # Testing Philosophy for `send_login_request`
59    //!
60    //! This test module focuses on **HTTP/protocol layer concerns** for the low-level
61    //! `send_login_request` utility function. These tests verify that the HTTP machinery
62    //! works correctly, not comprehensive error scenario testing.
63    //!
64    //! ## What These Tests Cover
65    //!
66    //! 1. **HTTP Success Path** - Response parsing and conversion to domain types
67    //! 2. **OAuth2 Error Discrimination** - Different OAuth2 error types are correctly deserialized
68    //!    and preserved (invalid_grant, invalid_request, invalid_client)
69    //! 3. **Error Propagation Mechanism** - One representative test confirming that lower-layer
70    //!    errors (reqwest, serde) are converted to `LoginErrorApiResponse`
71    //! 4. **Response Validation** - One test for incomplete data (different code path from JSON
72    //!    parsing failures)
73    //! 5. **HTTP Headers** - Verification that required headers are set correctly
74    //!
75    //! ## What These Tests DON'T Cover
76    //!
77    //! **Comprehensive error scenario testing** is intentionally done at the integration
78    //! level in `login_via_password_impl.rs` (and other login method implementations).
79    //! This includes:
80    //! - Multiple network error types (DNS, timeout, connection refused, etc.)
81    //! - Multiple malformed response types (empty body, invalid JSON, wrong content-type, etc.)
82    //! - Unexpected HTTP status codes
83    //! - Domain-specific error conversion and handling
84    //!
85    //! ## Rationale
86    //!
87    //! `send_login_request` is a **shared utility** used by multiple login methods.
88    //! Testing every error permutation here would:
89    //! - Create maintenance burden (updating tests in multiple places)
90    //! - Provide false confidence (many tests covering the same code paths)
91    //! - Obscure the function's actual responsibilities
92    //!
93    //! Instead, we test **what this function is responsible for** (HTTP mechanics and
94    //! error type discrimination), and rely on integration tests to verify end-to-end
95    //! error handling through the complete stack.
96
97    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    // Test constants
109    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    // Simple mock login mechanism fields for testing
114    #[derive(Serialize, serde::Deserialize, Debug)]
115    struct MockLoginFields {
116        username: String,
117        password: String,
118    }
119
120    // ==================== Test Helper Functions ====================
121
122    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                // Verify user decryption options
211                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                // Verify master password policy is present
221                assert!(success.master_password_policy.is_some());
222            }
223        }
224    }
225
226    // ==================== Success Tests ====================
227
228    #[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    // ==================== OAuth2 Error Tests ====================
248
249    #[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    // ==================== Error Propagation Tests ====================
340    // These tests verify that errors from lower layers (reqwest, serde) are
341    // properly propagated and converted to LoginErrorApiResponse.
342    // Comprehensive error scenario testing is done in login_via_password_impl.rs
343    // to verify end-to-end error handling through the full stack.
344
345    #[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        // Verify that network errors are propagated as UnexpectedError.
351        // This test confirms the error conversion mechanism works.
352        let identity_config = Configuration {
353            base_path: "http://127.0.0.1:1/identity".to_string(), // Port 1 will refuse connections
354            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    // ==================== Response Parsing Tests ====================
372
373    #[tokio::test]
374    async fn test_send_login_request_incomplete_success_response() {
375        // Verify that responses with missing required fields fail during
376        // deserialization/validation. This tests a different code path than
377        // JSON parsing errors - the JSON is valid but the data is incomplete.
378        let incomplete_response = serde_json::json!({
379            "access_token": "token_without_required_fields"
380            // Missing expires_in, token_type, and other required fields
381        });
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    // ==================== Header Verification Tests ====================
406
407    #[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        // Verify all required headers including content-type
414        .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}