use std::collections::HashMap;
use std::fmt;
use base64::Engine;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::headers::util::split_key_value;
use autopush_common::util::{sec_since_epoch, ONE_DAY_IN_SECONDS};
pub const ALLOWED_SCHEMES: [&str; 3] = ["bearer", "webpush", "vapid"];
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
pub struct VapidClaims {
pub exp: u64,
pub aud: Option<String>,
pub sub: Option<String>,
}
impl Default for VapidClaims {
fn default() -> Self {
Self {
exp: VapidClaims::default_exp(),
aud: None,
sub: None,
}
}
}
impl VapidClaims {
pub fn default_exp() -> u64 {
sec_since_epoch() + ONE_DAY_IN_SECONDS
}
}
impl fmt::Display for VapidClaims {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VapidClaims")
.field("exp", &self.exp)
.field("aud", &self.aud)
.field("sub", &self.sub)
.finish()
}
}
impl TryFrom<VapidHeader> for VapidClaims {
type Error = VapidError;
fn try_from(header: VapidHeader) -> Result<Self, Self::Error> {
let b64_str = header
.token
.split('.')
.nth(1)
.ok_or(VapidError::InvalidVapid(header.token.to_string()))?;
let value_str = String::from_utf8(
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(b64_str)
.map_err(|e| VapidError::InvalidVapid(e.to_string()))?,
)
.map_err(|e| VapidError::InvalidVapid(e.to_string()))?;
serde_json::from_str::<VapidClaims>(&value_str)
.map_err(|e| VapidError::InvalidVapid(e.to_string()))
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VapidHeader {
pub scheme: String,
pub token: String,
pub version_data: VapidVersionData,
}
#[derive(Clone, Debug)]
pub struct VapidHeaderWithKey {
pub vapid: VapidHeader,
pub public_key: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VapidVersionData {
Version1,
Version2 { public_key: String },
}
impl VapidHeader {
pub fn parse(header: &str) -> Result<VapidHeader, VapidError> {
let mut scheme_split = header.splitn(2, ' ');
let scheme = scheme_split
.next()
.ok_or(VapidError::MissingToken)?
.to_lowercase();
let data = scheme_split
.next()
.ok_or(VapidError::MissingToken)?
.replace(' ', "");
if !ALLOWED_SCHEMES.contains(&scheme.as_str()) {
return Err(VapidError::UnknownScheme);
}
let (token, version_data) = if scheme == "vapid" {
let data: HashMap<&str, &str> = data.split(',').filter_map(split_key_value).collect();
let public_key = *data.get("k").ok_or(VapidError::MissingKey)?;
let token = *data.get("t").ok_or(VapidError::MissingToken)?;
(
token.to_string(),
VapidVersionData::Version2 {
public_key: public_key.to_string(),
},
)
} else {
(data, VapidVersionData::Version1)
};
Ok(Self {
scheme,
token,
version_data,
})
}
pub fn version(&self) -> usize {
match self.version_data {
VapidVersionData::Version1 => 1,
VapidVersionData::Version2 { .. } => 2,
}
}
pub fn insecure_sub(&self) -> Result<String, VapidError> {
let data = VapidClaims::try_from(self.clone()).inspect_err(|e| {
warn!("🔐 Vapid: {:?} {:?}", e, &self.token);
})?;
if let Some(sub) = data.sub {
if !sub.starts_with("mailto:") && !sub.starts_with("https://") {
info!("🔐 Vapid: Bad Format {:?}", sub);
return Err(VapidError::SubBadFormat);
}
if sub.is_empty() {
info!("🔐 Empty Vapid sub");
return Err(VapidError::SubEmpty);
}
info!("🔐 Vapid: sub: {:?}", sub);
return Ok(sub.to_owned());
}
Err(VapidError::SubMissing)
}
pub fn claims(&self) -> Result<VapidClaims, VapidError> {
VapidClaims::try_from(self.clone())
}
}
#[derive(Debug, Error, Eq, PartialEq)]
pub enum VapidError {
#[error("Missing VAPID token")]
MissingToken,
#[error("Invalid VAPID token: {0}")]
InvalidVapid(String),
#[error("Missing VAPID public key")]
MissingKey,
#[error("Invalid VAPID public key: {0}")]
InvalidKey(String),
#[error("Invalid VAPID audience")]
InvalidAudience,
#[error("Invalid VAPID expiry")]
InvalidExpiry,
#[error("VAPID public key mismatch")]
KeyMismatch,
#[error("The VAPID token expiration is too long")]
FutureExpirationToken,
#[error("Unknown auth scheme")]
UnknownScheme,
#[error("Unparsable sub string")]
SubInvalid,
#[error("Improperly formatted sub string")]
SubBadFormat,
#[error("Empty sub string")]
SubEmpty,
#[error("Missing sub")]
SubMissing,
}
impl VapidError {
pub fn as_metric(&self) -> &str {
match self {
Self::MissingToken => "missing_token",
Self::InvalidVapid(_) => "invalid_vapid",
Self::MissingKey => "missing_key",
Self::InvalidKey(_) => "invalid_key",
Self::InvalidAudience => "invalid_audience",
Self::InvalidExpiry => "invalid_expiry",
Self::KeyMismatch => "key_mismatch",
Self::FutureExpirationToken => "future_expiration_token",
Self::UnknownScheme => "unknown_scheme",
Self::SubInvalid => "invalid_sub",
Self::SubBadFormat => "bad_format_sub",
Self::SubEmpty => "empty_sub",
Self::SubMissing => "missing_sub",
}
}
}
#[cfg(test)]
mod tests {
use super::{VapidClaims, VapidHeader, VapidVersionData};
const VALID_HEADER: &str = "vapid t=eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.ey\
JhdWQiOiJodHRwczovL3B1c2guc2VydmljZXMubW96aWxsYS5jb20iLCJleHAiOjE3MTM1N\
jQ4NzIsInN1YiI6Im1haWx0bzphZG1pbkBleGFtcGxlLmNvbSJ9.t7uOYm8nbqFkuNpDeln\
-UeqSC58xu96Mc9tUVifQu1zAAndHYwvMd3-u--PuUo3S_VrqYXEaIlNIOOrGd3iUBA,k=B\
LMymkOqvT6OZ1o9etCqV4jGPkvOXNz5FdBjsAR9zR5oeCV1x5CBKuSLTlHon-H_boHTzMtM\
oNHsAGDlDB6X7vI";
#[test]
fn parse_succeeds() {
let mut parts = VALID_HEADER.split(' ').nth(1).unwrap().split(',');
let token = parts.next().unwrap().split('=').nth(1).unwrap().to_string();
let public_key = parts.next().unwrap().split('=').nth(1).unwrap().to_string();
let expected_header = VapidHeader {
scheme: "vapid".to_string(),
token,
version_data: VapidVersionData::Version2 { public_key },
};
let returned_header = VapidHeader::parse(VALID_HEADER);
assert_eq!(returned_header, Ok(expected_header.clone()));
assert_eq!(
returned_header.unwrap().claims(),
Ok(VapidClaims {
exp: 1713564872,
aud: Some("https://push.services.mozilla.com".to_owned()),
sub: Some("mailto:admin@example.com".to_owned())
})
);
let returned_header = VapidHeader::parse(VALID_HEADER);
assert_eq!(
returned_header.unwrap().insecure_sub(),
Ok("mailto:admin@example.com".to_owned())
)
}
}