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, KeySet};
10use jsonwebtoken::{decode, decode_header, Header, 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 keys = config.keys.as_ref().ok_or(JwtError::SecretMissing)?;
30
31    // Read unverified, and used for one thing only: choosing which keys to try.
32    // Nothing in it is believed -- its `alg` must be one the chosen key admits,
33    // and the key, not the header, is what the signature is checked against.
34    // A header that does not parse, `alg: none` among them, names no key.
35    let header = decode_header(token).map_err(|e| match map_jwt_error(e) {
36        JwtError::Claim(ClaimFault::Unparsable) => JwtError::NoSuitableKey,
37        other => other,
38    })?;
39
40    let claims = verify(token, &header, &keys.current())?;
41
42    check_claims(&claims, config, now())?;
43
44    // The role decides what the request may do, so a token naming none, on a
45    // server with no anonymous role, is a request with no identity at all.
46    let role = role_of(&claims, config).ok_or(JwtError::NoIdentity)?;
47
48    Ok(AuthResult { role, claims })
49}
50
51/// Check the signature against the keys the header selects.
52///
53/// No candidate at all -- an unknown `kid`, or an `alg` no key admits -- is a
54/// token this server holds no key for, which PostgREST reports as "No
55/// suitable key or wrong key type". Candidates that all refuse it is a
56/// signature that does not verify. A token without a `kid` is tried against
57/// every candidate in turn and passes on the first that verifies it, as in
58/// PostgREST.
59fn verify(
60    token: &str,
61    header: &Header,
62    keys: &KeySet,
63) -> Result<HashMap<String, serde_json::Value>, JwtError> {
64    // Signature only, and only the header's algorithm, which every candidate
65    // is already known to admit. Every claim the library could check here, it
66    // checks differently from the way this API's contract says to -- a
67    // missing `exp` is not an error, an `aud` that is not a string has a code
68    // of its own -- so all of it is done in `check_claims`, where the answers
69    // can be the right ones.
70    let mut validation = Validation::new(header.alg);
71    validation.validate_exp = false;
72    validation.validate_nbf = false;
73    validation.validate_aud = false;
74    validation.required_spec_claims.clear();
75
76    let mut failure = JwtError::NoSuitableKey;
77    for key in keys.candidates(header.kid.as_deref(), header.alg) {
78        match decode::<HashMap<String, serde_json::Value>>(token, key, &validation) {
79            Ok(data) => return Ok(data.claims),
80            // The signature verified and the payload is not claims. Another
81            // key cannot change what the payload says.
82            Err(e) if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::Json(_)) => {
83                return Err(JwtError::Claim(ClaimFault::Unparsable))
84            }
85            // A signature that failed under one candidate is the more useful
86            // thing to report than a later candidate's key that could not be
87            // used at all.
88            Err(e) => {
89                if !matches!(failure, JwtError::InvalidSignature) {
90                    failure = map_jwt_error(e);
91                }
92            }
93        }
94    }
95    Err(failure)
96}
97
98/// The role a token names, or the anonymous one where it names none.
99///
100/// A role is a name, and only a string is one. Rendering anything else --
101/// `"role": 42`, `"role": {"a": 1}` -- would make a database role nobody
102/// created out of a claim that does not name one, so a claim of the wrong
103/// shape is read as a token that named no role at all and falls back the same
104/// way an absent claim does.
105///
106/// So are two strings that are not names either. The role is switched to with
107/// `set_config('role', ..)`, and PostgreSQL reads `none` there as "no role":
108/// the request would run as the connection's own role, the authenticator,
109/// rather than as one the token names. An empty string names no role at all,
110/// and was refused by the database with a 500.
111fn role_of(claims: &HashMap<String, serde_json::Value>, config: &JwtConfig) -> Option<String> {
112    lookup_claim(claims, &config.role_claim_key)
113        .and_then(serde_json::Value::as_str)
114        .filter(|role| !matches!(*role, "" | "none"))
115        .map(str::to_string)
116        .or_else(|| config.anon_role.clone())
117}
118
119/// Resolve a claim key, which may name a nested claim.
120///
121/// The literal key is tried first, and a dotted path only if that misses. The
122/// order matters: a top-level claim name is allowed to contain dots, and the
123/// ones that do are exactly the ones that must not be re-read as a path --
124/// Hasura mints `https://hasura.io/jwt/claims`, and splitting that would look
125/// for a claim named `https://hasura` instead.
126///
127/// Only objects are walked. A path that runs into a string or a number stops
128/// there and finds nothing, rather than pretending the rest of the path was
129/// satisfied.
130fn lookup_claim<'a>(
131    claims: &'a HashMap<String, serde_json::Value>,
132    key: &str,
133) -> Option<&'a serde_json::Value> {
134    if let Some(value) = claims.get(key) {
135        return Some(value);
136    }
137
138    let (first, rest) = key.split_once('.')?;
139    let mut current = claims.get(first)?;
140    for segment in rest.split('.') {
141        current = current.as_object()?.get(segment)?;
142    }
143    Some(current)
144}
145
146/// The registered claims, checked in the order the contract checks them.
147///
148/// Order is part of the answer: a token that is both expired and out of
149/// audience is reported as expired, because that is the first thing wrong with
150/// it and the one the client can act on.
151fn check_claims(
152    claims: &HashMap<String, serde_json::Value>,
153    config: &JwtConfig,
154    now: i64,
155) -> Result<(), JwtError> {
156    // A claim that is absent is not a claim that is wrong. Only one that is
157    // present in the wrong shape is.
158    let number = |name: &'static str| -> Result<Option<i64>, JwtError> {
159        match claims.get(name) {
160            None | Some(serde_json::Value::Null) => Ok(None),
161            Some(value) => value
162                .as_f64()
163                .map(|seconds| seconds as i64)
164                .map(Some)
165                .ok_or(JwtError::Claim(ClaimFault::NotANumber(name))),
166        }
167    };
168
169    if let Some(exp) = number("exp")? {
170        if now > exp {
171            return Err(JwtError::Claim(ClaimFault::Expired));
172        }
173    }
174    if let Some(nbf) = number("nbf")? {
175        if now + ALLOWED_SKEW < nbf {
176            return Err(JwtError::Claim(ClaimFault::NotYetValid));
177        }
178    }
179    if let Some(iat) = number("iat")? {
180        if now + ALLOWED_SKEW < iat {
181            return Err(JwtError::Claim(ClaimFault::IssuedInFuture));
182        }
183    }
184
185    // With no audience configured the server accepts any, but the claim still
186    // has to *be* an audience: a list with an object in it is a malformed
187    // token, not a token meant for somebody else. That is the difference
188    // between a client fixing how it mints tokens and hunting for a setting
189    // this server does not have.
190    let matches = |aud: &str| match &config.audience {
191        Some(wanted) => aud == wanted,
192        None => true,
193    };
194    match claims.get("aud") {
195        None | Some(serde_json::Value::Null) => {}
196        Some(serde_json::Value::String(aud)) => {
197            if !matches(aud) {
198                return Err(JwtError::Claim(ClaimFault::NotInAudience));
199            }
200        }
201        Some(serde_json::Value::Array(auds)) => {
202            if !auds.iter().all(serde_json::Value::is_string) {
203                return Err(JwtError::Claim(ClaimFault::AudienceNotStrings));
204            }
205            // A list naming no audience excludes nobody.
206            let admitted = auds
207                .iter()
208                .filter_map(serde_json::Value::as_str)
209                .any(matches);
210            if !auds.is_empty() && !admitted {
211                return Err(JwtError::Claim(ClaimFault::NotInAudience));
212            }
213        }
214        Some(_) => return Err(JwtError::Claim(ClaimFault::AudienceNotStrings)),
215    }
216
217    // Unlike `aud`, an absent `iss` is not admitted. A token without an `aud`
218    // says it is meant for everyone, which is how PostgREST reads it; a token
219    // without an `iss` says nothing about who minted it, and naming the one
220    // issuer to accept is the whole of what this setting is for. It matters
221    // most where it is most likely to be set: an identity provider whose
222    // signing keys are shared across tenants, where the `iss` is the only
223    // thing that tells one tenant's tokens from another's. And unlike `aud`,
224    // nothing is checked when no issuer is configured -- PostgREST does not
225    // look at `iss` at all, so a token it accepts must not be refused here
226    // over the shape of a claim nobody asked about.
227    if let Some(wanted) = &config.issuer {
228        if claims.get("iss").and_then(serde_json::Value::as_str) != Some(wanted.as_str()) {
229            return Err(JwtError::Claim(ClaimFault::UnexpectedIssuer));
230        }
231    }
232
233    Ok(())
234}
235
236/// Seconds since the epoch. `web_time` is `std::time` natively; on wasm32,
237/// where `SystemTime::now()` panics, it asks the JavaScript host.
238fn now() -> i64 {
239    web_time::SystemTime::now()
240        .duration_since(web_time::UNIX_EPOCH)
241        .map(|elapsed| elapsed.as_secs() as i64)
242        .unwrap_or_default()
243}
244
245/// Map jsonwebtoken error to JwtError.
246fn map_jwt_error(e: jsonwebtoken::errors::Error) -> JwtError {
247    use jsonwebtoken::errors::ErrorKind;
248
249    match e.kind() {
250        ErrorKind::InvalidSignature => JwtError::InvalidSignature,
251        // The token was read and its claims were not a JSON object, which is a
252        // different failure from a key that could not decode it at all.
253        ErrorKind::Json(_) => JwtError::Claim(ClaimFault::Unparsable),
254        _ => JwtError::NoSuitableKey,
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn config() -> JwtConfig {
263        JwtConfig {
264            keys: crate::JwtKeys::from_config(Some("reallyreallyreallyreallyverysafe"), false)
265                .unwrap(),
266            ..JwtConfig::default()
267        }
268    }
269
270    fn claims(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
271        match json {
272            serde_json::Value::Object(map) => map.into_iter().collect(),
273            _ => panic!("claims must be an object"),
274        }
275    }
276
277    fn role_with(key: &str, json: serde_json::Value) -> Option<String> {
278        let config = JwtConfig {
279            role_claim_key: key.to_string(),
280            ..config()
281        };
282        role_of(&claims(json), &config)
283    }
284
285    #[test]
286    fn a_flat_role_claim_is_found() {
287        assert_eq!(
288            role_with("role", serde_json::json!({"role": "web_user"})),
289            Some("web_user".to_string())
290        );
291        assert_eq!(
292            role_with("my_role", serde_json::json!({"my_role": "web_user"})),
293            Some("web_user".to_string())
294        );
295    }
296
297    /// `none` would run the request as the authenticator, and `""` names
298    /// nothing: both are read as no role, as a role that is not a string is.
299    #[test]
300    fn a_role_of_none_or_nothing_is_no_role() {
301        for role in ["none", ""] {
302            assert_eq!(role_with("role", serde_json::json!({ "role": role })), None);
303            let anon = JwtConfig {
304                anon_role: Some("web_anon".into()),
305                ..config()
306            };
307            assert_eq!(
308                role_of(&claims(serde_json::json!({ "role": role })), &anon),
309                Some("web_anon".to_string())
310            );
311        }
312        // A role that only looks like it is still a role.
313        assert_eq!(
314            role_with("role", serde_json::json!({"role": "None"})),
315            Some("None".to_string())
316        );
317    }
318
319    #[test]
320    fn a_dotted_key_reaches_a_nested_claim() {
321        // `PGRST_JWT_ROLE_CLAIM_KEY="user.role"` over
322        // `{"user": {"role": "..."}}` is the example the documentation gives.
323        // A flat `get` looks for a claim literally named `user.role`, finds
324        // nothing, and every such request silently became anonymous.
325        assert_eq!(
326            role_with(
327                "user.role",
328                serde_json::json!({"user": {"role": "web_user"}})
329            ),
330            Some("web_user".to_string())
331        );
332        assert_eq!(
333            role_with("a.b.c", serde_json::json!({"a": {"b": {"c": "web_user"}}})),
334            Some("web_user".to_string())
335        );
336    }
337
338    #[test]
339    fn a_literal_dotted_claim_wins_over_the_path_reading() {
340        // Hasura mints `https://hasura.io/jwt/claims`, and a namespaced claim
341        // name full of dots must not be split into a path.
342        assert_eq!(
343            role_with(
344                "https://hasura.io/jwt/claims",
345                serde_json::json!({"https://hasura.io/jwt/claims": "web_user"})
346            ),
347            Some("web_user".to_string())
348        );
349        // Both present: the literal key is the one that was asked for.
350        assert_eq!(
351            role_with(
352                "user.role",
353                serde_json::json!({"user.role": "literal", "user": {"role": "nested"}})
354            ),
355            Some("literal".to_string())
356        );
357    }
358
359    #[test]
360    fn a_path_that_runs_into_a_non_object_finds_nothing() {
361        // Falls back to the anonymous role rather than inventing one.
362        let anon = JwtConfig {
363            role_claim_key: "user.role".to_string(),
364            anon_role: Some("web_anon".to_string()),
365            ..config()
366        };
367        assert_eq!(
368            role_of(&claims(serde_json::json!({"user": "not-an-object"})), &anon),
369            Some("web_anon".to_string())
370        );
371        assert_eq!(
372            role_of(&claims(serde_json::json!({"user": {"other": 1}})), &anon),
373            Some("web_anon".to_string())
374        );
375    }
376
377    /// The token in PostgREST's own suite carries `"aud": [{...}, "test"]`,
378    /// which is neither an audience nor a list of them.
379    #[test]
380    fn an_audience_that_is_not_a_string_is_a_malformed_token() {
381        assert!(matches!(
382            check_claims(
383                &claims(serde_json::json!({"aud": [{"invalid": "value"}, "test"]})),
384                &config(),
385                0
386            ),
387            Err(JwtError::Claim(ClaimFault::AudienceNotStrings))
388        ));
389    }
390
391    /// With no audience configured, any audience is this server's.
392    #[test]
393    fn any_audience_is_accepted_when_none_is_configured() {
394        assert!(check_claims(
395            &claims(serde_json::json!({"aud": ["someone", "else"]})),
396            &config(),
397            0
398        )
399        .is_ok());
400    }
401
402    /// A list naming no audience excludes nobody.
403    #[test]
404    fn an_empty_audience_list_excludes_no_one() {
405        let configured = JwtConfig {
406            audience: Some("us".into()),
407            ..config()
408        };
409        assert!(check_claims(&claims(serde_json::json!({"aud": []})), &configured, 0).is_ok());
410        assert!(matches!(
411            check_claims(
412                &claims(serde_json::json!({"aud": ["them"]})),
413                &configured,
414                0
415            ),
416            Err(JwtError::Claim(ClaimFault::NotInAudience))
417        ));
418    }
419
420    /// A token that does not say when it expires does not expire.
421    #[test]
422    fn a_token_without_exp_is_not_rejected_for_having_none() {
423        assert!(check_claims(&claims(serde_json::json!({"role": "x"})), &config(), 0).is_ok());
424    }
425
426    /// Present, but not the kind of thing that claim is.
427    #[test]
428    fn a_timestamp_claim_that_is_not_a_number_is_reported_as_such() {
429        assert!(matches!(
430            check_claims(&claims(serde_json::json!({"exp": "soon"})), &config(), 0),
431            Err(JwtError::Claim(ClaimFault::NotANumber("exp")))
432        ));
433    }
434
435    /// A clock a few seconds ahead is not a forgery.
436    #[test]
437    fn a_token_that_is_not_valid_yet_is_given_the_benefit_of_the_clock() {
438        let issued_moments_hence = claims(serde_json::json!({"iat": 1_000}));
439        assert!(check_claims(&issued_moments_hence, &config(), 990).is_ok());
440        assert!(matches!(
441            check_claims(&issued_moments_hence, &config(), 900),
442            Err(JwtError::Claim(ClaimFault::IssuedInFuture))
443        ));
444
445        let valid_moments_hence = claims(serde_json::json!({"nbf": 1_000}));
446        assert!(check_claims(&valid_moments_hence, &config(), 990).is_ok());
447        assert!(matches!(
448            check_claims(&valid_moments_hence, &config(), 900),
449            Err(JwtError::Claim(ClaimFault::NotYetValid))
450        ));
451    }
452
453    /// The same slack the other way would keep a withdrawn token alive past
454    /// the second its issuer said it ended.
455    #[test]
456    fn an_expiry_is_honoured_to_the_second() {
457        let expires_at = claims(serde_json::json!({"exp": 1_000}));
458        assert!(check_claims(&expires_at, &config(), 1_000).is_ok());
459        assert!(matches!(
460            check_claims(&expires_at, &config(), 1_001),
461            Err(JwtError::Claim(ClaimFault::Expired))
462        ));
463    }
464
465    /// A role is a name, and a claim that is not a string does not carry one.
466    #[test]
467    fn a_role_claim_that_is_not_a_string_names_no_role() {
468        let configured = JwtConfig {
469            anon_role: Some("anon".into()),
470            ..config()
471        };
472        for value in [
473            serde_json::json!(42),
474            serde_json::json!({"a": 1}),
475            serde_json::json!(["admin"]),
476        ] {
477            let mut claims = HashMap::new();
478            claims.insert("role".to_string(), value.clone());
479            assert_eq!(
480                role_of(&claims, &configured),
481                Some("anon".to_string()),
482                "{value}"
483            );
484        }
485
486        let mut named = HashMap::new();
487        named.insert("role".to_string(), serde_json::json!("web_user"));
488        assert_eq!(role_of(&named, &configured), Some("web_user".to_string()));
489    }
490
491    /// Configured, the issuer must be named, and named exactly.
492    #[test]
493    fn a_configured_issuer_must_be_the_tokens() {
494        let configured = JwtConfig {
495            issuer: Some("https://issuer.example".into()),
496            ..config()
497        };
498        assert!(check_claims(
499            &claims(serde_json::json!({"iss": "https://issuer.example"})),
500            &configured,
501            0
502        )
503        .is_ok());
504        for token in [
505            serde_json::json!({"iss": "https://issuer.example/"}),
506            serde_json::json!({"iss": null}),
507            serde_json::json!({}),
508        ] {
509            assert!(
510                matches!(
511                    check_claims(&claims(token.clone()), &configured, 0),
512                    Err(JwtError::Claim(ClaimFault::UnexpectedIssuer))
513                ),
514                "{token}"
515            );
516        }
517    }
518
519    /// Unconfigured, `iss` is not looked at, whatever shape it has.
520    #[test]
521    fn no_issuer_configured_means_iss_is_not_checked() {
522        for iss in [serde_json::json!("anyone"), serde_json::json!(42)] {
523            assert!(check_claims(&claims(serde_json::json!({"iss": iss})), &config(), 0).is_ok());
524        }
525    }
526
527    /// An expired token is reported as expired before its issuer is.
528    #[test]
529    fn expiry_is_reported_before_the_issuer() {
530        let configured = JwtConfig {
531            issuer: Some("https://issuer.example".into()),
532            ..config()
533        };
534        assert!(matches!(
535            check_claims(&claims(serde_json::json!({"exp": 1})), &configured, 9_000),
536            Err(JwtError::Claim(ClaimFault::Expired))
537        ));
538    }
539
540    /// The first thing wrong with the token is what the client is told.
541    #[test]
542    fn an_expired_token_is_reported_as_expired_whatever_else_is_wrong() {
543        assert!(matches!(
544            check_claims(
545                &claims(serde_json::json!({"exp": 1_000, "aud": [{"bad": 1}]})),
546                &config(),
547                9_000
548            ),
549            Err(JwtError::Claim(ClaimFault::Expired))
550        ));
551    }
552}