use std::collections::{HashMap, HashSet};
use std::fmt;
use std::sync::Arc;
use std::time::SystemTime;
use async_trait::async_trait;
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use rvoip_core_traits::identity::IdentityAssurance;
use rvoip_core_traits::ids::IdentityId;
use serde::Deserialize;
use crate::bearer::{
unix_time_from_seconds, validate_optional_token_id, AuthenticatedPrincipal,
AuthenticationMethod, BearerAuthError, BearerValidator, ValidatedBearer,
};
use crate::providers::{
CredentialAuthError, TokenRevocationChecker, TokenRevocationContext, TokenRevocationStatus,
};
#[derive(Deserialize)]
struct Claims {
sub: String,
#[serde(default)]
iss: Option<String>,
#[serde(default)]
iat: Option<u64>,
#[serde(default)]
exp: Option<u64>,
#[serde(default)]
jti: Option<String>,
#[serde(default)]
scope: Option<String>,
#[serde(default)]
scopes: Option<Vec<String>>,
#[serde(default)]
roles: Option<Vec<String>>,
#[serde(default)]
realm_access: Option<RoleAccess>,
#[serde(default)]
resource_access: Option<HashMap<String, RoleAccess>>,
#[serde(default, alias = "tenant", alias = "tid")]
tenant_id: Option<String>,
}
impl fmt::Debug for Claims {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("Claims")
.field("subject_present", &!self.sub.is_empty())
.field("issuer_present", &self.iss.is_some())
.field("issued_at_present", &self.iat.is_some())
.field("expires_at_present", &self.exp.is_some())
.field("token_id_present", &self.jti.is_some())
.field("scope_present", &self.scope.is_some())
.field("scope_bytes", &self.scope.as_ref().map_or(0, String::len))
.field(
"scope_list_count",
&self.scopes.as_ref().map_or(0, Vec::len),
)
.field("role_count", &self.roles.as_ref().map_or(0, Vec::len))
.field("realm_access_present", &self.realm_access.is_some())
.field(
"resource_access_count",
&self.resource_access.as_ref().map_or(0, HashMap::len),
)
.field("tenant_present", &self.tenant_id.is_some())
.finish()
}
}
#[derive(Deserialize)]
struct RoleAccess {
#[serde(default)]
roles: Vec<String>,
}
impl fmt::Debug for RoleAccess {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RoleAccess")
.field("role_count", &self.roles.len())
.finish()
}
}
pub struct JwtValidator {
decoding_key: DecodingKey,
validation: Validation,
revocation_checker: Option<Arc<dyn TokenRevocationChecker>>,
require_jti: bool,
}
impl JwtValidator {
pub fn from_decoding_key(decoding_key: DecodingKey, algorithm: Algorithm) -> Self {
let mut validation = Validation::new(algorithm);
validation.validate_aud = false;
Self {
decoding_key,
validation,
revocation_checker: None,
require_jti: false,
}
}
pub fn from_hmac_secret(secret: &[u8]) -> Self {
let mut validation = Validation::new(Algorithm::HS256);
validation.set_audience::<&str>(&[]);
validation.validate_aud = false;
Self {
decoding_key: DecodingKey::from_secret(secret),
validation,
revocation_checker: None,
require_jti: false,
}
}
pub fn from_rsa_pem(pem: &[u8]) -> Result<Self, BearerAuthError> {
let key = DecodingKey::from_rsa_pem(pem)
.map_err(|e| BearerAuthError::Unavailable(format!("invalid RSA PEM: {e}")))?;
let mut validation = Validation::new(Algorithm::RS256);
validation.validate_aud = false;
Ok(Self {
decoding_key: key,
validation,
revocation_checker: None,
require_jti: false,
})
}
pub fn from_ec_pem(pem: &[u8]) -> Result<Self, BearerAuthError> {
let key = DecodingKey::from_ec_pem(pem)
.map_err(|e| BearerAuthError::Unavailable(format!("invalid EC PEM: {e}")))?;
let mut validation = Validation::new(Algorithm::ES256);
validation.validate_aud = false;
Ok(Self {
decoding_key: key,
validation,
revocation_checker: None,
require_jti: false,
})
}
pub fn with_algorithm(mut self, algorithm: Algorithm) -> Self {
self.validation.algorithms = vec![algorithm];
self
}
pub fn with_revocation_checker(mut self, checker: Arc<dyn TokenRevocationChecker>) -> Self {
self.revocation_checker = Some(checker);
self
}
pub fn with_required_jti(mut self) -> Self {
self.require_jti = true;
self
}
pub fn with_audience<I, S>(mut self, audiences: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let auds: HashSet<String> = audiences
.into_iter()
.map(|s| s.as_ref().to_string())
.collect();
self.validation
.set_audience(&auds.into_iter().collect::<Vec<_>>());
self.validation.validate_aud = true;
self
}
pub fn with_issuer<I, S>(mut self, issuers: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
self.validation.set_issuer(
&issuers
.into_iter()
.map(|s| s.as_ref().to_string())
.collect::<Vec<_>>(),
);
self
}
pub fn into_arc(self) -> Arc<dyn BearerValidator> {
Arc::new(self)
}
}
#[async_trait]
impl BearerValidator for JwtValidator {
async fn validate(&self, token: &str) -> Result<IdentityAssurance, BearerAuthError> {
Ok(self.validate_credential(token).await?.principal.assurance)
}
async fn validate_principal(
&self,
token: &str,
) -> Result<AuthenticatedPrincipal, BearerAuthError> {
Ok(self.validate_credential(token).await?.principal)
}
async fn validate_credential(&self, token: &str) -> Result<ValidatedBearer, BearerAuthError> {
if token.is_empty() {
return Err(BearerAuthError::Empty);
}
let data = decode::<Claims>(token, &self.decoding_key, &self.validation)
.map_err(|e| BearerAuthError::Invalid(e.to_string()))?;
let claims = data.claims;
let token_id = validate_optional_token_id(claims.jti.clone())?;
if self.require_jti && token_id.is_none() {
return Err(BearerAuthError::Invalid(
"token missing required jti".into(),
));
}
let issued_at = claims
.iat
.map(|iat| unix_time_from_seconds(iat, "iat"))
.transpose()?;
let expires_at_system = claims
.exp
.map(|exp| unix_time_from_seconds(exp, "exp"))
.transpose()?;
let revocation_context = revocation_context_from_claims(
&claims,
token_id.as_deref(),
issued_at,
expires_at_system,
);
check_revocation(
self.revocation_checker.as_ref(),
revocation_context.as_ref(),
)
.await?;
let subject = claims.sub.clone();
let expires_at = claims.exp.map(expiration_from_unix).transpose()?;
let identity = IdentityId::from_string(subject.clone());
let scopes = scopes_from_claims(
claims.scope,
claims.scopes,
claims.roles,
claims.realm_access,
claims.resource_access,
);
let assurance = IdentityAssurance::UserAuthorized {
identity: identity.clone(),
user_id: identity,
scopes: scopes.clone(),
};
ValidatedBearer::new(
AuthenticatedPrincipal {
subject,
tenant: claims.tenant_id,
scopes,
issuer: claims.iss,
expires_at,
method: AuthenticationMethod::Jwt,
assurance,
},
token_id,
issued_at,
)
}
}
fn expiration_from_unix(seconds: u64) -> Result<chrono::DateTime<chrono::Utc>, BearerAuthError> {
i64::try_from(seconds)
.ok()
.and_then(|seconds| chrono::DateTime::from_timestamp(seconds, 0))
.ok_or_else(|| BearerAuthError::Invalid("token exp is outside the supported range".into()))
}
async fn check_revocation(
checker: Option<&Arc<dyn TokenRevocationChecker>>,
context: Option<&TokenRevocationContext>,
) -> Result<(), BearerAuthError> {
let Some(checker) = checker else {
return Ok(());
};
let Some(context) = context else {
return Err(BearerAuthError::Invalid(
"token missing jti for revocation check".into(),
));
};
match checker.check_token(context).await {
Ok(TokenRevocationStatus::Active) => Ok(()),
Ok(TokenRevocationStatus::Revoked) => Err(BearerAuthError::Invalid("token revoked".into())),
Err(CredentialAuthError::Invalid) | Err(CredentialAuthError::PolicyRejected(_)) => Err(
BearerAuthError::Invalid("revocation check rejected token".into()),
),
Err(CredentialAuthError::Unavailable(err)) => Err(BearerAuthError::Unavailable(err)),
}
}
fn revocation_context_from_claims(
claims: &Claims,
token_id: Option<&str>,
issued_at: Option<SystemTime>,
expires_at: Option<SystemTime>,
) -> Option<TokenRevocationContext> {
let mut context = TokenRevocationContext::new(token_id?).with_subject(claims.sub.clone());
if let Some(issuer) = claims.iss.clone() {
context = context.with_issuer(issuer);
}
context = context.with_times(issued_at, expires_at);
Some(context)
}
fn scopes_from_claims(
scope: Option<String>,
scopes: Option<Vec<String>>,
roles: Option<Vec<String>>,
realm_access: Option<RoleAccess>,
resource_access: Option<HashMap<String, RoleAccess>>,
) -> Vec<String> {
let mut values = Vec::new();
if let Some(scope) = scope {
values.extend(scope.split_whitespace().map(str::to_string));
}
if let Some(scopes) = scopes {
for scope in scopes {
push_unique(&mut values, scope);
}
}
if let Some(roles) = roles {
for role in roles {
push_unique(&mut values, format!("role:{role}"));
}
}
if let Some(realm_access) = realm_access {
for role in realm_access.roles {
push_unique(&mut values, format!("realm:{role}"));
}
}
if let Some(resource_access) = resource_access {
for (client, access) in resource_access {
for role in access.roles {
push_unique(&mut values, format!("{client}:{role}"));
}
}
}
values
}
fn push_unique(values: &mut Vec<String>, value: String) {
if !values.contains(&value) {
values.push(value);
}
}
#[cfg(test)]
mod diagnostic_tests {
use super::*;
const CANARY: &str = "jwt-claims-malicious-canary\r\nAuthorization: exposed";
#[test]
fn decoded_claims_keep_values_out_of_debug() {
let claims: Claims = serde_json::from_value(serde_json::json!({
"sub": CANARY,
"iss": CANARY,
"iat": 1,
"exp": 2,
"jti": CANARY,
"scope": CANARY,
"scopes": [CANARY],
"roles": [CANARY],
"realm_access": { "roles": [CANARY] },
"resource_access": { (CANARY): { "roles": [CANARY] } },
"tenant_id": CANARY,
}))
.unwrap();
for rendered in [
format!("{claims:?}"),
format!("{:?}", claims.realm_access.as_ref().unwrap()),
format!(
"{:?}",
claims
.resource_access
.as_ref()
.unwrap()
.get(CANARY)
.unwrap()
),
] {
assert!(!rendered.contains(CANARY), "claim leaked: {rendered}");
}
assert_eq!(claims.sub, CANARY);
assert_eq!(claims.iss.as_deref(), Some(CANARY));
assert_eq!(claims.jti.as_deref(), Some(CANARY));
assert_eq!(claims.scope.as_deref(), Some(CANARY));
assert_eq!(claims.scopes.as_deref(), Some(&[CANARY.to_string()][..]));
assert_eq!(claims.roles.as_deref(), Some(&[CANARY.to_string()][..]));
assert_eq!(claims.tenant_id.as_deref(), Some(CANARY));
assert_eq!(
claims.realm_access.as_ref().unwrap().roles,
[CANARY.to_string()]
);
assert_eq!(
claims.resource_access.as_ref().unwrap()[CANARY].roles,
[CANARY.to_string()]
);
}
}