bitwarden_auth/send_access/
access_token_request.rs

1#[cfg(feature = "wasm")]
2use tsify::Tsify;
3
4/// Credentials for sending password secured access requests.
5/// Clone auto implements the standard lib's Clone trait, allowing us to create copies of this
6/// struct.
7#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
8#[serde(rename_all = "camelCase", deny_unknown_fields)]
9#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
10pub struct SendPasswordCredentials {
11    /// A Base64-encoded hash of the password protecting the send.
12    pub password_hash_b64: String,
13}
14
15/// Credentials for sending an OTP to the user's email address.
16/// This is used when the send requires email verification with an OTP.
17#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
18#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
19pub struct SendEmailCredentials {
20    /// The email address to which the OTP will be sent.
21    pub email: String,
22}
23
24/// Credentials for getting a send access token using an email and OTP.
25#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
26#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
27pub struct SendEmailOtpCredentials {
28    /// The email address to which the OTP will be sent.
29    pub email: String,
30    /// The one-time password (OTP) that the user has received via email.
31    pub otp: String,
32}
33
34/// The credentials used for send access requests.
35#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
36#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
37// Use untagged so that each variant can be serialized without a type tag.
38// For example, this allows us to serialize the password credentials as just
39// {"password_hash_b64": "value"} instead of {"type": "password", "password_hash_b64": "value"}.
40#[serde(untagged)]
41pub enum SendAccessCredentials {
42    #[allow(missing_docs)]
43    Password(SendPasswordCredentials),
44    // IMPORTANT: EmailOtp must come before Email due to #[serde(untagged)] deserialization.
45    // Serde tries variants in order and stops at the first match. Since EmailOtp has
46    // both "email" and "otp" fields, while Email only has "email", placing Email first
47    // would cause {"email": "...", "otp": "..."} to incorrectly match the Email variant
48    // (ignoring the "otp" field). We always must order untagged enum variants from most specific
49    // (most fields) to least specific (fewest fields).
50    #[allow(missing_docs)]
51    EmailOtp(SendEmailOtpCredentials),
52    #[allow(missing_docs)]
53    Email(SendEmailCredentials),
54}
55
56/// A request structure for requesting a send access token from the API.
57#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
58#[serde(rename_all = "camelCase", deny_unknown_fields)]
59#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
60pub struct SendAccessTokenRequest {
61    /// The id of the send for which the access token is requested.
62    pub send_id: String,
63
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    #[cfg_attr(feature = "wasm", tsify(optional))]
66    /// The optional send access credentials.
67    pub send_access_credentials: Option<SendAccessCredentials>,
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    mod send_access_token_request_tests {
75        use serde_json::{from_str, to_string};
76
77        use super::*;
78
79        #[test]
80        fn deserialize_camelcase_request() {
81            let json = r#"
82        {
83          "sendId": "abc123",
84          "sendAccessCredentials": { "passwordHashB64": "ha$h" }
85        }"#;
86
87            let req: SendAccessTokenRequest = from_str(json).unwrap();
88            assert_eq!(req.send_id, "abc123");
89
90            let creds = req.send_access_credentials.expect("expected Some");
91            match creds {
92                SendAccessCredentials::Password(p) => assert_eq!(p.password_hash_b64, "ha$h"),
93                _ => panic!("expected Password variant"),
94            }
95        }
96
97        #[test]
98        fn serialize_camelcase_request_with_credentials() {
99            let req = SendAccessTokenRequest {
100                send_id: "abc123".into(),
101                send_access_credentials: Some(SendAccessCredentials::Password(
102                    SendPasswordCredentials {
103                        password_hash_b64: "ha$h".into(),
104                    },
105                )),
106            };
107            let json = to_string(&req).unwrap();
108            assert_eq!(
109                json,
110                r#"{"sendId":"abc123","sendAccessCredentials":{"passwordHashB64":"ha$h"}}"#
111            );
112        }
113
114        #[test]
115        fn serialize_omits_optional_credentials_when_none() {
116            let req = SendAccessTokenRequest {
117                send_id: "abc123".into(),
118                send_access_credentials: None,
119            };
120            let json = to_string(&req).unwrap();
121            assert_eq!(json, r#"{"sendId":"abc123"}"#);
122        }
123
124        #[test]
125        fn roundtrip_camel_in_to_camel_out() {
126            let in_json = r#"
127        {
128          "sendId": "abc123",
129          "sendAccessCredentials": { "passwordHashB64": "ha$h" }
130        }"#;
131
132            let req: SendAccessTokenRequest = from_str(in_json).unwrap();
133            let out_json = to_string(&req).unwrap();
134            assert_eq!(
135                out_json,
136                r#"{"sendId":"abc123","sendAccessCredentials":{"passwordHashB64":"ha$h"}}"#
137            );
138        }
139
140        #[test]
141        fn snakecase_top_level_keys_are_rejected() {
142            let json = r#"
143        {
144          "send_id": "abc123",
145          "sendAccessCredentials": { "passwordHashB64": "ha$h" }
146        }"#;
147            let err = from_str::<SendAccessTokenRequest>(json).unwrap_err();
148            let msg = err.to_string();
149            assert!(
150                msg.contains("unknown field") && msg.contains("send_id"),
151                "unexpected: {msg}"
152            );
153        }
154
155        #[test]
156        fn extra_top_level_key_is_rejected() {
157            let json = r#"
158        {
159          "sendId": "abc123",
160          "sendAccessCredentials": { "passwordHashB64": "ha$h" },
161          "extra": "nope"
162        }"#;
163            let err = from_str::<SendAccessTokenRequest>(json).unwrap_err();
164            let msg = err.to_string();
165            assert!(
166                msg.contains("unknown field") && msg.contains("extra"),
167                "unexpected: {msg}"
168            );
169        }
170
171        #[test]
172        fn snakecase_nested_keys_are_rejected() {
173            let json = r#"
174    {
175      "sendId": "abc123",
176      "sendAccessCredentials": { "password_hash_b64": "ha$h" }
177    }"#;
178
179            let err = serde_json::from_str::<SendAccessTokenRequest>(json).unwrap_err();
180            let msg = err.to_string();
181            assert!(
182                msg.contains("did not match any variant"),
183                "unexpected: {msg}"
184            );
185        }
186
187        #[test]
188        fn extra_nested_key_is_rejected() {
189            let json = r#"
190        {
191          "sendId": "abc123",
192          "sendAccessCredentials": {
193            "passwordHashB64": "ha$h",
194            "extra": "nope"
195          }
196        }"#;
197            let err = from_str::<SendAccessTokenRequest>(json).unwrap_err();
198            let msg = err.to_string();
199            assert!(
200                msg.contains("did not match any variant"),
201                "unexpected: {msg}"
202            );
203        }
204    }
205
206    mod send_access_credentials_tests {
207        use super::*;
208
209        mod send_access_password_credentials_tests {
210            use serde_json::{from_str, to_string};
211
212            use super::*;
213
214            #[test]
215            fn deserialize_struct_camelcase_from_ts() {
216                let json = r#"{ "passwordHashB64": "ha$h" }"#;
217                let s: SendPasswordCredentials = from_str(json).unwrap();
218                assert_eq!(s.password_hash_b64, "ha$h");
219            }
220
221            #[test]
222            fn serialize_struct_camelcase_to_wire() {
223                let s = SendPasswordCredentials {
224                    password_hash_b64: "ha$h".into(),
225                };
226                let json = to_string(&s).unwrap();
227                assert_eq!(json, r#"{"passwordHashB64":"ha$h"}"#);
228            }
229
230            #[test]
231            fn roundtrip_struct_camel_in_to_camel_out() {
232                let in_json = r#"{ "passwordHashB64": "ha$h" }"#;
233                let parsed: SendPasswordCredentials = from_str(in_json).unwrap();
234                let out_json = to_string(&parsed).unwrap();
235                assert_eq!(out_json, r#"{"passwordHashB64":"ha$h"}"#);
236            }
237
238            #[test]
239            fn deserialize_enum_variant_from_json() {
240                let json = r#"{"passwordHashB64":"ha$h"}"#;
241                let creds: SendAccessCredentials = from_str(json).unwrap();
242                match creds {
243                    SendAccessCredentials::Password(password_creds) => {
244                        assert_eq!(password_creds.password_hash_b64, "ha$h");
245                    }
246                    _ => panic!("Expected Password variant"),
247                }
248            }
249
250            #[test]
251            fn serialize_enum_variant_to_json() {
252                let creds = SendAccessCredentials::Password(SendPasswordCredentials {
253                    password_hash_b64: "ha$h".into(),
254                });
255                let json = to_string(&creds).unwrap();
256                assert_eq!(json, r#"{"passwordHashB64":"ha$h"}"#);
257            }
258
259            #[test]
260            fn roundtrip_enum_variant_through_json() {
261                let in_json = r#"{"passwordHashB64":"ha$h"}"#;
262                let creds: SendAccessCredentials = from_str(in_json).unwrap();
263                let out_json = to_string(&creds).unwrap();
264                assert_eq!(out_json, r#"{"passwordHashB64":"ha$h"}"#);
265            }
266        }
267
268        mod send_access_email_credentials_tests {
269            use serde_json::{from_str, to_string};
270
271            use super::*;
272
273            #[test]
274            fn deserialize_struct_camelcase_from_ts() {
275                let json = r#"{ "email": "[email protected]" }"#;
276                let s: SendEmailCredentials = from_str(json).unwrap();
277                assert_eq!(s.email, "[email protected]");
278            }
279
280            #[test]
281            fn serialize_struct_camelcase_to_wire() {
282                let s = SendEmailCredentials {
283                    email: "[email protected]".into(),
284                };
285                let json = to_string(&s).unwrap();
286                assert_eq!(json, r#"{"email":"[email protected]"}"#);
287            }
288
289            #[test]
290            fn roundtrip_struct_camel_in_to_camel_out() {
291                let in_json = r#"{ "email": "[email protected]" }"#;
292                let parsed: SendEmailCredentials = from_str(in_json).unwrap();
293                let out_json = to_string(&parsed).unwrap();
294                assert_eq!(out_json, r#"{"email":"[email protected]"}"#);
295            }
296
297            #[test]
298            fn deserialize_enum_variant_from_json() {
299                let json = r#"{"email":"[email protected]"}"#;
300                let creds: SendAccessCredentials = from_str(json).unwrap();
301                match creds {
302                    SendAccessCredentials::Email(email_creds) => {
303                        assert_eq!(email_creds.email, "[email protected]");
304                    }
305                    _ => panic!("Expected Email variant"),
306                }
307            }
308
309            #[test]
310            fn serialize_enum_variant_to_json() {
311                let creds = SendAccessCredentials::Email(SendEmailCredentials {
312                    email: "[email protected]".into(),
313                });
314                let json = to_string(&creds).unwrap();
315                assert_eq!(json, r#"{"email":"[email protected]"}"#);
316            }
317
318            #[test]
319            fn roundtrip_enum_variant_through_json() {
320                let in_json = r#"{"email":"[email protected]"}"#;
321                let creds: SendAccessCredentials = from_str(in_json).unwrap();
322                let out_json = to_string(&creds).unwrap();
323                assert_eq!(out_json, r#"{"email":"[email protected]"}"#);
324            }
325        }
326
327        mod send_access_email_otp_credentials_tests {
328            use serde_json::{from_str, to_string};
329
330            use super::*;
331
332            #[test]
333            fn deserialize_struct_camelcase_from_ts() {
334                let json = r#"{ "email": "[email protected]", "otp": "123456" }"#;
335                let s: SendEmailOtpCredentials = from_str(json).unwrap();
336                assert_eq!(s.email, "[email protected]");
337                assert_eq!(s.otp, "123456");
338            }
339
340            #[test]
341            fn serialize_struct_camelcase_to_wire() {
342                let s = SendEmailOtpCredentials {
343                    email: "[email protected]".into(),
344                    otp: "123456".into(),
345                };
346                let json = to_string(&s).unwrap();
347                assert_eq!(json, r#"{"email":"[email protected]","otp":"123456"}"#);
348            }
349
350            #[test]
351            fn roundtrip_struct_camel_in_to_camel_out() {
352                let in_json = r#"{ "email": "[email protected]", "otp": "123456" }"#;
353                let parsed: SendEmailOtpCredentials = from_str(in_json).unwrap();
354                let out_json = to_string(&parsed).unwrap();
355                assert_eq!(out_json, r#"{"email":"[email protected]","otp":"123456"}"#);
356            }
357
358            #[test]
359            fn deserialize_enum_variant_from_json() {
360                let json = r#"{"email":"[email protected]","otp":"123456"}"#;
361                let creds: SendAccessCredentials = from_str(json).unwrap();
362                match creds {
363                    SendAccessCredentials::EmailOtp(otp_creds) => {
364                        assert_eq!(otp_creds.email, "[email protected]");
365                        assert_eq!(otp_creds.otp, "123456");
366                    }
367                    _ => panic!("Expected EmailOtp variant"),
368                }
369            }
370
371            #[test]
372            fn serialize_enum_variant_to_json() {
373                let creds = SendAccessCredentials::EmailOtp(SendEmailOtpCredentials {
374                    email: "[email protected]".into(),
375                    otp: "123456".into(),
376                });
377                let json = to_string(&creds).unwrap();
378                assert_eq!(json, r#"{"email":"[email protected]","otp":"123456"}"#);
379            }
380
381            #[test]
382            fn roundtrip_enum_variant_through_json() {
383                let in_json = r#"{"email":"[email protected]","otp":"123456"}"#;
384                let creds: SendAccessCredentials = from_str(in_json).unwrap();
385                let out_json = to_string(&creds).unwrap();
386                assert_eq!(out_json, r#"{"email":"[email protected]","otp":"123456"}"#);
387            }
388        }
389    }
390}