using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using FluentAssertions; using FsCheck; using FsCheck.Xunit; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using SchotzIadKmTracker.Api.Configuration; using SchotzIadKmTracker.Api.Models.Entities; using SchotzIadKmTracker.Api.Services; namespace SchotzIadKmTracker.Tests.Auth; /// /// Propriétés de correction du service JWT (feature authentification). /// Couvre l'émission/validation des jetons (Property 2), le rejet des jetons expirés /// (Property 3) et le rejet des signatures invalides (Property 4). /// public class JwtServiceProperties { // Clé de signature >= 32 octets requise pour HS256 (sinon SecurityTokenEncryptionKeySizeException). private const string CleSecrete = "clef-secrete-de-test-suffisamment-longue-0123456789"; private const string Issuer = "SchotzIadKmTracker"; private const string Audience = "SchotzIadKmTracker"; /// Construit un JwtService réel avec la durée d'expiration fournie. private static JwtService CreerService(int expirationMinutes) { var options = Options.Create(new JwtOptions { SecretKey = CleSecrete, Issuer = Issuer, Audience = Audience, ExpirationMinutes = expirationMinutes }); return new JwtService(options); } /// Paramètres de validation alignés sur ceux du pipeline (Program.cs). private static TokenValidationParameters CreerParametresValidation(string cleSecrete = CleSecrete) => new() { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = Issuer, ValidAudience = Audience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(cleSecrete)), ClockSkew = TimeSpan.FromSeconds(30) }; /// Générateur d'ExpirationMinutes borné (1..100000) pour éviter les valeurs absurdes. private static Arbitrary ExpirationMinutesArb() => Gen.Choose(1, 100_000).ToArbitrary(); /// Générateur d'emails non vides (bornés) pour les comptes de test. private static Gen EmailGen() => from local in Gen.Elements("alice", "bob", "carol", "utilisateur", "compte.unique") from domaine in Gen.Elements("example.com", "test.fr", "mail.org") select $"{local}@{domaine}"; // Feature: authentification, Property 2: Pour toute valeur d'ExpirationMinutes positive et tout // compte, le jeton émis est validable avec les paramètres de validation, préserve les claims // sub et email, et son exp vaut iat + ExpirationMinutes (à la tolérance d'horloge près). // Validates: Requirements 2.1, 2.4, 5.1, 5.4, 5.5 [Property(MaxTest = 100)] public Property Le_jeton_emis_est_validable_et_preserve_les_claims() { var arb = Arb.From( from expiration in ExpirationMinutesArb().Generator from id in Gen.Choose(1, 1_000_000) from email in EmailGen() select (expiration, id, email)); return Prop.ForAll(arb, tuple => { var (expirationMinutes, id, email) = tuple; var service = CreerService(expirationMinutes); var compte = new Compte { Id = id, Email = email }; var jeton = service.GenererJeton(compte); // Ne pas remapper les noms de claims entrants (sub reste "sub", email reste "email"). var handler = new JwtSecurityTokenHandler { MapInboundClaims = false }; handler.ValidateToken(jeton, CreerParametresValidation(), out var validated); var jwt = (JwtSecurityToken)validated; // Claims sub et email préservés (lecture sur le jeton validé, noms d'origine). var sub = jwt.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Sub)?.Value; var emailClaim = jwt.Claims.FirstOrDefault(c => c.Type == JwtRegisteredClaimNames.Email)?.Value; sub.Should().Be(id.ToString()); emailClaim.Should().Be(email); // exp ≈ iat + ExpirationMinutes (tolérance de 5 secondes pour l'écart d'exécution). var ecart = jwt.ValidTo - jwt.ValidFrom; ecart.Should().BeCloseTo(TimeSpan.FromMinutes(expirationMinutes), TimeSpan.FromSeconds(5)); }); } // Feature: authentification, Property 3: Pour tout jeton dont la date d'expiration est antérieure // à l'instant courant, la validation échoue (SecurityTokenExpiredException). // Validates: Requirements 4.3, 5.2 [Property(MaxTest = 100)] public Property Un_jeton_expire_est_rejete() { // On fabrique un jeton déjà expiré via le handler avec la même clé. // minutesEcoulees > ClockSkew (30 s) pour dépasser la tolérance d'horloge. var arb = Gen.Choose(2, 10_000).ToArbitrary(); return Prop.ForAll(arb, minutesEcoulees => { var maintenant = DateTime.UtcNow; var cle = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(CleSecrete)); var credentials = new SigningCredentials(cle, SecurityAlgorithms.HmacSha256); var jetonExpire = new JwtSecurityToken( issuer: Issuer, audience: Audience, claims: new[] { new Claim(JwtRegisteredClaimNames.Sub, "1") }, notBefore: maintenant.AddMinutes(-minutesEcoulees - 1), expires: maintenant.AddMinutes(-minutesEcoulees), signingCredentials: credentials); var jeton = new JwtSecurityTokenHandler().WriteToken(jetonExpire); var handler = new JwtSecurityTokenHandler(); Action validation = () => handler.ValidateToken(jeton, CreerParametresValidation(), out _); validation.Should().Throw(); }); } // Feature: authentification, Property 4: Pour tout jeton signé avec une clé différente de la clé // de validation, ou dont la charge utile a été altérée, la validation échoue. // Validates: Requirements 5.3, 4.2 [Property(MaxTest = 100)] public Property Une_signature_invalide_est_rejetee() { // Deux cas : (a) jeton signé avec une autre clé, (b) charge utile altérée. var arb = Arb.From( from id in Gen.Choose(1, 1_000_000) from email in EmailGen() from altererCharge in Gen.Elements(true, false) select (id, email, altererCharge)); return Prop.ForAll(arb, tuple => { var (id, email, altererCharge) = tuple; var handler = new JwtSecurityTokenHandler(); var parametres = CreerParametresValidation(); string jeton; if (altererCharge) { // Jeton valide dont on altère la charge utile (segment central). var service = CreerService(720); var valide = service.GenererJeton(new Compte { Id = id, Email = email }); var segments = valide.Split('.'); // Inverser deux caractères de la charge (payload) pour casser la signature. var charge = segments[1].ToCharArray(); if (charge.Length >= 2) { (charge[0], charge[1]) = (charge[1], charge[0]); } else { charge = (segments[1] + "X").ToCharArray(); } segments[1] = new string(charge); jeton = string.Join('.', segments); } else { // Jeton signé avec une clé DIFFÉRENTE (>= 32 octets) de celle de validation. const string autreCle = "AUTRE-clef-secrete-differente-mais-assez-longue-9876543210"; var cle = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(autreCle)); var credentials = new SigningCredentials(cle, SecurityAlgorithms.HmacSha256); var maintenant = DateTime.UtcNow; var jwt = new JwtSecurityToken( issuer: Issuer, audience: Audience, claims: new[] { new Claim(JwtRegisteredClaimNames.Sub, id.ToString()), new Claim(JwtRegisteredClaimNames.Email, email) }, notBefore: maintenant, expires: maintenant.AddMinutes(720), signingCredentials: credentials); jeton = new JwtSecurityTokenHandler().WriteToken(jwt); } // La validation doit échouer : soit une exception de signature (SecurityTokenException, // p. ex. SecurityTokenInvalidSignatureException / SecurityTokenSignatureKeyNotFoundException), // soit un rejet au décodage lorsque la charge altérée n'est plus décodable (ArgumentException). Exception? levee = null; try { handler.ValidateToken(jeton, parametres, out _); } catch (Exception ex) { levee = ex; } levee.Should().NotBeNull("un jeton à signature invalide ou charge altérée doit être rejeté"); levee.Should().Match(e => e is SecurityTokenException || e is ArgumentException); }); } }