postrust-auth 2.3.2

JWT authentication and role resolution for Postrust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
//! JWT token validation.
//!
//! The signature is checked by the library; the claims are checked here. That
//! split is deliberate: a claim can be wrong in two different ways -- absent,
//! or present and not the kind of thing that claim is -- and the client needs
//! to be told which. A library that folds both into "invalid token" answers a
//! question nobody asked.

use super::{AuthResult, ClaimFault, JwtConfig, JwtError};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use std::collections::HashMap;

/// How far ahead of the server a token's clock may be.
///
/// A token minted a moment ago on a machine whose clock runs a little fast is
/// not a forgery, and rejecting it would make a working deployment fail
/// intermittently and unreproducibly.
///
/// Only in that direction. `exp` is checked to the second, because leniency
/// there is leniency about a token that has been *withdrawn* -- it keeps a
/// revoked session alive past the moment its issuer said it ended, and it is a
/// window an attacker holding a stale token gets for free. `nbf` and `iat`
/// describe a token that is not valid *yet*, where the same slack costs
/// nothing and is the only remedy for a clock nobody controls.
const ALLOWED_SKEW: i64 = 30;

/// Validate a JWT token and extract claims.
pub fn validate_token(token: &str, config: &JwtConfig) -> Result<AuthResult, JwtError> {
    let secret = config.secret.as_ref().ok_or(JwtError::SecretMissing)?;

    // Decode secret
    let key_bytes = if config.secret_is_base64 {
        base64_decode(secret)?
    } else {
        secret.as_bytes().to_vec()
    };

    let key = DecodingKey::from_secret(&key_bytes);

    // Signature only. Every claim the library could check here, it checks
    // differently from the way this API's contract says to -- a missing `exp`
    // is not an error, an `aud` that is not a string has a code of its own --
    // so all of it is done below, where the answers can be the right ones.
    let mut validation = Validation::new(Algorithm::HS256);
    validation.validate_exp = false;
    validation.validate_nbf = false;
    validation.validate_aud = false;
    validation.required_spec_claims.clear();

    let token_data = decode::<HashMap<String, serde_json::Value>>(token, &key, &validation)
        .map_err(map_jwt_error)?;
    let claims = token_data.claims;

    check_claims(&claims, config, now())?;

    // The role decides what the request may do, so a token naming none, on a
    // server with no anonymous role, is a request with no identity at all.
    let role = role_of(&claims, config).ok_or(JwtError::NoIdentity)?;

    Ok(AuthResult { role, claims })
}

/// The role a token names, or the anonymous one where it names none.
///
/// A role is a name, and only a string is one. Rendering anything else --
/// `"role": 42`, `"role": {"a": 1}` -- would make a database role nobody
/// created out of a claim that does not name one, so a claim of the wrong
/// shape is read as a token that named no role at all and falls back the same
/// way an absent claim does.
fn role_of(claims: &HashMap<String, serde_json::Value>, config: &JwtConfig) -> Option<String> {
    lookup_claim(claims, &config.role_claim_key)
        .and_then(serde_json::Value::as_str)
        .map(str::to_string)
        .or_else(|| config.anon_role.clone())
}

/// Resolve a claim key, which may name a nested claim.
///
/// The literal key is tried first, and a dotted path only if that misses. The
/// order matters: a top-level claim name is allowed to contain dots, and the
/// ones that do are exactly the ones that must not be re-read as a path --
/// Hasura mints `https://hasura.io/jwt/claims`, and splitting that would look
/// for a claim named `https://hasura` instead.
///
/// Only objects are walked. A path that runs into a string or a number stops
/// there and finds nothing, rather than pretending the rest of the path was
/// satisfied.
fn lookup_claim<'a>(
    claims: &'a HashMap<String, serde_json::Value>,
    key: &str,
) -> Option<&'a serde_json::Value> {
    if let Some(value) = claims.get(key) {
        return Some(value);
    }

    let (first, rest) = key.split_once('.')?;
    let mut current = claims.get(first)?;
    for segment in rest.split('.') {
        current = current.as_object()?.get(segment)?;
    }
    Some(current)
}

