Files
zesdex/apps/infrastructure/src/auth/jwt.rs
T

120 lines
3.9 KiB
Rust
Raw Normal View History

//! JWT token utilities for HMAC-SHA256 / HS256 signing and verification.
use serde::{Deserialize, Serialize};
/// Standard JWT claims with optional session binding.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JwtClaims {
pub sub: String,
pub exp: u64,
pub iat: u64,
/// Token purpose: `"access"` or `"refresh"`.
///
/// Prevents an access token from being replayed as a refresh token
/// (which would otherwise extend a short-lived credential into the
/// 7-day refresh window).
#[serde(rename = "typ")]
pub token_type: TokenType,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
/// JWT token purpose.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TokenType {
Access,
Refresh,
}
impl JwtClaims {
pub fn new(sub: String, exp: u64, token_type: TokenType, session_id: Option<String>) -> Self {
let iat = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Self {
sub,
exp,
iat,
token_type,
session_id,
}
}
}
/// Sign a set of claims into a JWT string using HS256.
pub fn create_token(secret: &str, claims: JwtClaims) -> anyhow::Result<String> {
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes());
let token = jsonwebtoken::encode(&header, &claims, &key)?;
Ok(token)
}
/// Verify a JWT string and return its claims.
pub fn verify_token(secret: &str, token: &str) -> anyhow::Result<JwtClaims> {
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
validation.validate_exp = true;
validation.required_spec_claims = ["sub", "exp", "iat"]
.iter()
.map(|&s| s.to_string())
.collect();
let key = jsonwebtoken::DecodingKey::from_secret(secret.as_bytes());
let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)?;
Ok(token_data.claims)
}
#[cfg(test)]
mod tests {
use super::*;
fn claims(exp_secs_from_now: u64, token_type: TokenType) -> JwtClaims {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
JwtClaims::new(
"user-1".to_string(),
now + exp_secs_from_now,
token_type,
None,
)
}
#[test]
fn access_and_refresh_tokens_roundtrip() {
let secret = "test-secret";
let access = create_token(secret, claims(3600, TokenType::Access)).unwrap();
let refresh = create_token(secret, claims(604800, TokenType::Refresh)).unwrap();
let acc = verify_token(secret, &access).unwrap();
assert_eq!(acc.token_type, TokenType::Access);
let refr = verify_token(secret, &refresh).unwrap();
assert_eq!(refr.token_type, TokenType::Refresh);
}
#[test]
fn token_type_is_distinct() {
let secret = "test-secret";
let access = create_token(secret, claims(3600, TokenType::Access)).unwrap();
let claims = verify_token(secret, &access).unwrap();
assert_ne!(claims.token_type, TokenType::Refresh);
}
#[test]
fn expired_token_is_rejected() {
let secret = "test-secret";
// exp well in the past (beyond the library's default 60s leeway) →
// verification must fail.
let past = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
.saturating_sub(120);
let expired = JwtClaims::new("user-1".to_string(), past, TokenType::Access, None);
let token = create_token(secret, expired).unwrap();
assert!(verify_token(secret, &token).is_err());
}
}