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    lookup_claim(claims, &config.role_claim_key)
72        .and_then(serde_json::Value::as_str)
73        .map(str::to_string)
74        .or_else(|| config.anon_role.clone())
75}
76
77/// Resolve a claim key, which may name a nested claim.
78///
79/// The literal key is tried first, and a dotted path only if that misses. The
80/// order matters: a top-level claim name is allowed to contain dots, and the
81/// ones that do are exactly the ones that must not be re-read as a path --
82/// Hasura mints `https://hasura.io/jwt/claims`, and splitting that would look
83/// for a claim named `https://hasura` instead.
84///
85/// Only objects are walked. A path that runs into a string or a number stops
86/// there and finds nothing, rather than pretending the rest of the path was
87/// satisfied.
88fn lookup_claim<'a>(
89    claims: &'a HashMap<String, serde_json::Value>,
90    key: &str,
91) -> Option<&'a serde_json::Value> {
92    if let Some(value) = claims.get(key) {
93        return Some(value);
94    }
95
96    let (first, rest) = key.split_once('.')?;
97    let mut current = claims.get(first)?;
98    for segment in rest.split('.') {
99        current = current.as_object()?.get(segment)?;
100    }
101    Some(current)
102}
103
104/// The registered claims, checked in the order the contract checks them.
105///
106/// Order is part of the answer: a token that is both expired and out of
107/// audience is reported as expired, because that is the first thing wrong with
108/// it and the one the client can act on.
109fn check_claims(
110    claims: &HashMap<String, serde_json::Value>,
111    config: &JwtConfig,
112    now: i64,
113) -> Result<(), JwtError> {
114    // A claim that is absent is not a claim that is wrong. Only one that is
115    // present in the wrong shape is.
116    let number = |name: &'static str| -> Result<Option<i64>, JwtError> {
117        match claims.get(name) {
118            None | Some(serde_json::Value::Null) => Ok(None),
119            Some(value) => value
120                .as_f64()
121                .map(|seconds| seconds as i64)
122                .map(Some)
123                .ok_or(JwtError::Claim(ClaimFault::NotANumber(name))),
124        }
125    };
126
127    if let Some(exp) = number("exp")? {
128        if now > exp {
129            return Err(JwtError::Claim(ClaimFault::Expired));
130        }
131    }
132    if let Some(nbf) = number("nbf")? {
133        if now + ALLOWED_SKEW < nbf {
134            return Err(JwtError::Claim(ClaimFault::NotYetValid));
135        }
136    }
137    if let Some(iat) = number("iat")? {
138        if now + ALLOWED_SKEW < iat {
139            return Err(JwtError::Claim(ClaimFault::IssuedInFuture));
140        }
141    }
142
143    // With no audience configured the server accepts any, but the claim still
144    // has to *be* an audience: a list with an object in it is a malformed
145    // token, not a token meant for somebody else. That is the difference
146    // between a client fixing how it mints tokens and hunting for a setting
147    // this server does not have.
148    let matches = |aud: &str| match &config.audience {
149        Some(wanted) => aud == wanted,
150        None => true,
151    };
152    match claims.get("aud") {
153        None | Some(serde_json::Value::Null) => {}
154        Some(serde_json::Value::String(aud)) => {
155            if !matches(aud) {
156                return Err(JwtError::Claim(ClaimFault::NotInAudience));
157            }
158        }
159        Some(serde_json::Value::Array(auds)) => {
160            if !auds.iter().all(serde_json::Value::is_string) {
161                return Err(JwtError::Claim(ClaimFault::AudienceNotStrings));
162            }
163            // A list naming no audience excludes nobody.
164            let admitted = auds
165                .iter()
166                .filter_map(serde_json::Value::as_str)
167                .any(matches);
168            if !auds.is_empty() && !admitted {
169                return Err(JwtError::Claim(ClaimFault::NotInAudience));
170            }
171        }
172        Some(_) => return Err(JwtError::Claim(ClaimFault::AudienceNotStrings)),
173    }
174
175    Ok(())
176}
177
178/// Seconds since the epoch.
179fn now() -> i64 {
180    std::time::SystemTime::now()
181        .duration_since(std::time::UNIX_EPOCH)
182        .map(|elapsed| elapsed.as_secs() as i64)
183        .unwrap_or_default()
184}
185
186/// Decode base64 secret.
187fn base64_decode(s: &str) -> Result<Vec<u8>, JwtError> {
188    use base64::{engine::general_purpose::STANDARD, Engine};
189    STANDARD.decode(s).map_err(|_| JwtError::NoSuitableKey)
190}
191
192/// Map jsonwebtoken error to JwtError.
193fn map_jwt_error(e: jsonwebtoken::errors::Error) -> JwtError {
194    use jsonwebtoken::errors::ErrorKind;
195
196    match e.kind() {
197        ErrorKind::InvalidSignature => JwtError::InvalidSignature,
198        // The token was read and its claims were not a JSON object, which is a
199        // different failure from a key that could not decode it at all.
200        ErrorKind::Json(_) => JwtError::Claim(ClaimFault::Unparsable),
201        _ => JwtError::NoSuitableKey,
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn config() -> JwtConfig {
210        JwtConfig {
211            secret: Some("reallyreallyreallyreallyverysafe".into()),
212            ..JwtConfig::default()
213        }
214    }
215
216    fn claims(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
217        match json {
218            serde_json::Value::Object(map) => map.into_iter().collect(),
219            _ => panic!("claims must be an object"),
220        }
221    }
222
223    fn role_with(key: &str, json: serde_json::Value) -> Option<String> {
224        let config = JwtConfig {
225            role_claim_key: key.to_string(),
226            ..config()
227        };
228        role_of(&claims(json), &config)
229    }
230
231    #[test]
232    fn a_flat_role_claim_is_found() {
233        assert_eq!(
234            role_with("role", serde_json::json!({"role": "web_user"})),
235            Some("web_user".to_string())
236        );
237        assert_eq!(
238            role_with("my_role", serde_json::json!({"my_role": "web_user"})),
239            Some("web_user".to_string())
240        );
241    }
242
243    #[test]
244    fn a_dotted_key_reaches_a_nested_claim() {
245        // `PGRST_JWT_ROLE_CLAIM_KEY="user.role"` over
246        // `{"user": {"role": "..."}}` is the example the documentation gives.
247        // A flat `get` looks for a claim literally named `user.role`, finds
248        // nothing, and every such request silently became anonymous.
249        assert_eq!(
250            role_with(
251                "user.role",
252                serde_json::json!({"user": {"role": "web_user"}})
253            ),
254            Some("web_user".to_string())
255        );
256        assert_eq!(
257            role_with("a.b.c", serde_json::json!({"a": {"b": {"c": "web_user"}}})),
258            Some("web_user".to_string())
259        );
260    }
261
262    #[test]
263    fn a_literal_dotted_claim_wins_over_the_path_reading() {
264        // Hasura mints `https://hasura.io/jwt/claims`, and a namespaced claim
265        // name full of dots must not be split into a path.
266        assert_eq!(
267            role_with(
268                "https://hasura.io/jwt/claims",
269                serde_json::json!({"https://hasura.io/jwt/claims": "web_user"})
270            ),
271            Some("web_user".to_string())
272        );
273        // Both present: the literal key is the one that was asked for.
274        assert_eq!(
275            role_with(
276                "user.role",
277                serde_json::json!({"user.role": "literal", "user": {"role": "nested"}})
278            ),
279            Some("literal".to_string())
280        );
281    }
282
283    #[test]
284    fn a_path_that_runs_into_a_non_object_finds_nothing() {
285        // Falls back to the anonymous role rather than inventing one.
286        let anon = JwtConfig {
287            role_claim_key: "user.role".to_string(),
288            anon_role: Some("web_anon".to_string()),
289            ..config()
290        };
291        assert_eq!(
292            role_of(&claims(serde_json::json!({"user": "not-an-object"})), &anon),
293            Some("web_anon".to_string())
294        );
295        assert_eq!(
296            role_of(&claims(serde_json::json!({"user": {"other": 1}})), &anon),
297            Some("web_anon".to_string())
298        );
299    }
300
301    /// The token in PostgREST's own suite carries `"aud": [{...}, "test"]`,
302    /// which is neither an audience nor a list of them.
303    #[test]
304    fn an_audience_that_is_not_a_string_is_a_malformed_token() {
305        assert!(matches!(
306            check_claims(
307                &claims(serde_json::json!({"aud": [{"invalid": "value"}, "test"]})),
308                &config(),
309                0
310            ),
311            Err(JwtError::Claim(ClaimFault::AudienceNotStrings))
312        ));
313    }
314
315    /// With no audience configured, any audience is this server's.
316    #[test]
317    fn any_audience_is_accepted_when_none_is_configured() {
318        assert!(check_claims(
319            &claims(serde_json::json!({"aud": ["someone", "else"]})),
320            &config(),
321            0
322        )
323        .is_ok());
324    }
325
326    /// A list naming no audience excludes nobody.
327    #[test]
328    fn an_empty_audience_list_excludes_no_one() {
329        let configured = JwtConfig {
330            audience: Some("us".into()),
331            ..config()
332        };
333        assert!(check_claims(&claims(serde_json::json!({"aud": []})), &configured, 0).is_ok());
334        assert!(matches!(
335            check_claims(
336                &claims(serde_json::json!({"aud": ["them"]})),
337                &configured,
338                0
339            ),
340            Err(JwtError::Claim(ClaimFault::NotInAudience))
341        ));
342    }
343
344    /// A token that does not say when it expires does not expire.
345    #[test]
346    fn a_token_without_exp_is_not_rejected_for_having_none() {
347        assert!(check_claims(&claims(serde_json::json!({"role": "x"})), &config(), 0).is_ok());
348    }
349
350    /// Present, but not the kind of thing that claim is.
351    #[test]
352    fn a_timestamp_claim_that_is_not_a_number_is_reported_as_such() {
353        assert!(matches!(
354            check_claims(&claims(serde_json::json!({"exp": "soon"})), &config(), 0),
355            Err(JwtError::Claim(ClaimFault::NotANumber("exp")))
356        ));
357    }
358
359    /// A clock a few seconds ahead is not a forgery.
360    #[test]
361    fn a_token_that_is_not_valid_yet_is_given_the_benefit_of_the_clock() {
362        let issued_moments_hence = claims(serde_json::json!({"iat": 1_000}));
363        assert!(check_claims(&issued_moments_hence, &config(), 990).is_ok());
364        assert!(matches!(
365            check_claims(&issued_moments_hence, &config(), 900),
366            Err(JwtError::Claim(ClaimFault::IssuedInFuture))
367        ));
368
369        let valid_moments_hence = claims(serde_json::json!({"nbf": 1_000}));
370        assert!(check_claims(&valid_moments_hence, &config(), 990).is_ok());
371        assert!(matches!(
372            check_claims(&valid_moments_hence, &config(), 900),
373            Err(JwtError::Claim(ClaimFault::NotYetValid))
374        ));
375    }
376
377    /// The same slack the other way would keep a withdrawn token alive past
378    /// the second its issuer said it ended.
379    #[test]
380    fn an_expiry_is_honoured_to_the_second() {
381        let expires_at = claims(serde_json::json!({"exp": 1_000}));
382        assert!(check_claims(&expires_at, &config(), 1_000).is_ok());
383        assert!(matches!(
384            check_claims(&expires_at, &config(), 1_001),
385            Err(JwtError::Claim(ClaimFault::Expired))
386        ));
387    }
388
389    /// A role is a name, and a claim that is not a string does not carry one.
390    #[test]
391    fn a_role_claim_that_is_not_a_string_names_no_role() {
392        let configured = JwtConfig {
393            anon_role: Some("anon".into()),
394            ..config()
395        };
396        for value in [
397            serde_json::json!(42),
398            serde_json::json!({"a": 1}),
399            serde_json::json!(["admin"]),
400        ] {
401            let mut claims = HashMap::new();
402            claims.insert("role".to_string(), value.clone());
403            assert_eq!(
404                role_of(&claims, &configured),
405                Some("anon".to_string()),
406                "{value}"
407            );
408        }
409
410        let mut named = HashMap::new();
411        named.insert("role".to_string(), serde_json::json!("web_user"));
412        assert_eq!(role_of(&named, &configured), Some("web_user".to_string()));
413    }
414
415    /// The first thing wrong with the token is what the client is told.
416    #[test]
417    fn an_expired_token_is_reported_as_expired_whatever_else_is_wrong() {
418        assert!(matches!(
419            check_claims(
420                &claims(serde_json::json!({"exp": 1_000, "aud": [{"bad": 1}]})),
421                &config(),
422                9_000
423            ),
424            Err(JwtError::Claim(ClaimFault::Expired))
425        ));
426    }
427}