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#[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 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 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 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 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 let request = {
141 let mut ctx = self.key_store.context();
142
143 let invite_key =
145 invite.unseal_invite_key_with_invite_secret(&invite_secret, &mut ctx)?;
146
147 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 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
225enum 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 {
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 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 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 *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 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 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 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 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}