bitwarden_auth/login/login_via_password/
password_login_api_request.rs

1use bitwarden_core::key_management::MasterPasswordAuthenticationData;
2use serde::{Deserialize, Serialize};
3
4use crate::{
5    api::enums::GrantType,
6    login::{api::request::LoginApiRequest, login_via_password::PasswordLoginRequest},
7};
8
9/// Internal API request model for logging in via password.
10///
11/// This struct represents the password-specific fields sent to the Identity API's
12/// `/connect/token` endpoint. It is combined with common login fields in [`LoginApiRequest`].
13///
14/// # Field Mappings
15///
16/// The API expects OAuth2-style field names, so we rename our fields during serialization:
17/// - `email` → `"username"` - The user's email address (OAuth2 uses "username")
18/// - `master_password_hash` → `"password"` - The derived master password hash (not the raw
19///   password)
20#[derive(Serialize, Deserialize, Debug)]
21pub(crate) struct PasswordLoginApiRequest {
22    /// Bitwarden user email address.
23    ///
24    /// Serialized as `"username"` to match OAuth2 conventions expected by the Identity API.
25    #[serde(rename = "username")]
26    pub email: String,
27
28    /// Derived master password server authentication hash.
29    /// Serialized as `"password"` to match OAuth2 conventions expected by the Identity API.
30    #[serde(rename = "password")]
31    pub master_password_hash: String,
32}
33
34/// Converts a `PasswordLoginRequest` and `MasterPasswordAuthenticationData` into a
35/// `PasswordLoginApiRequest` for making the API call.
36impl From<(PasswordLoginRequest, MasterPasswordAuthenticationData)>
37    for LoginApiRequest<PasswordLoginApiRequest>
38{
39    fn from(
40        (request, master_password_authentication): (
41            PasswordLoginRequest,
42            MasterPasswordAuthenticationData,
43        ),
44    ) -> Self {
45        // Create the PasswordLoginApiRequest with required fields
46        let password_login_api_request = PasswordLoginApiRequest {
47            email: request.email,
48            master_password_hash: master_password_authentication
49                .master_password_authentication_hash
50                .to_string(),
51        };
52
53        // Create the UserLoginApiRequest with standard scopes configuration and return
54        LoginApiRequest::new(
55            request.login_request.client_id,
56            GrantType::Password,
57            request.login_request.device.device_type,
58            request.login_request.device.device_identifier,
59            request.login_request.device.device_name,
60            request.login_request.device.device_push_token,
61            password_login_api_request,
62        )
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use bitwarden_core::DeviceType;
69    use bitwarden_crypto::Kdf;
70
71    use super::*;
72    use crate::login::{
73        login_via_password::PasswordPreloginResponse,
74        models::{LoginDeviceRequest, LoginRequest},
75    };
76
77    const TEST_EMAIL: &str = "[email protected]";
78    const TEST_PASSWORD: &str = "test-password-123";
79    const TEST_SALT: &str = "test-salt-value";
80    const TEST_CLIENT_ID: &str = "connector";
81    const TEST_DEVICE_IDENTIFIER: &str = "test-device-id";
82    const TEST_DEVICE_NAME: &str = "Test Device";
83    const TEST_DEVICE_PUSH_TOKEN: &str = "test-push-token";
84
85    fn make_test_password_login_request(with_push_token: bool) -> PasswordLoginRequest {
86        PasswordLoginRequest {
87            login_request: LoginRequest {
88                client_id: TEST_CLIENT_ID.to_string(),
89                device: LoginDeviceRequest {
90                    device_type: DeviceType::SDK,
91                    device_identifier: TEST_DEVICE_IDENTIFIER.to_string(),
92                    device_name: TEST_DEVICE_NAME.to_string(),
93                    device_push_token: if with_push_token {
94                        Some(TEST_DEVICE_PUSH_TOKEN.to_string())
95                    } else {
96                        None
97                    },
98                },
99            },
100            email: TEST_EMAIL.to_string(),
101            password: TEST_PASSWORD.to_string(),
102            prelogin_response: PasswordPreloginResponse {
103                kdf: Kdf::default_pbkdf2(),
104                salt: TEST_SALT.to_string(),
105            },
106        }
107    }
108
109    fn make_test_master_password_auth() -> MasterPasswordAuthenticationData {
110        let request = make_test_password_login_request(false);
111        MasterPasswordAuthenticationData::derive(
112            &request.password,
113            &request.prelogin_response.kdf,
114            &request.prelogin_response.salt,
115        )
116        .unwrap()
117    }
118
119    #[test]
120    fn test_password_login_request_conversion() {
121        let request = make_test_password_login_request(true);
122        let master_password_auth = make_test_master_password_auth();
123        let expected_hash = master_password_auth
124            .master_password_authentication_hash
125            .to_string();
126
127        let api_request: LoginApiRequest<PasswordLoginApiRequest> =
128            (request, master_password_auth).into();
129
130        // Verify grant type is set to password
131        assert_eq!(api_request.grant_type, GrantType::Password);
132
133        // Verify standard scopes
134        assert_eq!(api_request.scope, "api offline_access");
135
136        // Verify common fields
137        assert_eq!(api_request.client_id, TEST_CLIENT_ID);
138        assert_eq!(api_request.device_type, DeviceType::SDK);
139        assert_eq!(api_request.device_identifier, TEST_DEVICE_IDENTIFIER);
140        assert_eq!(api_request.device_name, TEST_DEVICE_NAME);
141        assert_eq!(
142            api_request.device_push_token,
143            Some(TEST_DEVICE_PUSH_TOKEN.to_string())
144        );
145
146        // Verify password-specific fields
147        assert_eq!(api_request.login_mechanism_fields.email, TEST_EMAIL);
148        assert_eq!(
149            api_request.login_mechanism_fields.master_password_hash,
150            expected_hash
151        );
152        assert!(
153            !api_request
154                .login_mechanism_fields
155                .master_password_hash
156                .is_empty()
157        );
158    }
159
160    #[test]
161    fn test_password_login_api_request_serialization() {
162        use crate::{api::enums::scopes_to_string, login::api::request::STANDARD_USER_SCOPES};
163
164        // Create a complete API request with all fields
165        let request = make_test_password_login_request(true);
166        let master_password_auth = make_test_master_password_auth();
167
168        let api_request: LoginApiRequest<PasswordLoginApiRequest> =
169            (request, master_password_auth).into();
170
171        // Serialize to URL-encoded form data (as used by the API)
172        let serialized =
173            serde_urlencoded::to_string(&api_request).expect("Failed to serialize LoginApiRequest");
174
175        // Verify OAuth2 standard fields use snake_case
176        // Serialize GrantType::Password to get the actual string value
177        let expected_grant_type =
178            serde_urlencoded::to_string([("grant_type", &GrantType::Password)])
179                .expect("Failed to serialize GrantType");
180        assert!(
181            serialized.contains(&expected_grant_type),
182            "Should contain {expected_grant_type}, got: {serialized}",
183        );
184        assert!(
185            serialized.contains(&format!("client_id={TEST_CLIENT_ID}")),
186            "Should contain client_id, got: {serialized}",
187        );
188        // Verify scope matches the standard scopes (space becomes + in URL encoding)
189        let expected_scope = scopes_to_string(STANDARD_USER_SCOPES).replace(' ', "+");
190        assert!(
191            serialized.contains(&format!("scope={expected_scope}")),
192            "Should contain scope={expected_scope}, got: {serialized}",
193        );
194
195        // Verify password-specific fields use snake_case (OAuth2 convention)
196        // Email is URL-encoded (@ becomes %40)
197        let url_encoded_email = TEST_EMAIL.replace('@', "%40");
198        assert!(
199            serialized.contains(&format!("username={url_encoded_email}")),
200            "Email should be serialized as 'username' per OAuth2 convention, got: {serialized}",
201        );
202        assert!(
203            serialized.contains("password="),
204            "Should contain password field with hash, got: {serialized}",
205        );
206        // Verify the actual hash is present (check for the hash in the serialized output)
207        // The hash may be URL-encoded, so we just verify the field exists with content
208        let password_field_present = serialized
209            .split('&')
210            .any(|pair| pair.starts_with("password=") && pair.len() > "password=".len());
211        assert!(
212            password_field_present,
213            "Should contain password field with hash value, got: {serialized}",
214        );
215
216        // Verify Bitwarden custom fields use camelCase
217        // DeviceType serializes using Debug format (variant name)
218        let expected_device_type = format!("deviceType={:?}", DeviceType::SDK);
219        assert!(
220            serialized.contains(&expected_device_type),
221            "Should contain {expected_device_type}, got: {serialized}",
222        );
223        assert!(
224            serialized.contains(&format!("deviceIdentifier={TEST_DEVICE_IDENTIFIER}")),
225            "Should contain deviceIdentifier field, got: {serialized}",
226        );
227        // Device name is URL-encoded (space becomes +)
228        let url_encoded_device_name = TEST_DEVICE_NAME.replace(' ', "+");
229        assert!(
230            serialized.contains(&format!("deviceName={url_encoded_device_name}")),
231            "Should contain deviceName={url_encoded_device_name}, got: {serialized}",
232        );
233        assert!(
234            serialized.contains(&format!("devicePushToken={TEST_DEVICE_PUSH_TOKEN}")),
235            "Should contain devicePushToken field, got: {serialized}",
236        );
237
238        // Verify optional fields are not present when None
239        assert!(
240            !serialized.contains("twoFactorToken"),
241            "Should not contain twoFactorToken when None, got: {serialized}",
242        );
243        assert!(
244            !serialized.contains("twoFactorProvider"),
245            "Should not contain twoFactorProvider when None, got: {serialized}",
246        );
247        assert!(
248            !serialized.contains("twoFactorRemember"),
249            "Should not contain twoFactorRemember when None, got: {serialized}",
250        );
251    }
252}