/// The registered claims, checked in the order the contract checks them.
///
/// Order is part of the answer: a token that is both expired and out of
/// audience is reported as expired, because that is the first thing wrong with
/// it and the one the client can act on.
fn check_claims(
    claims: &HashMap<String, serde_json::Value>,
    config: &JwtConfig,
    now: i64,
) -> Result<(), JwtError> {
    // A claim that is absent is not a claim that is wrong. Only one that is
    // present in the wrong shape is.
    let number = |name: &'static str| -> Result<Option<i64>, JwtError> {
        match claims.get(name) {
            None | Some(serde_json::Value::Null) => Ok(None),
            Some(value) => value
                .as_f64()
                .map(|seconds| seconds as i64)
                .map(Some)
                .ok_or(JwtError::Claim(ClaimFault::NotANumber(name))),
        }
    };

    if let Some(exp) = number("exp")? {
        if now > exp {
            return Err(JwtError::Claim(ClaimFault::Expired));
        }
    }
    if let Some(nbf) = number("nbf")? {
        if now + ALLOWED_SKEW < nbf {
            return Err(JwtError::Claim(ClaimFault::NotYetValid));
        }
    }
    if let Some(iat) = number("iat")? {
        if now + ALLOWED_SKEW < iat {
            return Err(JwtError::Claim(ClaimFault::IssuedInFuture));
        }
    }

    // With no audience configured the server accepts any, but the claim still
    // has to *be* an audience: a list with an object in it is a malformed
    // token, not a token meant for somebody else. That is the difference
    // between a client fixing how it mints tokens and hunting for a setting
    // this server does not have.
    let matches = |aud: &str| match &config.audience {
        Some(wanted) => aud == wanted,
        None => true,
    };
    match claims.get("aud") {
        None | Some(serde_json::Value::Null) => {}
        Some(serde_json::Value::String(aud)) => {
            if !matches(aud) {
                return Err(JwtError::Claim(ClaimFault::NotInAudience));
            }
        }
        Some(serde_json::Value::Array(auds)) => {
            if !auds.iter().all(serde_json::Value::is_string) {
                return Err(JwtError::Claim(ClaimFault::AudienceNotStrings));
            }
            // A list naming no audience excludes nobody.
            let admitted = auds
                .iter()
                .filter_map(serde_json::Value::as_str)
                .any(matches);
            if !auds.is_empty() && !admitted {
                return Err(JwtError::Claim(ClaimFault::NotInAudience));
            }
        }
        Some(_) => return Err(JwtError::Claim(ClaimFault::AudienceNotStrings)),
    }

    Ok(())
}

/// Seconds since the epoch. `web_time` is `std::time` natively; on wasm32,
/// where `SystemTime::now()` panics, it asks the JavaScript host.
fn now() -> i64 {
    web_time::SystemTime::now()
        .duration_since(web_time::UNIX_EPOCH)
        .map(|elapsed| elapsed.as_secs() as i64)
        .unwrap_or_default()
}

/// Decode base64 secret.
fn base64_decode(s: &str) -> Result<Vec<u8>, JwtError> {
    use base64::{engine::general_purpose::STANDARD, Engine};
    STANDARD.decode(s).map_err(|_| JwtError::NoSuitableKey)
}

