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        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                // Verify user decryption options
209                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                // Verify master password policy is present
219                assert!(success.master_password_policy.is_some());
220            }
221        }
222    }
223
224    // ==================== Success Tests ====================
225
226    #[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    // ==================== OAuth2 Error Tests ====================
246
247    #[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    // ==================== Error Propagation Tests ====================
338    // These tests verify that errors from lower layers (reqwest, serde) are
339    // properly propagated and converted to LoginErrorApiResponse.
340    // Comprehensive error scenario testing is done in login_via_password_impl.rs
341    // to verify end-to-end error handling through the full stack.
342
343    #[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        // Verify that network errors are propagated as UnexpectedError.
349        // This test confirms the error conversion mechanism works.
350        let identity_config = Configuration {
351            base_path: "http://127.0.0.1:1/identity".to_string(), // Port 1 will refuse connections
352            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    // ==================== Response Parsing Tests ====================
368
369    #[tokio::test]
370    async fn test_send_login_request_incomplete_success_response() {
371        // Verify that responses with missing required fields fail during
372        // deserialization/validation. This tests a different code path than
373        // JSON parsing errors - the JSON is valid but the data is incomplete.
374        let incomplete_response = serde_json::json!({
375            "access_token": "token_without_required_fields"
376            // Missing expires_in, token_type, and other required fields
377        });
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    // ==================== Header Verification Tests ====================
402
403    #[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        // Verify all required headers including content-type
410        .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}