use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
#[serde(skip_serializing_if = "Option::is_none")]
pub iss: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sub: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub aud: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nbf: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub iat: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jti: Option<String>,
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before UNIX epoch")
.as_secs()
}
impl Claims {
pub fn new() -> Self {
Self {
iss: None,
sub: None,
aud: None,
exp: None,
nbf: None,
iat: None,
jti: None,
}
}
pub fn with_iss(mut self, iss: impl Into<String>) -> Self {
self.iss = Some(iss.into());
self
}
pub fn with_sub(mut self, sub: impl Into<String>) -> Self {
self.sub = Some(sub.into());
self
}
pub fn with_aud(mut self, aud: impl Into<String>) -> Self {
self.aud = Some(aud.into());
self
}
pub fn with_exp(mut self, exp: u64) -> Self {
self.exp = Some(exp);
self
}
pub fn with_exp_in(mut self, duration: Duration) -> Self {
self.exp = Some(now_secs() + duration.as_secs());
self
}
pub fn with_nbf(mut self, nbf: u64) -> Self {
self.nbf = Some(nbf);
self
}
pub fn with_iat_now(mut self) -> Self {
self.iat = Some(now_secs());
self
}
pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
self.jti = Some(jti.into());
self
}
pub fn is_expired(&self) -> bool {
match self.exp {
Some(exp) => now_secs() > exp,
None => false,
}
}
pub fn is_not_yet_valid(&self) -> bool {
match self.nbf {
Some(nbf) => now_secs() < nbf,
None => false,
}
}
pub fn subject(&self) -> Option<&str> {
self.sub.as_deref()
}
pub fn token_id(&self) -> Option<&str> {
self.jti.as_deref()
}
pub fn issuer(&self) -> Option<&str> {
self.iss.as_deref()
}
pub fn audience(&self) -> Option<&str> {
self.aud.as_deref()
}
}
impl Default for Claims {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_sets_all_fields_none() {
let claims = Claims::new();
assert!(claims.iss.is_none());
assert!(claims.sub.is_none());
assert!(claims.aud.is_none());
assert!(claims.exp.is_none());
assert!(claims.nbf.is_none());
assert!(claims.iat.is_none());
assert!(claims.jti.is_none());
}
#[test]
fn builders_set_fields() {
let claims = Claims::new()
.with_sub("user_1")
.with_iss("my-app")
.with_aud("api")
.with_exp(9999999999)
.with_nbf(1000000000)
.with_jti("token-id");
assert_eq!(claims.subject(), Some("user_1"));
assert_eq!(claims.issuer(), Some("my-app"));
assert_eq!(claims.audience(), Some("api"));
assert_eq!(claims.exp, Some(9999999999));
assert_eq!(claims.nbf, Some(1000000000));
assert_eq!(claims.token_id(), Some("token-id"));
}
#[test]
fn with_exp_in_sets_future_timestamp() {
let claims = Claims::new().with_exp_in(Duration::from_secs(3600));
let exp = claims.exp.unwrap();
let now = now_secs();
assert!(exp >= now + 3599 && exp <= now + 3601);
}
#[test]
fn with_iat_now_sets_current_timestamp() {
let claims = Claims::new().with_iat_now();
let iat = claims.iat.unwrap();
let now = now_secs();
assert!(iat >= now - 1 && iat <= now + 1);
}
#[test]
fn is_expired_returns_false_for_future_exp() {
let claims = Claims::new().with_exp(now_secs() + 3600);
assert!(!claims.is_expired());
}
#[test]
fn is_expired_returns_true_for_past_exp() {
let claims = Claims::new().with_exp(now_secs() - 1);
assert!(claims.is_expired());
}
#[test]
fn is_expired_returns_false_when_no_exp() {
let claims = Claims::new();
assert!(!claims.is_expired());
}
#[test]
fn is_not_yet_valid_returns_true_for_future_nbf() {
let claims = Claims::new().with_nbf(now_secs() + 3600);
assert!(claims.is_not_yet_valid());
}
#[test]
fn is_not_yet_valid_returns_false_for_past_nbf() {
let claims = Claims::new().with_nbf(now_secs() - 1);
assert!(!claims.is_not_yet_valid());
}
#[test]
fn serialization_skips_none_fields() {
let claims = Claims::new().with_sub("user_1");
let json = serde_json::to_value(&claims).unwrap();
assert!(json.get("sub").is_some());
assert!(json.get("iss").is_none());
assert!(json.get("exp").is_none());
}
#[test]
fn deserialization_roundtrip() {
let original = Claims::new()
.with_sub("user_1")
.with_exp(9999999999)
.with_iss("my-app");
let json = serde_json::to_string(&original).unwrap();
let decoded: Claims = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.sub, original.sub);
assert_eq!(decoded.exp, original.exp);
assert_eq!(decoded.iss, original.iss);
}
#[test]
fn default_matches_new() {
let a = Claims::new();
let b = Claims::default();
assert_eq!(
serde_json::to_string(&a).unwrap(),
serde_json::to_string(&b).unwrap()
);
}
}