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. `web_time` is `std::time` natively; on wasm32,
179/// where `SystemTime::now()` panics, it asks the JavaScript host.
180fn now() -> i64 {
181    web_time::SystemTime::now()
182        .duration_since(web_time::UNIX_EPOCH)
183        .map(|elapsed| elapsed.as_secs() as i64)
184        .unwrap_or_default()
185}
186
187/// Decode base64 secret.
188fn base64_decode(s: &str) -> Result<Vec<u8>, JwtError> {
189    use base64::{engine::general_purpose::STANDARD, Engine};
190    STANDARD.decode(s).map_err(|_| JwtError::NoSuitableKey)
191}
192
193/// Map jsonwebtoken error to JwtError.
194fn map_jwt_error(e: jsonwebtoken::errors::Error) -> JwtError {
195    use jsonwebtoken::errors::ErrorKind;
196
197    match e.kind() {
198        ErrorKind::InvalidSignature => JwtError::InvalidSignature,
199        // The token was read and its claims were not a JSON object, which is a
200        // different failure from a key that could not decode it at all.
201        ErrorKind::Json(_) => JwtError::Claim(ClaimFault::Unparsable),
202        _ => JwtError::NoSuitableKey,
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn config() -> JwtConfig {
211        JwtConfig {
212            secret: Some("reallyreallyreallyreallyverysafe".into()),
213            ..JwtConfig::default()
214        }
215    }
216
217    fn claims(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
218        match json {
219            serde_json::Value::Object(map) => map.into_iter().collect(),
220            _ => panic!("claims must be an object"),
221        }
222    }
223
224    fn role_with(key: &str, json: serde_json::Value) -> Option<String> {
225        let config = JwtConfig {
226            role_claim_key: key.to_string(),
227            ..config()
228        };
229        role_of(&claims(json), &config)
230    }
231
232    #[test]
233    fn a_flat_role_claim_is_found() {
234        assert_eq!(
235            role_with("role", serde_json::json!({"role": "web_user"})),
236            Some("web_user".to_string())
237        );
238        assert_eq!(
239            role_with("my_role", serde_json::json!({"my_role": "web_user"})),
240            Some("web_user".to_string())
241        );
242    }
243
244    #[test]
245    fn a_dotted_key_reaches_a_nested_claim() {
246        // `PGRST_JWT_ROLE_CLAIM_KEY="user.role"` over
247        // `{"user": {"role": "..."}}` is the example the documentation gives.
248        // A flat `get` looks for a claim literally named `user.role`, finds
249        // nothing, and every such request silently became anonymous.
250        assert_eq!(
251            role_with(
252                "user.role",
253                serde_json::json!({"user": {"role": "web_user"}})
254            ),
255            Some("web_user".to_string())
256        );
257        assert_eq!(
258            role_with("a.b.c", serde_json::json!({"a": {"b": {"c": "web_user"}}})),
259            Some("web_user".to_string())
260        );
261    }
262
263    #[test]
264    fn a_literal_dotted_claim_wins_over_the_path_reading() {
265        // Hasura mints `https://hasura.io/jwt/claims`, and a namespaced claim
266        // name full of dots must not be split into a path.
267        assert_eq!(
268            role_with(
269                "https://hasura.io/jwt/claims",
270                serde_json::json!({"https://hasura.io/jwt/claims": "web_user"})
271            ),
272            Some("web_user".to_string())
273        );
274        // Both present: the literal key is the one that was asked for.
275        assert_eq!(
276            role_with(
277                "user.role",
278                serde_json::json!({"user.role": "literal", "user": {"role": "nested"}})
279            ),
280            Some("literal".to_string())
281        );
282    }
283
284    #[test]
285    fn a_path_that_runs_into_a_non_object_finds_nothing() {
286        // Falls back to the anonymous role rather than inventing one.
287        let anon = JwtConfig {
288            role_claim_key: "user.role".to_string(),
289            anon_role: Some("web_anon".to_string()),
290            ..config()
291        };
292        assert_eq!(
293            role_of(&claims(serde_json::json!({"user": "not-an-object"})), &anon),
294            Some("web_anon".to_string())
295        );
296        assert_eq!(
297            role_of(&claims(serde_json::json!({"user": {"other": 1}})), &anon),
298            Some("web_anon".to_string())
299        );
300    }
301
302    /// The token in PostgREST's own suite carries `"aud": [{...}, "test"]`,
303    /// which is neither an audience nor a list of them.
304    #[test]
305    fn an_audience_that_is_not_a_string_is_a_malformed_token() {
306        assert!(matches!(
307            check_claims(
308                &claims(serde_json::json!({"aud": [{"invalid": "value"}, "test"]})),
309                &config(),
310                0
311            ),
312            Err(JwtError::Claim(ClaimFault::AudienceNotStrings))
313        ));
314    }
315
316    /// With no audience configured, any audience is this server's.
317    #[test]
318    fn any_audience_is_accepted_when_none_is_configured() {
319        assert!(check_claims(
320            &claims(serde_json::json!({"aud": ["someone", "else"]})),
321            &config(),
322            0
323        )
324        .is_ok());
325    }
326
327    /// A list naming no audience excludes nobody.
328    #[test]
329    fn an_empty_audience_list_excludes_no_one() {
330        let configured = JwtConfig {
331            audience: Some("us".into()),
332            ..config()
333        };
334        assert!(check_claims(&claims(serde_json::json!({"aud": []})), &configured, 0).is_ok());
335        assert!(matches!(
336            check_claims(
337                &claims(serde_json::json!({"aud": ["them"]})),
338                &configured,
339                0
340            ),
341            Err(JwtError::Claim(ClaimFault::NotInAudience))
342        ));
343    }
344
345    /// A token that does not say when it expires does not expire.
346    #[test]
347    fn a_token_without_exp_is_not_rejected_for_having_none() {
348        assert!(check_claims(&claims(serde_json::json!({"role": "x"})), &config(), 0).is_ok());
349    }
350
351    /// Present, but not the kind of thing that claim is.
352    #[test]
353    fn a_timestamp_claim_that_is_not_a_number_is_reported_as_such() {
354        assert!(matches!(
355            check_claims(&claims(serde_json::json!({"exp": "soon"})), &config(), 0),
356            Err(JwtError::Claim(ClaimFault::NotANumber("exp")))
357        ));
358    }
359
360    /// A clock a few seconds ahead is not a forgery.
361    #[test]
362    fn a_token_that_is_not_valid_yet_is_given_the_benefit_of_the_clock() {
363        let issued_moments_hence = claims(serde_json::json!({"iat": 1_000}));
364        assert!(check_claims(&issued_moments_hence, &config(), 990).is_ok());
365        assert!(matches!(
366            check_claims(&issued_moments_hence, &config(), 900),
367            Err(JwtError::Claim(ClaimFault::IssuedInFuture))
368        ));
369
370        let valid_moments_hence = claims(serde_json::json!({"nbf": 1_000}));
371        assert!(check_claims(&valid_moments_hence, &config(), 990).is_ok());
372        assert!(matches!(
373            check_claims(&valid_moments_hence, &config(), 900),
374            Err(JwtError::Claim(ClaimFault::NotYetValid))
375        ));
376    }
377
378    /// The same slack the other way would keep a withdrawn token alive past
379    /// the second its issuer said it ended.
380    #[test]
381    fn an_expiry_is_honoured_to_the_second() {
382        let expires_at = claims(serde_json::json!({"exp": 1_000}));
383        assert!(check_claims(&expires_at, &config(), 1_000).is_ok());
384        assert!(matches!(
385            check_claims(&expires_at, &config(), 1_001),
386            Err(JwtError::Claim(ClaimFault::Expired))
387        ));
388    }
389
390    /// A role is a name, and a claim that is not a string does not carry one.
391    #[test]
392    fn a_role_claim_that_is_not_a_string_names_no_role() {
393        let configured = JwtConfig {
394            anon_role: Some("anon".into()),
395            ..config()
396        };
397        for value in [
398            serde_json::json!(42),
399            serde_json::json!({"a": 1}),
400            serde_json::json!(["admin"]),
401        ] {
402            let mut claims = HashMap::new();
403            claims.insert("role".to_string(), value.clone());
404            assert_eq!(
405                role_of(&claims, &configured),
406                Some("anon".to_string()),
407                "{value}"
408            );
409        }
410
411        let mut named = HashMap::new();
412        named.insert("role".to_string(), serde_json::json!("web_user"));
413        assert_eq!(role_of(&named, &configured), Some("web_user".to_string()));
414    }
415
416    /// The first thing wrong with the token is what the client is told.
417    #[test]
418    fn an_expired_token_is_reported_as_expired_whatever_else_is_wrong() {
419        assert!(matches!(
420            check_claims(
421                &claims(serde_json::json!({"exp": 1_000, "aud": [{"bad": 1}]})),
422                &config(),
423                9_000
424            ),
425            Err(JwtError::Claim(ClaimFault::Expired))
426        ));
427    }
428}