bitwarden_auth/login/api/response/
login_error_api_response.rs

1use bitwarden_core::key_management::MasterPasswordError;
2use serde::Deserialize;
3
4#[derive(Deserialize, PartialEq, Eq, Debug)]
5#[serde(rename_all = "snake_case")]
6pub enum PasswordInvalidGrantError {
7    /// The username or password provided was invalid.
8    InvalidUsernameOrPassword,
9}
10
11// Actual 2fa rejection response for future use in TwoFactorInvalidGrantError
12// {
13//     "error": "invalid_grant",
14//     "error_description": "Two factor required.",
15//     "TwoFactorProviders": [
16//         "1",
17//         "3"
18//     ],
19//     "TwoFactorProviders2": {
20//         "1": {
21//             "Email": "test*****@bitwarden.com"
22//         },
23//         "3": {
24//             "Nfc": true
25//         }
26//     },
27//     "SsoEmail2faSessionToken": "BwSsoEmail2FaSessionToken_stuff",
28//     "Email": "test*****@bitwarden.com",
29//     "MasterPasswordPolicy": {
30//         "MinComplexity": 4,
31//         "RequireLower": false,
32//         "RequireUpper": false,
33//         "RequireNumbers": false,
34//         "RequireSpecial": false,
35//         "EnforceOnLogin": true,
36//         "Object": "masterPasswordPolicy"
37//     }
38// }
39
40// Use untagged so serde tries to deserialize into each variant in order.
41// For "invalid_username_or_password", it tries Password(PasswordInvalidGrantError) first,
42// which succeeds via the #[serde(rename_all = "snake_case")] on PasswordInvalidGrantError.
43// For unknown values like "new_error_code", Password variant fails, so it falls back to
44// Unknown(String).
45#[derive(Deserialize, PartialEq, Eq, Debug)]
46#[serde(untagged)]
47pub enum InvalidGrantError {
48    // Password grant specific errors
49    Password(PasswordInvalidGrantError),
50
51    // TODO: other grant specific errors can go here
52    // TwoFactorRequired(TwoFactorInvalidGrantError)
53    /// Fallback for unknown variants for forward compatibility.
54    /// Must be last in the enum due to untagged deserialization trying variants in order.
55    Unknown(String),
56}
57
58/// Per RFC 6749 Section 5.2, these are the standard error responses for OAuth 2.0 token requests.
59/// <https://datatracker.ietf.org/doc/html/rfc6749#section-5.2>
60#[derive(Deserialize, PartialEq, Eq, Debug)]
61#[serde(rename_all = "snake_case")]
62#[serde(tag = "error")]
63pub enum OAuth2ErrorApiResponse {
64    /// Invalid request error, typically due to missing parameters for a specific
65    /// credential flow. Ex. `password` is required.
66    InvalidRequest {
67        // we need default b/c we don't want deserialization to fail if error_description is
68        // missing. we want it to be None in that case.
69        /// The optional error description for invalid request errors.
70        #[serde(default)]
71        error_description: Option<String>,
72    },
73
74    /// Invalid grant error, typically due to invalid credentials.
75    InvalidGrant {
76        #[serde(default)]
77        /// The optional error description for invalid grant errors.
78        error_description: Option<InvalidGrantError>,
79    },
80
81    /// Invalid client error, typically due to an invalid client secret or client ID.
82    InvalidClient {
83        #[serde(default)]
84        /// The optional error description for invalid client errors.
85        error_description: Option<String>,
86    },
87
88    /// Unauthorized client error, typically due to an unauthorized client.
89    UnauthorizedClient {
90        #[serde(default)]
91        /// The optional error description for unauthorized client errors.
92        error_description: Option<String>,
93    },
94
95    /// Unsupported grant type error, typically due to an unsupported credential flow.
96    UnsupportedGrantType {
97        #[serde(default)]
98        /// The optional error description for unsupported grant type errors.
99        error_description: Option<String>,
100    },
101
102    /// Invalid scope error, typically due to an invalid scope requested.
103    InvalidScope {
104        #[serde(default)]
105        /// The optional error description for invalid scope errors.
106        error_description: Option<String>,
107    },
108
109    /// Invalid target error which is shown if the requested
110    /// resource is invalid, missing, unknown, or malformed.
111    InvalidTarget {
112        #[serde(default)]
113        /// The optional error description for invalid target errors.
114        error_description: Option<String>,
115    },
116}
117
118#[derive(Deserialize, PartialEq, Eq, Debug)]
119// Use untagged so serde tries each variant in order without expecting a wrapper object.
120// This allows us to deserialize directly from { "error": "invalid_grant", ... } instead of
121// requiring { "OAuth2Error": { "error": "invalid_grant", ... } }.
122#[serde(untagged)]
123pub enum LoginErrorApiResponse {
124    OAuth2Error(OAuth2ErrorApiResponse),
125    UnexpectedError(String),
126}
127
128// This is just a utility function so that the ? operator works correctly without manual mapping
129impl From<reqwest::Error> for LoginErrorApiResponse {
130    fn from(value: reqwest::Error) -> Self {
131        Self::UnexpectedError(format!("{value:?}"))
132    }
133}
134
135impl From<reqwest_middleware::Error> for LoginErrorApiResponse {
136    fn from(value: reqwest_middleware::Error) -> Self {
137        Self::UnexpectedError(format!("{value:?}"))
138    }
139}
140
141impl From<MasterPasswordError> for LoginErrorApiResponse {
142    fn from(value: MasterPasswordError) -> Self {
143        Self::UnexpectedError(format!("{value:?}"))
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    // Test constants for common error values
152    const ERROR_INVALID_USERNAME_OR_PASSWORD: &str = "invalid_username_or_password";
153    const ERROR_TYPE_INVALID_GRANT: &str = "invalid_grant";
154
155    mod invalid_grant_error_tests {
156        use serde_json::{from_str, json};
157
158        use super::*;
159
160        #[test]
161        fn password_invalid_username_or_password_deserializes() {
162            let json = format!(r#""{ERROR_INVALID_USERNAME_OR_PASSWORD}""#);
163            let error: InvalidGrantError = from_str(&json).unwrap();
164            assert_eq!(
165                error,
166                InvalidGrantError::Password(PasswordInvalidGrantError::InvalidUsernameOrPassword)
167            );
168        }
169
170        #[test]
171        fn unknown_error_description_maps_to_unknown() {
172            let json = r#""some_new_error_code""#;
173            let error: InvalidGrantError = from_str(json).unwrap();
174            assert_eq!(
175                error,
176                InvalidGrantError::Unknown("some_new_error_code".to_string())
177            );
178        }
179
180        #[test]
181        fn full_invalid_grant_response_with_invalid_username_or_password() {
182            let payload = json!({
183                "error": ERROR_TYPE_INVALID_GRANT,
184                "error_description": ERROR_INVALID_USERNAME_OR_PASSWORD
185            })
186            .to_string();
187
188            let parsed: OAuth2ErrorApiResponse = from_str(&payload).unwrap();
189            match parsed {
190                OAuth2ErrorApiResponse::InvalidGrant { error_description } => {
191                    assert_eq!(
192                        error_description,
193                        Some(InvalidGrantError::Password(
194                            PasswordInvalidGrantError::InvalidUsernameOrPassword
195                        ))
196                    );
197                }
198                _ => panic!("expected invalid_grant"),
199            }
200        }
201
202        #[test]
203        fn invalid_grant_without_error_description_is_allowed() {
204            let payload = json!({ "error": ERROR_TYPE_INVALID_GRANT }).to_string();
205            let parsed: OAuth2ErrorApiResponse = from_str(&payload).unwrap();
206            match parsed {
207                OAuth2ErrorApiResponse::InvalidGrant { error_description } => {
208                    assert!(error_description.is_none());
209                }
210                _ => panic!("expected invalid_grant"),
211            }
212        }
213
214        #[test]
215        fn invalid_grant_null_error_description_becomes_none() {
216            let payload = json!({
217                "error": ERROR_TYPE_INVALID_GRANT,
218                "error_description": null
219            })
220            .to_string();
221
222            let parsed: OAuth2ErrorApiResponse = from_str(&payload).unwrap();
223            match parsed {
224                OAuth2ErrorApiResponse::InvalidGrant { error_description } => {
225                    assert!(error_description.is_none());
226                }
227                _ => panic!("expected invalid_grant"),
228            }
229        }
230
231        #[test]
232        fn invalid_grant_with_unknown_error_description() {
233            let payload = json!({
234                "error": ERROR_TYPE_INVALID_GRANT,
235                "error_description": "brand_new_error_type"
236            })
237            .to_string();
238
239            let parsed: OAuth2ErrorApiResponse = from_str(&payload).unwrap();
240            match parsed {
241                OAuth2ErrorApiResponse::InvalidGrant { error_description } => {
242                    assert_eq!(
243                        error_description,
244                        Some(InvalidGrantError::Unknown(
245                            "brand_new_error_type".to_string()
246                        ))
247                    );
248                }
249                _ => panic!("expected invalid_grant"),
250            }
251        }
252    }
253
254    mod login_error_api_response_tests {
255        use serde_json::{from_str, json};
256
257        use super::*;
258
259        #[test]
260        fn full_server_response_with_error_model_deserializes() {
261            // This is the actual server response format with ErrorModel
262            // which we don't care about but need to handle during deserialization.
263            let payload = json!({
264                "error": ERROR_TYPE_INVALID_GRANT,
265                "error_description": ERROR_INVALID_USERNAME_OR_PASSWORD,
266                "ErrorModel": {
267                    "Message": "Username or password is incorrect. Try again.",
268                    "Object": "error"
269                }
270            })
271            .to_string();
272
273            let parsed: LoginErrorApiResponse = from_str(&payload).unwrap();
274            match parsed {
275                LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant {
276                    error_description,
277                }) => {
278                    assert_eq!(
279                        error_description,
280                        Some(InvalidGrantError::Password(
281                            PasswordInvalidGrantError::InvalidUsernameOrPassword
282                        ))
283                    );
284                }
285                _ => panic!("expected OAuth2Error(InvalidGrant)"),
286            }
287        }
288
289        #[test]
290        fn oauth2_error_without_error_model_deserializes() {
291            let payload = json!({
292                "error": ERROR_TYPE_INVALID_GRANT,
293                "error_description": ERROR_INVALID_USERNAME_OR_PASSWORD
294            })
295            .to_string();
296
297            let parsed: LoginErrorApiResponse = from_str(&payload).unwrap();
298            match parsed {
299                LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant {
300                    error_description,
301                }) => {
302                    assert_eq!(
303                        error_description,
304                        Some(InvalidGrantError::Password(
305                            PasswordInvalidGrantError::InvalidUsernameOrPassword
306                        ))
307                    );
308                }
309                _ => panic!("expected OAuth2Error(InvalidGrant)"),
310            }
311        }
312
313        #[test]
314        fn invalid_request_error_deserializes() {
315            let payload = json!({
316                "error": "invalid_request",
317                "error_description": "password is required"
318            })
319            .to_string();
320
321            let parsed: LoginErrorApiResponse = from_str(&payload).unwrap();
322            match parsed {
323                LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidRequest {
324                    error_description,
325                }) => {
326                    assert_eq!(error_description.as_deref(), Some("password is required"));
327                }
328                _ => panic!("expected OAuth2Error(InvalidRequest)"),
329            }
330        }
331
332        #[test]
333        fn invalid_client_error_deserializes() {
334            let payload = json!({
335                "error": "invalid_client",
336                "error_description": "Invalid client credentials"
337            })
338            .to_string();
339
340            let parsed: LoginErrorApiResponse = from_str(&payload).unwrap();
341            match parsed {
342                LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidClient {
343                    error_description,
344                }) => {
345                    assert_eq!(
346                        error_description.as_deref(),
347                        Some("Invalid client credentials")
348                    );
349                }
350                _ => panic!("expected OAuth2Error(InvalidClient)"),
351            }
352        }
353
354        #[test]
355        fn unauthorized_client_error_deserializes() {
356            let payload = json!({
357                "error": "unauthorized_client"
358            })
359            .to_string();
360
361            let parsed: LoginErrorApiResponse = from_str(&payload).unwrap();
362            match parsed {
363                LoginErrorApiResponse::OAuth2Error(
364                    OAuth2ErrorApiResponse::UnauthorizedClient { error_description },
365                ) => {
366                    assert!(error_description.is_none());
367                }
368                _ => panic!("expected OAuth2Error(UnauthorizedClient)"),
369            }
370        }
371
372        #[test]
373        fn unsupported_grant_type_error_deserializes() {
374            let payload = json!({
375                "error": "unsupported_grant_type",
376                "error_description": "This grant type is not supported"
377            })
378            .to_string();
379
380            let parsed: LoginErrorApiResponse = from_str(&payload).unwrap();
381            match parsed {
382                LoginErrorApiResponse::OAuth2Error(
383                    OAuth2ErrorApiResponse::UnsupportedGrantType { error_description },
384                ) => {
385                    assert_eq!(
386                        error_description.as_deref(),
387                        Some("This grant type is not supported")
388                    );
389                }
390                _ => panic!("expected OAuth2Error(UnsupportedGrantType)"),
391            }
392        }
393
394        #[test]
395        fn invalid_scope_error_deserializes() {
396            let payload = json!({
397                "error": "invalid_scope"
398            })
399            .to_string();
400
401            let parsed: LoginErrorApiResponse = from_str(&payload).unwrap();
402            match parsed {
403                LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidScope {
404                    error_description,
405                }) => {
406                    assert!(error_description.is_none());
407                }
408                _ => panic!("expected OAuth2Error(InvalidScope)"),
409            }
410        }
411
412        #[test]
413        fn invalid_target_error_deserializes() {
414            let payload = json!({
415                "error": "invalid_target",
416                "error_description": "Resource not found"
417            })
418            .to_string();
419
420            let parsed: LoginErrorApiResponse = from_str(&payload).unwrap();
421            match parsed {
422                LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidTarget {
423                    error_description,
424                }) => {
425                    assert_eq!(error_description.as_deref(), Some("Resource not found"));
426                }
427                _ => panic!("expected OAuth2Error(InvalidTarget)"),
428            }
429        }
430
431        #[test]
432        fn missing_or_null_error_description_deserializes_to_none() {
433            // Test both missing field and null value
434            let test_cases = vec![
435                json!({ "error": ERROR_TYPE_INVALID_GRANT }),
436                json!({ "error": ERROR_TYPE_INVALID_GRANT, "error_description": null }),
437            ];
438
439            for payload in test_cases {
440                let parsed: LoginErrorApiResponse = from_str(&payload.to_string()).unwrap();
441                match parsed {
442                    LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant {
443                        error_description,
444                    }) => {
445                        assert!(error_description.is_none());
446                    }
447                    _ => panic!("expected OAuth2Error(InvalidGrant)"),
448                }
449            }
450        }
451
452        #[test]
453        fn unknown_error_description_value_maps_to_unknown() {
454            let payload = json!({
455                "error": ERROR_TYPE_INVALID_GRANT,
456                "error_description": "some_future_error_code"
457            })
458            .to_string();
459
460            let parsed: LoginErrorApiResponse = from_str(&payload).unwrap();
461            match parsed {
462                LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant {
463                    error_description,
464                }) => {
465                    assert_eq!(
466                        error_description,
467                        Some(InvalidGrantError::Unknown(
468                            "some_future_error_code".to_string()
469                        ))
470                    );
471                }
472                _ => panic!("expected OAuth2Error(InvalidGrant)"),
473            }
474        }
475
476        #[test]
477        fn error_with_extra_fields_ignores_them() {
478            let payload = json!({
479                "error": ERROR_TYPE_INVALID_GRANT,
480                "error_description": ERROR_INVALID_USERNAME_OR_PASSWORD,
481                "extra_field": "should be ignored",
482                "another_field": 123,
483                "ErrorModel": {
484                    "Message": "Some message",
485                    "Object": "error"
486                }
487            })
488            .to_string();
489
490            let parsed: LoginErrorApiResponse = from_str(&payload).unwrap();
491            match parsed {
492                LoginErrorApiResponse::OAuth2Error(OAuth2ErrorApiResponse::InvalidGrant {
493                    error_description,
494                }) => {
495                    assert_eq!(
496                        error_description,
497                        Some(InvalidGrantError::Password(
498                            PasswordInvalidGrantError::InvalidUsernameOrPassword
499                        ))
500                    );
501                }
502                _ => panic!("expected OAuth2Error(InvalidGrant)"),
503            }
504        }
505    }
506}