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> {
71 lookup_claim(claims, &config.role_claim_key)
72 .and_then(serde_json::Value::as_str)
73 .map(str::to_string)
74 .or_else(|| config.anon_role.clone())
75}
76
77fn lookup_claim<'a>(
89 claims: &'a HashMap<String, serde_json::Value>,
90 key: &str,
91) -> Option<&'a serde_json::Value> {
92 if let Some(value) = claims.get(key) {
93 return Some(value);
94 }
95
96 let (first, rest) = key.split_once('.')?;
97 let mut current = claims.get(first)?;
98 for segment in rest.split('.') {
99 current = current.as_object()?.get(segment)?;
100 }
101 Some(current)
102}
103
104fn check_claims(
110 claims: &HashMap<String, serde_json::Value>,
111 config: &JwtConfig,
112 now: i64,
113) -> Result<(), JwtError> {
114 let number = |name: &'static str| -> Result<Option<i64>, JwtError> {
117 match claims.get(name) {
118 None | Some(serde_json::Value::Null) => Ok(None),
119 Some(value) => value
120 .as_f64()
121 .map(|seconds| seconds as i64)
122 .map(Some)
123 .ok_or(JwtError::Claim(ClaimFault::NotANumber(name))),
124 }
125 };
126
127 if let Some(exp) = number("exp")? {
128 if now > exp {
129 return Err(JwtError::Claim(ClaimFault::Expired));
130 }
131 }
132 if let Some(nbf) = number("nbf")? {
133 if now + ALLOWED_SKEW < nbf {
134 return Err(JwtError::Claim(ClaimFault::NotYetValid));
135 }
136 }
137 if let Some(iat) = number("iat")? {
138 if now + ALLOWED_SKEW < iat {
139 return Err(JwtError::Claim(ClaimFault::IssuedInFuture));
140 }
141 }
142
143 let matches = |aud: &str| match &config.audience {
149 Some(wanted) => aud == wanted,
150 None => true,
151 };
152 match claims.get("aud") {
153 None | Some(serde_json::Value::Null) => {}
154 Some(serde_json::Value::String(aud)) => {
155 if !matches(aud) {
156 return Err(JwtError::Claim(ClaimFault::NotInAudience));
157 }
158 }
159 Some(serde_json::Value::Array(auds)) => {
160 if !auds.iter().all(serde_json::Value::is_string) {
161 return Err(JwtError::Claim(ClaimFault::AudienceNotStrings));
162 }
163 let admitted = auds
165 .iter()
166 .filter_map(serde_json::Value::as_str)
167 .any(matches);
168 if !auds.is_empty() && !admitted {
169 return Err(JwtError::Claim(ClaimFault::NotInAudience));
170 }
171 }
172 Some(_) => return Err(JwtError::Claim(ClaimFault::AudienceNotStrings)),
173 }
174
175 Ok(())
176}
177
178fn now() -> i64 {
180 std::time::SystemTime::now()
181 .duration_since(std::time::UNIX_EPOCH)
182 .map(|elapsed| elapsed.as_secs() as i64)
183 .unwrap_or_default()
184}
185
186fn base64_decode(s: &str) -> Result<Vec<u8>, JwtError> {
188 use base64::{engine::general_purpose::STANDARD, Engine};
189 STANDARD.decode(s).map_err(|_| JwtError::NoSuitableKey)
190}
191
192fn map_jwt_error(e: jsonwebtoken::errors::Error) -> JwtError {
194 use jsonwebtoken::errors::ErrorKind;
195
196 match e.kind() {
197 ErrorKind::InvalidSignature => JwtError::InvalidSignature,
198 ErrorKind::Json(_) => JwtError::Claim(ClaimFault::Unparsable),
201 _ => JwtError::NoSuitableKey,
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn config() -> JwtConfig {
210 JwtConfig {
211 secret: Some("reallyreallyreallyreallyverysafe".into()),
212 ..JwtConfig::default()
213 }
214 }
215
216 fn claims(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
217 match json {
218 serde_json::Value::Object(map) => map.into_iter().collect(),
219 _ => panic!("claims must be an object"),
220 }
221 }
222
223 fn role_with(key: &str, json: serde_json::Value) -> Option<String> {
224 let config = JwtConfig {
225 role_claim_key: key.to_string(),
226 ..config()
227 };
228 role_of(&claims(json), &config)
229 }
230
231 #[test]
232 fn a_flat_role_claim_is_found() {
233 assert_eq!(
234 role_with("role", serde_json::json!({"role": "web_user"})),
235 Some("web_user".to_string())
236 );
237 assert_eq!(
238 role_with("my_role", serde_json::json!({"my_role": "web_user"})),
239 Some("web_user".to_string())
240 );
241 }
242
243 #[test]
244 fn a_dotted_key_reaches_a_nested_claim() {
245 assert_eq!(
250 role_with(
251 "user.role",
252 serde_json::json!({"user": {"role": "web_user"}})
253 ),
254 Some("web_user".to_string())
255 );
256 assert_eq!(
257 role_with("a.b.c", serde_json::json!({"a": {"b": {"c": "web_user"}}})),
258 Some("web_user".to_string())
259 );
260 }
261
262 #[test]
263 fn a_literal_dotted_claim_wins_over_the_path_reading() {
264 assert_eq!(
267 role_with(
268 "https://hasura.io/jwt/claims",
269 serde_json::json!({"https://hasura.io/jwt/claims": "web_user"})
270 ),
271 Some("web_user".to_string())
272 );
273 assert_eq!(
275 role_with(
276 "user.role",
277 serde_json::json!({"user.role": "literal", "user": {"role": "nested"}})
278 ),
279 Some("literal".to_string())
280 );
281 }
282
283 #[test]
284 fn a_path_that_runs_into_a_non_object_finds_nothing() {
285 let anon = JwtConfig {
287 role_claim_key: "user.role".to_string(),
288 anon_role: Some("web_anon".to_string()),
289 ..config()
290 };
291 assert_eq!(
292 role_of(&claims(serde_json::json!({"user": "not-an-object"})), &anon),
293 Some("web_anon".to_string())
294 );
295 assert_eq!(
296 role_of(&claims(serde_json::json!({"user": {"other": 1}})), &anon),
297 Some("web_anon".to_string())
298 );
299 }
300
301 #[test]
304 fn an_audience_that_is_not_a_string_is_a_malformed_token() {
305 assert!(matches!(
306 check_claims(
307 &claims(serde_json::json!({"aud": [{"invalid": "value"}, "test"]})),
308 &config(),
309 0
310 ),
311 Err(JwtError::Claim(ClaimFault::AudienceNotStrings))
312 ));
313 }
314
315 #[test]
317 fn any_audience_is_accepted_when_none_is_configured() {
318 assert!(check_claims(
319 &claims(serde_json::json!({"aud": ["someone", "else"]})),
320 &config(),
321 0
322 )
323 .is_ok());
324 }
325
326 #[test]
328 fn an_empty_audience_list_excludes_no_one() {
329 let configured = JwtConfig {
330 audience: Some("us".into()),
331 ..config()
332 };
333 assert!(check_claims(&claims(serde_json::json!({"aud": []})), &configured, 0).is_ok());
334 assert!(matches!(
335 check_claims(
336 &claims(serde_json::json!({"aud": ["them"]})),
337 &configured,
338 0
339 ),
340 Err(JwtError::Claim(ClaimFault::NotInAudience))
341 ));
342 }
343
344 #[test]
346 fn a_token_without_exp_is_not_rejected_for_having_none() {
347 assert!(check_claims(&claims(serde_json::json!({"role": "x"})), &config(), 0).is_ok());
348 }
349
350 #[test]
352 fn a_timestamp_claim_that_is_not_a_number_is_reported_as_such() {
353 assert!(matches!(
354 check_claims(&claims(serde_json::json!({"exp": "soon"})), &config(), 0),
355 Err(JwtError::Claim(ClaimFault::NotANumber("exp")))
356 ));
357 }
358
359 #[test]
361 fn a_token_that_is_not_valid_yet_is_given_the_benefit_of_the_clock() {
362 let issued_moments_hence = claims(serde_json::json!({"iat": 1_000}));
363 assert!(check_claims(&issued_moments_hence, &config(), 990).is_ok());
364 assert!(matches!(
365 check_claims(&issued_moments_hence, &config(), 900),
366 Err(JwtError::Claim(ClaimFault::IssuedInFuture))
367 ));
368
369 let valid_moments_hence = claims(serde_json::json!({"nbf": 1_000}));
370 assert!(check_claims(&valid_moments_hence, &config(), 990).is_ok());
371 assert!(matches!(
372 check_claims(&valid_moments_hence, &config(), 900),
373 Err(JwtError::Claim(ClaimFault::NotYetValid))
374 ));
375 }
376
377 #[test]
380 fn an_expiry_is_honoured_to_the_second() {
381 let expires_at = claims(serde_json::json!({"exp": 1_000}));
382 assert!(check_claims(&expires_at, &config(), 1_000).is_ok());
383 assert!(matches!(
384 check_claims(&expires_at, &config(), 1_001),
385 Err(JwtError::Claim(ClaimFault::Expired))
386 ));
387 }
388
389 #[test]
391 fn a_role_claim_that_is_not_a_string_names_no_role() {
392 let configured = JwtConfig {
393 anon_role: Some("anon".into()),
394 ..config()
395 };
396 for value in [
397 serde_json::json!(42),
398 serde_json::json!({"a": 1}),
399 serde_json::json!(["admin"]),
400 ] {
401 let mut claims = HashMap::new();
402 claims.insert("role".to_string(), value.clone());
403 assert_eq!(
404 role_of(&claims, &configured),
405 Some("anon".to_string()),
406 "{value}"
407 );
408 }
409
410 let mut named = HashMap::new();
411 named.insert("role".to_string(), serde_json::json!("web_user"));
412 assert_eq!(role_of(&named, &configured), Some("web_user".to_string()));
413 }
414
415 #[test]
417 fn an_expired_token_is_reported_as_expired_whatever_else_is_wrong() {
418 assert!(matches!(
419 check_claims(
420 &claims(serde_json::json!({"exp": 1_000, "aud": [{"bad": 1}]})),
421 &config(),
422 9_000
423 ),
424 Err(JwtError::Claim(ClaimFault::Expired))
425 ));
426 }
427}