1use super::{AuthResult, ClaimFault, JwtConfig, JwtError};
10use jsonwebtoken::{decode, Algorithm, DecodingKey, 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 secret = config.secret.as_ref().ok_or(JwtError::SecretMissing)?;
30
31 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 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 let role = role_of(&claims, config).ok_or(JwtError::NoIdentity)?;
59
60 Ok(AuthResult { role, claims })
61}
62
63fn 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
84fn 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
111fn check_claims(
117 claims: &HashMap<String, serde_json::Value>,
118 config: &JwtConfig,
119 now: i64,
120) -> Result<(), JwtError> {
121 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 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 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
185fn 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
194fn 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
200fn map_jwt_error(e: jsonwebtoken::errors::Error) -> JwtError {
202 use jsonwebtoken::errors::ErrorKind;
203
204 match e.kind() {
205 ErrorKind::InvalidSignature => JwtError::InvalidSignature,
206 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 #[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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}