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