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 {
181 web_time::SystemTime::now()
182 .duration_since(web_time::UNIX_EPOCH)
183 .map(|elapsed| elapsed.as_secs() as i64)
184 .unwrap_or_default()
185}
186
187fn base64_decode(s: &str) -> Result<Vec<u8>, JwtError> {
189 use base64::{engine::general_purpose::STANDARD, Engine};
190 STANDARD.decode(s).map_err(|_| JwtError::NoSuitableKey)
191}
192
193fn map_jwt_error(e: jsonwebtoken::errors::Error) -> JwtError {
195 use jsonwebtoken::errors::ErrorKind;
196
197 match e.kind() {
198 ErrorKind::InvalidSignature => JwtError::InvalidSignature,
199 ErrorKind::Json(_) => JwtError::Claim(ClaimFault::Unparsable),
202 _ => JwtError::NoSuitableKey,
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 fn config() -> JwtConfig {
211 JwtConfig {
212 secret: Some("reallyreallyreallyreallyverysafe".into()),
213 ..JwtConfig::default()
214 }
215 }
216
217 fn claims(json: serde_json::Value) -> HashMap<String, serde_json::Value> {
218 match json {
219 serde_json::Value::Object(map) => map.into_iter().collect(),
220 _ => panic!("claims must be an object"),
221 }
222 }
223
224 fn role_with(key: &str, json: serde_json::Value) -> Option<String> {
225 let config = JwtConfig {
226 role_claim_key: key.to_string(),
227 ..config()
228 };
229 role_of(&claims(json), &config)
230 }
231
232 #[test]
233 fn a_flat_role_claim_is_found() {
234 assert_eq!(
235 role_with("role", serde_json::json!({"role": "web_user"})),
236 Some("web_user".to_string())
237 );
238 assert_eq!(
239 role_with("my_role", serde_json::json!({"my_role": "web_user"})),
240 Some("web_user".to_string())
241 );
242 }
243
244 #[test]
245 fn a_dotted_key_reaches_a_nested_claim() {
246 assert_eq!(
251 role_with(
252 "user.role",
253 serde_json::json!({"user": {"role": "web_user"}})
254 ),
255 Some("web_user".to_string())
256 );
257 assert_eq!(
258 role_with("a.b.c", serde_json::json!({"a": {"b": {"c": "web_user"}}})),
259 Some("web_user".to_string())
260 );
261 }
262
263 #[test]
264 fn a_literal_dotted_claim_wins_over_the_path_reading() {
265 assert_eq!(
268 role_with(
269 "https://hasura.io/jwt/claims",
270 serde_json::json!({"https://hasura.io/jwt/claims": "web_user"})
271 ),
272 Some("web_user".to_string())
273 );
274 assert_eq!(
276 role_with(
277 "user.role",
278 serde_json::json!({"user.role": "literal", "user": {"role": "nested"}})
279 ),
280 Some("literal".to_string())
281 );
282 }
283
284 #[test]
285 fn a_path_that_runs_into_a_non_object_finds_nothing() {
286 let anon = JwtConfig {
288 role_claim_key: "user.role".to_string(),
289 anon_role: Some("web_anon".to_string()),
290 ..config()
291 };
292 assert_eq!(
293 role_of(&claims(serde_json::json!({"user": "not-an-object"})), &anon),
294 Some("web_anon".to_string())
295 );
296 assert_eq!(
297 role_of(&claims(serde_json::json!({"user": {"other": 1}})), &anon),
298 Some("web_anon".to_string())
299 );
300 }
301
302 #[test]
305 fn an_audience_that_is_not_a_string_is_a_malformed_token() {
306 assert!(matches!(
307 check_claims(
308 &claims(serde_json::json!({"aud": [{"invalid": "value"}, "test"]})),
309 &config(),
310 0
311 ),
312 Err(JwtError::Claim(ClaimFault::AudienceNotStrings))
313 ));
314 }
315
316 #[test]
318 fn any_audience_is_accepted_when_none_is_configured() {
319 assert!(check_claims(
320 &claims(serde_json::json!({"aud": ["someone", "else"]})),
321 &config(),
322 0
323 )
324 .is_ok());
325 }
326
327 #[test]
329 fn an_empty_audience_list_excludes_no_one() {
330 let configured = JwtConfig {
331 audience: Some("us".into()),
332 ..config()
333 };
334 assert!(check_claims(&claims(serde_json::json!({"aud": []})), &configured, 0).is_ok());
335 assert!(matches!(
336 check_claims(
337 &claims(serde_json::json!({"aud": ["them"]})),
338 &configured,
339 0
340 ),
341 Err(JwtError::Claim(ClaimFault::NotInAudience))
342 ));
343 }
344
345 #[test]
347 fn a_token_without_exp_is_not_rejected_for_having_none() {
348 assert!(check_claims(&claims(serde_json::json!({"role": "x"})), &config(), 0).is_ok());
349 }
350
351 #[test]
353 fn a_timestamp_claim_that_is_not_a_number_is_reported_as_such() {
354 assert!(matches!(
355 check_claims(&claims(serde_json::json!({"exp": "soon"})), &config(), 0),
356 Err(JwtError::Claim(ClaimFault::NotANumber("exp")))
357 ));
358 }
359
360 #[test]
362 fn a_token_that_is_not_valid_yet_is_given_the_benefit_of_the_clock() {
363 let issued_moments_hence = claims(serde_json::json!({"iat": 1_000}));
364 assert!(check_claims(&issued_moments_hence, &config(), 990).is_ok());
365 assert!(matches!(
366 check_claims(&issued_moments_hence, &config(), 900),
367 Err(JwtError::Claim(ClaimFault::IssuedInFuture))
368 ));
369
370 let valid_moments_hence = claims(serde_json::json!({"nbf": 1_000}));
371 assert!(check_claims(&valid_moments_hence, &config(), 990).is_ok());
372 assert!(matches!(
373 check_claims(&valid_moments_hence, &config(), 900),
374 Err(JwtError::Claim(ClaimFault::NotYetValid))
375 ));
376 }
377
378 #[test]
381 fn an_expiry_is_honoured_to_the_second() {
382 let expires_at = claims(serde_json::json!({"exp": 1_000}));
383 assert!(check_claims(&expires_at, &config(), 1_000).is_ok());
384 assert!(matches!(
385 check_claims(&expires_at, &config(), 1_001),
386 Err(JwtError::Claim(ClaimFault::Expired))
387 ));
388 }
389
390 #[test]
392 fn a_role_claim_that_is_not_a_string_names_no_role() {
393 let configured = JwtConfig {
394 anon_role: Some("anon".into()),
395 ..config()
396 };
397 for value in [
398 serde_json::json!(42),
399 serde_json::json!({"a": 1}),
400 serde_json::json!(["admin"]),
401 ] {
402 let mut claims = HashMap::new();
403 claims.insert("role".to_string(), value.clone());
404 assert_eq!(
405 role_of(&claims, &configured),
406 Some("anon".to_string()),
407 "{value}"
408 );
409 }
410
411 let mut named = HashMap::new();
412 named.insert("role".to_string(), serde_json::json!("web_user"));
413 assert_eq!(role_of(&named, &configured), Some("web_user".to_string()));
414 }
415
416 #[test]
418 fn an_expired_token_is_reported_as_expired_whatever_else_is_wrong() {
419 assert!(matches!(
420 check_claims(
421 &claims(serde_json::json!({"exp": 1_000, "aud": [{"bad": 1}]})),
422 &config(),
423 9_000
424 ),
425 Err(JwtError::Claim(ClaimFault::Expired))
426 ));
427 }
428}