diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index e9e38c9..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..59b742c --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +node_modules/ +src/ +__tests__/ +*.log +jest.config.js diff --git a/Cargo.lock b/Cargo.lock index 1e986c3..c58b91d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "base64" @@ -17,23 +17,17 @@ dependencies = [ "generic-array", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cpufeatures" -version = "0.2.15" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca741a962e1b0bff6d724a1a0958b686406e853bb14061f218562e1896f95e6" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] @@ -61,9 +55,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", @@ -82,13 +76,16 @@ dependencies = [ [[package]] name = "hash_token_rust" -version = "0.1.0" +version = "0.2.0" dependencies = [ "base64", "hex", "hmac", "rand", + "serde", + "serde_json", "sha2", + "thiserror", ] [[package]] @@ -106,35 +103,47 @@ dependencies = [ "digest", ] +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + [[package]] name = "libc" -version = "0.2.164" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "ppv-lite86" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy", ] [[package]] name = "proc-macro2" -version = "1.0.89" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.37" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -169,11 +178,60 @@ dependencies = [ "getrandom", ] +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -188,26 +246,46 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.87" +version = "2.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25aa4ce346d03a6dcd68dd8b4010bcb74e54e62c90c573f394c46eae99aba32d" +checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "typenum" -version = "1.17.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicode-ident" -version = "1.0.13" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "version_check" @@ -217,25 +295,24 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" dependencies = [ - "byteorder", "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 23cfcce..82686f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,18 @@ [package] name = "hash_token_rust" -version = "0.1.0" +version = "0.2.0" edition = "2021" -authors = ["Seu Nome "] -description = "Descrição do que o package faz" +authors = ["dnettoRaw "] +description = "Rust implementation of the AdvancedTokenManager with JWT helpers" license = "MIT" repository = "https://github.com/dnettoRaw/hashTokenRust.git" - [dependencies] -hex = "0.4.3" -sha2 = "0.10.8" -hmac = "0.12.1" -rand = "0.8.5" -base64 = "0.22.1" \ No newline at end of file +base64 = "0.22" +hex = "0.4" +hmac = "0.12" +rand = "0.8" +sha2 = "0.10" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +thiserror = "1.0" diff --git a/src/advanced_token_manager.rs b/src/advanced_token_manager.rs index 025b6af..f3312bc 100644 --- a/src/advanced_token_manager.rs +++ b/src/advanced_token_manager.rs @@ -1,177 +1,574 @@ use std::env; +use std::sync::Arc; + use base64::{engine::general_purpose, Engine as _}; +use rand::distributions::{Distribution, Uniform}; +use rand::{rngs::OsRng, Rng}; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; +use thiserror::Error; + use hmac::{Hmac, Mac}; -use rand::Rng; use sha2::{Sha256, Sha512}; -type HmacSha256 = Hmac; -type HmacSha512 = Hmac; +use crate::jwt::{ + sign_jwt, verify_jwt_as, Audience, Issuer, JwtAlgorithm, JwtClaims, JwtError, SignJwtOptions, + VerifyJwtOptions, +}; const DEFAULT_SECRET_LENGTH: usize = 32; const DEFAULT_SALT_COUNT: usize = 10; const DEFAULT_SALT_LENGTH: usize = 16; const MIN_SECRET_LENGTH: usize = 16; const MIN_SALT_COUNT: usize = 2; +const CHARACTERS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; +type HmacSha256 = Hmac; +type HmacSha512 = Hmac; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Algorithm { Sha256, Sha512, } +impl Algorithm { + fn to_hmac(self, secret: &[u8], input: &[u8]) -> Result, TokenValidationError> { + match self { + Algorithm::Sha256 => compute_hmac_sha256(secret, input), + Algorithm::Sha512 => compute_hmac_sha512(secret, input), + } + } +} + +#[derive(Debug, Error)] +pub enum AdvancedTokenError { + #[error("{0}")] + Message(String), + #[error(transparent)] + Jwt(#[from] JwtError), +} + +#[derive(Debug, Error, Clone)] +pub enum TokenValidationError { + #[error("{0}")] + Message(String), +} + +impl TokenValidationError { + fn new(message: impl Into) -> Self { + Self::Message(message.into()) + } +} + +pub trait AdvancedTokenManagerLogger: Send + Sync { + fn warn(&self, message: &str); + fn error(&self, message: &str); +} + +#[derive(Clone)] +struct DefaultLogger; + +impl AdvancedTokenManagerLogger for DefaultLogger { + fn warn(&self, message: &str) { + eprintln!("{}", message); + } + + fn error(&self, message: &str) { + eprintln!("{}", message); + } +} + +#[derive(Default, Clone)] +pub struct AdvancedTokenManagerOptions { + pub logger: Option>, + pub jwt_default_algorithms: Option>, + pub default_secret_length: Option, + pub default_salt_count: Option, + pub default_salt_length: Option, + pub throw_on_validation_failure: Option, + pub jwt_max_payload_size: Option, + pub jwt_allowed_claims: Option>, +} + +#[derive(Default, Clone)] +pub struct ValidateTokenOptions { + pub throw_on_failure: Option, +} + +#[derive(Default, Clone)] +pub struct ManagerSignJwtOptions { + pub secret: Option, + pub algorithm: Option, + pub header: Option>, + pub expires_in: Option, + pub not_before: Option, + pub audience: Option, + pub issuer: Option, + pub subject: Option, + pub issued_at: Option, + pub clock_timestamp: Option, +} + +#[derive(Default, Clone)] +pub struct ManagerVerifyJwtOptions { + pub secret: Option, + pub algorithms: Option>, + pub clock_tolerance: Option, + pub audience: Option, + pub issuer: Option, + pub subject: Option, + pub max_age: Option, + pub clock_timestamp: Option, + pub max_payload_size: Option, + pub allowed_claims: Option>, +} + +pub struct ManagerConfig { + pub secret: String, + pub salts: Vec, +} + pub struct AdvancedTokenManager { secret: String, salts: Vec, algorithm: Algorithm, last_salt_index: Option, + logger: Arc, + throw_on_validation_failure: bool, + jwt_default_algorithms: Option>, + jwt_max_payload_size: Option, + jwt_allowed_claims: Option>, } impl AdvancedTokenManager { + #[allow(clippy::too_many_arguments)] pub fn new( secret: Option, salts: Option>, algorithm: Option, allow_auto_generate: bool, no_env: bool, - ) -> Result { - let secret: String = Self::initialize_secret(secret, allow_auto_generate, no_env)?; - let salts: Vec = Self::initialize_salts(salts, allow_auto_generate, no_env)?; - let algorithm: Algorithm = algorithm.unwrap_or(Algorithm::Sha256); + options: Option, + ) -> Result { + let options = options.unwrap_or_default(); + let logger = options.logger.unwrap_or_else(|| Arc::new(DefaultLogger)); + + let default_secret_length = resolve_length_option( + "defaultSecretLength", + options.default_secret_length, + DEFAULT_SECRET_LENGTH, + MIN_SECRET_LENGTH, + )?; + let default_salt_count = resolve_length_option( + "defaultSaltCount", + options.default_salt_count, + DEFAULT_SALT_COUNT, + MIN_SALT_COUNT, + )?; + let default_salt_length = resolve_length_option( + "defaultSaltLength", + options.default_salt_length, + DEFAULT_SALT_LENGTH, + 1, + )?; + let jwt_default_algorithms = normalize_algorithms(options.jwt_default_algorithms)?; + let throw_on_validation_failure = options.throw_on_validation_failure.unwrap_or(false); + let jwt_max_payload_size = + normalize_positive_usize("jwtMaxPayloadSize", options.jwt_max_payload_size)?; + let jwt_allowed_claims = normalize_allowed_claims(options.jwt_allowed_claims)?; + + let secret = initialize_secret( + secret, + allow_auto_generate, + no_env, + default_secret_length, + &*logger, + )?; + let salts = initialize_salts( + salts, + allow_auto_generate, + no_env, + default_salt_count, + default_salt_length, + &*logger, + )?; + let algorithm = algorithm.unwrap_or(Algorithm::Sha256); Ok(Self { secret, salts, algorithm, last_salt_index: None, + logger, + throw_on_validation_failure, + jwt_default_algorithms, + jwt_max_payload_size, + jwt_allowed_claims, }) } - fn initialize_secret( - secret: Option, - allow_auto_generate: bool, - no_env: bool, - ) -> Result { - let secret: Option = if !no_env { - secret.or_else(|| env::var("TOKEN_SECRET").ok()) - } else { - secret + pub fn generate_token( + &mut self, + input: &str, + salt_index: Option, + ) -> Result { + let index = match salt_index { + Some(index) => { + self.validate_salt_index(index)?; + index + } + None => self.get_random_salt_index(), }; + let salt = &self.salts[index]; + let checksum = self.create_checksum(input, salt)?; + Ok(general_purpose::STANDARD.encode(format!("{}|{}|{}", input, index, checksum))) + } - match secret { - Some(secret) if secret.len() >= MIN_SECRET_LENGTH => Ok(secret), - Some(_) => Err(format!( - "Secret must be at least {} characters long.", - MIN_SECRET_LENGTH - )), - None if allow_auto_generate => { - let generated_secret = Self::generate_random_key(DEFAULT_SECRET_LENGTH); - eprintln!("⚠️ Secret generated automatically. Store it securely."); - Ok(generated_secret) + pub fn validate_token(&self, token: &str) -> Result, TokenValidationError> { + self.validate_token_with_options(token, None) + } + + pub fn validate_token_with_options( + &self, + token: &str, + options: Option, + ) -> Result, TokenValidationError> { + let options = options.unwrap_or_default(); + let should_throw = options + .throw_on_failure + .unwrap_or(self.throw_on_validation_failure); + + match self.validate_token_internal(token) { + Ok(value) => Ok(Some(value)), + Err(error) => { + self.logger + .error(&format!("Error validating token: {}", error)); + if should_throw { + Err(error) + } else { + Ok(None) + } } - None => Err("Secret is required and must meet minimum length requirements.".to_string()), } } - fn initialize_salts( - salts: Option>, - allow_auto_generate: bool, - no_env: bool, - ) -> Result, String> { - let salts: Option> = if !no_env { - salts.or_else(|| env::var("TOKEN_SALTS").ok().map(|s: String| s.split(',').map(String::from).collect())) - } else { - salts + pub fn validate_token_lenient(&self, token: &str) -> Option { + self.validate_token_with_options( + token, + Some(ValidateTokenOptions { + throw_on_failure: Some(false), + }), + ) + .ok() + .flatten() + } + + pub fn extract_data(&self, token: &str) -> Result, TokenValidationError> { + self.validate_token(token) + } + + pub fn generate_jwt( + &self, + payload: &JwtClaims, + options: Option, + ) -> Result { + let options = options.unwrap_or_default(); + let secret = options.secret.unwrap_or_else(|| self.secret.clone()); + let sign_options = SignJwtOptions { + secret, + algorithm: options.algorithm, + header: options.header, + expires_in: options.expires_in, + not_before: options.not_before, + audience: options.audience, + issuer: options.issuer, + subject: options.subject, + issued_at: options.issued_at, + clock_timestamp: options.clock_timestamp, }; + Ok(sign_jwt(payload, &sign_options)?) + } - match salts { - Some(salts) if salts.len() >= MIN_SALT_COUNT && salts.iter().all(|s: &String| !s.trim().is_empty()) => Ok(salts), - Some(_) => Err(format!( - "Salt array must have at least {} non-empty elements.", - MIN_SALT_COUNT - )), - None if allow_auto_generate => { - let generated_salts: Vec = (0..DEFAULT_SALT_COUNT) - .map(|_| Self::generate_random_key(DEFAULT_SALT_LENGTH)) - .collect(); - eprintln!("⚠️ Salts generated automatically. Store them securely."); - Ok(generated_salts) - } - None => Err("Salts are required and must meet minimum requirements.".to_string()), + pub fn validate_jwt( + &self, + token: &str, + options: Option, + ) -> Result { + let options = options.unwrap_or_default(); + let secret = options.secret.unwrap_or_else(|| self.secret.clone()); + let verify_options = VerifyJwtOptions { + secret, + algorithms: options + .algorithms + .or_else(|| self.jwt_default_algorithms.clone()), + clock_tolerance: options.clock_tolerance, + audience: options.audience, + issuer: options.issuer, + subject: options.subject, + max_age: options.max_age, + clock_timestamp: options.clock_timestamp, + max_payload_size: options.max_payload_size.or(self.jwt_max_payload_size), + allowed_claims: options + .allowed_claims + .or_else(|| self.jwt_allowed_claims.clone()), + }; + + Ok(verify_jwt_as(token, &verify_options)?) + } + + pub fn get_config(&self) -> ManagerConfig { + ManagerConfig { + secret: self.secret.clone(), + salts: self.salts.clone(), } } - fn generate_random_key(length: usize) -> String { - let characters: Vec = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".chars().collect(); - let mut rng: rand::prelude::ThreadRng = rand::thread_rng(); - (0..length) - .map(|_| characters[rng.gen_range(0..characters.len())]) - .collect() + fn validate_token_internal(&self, token: &str) -> Result { + let decoded = general_purpose::STANDARD + .decode(token) + .map_err(|_| TokenValidationError::new("Invalid base64 token."))?; + let decoded = String::from_utf8(decoded) + .map_err(|_| TokenValidationError::new("Token is not valid UTF-8."))?; + let mut parts = decoded.split('|'); + let input = parts + .next() + .ok_or_else(|| TokenValidationError::new("Token missing payload."))?; + let salt_index = parts + .next() + .ok_or_else(|| TokenValidationError::new("Token missing salt index."))?; + let checksum = parts + .next() + .ok_or_else(|| TokenValidationError::new("Token missing checksum."))?; + + if parts.next().is_some() { + return Err(TokenValidationError::new( + "Token has unexpected extra data.", + )); + } + + let index: usize = salt_index + .parse() + .map_err(|_| TokenValidationError::new("Token has invalid salt index."))?; + self.validate_salt_index(index)?; + let expected_checksum = self.create_checksum(input, &self.salts[index])?; + if expected_checksum == checksum { + Ok(input.to_string()) + } else { + Err(TokenValidationError::new("Checksum mismatch.")) + } + } + + fn validate_salt_index(&self, index: usize) -> Result<(), TokenValidationError> { + if index < self.salts.len() { + Ok(()) + } else { + Err(TokenValidationError::new(format!( + "Invalid salt index: {}", + index + ))) + } + } + + fn create_checksum(&self, input: &str, salt: &str) -> Result { + let mut payload = String::with_capacity(input.len() + salt.len()); + payload.push_str(input); + payload.push_str(salt); + let digest = self + .algorithm + .to_hmac(self.secret.as_bytes(), payload.as_bytes())?; + Ok(hex::encode(digest)) } fn get_random_salt_index(&mut self) -> usize { - let mut index: usize; + let len = self.salts.len(); + let mut rng = rand::thread_rng(); loop { - index = rand::random::() % self.salts.len(); + let index = rng.gen_range(0..len); if Some(index) != self.last_salt_index { - break; + self.last_salt_index = Some(index); + return index; } } - self.last_salt_index = Some(index); - index - } - - pub fn generate_token(&mut self, input: &str, salt_index: Option) -> String { - let index: usize = salt_index.unwrap_or_else(|| self.get_random_salt_index()); - self.validate_salt_index(index).unwrap(); - let salt: &String = &self.salts[index]; - let checksum: String = self.create_checksum(input, salt); - general_purpose::STANDARD.encode(format!("{}|{}|{}", input, index, checksum)) - } - - pub fn validate_token(&self, token: &str) -> Option { - let decoded: String = match general_purpose::STANDARD.decode(token) { - Ok(decoded) => String::from_utf8(decoded).ok()?, - Err(_) => return None, - }; - - let parts: Vec<&str> = decoded.split('|').collect(); - if parts.len() != 3 { - return None; + } +} + +fn compute_hmac_sha256(secret: &[u8], input: &[u8]) -> Result, TokenValidationError> { + let mut mac = HmacSha256::new_from_slice(secret) + .map_err(|_| TokenValidationError::new("Invalid HMAC key."))?; + mac.update(input); + Ok(mac.finalize().into_bytes().to_vec()) +} + +fn compute_hmac_sha512(secret: &[u8], input: &[u8]) -> Result, TokenValidationError> { + let mut mac = HmacSha512::new_from_slice(secret) + .map_err(|_| TokenValidationError::new("Invalid HMAC key."))?; + mac.update(input); + Ok(mac.finalize().into_bytes().to_vec()) +} + +fn initialize_secret( + secret: Option, + allow_auto_generate: bool, + no_env: bool, + default_length: usize, + logger: &dyn AdvancedTokenManagerLogger, +) -> Result { + let mut candidate = secret.map(|value| value.trim().to_string()); + + if !no_env && candidate.is_none() { + candidate = env::var("TOKEN_SECRET") + .ok() + .map(|value| value.trim().to_string()); + } + + if let Some(secret) = candidate { + if secret.len() < MIN_SECRET_LENGTH { + return Err(AdvancedTokenError::Message(format!( + "Secret must be at least {} characters long.", + MIN_SECRET_LENGTH + ))); } - - let input: &str = parts[0]; - let salt_index: usize = parts[1].parse().ok()?; - let checksum: &str = parts[2]; - - self.validate_salt_index(salt_index).ok()?; - let valid_checksum: String = self.create_checksum(input, &self.salts[salt_index]); - - if valid_checksum == checksum { - Some(input.to_string()) - } else { - None + Ok(secret) + } else if allow_auto_generate { + let generated = generate_random_key(default_length); + logger.warn("⚠️ Secret generated automatically. Store it securely."); + Ok(generated) + } else { + Err(AdvancedTokenError::Message(format!( + "Secret must be at least {} characters long.", + MIN_SECRET_LENGTH + ))) + } +} + +fn initialize_salts( + salts: Option>, + allow_auto_generate: bool, + no_env: bool, + default_count: usize, + default_length: usize, + logger: &dyn AdvancedTokenManagerLogger, +) -> Result, AdvancedTokenError> { + let mut resolved = salts; + if !no_env { + if resolved.as_ref().map_or(true, |values| values.is_empty()) { + if let Ok(value) = env::var("TOKEN_SALTS") { + resolved = Some( + value + .split(',') + .map(|entry| entry.trim().to_string()) + .collect(), + ); + } } } - fn validate_salt_index(&self, index: usize) -> Result<(), String> { - if index < self.salts.len() { - Ok(()) - } else { - Err(format!("Invalid salt index: {}", index)) + if let Some(values) = resolved { + let sanitized: Vec = values + .into_iter() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .collect(); + if sanitized.len() < MIN_SALT_COUNT { + return Err(AdvancedTokenError::Message(format!( + "Salt array cannot be empty or less than {}.", + MIN_SALT_COUNT + ))); } + Ok(sanitized) + } else if allow_auto_generate { + let salts: Vec = (0..default_count) + .map(|_| generate_random_key(default_length)) + .collect(); + logger.warn("⚠️ Salts generated automatically. Store them securely."); + Ok(salts) + } else { + Err(AdvancedTokenError::Message( + "Salt array cannot be empty or less than 2.".to_string(), + )) } +} + +fn resolve_length_option( + name: &str, + provided: Option, + fallback: usize, + minimum: usize, +) -> Result { + match provided { + None => Ok(fallback), + Some(value) if value < minimum => Err(AdvancedTokenError::Message(format!( + "{} must be an integer greater than or equal to {}.", + name, minimum + ))), + Some(value) => Ok(value), + } +} + +fn normalize_positive_usize( + name: &str, + value: Option, +) -> Result, AdvancedTokenError> { + match value { + None => Ok(None), + Some(0) => Err(AdvancedTokenError::Message(format!( + "{} must be a positive number.", + name + ))), + Some(value) => Ok(Some(value)), + } +} - fn create_checksum(&self, input: &str, salt: &str) -> String { - match self.algorithm { - Algorithm::Sha256 => { - let mut mac = HmacSha256::new_from_slice(self.secret.as_bytes()).expect("Invalid HMAC key"); - mac.update(format!("{}{}", input, salt).as_bytes()); - hex::encode(mac.finalize().into_bytes()) +fn normalize_allowed_claims( + allowed: Option>, +) -> Result>, AdvancedTokenError> { + match allowed { + None => Ok(None), + Some(values) => { + let mut unique = Vec::new(); + for value in values { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + return Err(AdvancedTokenError::Message( + "jwtAllowedClaims must be an array of non-empty strings.".to_string(), + )); + } + if !unique.contains(&trimmed) { + unique.push(trimmed); + } } - Algorithm::Sha512 => { - let mut mac = HmacSha512::new_from_slice(self.secret.as_bytes()).expect("Invalid HMAC key"); - mac.update(format!("{}{}", input, salt).as_bytes()); - hex::encode(mac.finalize().into_bytes()) + Ok(Some(unique)) + } + } +} + +fn normalize_algorithms( + algorithms: Option>, +) -> Result>, AdvancedTokenError> { + match algorithms { + None => Ok(None), + Some(values) => { + if values.is_empty() { + return Err(AdvancedTokenError::Message( + "jwtDefaultAlgorithms must be a non-empty array when provided.".to_string(), + )); } + let mut unique = Vec::new(); + for value in values { + if !unique.contains(&value) { + unique.push(value); + } + } + Ok(Some(unique)) } } } + +fn generate_random_key(length: usize) -> String { + let distribution = Uniform::from(0..CHARACTERS.len()); + let mut rng = OsRng; + (0..length) + .map(|_| CHARACTERS[distribution.sample(&mut rng)] as char) + .collect() +} diff --git a/src/jwt.rs b/src/jwt.rs new file mode 100644 index 0000000..60b1841 --- /dev/null +++ b/src/jwt.rs @@ -0,0 +1,749 @@ +use std::collections::HashSet; +use std::fmt::{self, Display}; +use std::str::FromStr; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use hmac::{Hmac, Mac}; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; +use sha2::{Sha256, Sha512}; +use thiserror::Error; + +const STANDARD_CLAIMS: [&str; 6] = ["iss", "sub", "aud", "exp", "nbf", "iat"]; +const BASE64URL_ALLOWED: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + +pub type JwtClaims = Map; + +type HmacSha256 = Hmac; +type HmacSha512 = Hmac; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum JwtAlgorithm { + HS256, + HS512, +} + +impl JwtAlgorithm {} + +impl Display for JwtAlgorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + JwtAlgorithm::HS256 => write!(f, "HS256"), + JwtAlgorithm::HS512 => write!(f, "HS512"), + } + } +} + +impl FromStr for JwtAlgorithm { + type Err = JwtError; + + fn from_str(s: &str) -> Result { + match s { + "HS256" => Ok(JwtAlgorithm::HS256), + "HS512" => Ok(JwtAlgorithm::HS512), + other => Err(JwtError::new(format!( + "JWT: unsupported algorithm: {}.", + other + ))), + } + } +} + +#[derive(Debug, Error)] +#[error("{message}")] +pub struct JwtError { + message: String, +} + +impl JwtError { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + fn claim_conflict(claim: &str) -> Self { + Self::new(format!( + "JWT: claim \"{}\" already present with a different value.", + claim + )) + } +} + +#[derive(Clone, Debug, Default)] +pub struct SignJwtOptions { + pub secret: String, + pub algorithm: Option, + pub header: Option>, + pub expires_in: Option, + pub not_before: Option, + pub audience: Option, + pub issuer: Option, + pub subject: Option, + pub issued_at: Option, + pub clock_timestamp: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct VerifyJwtOptions { + pub secret: String, + pub algorithms: Option>, + pub clock_tolerance: Option, + pub audience: Option, + pub issuer: Option, + pub subject: Option, + pub max_age: Option, + pub clock_timestamp: Option, + pub max_payload_size: Option, + pub allowed_claims: Option>, +} + +#[derive(Clone, Debug)] +pub enum Audience { + Single(String), + Multiple(Vec), +} + +impl Audience { + fn into_vec(self) -> Result, JwtError> { + match self { + Audience::Single(value) => { + let normalized = normalize_string(value, "Audience")?; + Ok(vec![normalized]) + } + Audience::Multiple(values) => { + if values.is_empty() { + return Err(JwtError::new("JWT: audience array must not be empty.")); + } + let mut normalized = Vec::with_capacity(values.len()); + for value in values { + normalized.push(normalize_string(value, "Audience")?); + } + Ok(normalized) + } + } + } +} + +#[derive(Clone, Debug)] +pub enum Issuer { + Single(String), + Multiple(Vec), +} + +impl Issuer { + fn into_vec(self) -> Result, JwtError> { + match self { + Issuer::Single(value) => Ok(vec![normalize_string(value, "Issuer")?]), + Issuer::Multiple(values) => { + if values.is_empty() { + return Err(JwtError::new("JWT: issuer array must not be empty.")); + } + let mut normalized = Vec::with_capacity(values.len()); + for value in values { + normalized.push(normalize_string(value, "Issuer")?); + } + Ok(normalized) + } + } + } +} + +pub fn sign_jwt(payload: &JwtClaims, options: &SignJwtOptions) -> Result { + if options.secret.trim().is_empty() { + return Err(JwtError::new( + "JWT: a non-empty secret is required to sign.", + )); + } + + let algorithm = options.algorithm.unwrap_or(JwtAlgorithm::HS256); + let header = build_header(options, algorithm)?; + + let timestamp = current_timestamp(options.clock_timestamp)?; + let mut claims = payload.clone(); + + apply_issued_at(&mut claims, options.issued_at, timestamp)?; + apply_expires_in(&mut claims, options.expires_in, timestamp)?; + apply_not_before(&mut claims, options.not_before, timestamp)?; + apply_audience(&mut claims, options.audience.clone())?; + apply_issuer(&mut claims, options.issuer.clone())?; + apply_subject(&mut claims, options.subject.clone())?; + + let header_json = serde_json::to_vec(&header) + .map_err(|_| JwtError::new("JWT: failed to serialize header."))?; + let payload_json = serde_json::to_vec(&Value::Object(claims.clone())) + .map_err(|_| JwtError::new("JWT: failed to serialize payload."))?; + let encoded_header = base64url_encode(header_json); + let encoded_payload = base64url_encode(payload_json); + let signing_input = format!("{}.{}", &encoded_header, &encoded_payload); + let signature = create_signature(algorithm, &options.secret, &signing_input)?; + + Ok(format!( + "{}.{}.{}", + encoded_header, encoded_payload, signature + )) +} + +pub fn verify_jwt(token: &str, options: &VerifyJwtOptions) -> Result { + if token.trim().is_empty() { + return Err(JwtError::new("JWT: token must be a non-empty string.")); + } + if options.secret.trim().is_empty() { + return Err(JwtError::new( + "JWT: a non-empty secret is required to verify.", + )); + } + + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 || parts.iter().any(|part| part.is_empty()) { + return Err(JwtError::new("JWT: invalid token structure.")); + } + + let encoded_header = parts[0]; + let encoded_payload = parts[1]; + let encoded_signature = parts[2]; + + let header_bytes = base64url_decode(encoded_header, "header")?; + let header_value: Value = serde_json::from_slice(&header_bytes) + .map_err(|_| JwtError::new("JWT: invalid header JSON."))?; + let header_obj = header_value + .as_object() + .ok_or_else(|| JwtError::new("JWT: header must be a JSON object."))?; + + let alg_value = header_obj + .get("alg") + .and_then(Value::as_str) + .ok_or_else(|| JwtError::new("JWT: missing algorithm."))?; + + if alg_value.eq_ignore_ascii_case("none") { + return Err(JwtError::new( + "JWT: unsigned tokens (alg \"none\") are not allowed.", + )); + } + + let algorithm = JwtAlgorithm::from_str(alg_value.to_ascii_uppercase().as_str())?; + + if let Some(allowed) = &options.algorithms { + if !allowed.contains(&algorithm) { + return Err(JwtError::new(format!( + "JWT: algorithm {} is not allowed.", + algorithm + ))); + } + } + + if let Some(typ_value) = header_obj.get("typ") { + let typ = typ_value + .as_str() + .ok_or_else(|| JwtError::new("JWT: header type must be a string."))?; + if typ != "JWT" { + return Err(JwtError::new("JWT: header type must be \"JWT\".")); + } + } + + let payload_bytes = base64url_decode(encoded_payload, "payload")?; + if let Some(max_size) = options.max_payload_size { + if payload_bytes.len() > max_size { + return Err(JwtError::new("JWT: payload exceeds maxPayloadSize.")); + } + } + + let payload_value: Value = serde_json::from_slice(&payload_bytes) + .map_err(|_| JwtError::new("JWT: invalid payload JSON."))?; + let payload = payload_value + .as_object() + .cloned() + .ok_or_else(|| JwtError::new("JWT: payload must be a JSON object."))?; + + if let Some(allowed_claims) = &options.allowed_claims { + enforce_allowed_claims(&payload, allowed_claims)?; + } + + verify_signature( + algorithm, + &options.secret, + encoded_header, + encoded_payload, + encoded_signature, + )?; + + validate_temporal_claims(&payload, options)?; + validate_audience(&payload, options)?; + validate_issuer(&payload, options)?; + validate_subject(&payload, options)?; + + Ok(payload) +} + +pub fn verify_jwt_as( + token: &str, + options: &VerifyJwtOptions, +) -> Result { + let claims = verify_jwt(token, options)?; + serde_json::from_value(Value::Object(claims)) + .map_err(|_| JwtError::new("JWT: payload could not be deserialized into target type.")) +} + +fn build_header(options: &SignJwtOptions, algorithm: JwtAlgorithm) -> Result { + let mut header = match &options.header { + Some(custom) => custom.clone(), + None => Map::new(), + }; + + if let Some(alg) = header.get("alg") { + if alg.as_str() != Some(&algorithm.to_string()) { + return Err(JwtError::new("JWT: header algorithm mismatch.")); + } + } + + if let Some(typ) = header.get("typ") { + if typ.as_str() != Some("JWT") { + return Err(JwtError::new("JWT: header type must be \"JWT\".")); + } + } + + header.insert("alg".to_string(), Value::String(algorithm.to_string())); + header.insert("typ".to_string(), Value::String("JWT".to_string())); + + Ok(header) +} + +fn apply_issued_at( + claims: &mut JwtClaims, + issued_at: Option, + timestamp: i64, +) -> Result<(), JwtError> { + if let Some(value) = issued_at { + let normalized = normalize_number(value, "iat")?; + enforce_claim(claims, "iat", Value::from(normalized)) + } else if let Some(existing) = claims.get("iat") { + ensure_numeric(existing, "iat")?; + Ok(()) + } else { + claims.insert("iat".to_string(), Value::from(timestamp)); + Ok(()) + } +} + +fn apply_expires_in( + claims: &mut JwtClaims, + expires_in: Option, + timestamp: i64, +) -> Result<(), JwtError> { + if let Some(value) = expires_in { + if !value.is_finite() || value <= 0.0 { + return Err(JwtError::new( + "JWT: expiresIn must be a positive number of seconds.", + )); + } + let exp = timestamp + value.floor() as i64; + enforce_claim(claims, "exp", Value::from(exp)) + } else if let Some(existing) = claims.get("exp") { + ensure_numeric(existing, "exp") + } else { + Ok(()) + } +} + +fn apply_not_before( + claims: &mut JwtClaims, + not_before: Option, + timestamp: i64, +) -> Result<(), JwtError> { + if let Some(value) = not_before { + if !value.is_finite() { + return Err(JwtError::new("JWT: notBefore must be a number of seconds.")); + } + let nbf = timestamp + value.floor() as i64; + enforce_claim(claims, "nbf", Value::from(nbf)) + } else if let Some(existing) = claims.get("nbf") { + ensure_numeric(existing, "nbf") + } else { + Ok(()) + } +} + +fn apply_audience(claims: &mut JwtClaims, audience: Option) -> Result<(), JwtError> { + if let Some(audience) = audience { + let audiences = audience.into_vec()?; + let value = if audiences.len() == 1 { + Value::String(audiences[0].clone()) + } else { + Value::Array(audiences.into_iter().map(Value::String).collect()) + }; + enforce_claim(claims, "aud", value) + } else if let Some(existing) = claims.get("aud") { + if existing.is_array() { + normalize_audience_array(existing)?; + } else { + ensure_string(existing, "aud")?; + } + Ok(()) + } else { + Ok(()) + } +} + +fn apply_issuer(claims: &mut JwtClaims, issuer: Option) -> Result<(), JwtError> { + if let Some(issuer) = issuer { + let normalized = normalize_string(issuer, "Issuer")?; + enforce_claim(claims, "iss", Value::String(normalized)) + } else if let Some(existing) = claims.get("iss") { + ensure_string(existing, "iss") + } else { + Ok(()) + } +} + +fn apply_subject(claims: &mut JwtClaims, subject: Option) -> Result<(), JwtError> { + if let Some(subject) = subject { + let normalized = normalize_string(subject, "Subject")?; + enforce_claim(claims, "sub", Value::String(normalized)) + } else if let Some(existing) = claims.get("sub") { + ensure_string(existing, "sub") + } else { + Ok(()) + } +} + +fn enforce_claim(claims: &mut JwtClaims, key: &str, value: Value) -> Result<(), JwtError> { + match claims.get(key) { + Some(existing) if *existing != value => Err(JwtError::claim_conflict(key)), + Some(_) => Ok(()), + None => { + claims.insert(key.to_string(), value); + Ok(()) + } + } +} + +fn ensure_numeric(value: &Value, claim: &str) -> Result<(), JwtError> { + match value { + Value::Number(number) if number.as_i64().is_some() => Ok(()), + _ => Err(JwtError::new(format!( + "JWT: Claim \"{}\" must be a finite number.", + claim + ))), + } +} + +fn ensure_string(value: &Value, claim: &str) -> Result<(), JwtError> { + match value { + Value::String(text) if !text.is_empty() => Ok(()), + _ => Err(JwtError::new(format!( + "JWT: Claim \"{}\" must be a non-empty string.", + claim + ))), + } +} + +fn normalize_audience_array(value: &Value) -> Result, JwtError> { + let items = value + .as_array() + .ok_or_else(|| JwtError::new("JWT: audience must be an array of strings."))?; + if items.is_empty() { + return Err(JwtError::new("JWT: audience array must not be empty.")); + } + let mut normalized = Vec::with_capacity(items.len()); + for item in items { + let string = item + .as_str() + .ok_or_else(|| JwtError::new("JWT: audience must be an array of strings."))?; + if string.is_empty() { + return Err(JwtError::new("JWT: audience must be an array of strings.")); + } + normalized.push(string.to_string()); + } + Ok(normalized) +} + +fn enforce_allowed_claims(payload: &JwtClaims, allowed_claims: &[String]) -> Result<(), JwtError> { + let mut normalized = HashSet::new(); + for claim in allowed_claims { + let trimmed = claim.trim(); + if trimmed.is_empty() { + return Err(JwtError::new( + "JWT: allowedClaims must be an array of non-empty strings.", + )); + } + normalized.insert(trimmed.to_string()); + } + + for key in payload.keys() { + if STANDARD_CLAIMS.contains(&key.as_str()) { + continue; + } + if !normalized.contains(key) { + return Err(JwtError::new(format!( + "JWT: claim \"{}\" is not allowed.", + key + ))); + } + } + Ok(()) +} + +fn verify_signature( + algorithm: JwtAlgorithm, + secret: &str, + encoded_header: &str, + encoded_payload: &str, + encoded_signature: &str, +) -> Result<(), JwtError> { + let signing_input = format!("{}.{}", encoded_header, encoded_payload); + let provided_signature = base64url_decode(encoded_signature, "signature")?; + let expected_signature = create_signature_buffer(algorithm, secret, &signing_input)?; + + constant_time_compare(&expected_signature, &provided_signature) +} + +fn create_signature( + algorithm: JwtAlgorithm, + secret: &str, + signing_input: &str, +) -> Result { + let bytes = create_signature_buffer(algorithm, secret, signing_input)?; + Ok(base64url_encode(bytes)) +} + +fn constant_time_compare(expected: &[u8], provided: &[u8]) -> Result<(), JwtError> { + if expected.len() != provided.len() { + return Err(JwtError::new("JWT: invalid signature.")); + } + + let mut diff: u8 = 0; + for (a, b) in expected.iter().zip(provided) { + diff |= a ^ b; + } + + if diff == 0 { + Ok(()) + } else { + Err(JwtError::new("JWT: invalid signature.")) + } +} + +fn create_signature_buffer( + algorithm: JwtAlgorithm, + secret: &str, + signing_input: &str, +) -> Result, JwtError> { + match algorithm { + JwtAlgorithm::HS256 => compute_hmac_sha256(secret, signing_input), + JwtAlgorithm::HS512 => compute_hmac_sha512(secret, signing_input), + } +} + +fn compute_hmac_sha256(secret: &str, signing_input: &str) -> Result, JwtError> { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|_| JwtError::new("JWT: failed to create HMAC instance."))?; + mac.update(signing_input.as_bytes()); + Ok(mac.finalize().into_bytes().to_vec()) +} + +fn compute_hmac_sha512(secret: &str, signing_input: &str) -> Result, JwtError> { + let mut mac = HmacSha512::new_from_slice(secret.as_bytes()) + .map_err(|_| JwtError::new("JWT: failed to create HMAC instance."))?; + mac.update(signing_input.as_bytes()); + Ok(mac.finalize().into_bytes().to_vec()) +} + +fn current_timestamp(clock: Option) -> Result { + if let Some(value) = clock { + if !value.is_finite() { + return Err(JwtError::new( + "JWT: clockTimestamp must be a finite number.", + )); + } + return Ok(value.floor() as i64); + } + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| JwtError::new("JWT: system time before UNIX_EPOCH."))?; + Ok(duration.as_secs() as i64) +} + +fn normalize_number(value: f64, claim: &str) -> Result { + if !value.is_finite() { + return Err(JwtError::new(format!( + "JWT: Claim \"{}\" must be a finite number.", + claim + ))); + } + Ok(value.floor() as i64) +} + +fn normalize_string(value: String, context: &str) -> Result { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + return Err(JwtError::new(format!( + "JWT: {} must be a non-empty string.", + context + ))); + } + Ok(trimmed) +} + +fn base64url_encode>(data: T) -> String { + URL_SAFE_NO_PAD.encode(data) +} + +fn base64url_decode(input: &str, part: &str) -> Result, JwtError> { + if !input.bytes().all(|byte| BASE64URL_ALLOWED.contains(&byte)) { + return Err(JwtError::new(format!( + "JWT: invalid base64url encoding in {}.", + part + ))); + } + + let decoded = URL_SAFE_NO_PAD + .decode(input) + .map_err(|_| JwtError::new(format!("JWT: malformed base64url segment in {}.", part)))?; + + if base64url_encode(&decoded) != input.trim_end_matches('=') { + return Err(JwtError::new(format!( + "JWT: malformed base64url segment in {}.", + part + ))); + } + + Ok(decoded) +} + +fn validate_temporal_claims( + payload: &JwtClaims, + options: &VerifyJwtOptions, +) -> Result<(), JwtError> { + let now = current_timestamp(options.clock_timestamp)?; + let tolerance = match options.clock_tolerance { + Some(value) if value.is_finite() && value >= 0.0 => value, + Some(_) => { + return Err(JwtError::new( + "JWT: clockTolerance must be a non-negative number.", + )) + } + None => 0.0, + }; + let tolerance = tolerance.floor() as i64; + + if let Some(exp) = payload.get("exp") { + ensure_numeric(exp, "exp")?; + let exp_value = exp.as_i64().unwrap(); + if now > exp_value + tolerance { + return Err(JwtError::new("JWT: token expired.")); + } + } + + if let Some(nbf) = payload.get("nbf") { + ensure_numeric(nbf, "nbf")?; + let nbf_value = nbf.as_i64().unwrap(); + if now + tolerance < nbf_value { + return Err(JwtError::new("JWT: token not active yet.")); + } + } + + if let Some(iat) = payload.get("iat") { + ensure_numeric(iat, "iat")?; + let iat_value = iat.as_i64().unwrap(); + if iat_value - tolerance > now { + return Err(JwtError::new("JWT: token used before issued.")); + } + } + + if let Some(max_age) = options.max_age { + if !max_age.is_finite() || max_age <= 0.0 { + return Err(JwtError::new( + "JWT: maxAge must be a positive number of seconds.", + )); + } + let max_age = max_age.floor() as i64; + let iat = payload + .get("iat") + .and_then(Value::as_i64) + .ok_or_else(|| JwtError::new("JWT: cannot apply maxAge without an \"iat\" claim."))?; + if now - iat - tolerance > max_age { + return Err(JwtError::new("JWT: token exceeds maxAge.")); + } + } + + Ok(()) +} + +fn validate_audience(payload: &JwtClaims, options: &VerifyJwtOptions) -> Result<(), JwtError> { + if payload.get("aud").is_none() && options.audience.is_none() { + return Ok(()); + } + + let token_audience = match payload.get("aud") { + Some(value) => { + if value.is_array() { + normalize_audience_array(value)? + } else { + ensure_string(value, "aud")?; + vec![value.as_str().unwrap().to_string()] + } + } + None => return Err(JwtError::new("JWT: missing required audience claim.")), + }; + + if let Some(audience) = options.audience.clone() { + let expected = audience.into_vec()?; + if !expected + .iter() + .any(|value| token_audience.iter().any(|aud| aud == value)) + { + return Err(JwtError::new("JWT: audience mismatch.")); + } + } + + Ok(()) +} + +fn validate_issuer(payload: &JwtClaims, options: &VerifyJwtOptions) -> Result<(), JwtError> { + if payload.get("iss").is_none() && options.issuer.is_none() { + return Ok(()); + } + + let issuer = match payload.get("iss") { + Some(value) => { + ensure_string(value, "iss")?; + value.as_str().unwrap().to_string() + } + None => return Err(JwtError::new("JWT: missing required issuer claim.")), + }; + + if let Some(issuer_option) = options.issuer.clone() { + let allowed = issuer_option.into_vec()?; + if !allowed.contains(&issuer) { + return Err(JwtError::new("JWT: issuer mismatch.")); + } + } + + Ok(()) +} + +fn validate_subject(payload: &JwtClaims, options: &VerifyJwtOptions) -> Result<(), JwtError> { + if payload.get("sub").is_none() && options.subject.is_none() { + return Ok(()); + } + + let subject = match payload.get("sub") { + Some(value) => { + ensure_string(value, "sub")?; + value.as_str().unwrap().to_string() + } + None => return Err(JwtError::new("JWT: missing required subject claim.")), + }; + + if let Some(expected) = &options.subject { + let normalized = normalize_string(expected.clone(), "Subject")?; + if subject != normalized { + return Err(JwtError::new("JWT: subject mismatch.")); + } + } + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 1f83fa0..96ac1f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,27 +1,37 @@ -// Declara o módulo advanced_token_manager pub mod advanced_token_manager; +pub mod jwt; -// Reexporta o AdvancedTokenManager para facilitar o uso externo -pub use advanced_token_manager::{AdvancedTokenManager, Algorithm}; +pub use advanced_token_manager::{ + AdvancedTokenError, AdvancedTokenManager, AdvancedTokenManagerLogger, + AdvancedTokenManagerOptions, Algorithm, ManagerConfig, ManagerSignJwtOptions, + ManagerVerifyJwtOptions, TokenValidationError, ValidateTokenOptions, +}; +pub use jwt::{ + sign_jwt, verify_jwt, verify_jwt_as, Audience, Issuer, JwtAlgorithm, JwtClaims, JwtError, + SignJwtOptions, VerifyJwtOptions, +}; -/// # Exemplo de Uso: -/// ``` -/// use hash_token_rust::{AdvancedTokenManager, Algorithm}; -/// -/// let mut manager = AdvancedTokenManager::new( -/// Some("my-very-secure-key".to_string()), // Segredo com mais de 16 caracteres -/// Some(vec!["salt1".to_string(), "salt2".to_string()]), -/// Some(Algorithm::Sha256), // Algoritmo especificado corretamente -/// true, -/// true -/// ).unwrap(); -/// -/// let token = manager.generate_token("my-data", None); -/// let is_valid = manager.validate_token(&token).is_some(); -/// assert!(is_valid); -/// ``` +pub const LIBRARY_VERSION: &str = "0.2.0"; +#[cfg(test)] +mod docs { + use super::*; + #[test] + fn example_usage() { + let mut manager = AdvancedTokenManager::new( + Some("my-very-secure-key".to_string()), + Some(vec!["salt1".to_string(), "salt2".to_string()]), + Some(Algorithm::Sha256), + true, + true, + Some(AdvancedTokenManagerOptions::default()), + ) + .unwrap(); -pub const LIBRARY_VERSION: &str = "0.1.0"; + let token = manager.generate_token("my-data", None).unwrap(); + let validated = manager.validate_token(&token).unwrap(); + assert_eq!(validated, Some("my-data".to_string())); + } +} diff --git a/tests/advanced_token_manager_test.rs b/tests/advanced_token_manager_test.rs index ef8cc43..290aeef 100644 --- a/tests/advanced_token_manager_test.rs +++ b/tests/advanced_token_manager_test.rs @@ -1,149 +1,152 @@ -#[cfg(test)] -mod tests { - use base64::{engine::general_purpose, Engine as _}; - use hash_token_rust::{advanced_token_manager::Algorithm, AdvancedTokenManager}; - - use std::time::Instant; - - fn create_token_manager() -> AdvancedTokenManager { - let secret = Some("my-very-secure-key-12345".to_string()); - let salts = Some(vec![ - "salt-one".to_string(), - "salt-two".to_string(), - "salt-three".to_string(), - "salt-four".to_string(), - "salt-five".to_string(), - ]); - AdvancedTokenManager::new(secret, salts, Some(Algorithm::Sha256), true, true).unwrap() - } - - #[test] - fn generate_token() { - let mut manager: AdvancedTokenManager = create_token_manager(); - let input: &str = "sensitive-data"; - let token: String = manager.generate_token(input, None); - assert!(!token.is_empty()); - } - - #[test] - fn validate_valid_token() { - let mut manager: AdvancedTokenManager = create_token_manager(); - let input: &str = "sensitive-data"; - let token: String = manager.generate_token(input, None); - let validated: Option = manager.validate_token(&token); - assert_eq!(validated, Some(input.to_string())); - } - - #[test] - fn validate_modified_token() { - let mut manager: AdvancedTokenManager = create_token_manager(); - let input: &str = "sensitive-data"; - let mut token: String = manager.generate_token(input, None); - token.push('x'); // Modifica o token - let validated: Option = manager.validate_token(&token); - assert_eq!(validated, None); - } - - #[test] - fn unique_tokens_for_same_input() { - let mut manager: AdvancedTokenManager = create_token_manager(); - let input: &str = "sensitive-data"; - let token1: String = manager.generate_token(input, None); - let token2: String = manager.generate_token(input, None); - assert_ne!(token1, token2); // Tokens devem ser diferentes devido aos salts aleatórios - } - - #[test] - fn performance_generate_token() { - let mut manager: AdvancedTokenManager = create_token_manager(); - let input: &str = "sensitive-data"; - let iterations: i32 = 1000; - - let start: Instant = Instant::now(); - for _ in 0..iterations { - manager.generate_token(input, None); - } - let duration: std::time::Duration = start.elapsed(); - let avg_time: f64 = duration.as_secs_f64() / iterations as f64; - println!("Average time for generate_token: {:.6} ms", avg_time * 1000.0); - } - - #[test] - fn performance_validate_token() { - let mut manager: AdvancedTokenManager = create_token_manager(); - let input: &str = "sensitive-data"; - let token: String = manager.generate_token(input, None); - let iterations: i32 = 1000; - - let start: Instant = Instant::now(); - for _ in 0..iterations { - manager.validate_token(&token); - } - let duration: std::time::Duration = start.elapsed(); - let avg_time: f64 = duration.as_secs_f64() / iterations as f64; - println!("Average time for validate_token: {:.6} ms", avg_time * 1000.0); - } - - #[test] - fn invalid_secret_key() { - let result: Result = AdvancedTokenManager::new(Some("".to_string()), None, Some(Algorithm::Sha256), false, true); - assert!(result.is_err()); - } - - #[test] - fn invalid_salt_array() { - let result: Result = AdvancedTokenManager::new( - Some("my-very-secure-key-12345".to_string()), - Some(vec![]), - Some(Algorithm::Sha256), - false, - true, - ); - assert!(result.is_err()); - } - - #[test] - fn generate_token_with_forced_salt_index() { - let mut manager: AdvancedTokenManager = create_token_manager(); - let input: &str = "sensitive-data"; - let forced_salt_index = 2; - - let token: String = manager.generate_token(input, Some(forced_salt_index)); - - let decoded_token: Vec = general_purpose::STANDARD.decode(&token).unwrap(); - let token_str: String = String::from_utf8(decoded_token).unwrap(); - let parts: Vec<&str> = token_str.split('|').collect(); - - assert_eq!(parts[1].parse::().unwrap(), forced_salt_index); - } - - #[test] - fn validate_token_with_invalid_salt_index() { - let mut manager: AdvancedTokenManager = create_token_manager(); - let input: &str = "sensitive-data"; - let token: String = manager.generate_token(input, None); - - let decoded_token: Vec = general_purpose::STANDARD.decode(&token).unwrap(); - let token_str: String = String::from_utf8(decoded_token).unwrap(); - let mut parts: Vec<&str> = token_str.split('|').collect(); - - parts[1] = "100"; // Altera o índice do salt para um inválido - let tampered_token: String = general_purpose::STANDARD.encode(parts.join("|")); - assert_eq!(manager.validate_token(&tampered_token), None); - } - - #[test] - fn detect_tokens_with_tampered_checksum() { - let mut manager: AdvancedTokenManager = create_token_manager(); - let input: &str = "sensitive-data"; - let token: String = manager.generate_token(input, None); - - let decoded_token: Vec = general_purpose::STANDARD.decode(&token).unwrap(); - let token_str: String = String::from_utf8(decoded_token).unwrap(); - let mut parts: Vec<&str> = token_str.split('|').collect(); - - parts[2] = "tampered_checksum"; // Modifica o checksum - let tampered_token: String = general_purpose::STANDARD.encode(parts.join("|")); - assert_eq!(manager.validate_token(&tampered_token), None); - } +use base64::Engine; +use hash_token_rust::advanced_token_manager::{ + AdvancedTokenManager, AdvancedTokenManagerOptions, Algorithm, ManagerSignJwtOptions, + ManagerVerifyJwtOptions, ValidateTokenOptions, +}; +use hash_token_rust::jwt::{Audience, JwtAlgorithm, JwtClaims}; + +fn manager() -> AdvancedTokenManager { + AdvancedTokenManager::new( + Some("averysecuresecretvalue".to_string()), + Some(vec![ + "alpha".to_string(), + "beta".to_string(), + "gamma".to_string(), + ]), + Some(Algorithm::Sha256), + true, + true, + Some(AdvancedTokenManagerOptions::default()), + ) + .unwrap() +} + +#[test] +fn generate_and_validate_token() { + let mut manager = manager(); + let token = manager.generate_token("payload-data", None).unwrap(); + let result = manager.validate_token(&token).unwrap(); + assert_eq!(result, Some("payload-data".to_string())); +} + +#[test] +fn validate_token_lenient_failure_returns_none() { + let mut manager = manager(); + let token = manager.generate_token("payload", None).unwrap(); + let tampered = format!("{}x", token); + assert!(manager.validate_token(&tampered).unwrap().is_none()); + assert!(manager.validate_token_lenient(&tampered).is_none()); +} + +#[test] +fn validate_token_throws_when_configured() { + let mut manager = AdvancedTokenManager::new( + Some("averysecuresecretvalue".to_string()), + Some(vec!["salt-a".into(), "salt-b".into()]), + Some(Algorithm::Sha256), + true, + true, + Some(AdvancedTokenManagerOptions { + throw_on_validation_failure: Some(true), + ..Default::default() + }), + ) + .unwrap(); + + let token = manager.generate_token("payload", None).unwrap(); + let broken = format!("{}x", token); + let err = manager.validate_token(&broken).unwrap_err(); + let message = err.to_string(); + assert!( + message.contains("Checksum mismatch") || message.contains("Invalid base64 token"), + "unexpected error message: {}", + message + ); +} + +#[test] +fn generate_token_with_explicit_salt_index() { + let mut manager = manager(); + let token = manager.generate_token("payload", Some(1)).unwrap(); + let decoded = base64::engine::general_purpose::STANDARD + .decode(token) + .unwrap(); + let text = String::from_utf8(decoded).unwrap(); + let parts: Vec<&str> = text.split('|').collect(); + assert_eq!(parts[1], "1"); +} + +#[test] +fn manager_generates_and_validates_jwt() { + let manager = manager(); + let mut claims: JwtClaims = JwtClaims::new(); + claims.insert("sub".to_string(), "user-123".into()); + claims.insert("role".to_string(), "admin".into()); + + let token = manager + .generate_jwt(&claims, Some(ManagerSignJwtOptions::default())) + .unwrap(); + + let verified: JwtClaims = manager + .validate_jwt::(&token, Some(ManagerVerifyJwtOptions::default())) + .unwrap(); + + assert_eq!(verified.get("sub").unwrap(), "user-123"); + assert_eq!(verified.get("role").unwrap(), "admin"); +} + +#[test] +fn manager_applies_default_jwt_algorithms() { + let mut options = AdvancedTokenManagerOptions::default(); + options.jwt_default_algorithms = Some(vec![JwtAlgorithm::HS256]); + let manager = AdvancedTokenManager::new( + Some("averysecuresecretvalue".to_string()), + Some(vec!["salt-a".into(), "salt-b".into()]), + Some(Algorithm::Sha256), + true, + true, + Some(options.clone()), + ) + .unwrap(); + + let mut claims: JwtClaims = JwtClaims::new(); + claims.insert("sub".to_string(), "user-123".into()); + let token = manager.generate_jwt(&claims, None).unwrap(); + + let mut verify_options = ManagerVerifyJwtOptions::default(); + verify_options.algorithms = Some(vec![JwtAlgorithm::HS256]); + manager + .validate_jwt::(&token, Some(verify_options)) + .unwrap(); +} + +#[test] +fn validate_token_with_options_no_throw() { + let mut manager = manager(); + let token = manager.generate_token("payload", None).unwrap(); + let tampered = format!("{}x", token); + let result = manager + .validate_token_with_options( + &tampered, + Some(ValidateTokenOptions { + throw_on_failure: Some(false), + }), + ) + .unwrap(); + assert!(result.is_none()); +} + +#[test] +fn configure_audience_for_jwt_verification() { + let manager = manager(); + let mut claims: JwtClaims = JwtClaims::new(); + claims.insert("sub".to_string(), "user-123".into()); + claims.insert("aud".to_string(), "service-a".into()); + + let token = manager.generate_jwt(&claims, None).unwrap(); + + let mut verify_options = ManagerVerifyJwtOptions::default(); + verify_options.audience = Some(Audience::Single("service-a".into())); + let validated: JwtClaims = manager.validate_jwt(&token, Some(verify_options)).unwrap(); + assert_eq!(validated.get("sub").unwrap(), "user-123"); } diff --git a/tests/jwt_test.rs b/tests/jwt_test.rs new file mode 100644 index 0000000..80384b4 --- /dev/null +++ b/tests/jwt_test.rs @@ -0,0 +1,194 @@ +use hash_token_rust::jwt::{ + sign_jwt, verify_jwt, verify_jwt_as, Audience, Issuer, JwtAlgorithm, JwtClaims, SignJwtOptions, + VerifyJwtOptions, +}; + +#[test] +fn sign_and_verify_jwt() { + let mut payload = JwtClaims::new(); + payload.insert("sub".to_string(), "user-123".into()); + payload.insert("aud".to_string(), "service".into()); + + let token = sign_jwt( + &payload, + &SignJwtOptions { + secret: "secret-value".to_string(), + algorithm: Some(JwtAlgorithm::HS512), + ..Default::default() + }, + ) + .unwrap(); + + let verified = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + algorithms: Some(vec![JwtAlgorithm::HS512]), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(verified.get("sub").unwrap(), "user-123"); +} + +#[test] +fn verify_rejects_invalid_signature() { + let mut payload = JwtClaims::new(); + payload.insert("sub".to_string(), "user-123".into()); + let token = sign_jwt( + &payload, + &SignJwtOptions { + secret: "secret-value".to_string(), + ..Default::default() + }, + ) + .unwrap(); + + let tampered = format!("{}tampered", token); + let err = verify_jwt( + &tampered, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + ..Default::default() + }, + ) + .unwrap_err(); + let message = err.to_string(); + assert!( + message.contains("invalid signature") + || message.contains("invalid token structure") + || message.contains("malformed base64url"), + "unexpected error message: {}", + message + ); + + let err = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "wrong-secret".to_string(), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("invalid signature")); +} + +#[test] +fn verify_enforces_audience_and_issuer() { + let mut payload = JwtClaims::new(); + payload.insert("sub".to_string(), "user-123".into()); + payload.insert("aud".to_string(), "service-a".into()); + payload.insert("iss".to_string(), "issuer-a".into()); + + let token = sign_jwt( + &payload, + &SignJwtOptions { + secret: "secret-value".to_string(), + ..Default::default() + }, + ) + .unwrap(); + + let err = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + audience: Some(Audience::Single("other".into())), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("audience mismatch")); + + let err = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + issuer: Some(Issuer::Single("other".into())), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("issuer mismatch")); +} + +#[test] +fn verify_rejects_alg_none() { + let token = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjMifQ.c2ln"; + let err = verify_jwt( + token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("alg \"none\"")); +} + +#[test] +fn verify_rejects_disallowed_claims() { + let mut payload = JwtClaims::new(); + payload.insert("sub".to_string(), "user-123".into()); + payload.insert("custom".to_string(), 42.into()); + + let token = sign_jwt( + &payload, + &SignJwtOptions { + secret: "secret-value".to_string(), + ..Default::default() + }, + ) + .unwrap(); + + let err = verify_jwt( + &token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + allowed_claims: Some(vec!["other".into()]), + ..Default::default() + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("is not allowed")); +} + +#[test] +fn deserialize_verified_payload() { + #[derive(serde::Deserialize, Debug, PartialEq)] + struct Claims { + sub: String, + role: String, + } + + let mut payload = JwtClaims::new(); + payload.insert("sub".to_string(), "user-123".into()); + payload.insert("role".to_string(), "admin".into()); + + let token = sign_jwt( + &payload, + &SignJwtOptions { + secret: "secret-value".to_string(), + ..Default::default() + }, + ) + .unwrap(); + + let claims: Claims = verify_jwt_as( + &token, + &VerifyJwtOptions { + secret: "secret-value".to_string(), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!( + claims, + Claims { + sub: "user-123".into(), + role: "admin".into() + } + ); +}