Skip to main content

bitwarden_auth/login/api/response/
login_error_api_response.rs

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