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#[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 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#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct VapidHeader {
79 pub scheme: String,
80 pub token: String,
81 pub version_data: VapidVersionData,
82}
83
84#[derive(Clone, Debug)]
87pub struct VapidHeaderWithKey {
88 pub vapid: VapidHeader,
89 pub public_key: String,
90}
91
92#[derive(Clone, Debug, Eq, PartialEq)]
94pub enum VapidVersionData {
95 Version1,
96 Version2 { public_key: String },
97}
98
99impl VapidHeader {
100 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 Ok(Self {
136 scheme,
137 token,
138 version_data,
139 })
140 }
141
142 pub fn version(&self) -> usize {
144 match self.version_data {
145 VapidVersionData::Version1 => 1,
146 VapidVersionData::Version2 { .. } => 2,
147 }
148 }
149
150 pub fn insecure_sub(&self) -> Result<Option<String>, VapidError> {
155 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 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 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 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}