.NET Microservices - Authentication Architecture & Integration Testing¶
Overview¶
This document covers the architectural approach for isolating authentication into a separate microservice in .NET applications, implementing JWT-based authentication with .NET Identity Core, and comprehensive integration testing strategies using WebApplicationFactory.
Part 1: Authentication Microservice Architecture¶
Why Isolate Authentication?¶
Benefits: - Separation of Concerns - Authentication logic centralized in one service - Reusability - Multiple services leverage same authentication without code duplication - Scalability - Scale auth service independently based on login traffic patterns - Security - Sensitive operations (password hashing, token generation) contained in dedicated, highly-secured service - Maintainability - Updates to security protocols happen in one place
Authentication Flow Architecture¶
┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Client │────────▶│ Auth Service │ │ Orders Service │
│ (Web/Mobile)│ │ (Identity Core) │ │ (Consumer API) │
└─────────────┘ └──────────────────┘ └─────────────────┘
│ │ │
│ 1. Login Request │ │
│─────────────────────────▶│ │
│ │ │
│ 2. JWT Token │ │
│◀─────────────────────────│ │
│ │ │
│ 3. API Request + Token │ │
│──────────────────────────────────────────────────────▶│
│ │ │
│ │ 4. Validate JWT Token │
│ │ (No DB call needed) │
│ │ │
│ 5. Response │ │
│◀──────────────────────────────────────────────────────│
Key Architectural Components¶
1. Authentication Service (Isolated) - User registration and management - Password validation and hashing (via Identity Core) - JWT token generation (access + refresh tokens) - Token validation endpoints - Role management
2. Consumer Services (Orders, Products, etc.) - JWT token validation (no database needed) - Role-based authorization - Claims extraction from tokens - No direct user management
3. Shared Configuration - JWT Secret Key (must be identical across all services) - Issuer and Audience settings - Token expiry configuration
Part 2: Implementation with .NET Identity Core¶
Authentication Service Components¶
1. User Model with Identity Core¶
public class ApplicationUser : IdentityUser
{
public string? FirstName { get; set; }
public string? LastName { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
2. JWT Settings Configuration¶
{
"JwtSettings": {
"SecretKey": "YourSuperSecretKeyThatIsAtLeast32CharactersLong!",
"Issuer": "YourAuthService",
"Audience": "YourMicroservices",
"ExpiryMinutes": 60,
"RefreshTokenExpiryDays": 7
}
}
3. Identity Core Setup¶
// Configure Identity Core
builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequiredLength = 8;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.MaxFailedAccessAttempts = 5;
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
4. JWT Authentication Setup¶
// Configure JWT Authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings["Issuer"],
ValidAudience = jwtSettings["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(secretKey)),
ClockSkew = TimeSpan.Zero
};
});
Core Authentication Methods¶
User Registration¶
- Creates user via UserManager
- Hashes password automatically (Identity Core)
- Assigns default role
- Generates JWT tokens
- Returns access + refresh tokens
User Login¶
- Validates credentials via SignInManager
- Checks for account lockout
- Handles failed login attempts
- Generates JWT tokens on success
Token Generation¶
private async Task<(string AccessToken, string RefreshToken, DateTime ExpiresAt)>
GenerateTokensAsync(ApplicationUser user)
{
var roles = await _userManager.GetRolesAsync(user);
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Email, user.Email),
new Claim(ClaimTypes.Name, $"{user.FirstName} {user.LastName}"),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
foreach (var role in roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var expiresAt = DateTime.UtcNow.AddMinutes(_jwtSettings.ExpiryMinutes);
var token = new JwtSecurityToken(
issuer: _jwtSettings.Issuer,
audience: _jwtSettings.Audience,
claims: claims,
expires: expiresAt,
signingCredentials: credentials
);
return (new JwtSecurityTokenHandler().WriteToken(token), refreshToken, expiresAt);
}
Consumer Service Configuration¶
Consumer services (Orders, Products, etc.) only need JWT validation:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings["Issuer"],
ValidAudience = jwtSettings["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(secretKey)),
ClockSkew = TimeSpan.Zero
};
});
Authorization Patterns¶
1. Basic Authentication¶
[Authorize]
[HttpGet]
public IActionResult GetOrders()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var email = User.FindFirst(ClaimTypes.Email)?.Value;
// ... implementation
}
2. Role-Based Authorization¶
[Authorize(Roles = "Admin")]
[HttpGet("all")]
public IActionResult GetAllOrders()
{
// Only admins can access
}
3. Policy-Based Authorization¶
// Define policy
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("UserOrAdmin", policy =>
policy.RequireRole("User", "Admin"));
});
// Use policy
[Authorize(Policy = "UserOrAdmin")]
[HttpPost]
public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
{
// ... implementation
}
Part 3: Secure Configuration Management¶
Option 1: Azure Key Vault (Production Recommended)¶
builder.Configuration.AddAzureKeyVault(
new Uri($"https://{keyVaultName}.vault.azure.net/"),
new DefaultAzureCredential());
Option 2: Environment Variables¶
# Windows
setx JwtSettings__SecretKey "YourSuperSecretKey"
setx JwtSettings__Issuer "YourAuthService"
# Linux/Mac
export JwtSettings__SecretKey="YourSuperSecretKey"
export JwtSettings__Issuer="YourAuthService"
Option 3: Docker Secrets¶
version: '3.8'
services:
auth-service:
environment:
- JwtSettings__SecretKey=${JWT_SECRET_KEY}
secrets:
- jwt_secret
secrets:
jwt_secret:
file: ./secrets/jwt_secret.txt
Part 4: Integration Testing Strategy¶
Why Integration Testing for Microservices?¶
- Test real authentication flow end-to-end
- Validate JWT token generation and validation
- Ensure cross-service communication works
- Test authorization policies
- Verify role-based access control
WebApplicationFactory Approach¶
Key Concept: Both services run in-process during tests, making them fast and reliable while testing real authentication flow.
Test Project Structure¶
Solution/
├── src/
│ ├── AuthService/
│ ├── OrdersService/
│ └── Shared/
└── tests/
├── AuthService.IntegrationTests/
├── OrdersService.IntegrationTests/
└── Integration.Tests/ (tests both services)
Core Testing Components¶
1. WebApplicationFactory for Each Service¶
public class AuthServiceFactory : WebApplicationFactory<AuthServiceProgram>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
// Remove production DbContext
services.RemoveAll(typeof(DbContextOptions<ApplicationDbContext>));
// Add in-memory database
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseInMemoryDatabase("AuthTestDb");
});
// Seed test data
var sp = services.BuildServiceProvider();
using var scope = sp.CreateScope();
SeedTestData(scope.ServiceProvider).Wait();
});
}
}
2. AuthHelper for Token Management¶
public class AuthHelper
{
private readonly HttpClient _authClient;
public AuthHelper(HttpClient authClient)
{
_authClient = authClient;
}
public async Task<string> GetUserTokenAsync()
{
return await GetAccessTokenAsync("test@example.com", "Test@123");
}
public async Task<string> GetAdminTokenAsync()
{
return await GetAccessTokenAsync("admin@example.com", "Admin@123");
}
private async Task<string> GetAccessTokenAsync(string email, string password)
{
var response = await _authClient.PostAsJsonAsync("/api/auth/login",
new { email, password });
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
return result?.AccessToken ?? throw new Exception("Failed to get token");
}
}
3. Test Collection for Sharing Factories¶
[CollectionDefinition("Integration Tests")]
public class IntegrationTestCollection :
ICollectionFixture<AuthServiceFactory>,
ICollectionFixture<OrdersServiceFactory>
{
}
Testing Patterns¶
Pattern 1: Isolated Service Tests¶
[Collection("Integration Tests")]
public class AuthServiceTests
{
private readonly HttpClient _client;
private readonly AuthHelper _authHelper;
public AuthServiceTests(AuthServiceFactory factory)
{
_client = factory.CreateClient();
_authHelper = new AuthHelper(_client);
}
[Fact]
public async Task Login_WithValidCredentials_ReturnsToken()
{
var request = new { email = "test@example.com", password = "Test@123" };
var response = await _client.PostAsJsonAsync("/api/auth/login", request);
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
Assert.NotNull(result);
Assert.True(result.Success);
Assert.NotNull(result.AccessToken);
}
}
Pattern 2: Cross-Service Tests¶
[Collection("Integration Tests")]
public class OrdersServiceTests
{
private readonly HttpClient _ordersClient;
private readonly HttpClient _authClient;
private readonly AuthHelper _authHelper;
public OrdersServiceTests(
OrdersServiceFactory ordersFactory,
AuthServiceFactory authFactory)
{
_ordersClient = ordersFactory.CreateClient();
_authClient = authFactory.CreateClient();
_authHelper = new AuthHelper(_authClient);
}
[Fact]
public async Task GetOrders_WithValidToken_ReturnsOrders()
{
// Get token from auth service
var token = await _authHelper.GetUserTokenAsync();
// Use token in orders service
_ordersClient.AddBearerToken(token);
var response = await _ordersClient.GetAsync("/api/orders");
response.EnsureSuccessStatusCode();
}
}
Pattern 3: End-to-End Flow Tests¶
[Fact]
public async Task EndToEnd_RegisterLoginAndCreateOrder_Succeeds()
{
// Step 1: Register new user
var registerRequest = new
{
email = "e2etest@example.com",
password = "E2ETest@123",
firstName = "E2E",
lastName = "Test"
};
var registerResponse = await _authClient.PostAsJsonAsync(
"/api/auth/register", registerRequest);
registerResponse.EnsureSuccessStatusCode();
// Step 2: Login
var token = await _authHelper.GetAccessTokenAsync(
"e2etest@example.com", "E2ETest@123");
// Step 3: Create order with token
_ordersClient.AddBearerToken(token);
var orderRequest = new { product = "Laptop", amount = 1200m };
var orderResponse = await _ordersClient.PostAsJsonAsync(
"/api/orders", orderRequest);
orderResponse.EnsureSuccessStatusCode();
// Step 4: Verify we can retrieve orders
var getOrdersResponse = await _ordersClient.GetAsync("/api/orders");
getOrdersResponse.EnsureSuccessStatusCode();
}
Pattern 4: Role-Based Authorization Tests¶
[Theory]
[InlineData("User", "/api/orders", HttpStatusCode.OK)]
[InlineData("User", "/api/orders/all", HttpStatusCode.Forbidden)]
[InlineData("Admin", "/api/orders", HttpStatusCode.OK)]
[InlineData("Admin", "/api/orders/all", HttpStatusCode.OK)]
public async Task Endpoints_WithDifferentRoles_ReturnExpectedStatusCode(
string role, string endpoint, HttpStatusCode expectedStatus)
{
var token = role == "Admin"
? await _authHelper.GetAdminTokenAsync()
: await _authHelper.GetUserTokenAsync();
_ordersClient.AddBearerToken(token);
var response = await _ordersClient.GetAsync(endpoint);
Assert.Equal(expectedStatus, response.StatusCode);
}
Testing Utilities¶
TestDataBuilder - Dynamic User Creation¶
public class TestDataBuilder
{
public async Task<TestUser> CreateUserAsync(
string? email = null,
string? password = null)
{
email ??= $"user-{Guid.NewGuid()}@example.com";
password ??= "Test@123";
var response = await _authClient.PostAsJsonAsync("/api/auth/register",
new { email, password, firstName = "Test", lastName = "User" });
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
return new TestUser
{
Email = email,
Password = password,
AccessToken = result?.AccessToken ?? ""
};
}
}
HttpClient Extensions¶
public static class HttpClientExtensions
{
public static void AddBearerToken(this HttpClient client, string token)
{
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
}
public static HttpClient WithUser(this HttpClient client, TestUser user)
{
return client.AddBearerToken(user.AccessToken);
}
}
JWT Token Utilities¶
public static class JwtTokenUtility
{
public static Dictionary<string, string> DecodeTokenClaims(string token)
{
var parts = token.Split('.');
var payload = parts[1];
var jsonBytes = ParseBase64WithoutPadding(payload);
var json = Encoding.UTF8.GetString(jsonBytes);
return JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)
?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToString());
}
public static bool IsTokenExpired(string token)
{
var claims = DecodeTokenClaims(token);
if (!claims.TryGetValue("exp", out var expString)) return true;
var expDate = DateTimeOffset.FromUnixTimeSeconds(long.Parse(expString));
return expDate <= DateTimeOffset.UtcNow;
}
}
Advanced Testing Scenarios¶
Testing with Real Database (Testcontainers)¶
using Testcontainers.MsSql;
public class AuthServiceDatabaseFactory : WebApplicationFactory<Program>,
IAsyncLifetime
{
private readonly MsSqlContainer _mssqlContainer = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.RemoveAll(typeof(DbContextOptions<ApplicationDbContext>));
services.AddDbContext<ApplicationDbContext>(options =>
{
options.UseSqlServer(_mssqlContainer.GetConnectionString());
});
});
}
public async Task InitializeAsync()
{
await _mssqlContainer.StartAsync();
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
await db.Database.MigrateAsync();
}
}
Mock Authentication Handler (Bypass Real Auth)¶
public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string AuthenticationScheme = "Test";
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, "test-user-id"),
new Claim(ClaimTypes.Email, "test@example.com"),
new Claim(ClaimTypes.Role, "User")
};
var identity = new ClaimsIdentity(claims, AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, AuthenticationScheme);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
// Usage
public class OrdersServiceWithMockAuthFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureTestServices(services =>
{
services.AddAuthentication(TestAuthHandler.AuthenticationScheme)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.AuthenticationScheme, options => { });
});
}
}
Part 5: Best Practices¶
Security Best Practices¶
- Never commit JWT secret keys - Use environment variables or secret management
- Use HTTPS only - Never send tokens over HTTP
- Implement token rotation - Refresh tokens should be rotated on use
- Add rate limiting - Protect login endpoints from brute force attacks
- Log authentication events - Track failed login attempts
- Use short-lived access tokens - 15-60 minutes recommended
- Use longer-lived refresh tokens - 7-30 days recommended
- Validate tokens on critical operations - Don't just rely on JWT validation
Testing Best Practices¶
- Use In-Memory Database for fast tests - SQLite or EF InMemory for most tests
- Use Testcontainers for critical tests - Test against real database for important scenarios
- Isolate test data - Each test should create its own data
- Clean up between tests - Use fresh database for each test
- Test authentication flow separately - Unit test JWT generation/validation
- Mock external dependencies - Don't call real external APIs
- Use Test Collections - Share expensive setup across tests
- Parallel execution - Run tests in parallel when possible
- Clear test names - Use Method_Scenario_ExpectedResult naming convention
- Assert specific status codes - Don't just check IsSuccessStatusCode
Configuration Management Best Practices¶
- Centralize JWT settings - Use configuration server or secrets management
- Environment-specific configurations - Different settings for dev/staging/prod
- Rotate secrets regularly - Update JWT keys periodically
- Audit configuration access - Track who accesses secrets
- Use managed identities - When deploying to Azure/AWS
Part 6: NuGet Packages Required¶
Authentication Service¶
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package System.IdentityModel.Tokens.Jwt
Consumer Services¶
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add package System.IdentityModel.Tokens.Jwt
Integration Test Projects¶
dotnet add package Microsoft.NET.Test.Sdk
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Microsoft.AspNetCore.Mvc.Testing
dotnet add package Microsoft.EntityFrameworkCore.InMemory
dotnet add package Testcontainers
dotnet add package FluentAssertions
Part 7: Setup and Initialization¶
Database Migration (Authentication Service)¶
# Add initial migration
dotnet ef migrations add InitialCreate
# Update database
dotnet ef database update
Seed Initial Roles and Admin User¶
// Add to Program.cs before app.Run()
using (var scope = app.Services.CreateScope())
{
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<IdentityRole>>();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
// Create roles
string[] roleNames = { "Admin", "User", "Manager" };
foreach (var roleName in roleNames)
{
if (!await roleManager.RoleExistsAsync(roleName))
{
await roleManager.CreateAsync(new IdentityRole(roleName));
}
}
// Create admin user
var adminEmail = "admin@example.com";
var adminUser = await userManager.FindByEmailAsync(adminEmail);
if (adminUser == null)
{
var admin = new ApplicationUser
{
UserName = adminEmail,
Email = adminEmail,
FirstName = "Admin",
LastName = "User",
EmailConfirmed = true
};
var result = await userManager.CreateAsync(admin, "Admin@123");
if (result.Succeeded)
{
await userManager.AddToRolesAsync(admin, new[] { "Admin" });
}
}
}
Making Program.cs Testable¶
Add this at the bottom of your Program.cs files:
// Make the Program class accessible for testing
public partial class Program { }
Part 8: Testing the Flow¶
Manual Testing with curl¶
1. Register User¶
curl -X POST https://localhost:5001/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "User@123",
"firstName": "John",
"lastName": "Doe"
}'
2. Login¶
curl -X POST https://localhost:5001/api/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "user@example.com",
"password": "User@123"
}'
3. Call Protected Endpoint¶
curl -X GET https://localhost:5002/api/orders \
-H "Authorization: Bearer <your-access-token>"
4. Refresh Token¶
curl -X POST https://localhost:5001/api/auth/refresh \
-H "Content-Type: application/json" \
-d '{
"refreshToken": "<your-refresh-token>"
}'
Running Integration Tests¶
# Run all tests
dotnet test
# Run specific test project
dotnet test tests/Integration.Tests/Integration.Tests.csproj
# Run with detailed output
dotnet test --logger "console;verbosity=detailed"
# Run tests in parallel
dotnet test --parallel
# Run with code coverage
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=opencover
Common Issues and Solutions¶
Issue: 401 Unauthorized¶
Causes: - JWT secret doesn't match across services - Issuer/audience settings mismatch - Token expired - Missing Authorization header
Solutions: - Verify JWT configuration is identical in all services - Check token expiration with JWT debugger (jwt.io) - Ensure "Bearer " prefix in Authorization header
Issue: CORS Errors¶
Solutions: - Add CORS policy to authentication service - Ensure preflight requests (OPTIONS) are handled - Allow Authorization header in CORS policy
Issue: Claims Not Appearing¶
Solutions: - Verify claims are added during token generation - Check role assignments in database - Inspect decoded JWT token
Issue: Tests Failing with Database Errors¶
Solutions: - Ensure in-memory database is properly configured - Seed test data before running tests - Use unique database names for parallel tests
Key Takeaways¶
- JWT tokens enable stateless authentication - Services validate tokens independently without database calls
- .NET Identity Core handles password security - Built-in password hashing, validation, and account lockout
- WebApplicationFactory enables fast integration tests - Both services run in-process for testing
- Shared configuration is critical - All services must use identical JWT settings
- Refresh tokens improve UX - Short-lived access tokens with longer refresh tokens balance security and usability
- Role-based authorization flows through claims - Roles embedded in JWT are available to all services
- Integration tests validate the entire flow - Test real authentication from registration to API calls
Additional Resources¶
- Microsoft Identity Documentation
- JWT.io Token Debugger
- ASP.NET Core Integration Tests
- Testcontainers for .NET
Last Updated: December 2024 Tags: #dotnet #microservices #authentication #jwt #identity-core #integration-testing #webapplicationfactory