use std::time::Duration;
use sha2::{Digest, Sha256};
use crate::access_token::replay_defense::ReplayDefenseError;
use crate::access_token::{AuthError, Claims, VerifyConfig};
pub(crate) async fn run(
token: &str,
claims: &Claims,
cfg: &VerifyConfig,
now: i64,
) -> Result<(), AuthError> {
let Some(port) = cfg.replay.as_ref() else {
return Ok(());
};
let ttl_secs = claims.exp.saturating_sub(now);
if ttl_secs <= 0 {
return Ok(());
}
let ttl = Duration::from_secs(ttl_secs as u64);
let hash = jti_hash(token);
match port.check_and_record(&hash, ttl).await {
Ok(()) => {
tracing::trace!(
target: "ppoppo_token::revocation",
port = "replay",
outcome = "admit",
sub = %claims.sub,
"revocation.checked",
);
Ok(())
}
Err(ReplayDefenseError::Replayed) => {
tracing::warn!(
target: "ppoppo_token::revocation",
port = "replay",
outcome = "reject",
reason = "replayed",
sub = %claims.sub,
"revocation.checked",
);
Err(AuthError::JtiReplayed)
}
Err(ReplayDefenseError::Transient(detail)) => {
tracing::warn!(
target: "ppoppo_token::revocation",
port = "replay",
outcome = "transient",
sub = %claims.sub,
detail = %detail,
"revocation.checked",
);
Err(AuthError::ReplayCacheUnavailable)
}
}
}
fn jti_hash(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let digest = hasher.finalize();
hex::encode(&digest[..16])
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn jti_hash_is_deterministic_and_short() {
let h1 = jti_hash("eyJ.payload.sig");
let h2 = jti_hash("eyJ.payload.sig");
assert_eq!(h1, h2, "same input must produce same hash");
assert_eq!(h1.len(), 32, "16 bytes = 32 hex chars (STANDARDS §E)");
}
#[test]
fn jti_hash_differs_for_different_inputs() {
let h1 = jti_hash("eyJ.payload.sig1");
let h2 = jti_hash("eyJ.payload.sig2");
assert_ne!(h1, h2, "different inputs must produce different hashes");
}
}