Skip to main content

bitwarden_organization_invite_link/
invite_link_user_client.rs

1use std::sync::Arc;
2
3use bitwarden_api_api::models::{
4    AcceptOrganizationInviteLinkRequestModel, ConfirmOrganizationInviteLinkRequestModel,
5    GetOrganizationInviteLinkStatusRequestModel, GetOrganizationInviteRequestModel,
6    OrganizationInviteLinkValidateEmailDomainRequestModel,
7};
8use bitwarden_core::{
9    FromClient, OrganizationId,
10    client::ApiConfigurations,
11    key_management::{KeySlotIds, PrivateKeySlotId, SymmetricKeySlotId},
12    require,
13};
14use bitwarden_crypto::{
15    CoseKeyThumbprintExt, KeyStore, PrimitiveEncryptable, PublicKey, SpkiPublicKeyBytes,
16    UnsignedSharedKey,
17};
18use bitwarden_encoding::B64;
19use bitwarden_organization_crypto::invite::{Invite, InviteSecret};
20
21use crate::{AcceptInviteLinkError, InviteLinkError, OrganizationInviteLinkStatusView};
22
23/// Client for organization invite link invitee (user) operations: checking link status, validating
24/// email eligibility, and accepting or self-confirming an invite.
25#[bitwarden_ffi::wasm_object]
26#[derive(FromClient)]
27pub struct InviteLinkUserClient {
28    pub(crate) key_store: KeyStore<KeySlotIds>,
29    pub(crate) api_configurations: Arc<ApiConfigurations>,
30}
31
32#[bitwarden_ffi::wasm_export]
33impl InviteLinkUserClient {
34    /// Retrieves the status of an invite link.
35    /// Used to verify basic availability before attempting to accept.
36    pub async fn get_status(
37        &self,
38        organization_id: OrganizationId,
39        code: String,
40    ) -> Result<OrganizationInviteLinkStatusView, InviteLinkError> {
41        let code =
42            uuid::Uuid::parse_str(&code).map_err(|_| InviteLinkError::ParseFailure("code"))?;
43
44        let response = self
45            .api_configurations
46            .api_client
47            .organization_invite_links_api()
48            .get_status(Some(GetOrganizationInviteLinkStatusRequestModel {
49                organization_id: organization_id.into(),
50                code,
51            }))
52            .await?;
53
54        OrganizationInviteLinkStatusView::try_from(response)
55    }
56
57    /// Returns whether the given email address is in the allowed domains for an invite link.
58    pub async fn is_email_allowed(
59        &self,
60        organization_id: OrganizationId,
61        code: String,
62        email: String,
63    ) -> Result<bool, InviteLinkError> {
64        let code =
65            uuid::Uuid::parse_str(&code).map_err(|_| InviteLinkError::ParseFailure("code"))?;
66
67        let response = self
68            .api_configurations
69            .api_client
70            .organization_invite_links_api()
71            .validate_email_domain(Some(
72                OrganizationInviteLinkValidateEmailDomainRequestModel {
73                    organization_id: organization_id.into(),
74                    code,
75                    email,
76                },
77            ))
78            .await?;
79
80        Ok(require!(response.is_allowed))
81    }
82
83    /// Accepts an organization invite for the current user, optionally enrolling into account
84    /// recovery (when `enroll_into_account_recovery` is set) and — when the invite supports
85    /// confirmation — self-confirming.
86    ///
87    /// # Errors
88    /// Server-reported failures are mapped onto typed variants: a `404` from any of the acceptance
89    /// endpoints becomes [`AcceptInviteLinkError::LinkNotFound`], and a `400` validation
90    /// problem becomes the variant matching its error code (or
91    /// [`AcceptInviteLinkError::Unknown`], carrying the server's English description, for an
92    /// unrecognized code). Responses in the legacy error format carry no code and remain
93    /// [`AcceptInviteLinkError::Api`].
94    pub async fn accept_and_optionally_confirm(
95        &self,
96        organization_id: OrganizationId,
97        code: String,
98        invite_secret: InviteSecret,
99        default_collection_name: String,
100        enroll_into_account_recovery: bool,
101    ) -> Result<(), AcceptInviteLinkError> {
102        let code = uuid::Uuid::parse_str(&code)
103            .map_err(|_| AcceptInviteLinkError::ParseFailure("code"))?;
104
105        // When enrolling into account recovery, fetch the organization's public key (which is the
106        // account-recovery public key) from the server.
107        let recovery_public_key = if enroll_into_account_recovery {
108            let response = self
109                .api_configurations
110                .api_client
111                .organizations_api()
112                .get_public_key(&organization_id.to_string())
113                .await?;
114            Some(
115                require!(response.public_key)
116                    .parse::<B64>()
117                    .map_err(|_| AcceptInviteLinkError::ParseFailure("public_key"))?,
118            )
119        } else {
120            None
121        };
122
123        let invite_response = self
124            .api_configurations
125            .api_client
126            .organization_users_api()
127            .get_invite(Some(GetOrganizationInviteRequestModel {
128                organization_id: organization_id.into(),
129                code,
130            }))
131            .await
132            .map_err(AcceptInviteLinkError::from_api_error)?;
133
134        let invite: Invite = require!(invite_response.invite).parse()?;
135
136        // Confine the (non-Send) key store context to a synchronous scope; it produces the owned
137        // request payload consumed after the `.await`s below.
138        let request = {
139            let mut ctx = self.key_store.context();
140
141            // Recover the invite key from the invite secret the invitee holds.
142            let invite_key =
143                invite.unseal_invite_key_with_invite_secret(&invite_secret, &mut ctx)?;
144
145            // Enroll into account recovery when requested. Verify the account-recovery public key
146            // against the organization public-key thumbprint bound into the invite before
147            // enrolling: a substituted recovery key would not match, so the organization key cannot
148            // be captured by an attacker-supplied key. Then encapsulate the user key to it.
149            let reset_password_key = match &recovery_public_key {
150                Some(recovery_public_key) => {
151                    let recovery_public_key =
152                        PublicKey::from_der(&SpkiPublicKeyBytes::from(recovery_public_key))?;
153                    let bound_thumbprint =
154                        invite.get_public_key_thumbprint(invite_key, &mut ctx)?;
155                    if bound_thumbprint != recovery_public_key.thumbprint()? {
156                        return Err(AcceptInviteLinkError::RecoveryKeyMismatch);
157                    }
158                    Some(
159                        UnsignedSharedKey::encapsulate(
160                            SymmetricKeySlotId::User,
161                            &recovery_public_key,
162                            &ctx,
163                        )?
164                        .to_string(),
165                    )
166                }
167                None => None,
168            };
169
170            if invite.supports_confirmation() {
171                // Self-confirm: recover the organization key and encapsulate it to the user.
172                let org_key = invite.unseal_organization_key(invite_key, &mut ctx)?;
173                let user_public_key = ctx.get_public_key(PrivateKeySlotId::UserPrivateKey)?;
174                let org_user_key =
175                    UnsignedSharedKey::encapsulate(org_key, &user_public_key, &ctx)?.to_string();
176                let default_user_collection_name = default_collection_name
177                    .encrypt(&mut ctx, org_key)?
178                    .to_string();
179                PendingPost::Confirm(ConfirmOrganizationInviteLinkRequestModel {
180                    organization_id: organization_id.into(),
181                    code,
182                    org_user_key,
183                    reset_password_key,
184                    default_user_collection_name,
185                })
186            } else {
187                PendingPost::Accept(AcceptOrganizationInviteLinkRequestModel {
188                    organization_id: organization_id.into(),
189                    code,
190                    reset_password_key,
191                })
192            }
193        };
194
195        let organization_users_api = self.api_configurations.api_client.organization_users_api();
196        match request {
197            PendingPost::Confirm(model) => organization_users_api
198                .confirm_invite_link(Some(model))
199                .await
200                .map_err(AcceptInviteLinkError::from_api_error)?,
201            PendingPost::Accept(model) => organization_users_api
202                .accept_invite_link(Some(model))
203                .await
204                .map_err(AcceptInviteLinkError::from_api_error)?,
205        }
206
207        Ok(())
208    }
209}
210
211/// A prepared invite acceptance request, built while the key store context is held and posted once
212/// it has been dropped.
213enum PendingPost {
214    Confirm(ConfirmOrganizationInviteLinkRequestModel),
215    Accept(AcceptOrganizationInviteLinkRequestModel),
216}
217
218#[cfg(test)]
219mod tests {
220    use bitwarden_api_api::{
221        apis::ApiClient,
222        models::{
223            OrganizationInviteLinkSsoResponseModel, OrganizationInviteLinkStatusResponseModel,
224            OrganizationInviteLinkValidateEmailDomainResponseModel,
225            OrganizationInviteResponseModel, OrganizationPublicKeyResponseModel,
226        },
227    };
228    use bitwarden_core::{
229        client::ApiConfigurations, key_management::create_test_crypto_with_user_and_org_key,
230    };
231    use bitwarden_crypto::{
232        PublicKeyEncryptionAlgorithm, SymmetricCryptoKey, SymmetricKeyAlgorithm,
233    };
234
235    use super::*;
236    use crate::validation_problem::tests::response_error;
237
238    fn make_client(org_id: OrganizationId, api_client: ApiClient) -> InviteLinkUserClient {
239        let user_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
240        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
241        let key_store = create_test_crypto_with_user_and_org_key(user_key, org_id, org_key);
242        // Give the store a user private key so the confirmation branch can derive a user public
243        // key.
244        {
245            let mut ctx = key_store.context_mut();
246            let local = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
247            ctx.persist_private_key(local, PrivateKeySlotId::UserPrivateKey)
248                .expect("persisting the user private key should work");
249        }
250        InviteLinkUserClient {
251            key_store,
252            api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
253        }
254    }
255
256    /// Builds an invite + its secret and the organization public key it binds, all consistent with
257    /// the client's org key.
258    fn build_invite(
259        client: &InviteLinkUserClient,
260        org_id: OrganizationId,
261    ) -> (InviteSecret, Invite, B64) {
262        let mut ctx = client.key_store.context();
263        let org_key = SymmetricKeySlotId::Organization(org_id);
264        let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
265        let org_public_key = B64::from(
266            ctx.get_public_key(private_key)
267                .unwrap()
268                .to_der()
269                .unwrap()
270                .as_ref(),
271        );
272        let wrapped = ctx.wrap_private_key(org_key, private_key).unwrap();
273        let (secret, invite) = Invite::make_for_private_key(org_key, &wrapped, &mut ctx).unwrap();
274        (secret, invite, org_public_key)
275    }
276
277    #[tokio::test]
278    async fn accept_and_confirm_succeeds_for_confirmable_invite() {
279        let org_id = OrganizationId::new_v4();
280        // `get_public_key` returns the base64 key held in this cell, and `get_invite` returns the
281        // serialized invite; both are filled after the invite is generated below.
282        let recovery = Arc::new(std::sync::Mutex::new(None::<String>));
283        let invite_cell = Arc::new(std::sync::Mutex::new(None::<String>));
284        let recovery_mock = recovery.clone();
285        let invite_mock = invite_cell.clone();
286        let client = make_client(
287            org_id,
288            ApiClient::new_mocked(move |mock| {
289                mock.organizations_api
290                    .expect_get_public_key()
291                    .returning(move |_id| {
292                        Ok(OrganizationPublicKeyResponseModel {
293                            object: None,
294                            public_key: recovery_mock.lock().unwrap().clone(),
295                        })
296                    })
297                    .once();
298                mock.organization_users_api
299                    .expect_get_invite()
300                    .returning(move |_model| {
301                        Ok(OrganizationInviteResponseModel {
302                            invite: invite_mock.lock().unwrap().clone(),
303                        })
304                    })
305                    .once();
306                mock.organization_users_api
307                    .expect_confirm_invite_link()
308                    .returning(|_model| Ok(()))
309                    .once();
310            }),
311        );
312
313        let (secret, invite, org_public_key) = build_invite(&client, org_id);
314        assert!(invite.supports_confirmation());
315        // The recovery public key returned by the "server" matches the invite's bound org key.
316        *recovery.lock().unwrap() = Some(String::from(&org_public_key));
317        *invite_cell.lock().unwrap() = Some(String::from(&invite));
318
319        client
320            .accept_and_optionally_confirm(
321                org_id,
322                uuid::Uuid::new_v4().to_string(),
323                secret,
324                "Default".to_string(),
325                true,
326            )
327            .await
328            .unwrap();
329    }
330
331    #[tokio::test]
332    async fn accept_without_enrollment_confirms_without_recovery_key() {
333        let org_id = OrganizationId::new_v4();
334        // Without enrollment the recovery key is never fetched, so only `get_invite` and
335        // `confirm_invite_link` run.
336        let invite_cell = Arc::new(std::sync::Mutex::new(None::<String>));
337        let invite_mock = invite_cell.clone();
338        let client = make_client(
339            org_id,
340            ApiClient::new_mocked(move |mock| {
341                mock.organization_users_api
342                    .expect_get_invite()
343                    .returning(move |_model| {
344                        Ok(OrganizationInviteResponseModel {
345                            invite: invite_mock.lock().unwrap().clone(),
346                        })
347                    })
348                    .once();
349                mock.organization_users_api
350                    .expect_confirm_invite_link()
351                    .returning(|_model| Ok(()))
352                    .once();
353            }),
354        );
355
356        let (secret, invite, _org_public_key) = build_invite(&client, org_id);
357        *invite_cell.lock().unwrap() = Some(String::from(&invite));
358        client
359            .accept_and_optionally_confirm(
360                org_id,
361                uuid::Uuid::new_v4().to_string(),
362                secret,
363                "Default".to_string(),
364                false,
365            )
366            .await
367            .unwrap();
368    }
369
370    #[tokio::test]
371    async fn accept_without_confirmation_posts_acceptance() {
372        let org_id = OrganizationId::new_v4();
373        let invite_cell = Arc::new(std::sync::Mutex::new(None::<String>));
374        let invite_mock = invite_cell.clone();
375        let client = make_client(
376            org_id,
377            ApiClient::new_mocked(move |mock| {
378                mock.organization_users_api
379                    .expect_get_invite()
380                    .returning(move |_model| {
381                        Ok(OrganizationInviteResponseModel {
382                            invite: invite_mock.lock().unwrap().clone(),
383                        })
384                    })
385                    .once();
386                mock.organization_users_api
387                    .expect_accept_invite_link()
388                    .returning(|_model| Ok(()))
389                    .once();
390            }),
391        );
392
393        // An invite with confirmation disabled routes to the acceptance branch.
394        let (secret, mut invite, _org_public_key) = build_invite(&client, org_id);
395        invite.disable_confirmation();
396        assert!(!invite.supports_confirmation());
397        *invite_cell.lock().unwrap() = Some(String::from(&invite));
398
399        client
400            .accept_and_optionally_confirm(
401                org_id,
402                uuid::Uuid::new_v4().to_string(),
403                secret,
404                "Default".to_string(),
405                false,
406            )
407            .await
408            .unwrap();
409    }
410
411    #[tokio::test]
412    async fn accept_with_mismatched_recovery_key_fails() {
413        let org_id = OrganizationId::new_v4();
414        let recovery = Arc::new(std::sync::Mutex::new(None::<String>));
415        let invite_cell = Arc::new(std::sync::Mutex::new(None::<String>));
416        let recovery_mock = recovery.clone();
417        let invite_mock = invite_cell.clone();
418        let client = make_client(
419            org_id,
420            ApiClient::new_mocked(move |mock| {
421                mock.organizations_api
422                    .expect_get_public_key()
423                    .returning(move |_id| {
424                        Ok(OrganizationPublicKeyResponseModel {
425                            object: None,
426                            public_key: recovery_mock.lock().unwrap().clone(),
427                        })
428                    })
429                    .once();
430                mock.organization_users_api
431                    .expect_get_invite()
432                    .returning(move |_model| {
433                        Ok(OrganizationInviteResponseModel {
434                            invite: invite_mock.lock().unwrap().clone(),
435                        })
436                    })
437                    .once();
438            }),
439        );
440
441        let (secret, invite, _org_public_key) = build_invite(&client, org_id);
442        *invite_cell.lock().unwrap() = Some(String::from(&invite));
443        // The "server" returns an unrelated public key that must not match the invite's bound
444        // thumbprint.
445        let unrelated_public_key = {
446            let mut ctx = client.key_store.context();
447            let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
448            B64::from(
449                ctx.get_public_key(private_key)
450                    .unwrap()
451                    .to_der()
452                    .unwrap()
453                    .as_ref(),
454            )
455        };
456        *recovery.lock().unwrap() = Some(String::from(&unrelated_public_key));
457
458        let result = client
459            .accept_and_optionally_confirm(
460                org_id,
461                uuid::Uuid::new_v4().to_string(),
462                secret,
463                "Default".to_string(),
464                true,
465            )
466            .await;
467
468        assert!(matches!(
469            result,
470            Err(AcceptInviteLinkError::RecoveryKeyMismatch)
471        ));
472    }
473
474    #[tokio::test]
475    async fn get_status_returns_mapped_view() {
476        let org_id = OrganizationId::new_v4();
477        let client = make_client(
478            org_id,
479            ApiClient::new_mocked(|mock| {
480                mock.organization_invite_links_api
481                    .expect_get_status()
482                    .returning(|_model| {
483                        Ok(OrganizationInviteLinkStatusResponseModel {
484                            object: None,
485                            organization_name: Some("Test Org".to_string()),
486                            links_enabled: Some(true),
487                            seats_available: Some(true),
488                            supports_confirmation: Some(true),
489                            sso: Some(Box::new(OrganizationInviteLinkSsoResponseModel {
490                                object: None,
491                                org_sso_id: Some("sso-id".to_string()),
492                                required: Some(true),
493                            })),
494                        })
495                    })
496                    .once();
497            }),
498        );
499
500        let status = client
501            .get_status(org_id, uuid::Uuid::new_v4().to_string())
502            .await
503            .unwrap();
504
505        assert_eq!(status.organization_name, "Test Org");
506        assert!(status.links_enabled);
507        assert!(status.seats_available);
508        assert!(status.supports_confirmation);
509        let sso = status.sso.expect("sso should be present");
510        assert_eq!(sso.org_sso_id.as_deref(), Some("sso-id"));
511        assert!(sso.required);
512    }
513
514    #[tokio::test]
515    async fn is_email_allowed_returns_is_allowed() {
516        let org_id = OrganizationId::new_v4();
517        let client = make_client(
518            org_id,
519            ApiClient::new_mocked(|mock| {
520                mock.organization_invite_links_api
521                    .expect_validate_email_domain()
522                    .returning(|_model| {
523                        Ok(OrganizationInviteLinkValidateEmailDomainResponseModel {
524                            is_allowed: Some(true),
525                        })
526                    })
527                    .once();
528            }),
529        );
530
531        let allowed = client
532            .is_email_allowed(
533                org_id,
534                uuid::Uuid::new_v4().to_string(),
535                "[email protected]".to_string(),
536            )
537            .await
538            .unwrap();
539
540        assert!(allowed);
541    }
542
543    /// Runs `accept_and_optionally_confirm` against a confirmable invite whose confirm request
544    /// fails with the given status and body.
545    async fn accept_with_confirm_failure(status: u16, body: &'static str) -> AcceptInviteLinkError {
546        let org_id = OrganizationId::new_v4();
547        let invite_cell = Arc::new(std::sync::Mutex::new(None::<String>));
548        let invite_mock = invite_cell.clone();
549        let client = make_client(
550            org_id,
551            ApiClient::new_mocked(move |mock| {
552                mock.organization_users_api
553                    .expect_get_invite()
554                    .returning(move |_model| {
555                        Ok(OrganizationInviteResponseModel {
556                            invite: invite_mock.lock().unwrap().clone(),
557                        })
558                    })
559                    .once();
560                mock.organization_users_api
561                    .expect_confirm_invite_link()
562                    .returning(move |_model| Err(response_error(status, body)))
563                    .once();
564            }),
565        );
566
567        let (secret, invite, _org_public_key) = build_invite(&client, org_id);
568        *invite_cell.lock().unwrap() = Some(String::from(&invite));
569
570        client
571            .accept_and_optionally_confirm(
572                org_id,
573                uuid::Uuid::new_v4().to_string(),
574                secret,
575                "Default".to_string(),
576                false,
577            )
578            .await
579            .unwrap_err()
580    }
581
582    #[tokio::test]
583    async fn confirm_maps_validation_code_to_variant() {
584        let error = accept_with_confirm_failure(
585            400,
586            r#"{"type":"validation_error","status":400,"errors":{"code":[{"type":"already_organization_member","detail":"You're already a member of Acme."}]}}"#,
587        )
588        .await;
589
590        assert!(matches!(
591            error,
592            AcceptInviteLinkError::AlreadyOrganizationMember
593        ));
594    }
595
596    #[tokio::test]
597    async fn confirm_maps_not_found_to_link_not_found() {
598        let error = accept_with_confirm_failure(
599            404,
600            r#"{"message":"Invite link not found.","object":"error"}"#,
601        )
602        .await;
603
604        assert!(matches!(error, AcceptInviteLinkError::LinkNotFound));
605    }
606
607    #[tokio::test]
608    async fn confirm_maps_unmapped_code_to_unknown() {
609        let error = accept_with_confirm_failure(
610            400,
611            r#"{"type":"validation_error","status":400,"errors":{"code":[{"type":"some_future_code","detail":"Something new."}]}}"#,
612        )
613        .await;
614
615        assert!(
616            matches!(error, AcceptInviteLinkError::Unknown(detail) if detail == "Something new.")
617        );
618    }
619
620    #[tokio::test]
621    async fn confirm_keeps_legacy_error_as_api_error() {
622        let error = accept_with_confirm_failure(
623            400,
624            r#"{"message":"You're already a member of Acme.","object":"error"}"#,
625        )
626        .await;
627
628        assert!(matches!(error, AcceptInviteLinkError::Api(_)));
629    }
630
631    #[tokio::test]
632    async fn get_invite_not_found_maps_to_link_not_found() {
633        let org_id = OrganizationId::new_v4();
634        let client = make_client(
635            org_id,
636            ApiClient::new_mocked(|mock| {
637                mock.organization_users_api
638                    .expect_get_invite()
639                    .returning(|_model| Err(response_error(404, "")))
640                    .once();
641            }),
642        );
643        let (secret, _invite, _org_public_key) = build_invite(&client, org_id);
644
645        let result = client
646            .accept_and_optionally_confirm(
647                org_id,
648                uuid::Uuid::new_v4().to_string(),
649                secret,
650                "Default".to_string(),
651                false,
652            )
653            .await;
654
655        assert!(matches!(result, Err(AcceptInviteLinkError::LinkNotFound)));
656    }
657}