use std::sync::Arc;
use base64::Engine;
use subtle::ConstantTimeEq;
use crate::jti_store::{InMemoryJtiStore, JtiStore};
#[derive(Debug, Clone)]
pub struct JwtTokenPair {
pub access: String,
pub refresh: String,
}
#[derive(Debug, Clone)]
pub struct JwtClaims {
pub sub: i64,
pub exp: i64,
pub jti: String,
pub typ: String,
pub custom: serde_json::Map<String, serde_json::Value>,
}
impl JwtClaims {
#[must_use]
pub fn get_custom<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
let v = self.custom.get(key)?;
serde_json::from_value(v.clone()).ok()
}
#[must_use]
pub fn custom_value(&self, key: &str) -> Option<&serde_json::Value> {
self.custom.get(key)
}
pub fn user_id_in(&self, scope: UserTokenScope<'_>) -> Result<i64, &'static str> {
scope.admits(&self.custom).map(|()| self.sub)
}
}
pub const CLAIM_KIND: &str = "kind";
pub const CLAIM_TENANT: &str = "tenant";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum UserTokenScope<'a> {
Tenant(&'a str),
Unscoped,
}
impl UserTokenScope<'_> {
pub(crate) fn admits(
self,
claims: &serde_json::Map<String, serde_json::Value>,
) -> Result<(), &'static str> {
if claims.contains_key(CLAIM_KIND) {
return Err("token is not a user token");
}
let bound = claims.get(CLAIM_TENANT);
match (self, bound) {
(Self::Tenant(slug), Some(v)) if v.as_str() == Some(slug) => Ok(()),
(Self::Tenant(_), Some(_)) => Err("token issued for different tenant"),
(Self::Tenant(_), None) => Err("token missing tenant binding"),
(Self::Unscoped, None) => Ok(()),
(Self::Unscoped, Some(_)) => Err("tenant token presented without a tenant"),
}
}
}
pub const RESERVED_CLAIM_NAMES: &[&str] = &["sub", "exp", "jti", "typ"];
#[derive(Debug, thiserror::Error)]
pub enum JwtIssueError {
#[error("reserved claim `{0}` cannot be set in custom payload")]
ReservedClaim(String),
}
const ACCESS_TYP: &str = "access";
const REFRESH_TYP: &str = "refresh";
pub const DEFAULT_ACCESS_TTL_SECS: i64 = 900;
pub const DEFAULT_REFRESH_TTL_SECS: i64 = 7 * 24 * 3600;
pub struct JwtLifecycle {
secret: Vec<u8>,
pub access_ttl_secs: i64,
pub refresh_ttl_secs: i64,
jti_store: Arc<dyn JtiStore>,
}
impl JwtLifecycle {
#[must_use]
pub fn new(secret: Vec<u8>) -> Self {
assert!(
secret.len() >= 32,
"JwtLifecycle signing key is too short; need >= 32 bytes (a shorter key is forgeable)",
);
Self {
secret,
access_ttl_secs: DEFAULT_ACCESS_TTL_SECS,
refresh_ttl_secs: DEFAULT_REFRESH_TTL_SECS,
jti_store: Arc::new(InMemoryJtiStore::new()),
}
}
#[must_use]
pub fn with_jti_store(mut self, store: Arc<dyn JtiStore>) -> Self {
self.jti_store = store;
self
}
#[must_use]
pub fn jti_store_is_process_local(&self) -> bool {
self.jti_store.is_process_local()
}
#[must_use]
pub fn with_access_ttl(mut self, secs: i64) -> Self {
self.access_ttl_secs = secs;
self
}
#[must_use]
pub fn with_refresh_ttl(mut self, secs: i64) -> Self {
self.refresh_ttl_secs = secs;
self
}
pub fn issue_pair(&self, user_id: i64) -> JwtTokenPair {
self.issue_pair_with(user_id, serde_json::Map::new())
.expect("empty custom map cannot trigger ReservedClaim")
}
pub fn issue_pair_with(
&self,
user_id: i64,
custom: serde_json::Map<String, serde_json::Value>,
) -> Result<JwtTokenPair, JwtIssueError> {
check_reserved(&custom)?;
let access = self.issue_token_inner(user_id, ACCESS_TYP, self.access_ttl_secs, &custom);
let refresh = self.issue_token_inner(user_id, REFRESH_TYP, self.refresh_ttl_secs, &custom);
Ok(JwtTokenPair { access, refresh })
}
pub fn issue_access_with(
&self,
user_id: i64,
custom: serde_json::Map<String, serde_json::Value>,
) -> Result<String, JwtIssueError> {
check_reserved(&custom)?;
Ok(self.issue_token_inner(user_id, ACCESS_TYP, self.access_ttl_secs, &custom))
}
#[must_use]
pub async fn verify_access(&self, token: &str) -> Option<JwtClaims> {
let claims = self.verify_token(token).await?;
if claims.typ != ACCESS_TYP {
return None;
}
Some(claims)
}
#[must_use]
pub async fn verify_refresh(&self, token: &str) -> Option<JwtClaims> {
let claims = self.verify_token(token).await?;
if claims.typ != REFRESH_TYP {
return None;
}
Some(claims)
}
pub async fn refresh(&self, refresh_token: &str) -> Option<JwtTokenPair> {
let claims = self.redeem_refresh(refresh_token).await?;
self.issue_pair_with(claims.sub, claims.custom).ok()
}
pub async fn refresh_with(
&self,
refresh_token: &str,
new_custom: serde_json::Map<String, serde_json::Value>,
) -> Result<Option<JwtTokenPair>, JwtIssueError> {
let Some(claims) = self.redeem_refresh(refresh_token).await else {
return Ok(None);
};
self.issue_pair_with(claims.sub, new_custom).map(Some)
}
pub async fn revoke(&self, token: &str) -> bool {
let Some(claims) = self.decode_signed(token) else {
return false;
};
self.blacklist_jti(&claims.jti, claims.exp).await;
true
}
#[must_use]
pub async fn blacklist_size(&self) -> usize {
self.jti_store.approx_size().await.unwrap_or(0)
}
fn issue_token_inner(
&self,
user_id: i64,
typ: &str,
ttl_secs: i64,
custom: &serde_json::Map<String, serde_json::Value>,
) -> String {
let exp = chrono::Utc::now().timestamp() + ttl_secs;
let jti = random_jti();
let mut payload = serde_json::Map::new();
for (k, v) in custom {
payload.insert(k.clone(), v.clone());
}
payload.insert("sub".into(), serde_json::Value::from(user_id));
payload.insert("exp".into(), serde_json::Value::from(exp));
payload.insert("jti".into(), serde_json::Value::String(jti));
payload.insert("typ".into(), serde_json::Value::String(typ.into()));
let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
serde_json::to_vec(&serde_json::json!({"alg": "HS256", "typ": "JWT"}))
.unwrap_or_default(),
);
let payload_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(serde_json::to_vec(&payload).unwrap_or_default());
let signing_input = format!("{header_b64}.{payload_b64}");
let sig = self.sign(signing_input.as_bytes());
let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig);
format!("{signing_input}.{sig_b64}")
}
async fn verify_token(&self, token: &str) -> Option<JwtClaims> {
let claims = self.decode_unexpired(token)?;
if self.is_blacklisted(&claims.jti).await {
return None;
}
Some(claims)
}
async fn redeem_refresh(&self, token: &str) -> Option<JwtClaims> {
let claims = self.decode_unexpired(token)?;
if claims.typ != REFRESH_TYP {
return None;
}
self.jti_store
.mark_used(&claims.jti, claims.exp)
.await
.then_some(claims)
}
pub(crate) fn decode_refresh(&self, token: &str) -> Option<JwtClaims> {
self.decode_unexpired(token)
.filter(|c| c.typ == REFRESH_TYP)
}
pub(crate) async fn revoke_family(&self, fam: &str, exp: i64) {
let key = family_key(fam);
if !self.jti_store.mark_used(&key, exp).await && !self.jti_store.is_used(&key).await {
tracing::warn!(
target: "rustango::tenancy",
"JTI store did not record a refresh-family revocation; the replayed chain stays live"
);
}
}
pub(crate) async fn note_refresh_attempt(&self, jti: &str, grace: i64) {
if grace <= 0 || self.jti_store.is_used(jti).await {
return;
}
let now = chrono::Utc::now().timestamp();
let key = grace_key(jti, now / grace);
let _ = self.jti_store.mark_used(&key, now + 2 * grace).await;
}
pub(crate) async fn recently_attempted(&self, jti: &str, grace: i64) -> bool {
if grace <= 0 {
return false;
}
let bucket = chrono::Utc::now().timestamp() / grace;
self.jti_store.is_used(&grace_key(jti, bucket)).await
|| self.jti_store.is_used(&grace_key(jti, bucket - 1)).await
}
pub(crate) fn jti_store(&self) -> &dyn JtiStore {
&*self.jti_store
}
fn decode_unexpired(&self, token: &str) -> Option<JwtClaims> {
let claims = self.decode_signed(token)?;
(chrono::Utc::now().timestamp() < claims.exp).then_some(claims)
}
pub(crate) fn decode_signed(&self, token: &str) -> Option<JwtClaims> {
let parts: Vec<&str> = token.split('.').collect();
let (signing_input, payload_b64, sig_b64) = match parts.as_slice() {
[header, payload, sig] => {
let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(header)
.ok()?;
let header_json: serde_json::Value = serde_json::from_slice(&header_bytes).ok()?;
if header_json.get("alg").and_then(serde_json::Value::as_str) != Some("HS256") {
return None;
}
(format!("{header}.{payload}"), *payload, *sig)
}
[payload, sig] => ((*payload).to_owned(), *payload, *sig),
_ => return None,
};
let expected = self.sign(signing_input.as_bytes());
let provided = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(sig_b64)
.ok()?;
if expected.ct_eq(&provided[..]).unwrap_u8() == 0 {
return None;
}
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.ok()?;
let mut payload: serde_json::Map<String, serde_json::Value> =
serde_json::from_slice(&payload_bytes).ok()?;
let sub = payload.get("sub")?.as_i64()?;
let exp = payload.get("exp")?.as_i64()?;
let jti = payload.get("jti")?.as_str()?.to_owned();
let typ = payload.get("typ")?.as_str()?.to_owned();
for reserved in RESERVED_CLAIM_NAMES {
payload.remove(*reserved);
}
Some(JwtClaims {
sub,
exp,
jti,
typ,
custom: payload,
})
}
fn sign(&self, msg: &[u8]) -> Vec<u8> {
use hmac::{Hmac, Mac};
use sha2::Sha256;
let mut mac = <Hmac<Sha256>>::new_from_slice(&self.secret).expect("HMAC accepts any key");
mac.update(msg);
mac.finalize().into_bytes().to_vec()
}
async fn blacklist_jti(&self, jti: &str, expires_at: i64) {
let _ = self.jti_store.mark_used(jti, expires_at).await;
}
pub(crate) async fn is_blacklisted(&self, jti: &str) -> bool {
self.jti_store.is_used(jti).await
}
}
pub(crate) async fn family_revoked_in(store: &dyn JtiStore, fam: &str) -> bool {
store.is_used(&family_key(fam)).await
}
fn family_key(fam: &str) -> String {
format!("fam:{fam}")
}
fn grace_key(jti: &str, bucket: i64) -> String {
format!("grace:{jti}:{bucket}")
}
pub(crate) fn random_jti() -> String {
use rand::{rngs::OsRng, RngCore};
let mut bytes = [0u8; 16];
OsRng.fill_bytes(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn check_reserved(
custom: &serde_json::Map<String, serde_json::Value>,
) -> Result<(), JwtIssueError> {
for reserved in RESERVED_CLAIM_NAMES {
if custom.contains_key(*reserved) {
return Err(JwtIssueError::ReservedClaim((*reserved).to_owned()));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn jwt() -> JwtLifecycle {
JwtLifecycle::new(b"test-secret-at-least-32-bytes-ok!".to_vec())
}
#[tokio::test]
async fn issue_and_verify_access() {
let j = jwt();
let pair = j.issue_pair(42);
let claims = j
.verify_access(&pair.access)
.await
.expect("access verifies");
assert_eq!(claims.sub, 42);
assert_eq!(claims.typ, "access");
assert!(!claims.jti.is_empty());
}
#[tokio::test]
async fn issue_and_verify_refresh() {
let j = jwt();
let pair = j.issue_pair(42);
let claims = j
.verify_refresh(&pair.refresh)
.await
.expect("refresh verifies");
assert_eq!(claims.sub, 42);
assert_eq!(claims.typ, "refresh");
}
#[tokio::test]
async fn access_token_rejected_as_refresh() {
let j = jwt();
let pair = j.issue_pair(1);
assert!(j.verify_refresh(&pair.access).await.is_none());
}
#[tokio::test]
async fn refresh_token_rejected_as_access() {
let j = jwt();
let pair = j.issue_pair(1);
assert!(j.verify_access(&pair.refresh).await.is_none());
}
#[tokio::test]
async fn refresh_returns_new_pair() {
let j = jwt();
let pair = j.issue_pair(7);
let new_pair = j.refresh(&pair.refresh).await.expect("refresh succeeds");
assert_ne!(pair.access, new_pair.access);
assert_ne!(pair.refresh, new_pair.refresh);
let claims = j.verify_access(&new_pair.access).await.unwrap();
assert_eq!(claims.sub, 7);
}
#[tokio::test]
async fn refresh_blacklists_old_refresh_token() {
let j = jwt();
let pair = j.issue_pair(7);
let _new = j.refresh(&pair.refresh).await.unwrap();
assert!(j.refresh(&pair.refresh).await.is_none());
assert!(j.verify_refresh(&pair.refresh).await.is_none());
}
struct YieldingStore(std::sync::Mutex<std::collections::HashSet<String>>);
impl JtiStore for YieldingStore {
fn is_used<'a>(&'a self, jti: &'a str) -> crate::jti_store::JtiFuture<'a, bool> {
Box::pin(async move {
tokio::task::yield_now().await;
self.0.lock().unwrap().contains(jti)
})
}
fn mark_used<'a>(&'a self, jti: &'a str, _: i64) -> crate::jti_store::JtiFuture<'a, bool> {
Box::pin(async move {
tokio::task::yield_now().await;
self.0.lock().unwrap().insert(jti.to_owned())
})
}
}
#[tokio::test]
async fn concurrent_refreshes_of_one_token_yield_one_pair() {
let j = jwt().with_jti_store(Arc::new(YieldingStore(Default::default())));
let pair = j.issue_pair(7);
let (a, b) = tokio::join!(j.refresh(&pair.refresh), j.refresh(&pair.refresh));
assert!(a.is_some() ^ b.is_some(), "a refresh token redeemed twice");
let pair = j.issue_pair(7);
let (a, b) = tokio::join!(
j.refresh_with(&pair.refresh, serde_json::Map::new()),
j.refresh_with(&pair.refresh, serde_json::Map::new()),
);
assert!(
a.unwrap().is_some() ^ b.unwrap().is_some(),
"refresh_with redeemed twice"
);
}
#[tokio::test]
async fn revoked_refresh_token_cannot_be_redeemed() {
let j = jwt();
let pair = j.issue_pair(7);
assert!(j.revoke(&pair.refresh).await);
assert!(j.refresh(&pair.refresh).await.is_none());
assert!(
j.refresh(&pair.access).await.is_none(),
"an access token is not a refresh token"
);
}
#[tokio::test]
async fn revoke_invalidates_access_token() {
let j = jwt();
let pair = j.issue_pair(1);
assert!(j.verify_access(&pair.access).await.is_some());
assert!(j.revoke(&pair.access).await);
assert!(j.verify_access(&pair.access).await.is_none());
}
#[tokio::test]
async fn revoke_invalid_token_returns_false() {
let j = jwt();
assert!(!j.revoke("not-a-valid-token").await);
}
#[tokio::test]
async fn tampered_signature_fails_verification() {
let j = jwt();
let pair = j.issue_pair(1);
let mut bytes = pair.access.into_bytes();
let last = bytes.len() - 1;
bytes[last] ^= 0x01;
let tampered = String::from_utf8(bytes).unwrap();
assert!(j.verify_access(&tampered).await.is_none());
}
#[tokio::test]
async fn wrong_secret_fails_verification() {
let j1 = jwt();
let j2 = JwtLifecycle::new(b"a-different-secret-32-bytes-long!".to_vec());
let pair = j1.issue_pair(5);
assert!(j2.verify_access(&pair.access).await.is_none());
}
#[tokio::test]
async fn unique_jti_per_issuance() {
let j = jwt();
let pair1 = j.issue_pair(1);
let pair2 = j.issue_pair(1);
let c1 = j.verify_access(&pair1.access).await.unwrap();
let c2 = j.verify_access(&pair2.access).await.unwrap();
assert_ne!(c1.jti, c2.jti);
}
#[test]
fn custom_ttls() {
let j = JwtLifecycle::new(b"custom-ttl-secret-32-bytes-long!!".to_vec())
.with_access_ttl(60)
.with_refresh_ttl(3600);
assert_eq!(j.access_ttl_secs, 60);
assert_eq!(j.refresh_ttl_secs, 3600);
}
fn map(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
value.as_object().unwrap().clone()
}
#[tokio::test]
async fn issue_pair_with_embeds_custom_claims() {
let j = jwt();
let pair = j
.issue_pair_with(
42,
map(serde_json::json!({"roles": ["admin", "editor"], "tenant": "acme"})),
)
.unwrap();
let claims = j.verify_access(&pair.access).await.unwrap();
assert_eq!(claims.sub, 42);
let roles: Vec<String> = claims.get_custom("roles").unwrap();
assert_eq!(roles, vec!["admin", "editor"]);
let tenant: String = claims.get_custom("tenant").unwrap();
assert_eq!(tenant, "acme");
}
#[tokio::test]
async fn issue_pair_no_custom_returns_empty_custom_map() {
let j = jwt();
let pair = j.issue_pair(7);
let claims = j.verify_access(&pair.access).await.unwrap();
assert!(claims.custom.is_empty());
let missing: Option<String> = claims.get_custom("anything");
assert!(missing.is_none());
}
#[test]
fn issue_pair_with_rejects_reserved_claims() {
let j = jwt();
for reserved in RESERVED_CLAIM_NAMES {
let custom = map(serde_json::json!({ *reserved: "evil" }));
let r = j.issue_pair_with(1, custom);
assert!(
matches!(r, Err(JwtIssueError::ReservedClaim(_))),
"should reject {reserved}"
);
}
}
#[tokio::test]
async fn refresh_preserves_custom_claims() {
let j = jwt();
let pair = j
.issue_pair_with(
7,
map(serde_json::json!({"scope": "read:posts write:posts"})),
)
.unwrap();
let new_pair = j.refresh(&pair.refresh).await.unwrap();
let new_access_claims = j.verify_access(&new_pair.access).await.unwrap();
let new_refresh_claims = j.verify_refresh(&new_pair.refresh).await.unwrap();
assert_eq!(new_access_claims.sub, 7);
let scope: String = new_access_claims.get_custom("scope").unwrap();
assert_eq!(scope, "read:posts write:posts");
let scope_r: String = new_refresh_claims.get_custom("scope").unwrap();
assert_eq!(scope_r, "read:posts write:posts");
}
#[tokio::test]
async fn refresh_with_substitutes_new_custom_claims() {
let j = jwt();
let pair = j
.issue_pair_with(7, map(serde_json::json!({"roles": ["admin"]})))
.unwrap();
let new_pair = j
.refresh_with(&pair.refresh, map(serde_json::json!({"roles": ["viewer"]})))
.await
.unwrap()
.unwrap();
let claims = j.verify_access(&new_pair.access).await.unwrap();
let roles: Vec<String> = claims.get_custom("roles").unwrap();
assert_eq!(roles, vec!["viewer"]);
}
#[tokio::test]
async fn refresh_with_invalid_token_returns_ok_none() {
let j = jwt();
let r = j
.refresh_with("not-a-token", map(serde_json::json!({})))
.await
.unwrap();
assert!(r.is_none());
}
#[tokio::test]
async fn refresh_with_rejects_reserved_claims() {
let j = jwt();
let pair = j.issue_pair(1);
let r = j
.refresh_with(&pair.refresh, map(serde_json::json!({"sub": 999})))
.await;
assert!(matches!(r, Err(JwtIssueError::ReservedClaim(_))));
}
#[tokio::test]
async fn issue_access_with_returns_single_token() {
let j = jwt();
let token = j
.issue_access_with(42, map(serde_json::json!({"key_id": "abc"})))
.unwrap();
let claims = j.verify_access(&token).await.unwrap();
assert_eq!(claims.sub, 42);
assert_eq!(claims.typ, "access");
let key_id: String = claims.get_custom("key_id").unwrap();
assert_eq!(key_id, "abc");
}
#[tokio::test]
async fn custom_value_returns_raw_json() {
let j = jwt();
let token = j
.issue_access_with(1, map(serde_json::json!({"nested": {"x": 1}})))
.unwrap();
let claims = j.verify_access(&token).await.unwrap();
let raw = claims.custom_value("nested").unwrap();
assert_eq!(raw["x"], 1);
}
#[tokio::test]
async fn refresh_blacklists_old_refresh_even_with_custom_claims() {
let j = jwt();
let pair = j
.issue_pair_with(7, map(serde_json::json!({"role": "admin"})))
.unwrap();
let _new = j.refresh(&pair.refresh).await.unwrap();
assert!(j.refresh(&pair.refresh).await.is_none());
}
#[tokio::test]
async fn shared_jti_store_makes_revoke_visible_across_handles() {
use crate::jti_store::{InMemoryJtiStore, JtiStore};
let secret = b"shared-test-secret-32-bytes-long".to_vec();
let shared: std::sync::Arc<dyn JtiStore> = std::sync::Arc::new(InMemoryJtiStore::new());
let j_a = JwtLifecycle::new(secret.clone()).with_jti_store(std::sync::Arc::clone(&shared));
let j_b = JwtLifecycle::new(secret).with_jti_store(std::sync::Arc::clone(&shared));
let pair = j_a.issue_pair(7);
assert!(j_a.verify_access(&pair.access).await.is_some());
assert!(j_b.verify_access(&pair.access).await.is_some());
assert!(j_a.revoke(&pair.access).await);
assert!(
j_a.verify_access(&pair.access).await.is_none(),
"instance A must reject its own revoked token"
);
assert!(
j_b.verify_access(&pair.access).await.is_none(),
"instance B must see the revocation from A via the shared store"
);
}
#[tokio::test]
async fn blacklist_size_uses_jti_store_approx_size() {
let j = jwt();
assert_eq!(j.blacklist_size().await, 0);
let pair = j.issue_pair(1);
j.revoke(&pair.access).await;
assert_eq!(j.blacklist_size().await, 1);
j.revoke(&pair.refresh).await;
assert_eq!(j.blacklist_size().await, 2);
}
#[test]
#[should_panic(expected = "need >= 32")]
fn a_short_signing_key_is_refused() {
let _ = JwtLifecycle::new(b"too-short".to_vec());
}
#[test]
fn a_thirty_two_byte_key_is_accepted() {
let life = JwtLifecycle::new(vec![7u8; 32]);
assert_eq!(life.access_ttl_secs, DEFAULT_ACCESS_TTL_SECS);
}
}