351 lines
11 KiB
Rust
351 lines
11 KiB
Rust
//! JWT token generation and validation (HS256).
|
|
//!
|
|
//! This does NOT implement a domain port — it is used directly by the
|
|
//! presentation layer's auth extractors.
|
|
|
|
use domain::User;
|
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
/// Minimum secret length for production (256 bits = 32 bytes).
|
|
const MIN_SECRET_LENGTH: usize = 32;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// JWT configuration.
|
|
#[derive(Debug, Clone)]
|
|
pub struct JwtConfig {
|
|
/// Secret key for HS256 signing/verification.
|
|
pub secret: String,
|
|
/// Expected issuer (for validation).
|
|
pub issuer: Option<String>,
|
|
/// Expected audience (for validation).
|
|
pub audience: Option<String>,
|
|
/// Access token expiry in hours (default: 24).
|
|
pub expiry_hours: u64,
|
|
/// Refresh token expiry in days (default: 30).
|
|
pub refresh_expiry_days: u64,
|
|
}
|
|
|
|
impl JwtConfig {
|
|
/// Create a new JWT config with validation.
|
|
///
|
|
/// In production mode, this rejects secrets shorter than
|
|
/// [`MIN_SECRET_LENGTH`] bytes.
|
|
pub fn new(
|
|
secret: String,
|
|
issuer: Option<String>,
|
|
audience: Option<String>,
|
|
expiry_hours: Option<u64>,
|
|
refresh_expiry_days: Option<u64>,
|
|
is_production: bool,
|
|
) -> Result<Self, JwtError> {
|
|
if is_production && secret.len() < MIN_SECRET_LENGTH {
|
|
return Err(JwtError::WeakSecret {
|
|
min_length: MIN_SECRET_LENGTH,
|
|
actual_length: secret.len(),
|
|
});
|
|
}
|
|
|
|
Ok(Self {
|
|
secret,
|
|
issuer,
|
|
audience,
|
|
expiry_hours: expiry_hours.unwrap_or(24),
|
|
refresh_expiry_days: refresh_expiry_days.unwrap_or(30),
|
|
})
|
|
}
|
|
|
|
/// Create config without validation (for testing).
|
|
pub fn new_unchecked(secret: String) -> Self {
|
|
Self {
|
|
secret,
|
|
issuer: None,
|
|
audience: None,
|
|
expiry_hours: 24,
|
|
refresh_expiry_days: 30,
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Claims
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn default_token_type() -> String {
|
|
"access".to_string()
|
|
}
|
|
|
|
/// JWT claims structure.
|
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
|
pub struct JwtClaims {
|
|
/// Subject — the user's unique identifier (user ID as string).
|
|
pub sub: String,
|
|
/// User's email address.
|
|
pub email: String,
|
|
/// Expiry timestamp (seconds since UNIX epoch).
|
|
pub exp: usize,
|
|
/// Issued-at timestamp (seconds since UNIX epoch).
|
|
pub iat: usize,
|
|
/// Issuer.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub iss: Option<String>,
|
|
/// Audience.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub aud: Option<String>,
|
|
/// Token type: `"access"` or `"refresh"`. Defaults to `"access"` for
|
|
/// backward compatibility.
|
|
#[serde(default = "default_token_type")]
|
|
pub token_type: String,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Errors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// JWT-related errors.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum JwtError {
|
|
#[error("JWT secret too weak: minimum {min_length} bytes, got {actual_length}")]
|
|
WeakSecret {
|
|
min_length: usize,
|
|
actual_length: usize,
|
|
},
|
|
|
|
#[error("Token creation failed: {0}")]
|
|
CreationFailed(#[from] jsonwebtoken::errors::Error),
|
|
|
|
#[error("Token validation failed: {0}")]
|
|
ValidationFailed(String),
|
|
|
|
#[error("Token expired")]
|
|
Expired,
|
|
|
|
#[error("Invalid token format")]
|
|
InvalidFormat,
|
|
|
|
#[error("Missing configuration")]
|
|
MissingConfig,
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Validator / generator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// JWT token validator and generator.
|
|
#[derive(Clone)]
|
|
pub struct JwtValidator {
|
|
config: JwtConfig,
|
|
encoding_key: EncodingKey,
|
|
decoding_key: DecodingKey,
|
|
validation: Validation,
|
|
}
|
|
|
|
impl JwtValidator {
|
|
/// Create a new JWT validator with the given configuration.
|
|
pub fn new(config: JwtConfig) -> Self {
|
|
let encoding_key = EncodingKey::from_secret(config.secret.as_bytes());
|
|
let decoding_key = DecodingKey::from_secret(config.secret.as_bytes());
|
|
|
|
let mut validation = Validation::new(Algorithm::HS256);
|
|
|
|
if let Some(ref issuer) = config.issuer {
|
|
validation.set_issuer(&[issuer]);
|
|
}
|
|
if let Some(ref audience) = config.audience {
|
|
validation.set_audience(&[audience]);
|
|
}
|
|
|
|
Self {
|
|
config,
|
|
encoding_key,
|
|
decoding_key,
|
|
validation,
|
|
}
|
|
}
|
|
|
|
/// Create an access JWT token for the given user.
|
|
pub fn create_token(&self, user: &User) -> Result<String, JwtError> {
|
|
let now = now_secs();
|
|
let expiry = now + (self.config.expiry_hours as usize * 3600);
|
|
|
|
let claims = JwtClaims {
|
|
sub: user.id().to_string(),
|
|
email: user.email().as_ref().to_string(),
|
|
exp: expiry,
|
|
iat: now,
|
|
iss: self.config.issuer.clone(),
|
|
aud: self.config.audience.clone(),
|
|
token_type: "access".to_string(),
|
|
};
|
|
|
|
encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key)
|
|
.map_err(JwtError::CreationFailed)
|
|
}
|
|
|
|
/// Create a refresh JWT token for the given user (longer-lived).
|
|
pub fn create_refresh_token(&self, user: &User) -> Result<String, JwtError> {
|
|
let now = now_secs();
|
|
let expiry = now + (self.config.refresh_expiry_days as usize * 86400);
|
|
|
|
let claims = JwtClaims {
|
|
sub: user.id().to_string(),
|
|
email: user.email().as_ref().to_string(),
|
|
exp: expiry,
|
|
iat: now,
|
|
iss: self.config.issuer.clone(),
|
|
aud: self.config.audience.clone(),
|
|
token_type: "refresh".to_string(),
|
|
};
|
|
|
|
encode(&Header::new(Algorithm::HS256), &claims, &self.encoding_key)
|
|
.map_err(JwtError::CreationFailed)
|
|
}
|
|
|
|
/// Validate a JWT token and return the claims.
|
|
pub fn validate_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
|
let token_data =
|
|
decode::<JwtClaims>(token, &self.decoding_key, &self.validation).map_err(|e| {
|
|
match e.kind() {
|
|
jsonwebtoken::errors::ErrorKind::ExpiredSignature => JwtError::Expired,
|
|
jsonwebtoken::errors::ErrorKind::InvalidToken => JwtError::InvalidFormat,
|
|
_ => JwtError::ValidationFailed(e.to_string()),
|
|
}
|
|
})?;
|
|
|
|
Ok(token_data.claims)
|
|
}
|
|
|
|
/// Validate an access token — rejects refresh tokens.
|
|
pub fn validate_access_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
|
let claims = self.validate_token(token)?;
|
|
if claims.token_type != "access" {
|
|
return Err(JwtError::ValidationFailed(
|
|
"Not an access token".to_string(),
|
|
));
|
|
}
|
|
Ok(claims)
|
|
}
|
|
|
|
/// Validate a refresh token — rejects access tokens.
|
|
pub fn validate_refresh_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
|
let claims = self.validate_token(token)?;
|
|
if claims.token_type != "refresh" {
|
|
return Err(JwtError::ValidationFailed(
|
|
"Not a refresh token".to_string(),
|
|
));
|
|
}
|
|
Ok(claims)
|
|
}
|
|
|
|
/// Get the user ID (subject) from a token without full validation.
|
|
///
|
|
/// Useful for logging/debugging — should not be trusted for auth decisions.
|
|
pub fn decode_unverified(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
|
let mut insecure = Validation::new(Algorithm::HS256);
|
|
insecure.insecure_disable_signature_validation();
|
|
insecure.validate_exp = false;
|
|
insecure.validate_aud = false;
|
|
|
|
let token_data = decode::<JwtClaims>(token, &self.decoding_key, &insecure)
|
|
.map_err(|_| JwtError::InvalidFormat)?;
|
|
Ok(token_data.claims)
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Debug for JwtValidator {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("JwtValidator")
|
|
.field("issuer", &self.config.issuer)
|
|
.field("audience", &self.config.audience)
|
|
.field("expiry_hours", &self.config.expiry_hours)
|
|
.finish_non_exhaustive()
|
|
}
|
|
}
|
|
|
|
fn now_secs() -> usize {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("Time went backwards")
|
|
.as_secs() as usize
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use domain::Email;
|
|
|
|
fn test_user() -> User {
|
|
let email = Email::new("test@example.com").unwrap();
|
|
User::new("test-subject", email)
|
|
}
|
|
|
|
#[test]
|
|
fn create_and_validate_token() {
|
|
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
|
|
let validator = JwtValidator::new(config);
|
|
let user = test_user();
|
|
|
|
let token = validator.create_token(&user).expect("create token");
|
|
let claims = validator.validate_token(&token).expect("validate token");
|
|
|
|
assert_eq!(claims.sub, user.id().to_string());
|
|
assert_eq!(claims.email, "test@example.com");
|
|
assert_eq!(claims.token_type, "access");
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_token_round_trip() {
|
|
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
|
|
let validator = JwtValidator::new(config);
|
|
let user = test_user();
|
|
|
|
let token = validator.create_refresh_token(&user).unwrap();
|
|
let claims = validator.validate_refresh_token(&token).unwrap();
|
|
assert_eq!(claims.token_type, "refresh");
|
|
|
|
// Access-only validation rejects it
|
|
assert!(validator.validate_access_token(&token).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn weak_secret_rejected_in_production() {
|
|
let result = JwtConfig::new("short".to_string(), None, None, None, None, true);
|
|
assert!(matches!(result, Err(JwtError::WeakSecret { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn weak_secret_allowed_in_development() {
|
|
let result = JwtConfig::new("short".to_string(), None, None, None, None, false);
|
|
assert!(result.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn invalid_token_rejected() {
|
|
let config = JwtConfig::new_unchecked("test-secret-key-that-is-long-enough".to_string());
|
|
let validator = JwtValidator::new(config);
|
|
assert!(validator.validate_token("invalid.token.here").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_secret_rejected() {
|
|
let v1 = JwtValidator::new(JwtConfig::new_unchecked(
|
|
"secret-one-that-is-long-enough".to_string(),
|
|
));
|
|
let v2 = JwtValidator::new(JwtConfig::new_unchecked(
|
|
"secret-two-that-is-long-enough".to_string(),
|
|
));
|
|
|
|
let user = test_user();
|
|
let token = v1.create_token(&user).unwrap();
|
|
assert!(v2.validate_token(&token).is_err());
|
|
}
|
|
}
|