1use super::{AuthResult, ClaimFault, JwtConfig, JwtError, KeySet};
10use jsonwebtoken::{decode, decode_header, Header, Validation};
11use std::collections::HashMap;
12
13const ALLOWED_SKEW: i64 = 30;
26
27pub fn validate_token(token: &str, config: &JwtConfig) -> Result<AuthResult, JwtError> {
29 let keys = config.keys.as_ref().ok_or(JwtError::SecretMissing)?;
30
31 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 let role = role_of(&claims, config).ok_or(JwtError::NoIdentity)?;
47
48 Ok(AuthResult { role, claims })
49}
50
51fn verify(
60 token: &str,
61 header: &Header,
62 keys: &KeySet,
63) -> Result<HashMap<String, serde_json::Value>, JwtError> {
64 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 Err(e) if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::Json(_)) => {
83 return Err(JwtError::Claim(ClaimFault::Unparsable))
84 }
85 Err(e) => {
89 if !matches!(failure, JwtError::InvalidSignature) {
90 failure = map_jwt_error(e);
91 }
92 }
93 }
94 }
95 Err(failure)
96}
97
98fn 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
119fn 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
146fn check_claims(
152 claims: &HashMap<String, serde_json::Value>,
153 config: &JwtConfig,
154 now: i64,
155) -> Result<(), JwtError> {
156 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 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 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 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
236fn 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
245fn map_jwt_error(e: jsonwebtoken::errors::Error) -> JwtError {
247 use jsonwebtoken::errors::ErrorKind;
248
249 match e.kind() {
250 ErrorKind::InvalidSignature => JwtError::InvalidSignature,
251 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 #[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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}