heddle_api/v2/
invitation_code.rs1use crate::heddle::api::v1alpha2::{
5 GetInvitationCodeResponse, GetSignupInvitationCodeResponse, InvitationRecord, InvitationState,
6 SignupInvitation, invitation_record,
7};
8use prost_types::Timestamp;
9
10#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
11pub enum InvitationCodeError {
12 #[error("Creator: invitation code read requires the original creator")]
13 Creator,
14 #[error("Authority: invitation code read requires current inviter admin authority")]
15 Authority,
16 #[error("Expiry: invitation code read requires valid current time and finite expiry")]
17 Expiry,
18 #[error("NotPending: terminal invitation code response must be empty")]
19 NotPending,
20}
21
22pub struct InvitationCodeReadContext<'a> {
25 pub caller_subject: &'a str,
26 pub creator_subject: &'a str,
27 pub now: &'a Timestamp,
28}
29
30pub fn validate_signup_invitation_code_response(
31 response: &GetSignupInvitationCodeResponse,
32 invitation: &SignupInvitation,
33 context: &InvitationCodeReadContext<'_>,
34) -> Result<(), InvitationCodeError> {
35 validate_code_response(
36 &response.redemption_secret,
37 invitation.redeemed,
38 invitation.revoked,
39 invitation.expires_at.as_ref(),
40 context,
41 )
42}
43
44pub fn validate_invitation_code_response(
45 response: &GetInvitationCodeResponse,
46 invitation: &InvitationRecord,
47 context: &InvitationCodeReadContext<'_>,
48 inviter_role: i32,
49) -> Result<(), InvitationCodeError> {
50 validate_creator(context)?;
51 if inviter_role != 3 {
52 return Err(InvitationCodeError::Authority);
53 }
54 let non_link = !matches!(
55 invitation.recipient.as_ref(),
56 Some(invitation_record::Recipient::Email(email)) if !email.is_empty()
57 );
58 validate_code_response(
59 &response.redemption_secret,
60 invitation.state != InvitationState::Pending as i32,
61 non_link,
62 invitation.expires_at.as_ref(),
63 context,
64 )
65}
66
67fn valid_timestamp(time: &Timestamp) -> bool {
68 (-62_135_596_800..=253_402_300_799).contains(&time.seconds)
69 && (0..1_000_000_000).contains(&time.nanos)
70}
71
72fn validate_code_response(
73 secret: &[u8],
74 redeemed: bool,
75 revoked: bool,
76 expires_at: Option<&Timestamp>,
77 context: &InvitationCodeReadContext<'_>,
78) -> Result<(), InvitationCodeError> {
79 validate_creator(context)?;
80 let expiry = expires_at.ok_or(InvitationCodeError::Expiry)?;
81 if !valid_timestamp(expiry) || !valid_timestamp(context.now) {
82 return Err(InvitationCodeError::Expiry);
83 }
84 let expired = (context.now.seconds, context.now.nanos) >= (expiry.seconds, expiry.nanos);
85 if !secret.is_empty() && (redeemed || revoked || expired) {
86 return Err(InvitationCodeError::NotPending);
87 }
88 Ok(())
89}
90
91fn validate_creator(context: &InvitationCodeReadContext<'_>) -> Result<(), InvitationCodeError> {
92 if context.caller_subject.is_empty()
93 || context.creator_subject.is_empty()
94 || context.caller_subject != context.creator_subject
95 {
96 return Err(InvitationCodeError::Creator);
97 }
98 Ok(())
99}