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