use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, TimeZone, Utc};
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TokenConfig {
pub clock_skew: Option<u64>,
pub max_age: u64,
}
impl Default for TokenConfig {
fn default() -> Self {
Self {
clock_skew: None,
max_age: 1800,
}
}
}
impl TokenConfig {
#[inline]
pub fn new(clock_skew: u64, max_age: u64) -> Self {
Self {
clock_skew: (clock_skew > 0).then_some(clock_skew),
max_age,
}
}
#[inline]
pub fn with_clock_skew(mut self, clock_skew: u64) -> Self {
self.clock_skew = (clock_skew > 0).then_some(clock_skew);
self
}
#[inline]
pub fn with_max_age(mut self, max_age: u64) -> Self {
self.max_age = max_age;
self
}
}
pub fn generate(secret_key: &[u8; 32], context: Option<&str>) -> String {
generate_at_time(secret_key, context, current_timestamp())
}
#[inline]
pub fn generate_at_time(secret_key: &[u8; 32], context: Option<&str>, timestamp: i64) -> String {
let context_str = context.unwrap_or("");
let payload = format!("{timestamp}:{context_str}");
let mut mac = HmacSha256::new_from_slice(secret_key).expect("HMAC can accept keys of any size");
mac.update(payload.as_bytes());
let signature = mac.finalize().into_bytes();
let token_data = format!("{timestamp}:{}", hex::encode(signature));
URL_SAFE_NO_PAD.encode(token_data.as_bytes())
}
#[inline]
pub fn verify(
secret_key: &[u8; 32],
token: &str,
context: Option<&str>,
max_age_seconds: u64,
) -> Result<(), TokenError> {
verify_with_config(
secret_key,
token,
context,
&TokenConfig::default().with_max_age(max_age_seconds),
)
}
#[inline]
pub fn verify_with_config(
secret_key: &[u8; 32],
token: &str,
context: Option<&str>,
config: &TokenConfig,
) -> Result<(), TokenError> {
verify_at_time(secret_key, token, context, current_timestamp(), config)
}
pub fn verify_at_time(
secret_key: &[u8; 32],
token: &str,
context: Option<&str>,
validation_time: i64,
config: &TokenConfig,
) -> Result<(), TokenError> {
let decoded = URL_SAFE_NO_PAD
.decode(token)
.map_err(|_| TokenError::InvalidFormat)?;
let decoded_str = String::from_utf8(decoded).map_err(|_| TokenError::InvalidFormat)?;
let mut parts = decoded_str.splitn(2, ':');
let timestamp_str = parts.next().ok_or(TokenError::InvalidFormat)?;
let signature_str = parts.next().ok_or(TokenError::InvalidFormat)?;
let timestamp: i64 = timestamp_str
.parse()
.map_err(|_| TokenError::InvalidFormat)?;
if timestamp < 0 {
return Err(TokenError::InvalidTimestamp);
}
let age = validation_time - timestamp;
let tolerance = config.clock_skew.unwrap_or(0) as i64;
let max_age = config.max_age as i64;
let effective_max_age = max_age + tolerance;
let min_age = -tolerance;
if age >= effective_max_age {
return Err(TokenError::Expired);
}
if age < min_age {
return Err(TokenError::InvalidTimestamp);
}
let provided_signature = hex::decode(signature_str).map_err(|_| TokenError::InvalidFormat)?;
let context_str = context.unwrap_or("");
let payload = format!("{timestamp}:{context_str}");
let mut mac = HmacSha256::new_from_slice(secret_key).expect("HMAC can accept keys of any size");
mac.update(payload.as_bytes());
mac.verify_slice(&provided_signature)
.map_err(|_| TokenError::InvalidSignature)
}
pub fn is_expired(token: &str, max_age_seconds: u64) -> Result<bool, TokenError> {
let timestamp = extract_timestamp(token)?;
if timestamp < 0 {
return Err(TokenError::InvalidTimestamp);
}
let now = current_timestamp();
let age = now - timestamp;
if age < 0 {
return Err(TokenError::InvalidTimestamp);
}
Ok(age >= max_age_seconds as i64)
}
#[inline]
pub fn extract_datetime(token: &str) -> Result<DateTime<Utc>, TokenError> {
let timestamp = extract_timestamp(token)?;
Utc.timestamp_opt(timestamp, 0)
.single()
.ok_or(TokenError::InvalidTimestamp)
}
#[inline]
pub fn token_age(token: &str) -> Result<i64, TokenError> {
let timestamp = extract_timestamp(token)?;
Ok(current_timestamp() - timestamp)
}
#[inline]
pub fn extract_timestamp(token: &str) -> Result<i64, TokenError> {
let decoded = URL_SAFE_NO_PAD
.decode(token)
.map_err(|_| TokenError::InvalidFormat)?;
let decoded_str = String::from_utf8(decoded).map_err(|_| TokenError::InvalidFormat)?;
let timestamp_str = decoded_str
.split(':')
.next()
.ok_or(TokenError::InvalidFormat)?;
timestamp_str.parse().map_err(|_| TokenError::InvalidFormat)
}
#[inline]
pub fn generate_secret_key() -> [u8; 32] {
rand::random()
}
#[inline]
pub fn current_timestamp() -> i64 {
Utc::now().timestamp()
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum TokenError {
#[error("token format is invalid")]
InvalidFormat,
#[error("token signature is invalid")]
InvalidSignature,
#[error("token has expired")]
Expired,
#[error("token timestamp is invalid")]
InvalidTimestamp,
}