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