/// Map jsonwebtoken error to JwtError.
fn map_jwt_error(e: jsonwebtoken::errors::Error) -> JwtError {
    use jsonwebtoken::errors::ErrorKind;

    match e.kind() {
        ErrorKind::InvalidSignature => JwtError::InvalidSignature,
        // The token was read and its claims were not a JSON object, which is a
        // different failure from a key that could not decode it at all.
        ErrorKind::Json(_) => JwtError::Claim(ClaimFault::Unparsable),
        _ => JwtError::NoSuitableKey,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn config() -> JwtConfig {
        JwtConfig {
            secret: Some("reallyreallyreallyreallyverysafe".into()),
            ..JwtConfig::default()
        }
    }

    fn claims(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
        match json {
            serde_json::Value::Object(map) => map.into_iter().collect(),
            _ => panic!("claims must be an object"),
        }
    }

    fn role_with(key: &str, json: serde_json::Value) -> Option<String> {
        let config = JwtConfig {
            role_claim_key: key.to_string(),
            ..config()
        };
        role_of(&claims(json), &config)
    }

    #[test]
    fn a_flat_role_claim_is_found() {
        assert_eq!(
            role_with("role", serde_json::json!({"role": "web_user"})),
            Some("web_user".to_string())
        );
        assert_eq!(
            role_with("my_role", serde_json::json!({"my_role": "web_user"})),
            Some("web_user".to_string())
        );
    }

    #[test]
    fn a_dotted_key_reaches_a_nested_claim() {
        // `PGRST_JWT_ROLE_CLAIM_KEY="user.role"` over
        // `{"user": {"role": "..."}}` is the example the documentation gives.
        // A flat `get` looks for a claim literally named `user.role`, finds
        // nothing, and every such request silently became anonymous.
        assert_eq!(
            role_with(
                "user.role",
                serde_json::json!({"user": {"role": "web_user"}})
            ),
            Some("web_user".to_string())
        );
        assert_eq!(
            role_with("a.b.c", serde_json::json!({"a": {"b": {"c": "web_user"}}})),
            Some("web_user".to_string())
        );
    }

    #[test]
    fn a_literal_dotted_claim_wins_over_the_path_reading() {
        // Hasura mints `https://hasura.io/jwt/claims`, and a namespaced claim
        // name full of dots must not be split into a path.
        assert_eq!(
            role_with(
                "https://hasura.io/jwt/claims",
                serde_json::json!({"https://hasura.io/jwt/claims": "web_user"})
            ),
            Some("web_user".to_string())
        );
        // Both present: the literal key is the one that was asked for.
        assert_eq!(
            role_with(
                "user.role",
                serde_json::json!({"user.role": "literal", "user": {"role": "nested"}})
            ),
            Some("literal".to_string())
        );
    }

    #[test]
    fn a_path_that_runs_into_a_non_object_finds_nothing() {
        // Falls back to the anonymous role rather than inventing one.
        let anon = JwtConfig {
            role_claim_key: "user.role".to_string(),
            anon_role: Some("web_anon".to_string()),
            ..config()
        };
        assert_eq!(
            role_of(&claims(serde_json::json!({"user": "not-an-object"})), &anon),
            Some("web_anon".to_string())
        );
        assert_eq!(
            role_of(&claims(serde_json::json!({"user": {"other": 1}})), &anon),
            Some("web_anon".to_string())
        );
    }

    /// The token in PostgREST's own suite carries `"aud": [{...}, "test"]`,
    /// which is neither an audience nor a list of them.
    #[test]
    fn an_audience_that_is_not_a_string_is_a_malformed_token() {
        assert!(matches!(
            check_claims(
                &claims(serde_json::json!({"aud": [{"invalid": "value"}, "test"]})),
                &config(),
                0
            ),
            Err(JwtError::Claim(ClaimFault::AudienceNotStrings))
        ));
    }

    /// With no audience configured, any audience is this server's.
    #[test]
    fn any_audience_is_accepted_when_none_is_configured() {
        assert!(check_claims(
            &claims(serde_json::json!({"aud": ["someone", "else"]})),
            &config(),
            0
        )
        .is_ok());
    }

    /// A list naming no audience excludes nobody.
    #[test]
    fn an_empty_audience_list_excludes_no_one() {
        let configured = JwtConfig {
            audience: Some("us".into()),
            ..config()
        };
        assert!(check_claims(&claims(serde_json::json!({"aud": []})), &configured, 0).is_ok());
        assert!(matches!(
            check_claims(
                &claims(serde_json::json!({"aud": ["them"]})),
                &configured,
                0
            ),
            Err(JwtError::Claim(ClaimFault::NotInAudience))
        ));
    }

    /// A token that does not say when it expires does not expire.
    #[test]
    fn a_token_without_exp_is_not_rejected_for_having_none() {
        assert!(check_claims(&claims(serde_json::json!({"role": "x"})), &config(), 0).is_ok());
    }

    /// Present, but not the kind of thing that claim is.
    #[test]
    fn a_timestamp_claim_that_is_not_a_number_is_reported_as_such() {
        assert!(matches!(
            check_claims(&claims(serde_json::json!({"exp": "soon"})), &config(), 0),
            Err(JwtError::Claim(ClaimFault::NotANumber("exp")))
        ));
    }

    /// A clock a few seconds ahead is not a forgery.
    #[test]
    fn a_token_that_is_not_valid_yet_is_given_the_benefit_of_the_clock() {
        let issued_moments_hence = claims(serde_json::json!({"iat": 1_000}));
        assert!(check_claims(&issued_moments_hence, &config(), 990).is_ok());
        assert!(matches!(
            check_claims(&issued_moments_hence, &config(), 900),
            Err(JwtError::Claim(ClaimFault::IssuedInFuture))
        ));

        let valid_moments_hence = claims(serde_json::json!({"nbf": 1_000}));
        assert!(check_claims(&valid_moments_hence, &config(), 990).is_ok());
        assert!(matches!(
            check_claims(&valid_moments_hence, &config(), 900),
            Err(JwtError::Claim(ClaimFault::NotYetValid))
        ));
    }

    /// The same slack the other way would keep a withdrawn token alive past
    /// the second its issuer said it ended.
    #[test]
    fn an_expiry_is_honoured_to_the_second() {
        let expires_at = claims(serde_json::json!({"exp": 1_000}));
        assert!(check_claims(&expires_at, &config(), 1_000).is_ok());
        assert!(matches!(
            check_claims(&expires_at, &config(), 1_001),
            Err(JwtError::Claim(ClaimFault::Expired))
        ));
    }

    /// A role is a name, and a claim that is not a string does not carry one.
    #[test]
    fn a_role_claim_that_is_not_a_string_names_no_role() {
        let configured = JwtConfig {
            anon_role: Some("anon".into()),
            ..config()
        };
        for value in [
            serde_json::json!(42),
            serde_json::json!({"a": 1}),
            serde_json::json!(["admin"]),
        ] {
            let mut claims = HashMap::new();
            claims.insert("role".to_string(), value.clone());
            assert_eq!(
                role_of(&claims, &configured),
                Some("anon".to_string()),
                "{value}"
            );
        }

        let mut named = HashMap::new();
        named.insert("role".to_string(), serde_json::json!("web_user"));
        assert_eq!(role_of(&named, &configured), Some("web_user".to_string()));
    }

    /// The first thing wrong with the token is what the client is told.
    #[test]
    fn an_expired_token_is_reported_as_expired_whatever_else_is_wrong() {
        assert!(matches!(
            check_claims(
                &claims(serde_json::json!({"exp": 1_000, "aud": [{"bad": 1}]})),
                &config(),
                9_000
            ),
            Err(JwtError::Claim(ClaimFault::Expired))
        ));
    }
}