Skip to main content

ullun/
jwt.rs

1//! HS256 JSON Web Tokens and a bearer-token middleware (feature `jwt`)
2//!
3//! Only `HS256` is accepted here (RS256 / ES256: see `ullun::jwt_keys`): tokens with any other `alg` (including `none`)
4//! are rejected, signatures are compared in constant time, and `exp` / `nbf`
5//! are checked. Secrets must be at least 32 bytes (RFC 7518, section 3.2).
6//!
7//! ```
8//! use ullun::jwt;
9//!
10//! let secret = b"0123456789abcdef0123456789abcdef";
11//! let token = jwt::encode(&serde_json::json!({ "sub": "ann", "exp": jwt::expires_in(3600) }), secret).unwrap();
12//! let claims: serde_json::Value = jwt::decode(&token, secret).unwrap();
13//! assert_eq!(claims["sub"], "ann");
14//! ```
15//!
16//! As middleware, valid claims are stored in `req.extensions`:
17//!
18//! ```no_run
19//! use ullun::prelude::*;
20//! use ullun::jwt::{Claims, JwtAuth};
21//!
22//! # async fn run() {
23//! App::new()
24//!     .group("/api", |g| {
25//!         g.middleware(JwtAuth::new("0123456789abcdef0123456789abcdef"))
26//!             .get("/me", |req: Request| async move {
27//!                 let claims = req.extensions.get::<Claims>().expect("set by JwtAuth");
28//!                 Ok(Response::json(&claims.0))
29//!             })
30//!     })
31//!     .run("127.0.0.1:3000")
32//!     .await
33//!     .unwrap();
34//! # }
35//! ```
36
37use crate::error::{Error, Result};
38use crate::middleware::{Middleware, Next};
39use crate::request::Request;
40use crate::response::Response;
41use async_trait::async_trait;
42use base64::engine::general_purpose::URL_SAFE_NO_PAD;
43use base64::Engine;
44use hmac::{Hmac, KeyInit, Mac};
45use serde::de::DeserializeOwned;
46use serde::Serialize;
47use serde_json::Value;
48use sha2::Sha256;
49use std::time::{SystemTime, UNIX_EPOCH};
50
51type HmacSha256 = Hmac<Sha256>;
52
53const MIN_SECRET_LEN: usize = 32;
54const HEADER: &str = r#"{"alg":"HS256","typ":"JWT"}"#;
55
56/// Verified claims, inserted into `Request::extensions` by [`JwtAuth`]
57#[derive(Debug, Clone, PartialEq)]
58pub struct Claims(pub Value);
59
60/// What `decode_with` checks besides signature, `exp` and `nbf`
61#[derive(Debug, Clone, Default)]
62pub struct Validation {
63    /// Clock skew tolerated for `exp` and `nbf`, in seconds
64    pub leeway_secs: u64,
65    /// Required `iss` claim
66    pub issuer: Option<String>,
67    /// Required `aud` claim (string or array containing it)
68    pub audience: Option<String>,
69}
70
71/// Seconds since the Unix epoch
72pub fn now() -> u64 {
73    SystemTime::now()
74        .duration_since(UNIX_EPOCH)
75        .map(|d| d.as_secs())
76        .unwrap_or(0)
77}
78
79/// `exp` value for a token that lives `secs` seconds from now
80pub fn expires_in(secs: u64) -> u64 {
81    now() + secs
82}
83
84fn mac(secret: &[u8]) -> Result<HmacSha256> {
85    if secret.len() < MIN_SECRET_LEN {
86        return Err(Error::internal(format!(
87            "JWT secret must be at least {} bytes",
88            MIN_SECRET_LEN
89        )));
90    }
91    HmacSha256::new_from_slice(secret).map_err(|_| Error::internal("Invalid JWT secret"))
92}
93
94/// Sign `claims` as an HS256 token
95pub fn encode<T: Serialize>(claims: &T, secret: &[u8]) -> Result<String> {
96    let payload =
97        serde_json::to_vec(claims).map_err(|e| Error::internal(format!("JWT claims: {}", e)))?;
98    let signing_input = format!(
99        "{}.{}",
100        URL_SAFE_NO_PAD.encode(HEADER),
101        URL_SAFE_NO_PAD.encode(payload)
102    );
103    let mut m = mac(secret)?;
104    m.update(signing_input.as_bytes());
105    let signature = URL_SAFE_NO_PAD.encode(m.finalize().into_bytes());
106    Ok(format!("{}.{}", signing_input, signature))
107}
108
109/// Verify signature, `exp` and `nbf` and return the claims
110pub fn decode<T: DeserializeOwned>(token: &str, secret: &[u8]) -> Result<T> {
111    decode_with(token, secret, &Validation::default())
112}
113
114/// Like [`decode`] with leeway, issuer and audience checks
115pub fn decode_with<T: DeserializeOwned>(
116    token: &str,
117    secret: &[u8],
118    validation: &Validation,
119) -> Result<T> {
120    let invalid = || Error::unauthorized("Invalid token");
121
122    let mut parts = token.split('.');
123    let (Some(h), Some(p), Some(s), None) =
124        (parts.next(), parts.next(), parts.next(), parts.next())
125    else {
126        return Err(invalid());
127    };
128
129    let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(h).map_err(|_| invalid())?)
130        .map_err(|_| invalid())?;
131    if header.get("alg").and_then(Value::as_str) != Some("HS256") {
132        return Err(invalid());
133    }
134
135    let signature = URL_SAFE_NO_PAD.decode(s).map_err(|_| invalid())?;
136    let mut m = mac(secret)?;
137    m.update(h.as_bytes());
138    m.update(b".");
139    m.update(p.as_bytes());
140    m.verify_slice(&signature).map_err(|_| invalid())?;
141
142    let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(p).map_err(|_| invalid())?)
143        .map_err(|_| invalid())?;
144    check_claims(&claims, validation, now())?;
145    serde_json::from_value(claims).map_err(|_| invalid())
146}
147
148pub(crate) fn check_claims(claims: &Value, v: &Validation, now: u64) -> Result<()> {
149    if let Some(exp) = claims.get("exp") {
150        let exp = exp
151            .as_u64()
152            .ok_or_else(|| Error::unauthorized("Invalid token"))?;
153        if now >= exp.saturating_add(v.leeway_secs) {
154            return Err(Error::unauthorized("Token expired"));
155        }
156    }
157    if let Some(nbf) = claims.get("nbf") {
158        let nbf = nbf
159            .as_u64()
160            .ok_or_else(|| Error::unauthorized("Invalid token"))?;
161        if now.saturating_add(v.leeway_secs) < nbf {
162            return Err(Error::unauthorized("Token not yet valid"));
163        }
164    }
165    if let Some(iss) = &v.issuer {
166        if claims.get("iss").and_then(Value::as_str) != Some(iss) {
167            return Err(Error::unauthorized("Invalid token"));
168        }
169    }
170    if let Some(aud) = &v.audience {
171        let ok = match claims.get("aud") {
172            Some(Value::String(a)) => a == aud,
173            Some(Value::Array(list)) => list.iter().any(|a| a.as_str() == Some(aud)),
174            _ => false,
175        };
176        if !ok {
177            return Err(Error::unauthorized("Invalid token"));
178        }
179    }
180    Ok(())
181}
182
183/// Middleware requiring `Authorization: Bearer <jwt>`
184///
185/// Answers `401` otherwise. CORS preflights pass through unchecked.
186pub struct JwtAuth {
187    secret: Vec<u8>,
188    #[cfg(feature = "jwt-asymmetric")]
189    keys: Option<crate::jwt_keys::KeySet>,
190    validation: Validation,
191}
192
193impl JwtAuth {
194    /// # Panics
195    /// If the secret is shorter than 32 bytes
196    pub fn new(secret: impl Into<Vec<u8>>) -> Self {
197        let secret = secret.into();
198        assert!(
199            secret.len() >= MIN_SECRET_LEN,
200            "JWT secret must be at least {} bytes",
201            MIN_SECRET_LEN
202        );
203        Self {
204            secret,
205            #[cfg(feature = "jwt-asymmetric")]
206            keys: None,
207            validation: Validation::default(),
208        }
209    }
210
211    /// Verify RS256 / ES256 tokens against `keys` instead of an HS256 secret
212    #[cfg(feature = "jwt-asymmetric")]
213    pub fn with_keys(keys: crate::jwt_keys::KeySet) -> Self {
214        Self {
215            secret: Vec::new(),
216            keys: Some(keys),
217            validation: Validation::default(),
218        }
219    }
220
221    pub fn validation(mut self, validation: Validation) -> Self {
222        self.validation = validation;
223        self
224    }
225}
226
227#[async_trait]
228impl Middleware for JwtAuth {
229    async fn handle(&self, mut req: Request, next: Next) -> Result<Response> {
230        if req.method == http::Method::OPTIONS {
231            return next.run(req).await;
232        }
233        let token = req
234            .headers
235            .get(http::header::AUTHORIZATION)
236            .and_then(|v| v.to_str().ok())
237            .and_then(|v| {
238                let (scheme, token) = v.split_once(' ')?;
239                scheme
240                    .eq_ignore_ascii_case("bearer")
241                    .then_some(token.trim())
242            })
243            .ok_or_else(|| Error::unauthorized("Missing bearer token"))?;
244
245        #[cfg(feature = "jwt-asymmetric")]
246        let claims: Value = match &self.keys {
247            Some(keys) => keys.decode(token, &self.validation)?,
248            None => decode_with(token, &self.secret, &self.validation)?,
249        };
250        #[cfg(not(feature = "jwt-asymmetric"))]
251        let claims: Value = decode_with(token, &self.secret, &self.validation)?;
252        req.extensions.insert(Claims(claims));
253        next.run(req).await
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use serde_json::json;
261
262    const SECRET: &[u8] = b"0123456789abcdef0123456789abcdef";
263
264    fn tamper(token: &str, part: usize, f: impl Fn(&str) -> String) -> String {
265        let mut p: Vec<String> = token.split('.').map(String::from).collect();
266        p[part] = f(&p[part]);
267        p.join(".")
268    }
269
270    #[test]
271    fn roundtrip_with_typed_claims() {
272        #[derive(Serialize, serde::Deserialize, PartialEq, Debug)]
273        struct C {
274            sub: String,
275            exp: u64,
276        }
277        let c = C {
278            sub: "ann".into(),
279            exp: expires_in(60),
280        };
281        let token = encode(&c, SECRET).unwrap();
282        assert_eq!(token.split('.').count(), 3);
283        assert_eq!(decode::<C>(&token, SECRET).unwrap(), c);
284    }
285
286    #[test]
287    fn matches_the_jwt_io_reference_vector() {
288        // https://jwt.io default HS256 example (secret is shorter than our
289        // minimum, so check the primitives directly)
290        let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
291        let payload = "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ";
292        assert_eq!(URL_SAFE_NO_PAD.encode(HEADER), header);
293
294        let mut m = HmacSha256::new_from_slice(b"your-256-bit-secret").unwrap();
295        m.update(format!("{}.{}", header, payload).as_bytes());
296        assert_eq!(
297            URL_SAFE_NO_PAD.encode(m.finalize().into_bytes()),
298            "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
299        );
300    }
301
302    #[test]
303    fn rejects_tampering_wrong_secret_and_malformed() {
304        let token = encode(&json!({ "sub": "ann" }), SECRET).unwrap();
305        let other = b"ffffffffffffffffffffffffffffffff";
306        assert!(decode::<Value>(&token, other).is_err());
307        assert!(decode::<Value>(
308            &tamper(&token, 1, |_| URL_SAFE_NO_PAD.encode(r#"{"sub":"root"}"#)),
309            SECRET
310        )
311        .is_err());
312        assert!(decode::<Value>(&tamper(&token, 2, |s| format!("{}A", s)), SECRET).is_err());
313        for bad in ["", "a", "a.b", "a.b.c", "a.b.c.d", "..", "!!!.!!!.!!!"] {
314            assert_eq!(
315                decode::<Value>(bad, SECRET).unwrap_err().status_code(),
316                401,
317                "{bad}"
318            );
319        }
320    }
321
322    #[test]
323    fn rejects_alg_none_and_other_algs() {
324        let payload = URL_SAFE_NO_PAD.encode(r#"{"sub":"root"}"#);
325        for alg in ["none", "HS512", "RS256", "hs256"] {
326            let header = URL_SAFE_NO_PAD.encode(format!(r#"{{"alg":"{}","typ":"JWT"}}"#, alg));
327            let unsigned = format!("{}.{}.", header, payload);
328            assert!(decode::<Value>(&unsigned, SECRET).is_err(), "{alg}");
329
330            // even with a correct HS256 signature over that header
331            let mut m = HmacSha256::new_from_slice(SECRET).unwrap();
332            m.update(format!("{}.{}", header, payload).as_bytes());
333            let signed = format!(
334                "{}.{}.{}",
335                header,
336                payload,
337                URL_SAFE_NO_PAD.encode(m.finalize().into_bytes())
338            );
339            assert!(decode::<Value>(&signed, SECRET).is_err(), "{alg} signed");
340        }
341    }
342
343    #[test]
344    fn exp_nbf_leeway_issuer_audience() {
345        let v = Validation::default();
346        let n = 1_000_000;
347        assert!(check_claims(&json!({ "exp": n + 1 }), &v, n).is_ok());
348        assert_eq!(
349            check_claims(&json!({ "exp": n }), &v, n)
350                .unwrap_err()
351                .message(),
352            "Token expired"
353        );
354        assert!(check_claims(
355            &json!({ "exp": n }),
356            &Validation {
357                leeway_secs: 5,
358                ..Default::default()
359            },
360            n
361        )
362        .is_ok());
363        assert!(check_claims(&json!({ "nbf": n + 10 }), &v, n).is_err());
364        assert!(check_claims(
365            &json!({ "nbf": n + 10 }),
366            &Validation {
367                leeway_secs: 10,
368                ..Default::default()
369            },
370            n
371        )
372        .is_ok());
373        assert!(check_claims(&json!({ "exp": "soon" }), &v, n).is_err());
374
375        let strict = Validation {
376            issuer: Some("me".into()),
377            audience: Some("api".into()),
378            ..Default::default()
379        };
380        assert!(check_claims(&json!({ "iss": "me", "aud": "api" }), &strict, n).is_ok());
381        assert!(check_claims(&json!({ "iss": "me", "aud": ["x", "api"] }), &strict, n).is_ok());
382        assert!(check_claims(&json!({ "iss": "you", "aud": "api" }), &strict, n).is_err());
383        assert!(check_claims(&json!({ "iss": "me" }), &strict, n).is_err());
384    }
385
386    #[test]
387    fn expired_token_is_rejected_end_to_end() {
388        let token = encode(&json!({ "exp": now() - 10 }), SECRET).unwrap();
389        assert_eq!(
390            decode::<Value>(&token, SECRET).unwrap_err().message(),
391            "Token expired"
392        );
393    }
394
395    #[test]
396    fn short_secrets_are_refused() {
397        assert!(encode(&json!({}), b"short").is_err());
398        let token = encode(&json!({}), SECRET).unwrap();
399        assert!(decode::<Value>(&token, b"short").is_err());
400    }
401
402    #[test]
403    #[should_panic(expected = "at least 32 bytes")]
404    fn middleware_constructor_panics_on_short_secret() {
405        let _ = JwtAuth::new("short");
406    }
407}