Skip to main content

postrust_auth/
jwt.rs

1//! JWT token validation.
2//!
3//! The signature is checked by the library; the claims are checked here. That
4//! split is deliberate: a claim can be wrong in two different ways -- absent,
5//! or present and not the kind of thing that claim is -- and the client needs
6//! to be told which. A library that folds both into "invalid token" answers a
7//! question nobody asked.
8
9use super::{AuthResult, ClaimFault, JwtConfig, JwtError};
10use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
11use std::collections::HashMap;
12
13/// How far ahead of the server a token's clock may be.
14///
15/// A token minted a moment ago on a machine whose clock runs a little fast is
16/// not a forgery, and rejecting it would make a working deployment fail
17/// intermittently and unreproducibly.
18///
19/// Only in that direction. `exp` is checked to the second, because leniency
20/// there is leniency about a token that has been *withdrawn* -- it keeps a
21/// revoked session alive past the moment its issuer said it ended, and it is a
22/// window an attacker holding a stale token gets for free. `nbf` and `iat`
23/// describe a token that is not valid *yet*, where the same slack costs
24/// nothing and is the only remedy for a clock nobody controls.
25const ALLOWED_SKEW: i64 = 30;
26
27/// Validate a JWT token and extract claims.
28pub fn validate_token(token: &str, config: &JwtConfig) -> Result<AuthResult, JwtError> {
29    let secret = config.secret.as_ref().ok_or(JwtError::SecretMissing)?;
30
31    // Decode secret
32    let key_bytes = if config.secret_is_base64 {
33        base64_decode(secret)?
34    } else {
35        secret.as_bytes().to_vec()
36    };
37
38    let key = DecodingKey::from_secret(&key_bytes);
39
40    // Signature only. Every claim the library could check here, it checks
41    // differently from the way this API's contract says to -- a missing `exp`
42    // is not an error, an `aud` that is not a string has a code of its own --
43    // so all of it is done below, where the answers can be the right ones.
44    let mut validation = Validation::new(Algorithm::HS256);
45    validation.validate_exp = false;
46    validation.validate_nbf = false;
47    validation.validate_aud = false;
48    validation.required_spec_claims.clear();
49
50    let token_data = decode::<HashMap<String, serde_json::Value>>(token, &key, &validation)
51        .map_err(map_jwt_error)?;
52    let claims = token_data.claims;
53
54    check_claims(&claims, config, now())?;
55
56    // The role decides what the request may do, so a token naming none, on a
57    // server with no anonymous role, is a request with no identity at all.
58    let role = role_of(&claims, config).ok_or(JwtError::NoIdentity)?;
59
60    Ok(AuthResult { role, claims })
61}
62
63/// The role a token names, or the anonymous one where it names none.
64///
65/// A role is a name, and only a string is one. Rendering anything else --
66/// `"role": 42`, `"role": {"a": 1}` -- would make a database role nobody
67/// created out of a claim that does not name one, so a claim of the wrong
68/// shape is read as a token that named no role at all and falls back the same
69/// way an absent claim does.
70fn role_of(claims: &HashMap<String, serde_json::Value>, config: &JwtConfig) -> Option<String> {
71    claims
72        .get(&config.role_claim_key)
73        .and_then(serde_json::Value::as_str)
74        .map(str::to_string)
75        .or_else(|| config.anon_role.clone())
76}
77
78/// The registered claims, checked in the order the contract checks them.
79///
80/// Order is part of the answer: a token that is both expired and out of
81/// audience is reported as expired, because that is the first thing wrong with
82/// it and the one the client can act on.
83fn check_claims(
84    claims: &HashMap<String, serde_json::Value>,
85    config: &JwtConfig,
86    now: i64,
87) -> Result<(), JwtError> {
88    // A claim that is absent is not a claim that is wrong. Only one that is
89    // present in the wrong shape is.
90    let number = |name: &'static str| -> Result<Option<i64>, JwtError> {
91        match claims.get(name) {
92            None | Some(serde_json::Value::Null) => Ok(None),
93            Some(value) => value
94                .as_f64()
95                .map(|seconds| seconds as i64)
96                .map(Some)
97                .ok_or(JwtError::Claim(ClaimFault::NotANumber(name))),
98        }
99    };
100
101    if let Some(exp) = number("exp")? {
102        if now > exp {
103            return Err(JwtError::Claim(ClaimFault::Expired));
104        }
105    }
106    if let Some(nbf) = number("nbf")? {
107        if now + ALLOWED_SKEW < nbf {
108            return Err(JwtError::Claim(ClaimFault::NotYetValid));
109        }
110    }
111    if let Some(iat) = number("iat")? {
112        if now + ALLOWED_SKEW < iat {
113            return Err(JwtError::Claim(ClaimFault::IssuedInFuture));
114        }
115    }
116
117    // With no audience configured the server accepts any, but the claim still
118    // has to *be* an audience: a list with an object in it is a malformed
119    // token, not a token meant for somebody else. That is the difference
120    // between a client fixing how it mints tokens and hunting for a setting
121    // this server does not have.
122    let matches = |aud: &str| match &config.audience {
123        Some(wanted) => aud == wanted,
124        None => true,
125    };
126    match claims.get("aud") {
127        None | Some(serde_json::Value::Null) => {}
128        Some(serde_json::Value::String(aud)) => {
129            if !matches(aud) {
130                return Err(JwtError::Claim(ClaimFault::NotInAudience));
131            }
132        }
133        Some(serde_json::Value::Array(auds)) => {
134            if !auds.iter().all(serde_json::Value::is_string) {
135                return Err(JwtError::Claim(ClaimFault::AudienceNotStrings));
136            }
137            // A list naming no audience excludes nobody.
138            let admitted = auds
139                .iter()
140                .filter_map(serde_json::Value::as_str)
141                .any(matches);
142            if !auds.is_empty() && !admitted {
143                return Err(JwtError::Claim(ClaimFault::NotInAudience));
144            }
145        }
146        Some(_) => return Err(JwtError::Claim(ClaimFault::AudienceNotStrings)),
147    }
148
149    Ok(())
150}
151
152/// Seconds since the epoch.
153fn now() -> i64 {
154    std::time::SystemTime::now()
155        .duration_since(std::time::UNIX_EPOCH)
156        .map(|elapsed| elapsed.as_secs() as i64)
157        .unwrap_or_default()
158}
159
160/// Decode base64 secret.
161fn base64_decode(s: &str) -> Result<Vec<u8>, JwtError> {
162    use base64::{engine::general_purpose::STANDARD, Engine};
163    STANDARD.decode(s).map_err(|_| JwtError::NoSuitableKey)
164}
165
166/// Map jsonwebtoken error to JwtError.
167fn map_jwt_error(e: jsonwebtoken::errors::Error) -> JwtError {
168    use jsonwebtoken::errors::ErrorKind;
169
170    match e.kind() {
171        ErrorKind::InvalidSignature => JwtError::InvalidSignature,
172        // The token was read and its claims were not a JSON object, which is a
173        // different failure from a key that could not decode it at all.
174        ErrorKind::Json(_) => JwtError::Claim(ClaimFault::Unparsable),
175        _ => JwtError::NoSuitableKey,
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    fn config() -> JwtConfig {
184        JwtConfig {
185            secret: Some("reallyreallyreallyreallyverysafe".into()),
186            ..JwtConfig::default()
187        }
188    }
189
190    fn claims(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
191        match json {
192            serde_json::Value::Object(map) => map.into_iter().collect(),
193            _ => panic!("claims must be an object"),
194        }
195    }
196
197    /// The token in PostgREST's own suite carries `"aud": [{...}, "test"]`,
198    /// which is neither an audience nor a list of them.
199    #[test]
200    fn an_audience_that_is_not_a_string_is_a_malformed_token() {
201        assert!(matches!(
202            check_claims(
203                &claims(serde_json::json!({"aud": [{"invalid": "value"}, "test"]})),
204                &config(),
205                0
206            ),
207            Err(JwtError::Claim(ClaimFault::AudienceNotStrings))
208        ));
209    }
210
211    /// With no audience configured, any audience is this server's.
212    #[test]
213    fn any_audience_is_accepted_when_none_is_configured() {
214        assert!(check_claims(
215            &claims(serde_json::json!({"aud": ["someone", "else"]})),
216            &config(),
217            0
218        )
219        .is_ok());
220    }
221
222    /// A list naming no audience excludes nobody.
223    #[test]
224    fn an_empty_audience_list_excludes_no_one() {
225        let configured = JwtConfig {
226            audience: Some("us".into()),
227            ..config()
228        };
229        assert!(check_claims(&claims(serde_json::json!({"aud": []})), &configured, 0).is_ok());
230        assert!(matches!(
231            check_claims(
232                &claims(serde_json::json!({"aud": ["them"]})),
233                &configured,
234                0
235            ),
236            Err(JwtError::Claim(ClaimFault::NotInAudience))
237        ));
238    }
239
240    /// A token that does not say when it expires does not expire.
241    #[test]
242    fn a_token_without_exp_is_not_rejected_for_having_none() {
243        assert!(check_claims(&claims(serde_json::json!({"role": "x"})), &config(), 0).is_ok());
244    }
245
246    /// Present, but not the kind of thing that claim is.
247    #[test]
248    fn a_timestamp_claim_that_is_not_a_number_is_reported_as_such() {
249        assert!(matches!(
250            check_claims(&claims(serde_json::json!({"exp": "soon"})), &config(), 0),
251            Err(JwtError::Claim(ClaimFault::NotANumber("exp")))
252        ));
253    }
254
255    /// A clock a few seconds ahead is not a forgery.
256    #[test]
257    fn a_token_that_is_not_valid_yet_is_given_the_benefit_of_the_clock() {
258        let issued_moments_hence = claims(serde_json::json!({"iat": 1_000}));
259        assert!(check_claims(&issued_moments_hence, &config(), 990).is_ok());
260        assert!(matches!(
261            check_claims(&issued_moments_hence, &config(), 900),
262            Err(JwtError::Claim(ClaimFault::IssuedInFuture))
263        ));
264
265        let valid_moments_hence = claims(serde_json::json!({"nbf": 1_000}));
266        assert!(check_claims(&valid_moments_hence, &config(), 990).is_ok());
267        assert!(matches!(
268            check_claims(&valid_moments_hence, &config(), 900),
269            Err(JwtError::Claim(ClaimFault::NotYetValid))
270        ));
271    }
272
273    /// The same slack the other way would keep a withdrawn token alive past
274    /// the second its issuer said it ended.
275    #[test]
276    fn an_expiry_is_honoured_to_the_second() {
277        let expires_at = claims(serde_json::json!({"exp": 1_000}));
278        assert!(check_claims(&expires_at, &config(), 1_000).is_ok());
279        assert!(matches!(
280            check_claims(&expires_at, &config(), 1_001),
281            Err(JwtError::Claim(ClaimFault::Expired))
282        ));
283    }
284
285    /// A role is a name, and a claim that is not a string does not carry one.
286    #[test]
287    fn a_role_claim_that_is_not_a_string_names_no_role() {
288        let configured = JwtConfig {
289            anon_role: Some("anon".into()),
290            ..config()
291        };
292        for value in [
293            serde_json::json!(42),
294            serde_json::json!({"a": 1}),
295            serde_json::json!(["admin"]),
296        ] {
297            let mut claims = HashMap::new();
298            claims.insert("role".to_string(), value.clone());
299            assert_eq!(
300                role_of(&claims, &configured),
301                Some("anon".to_string()),
302                "{value}"
303            );
304        }
305
306        let mut named = HashMap::new();
307        named.insert("role".to_string(), serde_json::json!("web_user"));
308        assert_eq!(role_of(&named, &configured), Some("web_user".to_string()));
309    }
310
311    /// The first thing wrong with the token is what the client is told.
312    #[test]
313    fn an_expired_token_is_reported_as_expired_whatever_else_is_wrong() {
314        assert!(matches!(
315            check_claims(
316                &claims(serde_json::json!({"exp": 1_000, "aud": [{"bad": 1}]})),
317                &config(),
318                9_000
319            ),
320            Err(JwtError::Claim(ClaimFault::Expired))
321        ));
322    }
323}