rocket_webhook/webhooks/built_in/
sendgrid.rs

1use base64::{Engine, prelude::BASE64_STANDARD};
2use rocket::{data::Outcome, http::Status, outcome::try_outcome, tokio::io::AsyncRead};
3use tokio_util::bytes::{Bytes, BytesMut};
4
5use crate::{
6    WebhookError,
7    webhooks::{
8        Webhook,
9        interface::public_key::{WebhookPublicKey, algorithms::p256::EcdsaP256Asn1},
10    },
11};
12
13/// # Sendgrid webhook
14///
15/// Looks for `X-Twilio-Email-Event-Webhook-Signature` and `X-Twilio-Email-Event-Webhook-Timestamp` headers.
16/// Signature header should be base64 ECDSA P256 ASN1 signature of `{timestamp}{body}`
17///
18/// [SendGrid docs](https://www.twilio.com/docs/sendgrid/for-developers/tracking-events/getting-started-event-webhook-security-features#verify-the-signature)
19pub struct SendGridWebhook {
20    public_key: Bytes,
21}
22
23impl SendGridWebhook {
24    /// Instantiate using the base64 public key from SendGrid
25    pub fn with_public_key(public_key: impl AsRef<str>) -> Result<Self, base64::DecodeError> {
26        let public_key = Bytes::from(BASE64_STANDARD.decode(public_key.as_ref())?);
27        Ok(Self { public_key })
28    }
29}
30
31impl Webhook for SendGridWebhook {
32    async fn validate_body(
33        &self,
34        req: &rocket::Request<'_>,
35        body: impl AsyncRead + Unpin + Send + Sync,
36        time_bounds: (u32, u32),
37    ) -> Outcome<'_, Vec<u8>, WebhookError> {
38        self.validate_with_public_key(req, body, time_bounds).await
39    }
40}
41
42impl WebhookPublicKey for SendGridWebhook {
43    type ALG = EcdsaP256Asn1;
44
45    async fn public_key(&self, _req: &rocket::Request<'_>) -> Outcome<'_, Bytes, WebhookError> {
46        Outcome::Success(self.public_key.clone())
47    }
48
49    fn expected_signature(&self, req: &rocket::Request<'_>) -> Outcome<'_, Vec<u8>, WebhookError> {
50        let sig_header =
51            try_outcome!(self.get_header(req, "X-Twilio-Email-Event-Webhook-Signature", None));
52        match BASE64_STANDARD.decode(sig_header) {
53            Ok(bytes) => Outcome::Success(bytes),
54            Err(_) => Outcome::Error((
55                Status::BadRequest,
56                WebhookError::InvalidHeader(format!(
57                    "X-Twilio-Email-Event-Webhook-Signature header was not valid base64: '{sig_header}'"
58                )),
59            )),
60        }
61    }
62
63    fn message_to_verify(
64        &self,
65        req: &rocket::Request<'_>,
66        body: &Bytes,
67        time_bounds: (u32, u32),
68    ) -> Outcome<'_, Bytes, WebhookError> {
69        let timestamp =
70            try_outcome!(self.get_header(req, "X-Twilio-Email-Event-Webhook-Timestamp", None));
71        try_outcome!(self.validate_timestamp(timestamp, time_bounds));
72
73        let mut timestamp_and_body = BytesMut::with_capacity(timestamp.len() + body.len());
74        timestamp_and_body.extend_from_slice(timestamp.as_bytes());
75        timestamp_and_body.extend_from_slice(body);
76
77        Outcome::Success(timestamp_and_body.freeze())
78    }
79}