diff --git a/GermanApp/Presentation/Controllers/AdminController.cs b/GermanApp/Presentation/Controllers/AdminController.cs
index 1f25f66..cc46731 100644
--- a/GermanApp/Presentation/Controllers/AdminController.cs
+++ b/GermanApp/Presentation/Controllers/AdminController.cs
@@ -116,8 +116,9 @@ public class AdminController : ControllerBase
{
try
{
- // Get admin user ID from JWT "sub" claim (standard JWT claim for subject/user ID)
- var adminUserIdClaim = User.FindFirst(JwtRegisteredClaimNames.Sub) ?? User.FindFirst("sub");
+ // Get admin user ID from JWT claim
+ // Note: JWT "sub" claim is mapped by ASP.NET Core JWT middleware to ClaimTypes.NameIdentifier
+ var adminUserIdClaim = User.FindFirst(ClaimTypes.NameIdentifier) ?? User.FindFirst("sub");
if (adminUserIdClaim == null || !int.TryParse(adminUserIdClaim.Value, out var adminUserId) || adminUserId == 0)
return Unauthorized();
diff --git a/GermanApp/Presentation/Controllers/AuthController.cs b/GermanApp/Presentation/Controllers/AuthController.cs
index c8c8a3f..5bcffb7 100644
--- a/GermanApp/Presentation/Controllers/AuthController.cs
+++ b/GermanApp/Presentation/Controllers/AuthController.cs
@@ -87,8 +87,10 @@ public class AuthController : ControllerBase
{
try
{
- // Get user ID from JWT "sub" claim (standard JWT claim for subject/user ID)
- var userIdClaim = User.FindFirst(JwtRegisteredClaimNames.Sub) ?? User.FindFirst("sub");
+ // Get user ID from JWT claim
+ // Note: JWT "sub" claim is mapped by ASP.NET Core JWT middleware to ClaimTypes.NameIdentifier
+ // (http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier)
+ var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier) ?? User.FindFirst("sub");
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId) || userId == 0)
return Unauthorized();
diff --git a/GermanApp/Presentation/Controllers/StoryController.cs b/GermanApp/Presentation/Controllers/StoryController.cs
index 054d37a..c0b673d 100644
--- a/GermanApp/Presentation/Controllers/StoryController.cs
+++ b/GermanApp/Presentation/Controllers/StoryController.cs
@@ -1,4 +1,5 @@
using System.Collections.Generic;
+using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using GermanApp.Application.DTOs;
@@ -456,7 +457,8 @@ public class StoryController : ControllerBase
/// The user ID
private int GetUserId()
{
- var userIdClaim = User.FindFirst("sub") ?? User.FindFirst("nameidentifier");
+ // Note: JWT "sub" claim is mapped by ASP.NET Core JWT middleware to ClaimTypes.NameIdentifier
+ var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier) ?? User.FindFirst("sub");
if (userIdClaim == null || !int.TryParse(userIdClaim.Value, out var userId))
{
diff --git a/Tests/Unit/Infrastructure/Services/JwtTokenValidationTests.cs b/Tests/Unit/Infrastructure/Services/JwtTokenValidationTests.cs
new file mode 100644
index 0000000..bd4bf6f
--- /dev/null
+++ b/Tests/Unit/Infrastructure/Services/JwtTokenValidationTests.cs
@@ -0,0 +1,200 @@
+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
+ }
+}