bitwarden_auth/login/api/request/
login_api_request.rs1use std::fmt::Debug;
2
3use bitwarden_core::DeviceType;
4use serde::{Deserialize, Serialize, de::DeserializeOwned};
5
6use crate::api::enums::{GrantType, Scope, TwoFactorProvider, scopes_to_string};
7
8pub(crate) const STANDARD_USER_SCOPES: &[Scope] = &[Scope::Api, Scope::OfflineAccess];
10
11#[derive(Serialize, Deserialize, Debug)]
14#[serde(bound = "T: Serialize + DeserializeOwned + Debug")] pub(crate) struct LoginApiRequest<T: Serialize + DeserializeOwned + Debug> {
16 pub client_id: String,
20
21 pub grant_type: GrantType,
24
25 pub scope: String,
27
28 #[serde(rename = "deviceType")]
31 pub device_type: DeviceType,
32
33 #[serde(rename = "deviceIdentifier")]
35 pub device_identifier: String,
36
37 #[serde(rename = "deviceName")]
39 pub device_name: String,
40
41 #[serde(rename = "devicePushToken")]
43 pub device_push_token: Option<String>,
44
45 #[serde(rename = "twoFactorToken")]
48 pub two_factor_token: Option<String>,
49
50 #[serde(rename = "twoFactorProvider")]
52 pub two_factor_provider: Option<TwoFactorProvider>,
53
54 #[serde(rename = "twoFactorRemember")]
56 pub two_factor_remember: Option<bool>,
57
58 #[serde(flatten)]
60 pub login_mechanism_fields: T,
61}
62
63impl<T: Serialize + DeserializeOwned + Debug> LoginApiRequest<T> {
64 pub(crate) fn new(
67 client_id: String,
68 grant_type: GrantType,
69 device_type: DeviceType,
70 device_identifier: String,
71 device_name: String,
72 device_push_token: Option<String>,
73 login_mechanism_fields: T,
74 ) -> Self {
75 Self {
76 client_id,
77 grant_type,
78 scope: scopes_to_string(STANDARD_USER_SCOPES),
79 device_type,
80 device_identifier,
81 device_name,
82 device_push_token,
83 two_factor_token: None,
84 two_factor_provider: None,
85 two_factor_remember: None,
86 login_mechanism_fields,
87 }
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 const TEST_CLIENT_ID: &str = "test-client-id";
97 const TEST_DEVICE_IDENTIFIER: &str = "test-device-identifier";
98 const TEST_DEVICE_NAME: &str = "Test Device";
99 const TEST_DEVICE_PUSH_TOKEN: &str = "test-push-token";
100
101 #[derive(Serialize, Deserialize, Debug, PartialEq)]
103 struct MockLoginMechanismFields {
104 username: String,
105 password: String,
106 }
107
108 #[derive(Serialize, Deserialize, Debug, PartialEq)]
110 struct AlternativeMechanismFields {
111 token: String,
112 }
113
114 #[test]
115 fn test_constructor_creates_proper_defaults() {
116 let mock_fields = MockLoginMechanismFields {
117 username: "[email protected]".to_string(),
118 password: "hashed-password".to_string(),
119 };
120
121 let request = LoginApiRequest::new(
122 TEST_CLIENT_ID.to_string(),
123 GrantType::Password,
124 DeviceType::SDK,
125 TEST_DEVICE_IDENTIFIER.to_string(),
126 TEST_DEVICE_NAME.to_string(),
127 Some(TEST_DEVICE_PUSH_TOKEN.to_string()),
128 mock_fields,
129 );
130
131 assert_eq!(
133 request.scope,
134 scopes_to_string(STANDARD_USER_SCOPES),
135 "Should use standard user scopes"
136 );
137 assert_eq!(request.scope, "api offline_access");
138
139 assert_eq!(request.two_factor_token, None);
141 assert_eq!(request.two_factor_provider, None);
142 assert_eq!(request.two_factor_remember, None);
143
144 assert_eq!(request.client_id, TEST_CLIENT_ID);
146 assert_eq!(request.grant_type, GrantType::Password);
147 assert_eq!(request.device_type, DeviceType::SDK);
148 assert_eq!(request.device_identifier, TEST_DEVICE_IDENTIFIER);
149 assert_eq!(request.device_name, TEST_DEVICE_NAME);
150 assert_eq!(
151 request.device_push_token,
152 Some(TEST_DEVICE_PUSH_TOKEN.to_string())
153 );
154 }
155
156 #[test]
157 fn test_constructor_without_device_push_token() {
158 let mock_fields = MockLoginMechanismFields {
159 username: "[email protected]".to_string(),
160 password: "hashed-password".to_string(),
161 };
162
163 let request = LoginApiRequest::new(
164 TEST_CLIENT_ID.to_string(),
165 GrantType::Password,
166 DeviceType::SDK,
167 TEST_DEVICE_IDENTIFIER.to_string(),
168 TEST_DEVICE_NAME.to_string(),
169 None, mock_fields,
171 );
172
173 assert_eq!(request.device_push_token, None);
174 }
175
176 #[test]
177 fn test_serialization_field_names() {
178 let mock_fields = MockLoginMechanismFields {
179 username: "[email protected]".to_string(),
180 password: "hashed-password".to_string(),
181 };
182
183 let request = LoginApiRequest::new(
184 TEST_CLIENT_ID.to_string(),
185 GrantType::Password,
186 DeviceType::SDK,
187 TEST_DEVICE_IDENTIFIER.to_string(),
188 TEST_DEVICE_NAME.to_string(),
189 Some(TEST_DEVICE_PUSH_TOKEN.to_string()),
190 mock_fields,
191 );
192
193 let serialized =
194 serde_urlencoded::to_string(&request).expect("Failed to serialize LoginApiRequest");
195
196 assert!(
198 serialized.contains("client_id="),
199 "client_id should use snake_case"
200 );
201 assert!(
202 serialized.contains("grant_type="),
203 "grant_type should use snake_case"
204 );
205 assert!(serialized.contains("scope="), "scope should use snake_case");
206
207 assert!(
209 serialized.contains("deviceType="),
210 "device_type should serialize as deviceType"
211 );
212 assert!(
213 serialized.contains("deviceIdentifier="),
214 "device_identifier should serialize as deviceIdentifier"
215 );
216 assert!(
217 serialized.contains("deviceName="),
218 "device_name should serialize as deviceName"
219 );
220 assert!(
221 serialized.contains("devicePushToken="),
222 "device_push_token should serialize as devicePushToken"
223 );
224 assert!(
225 !serialized.contains("device_push_token"),
226 "device_push_token should not appear in snake_case"
227 );
228
229 assert!(
233 !serialized.contains("two_factor_token"),
234 "two_factor_token should not appear in snake_case"
235 );
236 assert!(
237 !serialized.contains("two_factor_provider"),
238 "two_factor_provider should not appear in snake_case"
239 );
240 assert!(
241 !serialized.contains("two_factor_remember"),
242 "two_factor_remember should not appear in snake_case"
243 );
244 assert!(
245 !serialized.contains("twoFactorToken"),
246 "twoFactorToken should be omitted when None"
247 );
248 assert!(
249 !serialized.contains("twoFactorProvider"),
250 "twoFactorProvider should be omitted when None"
251 );
252 assert!(
253 !serialized.contains("twoFactorRemember"),
254 "twoFactorRemember should be omitted when None"
255 );
256
257 assert!(
259 serialized.contains("username="),
260 "username should be included from flattened fields"
261 );
262 assert!(
263 serialized.contains("password="),
264 "password should be included from flattened fields"
265 );
266 }
267
268 #[test]
269 fn test_generic_type_parameter_with_different_types() {
270 let mock_fields = MockLoginMechanismFields {
272 username: "[email protected]".to_string(),
273 password: "password-hash".to_string(),
274 };
275
276 let request1 = LoginApiRequest::new(
277 TEST_CLIENT_ID.to_string(),
278 GrantType::Password,
279 DeviceType::SDK,
280 TEST_DEVICE_IDENTIFIER.to_string(),
281 TEST_DEVICE_NAME.to_string(),
282 None,
283 mock_fields,
284 );
285
286 assert_eq!(request1.login_mechanism_fields.username, "[email protected]");
287 assert_eq!(request1.login_mechanism_fields.password, "password-hash");
288
289 let alternative_fields = AlternativeMechanismFields {
291 token: "some-token".to_string(),
292 };
293
294 let request2 = LoginApiRequest::new(
295 TEST_CLIENT_ID.to_string(),
296 GrantType::Password,
297 DeviceType::SDK,
298 TEST_DEVICE_IDENTIFIER.to_string(),
299 TEST_DEVICE_NAME.to_string(),
300 None,
301 alternative_fields,
302 );
303
304 assert_eq!(request2.login_mechanism_fields.token, "some-token");
305 }
306
307 #[test]
308 fn test_serialization_with_2fa_fields() {
309 let mock_fields = MockLoginMechanismFields {
310 username: "[email protected]".to_string(),
311 password: "hashed-password".to_string(),
312 };
313
314 let mut request = LoginApiRequest::new(
315 TEST_CLIENT_ID.to_string(),
316 GrantType::Password,
317 DeviceType::SDK,
318 TEST_DEVICE_IDENTIFIER.to_string(),
319 TEST_DEVICE_NAME.to_string(),
320 None,
321 mock_fields,
322 );
323
324 request.two_factor_token = Some("2fa-token".to_string());
326 request.two_factor_provider = Some(TwoFactorProvider::Authenticator);
327 request.two_factor_remember = Some(true);
328
329 let serialized =
330 serde_urlencoded::to_string(&request).expect("Failed to serialize LoginApiRequest");
331
332 assert!(
334 serialized.contains("twoFactorToken=2fa-token"),
335 "2FA token should be serialized with camelCase"
336 );
337 assert!(
338 serialized.contains("twoFactorProvider="),
339 "2FA provider should be serialized with camelCase"
340 );
341 assert!(
342 serialized.contains("twoFactorRemember=true"),
343 "2FA remember should be serialized with camelCase"
344 );
345 }
346
347 #[test]
348 fn test_scope_can_be_overridden() {
349 let mock_fields = MockLoginMechanismFields {
350 username: "[email protected]".to_string(),
351 password: "hashed-password".to_string(),
352 };
353
354 let mut request = LoginApiRequest::new(
355 TEST_CLIENT_ID.to_string(),
356 GrantType::Password,
357 DeviceType::SDK,
358 TEST_DEVICE_IDENTIFIER.to_string(),
359 TEST_DEVICE_NAME.to_string(),
360 None,
361 mock_fields,
362 );
363
364 assert_eq!(request.scope, "api offline_access");
366
367 request.scope = "custom_scope".to_string();
369 assert_eq!(request.scope, "custom_scope");
370 }
371}