bitwarden_auth/login/api/request/
login_api_request.rs

1use 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
8/// Standard scopes for user token requests: "api offline_access"
9pub(crate) const STANDARD_USER_SCOPES: &[Scope] = &[Scope::Api, Scope::OfflineAccess];
10
11/// The common payload properties to send to the /connect/token endpoint to obtain
12/// tokens for a BW user.
13#[derive(Serialize, Deserialize, Debug)]
14#[serde(bound = "T: Serialize + DeserializeOwned + Debug")] // Ensure T meets trait bounds
15pub(crate) struct LoginApiRequest<T: Serialize + DeserializeOwned + Debug> {
16    // Standard OAuth2 fields
17    /// The client ID for the SDK consuming client.
18    /// Note: snake_case is intentional to match the API expectations.
19    pub client_id: String,
20
21    /// The grant type for the token request.
22    /// Note: snake_case is intentional to match the API expectations.
23    pub grant_type: GrantType,
24
25    /// The space-separated scopes for the token request (e.g., "api offline_access").
26    pub scope: String,
27
28    // Custom fields BW uses for user token requests
29    /// The device type making the request.
30    #[serde(rename = "deviceType")]
31    pub device_type: DeviceType,
32
33    /// The identifier of the device.
34    #[serde(rename = "deviceIdentifier")]
35    pub device_identifier: String,
36
37    /// The name of the device.
38    #[serde(rename = "deviceName")]
39    pub device_name: String,
40
41    /// The push notification registration token for mobile devices.
42    #[serde(rename = "devicePushToken")]
43    pub device_push_token: Option<String>,
44
45    // Two-factor authentication fields
46    /// The two-factor authentication token.
47    #[serde(rename = "twoFactorToken")]
48    pub two_factor_token: Option<String>,
49
50    /// The two-factor authentication provider.
51    #[serde(rename = "twoFactorProvider")]
52    pub two_factor_provider: Option<TwoFactorProvider>,
53
54    /// Whether to remember two-factor authentication on this device.
55    #[serde(rename = "twoFactorRemember")]
56    pub two_factor_remember: Option<bool>,
57
58    // Specific login mechanism fields will go here (e.g., password, SSO, etc)
59    #[serde(flatten)]
60    pub login_mechanism_fields: T,
61}
62
63impl<T: Serialize + DeserializeOwned + Debug> LoginApiRequest<T> {
64    /// Creates a new UserLoginApiRequest with standard scopes ("api offline_access").
65    /// The scope can be overridden after construction if needed for specific auth flows.
66    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    // Test constants
96    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    // Simple test struct for testing the generic type parameter
102    #[derive(Serialize, Deserialize, Debug, PartialEq)]
103    struct MockLoginMechanismFields {
104        username: String,
105        password: String,
106    }
107
108    // Another test struct to verify the generic works with different types
109    #[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        // Verify standard scopes are set correctly
132        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        // Verify 2FA fields default to None
140        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        // Verify all constructor parameters are set correctly
145        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, // No push token
170            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        // Verify OAuth2 standard fields use snake_case
197        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        // Verify Bitwarden custom fields use camelCase
208        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        // Verify 2FA fields use camelCase
230        // Note: These are None, so they won't appear in the serialization
231        // But we can verify they would use camelCase by checking field omission
232        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        // Verify flattened login mechanism fields are present
258        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        // Test with MockLoginMechanismFields
271        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        // Test with AlternativeMechanismFields
290        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        // Manually set 2FA fields to verify they serialize correctly
325        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        // Verify 2FA fields are present and use camelCase
333        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        // Verify default scope
365        assert_eq!(request.scope, "api offline_access");
366
367        // Override scope for a custom auth flow
368        request.scope = "custom_scope".to_string();
369        assert_eq!(request.scope, "custom_scope");
370    }
371}