use super::{AuthResult, ClaimFault, JwtConfig, JwtError};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use std::collections::HashMap;
const ALLOWED_SKEW: i64 = 30;
pub fn validate_token(token: &str, config: &JwtConfig) -> Result<AuthResult, JwtError> {
let secret = config.secret.as_ref().ok_or(JwtError::SecretMissing)?;
let key_bytes = if config.secret_is_base64 {
base64_decode(secret)?
} else {
secret.as_bytes().to_vec()
};
let key = DecodingKey::from_secret(&key_bytes);
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())?;
let role = role_of(&claims, config).ok_or(JwtError::NoIdentity)?;
Ok(AuthResult { role, claims })
}
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())
}
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)
}
fn check_claims(
claims: &HashMap<String, serde_json::Value>,
config: &JwtConfig,
now: i64,
) -> Result<(), JwtError> {
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));
}
}
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));
}
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(())
}
fn now() -> i64 {
web_time::SystemTime::now()
.duration_since(web_time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs() as i64)
.unwrap_or_default()
}
fn base64_decode(s: &str) -> Result<Vec<u8>, JwtError> {
use base64::{engine::general_purpose::STANDARD, Engine};
STANDARD.decode(s).map_err(|_| JwtError::NoSuitableKey)
}
fn map_jwt_error(e: jsonwebtoken::errors::Error) -> JwtError {
use jsonwebtoken::errors::ErrorKind;
match e.kind() {
ErrorKind::InvalidSignature => JwtError::InvalidSignature,
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() {
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() {
assert_eq!(
role_with(
"https://hasura.io/jwt/claims",
serde_json::json!({"https://hasura.io/jwt/claims": "web_user"})
),
Some("web_user".to_string())
);
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() {
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())
);
}
#[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))
));
}
#[test]
fn any_audience_is_accepted_when_none_is_configured() {
assert!(check_claims(
&claims(serde_json::json!({"aud": ["someone", "else"]})),
&config(),
0
)
.is_ok());
}
#[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))
));
}
#[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());
}
#[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")))
));
}
#[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))
));
}
#[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))
));
}
#[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()));
}
#[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))
));
}
}