using System; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using Microsoft.IdentityModel.Tokens; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace GermanApp.Tests.Unit.Infrastructure.Services; /// /// Tests JWT token generation and validation to verify the authentication fix /// [TestClass] public class JwtTokenValidationTests { private const string TestJwtKey = "test-secret-key-at-least-32-characters-long"; private const string TestJwtIssuer = "DeutschLernen"; private const string TestJwtAudience = "DeutschLernen"; [TestMethod] [Description("Tests that JWT sub claim can be found after validation (reproduces the bug and verifies the fix)")] public void JWT_SubClaim_Mapping_Bug_Reproduction() { // This test reproduces the exact bug that was causing /me to return 401 // Arrange: Generate a JWT token with "sub" claim (what AuthService.GenerateJwtToken does) var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(TestJwtKey)); var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, "123"), // JWT standard: "sub" new Claim(JwtRegisteredClaimNames.Name, "testuser"), new Claim(ClaimTypes.Role, "User") }; var token = new JwtSecurityToken( issuer: TestJwtIssuer, audience: TestJwtAudience, claims: claims, expires: DateTime.UtcNow.AddHours(24), signingCredentials: credentials ); var tokenString = new JwtSecurityTokenHandler().WriteToken(token); // Act: Validate the token (what ASP.NET Core JWT middleware does) var validationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = TestJwtIssuer, ValidAudience = TestJwtAudience, IssuerSigningKey = securityKey, ClockSkew = TimeSpan.FromMinutes(5) }; var tokenHandler = new JwtSecurityTokenHandler(); SecurityToken validatedToken; var principal = tokenHandler.ValidateToken(tokenString, validationParameters, out validatedToken); // Assert: This is what AuthController.GetCurrentUser() does // BEFORE THE FIX: User.FindFirst("sub") returned null because JWT middleware maps "sub" to ClaimTypes.NameIdentifier // AFTER THE FIX: We use ClaimTypes.NameIdentifier to find the mapped claim // The WRONG way (what was causing the bug): var subClaimByString = principal.FindFirst("sub"); // The RIGHT way (the fix): var subClaimByNameIdentifier = principal.FindFirst(ClaimTypes.NameIdentifier); // Verify the problem exists (for documentation): Assert.IsNull(subClaimByString, "This SHOULD be null - JWT middleware maps 'sub' to NameIdentifier"); // Verify the fix works: Assert.IsNotNull(subClaimByNameIdentifier, "This SHOULD NOT be null - using ClaimTypes.NameIdentifier"); Assert.AreEqual("123", subClaimByNameIdentifier.Value); // Also verify the claim type is the full URI: Assert.AreEqual( "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", subClaimByNameIdentifier.Type ); } [TestMethod] [Description("Tests that User.FindFirst(ClaimTypes.NameIdentifier) works for getting user ID")] public void JWT_UserId_Extraction_Works() { // Simulates the exact flow: login -> token generated -> /me endpoint called // Arrange: Generate token (AuthService.GenerateJwtToken) var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(TestJwtKey)); var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); var userId = 42; var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), new Claim(JwtRegisteredClaimNames.Name, "testuser"), new Claim(ClaimTypes.Role, "User") }; var token = new JwtSecurityToken( issuer: TestJwtIssuer, audience: TestJwtAudience, claims: claims, expires: DateTime.UtcNow.AddHours(24), signingCredentials: credentials ); var tokenString = new JwtSecurityTokenHandler().WriteToken(token); // Act: Validate token (JWT middleware) var validationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = TestJwtIssuer, ValidAudience = TestJwtAudience, IssuerSigningKey = securityKey, ClockSkew = TimeSpan.FromMinutes(5) }; var tokenHandler = new JwtSecurityTokenHandler(); SecurityToken validatedToken; var principal = tokenHandler.ValidateToken(tokenString, validationParameters, out validatedToken); // Act: Extract user ID (AuthController.GetCurrentUser) // This is the FIX - use ClaimTypes.NameIdentifier instead of "sub" var userIdClaim = principal.FindFirst(ClaimTypes.NameIdentifier) ?? principal.FindFirst("sub"); // Assert Assert.IsNotNull(userIdClaim, "User ID claim should be found"); Assert.AreEqual(userId.ToString(), userIdClaim.Value); Assert.AreEqual("42", userIdClaim.Value); } [TestMethod] [Description("Tests fallback: if ClaimTypes.NameIdentifier is null, try 'sub' string")] public void JWT_Fallback_To_Sub_String_Works() { // This tests the fallback logic in case the mapping doesn't happen // Arrange: Generate token var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(TestJwtKey)); var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); var claims = new[] { new Claim(JwtRegisteredClaimNames.Sub, "123"), new Claim(JwtRegisteredClaimNames.Name, "testuser"), new Claim(ClaimTypes.Role, "User") }; var token = new JwtSecurityToken( issuer: TestJwtIssuer, audience: TestJwtAudience, claims: claims, expires: DateTime.UtcNow.AddHours(24), signingCredentials: credentials ); var tokenString = new JwtSecurityTokenHandler().WriteToken(token); // Act: Validate var validationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = TestJwtIssuer, ValidAudience = TestJwtAudience, IssuerSigningKey = securityKey, ClockSkew = TimeSpan.FromMinutes(5) }; var tokenHandler = new JwtSecurityTokenHandler(); SecurityToken validatedToken; var principal = tokenHandler.ValidateToken(tokenString, validationParameters, out validatedToken); // Act: Extract user ID with fallback var userIdClaim = principal.FindFirst(ClaimTypes.NameIdentifier) ?? principal.FindFirst("sub"); // Assert: Should find it with ClaimTypes.NameIdentifier Assert.IsNotNull(userIdClaim); Assert.AreEqual("123", userIdClaim.Value); // Also verify that the fallback ("sub") would also work if NameIdentifier is not found // (though in practice, NameIdentifier should always be found when JWT has "sub") var subClaim = principal.FindFirst("sub"); // This will be null because of the JWT middleware mapping, but the fallback logic handles it // The actual fix is using ClaimTypes.NameIdentifier first } }