use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};
#[allow(clippy::incompatible_msrv)]
static REVOKED_TOKENS: std::sync::LazyLock<RwLock<RevocationList>> =
std::sync::LazyLock::new(|| RwLock::new(RevocationList::default()));
#[derive(Default)]
struct RevocationList {
entries: HashMap<String, u64>,
store: Option<PathBuf>,
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
impl RevocationList {
fn prune(&mut self) {
let now = now_secs();
self.entries.retain(|_, exp| *exp > now);
}
fn persist(&self) -> std::io::Result<()> {
let Some(path) = &self.store else {
return Ok(());
};
let json = serde_json::to_vec(&self.entries)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
}
pub fn configure_revocation_store(path: impl AsRef<Path>) -> std::io::Result<()> {
let path = path.as_ref().to_path_buf();
let entries: HashMap<String, u64> = match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("Revocation store {} is corrupt: {e}", path.display()),
)
})?,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => HashMap::new(),
Err(err) => return Err(err),
};
let mut list = REVOKED_TOKENS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
list.entries = entries;
list.store = Some(path);
list.prune();
list.persist()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Admin,
Operator,
Viewer,
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Role::Admin => write!(f, "admin"),
Role::Operator => write!(f, "operator"),
Role::Viewer => write!(f, "viewer"),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String,
pub jti: String,
#[serde(default = "default_role")]
pub role: Role,
pub iat: u64,
pub exp: u64,
}
fn default_role() -> Role {
Role::Admin
}
pub fn create_token(secret: &str, expiry_secs: u64) -> Result<String, jsonwebtoken::errors::Error> {
create_token_with_role(secret, expiry_secs, Role::Admin)
}
pub fn create_token_with_role(
secret: &str,
expiry_secs: u64,
role: Role,
) -> Result<String, jsonwebtoken::errors::Error> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let jti = uuid::Uuid::new_v4().to_string();
let claims = Claims {
sub: "vault-user".into(),
jti,
role,
iat: now,
exp: now + expiry_secs,
};
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
}
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
let data = decode::<Claims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)?;
{
let revoked = REVOKED_TOKENS
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if revoked.entries.contains_key(&data.claims.jti) {
return Err(jsonwebtoken::errors::Error::from(
jsonwebtoken::errors::ErrorKind::InvalidToken,
));
}
}
Ok(data.claims)
}
pub fn revoke_claims(claims: &Claims) -> std::io::Result<()> {
let mut revoked = REVOKED_TOKENS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
revoked.entries.insert(claims.jti.clone(), claims.exp);
revoked.prune();
revoked.persist()
}
#[deprecated(
since = "4.0.0",
note = "use `revoke_claims`, which records the token's expiry so the entry can be pruned"
)]
pub fn revoke_token(jti: &str) {
let mut revoked = REVOKED_TOKENS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
revoked.entries.insert(jti.to_string(), u64::MAX);
let _ = revoked.persist();
}
#[must_use]
pub fn revoked_count() -> usize {
let mut revoked = REVOKED_TOKENS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
revoked.prune();
revoked.entries.len()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_and_verify_token() {
let secret = "test-secret-key-for-jwt-signing";
let token = create_token(secret, 3600).unwrap();
let claims = verify_token(&token, secret).unwrap();
assert_eq!(claims.sub, "vault-user");
assert!(!claims.jti.is_empty());
assert_eq!(claims.role, Role::Admin);
}
#[test]
fn test_invalid_secret_rejects() {
let token = create_token("good-secret", 3600).unwrap();
let result = verify_token(&token, "wrong-secret");
assert!(result.is_err());
}
#[test]
fn test_expired_token_rejects() {
let secret = "test-secret";
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let claims = Claims {
sub: "vault-user".into(),
jti: uuid::Uuid::new_v4().to_string(),
role: Role::Admin,
iat: now - 7200,
exp: now - 3600,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.unwrap();
let result = verify_token(&token, secret);
assert!(result.is_err());
}
static REVOCATION_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn revocation_guard() -> std::sync::MutexGuard<'static, ()> {
let guard = REVOCATION_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut list = REVOKED_TOKENS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
list.entries.clear();
list.store = None;
drop(list);
guard
}
#[test]
fn test_revoke_claims() {
let _guard = revocation_guard();
let secret = "test-revoke-secret";
let token = create_token(secret, 3600).unwrap();
let claims = verify_token(&token, secret).unwrap();
revoke_claims(&claims).unwrap();
let result = verify_token(&token, secret);
assert!(result.is_err(), "a revoked token must not verify");
}
#[test]
fn test_expired_revocations_are_pruned() {
let _guard = revocation_guard();
let before = revoked_count();
{
let mut list = REVOKED_TOKENS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
list.entries
.insert("stale-jti-for-pruning-test".into(), now_secs() - 3600);
}
assert_eq!(
revoked_count(),
before,
"an entry past its exp should have been pruned"
);
let list = REVOKED_TOKENS
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert!(!list.entries.contains_key("stale-jti-for-pruning-test"));
}
#[test]
fn test_unexpired_revocations_are_kept() {
let _guard = revocation_guard();
let jti = "live-jti-for-pruning-test";
{
let mut list = REVOKED_TOKENS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
list.entries.insert(jti.into(), now_secs() + 3600);
}
let _ = revoked_count();
let mut list = REVOKED_TOKENS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
assert!(
list.entries.contains_key(jti),
"pruning must not drop an entry whose token is still valid"
);
list.entries.remove(jti);
}
#[test]
fn test_revocations_survive_a_restart() {
let _guard = revocation_guard();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("revocations.json");
configure_revocation_store(&path).unwrap();
let secret = "test-persistence-secret";
let token = create_token(secret, 3600).unwrap();
let claims = verify_token(&token, secret).unwrap();
revoke_claims(&claims).unwrap();
{
let mut list = REVOKED_TOKENS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
list.entries.clear();
list.store = None;
}
assert!(
verify_token(&token, secret).is_ok(),
"precondition: with the list wiped and no store, the token verifies \
again — this is exactly the bug persistence fixes"
);
configure_revocation_store(&path).unwrap();
assert!(
verify_token(&token, secret).is_err(),
"a revocation recorded before the restart must still apply after it"
);
}
#[test]
fn test_a_corrupt_revocation_store_is_an_error_not_an_empty_list() {
let _guard = revocation_guard();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("revocations.json");
std::fs::write(&path, b"{not json").unwrap();
let err = configure_revocation_store(&path).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(
err.to_string().contains("corrupt"),
"the operator needs to know the store was unreadable, not silently \
start with zero revocations; got: {err}"
);
}
#[test]
fn test_a_missing_revocation_store_is_a_valid_empty_list() {
let _guard = revocation_guard();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("does-not-exist-yet.json");
configure_revocation_store(&path).unwrap();
assert!(path.exists(), "the store should be created on first use");
}
#[test]
fn test_create_token_with_role() {
let secret = "test-role-secret";
let token = create_token_with_role(secret, 3600, Role::Operator).unwrap();
let claims = verify_token(&token, secret).unwrap();
assert_eq!(claims.role, Role::Operator);
let token2 = create_token_with_role(secret, 3600, Role::Viewer).unwrap();
let claims2 = verify_token(&token2, secret).unwrap();
assert_eq!(claims2.role, Role::Viewer);
}
}