Skip to main content

mercure/
jwt.rs

1use std::error::Error;
2use std::fmt;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use biscuit::jwa::SignatureAlgorithm;
6use biscuit::{jws, ClaimsSet, RegisteredClaims, JWT};
7use secrecy::{ExposeSecret as _, SecretSlice};
8use serde::{Deserialize, Serialize};
9
10use crate::topic_selector::TopicSelector;
11
12/// [RFC 7518, Section 3.2](https://datatracker.ietf.org/doc/html/rfc7518#section-3.2)
13///
14/// > A key of the same size as the hash output (for instance, 256 bits for
15/// > "HS256") or larger MUST be used with this algorithm. (This requirement is
16/// > based on Section 5.3.4 (Security Effect of the HMAC Key) of NIST SP
17/// > 800-117 [[NIST.800-107]], which states that the effective security
18/// > strength is the minimum of the security strength of the key and two times
19/// > the size of the internal hash value.)
20///
21/// [NIST.800-107]: http://csrc.nist.gov/publications/nistpubs/800-107-rev1/sp800-107-rev1.pdf
22pub const HS256_SECRET_KEY_LEN: usize = 64;
23
24/// A publisher [JWT] access token.
25///
26/// [JWT]: https://datatracker.ietf.org/doc/html/rfc7519
27#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
28#[serde(transparent)]
29pub struct PublisherJwt(JWT<MercureJwtClaims, biscuit::Empty>);
30
31/// The [HMAC] secret key used to sign publisher [JWT] access tokens.
32///
33/// [HMAC]: https://datatracker.ietf.org/doc/html/rfc2104
34/// [JWT]: https://datatracker.ietf.org/doc/html/rfc7519
35///
36/// # Note
37///
38/// It is recommended to use a key with a minimum length of
39/// [`HS256_SECRET_KEY_LEN`] bytes.
40///
41/// [RFC 7518, Section 3.2](https://datatracker.ietf.org/doc/html/rfc7518#section-3.2)
42///
43/// > A key of the same size as the hash output (for instance, 256 bits for
44/// > "HS256") or larger MUST be used with this algorithm. (This requirement is
45/// > based on Section 5.3.4 (Security Effect of the HMAC Key) of NIST SP
46/// > 800-117 [[NIST.800-107]], which states that the effective security
47/// > strength is the minimum of the security strength of the key and two times
48/// > the size of the internal hash value.)
49///
50/// [NIST.800-107]: http://csrc.nist.gov/publications/nistpubs/800-107-rev1/sp800-107-rev1.pdf
51#[derive(Clone)]
52pub struct PublisherJwtSecret(SecretSlice<u8>);
53
54/// An error returned from [`PublisherJwt::new`].
55#[derive(Debug)]
56#[non_exhaustive]
57pub struct PublisherJwtError {
58    kind: PublisherJwtErrorKind,
59    inner: Box<dyn Error + Send + Sync + 'static>,
60}
61
62/// The various types of errors that can cause [`PublisherJwt::new`] to fail.
63#[derive(Debug)]
64#[non_exhaustive]
65pub enum PublisherJwtErrorKind {
66    /// Failed to encode and sign publisher JWT.
67    EncodeAndSign,
68}
69
70/// A subscriber [JWT] access token.
71///
72/// [JWT]: https://datatracker.ietf.org/doc/html/rfc7519
73#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
74#[serde(transparent)]
75pub struct SubscriberJwt(JWT<MercureJwtClaims, biscuit::Empty>);
76
77/// The [HMAC] secret key used to sign subscriber [JWT] access tokens.
78///
79/// [HMAC]: https://datatracker.ietf.org/doc/html/rfc2104
80/// [JWT]: https://datatracker.ietf.org/doc/html/rfc7519
81///
82/// # Note
83///
84/// It is recommended to use a key with a minimum length of
85/// [`HS256_SECRET_KEY_LEN`] bytes.
86///
87/// [RFC 7518, Section 3.2](https://datatracker.ietf.org/doc/html/rfc7518#section-3.2)
88///
89/// > A key of the same size as the hash output (for instance, 256 bits for
90/// > "HS256") or larger MUST be used with this algorithm. (This requirement is
91/// > based on Section 5.3.4 (Security Effect of the HMAC Key) of NIST SP
92/// > 800-117 [[NIST.800-107]], which states that the effective security
93/// > strength is the minimum of the security strength of the key and two times
94/// > the size of the internal hash value.)
95///
96/// [NIST.800-107]: http://csrc.nist.gov/publications/nistpubs/800-107-rev1/sp800-107-rev1.pdf
97#[derive(Clone)]
98pub struct SubscriberJwtSecret(SecretSlice<u8>);
99
100/// The max-age used to calculate and set the "exp"[^exp] claim in the
101/// subscriber [JWT] access token.
102///
103/// [JWT]: https://datatracker.ietf.org/doc/html/rfc7519
104///
105/// [^exp]: <https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.4>
106#[derive(Copy, Clone, Debug)]
107pub struct SubscriberJwtMaxAge(std::time::Duration);
108
109/// The error type returned when a conversion from [`std::time::Duration`] to
110/// [`SubscriberJwtMaxAge`] fails.
111#[derive(Debug)]
112#[non_exhaustive]
113pub struct TryFromDurationError {
114    kind: TryFromDurationErrorKind,
115}
116
117/// The various types of errors that can cause converting from
118/// [`std::time::Duration`] to [`SubscriberJwtMaxAge`] to fail.
119#[derive(Debug)]
120#[non_exhaustive]
121pub enum TryFromDurationErrorKind {
122    /// Subscriber JWT max-age must not be more than [`MAX_AGE_LIMIT`].
123    ///
124    /// [`MAX_AGE_LIMIT`]: crate::cookie::MAX_AGE_LIMIT
125    CookieLifetimeLimitExceeded,
126}
127
128/// An error returned from [`SubscriberJwt::new`].
129#[derive(Debug)]
130#[non_exhaustive]
131pub struct SubscriberJwtError {
132    kind: SubscriberJwtErrorKind,
133    inner: Box<dyn Error + Send + Sync + 'static>,
134}
135
136/// The various types of errors that can cause [`SubscriberJwt::new`] to fail.
137#[derive(Debug)]
138#[non_exhaustive]
139pub enum SubscriberJwtErrorKind {
140    /// Failed to encode and sign subscriber JWT.
141    EncodeAndSign,
142}
143
144#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
145struct MercureJwtClaims {
146    mercure: MercureClaim,
147}
148
149#[derive(Clone, Eq, PartialEq, Debug, Deserialize, Serialize)]
150struct MercureClaim {
151    /// [The Mercure Protocol, Section 6.1](https://datatracker.ietf.org/doc/html/draft-dunglas-mercure#section-6.1)
152    ///
153    /// > To be allowed to publish an update, the JWS presented by the publisher
154    /// > MUST contain a claim called "mercure", and this claim MUST contain a
155    /// > "publish" key. "mercure.publish" contains an array of topic selectors.
156    #[serde(skip_serializing_if = "Option::is_none")]
157    publish: Option<Vec<TopicSelector>>,
158    /// [The Mercure Protocol, Section 6.2](https://datatracker.ietf.org/doc/html/draft-dunglas-mercure#section-6.2)
159    ///
160    /// > To receive updates marked as "private", the JWS presented by the
161    /// > subscriber MUST have a claim named "mercure" with a key named
162    /// > "subscribe" that contains an array of topic selectors.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    subscribe: Option<Vec<TopicSelector>>,
165}
166
167impl fmt::Display for PublisherJwt {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        let jwt = self
170            .0
171            .encoded()
172            .expect("`PublisherJwt` should be already encoded");
173        write!(f, "{jwt}")
174    }
175}
176
177impl PublisherJwt {
178    /// Constructs a new `PublisherJwt`.
179    ///
180    /// # Example
181    ///
182    /// ```
183    /// # use std::error::Error;
184    /// #
185    /// use mercure::jwt::PublisherJwtSecret;
186    /// use mercure::{PublisherJwt, TopicSelector};
187    ///
188    /// # fn main() -> Result<(), Box<dyn Error>> {
189    /// let publisher_jwt_secret =
190    ///     PublisherJwtSecret::from(b"!ChangeThisMercureHubJWTSecretKey!".to_vec());
191    /// let publisher_jwt = PublisherJwt::new(&publisher_jwt_secret, vec![TopicSelector::Wildcard])?;
192    /// # Ok(())
193    /// # }
194    /// ```
195    pub fn new(
196        publisher_jwt_secret: &PublisherJwtSecret,
197        topic_selectors: Vec<TopicSelector>,
198    ) -> Result<Self, PublisherJwtError> {
199        let mercure_jwt = JWT::<MercureJwtClaims, biscuit::Empty>::new_decoded(
200            jws::RegisteredHeader {
201                algorithm: SignatureAlgorithm::HS256,
202                ..Default::default()
203            }
204            .into(),
205            ClaimsSet {
206                registered: RegisteredClaims::default(),
207                private: MercureJwtClaims {
208                    mercure: MercureClaim {
209                        publish: Some(topic_selectors),
210                        subscribe: None,
211                    },
212                },
213            },
214        );
215        let mercure_jwt = match mercure_jwt.encode(&jws::Secret::Bytes(
216            publisher_jwt_secret.0.expose_secret().to_vec(),
217        )) {
218            Ok(mercure_jwt) => mercure_jwt,
219            Err(biscuit::errors::Error::UnsupportedOperation) => {
220                panic!("`mercure_jwt` should not already be encoded");
221            },
222            Err(err) => {
223                return Err(PublisherJwtError {
224                    kind: PublisherJwtErrorKind::EncodeAndSign,
225                    inner: err.into(),
226                })?;
227            },
228        };
229
230        Ok(Self(mercure_jwt))
231    }
232}
233
234impl From<Vec<u8>> for PublisherJwtSecret {
235    fn from(vec: Vec<u8>) -> Self {
236        Self(SecretSlice::from(vec))
237    }
238}
239
240impl fmt::Display for PublisherJwtError {
241    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
242        match self.kind {
243            PublisherJwtErrorKind::EncodeAndSign => {
244                let err = self.inner.downcast_ref::<biscuit::errors::Error>().unwrap();
245                write!(f, "failed to encode and sign JWT: {err}")
246            },
247        }
248    }
249}
250
251impl Error for PublisherJwtError {
252    fn source(&self) -> Option<&(dyn Error + 'static)> {
253        match self.kind {
254            PublisherJwtErrorKind::EncodeAndSign => {
255                let err = self.inner.downcast_ref::<biscuit::errors::Error>().unwrap();
256                Some(err)
257            },
258        }
259    }
260}
261
262impl PublisherJwtError {
263    /// Returns the corresponding [`PublisherJwtErrorKind`] for this error.
264    #[must_use]
265    pub const fn kind(&self) -> &PublisherJwtErrorKind {
266        &self.kind
267    }
268}
269
270impl fmt::Display for SubscriberJwt {
271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272        let jwt = self
273            .0
274            .encoded()
275            .expect("`SubscriberJwt` should be already encoded");
276        write!(f, "{jwt}")
277    }
278}
279
280impl SubscriberJwt {
281    /// Creates a new `SubscriberJwt`.
282    ///
283    /// # Note
284    ///
285    /// It is recommended to provide a [`SubscriberJwtMaxAge`].
286    ///
287    /// [The Mercure Protocol, Section 6](https://datatracker.ietf.org/doc/html/draft-dunglas-mercure#section-6)
288    ///
289    /// > This JWS SHOULD be short-lived, especially if the subscriber is a web
290    /// > browser.
291    ///
292    /// [The Mercure Protocol, Section 12](https://datatracker.ietf.org/doc/html/draft-dunglas-mercure#section-12)
293    ///
294    /// > revoking JWSs before their expiration is often difficult. To that end,
295    /// > using short-lived tokens is strongly RECOMMENDED.
296    ///
297    /// # Example
298    ///
299    /// ```
300    /// # use std::error::Error;
301    /// #
302    /// use mercure::jwt::SubscriberJwtSecret;
303    /// use mercure::{SubscriberJwt, TopicSelector};
304    ///
305    /// # fn main() -> Result<(), Box<dyn Error>> {
306    /// let subscriber_jwt_secret =
307    ///     SubscriberJwtSecret::from(b"!ChangeThisMercureHubJWTSecretKey!".to_vec());
308    /// let subscriber_jwt = SubscriberJwt::new(&subscriber_jwt_secret, None, vec![
309    ///     TopicSelector::UriTemplate("https://example.com/users/1/books/{book_id}".try_into()?),
310    /// ])?;
311    /// # Ok(())
312    /// # }
313    /// ```
314    pub fn new(
315        subscriber_jwt_secret: &SubscriberJwtSecret,
316        subscriber_jwt_max_age: Option<SubscriberJwtMaxAge>,
317        topic_selectors: Vec<TopicSelector>,
318    ) -> Result<Self, SubscriberJwtError> {
319        let mercure_jwt = JWT::<MercureJwtClaims, biscuit::Empty>::new_decoded(
320            jws::RegisteredHeader {
321                algorithm: SignatureAlgorithm::HS256,
322                ..Default::default()
323            }
324            .into(),
325            ClaimsSet {
326                registered: RegisteredClaims {
327                    expiry: subscriber_jwt_max_age.map(|subscriber_jwt_max_age| {
328                        let expires_at = SystemTime::now()
329                            .checked_add(subscriber_jwt_max_age.0)
330                            .expect("`expires_at` should fit in `SystemTime`");
331                        let timestamp: i64 = expires_at
332                            .duration_since(UNIX_EPOCH)
333                            .unwrap()
334                            .as_secs()
335                            .try_into()
336                            .expect("`timestamp` should fit in `i64`");
337                        timestamp.into()
338                    }),
339                    ..Default::default()
340                },
341                private: MercureJwtClaims {
342                    mercure: MercureClaim {
343                        publish: None,
344                        subscribe: Some(topic_selectors),
345                    },
346                },
347            },
348        );
349        let mercure_jwt = match mercure_jwt.encode(&jws::Secret::Bytes(
350            subscriber_jwt_secret.0.expose_secret().to_vec(),
351        )) {
352            Ok(mercure_jwt) => mercure_jwt,
353            Err(biscuit::errors::Error::UnsupportedOperation) => {
354                panic!("`mercure_jwt` should not already be encoded");
355            },
356            Err(err) => {
357                return Err(SubscriberJwtError {
358                    kind: SubscriberJwtErrorKind::EncodeAndSign,
359                    inner: err.into(),
360                })?;
361            },
362        };
363
364        Ok(Self(mercure_jwt))
365    }
366}
367
368impl From<Vec<u8>> for SubscriberJwtSecret {
369    fn from(vec: Vec<u8>) -> Self {
370        Self(SecretSlice::from(vec))
371    }
372}
373
374impl TryFrom<std::time::Duration> for SubscriberJwtMaxAge {
375    type Error = TryFromDurationError;
376
377    fn try_from(duration: std::time::Duration) -> Result<Self, Self::Error> {
378        if duration > crate::cookie::MAX_AGE_LIMIT {
379            return Err(Self::Error {
380                kind: TryFromDurationErrorKind::CookieLifetimeLimitExceeded,
381            })?;
382        }
383
384        Ok(Self(duration))
385    }
386}
387
388impl From<SubscriberJwtMaxAge> for std::time::Duration {
389    fn from(subscriber_jwt_max_age: SubscriberJwtMaxAge) -> Self {
390        subscriber_jwt_max_age.0
391    }
392}
393
394impl SubscriberJwtMaxAge {
395    pub const MAX: Self = Self(crate::cookie::MAX_AGE_LIMIT);
396}
397
398impl fmt::Display for TryFromDurationError {
399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400        match self.kind {
401            TryFromDurationErrorKind::CookieLifetimeLimitExceeded => {
402                const SECONDS_IN_DAYS: u64 = 60 * 60 * 24;
403                const LIMIT_DAYS: u64 = crate::cookie::MAX_AGE_LIMIT.as_secs() / SECONDS_IN_DAYS;
404                write!(f, "max-age must not be more than {LIMIT_DAYS} days")
405            },
406        }
407    }
408}
409
410impl Error for TryFromDurationError {}
411
412impl TryFromDurationError {
413    /// Returns the corresponding [`TryFromDurationErrorKind`] for this error.
414    #[must_use]
415    pub const fn kind(&self) -> &TryFromDurationErrorKind {
416        &self.kind
417    }
418}
419
420impl fmt::Display for SubscriberJwtError {
421    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
422        match self.kind {
423            SubscriberJwtErrorKind::EncodeAndSign => {
424                let err = self.inner.downcast_ref::<biscuit::errors::Error>().unwrap();
425                write!(f, "failed to encode and sign JWT: {err}")
426            },
427        }
428    }
429}
430
431impl Error for SubscriberJwtError {
432    fn source(&self) -> Option<&(dyn Error + 'static)> {
433        match self.kind {
434            SubscriberJwtErrorKind::EncodeAndSign => {
435                let err = self.inner.downcast_ref::<biscuit::errors::Error>().unwrap();
436                Some(err)
437            },
438        }
439    }
440}
441
442impl SubscriberJwtError {
443    /// Returns the corresponding [`SubscriberJwtErrorKind`] for this error.
444    #[must_use]
445    pub const fn kind(&self) -> &SubscriberJwtErrorKind {
446        &self.kind
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use anyhow::{Context as _, Result};
453
454    use super::*;
455
456    #[test]
457    fn it_creates_publisher_jwt_with_wildcard() -> Result<()> {
458        let publisher_jwt_secret =
459            PublisherJwtSecret::from(b"!ChangeThisMercureHubJWTSecretKey!".to_vec());
460        let publisher_jwt =
461            PublisherJwt::new(&publisher_jwt_secret, vec![TopicSelector::Wildcard])?;
462        let publisher_jwt = publisher_jwt.0.encoded().context("JWT is not encoded")?;
463        assert_eq!(
464            publisher_jwt.to_string(),
465            "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiKiJdfX0.\
466             a8cjcSRUAcHdnGNMKifA4BK5epRXxQI0UBp2XpNrBdw"
467        );
468        Ok(())
469    }
470
471    #[test]
472    fn it_creates_publisher_jwt_with_uri_template() -> Result<()> {
473        let publisher_jwt_secret =
474            PublisherJwtSecret::from(b"!ChangeThisMercureHubJWTSecretKey!".to_vec());
475        let publisher_jwt =
476            PublisherJwt::new(&publisher_jwt_secret, vec![TopicSelector::UriTemplate(
477                "https://example.com/books/{book_id}".try_into()?,
478            )])?;
479        let publisher_jwt = publisher_jwt.0.encoded().context("JWT is not encoded")?;
480        assert_eq!(
481            publisher_jwt.to_string(),
482            "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.\
483             eyJtZXJjdXJlIjp7InB1Ymxpc2giOlsiaHR0cHM6Ly9leGFtcGxlLmNvbS9ib29rcy97Ym9va19pZH0iXX19.\
484             eyl-c2BUWrnx6VZNBfKWnTI2t28yO5NcHUgn83womNE"
485        );
486        Ok(())
487    }
488
489    #[test]
490    fn it_creates_subscriber_jwt_with_wildcard() -> Result<()> {
491        let subscriber_jwt_secret =
492            SubscriberJwtSecret::from(b"!ChangeThisMercureHubJWTSecretKey!".to_vec());
493        let subscriber_jwt =
494            SubscriberJwt::new(&subscriber_jwt_secret, None, vec![TopicSelector::Wildcard])?;
495        let subscriber_jwt = subscriber_jwt.0.encoded().context("JWT is not encoded")?;
496        assert_eq!(
497            subscriber_jwt.to_string(),
498            "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyIqIl19fQ.\
499             TMzyyYqIldgBLhqpiOR9a_HBk7iiP60Pb4X65ICaouA"
500        );
501        Ok(())
502    }
503
504    #[test]
505    fn it_creates_subscriber_jwt_with_uri_template() -> Result<()> {
506        let subscriber_jwt_secret =
507            SubscriberJwtSecret::from(b"!ChangeThisMercureHubJWTSecretKey!".to_vec());
508        let subscriber_jwt = SubscriberJwt::new(&subscriber_jwt_secret, None, vec![
509            TopicSelector::UriTemplate("https://example.com/users/1/books/{book_id}".try_into()?),
510        ])?;
511        let subscriber_jwt = subscriber_jwt.0.encoded().context("JWT is not encoded")?;
512        assert_eq!(
513            subscriber_jwt.to_string(),
514            "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.\
515             eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6WyJodHRwczovL2V4YW1wbGUuY29tL3VzZXJzLzEvYm9va3Mve2Jvb2tfaWR9Il19fQ.\
516             8ctfXioRle93VxIwoCxikZtTBBSGrL_WtkXrS5wVPDY"
517        );
518        Ok(())
519    }
520}