Skip to main content

bitwarden_organization_invite_link/
error.rs

1use bitwarden_core::{ApiError, MissingFieldError};
2use bitwarden_crypto::CryptoError;
3use bitwarden_error::bitwarden_error;
4use bitwarden_organization_crypto::invite::InviteKeyBundleError;
5use http::StatusCode;
6use thiserror::Error;
7
8use crate::validation_problem::{ValidationError, ValidationProblem};
9
10/// Errors returned from invite link client operations, except accepting an invite (see
11/// [`AcceptInviteLinkError`]).
12#[bitwarden_error(flat)]
13#[derive(Debug, Error)]
14pub enum InviteLinkError {
15    /// A cryptographic invite operation (creating, unsealing, or recovering the invite) failed.
16    #[error(transparent)]
17    Invite(#[from] InviteKeyBundleError),
18    /// A network request to the server failed.
19    #[error(transparent)]
20    Api(#[from] ApiError),
21    /// A low-level cryptographic operation (key wrapping, encapsulation, or public-key parsing)
22    /// failed.
23    #[error(transparent)]
24    Crypto(#[from] CryptoError),
25    /// A required field was missing from a server response.
26    #[error(transparent)]
27    MissingField(#[from] MissingFieldError),
28    /// A value was present but malformed and could not be parsed.
29    #[error("Failed to parse `{0}`")]
30    ParseFailure(&'static str),
31    /// No allowed domains were specified.
32    #[error("At least one allowed domain is required.")]
33    NoAllowedDomains,
34}
35
36/// Errors returned from [`crate::InviteLinkUserClient::accept_and_optionally_confirm`].
37#[bitwarden_error(flat)]
38#[derive(Debug, Error)]
39pub enum AcceptInviteLinkError {
40    /// A cryptographic invite operation (unsealing the invite key or organization key) failed.
41    #[error(transparent)]
42    Invite(#[from] InviteKeyBundleError),
43    /// A network request to the server failed.
44    #[error(transparent)]
45    Api(#[from] ApiError),
46    /// A low-level cryptographic operation (key encapsulation, encryption, or public-key parsing)
47    /// failed.
48    #[error(transparent)]
49    Crypto(#[from] CryptoError),
50    /// A required field was missing from a server response.
51    #[error(transparent)]
52    MissingField(#[from] MissingFieldError),
53    /// A value was present but malformed and could not be parsed.
54    #[error("Failed to parse `{0}`")]
55    ParseFailure(&'static str),
56    /// The account-recovery public key returned by the server does not match the organization
57    /// public key bound into the invite.
58    #[error("Account recovery public key does not match the invite's bound organization key")]
59    RecoveryKeyMismatch,
60
61    // Server-reported failures when accepting or confirming an invite link, mapped from the
62    // server's error responses by `AcceptInviteLinkError::from_api_error`.
63    /// The invite link does not exist, its code does not match, or the organization is disabled.
64    #[error("The invite link was not found")]
65    LinkNotFound,
66    /// The organization's plan does not support invite links.
67    #[error("The organization's plan does not support invite links")]
68    InviteLinkNotAvailable,
69    /// The invite link does not support self-confirmation.
70    #[error("The invite link does not support confirmation")]
71    InviteLinkConfirmationNotSupported,
72    /// The user must verify their email address before joining the organization.
73    #[error("The user's email address is not verified")]
74    EmailNotVerified,
75    /// The user's email domain is not in the invite link's allowed domains.
76    #[error("The user's email domain is not allowed by the invite link")]
77    EmailDomainNotAllowed,
78    /// Provider users cannot join organizations via invite links.
79    #[error("Provider users cannot join organizations via invite link")]
80    ProviderUsersCannotJoin,
81    /// The user's access to the organization has been revoked.
82    #[error("The user's access to the organization has been revoked")]
83    OrganizationAccessRevoked,
84    /// The user is already a confirmed member of the organization.
85    #[error("The user is already a member of the organization")]
86    AlreadyOrganizationMember,
87    /// The organization has no available seats.
88    #[error("The organization has no available seats")]
89    OrganizationHasNoAvailableSeats,
90    /// The server was unable to add a seat for the user.
91    #[error("Unable to add a seat for the user")]
92    SeatAddFailed,
93    /// The organization requires account recovery enrollment, but no reset password key was sent.
94    #[error("Account recovery enrollment is required")]
95    ResetPasswordKeyRequired,
96    /// The organization's Single Organization policy requires the user to leave all other
97    /// organizations first.
98    #[error("The user must leave all other organizations before joining")]
99    MemberOfAnotherOrganization,
100    /// The user belongs to another organization whose Single Organization policy forbids joining.
101    #[error("A Single Organization policy prevents joining")]
102    SingleOrganizationPolicy,
103    /// The organization requires two-step login, which the user has not enabled.
104    #[error("Two-step login is required to join the organization")]
105    TwoFactorRequiredForMembership,
106    /// The user is already an admin of a free organization.
107    #[error("The user can only be an admin of one free organization")]
108    OnlyOneFreeOrganizationAdminAllowed,
109    /// The server returned a validation error code this SDK version does not recognize. Carries
110    /// the server's human-readable English description of the error, suitable for display; the
111    /// unrecognized code itself is logged.
112    #[error("{0}")]
113    Unknown(String),
114}
115
116/// Displayed for an unrecognized error code when the server provides no `detail` for it.
117const UNKNOWN_ERROR_FALLBACK_DETAIL: &str = "An unexpected error occurred.";
118
119impl AcceptInviteLinkError {
120    /// Maps an error from an invite link endpoint (fetching the invite, accepting, or confirming)
121    /// onto an [`AcceptInviteLinkError`].
122    ///
123    /// A `400` [`ValidationProblem`] maps to the variant matching its error code. Responses in the
124    /// legacy error format carry no code and are left as [`AcceptInviteLinkError::Api`] so existing
125    /// clients that inspect the raw response keep working. A `404` always maps to
126    /// [`AcceptInviteLinkError::LinkNotFound`], regardless of body shape.
127    ///
128    /// Only use this for those endpoints: a `404` is interpreted as the invite link not existing.
129    pub(crate) fn from_api_error(error: ApiError) -> Self {
130        if let ApiError::Response(content) = &error
131            && content.status == StatusCode::NOT_FOUND
132        {
133            return Self::LinkNotFound;
134        }
135
136        match ValidationProblem::from_api_error(&error)
137            .and_then(ValidationProblem::into_first_error)
138        {
139            Some(error) => Self::from_validation_error(error),
140            None => Self::Api(error),
141        }
142    }
143
144    fn from_validation_error(error: ValidationError) -> Self {
145        match error.code.as_str() {
146            "invite_link_not_available" => Self::InviteLinkNotAvailable,
147            "invite_link_confirmation_not_supported" => Self::InviteLinkConfirmationNotSupported,
148            "email_not_verified" => Self::EmailNotVerified,
149            "email_domain_not_allowed" => Self::EmailDomainNotAllowed,
150            "provider_users_cannot_join" => Self::ProviderUsersCannotJoin,
151            "organization_access_revoked" => Self::OrganizationAccessRevoked,
152            "already_organization_member" => Self::AlreadyOrganizationMember,
153            "organization_has_no_available_seats" => Self::OrganizationHasNoAvailableSeats,
154            "seat_add_failed" => Self::SeatAddFailed,
155            "reset_password_key_required" => Self::ResetPasswordKeyRequired,
156            "member_of_another_organization" => Self::MemberOfAnotherOrganization,
157            "single_organization_policy" => Self::SingleOrganizationPolicy,
158            "two_factor_required_for_membership" => Self::TwoFactorRequiredForMembership,
159            "only_one_free_organization_admin_allowed" => Self::OnlyOneFreeOrganizationAdminAllowed,
160            code => {
161                tracing::warn!(code, "Unrecognized invite link error code");
162                Self::Unknown(
163                    error
164                        .detail
165                        .unwrap_or_else(|| UNKNOWN_ERROR_FALLBACK_DETAIL.to_owned()),
166                )
167            }
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::validation_problem::tests::{response_error, validation_problem};
176
177    fn map(status: u16, body: &str) -> AcceptInviteLinkError {
178        AcceptInviteLinkError::from_api_error(response_error(status, body))
179    }
180
181    #[test]
182    fn maps_every_known_code() {
183        let cases = [
184            ("code", "invite_link_not_available"),
185            ("code", "invite_link_confirmation_not_supported"),
186            ("organizationId", "email_not_verified"),
187            ("code", "email_domain_not_allowed"),
188            ("code", "provider_users_cannot_join"),
189            ("code", "organization_access_revoked"),
190            ("code", "already_organization_member"),
191            ("code", "organization_has_no_available_seats"),
192            ("code", "seat_add_failed"),
193            ("resetPasswordKey", "reset_password_key_required"),
194            ("organizationId", "member_of_another_organization"),
195            ("organizationId", "single_organization_policy"),
196            ("organizationId", "two_factor_required_for_membership"),
197            ("organizationId", "only_one_free_organization_admin_allowed"),
198        ];
199
200        for (property, code) in cases {
201            let error = map(400, &validation_problem(property, code));
202            assert!(
203                !matches!(
204                    error,
205                    AcceptInviteLinkError::Unknown(_) | AcceptInviteLinkError::Api(_)
206                ),
207                "`{code}` should map to a typed variant, got {error:?}"
208            );
209        }
210    }
211
212    #[test]
213    fn maps_already_organization_member() {
214        let error = map(
215            400,
216            &validation_problem("code", "already_organization_member"),
217        );
218        assert!(matches!(
219            error,
220            AcceptInviteLinkError::AlreadyOrganizationMember
221        ));
222    }
223
224    #[test]
225    fn maps_email_not_verified() {
226        let error = map(
227            400,
228            &validation_problem("organizationId", "email_not_verified"),
229        );
230        assert!(matches!(error, AcceptInviteLinkError::EmailNotVerified));
231    }
232
233    #[test]
234    fn maps_unmapped_code_to_unknown_with_detail() {
235        let error = map(400, &validation_problem("code", "some_future_code"));
236        assert!(
237            matches!(&error, AcceptInviteLinkError::Unknown(detail) if detail == "Some detail.")
238        );
239        assert_eq!(error.to_string(), "Some detail.");
240    }
241
242    #[test]
243    fn maps_unmapped_code_without_detail_to_unknown_with_fallback() {
244        let error = map(
245            400,
246            r#"{"type":"validation_error","status":400,"errors":{"code":[{"type":"some_future_code"}]}}"#,
247        );
248        assert!(
249            matches!(error, AcceptInviteLinkError::Unknown(detail) if detail == UNKNOWN_ERROR_FALLBACK_DETAIL)
250        );
251    }
252
253    #[test]
254    fn maps_not_found_with_legacy_body() {
255        let error = map(
256            404,
257            r#"{"message":"Invite link not found.","validationErrors":null,"exceptionMessage":null,"exceptionStackTrace":null,"innerExceptionMessage":null,"object":"error"}"#,
258        );
259        assert!(matches!(error, AcceptInviteLinkError::LinkNotFound));
260    }
261
262    #[test]
263    fn maps_not_found_with_empty_body() {
264        assert!(matches!(map(404, ""), AcceptInviteLinkError::LinkNotFound));
265    }
266
267    #[test]
268    fn keeps_legacy_bad_request_as_api_error() {
269        let error = map(
270            400,
271            r#"{"message":"You're already a member of Acme.","validationErrors":null,"object":"error"}"#,
272        );
273        assert!(matches!(
274            error,
275            AcceptInviteLinkError::Api(ApiError::Response(_))
276        ));
277    }
278
279    #[test]
280    fn keeps_model_binding_problem_as_api_error() {
281        // ASP.NET's default validation problem uses plain strings rather than error codes.
282        let error = map(
283            400,
284            r#"{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","status":400,"errors":{"Code":["The Code field is required."]}}"#,
285        );
286        assert!(matches!(
287            error,
288            AcceptInviteLinkError::Api(ApiError::Response(_))
289        ));
290    }
291
292    #[test]
293    fn keeps_other_statuses_as_api_error() {
294        let error = map(
295            500,
296            &validation_problem("code", "already_organization_member"),
297        );
298        assert!(matches!(
299            error,
300            AcceptInviteLinkError::Api(ApiError::Response(_))
301        ));
302    }
303
304    #[test]
305    fn keeps_transport_errors_as_api_error() {
306        let error =
307            AcceptInviteLinkError::from_api_error(ApiError::from(std::io::Error::other("boom")));
308        assert!(matches!(error, AcceptInviteLinkError::Api(ApiError::Io(_))));
309    }
310}