use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use crate::web::context::Claims;
#[derive(Debug)]
pub struct JwtSignError(pub jsonwebtoken::errors::Error);
impl std::fmt::Display for JwtSignError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "JWT signing failed: {}", self.0)
}
}
impl std::error::Error for JwtSignError {}
pub struct JwtConfig {
pub secret: String,
pub public_key_pem: Option<String>,
pub key_id: Option<String>,
pub algorithm: Algorithm,
pub access_ttl_secs: u64,
pub refresh_ttl_secs: u64,
}
impl Default for JwtConfig {
fn default() -> Self {
Self {
secret: "change-me-in-production".to_string(),
public_key_pem: None,
key_id: None,
algorithm: Algorithm::HS256,
access_ttl_secs: 900,
refresh_ttl_secs: 604_800,
}
}
}
fn is_asymmetric(alg: Algorithm) -> bool {
matches!(
alg,
Algorithm::RS256
| Algorithm::RS384
| Algorithm::RS512
| Algorithm::PS256
| Algorithm::PS384
| Algorithm::PS512
| Algorithm::ES256
| Algorithm::ES384
| Algorithm::EdDSA
)
}
#[derive(Debug, Serialize, Deserialize)]
struct JwtClaims {
sub: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
role: String,
#[serde(skip_serializing_if = "String::is_empty", default)]
email: String,
#[serde(rename = "type")]
kind: String,
jti: String,
iat: u64,
exp: u64,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
perms: Vec<String>,
#[serde(skip_serializing_if = "String::is_empty", default)]
tenant: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
cnf: Option<Cnf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cnf {
pub jkt: String,
}
struct JwtKeyMaterial {
encoding: EncodingKey,
verify: Vec<DecodingKey>, version: u64,
}
impl JwtKeyMaterial {
fn from_secret(secret: &[u8], version: u64, previous: Option<DecodingKey>) -> Self {
let mut verify = vec![DecodingKey::from_secret(secret)];
verify.extend(previous);
Self {
encoding: EncodingKey::from_secret(secret),
verify,
version,
}
}
fn build(
algorithm: Algorithm,
signing: &[u8],
public_pem: Option<&str>,
version: u64,
previous: Option<DecodingKey>,
) -> Result<Self, JwtSignError> {
if !is_asymmetric(algorithm) {
return Ok(Self::from_secret(signing, version, previous));
}
let pub_pem = public_pem
.ok_or_else(|| JwtSignError(jwt_err()))?
.as_bytes();
let (encoding, decoding) = match algorithm {
Algorithm::ES256 | Algorithm::ES384 => (
EncodingKey::from_ec_pem(signing).map_err(JwtSignError)?,
DecodingKey::from_ec_pem(pub_pem).map_err(JwtSignError)?,
),
Algorithm::EdDSA => (
EncodingKey::from_ed_pem(signing).map_err(JwtSignError)?,
DecodingKey::from_ed_pem(pub_pem).map_err(JwtSignError)?,
),
_ => (
EncodingKey::from_rsa_pem(signing).map_err(JwtSignError)?,
DecodingKey::from_rsa_pem(pub_pem).map_err(JwtSignError)?,
),
};
let mut verify = vec![decoding];
verify.extend(previous);
Ok(Self {
encoding,
verify,
version,
})
}
}
fn jwt_err() -> jsonwebtoken::errors::Error {
jsonwebtoken::errors::ErrorKind::InvalidKeyFormat.into()
}
pub struct JwtService {
keys: crate::auth::secrets::Rotating<JwtKeyMaterial>,
header: Header,
validation: Validation,
config: JwtConfig,
}
impl JwtService {
pub fn new(config: JwtConfig) -> Self {
Self::try_new(config).expect("JwtService: invalid key material")
}
pub fn try_new(config: JwtConfig) -> Result<Self, JwtSignError> {
let material = JwtKeyMaterial::build(
config.algorithm,
config.secret.as_bytes(),
config.public_key_pem.as_deref(),
1,
None,
)?;
let keys = crate::auth::secrets::Rotating::new(material);
let mut header = Header::new(config.algorithm);
header.kid = config.key_id.clone();
let mut validation = Validation::new(config.algorithm);
validation.validate_exp = true;
Ok(Self {
keys,
header,
validation,
config,
})
}
pub fn rotate_secret(&self, new_secret: &[u8], version: u64) {
self.rotate_keypair(new_secret, self.config.public_key_pem.as_deref(), version);
}
pub fn rotate_keypair(&self, new_signing: &[u8], new_public_pem: Option<&str>, version: u64) {
let current = self.keys.load();
if version <= current.version {
tracing::warn!(
current = current.version,
offered = version,
"ignoring stale JWT secret rotation",
);
return;
}
let previous = current.verify.first().cloned();
match JwtKeyMaterial::build(
self.config.algorithm,
new_signing,
new_public_pem,
version,
previous,
) {
Ok(material) => {
self.keys.store(material);
tracing::info!(version, "JwtService signing key rotated");
}
Err(e) => {
tracing::error!(version, error = %e, "JWT key rotation rejected — keeping current key");
}
}
}
pub fn public_key_pem(&self) -> Option<&str> {
if is_asymmetric(self.config.algorithm) {
self.config.public_key_pem.as_deref()
} else {
None
}
}
pub fn key_id(&self) -> Option<&str> {
self.config.key_id.as_deref()
}
pub fn algorithm_name(&self) -> &'static str {
match self.config.algorithm {
Algorithm::HS256 => "HS256",
Algorithm::HS384 => "HS384",
Algorithm::HS512 => "HS512",
Algorithm::RS256 => "RS256",
Algorithm::RS384 => "RS384",
Algorithm::RS512 => "RS512",
Algorithm::PS256 => "PS256",
Algorithm::PS384 => "PS384",
Algorithm::PS512 => "PS512",
Algorithm::ES256 => "ES256",
Algorithm::ES384 => "ES384",
Algorithm::EdDSA => "EdDSA",
}
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn sign(&self, claims: &serde_json::Value) -> Result<String, JwtSignError> {
encode(&self.header, claims, &self.keys.load().encoding).map_err(JwtSignError)
}
pub fn unix_now() -> u64 {
Self::now()
}
pub fn issue_access(&self, sub: &str, role: &str, email: &str) -> Result<String, JwtSignError> {
self.issue_access_with_perms(sub, role, email, &[])
}
pub fn issue_access_with_perms(
&self,
sub: &str,
role: &str,
email: &str,
perms: &[String],
) -> Result<String, JwtSignError> {
self.issue_access_bound(sub, role, email, perms, None)
}
pub fn issue_access_bound(
&self,
sub: &str,
role: &str,
email: &str,
perms: &[String],
tenant: Option<&str>,
) -> Result<String, JwtSignError> {
self.issue_access_bound_cnf(sub, role, email, perms, tenant, None)
}
pub fn issue_access_bound_cnf(
&self,
sub: &str,
role: &str,
email: &str,
perms: &[String],
tenant: Option<&str>,
cnf_jkt: Option<&str>,
) -> Result<String, JwtSignError> {
let now = Self::now();
let claims = JwtClaims {
sub: sub.to_owned(),
role: role.to_owned(),
email: email.to_owned(),
kind: "access".to_owned(),
jti: new_jti(),
iat: now,
exp: now + self.config.access_ttl_secs,
perms: perms.to_vec(),
tenant: tenant.unwrap_or("").to_owned(),
cnf: cnf_jkt.map(|jkt| Cnf {
jkt: jkt.to_owned(),
}),
};
encode(&self.header, &claims, &self.keys.load().encoding).map_err(JwtSignError)
}
pub fn issue_refresh(&self, sub: &str) -> Result<(String, String), JwtSignError> {
let now = Self::now();
let jti = new_jti();
let claims = JwtClaims {
sub: sub.to_owned(),
role: String::new(),
email: String::new(),
kind: "refresh".to_owned(),
jti: jti.clone(),
iat: now,
exp: now + self.config.refresh_ttl_secs,
perms: Vec::new(),
tenant: String::new(),
cnf: None,
};
let token =
encode(&self.header, &claims, &self.keys.load().encoding).map_err(JwtSignError)?;
Ok((token, jti))
}
pub fn decode(&self, token: &str) -> Option<Arc<Claims>> {
let keys = self.keys.load();
let data = keys
.verify
.iter()
.find_map(|k| decode::<serde_json::Value>(token, k, &self.validation).ok())?;
let obj = data.claims.as_object()?.clone();
Some(Arc::new(obj))
}
pub fn decode_access(&self, token: &str) -> Option<Arc<Claims>> {
let claims = self.decode(token)?;
if claims.get("type").and_then(|v| v.as_str()) != Some("access") {
return None;
}
Some(claims)
}
pub fn validate_refresh(&self, token: &str) -> Option<(String, String)> {
let claims = self.decode(token)?;
if claims.get("type")?.as_str()? != "refresh" {
return None;
}
let sub = claims.get("sub")?.as_str()?.to_owned();
let jti = claims.get("jti")?.as_str()?.to_owned();
Some((sub, jti))
}
pub fn access_ttl_secs(&self) -> u64 {
self.config.access_ttl_secs
}
pub fn refresh_ttl_secs(&self) -> u64 {
self.config.refresh_ttl_secs
}
}
pub fn decode_bearer_token(
headers: &http::HeaderMap,
container: &crate::core::engine::FrozenDiContainer,
) -> Option<Arc<Claims>> {
let raw = headers.get("authorization")?.to_str().ok()?;
let token = raw.strip_prefix("Bearer ").unwrap_or(raw).trim();
if token.is_empty() {
return None;
}
container.try_get::<JwtService>()?.decode_access(token)
}
fn new_jti() -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
let mut h1 = DefaultHasher::new();
SystemTime::now().hash(&mut h1);
seq.hash(&mut h1);
let mut h2 = DefaultHasher::new();
std::thread::current().id().hash(&mut h2);
seq.wrapping_add(1).hash(&mut h2);
format!("{:016x}{:016x}", h1.finish(), h2.finish())
}