feat(auth): add Keycloak JWT authentication and role-based authorization
- Configure JWT Bearer authentication with Keycloak realm integration - Create ClubRoleClaimsTransformation to parse 'clubs' claim and add ASP.NET roles - Add authorization policies: RequireAdmin, RequireManager, RequireMember, RequireViewer - Add health check endpoints (/health/live, /health/ready, /health/startup) - Add integration tests for authorization (TDD approach - tests written first) - Configure middleware order: Authentication → MultiTenant → Authorization - Add Keycloak configuration to appsettings.Development.json - Add AspNetCore.HealthChecks.NpgSql v9.0.0 package TDD Verification: - Tests initially FAILED (expected before implementation) ✓ - Implementation complete but blocked by Task 8 Infrastructure errors - Cannot verify tests PASS until Finbuckle.MultiTenant types resolve Security Notes: - RequireHttpsMetadata=false for dev only (MUST be true in production) - Claims transformation maps Keycloak roles (lowercase) to ASP.NET roles (PascalCase) - Health endpoints are public by default (no authentication required) Blockers: - Infrastructure project has Finbuckle.MultiTenant type resolution errors from Task 8 - Tests cannot execute until TenantProvider compilation errors are fixed
This commit is contained in:
134
backend/WorkClub.Tests.Integration/Auth/AuthorizationTests.cs
Normal file
134
backend/WorkClub.Tests.Integration/Auth/AuthorizationTests.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace WorkClub.Tests.Integration.Auth;
|
||||
|
||||
public class AuthorizationTests : IClassFixture<WebApplicationFactory<Program>>
|
||||
{
|
||||
private readonly WebApplicationFactory<Program> _factory;
|
||||
|
||||
public AuthorizationTests(WebApplicationFactory<Program> factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminCanAccessAdminEndpoints_Returns200()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
var token = CreateTestJwtToken("admin@test.com", "club-1", "admin");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "club-1");
|
||||
|
||||
// Act - using health endpoint as placeholder for admin endpoint
|
||||
var response = await client.GetAsync("/health/ready");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MemberCannotAccessAdminEndpoints_Returns403()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
var token = CreateTestJwtToken("member@test.com", "club-1", "member");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "club-1");
|
||||
|
||||
// Act - This will need actual admin endpoint in future (placeholder for now)
|
||||
var response = await client.GetAsync("/admin/test");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ViewerCanOnlyRead_PostReturns403()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
var token = CreateTestJwtToken("viewer@test.com", "club-1", "viewer");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "club-1");
|
||||
|
||||
// Act - Placeholder for actual POST endpoint
|
||||
var content = new StringContent("{}", Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync("/api/tasks", content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnauthenticatedUser_Returns401()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
// No Authorization header
|
||||
|
||||
// Act
|
||||
var response = await client.GetAsync("/api/tasks");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthEndpointsArePublic_NoAuthRequired()
|
||||
{
|
||||
// Arrange
|
||||
var client = _factory.CreateClient();
|
||||
// No Authorization header
|
||||
|
||||
// Act
|
||||
var liveResponse = await client.GetAsync("/health/live");
|
||||
var readyResponse = await client.GetAsync("/health/ready");
|
||||
var startupResponse = await client.GetAsync("/health/startup");
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, liveResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, readyResponse.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.OK, startupResponse.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test JWT token with specified user, club, and role
|
||||
/// </summary>
|
||||
private string CreateTestJwtToken(string username, string clubId, string role)
|
||||
{
|
||||
var clubsDict = new Dictionary<string, string>
|
||||
{
|
||||
[clubId] = role
|
||||
};
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, username),
|
||||
new Claim(JwtRegisteredClaimNames.Email, username),
|
||||
new Claim("clubs", JsonSerializer.Serialize(clubsDict)),
|
||||
new Claim(JwtRegisteredClaimNames.Aud, "workclub-api"),
|
||||
new Claim(JwtRegisteredClaimNames.Iss, "http://localhost:8080/realms/workclub")
|
||||
};
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("test-secret-key-must-be-at-least-32-chars-long-for-hmac-sha256"));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: "http://localhost:8080/realms/workclub",
|
||||
audience: "workclub-api",
|
||||
claims: claims,
|
||||
expires: DateTime.UtcNow.AddHours(1),
|
||||
signingCredentials: credentials
|
||||
);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user