bitwarden_auth/login/login_via_password/
password_login_api_request.rs1use 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#[derive(Serialize, Deserialize, Debug)]
21pub(crate) struct PasswordLoginApiRequest {
22 #[serde(rename = "username")]
26 pub email: String,
27
28 #[serde(rename = "password")]
31 pub master_password_hash: String,
32}
33
34impl From<(PasswordLoginRequest, MasterPasswordAuthenticationData)>
37 for LoginApiRequest<PasswordLoginApiRequest>
38{
39 fn from(
40 (request, master_password_authentication): (
41 PasswordLoginRequest,
42 MasterPasswordAuthenticationData,
43 ),
44 ) -> Self {
45 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 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 assert_eq!(api_request.grant_type, GrantType::Password);
132
133 assert_eq!(api_request.scope, "api offline_access");
135
136 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 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 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 let serialized =
173 serde_urlencoded::to_string(&api_request).expect("Failed to serialize LoginApiRequest");
174
175 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 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 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 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 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 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 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}