Skip to main content

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        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                // Verify user decryption options
203                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                // Verify master password policy is present
213                assert!(success.master_password_policy.is_some());
214            }
215        }
216    }
217
218    // ==================== Success Tests ====================
219
220    #[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    // ==================== OAuth2 Error Tests ====================
240
241    #[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    // ==================== Error Propagation Tests ====================
332    // These tests verify that errors from lower layers (reqwest, serde) are
333    // properly propagated and converted to LoginErrorApiResponse.
334    // Comprehensive error scenario testing is done in login_via_password_impl.rs
335    // to verify end-to-end error handling through the full stack.
336
337    #[tokio::test]
338    async fn test_send_login_request_network_error() {
339        // Verify that network errors are propagated as UnexpectedError.
340        // This test confirms the error conversion mechanism works.
341        let identity_config = Configuration::new("http://127.0.0.1:1/identity"); // Port 1 will refuse connections
342
343        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    // ==================== Response Parsing Tests ====================
356
357    #[tokio::test]
358    async fn test_send_login_request_incomplete_success_response() {
359        // Verify that responses with missing required fields fail during
360        // deserialization/validation. This tests a different code path than
361        // JSON parsing errors - the JSON is valid but the data is incomplete.
362        let incomplete_response = serde_json::json!({
363            "access_token": "token_without_required_fields"
364            // Missing expires_in, token_type, and other required fields
365        });
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    // ==================== Header Verification Tests ====================
390
391    #[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        // Verify all required headers including content-type
398        .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}