Skip to main content

bitwarden_organization_invite_link/
invite_link_client.rs

1use std::sync::Arc;
2
3use bitwarden_api_api::models::{
4    AcceptOrganizationInviteLinkRequestModel, ConfirmOrganizationInviteLinkRequestModel,
5    CreateOrganizationInviteLinkRequestModel, GetOrganizationInviteRequestModel,
6    RefreshOrganizationInviteLinkRequestModel,
7};
8use bitwarden_core::{
9    ApiError, Client, FromClient, MissingFieldError, OrganizationId,
10    client::ApiConfigurations,
11    key_management::{KeySlotIds, PrivateKeySlotId, SymmetricKeySlotId},
12    require,
13};
14use bitwarden_crypto::{
15    CoseKeyThumbprintExt, CryptoError, EncString, KeyStore, PrimitiveEncryptable, PublicKey,
16    SpkiPublicKeyBytes, UnsignedSharedKey,
17};
18use bitwarden_encoding::B64;
19use bitwarden_error::bitwarden_error;
20use bitwarden_organization_crypto::invite::{Invite, InviteKeyBundleError, InviteSecret};
21use thiserror::Error;
22#[cfg(feature = "wasm")]
23use wasm_bindgen::prelude::wasm_bindgen;
24
25use crate::OrganizationInviteLink;
26
27/// Errors returned from [`InviteLinkClient`] operations.
28#[bitwarden_error(flat)]
29#[derive(Debug, Error)]
30pub enum InviteLinkError {
31    /// A cryptographic invite operation (creating, unsealing, or recovering the invite) failed.
32    #[error(transparent)]
33    Invite(#[from] InviteKeyBundleError),
34    /// A network request to the server failed.
35    #[error(transparent)]
36    Api(#[from] ApiError),
37    /// A low-level cryptographic operation (key wrapping, encapsulation, or public-key parsing)
38    /// failed.
39    #[error(transparent)]
40    Crypto(#[from] CryptoError),
41    /// A required field was missing from a server response.
42    #[error(transparent)]
43    MissingField(#[from] MissingFieldError),
44    /// The account-recovery public key returned by the server does not match the organization
45    /// public key bound into the invite.
46    #[error("Account recovery public key does not match the invite's bound organization key")]
47    RecoveryKeyMismatch,
48}
49
50/// Client for organization invite link cryptographic and network operations.
51#[cfg_attr(feature = "wasm", wasm_bindgen)]
52#[derive(FromClient)]
53pub struct InviteLinkClient {
54    pub(crate) key_store: KeyStore<KeySlotIds>,
55    pub(crate) api_configurations: Arc<ApiConfigurations>,
56}
57
58#[cfg_attr(feature = "wasm", wasm_bindgen)]
59impl InviteLinkClient {
60    /// Creates a new organization invite and posts it to the server, returning the full
61    /// [`OrganizationInviteLink`] persisted by the server.
62    ///
63    /// # Security
64    /// Only the sealed invite is posted to the server; the invite secret is never sent. Use
65    /// [`InviteLinkClient::get_invite_secret`] to recover the secret needed to reconstruct the
66    /// invite link.
67    pub async fn create_invite_link(
68        &self,
69        organization_id: OrganizationId,
70        allowed_domains: Vec<String>,
71        supports_confirmation: bool,
72    ) -> Result<OrganizationInviteLink, InviteLinkError> {
73        let invite = self
74            .make_invite(organization_id, supports_confirmation)
75            .await?;
76
77        let response = self
78            .api_configurations
79            .api_client
80            .organization_invite_links_api()
81            .create(
82                organization_id.into(),
83                Some(CreateOrganizationInviteLinkRequestModel {
84                    allowed_domains,
85                    invite: String::from(&invite),
86                    supports_confirmation: invite.supports_confirmation(),
87                }),
88            )
89            .await
90            .map_err(ApiError::from)?;
91
92        OrganizationInviteLink::try_from(response)
93    }
94
95    /// Refresh an existing invite link.
96    /// This generates a new code and secret.
97    pub async fn refresh_invite_link(
98        &self,
99        organization_id: OrganizationId,
100        supports_confirmation: bool,
101    ) -> Result<OrganizationInviteLink, InviteLinkError> {
102        let invite = self
103            .make_invite(organization_id, supports_confirmation)
104            .await?;
105
106        let response = self
107            .api_configurations
108            .api_client
109            .organization_invite_links_api()
110            .refresh(
111                organization_id.into(),
112                Some(RefreshOrganizationInviteLinkRequestModel {
113                    invite: String::from(&invite),
114                    supports_confirmation: invite.supports_confirmation(),
115                }),
116            )
117            .await
118            .map_err(ApiError::from)?;
119
120        OrganizationInviteLink::try_from(response)
121    }
122
123    /// Using the organization key, recovers the [`InviteSecret`] from the invite carried in the
124    /// given [`OrganizationInviteLink`] so an admin can reconstruct the invite link.
125    #[cfg_attr(feature = "wasm", wasm_bindgen(unchecked_return_type = "InviteSecret"))]
126    pub fn get_invite_secret(
127        &self,
128        organization_id: OrganizationId,
129        invite: Invite,
130    ) -> Result<InviteSecret, InviteLinkError> {
131        let mut ctx = self.key_store.context();
132        let org_key = SymmetricKeySlotId::Organization(organization_id);
133        let invite_key = invite.unseal_invite_key_with_organization_key(org_key, &mut ctx)?;
134        let invite_secret = invite.get_invite_secret(invite_key, &mut ctx)?;
135        Ok(invite_secret)
136    }
137
138    /// Accepts an organization invite for the current user, optionally enrolling into account
139    /// recovery (when `enroll_into_account_recovery` is set) and — when the invite supports
140    /// confirmation — self-confirming.
141    pub async fn accept_and_optionally_confirm(
142        &self,
143        organization_id: OrganizationId,
144        code: String,
145        invite_secret: InviteSecret,
146        default_collection_name: String,
147        enroll_into_account_recovery: bool,
148    ) -> Result<(), InviteLinkError> {
149        let code = uuid::Uuid::parse_str(&code).map_err(|_| MissingFieldError("code"))?;
150
151        // When enrolling into account recovery, fetch the organization's public key (which is the
152        // account-recovery public key) from the server.
153        let recovery_public_key = if enroll_into_account_recovery {
154            let response = self
155                .api_configurations
156                .api_client
157                .organizations_api()
158                .get_public_key(&organization_id.to_string())
159                .await
160                .map_err(ApiError::from)?;
161            Some(
162                require!(response.public_key)
163                    .parse::<B64>()
164                    .map_err(|_| MissingFieldError("public_key"))?,
165            )
166        } else {
167            None
168        };
169
170        let invite_response = self
171            .api_configurations
172            .api_client
173            .organization_users_api()
174            .get_invite(Some(GetOrganizationInviteRequestModel {
175                organization_id: organization_id.into(),
176                code,
177            }))
178            .await
179            .map_err(ApiError::from)?;
180
181        let invite: Invite = require!(invite_response.invite).parse()?;
182
183        // Confine the (non-Send) key store context to a synchronous scope; it produces the owned
184        // request payload consumed after the `.await`s below.
185        let request = {
186            let mut ctx = self.key_store.context();
187
188            // Recover the invite key from the invite secret the invitee holds.
189            let invite_key =
190                invite.unseal_invite_key_with_invite_secret(&invite_secret, &mut ctx)?;
191
192            // Enroll into account recovery when requested. Verify the account-recovery public key
193            // against the organization public-key thumbprint bound into the invite before
194            // enrolling: a substituted recovery key would not match, so the organization key cannot
195            // be captured by an attacker-supplied key. Then encapsulate the user key to it.
196            let reset_password_key = match &recovery_public_key {
197                Some(recovery_public_key) => {
198                    let recovery_public_key =
199                        PublicKey::from_der(&SpkiPublicKeyBytes::from(recovery_public_key))?;
200                    let bound_thumbprint =
201                        invite.get_public_key_thumbprint(invite_key, &mut ctx)?;
202                    if bound_thumbprint != recovery_public_key.thumbprint()? {
203                        return Err(InviteLinkError::RecoveryKeyMismatch);
204                    }
205                    Some(
206                        UnsignedSharedKey::encapsulate(
207                            SymmetricKeySlotId::User,
208                            &recovery_public_key,
209                            &ctx,
210                        )?
211                        .to_string(),
212                    )
213                }
214                None => None,
215            };
216
217            if invite.supports_confirmation() {
218                // Self-confirm: recover the organization key and encapsulate it to the user.
219                let org_key = invite.unseal_organization_key(invite_key, &mut ctx)?;
220                let user_public_key = ctx.get_public_key(PrivateKeySlotId::UserPrivateKey)?;
221                let org_user_key =
222                    UnsignedSharedKey::encapsulate(org_key, &user_public_key, &ctx)?.to_string();
223                let default_user_collection_name = default_collection_name
224                    .encrypt(&mut ctx, org_key)?
225                    .to_string();
226                PendingPost::Confirm(ConfirmOrganizationInviteLinkRequestModel {
227                    organization_id: organization_id.into(),
228                    code,
229                    org_user_key,
230                    reset_password_key,
231                    default_user_collection_name,
232                })
233            } else {
234                PendingPost::Accept(AcceptOrganizationInviteLinkRequestModel {
235                    organization_id: organization_id.into(),
236                    code,
237                    reset_password_key,
238                })
239            }
240        };
241
242        let organization_users_api = self.api_configurations.api_client.organization_users_api();
243        match request {
244            PendingPost::Confirm(model) => organization_users_api
245                .confirm_invite_link(Some(model))
246                .await
247                .map_err(ApiError::from)?,
248            PendingPost::Accept(model) => organization_users_api
249                .accept_invite_link(Some(model))
250                .await
251                .map_err(ApiError::from)?,
252        }
253
254        Ok(())
255    }
256
257    /// Helper function to make a new Invite to be included in a request model.
258    async fn make_invite(
259        &self,
260        organization_id: OrganizationId,
261        supports_confirmation: bool,
262    ) -> Result<Invite, InviteLinkError> {
263        let wrapped_private_key_response = self
264            .api_configurations
265            .api_client
266            .organizations_api()
267            .get_private_key(organization_id.into())
268            .await
269            .map_err(ApiError::from)?;
270
271        let wrapped_private_key: EncString =
272            require!(wrapped_private_key_response.private_key).parse()?;
273
274        let mut ctx = self.key_store.context();
275        let org_key = SymmetricKeySlotId::Organization(organization_id);
276        let (_, mut invite) =
277            Invite::make_for_private_key(org_key, &wrapped_private_key, &mut ctx)?;
278
279        // Invites support confirmation by default; disable if not applicable
280        if !supports_confirmation {
281            invite.disable_confirmation();
282        }
283
284        Ok(invite)
285    }
286}
287
288/// A prepared invite acceptance request, built while the key store context is held and posted once
289/// it has been dropped.
290enum PendingPost {
291    Confirm(ConfirmOrganizationInviteLinkRequestModel),
292    Accept(AcceptOrganizationInviteLinkRequestModel),
293}
294
295/// Extension trait that exposes [`InviteLinkClient`] on [`Client`].
296pub trait InviteLinkClientExt {
297    /// Returns an [`InviteLinkClient`]
298    fn invite_link(&self) -> InviteLinkClient;
299}
300
301impl InviteLinkClientExt for Client {
302    fn invite_link(&self) -> InviteLinkClient {
303        InviteLinkClient::from_client(self)
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use bitwarden_api_api::{
310        apis::ApiClient,
311        models::{
312            OrganizationInviteLinkResponseModel, OrganizationInviteResponseModel,
313            OrganizationPrivateKeyResponseModel, OrganizationPublicKeyResponseModel,
314        },
315    };
316    use bitwarden_core::{
317        client::ApiConfigurations, key_management::create_test_crypto_with_user_and_org_key,
318    };
319    use bitwarden_crypto::{
320        PublicKeyEncryptionAlgorithm, SymmetricCryptoKey, SymmetricKeyAlgorithm,
321    };
322
323    use super::*;
324
325    fn make_client(org_id: OrganizationId, api_client: ApiClient) -> InviteLinkClient {
326        let user_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
327        let org_key = SymmetricCryptoKey::make(SymmetricKeyAlgorithm::Aes256CbcHmac);
328        let key_store = create_test_crypto_with_user_and_org_key(user_key, org_id, org_key);
329        // Give the store a user private key so the confirmation branch can derive a user public
330        // key.
331        {
332            let mut ctx = key_store.context_mut();
333            let local = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
334            ctx.persist_private_key(local, PrivateKeySlotId::UserPrivateKey)
335                .expect("persisting the user private key should work");
336        }
337        InviteLinkClient {
338            key_store,
339            api_configurations: Arc::new(ApiConfigurations::from_api_client(api_client)),
340        }
341    }
342
343    /// Wraps a fresh private key under the client's organization key and returns the serialized
344    /// [`EncString`], matching what the server's `get_private_key` endpoint would return.
345    fn wrapped_org_private_key(client: &InviteLinkClient, org_id: OrganizationId) -> String {
346        let mut ctx = client.key_store.context();
347        let org_key = SymmetricKeySlotId::Organization(org_id);
348        let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
349        ctx.wrap_private_key(org_key, private_key)
350            .unwrap()
351            .to_string()
352    }
353
354    /// Builds the response model an invite-links `create`/`refresh` endpoint would return, echoing
355    /// the posted invite back so it can be parsed into an [`OrganizationInviteLink`].
356    fn echo_link_response(
357        org_id: uuid::Uuid,
358        allowed_domains: Vec<String>,
359        invite: String,
360        supports_confirmation: bool,
361    ) -> OrganizationInviteLinkResponseModel {
362        OrganizationInviteLinkResponseModel {
363            object: None,
364            id: Some(uuid::Uuid::new_v4()),
365            code: Some(uuid::Uuid::new_v4()),
366            organization_id: Some(org_id),
367            allowed_domains: Some(allowed_domains),
368            invite: Some(invite),
369            supports_confirmation: Some(supports_confirmation),
370            creation_date: Some("2024-01-01T00:00:00Z".to_string()),
371        }
372    }
373
374    /// Builds an invite + its secret and the organization public key it binds, all consistent with
375    /// the client's org key.
376    fn build_invite(
377        client: &InviteLinkClient,
378        org_id: OrganizationId,
379    ) -> (InviteSecret, Invite, B64) {
380        let mut ctx = client.key_store.context();
381        let org_key = SymmetricKeySlotId::Organization(org_id);
382        let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
383        let org_public_key = B64::from(
384            ctx.get_public_key(private_key)
385                .unwrap()
386                .to_der()
387                .unwrap()
388                .as_ref(),
389        );
390        let wrapped = ctx.wrap_private_key(org_key, private_key).unwrap();
391        let (secret, invite) = Invite::make_for_private_key(org_key, &wrapped, &mut ctx).unwrap();
392        (secret, invite, org_public_key)
393    }
394
395    /// Regenerates the invite-link fixtures used by the WASM integration tests in
396    /// `crates/bitwarden-wasm-internal/integration-tests/tests/org-fixtures.ts`. All five values
397    /// belong together — the invites bind the thumbprint of the public key of the private key they
398    /// wrap — so they must always be copied over as a set.
399    #[tokio::test]
400    #[ignore = "Manual test to generate integration-test fixtures"]
401    async fn generate_integration_test_fixtures() {
402        let org_id: OrganizationId = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap();
403        let core = Client::init_test_account(
404            bitwarden_core::client::test_accounts::test_bitwarden_com_account(),
405        )
406        .await;
407        let client = core.invite_link();
408
409        let mut ctx = client.key_store.context();
410        let org_key = SymmetricKeySlotId::Organization(org_id);
411        let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
412        let public_key = B64::from(
413            ctx.get_public_key(private_key)
414                .unwrap()
415                .to_der()
416                .unwrap()
417                .as_ref(),
418        );
419        let wrapped = ctx.wrap_private_key(org_key, private_key).unwrap();
420        let (secret, invite) = Invite::make_for_private_key(org_key, &wrapped, &mut ctx).unwrap();
421
422        // The same invite with the organization-key envelope stripped, which drives the acceptance
423        // (rather than self-confirmation) branch. It shares the invite secret and the bound
424        // public-key thumbprint, so one secret and one public key serve both invites.
425        let mut no_confirmation = invite.clone();
426        no_confirmation.disable_confirmation();
427        assert!(invite.supports_confirmation() && !no_confirmation.supports_confirmation());
428
429        println!("TEST_ORG_WRAPPED_PRIVATE_KEY = {}", wrapped.to_string());
430        println!("TEST_ORG_PUBLIC_KEY = {public_key}");
431        println!("TEST_INVITE = {}", String::from(&invite));
432        println!(
433            "TEST_INVITE_NO_CONFIRMATION = {}",
434            String::from(&no_confirmation)
435        );
436        println!("TEST_INVITE_SECRET = {}", String::from(&secret));
437    }
438
439    #[tokio::test]
440    async fn create_invite_link_posts_and_returns_link_without_confirmation() {
441        let org_id = OrganizationId::new_v4();
442        let wrapped = Arc::new(std::sync::Mutex::new(None::<String>));
443        let for_mock = wrapped.clone();
444        let client = make_client(
445            org_id,
446            ApiClient::new_mocked(move |mock| {
447                mock.organizations_api
448                    .expect_get_private_key()
449                    .returning(move |_org| {
450                        Ok(OrganizationPrivateKeyResponseModel {
451                            object: None,
452                            private_key: for_mock.lock().unwrap().clone(),
453                        })
454                    })
455                    .once();
456                mock.organization_invite_links_api
457                    .expect_create()
458                    .returning(|org, model| {
459                        let model = model.unwrap();
460                        Ok(echo_link_response(
461                            org,
462                            model.allowed_domains,
463                            model.invite,
464                            model.supports_confirmation,
465                        ))
466                    })
467                    .once();
468            }),
469        );
470        *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id));
471
472        let link = client
473            .create_invite_link(org_id, vec!["example.com".to_string()], false)
474            .await
475            .unwrap();
476
477        assert_eq!(link.allowed_domains, vec!["example.com".to_string()]);
478        assert!(!String::from(&link.invite).is_empty());
479        assert!(!link.invite.supports_confirmation());
480    }
481
482    #[tokio::test]
483    async fn create_invite_link_posts_and_returns_link_with_confirmation() {
484        let org_id = OrganizationId::new_v4();
485        let wrapped = Arc::new(std::sync::Mutex::new(None::<String>));
486        let for_mock = wrapped.clone();
487        let client = make_client(
488            org_id,
489            ApiClient::new_mocked(move |mock| {
490                mock.organizations_api
491                    .expect_get_private_key()
492                    .returning(move |_org| {
493                        Ok(OrganizationPrivateKeyResponseModel {
494                            object: None,
495                            private_key: for_mock.lock().unwrap().clone(),
496                        })
497                    })
498                    .once();
499                mock.organization_invite_links_api
500                    .expect_create()
501                    .returning(|org, model| {
502                        let model = model.unwrap();
503                        Ok(echo_link_response(
504                            org,
505                            model.allowed_domains,
506                            model.invite,
507                            model.supports_confirmation,
508                        ))
509                    })
510                    .once();
511            }),
512        );
513        *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id));
514
515        let link = client
516            .create_invite_link(org_id, vec!["example.com".to_string()], true)
517            .await
518            .unwrap();
519
520        assert_eq!(link.allowed_domains, vec!["example.com".to_string()]);
521        assert!(!String::from(&link.invite).is_empty());
522        assert!(link.invite.supports_confirmation());
523    }
524
525    #[tokio::test]
526    async fn create_invite_link_two_calls_produce_different_invites() {
527        let org_id = OrganizationId::new_v4();
528        let wrapped = Arc::new(std::sync::Mutex::new(None::<String>));
529        let for_mock = wrapped.clone();
530        let client = make_client(
531            org_id,
532            ApiClient::new_mocked(move |mock| {
533                mock.organizations_api
534                    .expect_get_private_key()
535                    .returning(move |_org| {
536                        Ok(OrganizationPrivateKeyResponseModel {
537                            object: None,
538                            private_key: for_mock.lock().unwrap().clone(),
539                        })
540                    })
541                    .times(2);
542                mock.organization_invite_links_api
543                    .expect_create()
544                    .returning(|org, model| {
545                        let model = model.unwrap();
546                        Ok(echo_link_response(
547                            org,
548                            model.allowed_domains,
549                            model.invite,
550                            model.supports_confirmation,
551                        ))
552                    })
553                    .times(2);
554            }),
555        );
556        *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id));
557
558        let link1 = client
559            .create_invite_link(org_id, vec![], false)
560            .await
561            .unwrap();
562        let link2 = client
563            .create_invite_link(org_id, vec![], false)
564            .await
565            .unwrap();
566
567        assert_ne!(String::from(&link1.invite), String::from(&link2.invite));
568    }
569
570    #[tokio::test]
571    async fn create_invite_link_with_unknown_organization_id_fails() {
572        let org_id = OrganizationId::new_v4();
573        let other_org_id = OrganizationId::new_v4();
574        let wrapped = Arc::new(std::sync::Mutex::new(None::<String>));
575        let for_mock = wrapped.clone();
576        let client = make_client(
577            org_id,
578            ApiClient::new_mocked(move |mock| {
579                mock.organizations_api
580                    .expect_get_private_key()
581                    .returning(move |_org| {
582                        Ok(OrganizationPrivateKeyResponseModel {
583                            object: None,
584                            private_key: for_mock.lock().unwrap().clone(),
585                        })
586                    })
587                    .once();
588            }),
589        );
590        // The wrapped key is bound to the client's own org key; unwrapping it under a different
591        // organization's key slot (which is absent from the store) must fail.
592        *wrapped.lock().unwrap() = Some(wrapped_org_private_key(&client, org_id));
593
594        let result = client.create_invite_link(other_org_id, vec![], false).await;
595
596        assert!(matches!(result, Err(InviteLinkError::Invite(_))));
597    }
598
599    #[tokio::test]
600    async fn create_invite_link_surfaces_api_errors() {
601        let org_id = OrganizationId::new_v4();
602        let client = make_client(
603            org_id,
604            ApiClient::new_mocked(|mock| {
605                mock.organizations_api
606                    .expect_get_private_key()
607                    .returning(|_org| Err(std::io::Error::other("boom").into()));
608            }),
609        );
610
611        let result = client.create_invite_link(org_id, vec![], false).await;
612
613        assert!(matches!(result, Err(InviteLinkError::Api(_))));
614    }
615
616    #[tokio::test]
617    async fn get_invite_secret_round_trips_to_the_invite_secret() {
618        let org_id = OrganizationId::new_v4();
619        let client = make_client(org_id, ApiClient::new_mocked(|_| {}));
620
621        // A valid invite for the org must yield a non-empty secret recovered via the org key.
622        let (_secret, invite, _org_public_key) = build_invite(&client, org_id);
623        let secret = client.get_invite_secret(org_id, invite).unwrap();
624        assert!(!String::from(&secret).is_empty());
625    }
626
627    #[tokio::test]
628    async fn accept_and_confirm_succeeds_for_confirmable_invite() {
629        let org_id = OrganizationId::new_v4();
630        // `get_public_key` returns the base64 key held in this cell, and `get_invite` returns the
631        // serialized invite; both are filled after the invite is generated below.
632        let recovery = Arc::new(std::sync::Mutex::new(None::<String>));
633        let invite_cell = Arc::new(std::sync::Mutex::new(None::<String>));
634        let recovery_mock = recovery.clone();
635        let invite_mock = invite_cell.clone();
636        let client = make_client(
637            org_id,
638            ApiClient::new_mocked(move |mock| {
639                mock.organizations_api
640                    .expect_get_public_key()
641                    .returning(move |_id| {
642                        Ok(OrganizationPublicKeyResponseModel {
643                            object: None,
644                            public_key: recovery_mock.lock().unwrap().clone(),
645                        })
646                    })
647                    .once();
648                mock.organization_users_api
649                    .expect_get_invite()
650                    .returning(move |_model| {
651                        Ok(OrganizationInviteResponseModel {
652                            invite: invite_mock.lock().unwrap().clone(),
653                        })
654                    })
655                    .once();
656                mock.organization_users_api
657                    .expect_confirm_invite_link()
658                    .returning(|_model| Ok(()))
659                    .once();
660            }),
661        );
662
663        let (secret, invite, org_public_key) = build_invite(&client, org_id);
664        assert!(invite.supports_confirmation());
665        // The recovery public key returned by the "server" matches the invite's bound org key.
666        *recovery.lock().unwrap() = Some(String::from(&org_public_key));
667        *invite_cell.lock().unwrap() = Some(String::from(&invite));
668
669        client
670            .accept_and_optionally_confirm(
671                org_id,
672                uuid::Uuid::new_v4().to_string(),
673                secret,
674                "Default".to_string(),
675                true,
676            )
677            .await
678            .unwrap();
679    }
680
681    #[tokio::test]
682    async fn accept_without_enrollment_confirms_without_recovery_key() {
683        let org_id = OrganizationId::new_v4();
684        // Without enrollment the recovery key is never fetched, so only `get_invite` and
685        // `confirm_invite_link` run.
686        let invite_cell = Arc::new(std::sync::Mutex::new(None::<String>));
687        let invite_mock = invite_cell.clone();
688        let client = make_client(
689            org_id,
690            ApiClient::new_mocked(move |mock| {
691                mock.organization_users_api
692                    .expect_get_invite()
693                    .returning(move |_model| {
694                        Ok(OrganizationInviteResponseModel {
695                            invite: invite_mock.lock().unwrap().clone(),
696                        })
697                    })
698                    .once();
699                mock.organization_users_api
700                    .expect_confirm_invite_link()
701                    .returning(|_model| Ok(()))
702                    .once();
703            }),
704        );
705
706        let (secret, invite, _org_public_key) = build_invite(&client, org_id);
707        *invite_cell.lock().unwrap() = Some(String::from(&invite));
708        client
709            .accept_and_optionally_confirm(
710                org_id,
711                uuid::Uuid::new_v4().to_string(),
712                secret,
713                "Default".to_string(),
714                false,
715            )
716            .await
717            .unwrap();
718    }
719
720    #[tokio::test]
721    async fn accept_without_confirmation_posts_acceptance() {
722        let org_id = OrganizationId::new_v4();
723        let invite_cell = Arc::new(std::sync::Mutex::new(None::<String>));
724        let invite_mock = invite_cell.clone();
725        let client = make_client(
726            org_id,
727            ApiClient::new_mocked(move |mock| {
728                mock.organization_users_api
729                    .expect_get_invite()
730                    .returning(move |_model| {
731                        Ok(OrganizationInviteResponseModel {
732                            invite: invite_mock.lock().unwrap().clone(),
733                        })
734                    })
735                    .once();
736                mock.organization_users_api
737                    .expect_accept_invite_link()
738                    .returning(|_model| Ok(()))
739                    .once();
740            }),
741        );
742
743        // An invite with confirmation disabled routes to the acceptance branch.
744        let (secret, mut invite, _org_public_key) = build_invite(&client, org_id);
745        invite.disable_confirmation();
746        assert!(!invite.supports_confirmation());
747        *invite_cell.lock().unwrap() = Some(String::from(&invite));
748
749        client
750            .accept_and_optionally_confirm(
751                org_id,
752                uuid::Uuid::new_v4().to_string(),
753                secret,
754                "Default".to_string(),
755                false,
756            )
757            .await
758            .unwrap();
759    }
760
761    #[tokio::test]
762    async fn accept_with_mismatched_recovery_key_fails() {
763        let org_id = OrganizationId::new_v4();
764        let recovery = Arc::new(std::sync::Mutex::new(None::<String>));
765        let invite_cell = Arc::new(std::sync::Mutex::new(None::<String>));
766        let recovery_mock = recovery.clone();
767        let invite_mock = invite_cell.clone();
768        let client = make_client(
769            org_id,
770            ApiClient::new_mocked(move |mock| {
771                mock.organizations_api
772                    .expect_get_public_key()
773                    .returning(move |_id| {
774                        Ok(OrganizationPublicKeyResponseModel {
775                            object: None,
776                            public_key: recovery_mock.lock().unwrap().clone(),
777                        })
778                    })
779                    .once();
780                mock.organization_users_api
781                    .expect_get_invite()
782                    .returning(move |_model| {
783                        Ok(OrganizationInviteResponseModel {
784                            invite: invite_mock.lock().unwrap().clone(),
785                        })
786                    })
787                    .once();
788            }),
789        );
790
791        let (secret, invite, _org_public_key) = build_invite(&client, org_id);
792        *invite_cell.lock().unwrap() = Some(String::from(&invite));
793        // The "server" returns an unrelated public key that must not match the invite's bound
794        // thumbprint.
795        let unrelated_public_key = {
796            let mut ctx = client.key_store.context();
797            let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
798            B64::from(
799                ctx.get_public_key(private_key)
800                    .unwrap()
801                    .to_der()
802                    .unwrap()
803                    .as_ref(),
804            )
805        };
806        *recovery.lock().unwrap() = Some(String::from(&unrelated_public_key));
807
808        let result = client
809            .accept_and_optionally_confirm(
810                org_id,
811                uuid::Uuid::new_v4().to_string(),
812                secret,
813                "Default".to_string(),
814                true,
815            )
816            .await;
817
818        assert!(matches!(result, Err(InviteLinkError::RecoveryKeyMismatch)));
819    }
820}