All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
PROBLEM:
- Login returns JWT token with 'sub' claim
- /me endpoint tries to read user ID from JWT
- Gets 401 Unauthorized because user ID claim cannot be found
ROOT CAUSE:
ASP.NET Core JWT middleware automatically maps JWT standard claims to .NET claim types:
- JwtRegisteredClaimNames.Sub ('sub') -> ClaimTypes.NameIdentifier ('http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier')
Controllers were looking for 'sub' or 'nameid' but JWT middleware creates the claim with the full URI.
SOLUTION:
Updated all controllers to use ClaimTypes.NameIdentifier with fallback to 'sub':
- AuthController.GetCurrentUser()
- AdminController.DeleteUserAsync()
- StoryController.GetUserId()
This ensures the user ID can be found regardless of how the JWT middleware maps the claims.
CHANGES:
- AuthService: Generates JWT tokens with JwtRegisteredClaimNames.Sub (JWT standard)
- AuthController: Uses ClaimTypes.NameIdentifier ?? 'sub' fallback
- AdminController: Uses ClaimTypes.NameIdentifier ?? 'sub' fallback
- StoryController: Uses ClaimTypes.NameIdentifier ?? 'sub' fallback
- LessonsEndpoints.cs: Added .RequireAuthorization() to all GET endpoints
- docs/features/admin-module.md: Updated acceptance criteria and requirements
- Added unit tests in JwtTokenValidationTests.cs to verify the fix
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
200 lines
8.2 KiB
C#
200 lines
8.2 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Tests JWT token generation and validation to verify the authentication fix
|
|
/// </summary>
|
|
[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
|
|
}
|
|
}
|