use crate::jwt::HasJti;
use crate::middleware::{Role, RoleCheck, ScopeCheck};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn new_jti() -> String {
static CTR: AtomicU64 = AtomicU64::new(0);
let n = CTR.fetch_add(1, Ordering::Relaxed);
format!("{:x}-{:x}", now_secs(), n)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StandardClaims<R = ()> {
pub sub: String,
pub jti: String,
pub exp: u64,
pub iat: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<R>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
}
impl<R> StandardClaims<R> {
pub fn new(
sub: impl Into<String>,
ttl_secs: u64,
role: Option<R>,
scope: Option<String>,
) -> Self {
let iat = now_secs();
Self {
sub: sub.into(),
jti: new_jti(),
exp: iat + ttl_secs,
iat,
role,
scope,
}
}
}
impl<R> HasJti for StandardClaims<R> {
#[inline]
fn jti(&self) -> Option<&str> {
Some(&self.jti)
}
}
impl<R: Role> RoleCheck<R> for StandardClaims<R> {
#[inline]
fn has_role(&self, role: &R) -> bool {
self.role.as_ref().is_some_and(|r| r == role)
}
}
impl<R> ScopeCheck for StandardClaims<R> {
#[inline]
fn has_scope(&self, scope: &str) -> bool {
self.scope
.as_deref()
.is_some_and(|s| s.split(' ').any(|t| t == scope))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
enum TestRole {
Admin,
User,
}
impl Role for TestRole {}
type Claims = StandardClaims<TestRole>;
#[test]
fn test_new_sets_sub_and_expiry() {
let claims = Claims::new("alice", 3600, None, None);
assert_eq!(claims.sub, "alice");
assert!(claims.exp > claims.iat);
assert_eq!(claims.exp - claims.iat, 3600);
}
#[test]
fn test_iat_is_recent() {
let before = now_secs();
let claims = Claims::new("u1", 60, None, None);
let after = now_secs();
assert!(claims.iat >= before && claims.iat <= after);
}
#[test]
fn test_jti_is_unique() {
let a = Claims::new("u1", 60, None, None);
let b = Claims::new("u2", 60, None, None);
assert_ne!(a.jti, b.jti);
}
#[test]
fn test_jti_is_non_empty() {
let claims = Claims::new("u1", 60, None, None);
assert!(claims.jti().is_some());
assert!(!claims.jti().unwrap().is_empty());
}
#[test]
fn test_role_check_matching() {
let claims = Claims::new("u1", 60, Some(TestRole::Admin), None);
assert!(claims.has_role(&TestRole::Admin));
assert!(!claims.has_role(&TestRole::User));
}
#[test]
fn test_role_check_user_role() {
let claims = Claims::new("u1", 60, Some(TestRole::User), None);
assert!(claims.has_role(&TestRole::User));
assert!(!claims.has_role(&TestRole::Admin));
}
#[test]
fn test_role_check_no_role() {
let claims = Claims::new("u1", 60, None, None);
assert!(!claims.has_role(&TestRole::Admin));
assert!(!claims.has_role(&TestRole::User));
}
#[test]
fn test_scope_check_single() {
let claims = Claims::new("u1", 60, None, Some("read:users".into()));
assert!(claims.has_scope("read:users"));
assert!(!claims.has_scope("write:users"));
}
#[test]
fn test_scope_check_multiple() {
let claims = Claims::new("u1", 60, None, Some("read:users write:posts admin".into()));
assert!(claims.has_scope("read:users"));
assert!(claims.has_scope("write:posts"));
assert!(claims.has_scope("admin"));
assert!(!claims.has_scope("delete:users"));
}
#[test]
fn test_scope_check_no_scope() {
let claims = Claims::new("u1", 60, None, None);
assert!(!claims.has_scope("read:users"));
}
#[test]
fn test_scope_no_partial_match() {
let claims = Claims::new("u1", 60, None, Some("read:users".into()));
assert!(!claims.has_scope("read"));
}
#[test]
fn test_scope_no_prefix_match() {
let claims = Claims::new("u1", 60, None, Some("read:users".into()));
assert!(!claims.has_scope("read:users:extra"));
}
#[test]
fn test_no_role_serialises_as_absent() {
let claims = Claims::new("u1", 60, None, None);
let json = serde_json::to_string(&claims).unwrap();
assert!(!json.contains("role"));
assert!(!json.contains("scope"));
}
#[test]
fn test_role_serialises() {
let claims: StandardClaims<()> = StandardClaims::new("bob", 60, None, None);
let json = serde_json::to_string(&claims).unwrap();
let back: StandardClaims<()> = serde_json::from_str(&json).unwrap();
assert_eq!(back.sub, "bob");
assert_eq!(back.jti, claims.jti);
}
}