use jiff::Timestamp;
pub const EXPIRY_SKEW_SECS: i64 = 60;
pub fn is_expired(token: &str, now: Timestamp) -> bool {
parse_expiry(token)
.map(|exp| now + jiff::SignedDuration::from_secs(EXPIRY_SKEW_SECS) >= exp)
.unwrap_or(true)
}
pub fn parse_expiry(token: &str) -> Option<Timestamp> {
let mut segments = token.split('.');
let _header = segments.next()?;
let payload = segments.next()?;
let _signature = segments.next()?;
if segments.next().is_some() {
return None;
}
use base64::Engine as _;
let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload)
.ok()?;
let payload_json: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
let exp_secs = payload_json.get("exp")?.as_i64()?;
Timestamp::from_second(exp_secs).ok()
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
pub(crate) fn make_fake_token(exp: Timestamp) -> String {
use base64::Engine as _;
let payload_json = format!(r#"{{"sub":"test","exp":{}}}"#, exp.as_second());
let header_b64 =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA"}"#);
let payload_b64 =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
let signature_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0u8; 64]);
format!("{header_b64}.{payload_b64}.{signature_b64}")
}
#[test]
fn is_expired_returns_true_for_non_jwt() {
let now = Timestamp::now();
assert!(is_expired("not-a-jwt", now));
assert!(is_expired("only.two", now));
assert!(is_expired("a.b.c.d", now));
assert!(is_expired("", now));
}
#[test]
fn is_expired_returns_true_for_legacy_paseto_format() {
assert!(is_expired("v4.public.abc", Timestamp::now()));
}
#[test]
fn is_expired_returns_false_for_future_exp() {
let now = Timestamp::now();
let token = make_fake_token(now + jiff::SignedDuration::from_hours(1));
assert!(!is_expired(&token, now));
}
#[test]
fn is_expired_returns_true_for_past_exp() {
let now = Timestamp::now();
let token = make_fake_token(now - jiff::SignedDuration::from_hours(1));
assert!(is_expired(&token, now));
}
#[test]
fn is_expired_accounts_for_60s_skew() {
let now = Timestamp::now();
let token = make_fake_token(now + jiff::SignedDuration::from_secs(30));
assert!(is_expired(&token, now));
let token = make_fake_token(now + jiff::SignedDuration::from_secs(120));
assert!(!is_expired(&token, now));
}
#[test]
fn parse_expiry_rejects_string_exp() {
use base64::Engine as _;
let payload_json = r#"{"sub":"test","exp":"2027-01-01T00:00:00Z"}"#;
let payload_b64 =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json.as_bytes());
let token = format!("aGVhZGVy.{payload_b64}.c2ln");
assert_eq!(parse_expiry(&token), None);
}
#[test]
fn parse_expiry_returns_none_when_exp_missing() {
use base64::Engine as _;
let payload_b64 =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"sub":"x"}"#);
let token = format!("aGVhZGVy.{payload_b64}.c2ln");
assert!(parse_expiry(&token).is_none());
}
}