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