#![allow(dead_code)]
use std::time::{SystemTime, UNIX_EPOCH};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, FixedOffset, Utc};
use serde::Deserialize;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JwtPayload {
pub exp: Option<DateTime<FixedOffset>>,
pub iat: Option<DateTime<FixedOffset>>,
pub scope: Option<String>,
pub sub: Option<String>
}
#[derive(Debug, Deserialize)]
struct RawClaims {
exp: Option<f64>,
iat: Option<f64>,
scope: Option<String>,
sub: Option<String>
}
impl JwtPayload {
pub fn parse(token: &str) -> Self {
let segments: Vec<&str> = token.splitn(3, '.').collect();
if segments.len() < 2 {
return empty();
}
let payload_b64 = segments[1];
let Ok(decoded) = URL_SAFE_NO_PAD.decode(payload_b64) else {
return empty();
};
let Ok(text) = String::from_utf8(decoded) else {
return empty();
};
let Ok(raw) = serde_json::from_str::<RawClaims>(&text) else {
return empty();
};
Self {
exp: raw.exp.and_then(timestamp_to_datetime),
iat: raw.iat.and_then(timestamp_to_datetime),
scope: raw.scope,
sub: raw.sub
}
}
#[must_use]
pub fn days_remaining(&self) -> Option<i64> {
let exp = self.exp?;
let now = now_timestamp();
let diff_secs = exp.timestamp() - now;
Some(diff_secs / 86400)
}
#[must_use]
pub fn is_expiring_soon(&self) -> bool {
let Some(exp) = self.exp else {
return false;
};
let now = now_timestamp();
let window_secs = 30 * 86400;
exp.timestamp() > now && exp.timestamp() - now < window_secs
}
#[must_use]
pub fn is_expired(&self) -> bool {
let Some(exp) = self.exp else {
return false;
};
let now = now_timestamp();
exp.timestamp() < now
}
}
const fn empty() -> JwtPayload {
JwtPayload {
exp: None,
iat: None,
scope: None,
sub: None
}
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
fn timestamp_to_datetime(ts: f64) -> Option<DateTime<FixedOffset>> {
let secs = ts as i64;
let nanos = ((ts - secs as f64) * 1_000_000_000.0) as u32;
let dt = DateTime::<Utc>::from_timestamp(secs, nanos)?;
Some(dt.with_timezone(&FixedOffset::east_opt(0)?))
}
fn now_timestamp() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs().try_into().unwrap_or(0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_valid_token_with_claims() {
use base64::Engine;
let claims = serde_json::json!({
"sub": "user-42",
"scope": "read write",
"iat": 1_700_000_000.0,
"exp": 1_700_086_400.0
});
let payload_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let token = format!("header.{payload_b64}.signature");
let payload = JwtPayload::parse(&token);
assert_eq!(payload.sub, Some("user-42".to_string()));
assert_eq!(payload.scope, Some("read write".to_string()));
assert!(payload.exp.is_some());
assert!(payload.iat.is_some());
}
#[test]
fn parse_token_missing_claims() {
let claims = serde_json::json!({});
let payload_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let token = format!("header.{payload_b64}.sig");
let payload = JwtPayload::parse(&token);
assert_eq!(payload.sub, None);
assert_eq!(payload.scope, None);
assert!(payload.exp.is_none());
assert!(payload.iat.is_none());
}
#[test]
fn parse_invalid_base64_payload() {
let token = "header.!!!invalid!!!.signature";
let payload = JwtPayload::parse(token);
assert!(payload.sub.is_none());
assert!(payload.exp.is_none());
}
#[test]
fn parse_too_few_segments() {
let payload = JwtPayload::parse("headerpayload");
assert!(payload.sub.is_none());
}
#[test]
fn parse_invalid_utf8_payload() {
let invalid_utf8 = vec![0xff, 0xfe, 0x00, 0x01];
let payload_b64 = URL_SAFE_NO_PAD.encode(&invalid_utf8);
let token = format!("header.{payload_b64}.sig");
let result = JwtPayload::parse(&token);
assert!(result.sub.is_none());
}
#[test]
fn is_expired_false_when_no_exp() {
let claims = serde_json::json!({"sub": "test"});
let payload_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let token = format!("header.{payload_b64}.sig");
let payload = JwtPayload::parse(&token);
assert!(!payload.is_expired());
}
#[test]
fn is_expiring_soon_false_when_no_exp() {
let claims = serde_json::json!({"sub": "test"});
let payload_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let token = format!("header.{payload_b64}.sig");
let payload = JwtPayload::parse(&token);
assert!(!payload.is_expiring_soon());
}
#[test]
fn days_remaining_none_when_no_exp() {
let claims = serde_json::json!({"sub": "test"});
let payload_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let token = format!("header.{payload_b64}.sig");
let payload = JwtPayload::parse(&token);
assert!(payload.days_remaining().is_none());
}
#[test]
fn is_expired_true_for_past_exp() {
let claims = serde_json::json!({
"exp": 1_000_000.0
});
let payload_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let token = format!("header.{payload_b64}.sig");
let payload = JwtPayload::parse(&token);
assert!(payload.is_expired());
}
#[test]
#[expect(clippy::cast_precision_loss, clippy::suboptimal_flops)]
fn is_expired_false_for_future_exp() {
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let far_future = now_secs as f64 + 86400.0 * 365.0;
let claims = serde_json::json!({"exp": far_future});
let payload_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let token = format!("header.{payload_b64}.sig");
let payload = JwtPayload::parse(&token);
assert!(!payload.is_expired());
}
#[test]
fn parse_valid_utf8_but_invalid_json() {
let payload_b64 = URL_SAFE_NO_PAD.encode("not json at all");
let token = format!("header.{payload_b64}.sig");
let payload = JwtPayload::parse(&token);
assert!(payload.sub.is_none());
assert!(payload.exp.is_none());
}
#[test]
#[expect(clippy::cast_precision_loss, clippy::suboptimal_flops)]
fn days_remaining_some_for_future_exp() {
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let far_future = now_secs as f64 + 86400.0 * 100.0;
let claims = serde_json::json!({"exp": far_future});
let payload_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let token = format!("header.{payload_b64}.sig");
let payload = JwtPayload::parse(&token);
let days = payload.days_remaining().expect("exp is set");
assert!((95..=100).contains(&days), "unexpected days: {days}");
}
#[test]
#[expect(clippy::cast_precision_loss, clippy::suboptimal_flops)]
fn is_expiring_soon_true_within_window() {
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let soon = now_secs as f64 + 86400.0 * 10.0;
let claims = serde_json::json!({"exp": soon});
let payload_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let token = format!("header.{payload_b64}.sig");
let payload = JwtPayload::parse(&token);
assert!(payload.is_expiring_soon());
}
#[test]
#[expect(clippy::cast_precision_loss, clippy::suboptimal_flops)]
fn is_expiring_soon_false_beyond_window() {
let now_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
let far = now_secs as f64 + 86400.0 * 200.0;
let claims = serde_json::json!({"exp": far});
let payload_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
let token = format!("header.{payload_b64}.sig");
let payload = JwtPayload::parse(&token);
assert!(!payload.is_expiring_soon());
}
}