use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::de::DeserializeOwned;
use serde::Serialize;
pub trait ExpClaims {
fn exp(&self) -> i64;
}
#[derive(Clone)]
pub struct LocalJwt {
encoding: EncodingKey,
decoding: DecodingKey,
}
#[derive(Debug, thiserror::Error)]
pub enum LocalJwtError {
#[error("local jwt sign failed: {0}")]
Sign(#[source] jsonwebtoken::errors::Error),
#[error("local jwt verify failed: {0}")]
Verify(#[source] jsonwebtoken::errors::Error),
#[error("local jwt claims must carry a positive exp")]
MissingExp,
}
impl LocalJwt {
pub fn from_secret(secret: impl Into<Vec<u8>>) -> Self {
let secret = secret.into();
Self {
encoding: EncodingKey::from_secret(&secret),
decoding: DecodingKey::from_secret(&secret),
}
}
pub fn sign<C: Serialize + ExpClaims>(&self, claims: &C) -> Result<String, LocalJwtError> {
if claims.exp() <= 0 {
return Err(LocalJwtError::MissingExp);
}
jsonwebtoken::encode(&Header::new(Algorithm::HS256), claims, &self.encoding)
.map_err(LocalJwtError::Sign)
}
pub fn verify<C: DeserializeOwned + ExpClaims>(&self, token: &str) -> Result<C, LocalJwtError> {
let mut validation = Validation::new(Algorithm::HS256);
validation.leeway = 60;
validation.required_spec_claims.clear();
let data = jsonwebtoken::decode::<C>(token, &self.decoding, &validation)
.map_err(LocalJwtError::Verify)?;
Ok(data.claims)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
struct AppClaims {
sub: String,
custom: String,
exp: i64,
}
impl ExpClaims for AppClaims {
fn exp(&self) -> i64 {
self.exp
}
}
fn claims(exp: i64) -> AppClaims {
AppClaims {
sub: "device-7".into(),
custom: "machine-token".into(),
exp,
}
}
#[test]
fn round_trips_caller_defined_claims() {
let jwt = LocalJwt::from_secret(b"machine-fingerprint-derived");
let exp = chrono::Utc::now().timestamp() + 300;
let token = jwt.sign(&claims(exp)).unwrap();
let back = jwt.verify::<AppClaims>(&token).unwrap();
assert_eq!(back, claims(exp), "caller claims survive the wire");
}
#[test]
fn expired_tokens_are_rejected() {
let jwt = LocalJwt::from_secret(b"k");
let stale = jwt
.sign(&claims(chrono::Utc::now().timestamp() - 120))
.unwrap();
assert!(matches!(
jwt.verify::<AppClaims>(&stale),
Err(LocalJwtError::Verify(_))
));
}
#[test]
fn tampered_signature_and_wrong_key_are_rejected() {
let signer = LocalJwt::from_secret(b"alpha");
let other = LocalJwt::from_secret(b"beta");
let exp = chrono::Utc::now().timestamp() + 60;
let token = signer.sign(&claims(exp)).unwrap();
assert!(other.verify::<AppClaims>(&token).is_err());
let mut forged = token.clone();
forged.replace_range(0..1, "X");
assert!(signer.verify::<AppClaims>(&forged).is_err());
}
#[test]
fn signing_refuses_claims_without_a_positive_exp() {
let jwt = LocalJwt::from_secret(b"k");
assert!(matches!(
jwt.sign(&claims(0)),
Err(LocalJwtError::MissingExp)
));
assert!(matches!(
jwt.sign(&claims(-5)),
Err(LocalJwtError::MissingExp)
));
}
}