autoendpoint/headers/
vapid.rs

1use core::str;
2use std::collections::HashMap;
3use std::fmt;
4
5use base64::Engine;
6use chrono::TimeDelta;
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10use crate::headers::util::split_key_value;
11use autopush_common::util::sec_since_epoch;
12
13pub const ALLOWED_SCHEMES: [&str; 3] = ["bearer", "webpush", "vapid"];
14
15/*
16The Assertion block for the VAPID header.
17
18Please note: We require the `sub` claim in addition to the `exp` and `aud`.
19See [HTTP Endpoints for Notifications::Lexicon::{vapid_key}](https://mozilla-services.github.io/autopush-rs/http.html#lexicon-1)
20for details.
21
22 */
23#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
24pub struct VapidClaims {
25    pub exp: u64,
26    pub aud: Option<String>,
27    pub sub: Option<String>,
28}
29
30impl Default for VapidClaims {
31    fn default() -> Self {
32        Self {
33            exp: VapidClaims::default_exp(),
34            aud: None,
35            sub: None,
36        }
37    }
38}
39
40impl VapidClaims {
41    /// Returns default expiration of one day from creation (in seconds).
42    pub fn default_exp() -> u64 {
43        sec_since_epoch() + TimeDelta::days(1).num_seconds() as u64
44    }
45}
46
47impl fmt::Display for VapidClaims {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.debug_struct("VapidClaims")
50            .field("exp", &self.exp)
51            .field("aud", &self.aud)
52            .field("sub", &self.sub)
53            .finish()
54    }
55}
56
57impl TryFrom<VapidHeader> for VapidClaims {
58    type Error = VapidError;
59    fn try_from(header: VapidHeader) -> Result<Self, Self::Error> {
60        let b64_str = header
61            .token
62            .split('.')
63            .nth(1)
64            .ok_or(VapidError::InvalidVapid(header.token.to_string()))?;
65        let value_str = String::from_utf8(
66            base64::engine::general_purpose::URL_SAFE_NO_PAD
67                .decode(b64_str)
68                .map_err(|e| VapidError::InvalidVapid(e.to_string()))?,
69        )
70        .map_err(|e| VapidError::InvalidVapid(e.to_string()))?;
71        serde_json::from_str::<VapidClaims>(&value_str)
72            .map_err(|e| VapidError::InvalidVapid(e.to_string()))
73    }
74}
75
76/// Parses the VAPID authorization header
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct VapidHeader {
79    pub scheme: String,
80    pub token: String,
81    pub version_data: VapidVersionData,
82}
83
84/// Combines the VAPID header details with the public key, which may not be from
85/// the VAPID header
86#[derive(Clone, Debug)]
87pub struct VapidHeaderWithKey {
88    pub vapid: VapidHeader,
89    pub public_key: String,
90}
91
92/// Version-specific VAPID data. Also used to identify the VAPID version.
93#[derive(Clone, Debug, Eq, PartialEq)]
94pub enum VapidVersionData {
95    Version1,
96    Version2 { public_key: String },
97}
98
99impl VapidHeader {
100    /// Parse the VAPID authorization header. The public key is available if the
101    /// version is 2 ("vapid" scheme).
102    pub fn parse(header: &str) -> Result<VapidHeader, VapidError> {
103        let mut scheme_split = header.splitn(2, ' ');
104        let scheme = scheme_split
105            .next()
106            .ok_or(VapidError::MissingToken)?
107            .to_lowercase();
108        let data = scheme_split
109            .next()
110            .ok_or(VapidError::MissingToken)?
111            .replace(' ', "");
112
113        if !ALLOWED_SCHEMES.contains(&scheme.as_str()) {
114            return Err(VapidError::UnknownScheme);
115        }
116
117        let (token, version_data) = if scheme == "vapid" {
118            let data: HashMap<&str, &str> = data.split(',').filter_map(split_key_value).collect();
119
120            let public_key = *data.get("k").ok_or(VapidError::MissingKey)?;
121            let token = *data.get("t").ok_or(VapidError::MissingToken)?;
122
123            (
124                token.to_string(),
125                VapidVersionData::Version2 {
126                    public_key: public_key.to_string(),
127                },
128            )
129        } else {
130            (data, VapidVersionData::Version1)
131        };
132
133        // Validate the JWT here
134
135        Ok(Self {
136            scheme,
137            token,
138            version_data,
139        })
140    }
141
142    /// Get the VAPID version as a number
143    pub fn version(&self) -> usize {
144        match self.version_data {
145            VapidVersionData::Version1 => 1,
146            VapidVersionData::Version2 { .. } => 2,
147        }
148    }
149
150    /// Return the claimed `sub` after doing some minimal checks for validity.
151    /// WARNING: THIS FUNCTION DOES NOT VALIDATE THE VAPID HEADER AND SHOULD
152    /// ONLY BE USED FOR LOGGING AND METRIC REPORTING FUNCTIONS.
153    /// Proper validation should be done by [crate::extractors::subscription::validate_vapid_jwt()]
154    pub fn insecure_sub(&self) -> Result<Option<String>, VapidError> {
155        // This parses the VAPID header string
156        let data = VapidClaims::try_from(self.clone()).inspect_err(|e| {
157            warn!("🔐 Vapid: {:?} {:?}", e, &self.token);
158        })?;
159
160        let Some(sub) = data.sub else { return Ok(None) };
161        if sub.is_empty() {
162            info!("🔐 Empty Vapid sub");
163            return Err(VapidError::SubEmpty);
164        }
165        if !sub.starts_with("mailto:") && !sub.starts_with("https://") {
166            info!("🔐 Vapid: Bad Format {sub:?}");
167            return Err(VapidError::SubBadFormat);
168        };
169        info!("🔐 Vapid: sub: {sub}");
170        Ok(Some(sub))
171    }
172
173    pub fn claims(&self) -> Result<VapidClaims, VapidError> {
174        VapidClaims::try_from(self.clone())
175    }
176}
177
178#[derive(Debug, Error, Eq, PartialEq)]
179pub enum VapidError {
180    #[error("Missing VAPID token")]
181    MissingToken,
182    #[error("Invalid VAPID token: {0}")]
183    InvalidVapid(String),
184    #[error("Missing VAPID public key")]
185    MissingKey,
186    #[error("Invalid VAPID public key: {0}")]
187    InvalidKey(String),
188    #[error("Invalid VAPID audience")]
189    InvalidAudience,
190    #[error("Invalid VAPID expiry")]
191    InvalidExpiry,
192    #[error("VAPID public key mismatch")]
193    KeyMismatch,
194    #[error("The VAPID token expiration is too long")]
195    FutureExpirationToken,
196    #[error("Unknown auth scheme")]
197    UnknownScheme,
198    #[error("Unparsable sub string")]
199    SubInvalid,
200    #[error("Improperly formatted sub string")]
201    SubBadFormat,
202    #[error("Empty sub string")]
203    SubEmpty,
204    #[error("Missing sub")]
205    SubMissing,
206}
207
208impl VapidError {
209    pub fn as_metric(&self) -> &str {
210        match self {
211            Self::MissingToken => "missing_token",
212            Self::InvalidVapid(_) => "invalid_vapid",
213            Self::MissingKey => "missing_key",
214            Self::InvalidKey(_) => "invalid_key",
215            Self::InvalidAudience => "invalid_audience",
216            Self::InvalidExpiry => "invalid_expiry",
217            Self::KeyMismatch => "key_mismatch",
218            Self::FutureExpirationToken => "future_expiration_token",
219            Self::UnknownScheme => "unknown_scheme",
220            Self::SubInvalid => "invalid_sub",
221            Self::SubBadFormat => "bad_format_sub",
222            Self::SubEmpty => "empty_sub",
223            Self::SubMissing => "missing_sub",
224        }
225    }
226}
227
228#[cfg(test)]
229mod tests {
230
231    use super::{VapidClaims, VapidHeader, VapidVersionData};
232
233    // This was generated externally using the py_vapid package.
234    const VALID_HEADER: &str = "vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.ey\
235        JhdWQiOiJodHRwczovL3B1c2guc2VydmljZXMubW96aWxsYS5jb20iLCJleHAiOjE3MTM1N\
236        jQ4NzIsInN1YiI6Im1haWx0bzphZG1pbkBleGFtcGxlLmNvbSJ9.t7uOYm8nbqFkuNpDeln\
237        -UeqSC58xu96Mc9tUVifQu1zAAndHYwvMd3-u--PuUo3S_VrqYXEaIlNIOOrGd3iUBA,k=B\
238        LMymkOqvT6OZ1o9etCqV4jGPkvOXNz5FdBjsAR9zR5oeCV1x5CBKuSLTlHon-H_boHTzMtM\
239        oNHsAGDlDB6X7vI";
240
241    #[test]
242    fn parse_succeeds() {
243        // brain dead header parser.
244        let mut parts = VALID_HEADER.split(' ').nth(1).unwrap().split(',');
245        let token = parts.next().unwrap().split('=').nth(1).unwrap().to_string();
246        let public_key = parts.next().unwrap().split('=').nth(1).unwrap().to_string();
247
248        let expected_header = VapidHeader {
249            scheme: "vapid".to_string(),
250            token,
251            version_data: VapidVersionData::Version2 { public_key },
252        };
253
254        let returned_header = VapidHeader::parse(VALID_HEADER);
255        assert_eq!(returned_header, Ok(expected_header.clone()));
256
257        assert_eq!(
258            returned_header.unwrap().claims(),
259            Ok(VapidClaims {
260                exp: 1713564872,
261                aud: Some("https://push.services.mozilla.com".to_owned()),
262                sub: Some("mailto:admin@example.com".to_owned())
263            })
264        );
265
266        // Ensure the parent `.sub()` returns a valid value.
267        let returned_header = VapidHeader::parse(VALID_HEADER);
268        assert_eq!(
269            returned_header.unwrap().insecure_sub(),
270            Ok(Some("mailto:admin@example.com".to_owned()))
271        )
272    }
273
274    #[test]
275    fn parse_no_sub() {
276        const VAPID_HEADER_NO_SUB:&str = "vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJhdWQiOiJodHRwczovL3B1c2guc2VydmljZXMubW96aWxsYS5jb20iLCJleHAiOjE3MzgxMTE1OTN9.v3oneNnU-VWJK3rI0gNAvstaZHfbA57WdrYHEq0P2Od9nGsdpi1xN2aNS8412wJpdzsriYvLyEWdPEdsu3luAw,k=BLMymkOqvT6OZ1o9etCqV4jGPkvOXNz5FdBjsAR9zR5oeCV1x5CBKuSLTlHon-H_boHTzMtMoNHsAGDlDB6X7vI";
277
278        let returned_header = VapidHeader::parse(VAPID_HEADER_NO_SUB);
279        assert_eq!(returned_header.unwrap().insecure_sub(), Ok(None))
280    }
281
282    #[test]
283    fn extract_sub() {
284        let header = VapidHeader::parse(VALID_HEADER).unwrap();
285        assert_eq!(
286            header.insecure_sub().unwrap(),
287            Some("mailto:admin@example.com".to_string())
288        );
289    }
290}