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: 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
148fn 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    validation: Validation,
189}
190
191impl JwtAuth {
192    /// # Panics
193    /// If the secret is shorter than 32 bytes
194    pub fn new(secret: impl Into<Vec<u8>>) -> Self {
195        let secret = secret.into();
196        assert!(
197            secret.len() >= MIN_SECRET_LEN,
198            "JWT secret must be at least {} bytes",
199            MIN_SECRET_LEN
200        );
201        Self {
202            secret,
203            validation: Validation::default(),
204        }
205    }
206
207    pub fn validation(mut self, validation: Validation) -> Self {
208        self.validation = validation;
209        self
210    }
211}
212
213#[async_trait]
214impl Middleware for JwtAuth {
215    async fn handle(&self, mut req: Request, next: Next) -> Result<Response> {
216        if req.method == http::Method::OPTIONS {
217            return next.run(req).await;
218        }
219        let token = req
220            .headers
221            .get(http::header::AUTHORIZATION)
222            .and_then(|v| v.to_str().ok())
223            .and_then(|v| {
224                let (scheme, token) = v.split_once(' ')?;
225                scheme
226                    .eq_ignore_ascii_case("bearer")
227                    .then_some(token.trim())
228            })
229            .ok_or_else(|| Error::unauthorized("Missing bearer token"))?;
230
231        let claims: Value = decode_with(token, &self.secret, &self.validation)?;
232        req.extensions.insert(Claims(claims));
233        next.run(req).await
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use serde_json::json;
241
242    const SECRET: &[u8] = b"0123456789abcdef0123456789abcdef";
243
244    fn tamper(token: &str, part: usize, f: impl Fn(&str) -> String) -> String {
245        let mut p: Vec<String> = token.split('.').map(String::from).collect();
246        p[part] = f(&p[part]);
247        p.join(".")
248    }
249
250    #[test]
251    fn roundtrip_with_typed_claims() {
252        #[derive(Serialize, serde::Deserialize, PartialEq, Debug)]
253        struct C {
254            sub: String,
255            exp: u64,
256        }
257        let c = C {
258            sub: "ann".into(),
259            exp: expires_in(60),
260        };
261        let token = encode(&c, SECRET).unwrap();
262        assert_eq!(token.split('.').count(), 3);
263        assert_eq!(decode::<C>(&token, SECRET).unwrap(), c);
264    }
265
266    #[test]
267    fn matches_the_jwt_io_reference_vector() {
268        // https://jwt.io default HS256 example (secret is shorter than our
269        // minimum, so check the primitives directly)
270        let header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
271        let payload = "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ";
272        assert_eq!(URL_SAFE_NO_PAD.encode(HEADER), header);
273
274        let mut m = HmacSha256::new_from_slice(b"your-256-bit-secret").unwrap();
275        m.update(format!("{}.{}", header, payload).as_bytes());
276        assert_eq!(
277            URL_SAFE_NO_PAD.encode(m.finalize().into_bytes()),
278            "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
279        );
280    }
281
282    #[test]
283    fn rejects_tampering_wrong_secret_and_malformed() {
284        let token = encode(&json!({ "sub": "ann" }), SECRET).unwrap();
285        let other = b"ffffffffffffffffffffffffffffffff";
286        assert!(decode::<Value>(&token, other).is_err());
287        assert!(decode::<Value>(
288            &tamper(&token, 1, |_| URL_SAFE_NO_PAD.encode(r#"{"sub":"root"}"#)),
289            SECRET
290        )
291        .is_err());
292        assert!(decode::<Value>(&tamper(&token, 2, |s| format!("{}A", s)), SECRET).is_err());
293        for bad in ["", "a", "a.b", "a.b.c", "a.b.c.d", "..", "!!!.!!!.!!!"] {
294            assert_eq!(
295                decode::<Value>(bad, SECRET).unwrap_err().status_code(),
296                401,
297                "{bad}"
298            );
299        }
300    }
301
302    #[test]
303    fn rejects_alg_none_and_other_algs() {
304        let payload = URL_SAFE_NO_PAD.encode(r#"{"sub":"root"}"#);
305        for alg in ["none", "HS512", "RS256", "hs256"] {
306            let header = URL_SAFE_NO_PAD.encode(format!(r#"{{"alg":"{}","typ":"JWT"}}"#, alg));
307            let unsigned = format!("{}.{}.", header, payload);
308            assert!(decode::<Value>(&unsigned, SECRET).is_err(), "{alg}");
309
310            // even with a correct HS256 signature over that header
311            let mut m = HmacSha256::new_from_slice(SECRET).unwrap();
312            m.update(format!("{}.{}", header, payload).as_bytes());
313            let signed = format!(
314                "{}.{}.{}",
315                header,
316                payload,
317                URL_SAFE_NO_PAD.encode(m.finalize().into_bytes())
318            );
319            assert!(decode::<Value>(&signed, SECRET).is_err(), "{alg} signed");
320        }
321    }
322
323    #[test]
324    fn exp_nbf_leeway_issuer_audience() {
325        let v = Validation::default();
326        let n = 1_000_000;
327        assert!(check_claims(&json!({ "exp": n + 1 }), &v, n).is_ok());
328        assert_eq!(
329            check_claims(&json!({ "exp": n }), &v, n)
330                .unwrap_err()
331                .message(),
332            "Token expired"
333        );
334        assert!(check_claims(
335            &json!({ "exp": n }),
336            &Validation {
337                leeway_secs: 5,
338                ..Default::default()
339            },
340            n
341        )
342        .is_ok());
343        assert!(check_claims(&json!({ "nbf": n + 10 }), &v, n).is_err());
344        assert!(check_claims(
345            &json!({ "nbf": n + 10 }),
346            &Validation {
347                leeway_secs: 10,
348                ..Default::default()
349            },
350            n
351        )
352        .is_ok());
353        assert!(check_claims(&json!({ "exp": "soon" }), &v, n).is_err());
354
355        let strict = Validation {
356            issuer: Some("me".into()),
357            audience: Some("api".into()),
358            ..Default::default()
359        };
360        assert!(check_claims(&json!({ "iss": "me", "aud": "api" }), &strict, n).is_ok());
361        assert!(check_claims(&json!({ "iss": "me", "aud": ["x", "api"] }), &strict, n).is_ok());
362        assert!(check_claims(&json!({ "iss": "you", "aud": "api" }), &strict, n).is_err());
363        assert!(check_claims(&json!({ "iss": "me" }), &strict, n).is_err());
364    }
365
366    #[test]
367    fn expired_token_is_rejected_end_to_end() {
368        let token = encode(&json!({ "exp": now() - 10 }), SECRET).unwrap();
369        assert_eq!(
370            decode::<Value>(&token, SECRET).unwrap_err().message(),
371            "Token expired"
372        );
373    }
374
375    #[test]
376    fn short_secrets_are_refused() {
377        assert!(encode(&json!({}), b"short").is_err());
378        let token = encode(&json!({}), SECRET).unwrap();
379        assert!(decode::<Value>(&token, b"short").is_err());
380    }
381
382    #[test]
383    #[should_panic(expected = "at least 32 bytes")]
384    fn middleware_constructor_panics_on_short_secret() {
385        let _ = JwtAuth::new("short");
386    }
387}