use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::SyncError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Claims {
pub sub: String,
pub tenant_id: String,
pub client_id: Uuid,
pub exp: u64,
pub iat: u64,
pub nbf: u64,
pub jti: String,
pub iss: String,
pub aud: String,
pub scopes: Vec<String>,
}
impl Claims {
pub fn new(
user_id: String,
tenant_id: String,
client_id: Uuid,
expires_in: Duration,
) -> Self {
let now = Utc::now();
let exp = (now + expires_in).timestamp() as u64;
let iat = now.timestamp() as u64;
let nbf = iat;
Self {
sub: user_id,
tenant_id,
client_id,
exp,
iat,
nbf,
jti: Uuid::new_v4().to_string(),
iss: "heliosdb-sync".to_string(),
aud: "heliosdb-client".to_string(),
scopes: vec!["sync:read".to_string(), "sync:write".to_string()],
}
}
pub fn is_expired(&self) -> bool {
let now = Utc::now().timestamp() as u64;
self.exp < now
}
pub fn is_active(&self) -> bool {
let now = Utc::now().timestamp() as u64;
self.nbf <= now
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|s| s == scope)
}
pub fn validate_time(&self) -> Result<(), SyncError> {
if !self.is_active() {
return Err(SyncError::Authentication);
}
if self.is_expired() {
return Err(SyncError::Authentication);
}
Ok(())
}
}
pub struct JwtManager {
encoding_key: EncodingKey,
decoding_key: DecodingKey,
validation: Validation,
default_expiry: Duration,
refresh_expiry: Duration,
}
impl JwtManager {
pub fn new(secret: &[u8]) -> Self {
let mut validation = Validation::new(Algorithm::HS256);
validation.set_issuer(&["heliosdb-sync"]);
validation.set_audience(&["heliosdb-client"]);
validation.validate_exp = true;
validation.validate_nbf = true;
Self {
encoding_key: EncodingKey::from_secret(secret),
decoding_key: DecodingKey::from_secret(secret),
validation,
default_expiry: Duration::hours(1),
refresh_expiry: Duration::days(7),
}
}
pub fn from_env_or_default() -> Self {
let secret = std::env::var("HELIOSDB_JWT_SECRET")
.unwrap_or_else(|_| "default-secret-change-in-production".to_string());
Self::new(secret.as_bytes())
}
pub fn with_expiry(mut self, default: Duration, refresh: Duration) -> Self {
self.default_expiry = default;
self.refresh_expiry = refresh;
self
}
pub fn generate_token(
&self,
user_id: String,
tenant_id: String,
client_id: Uuid,
) -> Result<String, SyncError> {
let claims = Claims::new(user_id, tenant_id, client_id, self.default_expiry);
encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| SyncError::Authentication)
}
pub fn generate_refresh_token(
&self,
user_id: String,
tenant_id: String,
client_id: Uuid,
) -> Result<String, SyncError> {
let mut claims = Claims::new(user_id, tenant_id, client_id, self.refresh_expiry);
claims.scopes = vec!["refresh".to_string()];
encode(&Header::default(), &claims, &self.encoding_key)
.map_err(|e| SyncError::Authentication)
}
pub fn validate_token(&self, token: &str) -> Result<Claims, SyncError> {
let token_data = decode::<Claims>(token, &self.decoding_key, &self.validation)
.map_err(|e| {
tracing::warn!("JWT validation failed: {}", e);
SyncError::Authentication
})?;
let claims = token_data.claims;
claims.validate_time()?;
Ok(claims)
}
pub fn validate_with_scope(&self, token: &str, required_scope: &str) -> Result<Claims, SyncError> {
let claims = self.validate_token(token)?;
if !claims.has_scope(required_scope) {
tracing::warn!("Token missing required scope: {}", required_scope);
return Err(SyncError::Authentication);
}
Ok(claims)
}
pub fn refresh_access_token(&self, refresh_token: &str) -> Result<String, SyncError> {
let claims = self.validate_with_scope(refresh_token, "refresh")?;
self.generate_token(claims.sub, claims.tenant_id, claims.client_id)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenPair {
pub access_token: String,
pub refresh_token: String,
pub token_type: String,
pub expires_in: u64,
}
impl TokenPair {
pub fn new(access_token: String, refresh_token: String, expires_in: u64) -> Self {
Self {
access_token,
refresh_token,
token_type: "Bearer".to_string(),
expires_in,
}
}
}
pub struct Authorizer {
allowed_tenants: Vec<String>,
}
impl Authorizer {
pub fn new() -> Self {
Self {
allowed_tenants: vec![],
}
}
pub fn with_tenants(tenants: Vec<String>) -> Self {
Self {
allowed_tenants: tenants,
}
}
pub fn is_authorized(&self, tenant_id: &str) -> bool {
if self.allowed_tenants.is_empty() {
return true;
}
self.allowed_tenants.iter().any(|t| t == tenant_id)
}
pub fn add_tenant(&mut self, tenant_id: String) {
if !self.allowed_tenants.contains(&tenant_id) {
self.allowed_tenants.push(tenant_id);
}
}
pub fn remove_tenant(&mut self, tenant_id: &str) -> bool {
if let Some(pos) = self.allowed_tenants.iter().position(|t| t == tenant_id) {
self.allowed_tenants.remove(pos);
true
} else {
false
}
}
pub fn validate_claims(&self, claims: &Claims) -> Result<(), SyncError> {
if !self.is_authorized(&claims.tenant_id) {
tracing::warn!("Unauthorized tenant: {}", claims.tenant_id);
return Err(SyncError::Authentication);
}
Ok(())
}
}
impl Default for Authorizer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_claims_creation() {
let claims = Claims::new(
"user123".to_string(),
"tenant456".to_string(),
Uuid::new_v4(),
Duration::hours(1),
);
assert_eq!(claims.sub, "user123");
assert_eq!(claims.tenant_id, "tenant456");
assert_eq!(claims.iss, "heliosdb-sync");
assert!(claims.has_scope("sync:read"));
assert!(claims.has_scope("sync:write"));
}
#[test]
fn test_claims_expiry() {
let mut claims = Claims::new(
"user123".to_string(),
"tenant456".to_string(),
Uuid::new_v4(),
Duration::hours(1),
);
assert!(!claims.is_expired());
assert!(claims.is_active());
claims.exp = (Utc::now() - Duration::hours(2)).timestamp() as u64;
assert!(claims.is_expired());
}
#[test]
fn test_jwt_manager_generation_and_validation() {
let manager = JwtManager::new(b"test-secret-key-for-testing");
let client_id = Uuid::new_v4();
let token = manager
.generate_token(
"user123".to_string(),
"tenant456".to_string(),
client_id,
)
.unwrap();
let claims = manager.validate_token(&token).unwrap();
assert_eq!(claims.sub, "user123");
assert_eq!(claims.tenant_id, "tenant456");
assert_eq!(claims.client_id, client_id);
}
#[test]
fn test_jwt_manager_invalid_token() {
let manager = JwtManager::new(b"test-secret-key-for-testing");
let result = manager.validate_token("invalid.token.here");
assert!(result.is_err());
}
#[test]
fn test_jwt_manager_wrong_secret() {
let manager1 = JwtManager::new(b"secret1");
let manager2 = JwtManager::new(b"secret2");
let token = manager1
.generate_token(
"user123".to_string(),
"tenant456".to_string(),
Uuid::new_v4(),
)
.unwrap();
let result = manager2.validate_token(&token);
assert!(result.is_err());
}
#[test]
fn test_refresh_token_flow() {
let manager = JwtManager::new(b"test-secret-key-for-testing");
let client_id = Uuid::new_v4();
let refresh_token = manager
.generate_refresh_token(
"user123".to_string(),
"tenant456".to_string(),
client_id,
)
.unwrap();
let claims = manager.validate_token(&refresh_token).unwrap();
assert!(claims.has_scope("refresh"));
assert!(!claims.has_scope("sync:read"));
let new_access_token = manager.refresh_access_token(&refresh_token).unwrap();
let new_claims = manager.validate_token(&new_access_token).unwrap();
assert_eq!(new_claims.sub, "user123");
assert!(new_claims.has_scope("sync:read"));
}
#[test]
fn test_scope_validation() {
let manager = JwtManager::new(b"test-secret-key-for-testing");
let token = manager
.generate_token(
"user123".to_string(),
"tenant456".to_string(),
Uuid::new_v4(),
)
.unwrap();
let result = manager.validate_with_scope(&token, "sync:read");
assert!(result.is_ok());
let result = manager.validate_with_scope(&token, "admin:write");
assert!(result.is_err());
}
#[test]
fn test_authorizer() {
let mut authorizer = Authorizer::new();
assert!(authorizer.is_authorized("any-tenant"));
authorizer.add_tenant("tenant1".to_string());
authorizer.add_tenant("tenant2".to_string());
assert!(authorizer.is_authorized("tenant1"));
assert!(authorizer.is_authorized("tenant2"));
assert!(!authorizer.is_authorized("tenant3"));
assert!(authorizer.remove_tenant("tenant1"));
assert!(!authorizer.is_authorized("tenant1"));
}
#[test]
fn test_authorizer_with_claims() {
let manager = JwtManager::new(b"test-secret-key-for-testing");
let authorizer = Authorizer::with_tenants(vec!["allowed-tenant".to_string()]);
let token = manager
.generate_token(
"user123".to_string(),
"allowed-tenant".to_string(),
Uuid::new_v4(),
)
.unwrap();
let claims = manager.validate_token(&token).unwrap();
assert!(authorizer.validate_claims(&claims).is_ok());
let token2 = manager
.generate_token(
"user456".to_string(),
"forbidden-tenant".to_string(),
Uuid::new_v4(),
)
.unwrap();
let claims2 = manager.validate_token(&token2).unwrap();
assert!(authorizer.validate_claims(&claims2).is_err());
}
#[test]
fn test_token_pair() {
let manager = JwtManager::new(b"test-secret-key-for-testing");
let client_id = Uuid::new_v4();
let access_token = manager
.generate_token(
"user123".to_string(),
"tenant456".to_string(),
client_id,
)
.unwrap();
let refresh_token = manager
.generate_refresh_token(
"user123".to_string(),
"tenant456".to_string(),
client_id,
)
.unwrap();
let token_pair = TokenPair::new(access_token.clone(), refresh_token, 3600);
assert_eq!(token_pair.access_token, access_token);
assert_eq!(token_pair.token_type, "Bearer");
assert_eq!(token_pair.expires_in, 3600);
}
#[test]
fn test_claims_not_before() {
let mut claims = Claims::new(
"user123".to_string(),
"tenant456".to_string(),
Uuid::new_v4(),
Duration::hours(1),
);
assert!(claims.is_active());
claims.nbf = (Utc::now() + Duration::hours(1)).timestamp() as u64;
assert!(!claims.is_active());
}
}