autoendpoint/extractors/
subscription.rs

1use std::borrow::Cow;
2use std::error::Error;
3
4use actix_web::{dev::Payload, web::Data, FromRequest, HttpRequest};
5use autopush_common::{
6    db::User,
7    metric_name::MetricName,
8    metrics::StatsdClientExt,
9    tags::Tags,
10    util::{b64_decode_std, b64_decode_url},
11};
12
13use cadence::StatsdClient;
14use futures::{future::LocalBoxFuture, FutureExt};
15use jsonwebtoken::{Algorithm, DecodingKey, Validation};
16use openssl::hash::MessageDigest;
17use uuid::Uuid;
18
19use crate::error::{ApiError, ApiErrorKind, ApiResult};
20use crate::extractors::{
21    token_info::{ApiVersion, TokenInfo},
22    user::validate_user,
23};
24use crate::headers::{
25    crypto_key::CryptoKeyHeader,
26    vapid::{VapidClaims, VapidError, VapidHeader, VapidHeaderWithKey, VapidVersionData},
27};
28use crate::server::AppState;
29
30use crate::settings::Settings;
31
32/// Extracts subscription data from `TokenInfo` and verifies auth/crypto headers
33#[derive(Clone, Debug)]
34pub struct Subscription {
35    pub user: User,
36    pub channel_id: Uuid,
37    pub vapid: Option<VapidHeaderWithKey>,
38    /// A stored value here indicates that the subscription update
39    /// should be tracked internally.
40    /// (This should ONLY be applied for messages that match known
41    /// Mozilla provided VAPID public keys.)
42    ///
43    pub reliability_id: Option<String>,
44}
45
46impl FromRequest for Subscription {
47    type Error = ApiError;
48    type Future = LocalBoxFuture<'static, Result<Self, Self::Error>>;
49
50    fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
51        let req = req.clone();
52
53        async move {
54            // Collect token info and server state
55            let token_info = TokenInfo::extract(&req).await?;
56            trace!("🔐 Token info: {:?}", &token_info);
57            let app_state: Data<AppState> =
58                Data::extract(&req).await.expect("No server state found");
59
60            // Decrypt the token
61            let token = app_state
62                .fernet
63                .decrypt(&repad_base64(&token_info.token))
64                .map_err(|e| {
65                    // Since we're decrypting and endpoint, we get a lot of spam links.
66                    // This can fill our logs.
67                    trace!("🔐 fernet: {:?}", e.to_string());
68                    ApiErrorKind::InvalidToken
69                })?;
70
71            // Parse VAPID and extract public key.
72            let vapid: Option<VapidHeaderWithKey> = parse_vapid(&token_info, &app_state.metrics)?
73                .map(|vapid| extract_public_key(vapid, &token_info))
74                .transpose()?;
75            trace!("raw vapid: {:?}", &vapid);
76            // Validate the VAPID JWT token, fetch the claims, and record the version
77            if let Some(with_key) = &vapid {
78                // Validate the VAPID JWT token and record the version
79                validate_vapid_jwt(with_key, &app_state.settings, &app_state.metrics)?;
80                // Use the UpdatesVapidDraft metric with a formatted version
81                let vapid_version_metric = format!(
82                    "{}{:02}",
83                    MetricName::UpdatesVapidDraft.as_ref(),
84                    with_key.vapid.version()
85                );
86                app_state.metrics.incr_raw(&vapid_version_metric)?;
87            };
88            // If this is a known VAPID key, create a reliability_id from
89            // either the content of the vapid assertions, or the request
90            // header value, or just make one up.
91            let reliability_id: Option<String> = vapid.as_ref().and_then(|v| {
92                app_state
93                    .reliability_filter
94                    .is_trackable(v)
95                    .then(|| app_state.reliability_filter.get_id(req.headers()))
96            });
97            trace!("🔍 track_id: {:?}", reliability_id);
98            // Capturing the vapid sub right now will cause too much cardinality. Instead,
99            // let's just capture if we have a valid VAPID, as well as what sort of bad sub
100            // values we get.
101            if let Some(header) = &vapid {
102                if let Some(sub) = header
103                    .vapid
104                    .insecure_sub()
105                    .inspect_err(|e: &VapidError| {
106                        // Capture the type of error and add it to metrics.
107                        let mut tags = Tags::default();
108                        tags.tags
109                            .insert("error".to_owned(), e.as_metric().to_owned());
110                        app_state
111                            .metrics
112                            .incr_with_tags(MetricName::NotificationAuthError)
113                            .with_tag("error", e.as_metric())
114                            .send();
115                    })
116                    .unwrap_or_default()
117                {
118                    info!("VAPID sub: {sub}");
119                };
120                // For now, record that we had a good (?) VAPID sub,
121                app_state.metrics.incr(MetricName::NotificationAuthOk)?;
122            };
123
124            match token_info.api_version {
125                ApiVersion::Version1 => version_1_validation(&token)?,
126                ApiVersion::Version2 => version_2_validation(&token, vapid.as_ref())?,
127            }
128
129            // Load and validate user data.
130            // Note: It is safe to unwrap the Uuid result because an error is
131            // only returned if the slice length is not 16.
132            let uaid = Uuid::from_slice(&token[..16]).unwrap();
133            let channel_id = Uuid::from_slice(&token[16..32]).unwrap();
134
135            trace!("UAID: {:?}, CHID: {:?}", uaid, channel_id);
136
137            let user = app_state
138                .db
139                .get_user(&uaid)
140                .await?
141                .ok_or(ApiErrorKind::NoSubscription)?;
142
143            trace!("user: {:?}", &user);
144            validate_user(&user, &channel_id, &app_state).await?;
145
146            // Validate the VAPID JWT token and record the version
147            if let Some(vapid) = &vapid {
148                validate_vapid_jwt(vapid, &app_state.settings, &app_state.metrics)?;
149
150                app_state
151                    .metrics
152                    .incr_raw(&format!("updates.vapid.draft{:02}", vapid.vapid.version()))?;
153            }
154
155            Ok(Subscription {
156                user,
157                channel_id,
158                vapid,
159                reliability_id,
160            })
161        }
162        .boxed_local()
163    }
164}
165
166/// Add back padding to a base64 string
167fn repad_base64(data: &str) -> Cow<'_, str> {
168    let trailing_chars = data.len() % 4;
169
170    if trailing_chars != 0 {
171        let mut data = data.to_string();
172
173        for _ in trailing_chars..4 {
174            data.push('=');
175        }
176
177        Cow::Owned(data)
178    } else {
179        Cow::Borrowed(data)
180    }
181}
182
183/// Parse the authorization header for VAPID data and update metrics
184fn parse_vapid(token_info: &TokenInfo, metrics: &StatsdClient) -> ApiResult<Option<VapidHeader>> {
185    let auth_header = match token_info.auth_header.as_ref() {
186        Some(header) => header,
187        None => return Ok(None),
188    };
189
190    let vapid = VapidHeader::parse(auth_header).inspect_err(|e| {
191        metrics
192            .incr_with_tags(MetricName::NotificationAuthError)
193            .with_tag("error", e.as_metric())
194            .send();
195    })?;
196
197    metrics
198        .incr_with_tags(MetricName::NotificationAuth)
199        .with_tag("vapid", &vapid.version().to_string())
200        .with_tag("scheme", &vapid.scheme)
201        .send();
202
203    Ok(Some(vapid))
204}
205
206/// Extract the VAPID public key from the headers
207fn extract_public_key(vapid: VapidHeader, token_info: &TokenInfo) -> ApiResult<VapidHeaderWithKey> {
208    Ok(match vapid.version_data.clone() {
209        VapidVersionData::Version1 => {
210            // VAPID v1 stores the public key in the Crypto-Key header
211            let header = token_info.crypto_key_header.as_deref().ok_or_else(|| {
212                ApiErrorKind::InvalidEncryption("Missing Crypto-Key header".to_string())
213            })?;
214            let header_data = CryptoKeyHeader::parse(header).ok_or_else(|| {
215                ApiErrorKind::InvalidEncryption("Invalid Crypto-Key header".to_string())
216            })?;
217            let public_key = header_data.get_by_key("p256ecdsa").ok_or_else(|| {
218                ApiErrorKind::InvalidEncryption(
219                    "Missing p256ecdsa in Crypto-Key header".to_string(),
220                )
221            })?;
222
223            VapidHeaderWithKey {
224                vapid,
225                public_key: public_key.to_string(),
226            }
227        }
228        VapidVersionData::Version2 { public_key } => VapidHeaderWithKey { vapid, public_key },
229    })
230}
231
232/// `/webpush/v1/` validations
233fn version_1_validation(token: &[u8]) -> ApiResult<()> {
234    if token.len() != 32 {
235        // Corrupted token
236        return Err(ApiErrorKind::InvalidToken.into());
237    }
238
239    Ok(())
240}
241
242/// Decode a public key string
243///
244/// NOTE: Some customers send a VAPID public key with incorrect padding and
245/// in standard base64 encoding. (Both of these violate the VAPID RFC)
246/// Prior python versions ignored these errors, so we should too.
247fn decode_public_key(public_key: &str) -> ApiResult<Vec<u8>> {
248    if public_key.contains(['/', '+']) {
249        b64_decode_std(public_key.trim_end_matches('='))
250    } else {
251        b64_decode_url(public_key.trim_end_matches('='))
252    }
253    .map_err(|e| {
254        error!("decode_public_key: {:?}", e);
255        VapidError::InvalidKey(e.to_string()).into()
256    })
257}
258
259/// `/webpush/v2/` validations
260fn version_2_validation(token: &[u8], vapid: Option<&VapidHeaderWithKey>) -> ApiResult<()> {
261    if token.len() != 64 {
262        // Corrupted token
263        return Err(ApiErrorKind::InvalidToken.into());
264    }
265
266    // Verify that the sender is authorized to send notifications.
267    // The last 32 bytes of the token is the hashed public key.
268    let token_key = &token[32..];
269    let public_key = &vapid.ok_or(VapidError::MissingKey)?.public_key;
270
271    // Hash the VAPID public key
272    let public_key = decode_public_key(public_key)?;
273    let key_hash = openssl::hash::hash(MessageDigest::sha256(), &public_key)
274        .map_err(ApiErrorKind::TokenHashValidation)?;
275
276    // Verify that the VAPID public key equals the (expected) token public key
277    if !openssl::memcmp::eq(&key_hash, token_key) {
278        return Err(VapidError::KeyMismatch.into());
279    }
280
281    Ok(())
282}
283
284// Perform a very brain dead conversion of a string to a CamelCaseVersion
285fn term_to_label(term: &str) -> String {
286    term.split(' ').fold("".to_owned(), |prev, word: &str| {
287        format!(
288            "{}{}{}",
289            prev,
290            word.get(0..1).unwrap_or_default().to_ascii_uppercase(),
291            word.get(1..).unwrap_or_default()
292        )
293    })
294}
295
296/// Validate the VAPID JWT token. Specifically,
297/// - Check the signature
298/// - Make sure it hasn't expired
299/// - Make sure the expiration isn't too far into the future
300///
301/// This is mostly taken care of by the jsonwebtoken library
302fn validate_vapid_jwt(
303    vapid: &VapidHeaderWithKey,
304    settings: &Settings,
305    metrics: &StatsdClient,
306) -> ApiResult<VapidClaims> {
307    let VapidHeaderWithKey { vapid, public_key } = vapid;
308
309    let public_key = decode_public_key(public_key)?;
310    let mut validation = Validation::new(Algorithm::ES256);
311    // Set the audiences we allow. This obsoletes the need to manually match
312    // against values later.
313    validation.set_audience(&[settings.endpoint_url().origin().ascii_serialization()]);
314    validation.set_required_spec_claims(&["exp", "aud"]);
315
316    let token_data = match jsonwebtoken::decode::<VapidClaims>(
317        &vapid.token,
318        &DecodingKey::from_ec_der(&public_key),
319        &validation,
320    ) {
321        Ok(v) => v,
322        Err(e) => match e.kind() {
323            // NOTE: This will fail if `exp` is specified as anything instead of a numeric or if a required field is empty
324            jsonwebtoken::errors::ErrorKind::Json(e) => {
325                metrics
326                    .incr_with_tags(MetricName::NotificationAuthBadVapidJson)
327                    .with_tag(
328                        "error",
329                        match e.classify() {
330                            serde_json::error::Category::Io => "IO_ERROR",
331                            serde_json::error::Category::Syntax => "SYNTAX_ERROR",
332                            serde_json::error::Category::Data => "DATA_ERROR",
333                            serde_json::error::Category::Eof => "EOF_ERROR",
334                        },
335                    )
336                    .send();
337                if e.is_data() {
338                    debug!("VAPID data warning: {:?}", e);
339                    return Err(VapidError::InvalidVapid(
340                        "A value in the vapid claims is either missing or incorrectly specified (e.g. \"exp\":\"12345\" or \"sub\":null). Please correct and retry.".to_owned(),
341                    )
342                    .into());
343                }
344                // Other errors are always possible. Try to be helpful by returning
345                // the Json parse error.
346                return Err(VapidError::InvalidVapid(e.to_string()).into());
347            }
348            jsonwebtoken::errors::ErrorKind::InvalidAudience => {
349                return Err(VapidError::InvalidAudience.into());
350            }
351            jsonwebtoken::errors::ErrorKind::MissingRequiredClaim(e) => {
352                return Err(VapidError::InvalidVapid(format!("Missing required {}", e)).into());
353            }
354            _ => {
355                // Attempt to match up the majority of ErrorKind variants.
356                // The third-party errors all defer to the source, so we can
357                // use that to differentiate for actual errors.
358                let label = if e.source().is_none() {
359                    // These two have the most cardinality, so we need to handle
360                    // them separately.
361                    let mut label_name = e.to_string();
362                    if label_name.contains(':') {
363                        // if the error begins with a common tag e.g. "Missing required claim: ..."
364                        // then convert it to a less cardinal version. This is lossy, but acceptable.
365                        label_name =
366                            term_to_label(label_name.split(':').next().unwrap_or_default());
367                    } else if label_name.contains(' ') {
368                        // if a space still snuck through somehow, remove it.
369                        label_name = term_to_label(&label_name);
370                    }
371                    label_name
372                } else {
373                    // If you need to dig into these, there's always the logs.
374                    "Other".to_owned()
375                };
376                metrics
377                    .incr_with_tags(MetricName::NotificationAuthBadVapidOther)
378                    .with_tag("error", &label)
379                    .send();
380                error!("Bad Aud: Unexpected VAPID error: {:?}", &e);
381                return Err(e.into());
382            }
383        },
384    };
385
386    // Dump the claims.
387    // Note, this can produce a LOT of log messages if this feature is enabled.
388    #[cfg(feature = "log_vapid")]
389    if let Some(claims_str) = vapid.token.split('.').next() {
390        use base64::Engine;
391        info!(
392            "Vapid";
393            "sub" => &token_data.claims.sub,
394            "claims" => String::from_utf8(
395                base64::engine::general_purpose::URL_SAFE_NO_PAD
396                    .decode(claims_str)
397                    .unwrap_or_default()
398            )
399            .unwrap_or("UNKNOWN".to_owned())
400        );
401    };
402
403    if token_data.claims.exp > VapidClaims::default_exp() {
404        // The expiration is too far in the future
405        return Err(VapidError::FutureExpirationToken.into());
406    }
407
408    Ok(token_data.claims)
409}
410
411#[cfg(test)]
412pub mod tests {
413    use super::{term_to_label, validate_vapid_jwt, VapidClaims};
414    use crate::error::ApiErrorKind;
415    use crate::extractors::subscription::repad_base64;
416    use crate::headers::vapid::{VapidError, VapidHeader, VapidHeaderWithKey, VapidVersionData};
417    use crate::metrics::Metrics;
418    use crate::settings::Settings;
419
420    use autopush_common::util::b64_decode_std;
421    use lazy_static::lazy_static;
422    use serde::{Deserialize, Serialize};
423
424    pub const PUB_KEY: &str =
425        "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds";
426
427    lazy_static! {
428        static ref PRIV_KEY: Vec<u8> = b64_decode_std(
429            "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgZImOgpRszunnU3j1\
430                    oX5UQiX8KU4X2OdbENuvc/t8wpmhRANCAATN21Y1v8LmQueGpSG6o022gTbbYa4l\
431                    bXWZXITsjknW1WHmELtouYpyXX7e41FiAMuDvcRwW2Nfehn/taHW/IXb",
432        )
433        .unwrap();
434    }
435
436    /// Make a vapid header.
437    /// *NOTE*: This follows a python format where you only specify overrides. Any value not
438    /// specified will use a default value.
439    pub fn make_vapid(sub: &str, aud: &str, exp: u64, key: String) -> VapidHeaderWithKey {
440        let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
441        let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
442        let claims = VapidClaims {
443            exp,
444            aud: Some(aud.to_string()),
445            sub: Some(sub.to_string()),
446        };
447        let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
448
449        VapidHeaderWithKey {
450            public_key: key.to_owned(),
451            vapid: VapidHeader {
452                scheme: "vapid".to_string(),
453                token,
454                version_data: VapidVersionData::Version1,
455            },
456        }
457    }
458
459    #[test]
460    fn repad_base64_1_padding() {
461        assert_eq!(repad_base64("Zm9vYmE"), "Zm9vYmE=")
462    }
463
464    #[test]
465    fn repad_base64_2_padding() {
466        assert_eq!(repad_base64("Zm9vYg"), "Zm9vYg==")
467    }
468
469    #[test]
470    fn vapid_aud_valid() {
471        // Specify a potentially invalid padding.
472        let public_key = "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds==".to_owned();
473        let domain = "https://push.services.mozilla.org";
474        let test_settings = Settings {
475            endpoint_url: domain.to_owned(),
476            ..Default::default()
477        };
478
479        let header = make_vapid(
480            "mailto:admin@example.com",
481            domain,
482            VapidClaims::default_exp() - 100,
483            public_key,
484        );
485        let result = validate_vapid_jwt(&header, &test_settings, &Metrics::sink());
486        assert!(result.is_ok());
487    }
488
489    #[test]
490    fn vapid_aud_invalid() {
491        let domain = "https://push.services.mozilla.org";
492        let test_settings = Settings {
493            endpoint_url: domain.to_owned(),
494            ..Default::default()
495        };
496        let header = make_vapid(
497            "mailto:admin@example.com",
498            "https://example.com",
499            VapidClaims::default_exp() - 100,
500            PUB_KEY.to_owned(),
501        );
502        assert!(matches!(
503            validate_vapid_jwt(&header, &test_settings, &Metrics::sink())
504                .unwrap_err()
505                .kind,
506            ApiErrorKind::VapidError(VapidError::InvalidAudience)
507        ));
508    }
509
510    #[test]
511    fn vapid_aud_valid_for_alternate_host() {
512        let domain = "https://example.org";
513        let test_settings = Settings {
514            endpoint_url: domain.to_owned(),
515            ..Default::default()
516        };
517        let header = make_vapid(
518            "mailto:admin@example.com",
519            domain,
520            VapidClaims::default_exp() - 100,
521            PUB_KEY.to_owned(),
522        );
523        let result = validate_vapid_jwt(&header, &test_settings, &Metrics::sink());
524        assert!(result.is_ok());
525    }
526
527    #[test]
528    fn vapid_exp_is_string() {
529        #[derive(Debug, Deserialize, Serialize)]
530        struct StrExpVapidClaims {
531            exp: String,
532            aud: String,
533            sub: String,
534        }
535
536        let domain = "https://push.services.mozilla.org";
537        let test_settings = Settings {
538            endpoint_url: domain.to_owned(),
539            ..Default::default()
540        };
541        let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
542        let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
543        let claims = StrExpVapidClaims {
544            exp: (VapidClaims::default_exp() - 100).to_string(),
545            aud: domain.to_owned(),
546            sub: "mailto:admin@example.com".to_owned(),
547        };
548        let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
549        let header = VapidHeaderWithKey {
550            public_key: PUB_KEY.to_owned(),
551            vapid: VapidHeader {
552                scheme: "vapid".to_string(),
553                token,
554                version_data: VapidVersionData::Version1,
555            },
556        };
557        let vv = validate_vapid_jwt(&header, &test_settings, &Metrics::sink())
558            .unwrap_err()
559            .kind;
560        assert!(matches![
561            vv,
562            ApiErrorKind::VapidError(VapidError::InvalidVapid(_))
563        ])
564    }
565
566    #[test]
567    fn vapid_public_key_variants() {
568        #[derive(Debug, Deserialize, Serialize)]
569        struct StrExpVapidClaims {
570            exp: String,
571            aud: String,
572            sub: String,
573        }
574
575        // pretty much matches the kind of key we get from some partners.
576        let public_key_standard = "BM3bVjW/wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf+1odb8hds=".to_owned();
577        let public_key_url_safe = "BM3bVjW_wuZC54alIbqjTbaBNtthriVtdZlchOyOSdbVYeYQu2i5inJdft7jUWIAy4O9xHBbY196Gf-1odb8hds=".to_owned();
578        let domain = "https://push.services.mozilla.org";
579        let test_settings = Settings {
580            endpoint_url: domain.to_owned(),
581            ..Default::default()
582        };
583        let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
584        let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
585        let claims = VapidClaims {
586            exp: VapidClaims::default_exp() - 100,
587            aud: Some(domain.to_owned()),
588            sub: Some("mailto:admin@example.com".to_owned()),
589        };
590        let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
591        // try standard form with padding
592        let header = VapidHeaderWithKey {
593            public_key: public_key_standard.clone(),
594            vapid: VapidHeader {
595                scheme: "vapid".to_string(),
596                token: token.clone(),
597                version_data: VapidVersionData::Version1,
598            },
599        };
600        assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
601        // try standard form with no padding
602        let header = VapidHeaderWithKey {
603            public_key: public_key_standard.trim_end_matches('=').to_owned(),
604            vapid: VapidHeader {
605                scheme: "vapid".to_string(),
606                token: token.clone(),
607                version_data: VapidVersionData::Version1,
608            },
609        };
610        assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
611        // try URL safe form with padding
612        let header = VapidHeaderWithKey {
613            public_key: public_key_url_safe.clone(),
614            vapid: VapidHeader {
615                scheme: "vapid".to_string(),
616                token: token.clone(),
617                version_data: VapidVersionData::Version1,
618            },
619        };
620        assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
621        // try URL safe form without padding
622        let header = VapidHeaderWithKey {
623            public_key: public_key_url_safe.trim_end_matches('=').to_owned(),
624            vapid: VapidHeader {
625                scheme: "vapid".to_string(),
626                token,
627                version_data: VapidVersionData::Version1,
628            },
629        };
630        assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
631    }
632
633    #[test]
634    fn vapid_missing_sub() {
635        #[derive(Debug, Deserialize, Serialize)]
636        struct NoSubVapidClaims {
637            exp: u64,
638            aud: String,
639            sub: Option<String>,
640        }
641
642        let domain = "https://push.services.mozilla.org";
643        let test_settings = Settings {
644            endpoint_url: domain.to_owned(),
645            ..Default::default()
646        };
647        let jwk_header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES256);
648        let enc_key = jsonwebtoken::EncodingKey::from_ec_der(&PRIV_KEY);
649        let claims = NoSubVapidClaims {
650            exp: VapidClaims::default_exp() - 100,
651            aud: domain.to_owned(),
652            sub: None,
653        };
654        let token = jsonwebtoken::encode(&jwk_header, &claims, &enc_key).unwrap();
655        let header = VapidHeaderWithKey {
656            public_key: PUB_KEY.to_owned(),
657            vapid: VapidHeader {
658                scheme: "vapid".to_string(),
659                token,
660                version_data: VapidVersionData::Version1,
661            },
662        };
663        assert!(validate_vapid_jwt(&header, &test_settings, &Metrics::sink()).is_ok());
664    }
665
666    #[test]
667    fn test_crapitalize() {
668        assert_eq!(
669            "LabelFieldWithoutData",
670            term_to_label("LabelField without data")
671        );
672        assert_eq!("UntouchedField", term_to_label("UntouchedField"));
673    }
674}