use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::ops::ControlFlow;
use std::sync::{Arc, OnceLock};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use webx_core::auth::{IAuthenticationHandler, IClaims};
use webx_core::error::{Error, Result};
use webx_core::http::IHttpContext;
use webx_core::middleware::IMiddleware;
use webx_core::security::{ISessionStore, ITokenIssuer, IssuedToken, TokenClaims};
pub struct JwtClaims {
sub: String,
roles: Vec<String>,
permissions: Vec<String>,
raw: HashMap<String, String>,
}
#[derive(Debug, Deserialize, Serialize)]
struct RawClaims {
#[serde(default)]
sub: String,
#[serde(default)]
roles: Vec<String>,
#[serde(default)]
permissions: Vec<String>,
#[serde(flatten)]
extra: HashMap<String, serde_json::Value>,
}
impl JwtClaims {
pub fn new(subject: impl Into<String>) -> Self {
let sub = subject.into();
let mut raw = HashMap::new();
raw.insert("sub".to_string(), sub.clone());
Self {
sub,
roles: Vec::new(),
permissions: Vec::new(),
raw,
}
}
}
impl From<RawClaims> for JwtClaims {
fn from(rc: RawClaims) -> Self {
let mut raw = HashMap::new();
raw.insert("sub".to_string(), rc.sub.clone());
for (k, v) in &rc.extra {
match v {
serde_json::Value::String(s) => {
raw.insert(k.clone(), s.clone());
}
other => {
raw.insert(k.clone(), other.to_string());
}
}
}
Self {
sub: rc.sub,
roles: rc.roles,
permissions: rc.permissions,
raw,
}
}
}
impl IClaims for JwtClaims {
fn subject(&self) -> &str {
&self.sub
}
fn roles(&self) -> &[String] {
&self.roles
}
fn permissions(&self) -> &[String] {
&self.permissions
}
fn claims(&self) -> &HashMap<String, String> {
&self.raw
}
fn clone_box(&self) -> Box<dyn IClaims> {
Box::new(Self {
sub: self.sub.clone(),
roles: self.roles.clone(),
permissions: self.permissions.clone(),
raw: self.raw.clone(),
})
}
}
pub struct JwtAuth {
decoding_key: DecodingKey,
validation: Validation,
}
impl JwtAuth {
pub fn new(decoding_key: DecodingKey, validation: Validation) -> Self {
Self {
decoding_key,
validation,
}
}
}
#[async_trait::async_trait]
impl IAuthenticationHandler for JwtAuth {
async fn authenticate(&self, ctx: &mut dyn IHttpContext) -> Result<Option<Box<dyn IClaims>>> {
let header = match ctx.request().header("authorization") {
Some(h) => h,
None => return Ok(None),
};
let token = if header.len() >= 7 && header[..7].eq_ignore_ascii_case("Bearer ") {
header[7..].trim().to_string()
} else {
return Ok(None);
};
if token.is_empty() {
return Ok(None);
}
let token_data = match decode::<RawClaims>(&token, &self.decoding_key, &self.validation) {
Ok(data) => data,
Err(e) => {
tracing::debug!(reason = %e, "JWT rejected");
return Ok(None);
}
};
let claims: JwtClaims = token_data.claims.into();
Ok(Some(Box::new(claims)))
}
}
pub const DEFAULT_TOKEN_LIFETIME: Duration = Duration::from_secs(3600);
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or_default()
}
pub struct JwtTokenIssuer {
encoding_key: EncodingKey,
default_lifetime: Duration,
issuer: Option<String>,
audience: Option<String>,
}
impl JwtTokenIssuer {
pub fn from_secret(secret: impl AsRef<[u8]>, default_lifetime: Duration) -> Self {
Self {
encoding_key: EncodingKey::from_secret(secret.as_ref()),
default_lifetime,
issuer: None,
audience: None,
}
}
pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
self.issuer = Some(issuer.into());
self
}
pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
self.audience = Some(audience.into());
self
}
}
#[async_trait::async_trait]
impl ITokenIssuer for JwtTokenIssuer {
async fn issue(&self, claims: TokenClaims) -> Result<IssuedToken> {
let subject = claims.subject.trim();
if subject.is_empty() {
return Err(Error::Validation(
"token subject must not be empty".to_string(),
));
}
let lifetime = claims.expires_in.unwrap_or(self.default_lifetime);
let issued_at = unix_now();
let expires_at_unix_secs = issued_at.saturating_add(lifetime.as_secs());
let jti = uuid::Uuid::new_v4().to_string();
let mut payload = serde_json::Map::new();
for (key, value) in &claims.extra {
payload.insert(key.clone(), serde_json::Value::String(value.clone()));
}
payload.insert("sub".into(), serde_json::json!(subject));
payload.insert("roles".into(), serde_json::json!(claims.roles));
payload.insert("permissions".into(), serde_json::json!(claims.permissions));
payload.insert("jti".into(), serde_json::json!(jti));
payload.insert("iat".into(), serde_json::json!(issued_at));
payload.insert("exp".into(), serde_json::json!(expires_at_unix_secs));
if let Some(issuer) = &self.issuer {
payload.insert("iss".into(), serde_json::json!(issuer));
}
if let Some(audience) = &self.audience {
payload.insert("aud".into(), serde_json::json!(audience));
}
let token = encode(
&Header::default(),
&serde_json::Value::Object(payload),
&self.encoding_key,
)
.map_err(|error| Error::Internal(format!("JWT signing failed: {error}")))?;
Ok(IssuedToken {
token,
jti,
expires_at_unix_secs,
})
}
}
struct AuthMiddleware {
handler: Arc<dyn IAuthenticationHandler>,
session_store: Option<Arc<dyn ISessionStore>>,
}
#[async_trait::async_trait]
impl IMiddleware for AuthMiddleware {
async fn invoke(&self, ctx: &mut dyn IHttpContext) -> Result<ControlFlow<()>> {
if let Some(claims) = self.handler.authenticate(ctx).await? {
if let Some(store) = &self.session_store {
if let Some(jti) = claims.jti() {
if store.is_revoked(jti).await? {
return Err(Error::Unauthorized("token has been revoked".to_string()));
}
}
}
ctx.set_claims(claims);
}
Ok(ControlFlow::Continue(()))
}
}
pub fn jwt_middleware(handler: Arc<dyn IAuthenticationHandler>) -> impl IMiddleware {
AuthMiddleware {
handler,
session_store: None,
}
}
pub fn jwt_middleware_with_revocation(
handler: Arc<dyn IAuthenticationHandler>,
session_store: Arc<dyn ISessionStore>,
) -> impl IMiddleware {
AuthMiddleware {
handler,
session_store: Some(session_store),
}
}
static JWT_ENCODING_SECRET: OnceLock<String> = OnceLock::new();
pub fn init_jwt_secret(secret: &str) {
let _ = JWT_ENCODING_SECRET.set(secret.to_owned());
}
pub fn jwt_secret() -> &'static str {
JWT_ENCODING_SECRET
.get()
.expect("init_jwt_secret must be called before jwt_secret")
}