pub const REVOCATION_STORE_UNAVAILABLE_REASON: &str = "revocation store unavailable (fail-closed)";
pub const REVOCATION_DENYLIST_HIT_REASON: &str = "token revoked (cluster denylist)";
pub const REVOKED_JTI_KEY_PREFIX: &str = "udb:revoked_jti";
pub const REVOKED_TENANT_KEY_PREFIX: &str = "udb:denylist:tenant";
pub const REVOCATION_TENANT_DENYLIST_HIT_REASON: &str = "token revoked (tenant cluster denylist)";
pub const REVOKED_PRINCIPAL_KEY_PREFIX: &str = "udb:denylist:principal";
pub const REVOCATION_PRINCIPAL_DENYLIST_HIT_REASON: &str =
"token revoked (principal cluster denylist)";
pub fn revocation_lookup_error_outcome(fail_closed: bool) -> (bool, String) {
if fail_closed {
(true, REVOCATION_STORE_UNAVAILABLE_REASON.to_string())
} else {
(false, String::new())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DenylistDecision {
Revoked,
NotFound,
Error,
}
pub fn denylist_check_outcome(
decision: DenylistDecision,
fail_closed: bool,
) -> Option<(bool, String)> {
match decision {
DenylistDecision::Revoked => Some((true, REVOCATION_DENYLIST_HIT_REASON.to_string())),
DenylistDecision::NotFound => None,
DenylistDecision::Error => {
if fail_closed {
Some((true, REVOCATION_STORE_UNAVAILABLE_REASON.to_string()))
} else {
None
}
}
}
}
pub fn revoked_jti_key(jti_hash: &str) -> String {
format!("{REVOKED_JTI_KEY_PREFIX}:{jti_hash}")
}
pub fn revoked_tenant_key(tenant_id: &str) -> String {
format!("{REVOKED_TENANT_KEY_PREFIX}:{tenant_id}")
}
pub fn revoked_principal_key(principal_id: &str) -> String {
format!("{REVOKED_PRINCIPAL_KEY_PREFIX}:{principal_id}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TenantDenylistDecision {
Cutoff(u64),
NotFound,
Error,
}
pub fn tenant_denylist_check_outcome(
decision: TenantDenylistDecision,
iat: u64,
fail_closed: bool,
) -> Option<(bool, String)> {
match decision {
TenantDenylistDecision::Cutoff(cutoff) => {
if iat <= cutoff {
Some((true, REVOCATION_TENANT_DENYLIST_HIT_REASON.to_string()))
} else {
None
}
}
TenantDenylistDecision::NotFound => None,
TenantDenylistDecision::Error => {
if fail_closed {
Some((true, REVOCATION_STORE_UNAVAILABLE_REASON.to_string()))
} else {
None
}
}
}
}
#[cfg(feature = "redis")]
use redis::AsyncCommands;
#[cfg(feature = "redis")]
#[derive(Clone)]
pub struct JtiDenylist {
client: redis::Client,
default_ttl_secs: u64,
}
#[cfg(feature = "redis")]
impl JtiDenylist {
pub fn new(client: redis::Client, default_ttl_secs: u64) -> Self {
Self {
client,
default_ttl_secs: default_ttl_secs.max(1),
}
}
async fn connection(&self) -> Result<redis::aio::MultiplexedConnection, String> {
self.client
.get_multiplexed_async_connection()
.await
.map_err(|err| format!("jti denylist connection failed: {err}"))
}
pub async fn add(&self, jti_hash: &str, ttl_secs: u64) -> Result<(), String> {
let ttl = if ttl_secs == 0 {
self.default_ttl_secs
} else {
ttl_secs
}
.max(1);
let mut conn = self.connection().await?;
let key = revoked_jti_key(jti_hash);
let _: () = conn
.set_ex(&key, "1", ttl)
.await
.map_err(|err| format!("jti denylist SETEX failed: {err}"))?;
Ok(())
}
pub async fn check(&self, jti_hash: &str) -> DenylistDecision {
let mut conn = match self.connection().await {
Ok(conn) => conn,
Err(_) => return DenylistDecision::Error,
};
let key = revoked_jti_key(jti_hash);
let exists: Result<bool, _> = conn.exists(&key).await;
match exists {
Ok(true) => DenylistDecision::Revoked,
Ok(false) => DenylistDecision::NotFound,
Err(_) => DenylistDecision::Error,
}
}
pub async fn deny_tenant_after(&self, tenant_id: &str, cutoff_unix: u64) -> Result<(), String> {
let mut conn = self.connection().await?;
let key = revoked_tenant_key(tenant_id);
let _: () = conn
.set_ex(&key, cutoff_unix, self.default_ttl_secs)
.await
.map_err(|err| format!("tenant denylist SETEX failed: {err}"))?;
Ok(())
}
pub async fn tenant_denied_after(&self, tenant_id: &str) -> TenantDenylistDecision {
let mut conn = match self.connection().await {
Ok(conn) => conn,
Err(_) => return TenantDenylistDecision::Error,
};
let key = revoked_tenant_key(tenant_id);
let cutoff: Result<Option<u64>, _> = conn.get(&key).await;
match cutoff {
Ok(Some(cutoff)) => TenantDenylistDecision::Cutoff(cutoff),
Ok(None) => TenantDenylistDecision::NotFound,
Err(_) => TenantDenylistDecision::Error,
}
}
pub async fn deny_principal_after(
&self,
principal_id: &str,
cutoff_unix: u64,
) -> Result<(), String> {
let mut conn = self.connection().await?;
let key = revoked_principal_key(principal_id);
let _: () = conn
.set_ex(&key, cutoff_unix, self.default_ttl_secs)
.await
.map_err(|err| format!("principal denylist SETEX failed: {err}"))?;
Ok(())
}
pub async fn principal_denied_after(&self, principal_id: &str) -> TenantDenylistDecision {
let mut conn = match self.connection().await {
Ok(conn) => conn,
Err(_) => return TenantDenylistDecision::Error,
};
let key = revoked_principal_key(principal_id);
let cutoff: Result<Option<u64>, _> = conn.get(&key).await;
match cutoff {
Ok(Some(cutoff)) => TenantDenylistDecision::Cutoff(cutoff),
Ok(None) => TenantDenylistDecision::NotFound,
Err(_) => TenantDenylistDecision::Error,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lookup_error_fails_closed_when_hardened() {
let (revoked, reason) = revocation_lookup_error_outcome(true);
assert!(revoked, "fail-closed must treat a store error as revoked");
assert_eq!(reason, REVOCATION_STORE_UNAVAILABLE_REASON);
}
#[test]
fn lookup_error_fails_open_when_not_hardened() {
let (revoked, reason) = revocation_lookup_error_outcome(false);
assert!(!revoked, "dev fail-open must not report revoked");
assert!(reason.is_empty());
}
#[test]
fn denylist_hit_is_revoked_without_db_read() {
for fail_closed in [false, true] {
let out = denylist_check_outcome(DenylistDecision::Revoked, fail_closed);
let (revoked, reason) = out.expect("a hit must decide the outcome");
assert!(revoked, "denylist hit must be treated as revoked");
assert_eq!(reason, REVOCATION_DENYLIST_HIT_REASON);
}
}
#[test]
fn denylist_miss_falls_through_to_db() {
assert!(denylist_check_outcome(DenylistDecision::NotFound, false).is_none());
assert!(denylist_check_outcome(DenylistDecision::NotFound, true).is_none());
}
#[test]
fn denylist_error_fails_closed_when_hardened_else_falls_through() {
let hardened = denylist_check_outcome(DenylistDecision::Error, true)
.expect("hardened denylist error must fail closed");
assert!(hardened.0, "hardened denylist error must be revoked");
assert_eq!(hardened.1, REVOCATION_STORE_UNAVAILABLE_REASON);
assert!(
denylist_check_outcome(DenylistDecision::Error, false).is_none(),
"dev denylist error must fall through to the DB read"
);
}
#[test]
fn revoked_jti_key_is_namespaced_and_carries_only_the_hash() {
let key = revoked_jti_key("abc123hash");
assert_eq!(key, "udb:revoked_jti:abc123hash");
assert!(key.starts_with(REVOKED_JTI_KEY_PREFIX));
}
#[test]
fn revoked_tenant_key_is_namespaced() {
let key = revoked_tenant_key("tenant-uuid");
assert_eq!(key, "udb:denylist:tenant:tenant-uuid");
assert!(key.starts_with(REVOKED_TENANT_KEY_PREFIX));
}
#[test]
fn revoked_principal_key_is_namespaced() {
let key = revoked_principal_key("user-uuid");
assert_eq!(key, "udb:denylist:principal:user-uuid");
assert!(key.starts_with(REVOKED_PRINCIPAL_KEY_PREFIX));
}
#[test]
fn principal_cutoff_reuses_tenant_decision_semantics() {
let killed =
tenant_denylist_check_outcome(TenantDenylistDecision::Cutoff(1000), 1000, false)
.expect("iat == cutoff is revoked");
assert!(killed.0);
assert!(
tenant_denylist_check_outcome(TenantDenylistDecision::Cutoff(1000), 1001, false)
.is_none(),
"a token issued after the principal kill survives → DB read"
);
}
#[test]
fn tenant_cutoff_revokes_tokens_issued_at_or_before_it() {
for iat in [0u64, 500, 1000] {
let out =
tenant_denylist_check_outcome(TenantDenylistDecision::Cutoff(1000), iat, false);
let (revoked, reason) = out.expect("a token issued at/before the cutoff is revoked");
assert!(revoked, "iat {iat} <= cutoff must be revoked");
assert_eq!(reason, REVOCATION_TENANT_DENYLIST_HIT_REASON);
}
}
#[test]
fn tenant_cutoff_spares_tokens_issued_after_it() {
assert!(
tenant_denylist_check_outcome(TenantDenylistDecision::Cutoff(1000), 1001, false)
.is_none(),
"iat strictly after the cutoff must fall through"
);
assert!(
tenant_denylist_check_outcome(TenantDenylistDecision::Cutoff(1000), 1001, true)
.is_none()
);
}
#[test]
fn tenant_denylist_miss_falls_through_to_db() {
assert!(
tenant_denylist_check_outcome(TenantDenylistDecision::NotFound, 1000, false).is_none()
);
assert!(
tenant_denylist_check_outcome(TenantDenylistDecision::NotFound, 1000, true).is_none()
);
}
#[test]
fn tenant_denylist_redis_outage_defers_to_db_in_fail_open() {
assert!(
tenant_denylist_check_outcome(TenantDenylistDecision::Error, 1000, false).is_none(),
"a Redis outage must defer to PG, never deny spuriously"
);
let hardened = tenant_denylist_check_outcome(TenantDenylistDecision::Error, 1000, true)
.expect("hardened tenant denylist error must fail closed");
assert!(hardened.0);
assert_eq!(hardened.1, REVOCATION_STORE_UNAVAILABLE_REASON);
}
}