use crate::server::error::AppError;
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::debug;
fn ensure_jwt_crypto_provider() {
static INSTALLED: OnceLock<()> = OnceLock::new();
INSTALLED.get_or_init(|| {
let _ = jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER.install_default();
});
}
fn default_amr() -> Vec<String> {
vec!["did".to_string()]
}
fn default_acr() -> String {
"aal1".to_string()
}
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub aud: String,
pub sub: String,
pub session_id: String,
pub role: String,
pub exp: u64,
#[serde(default)]
pub iat: u64,
#[serde(default)]
pub jti: String,
#[serde(default = "default_amr")]
pub amr: Vec<String>,
#[serde(default = "default_acr")]
pub acr: String,
}
pub struct JwtKeys {
encoding: EncodingKey,
decoding: DecodingKey,
}
impl JwtKeys {
pub fn from_ed25519_bytes(private_bytes: &[u8; 32]) -> Result<Self, AppError> {
ensure_jwt_crypto_provider();
let signing_key = ed25519_dalek::SigningKey::from_bytes(private_bytes);
let public_bytes = signing_key.verifying_key().to_bytes();
let mut pkcs8 = Vec::with_capacity(48);
pkcs8.extend_from_slice(&[
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20, ]);
pkcs8.extend_from_slice(private_bytes);
let encoding = EncodingKey::from_ed_der(&pkcs8);
let decoding = DecodingKey::from_ed_der(&public_bytes);
Ok(Self { encoding, decoding })
}
pub fn encode(&self, claims: &Claims) -> Result<String, AppError> {
let header = Header::new(Algorithm::EdDSA);
jsonwebtoken::encode(&header, claims, &self.encoding)
.map_err(|e| AppError::Internal(format!("JWT encode failed: {e}")))
}
pub fn decode(&self, token: &str) -> Result<Claims, AppError> {
let mut validation = Validation::new(Algorithm::EdDSA);
validation.set_audience(&["WebVH"]);
validation.set_required_spec_claims(&["exp", "sub", "aud", "session_id", "role"]);
jsonwebtoken::decode::<Claims>(token, &self.decoding, &validation)
.map(|data| data.claims)
.map_err(|e| {
debug!(error = %e, "JWT decode failed");
AppError::Unauthorized(format!("invalid token: {e}"))
})
}
pub fn new_claims(sub: String, session_id: String, role: String, expiry_secs: u64) -> Claims {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Claims {
aud: "WebVH".to_string(),
sub,
session_id,
role,
exp: now + expiry_secs,
iat: now,
jti: uuid::Uuid::new_v4().to_string(),
amr: default_amr(),
acr: default_acr(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn keys() -> JwtKeys {
JwtKeys::from_ed25519_bytes(&[7u8; 32]).expect("test key")
}
fn other_keys() -> JwtKeys {
JwtKeys::from_ed25519_bytes(&[42u8; 32]).expect("other test key")
}
fn make_claims(role: &str, expiry_secs: u64) -> Claims {
JwtKeys::new_claims(
"did:example:caller".into(),
"session-abc".into(),
role.into(),
expiry_secs,
)
}
#[test]
fn encode_decode_round_trip() {
let keys = keys();
let claims = make_claims("admin", 60);
let token = keys.encode(&claims).unwrap();
let decoded = keys.decode(&token).unwrap();
assert_eq!(decoded.aud, "WebVH");
assert_eq!(decoded.sub, "did:example:caller");
assert_eq!(decoded.session_id, "session-abc");
assert_eq!(decoded.role, "admin");
assert_eq!(decoded.exp, claims.exp);
assert_eq!(decoded.jti, claims.jti);
}
#[test]
fn decode_rejects_token_signed_by_different_key() {
let issuer = keys();
let attacker = other_keys();
let claims = make_claims("admin", 60);
let token = attacker.encode(&claims).unwrap();
assert!(issuer.decode(&token).is_err());
}
#[test]
fn decode_rejects_expired_token() {
let keys = keys();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let claims = Claims {
aud: "WebVH".into(),
sub: "did:example:expired".into(),
session_id: "s".into(),
role: "owner".into(),
exp: now - 3600,
iat: now - 7200,
jti: "j".into(),
amr: vec!["did".into()],
acr: "aal1".into(),
};
let token = keys.encode(&claims).unwrap();
let err = keys.decode(&token).unwrap_err();
assert!(matches!(err, AppError::Unauthorized(_)));
}
#[test]
fn decode_rejects_wrong_audience() {
let keys = keys();
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let claims = Claims {
aud: "OtherAudience".into(),
sub: "did:example:wrong-aud".into(),
session_id: "s".into(),
role: "owner".into(),
exp: now + 60,
iat: now,
jti: "j".into(),
amr: vec!["did".into()],
acr: "aal1".into(),
};
let token = keys.encode(&claims).unwrap();
assert!(keys.decode(&token).is_err());
}
#[test]
fn decode_rejects_garbage_token() {
let keys = keys();
assert!(keys.decode("not-a-jwt").is_err());
assert!(keys.decode("ey.ey.ey").is_err());
assert!(keys.decode("").is_err());
}
#[test]
fn decode_rejects_alg_none_attempt() {
let header_b64 = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0";
let payload = serde_json::json!({
"aud": "WebVH",
"sub": "did:attacker",
"session_id": "s",
"role": "admin",
"exp": 9999999999u64,
"iat": 0u64,
"jti": "j",
});
let payload_b64 = base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
serde_json::to_vec(&payload).unwrap(),
);
let unsigned = format!("{header_b64}.{payload_b64}.");
assert!(keys().decode(&unsigned).is_err());
}
#[test]
fn new_claims_populates_fresh_jti_and_iat_each_call() {
let a = JwtKeys::new_claims("did:s".into(), "s".into(), "owner".into(), 60);
let b = JwtKeys::new_claims("did:s".into(), "s".into(), "owner".into(), 60);
assert_ne!(a.jti, b.jti);
assert!(a.iat > 0);
assert!(a.exp > a.iat);
assert_eq!(a.exp - a.iat, 60);
}
}