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