Skip to content

.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

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

  1. Never commit JWT secret keys - Use environment variables or secret management
  2. Use HTTPS only - Never send tokens over HTTP
  3. Implement token rotation - Refresh tokens should be rotated on use
  4. Add rate limiting - Protect login endpoints from brute force attacks
  5. Log authentication events - Track failed login attempts
  6. Use short-lived access tokens - 15-60 minutes recommended
  7. Use longer-lived refresh tokens - 7-30 days recommended
  8. Validate tokens on critical operations - Don't just rely on JWT validation

Testing Best Practices

  1. Use In-Memory Database for fast tests - SQLite or EF InMemory for most tests
  2. Use Testcontainers for critical tests - Test against real database for important scenarios
  3. Isolate test data - Each test should create its own data
  4. Clean up between tests - Use fresh database for each test
  5. Test authentication flow separately - Unit test JWT generation/validation
  6. Mock external dependencies - Don't call real external APIs
  7. Use Test Collections - Share expensive setup across tests
  8. Parallel execution - Run tests in parallel when possible
  9. Clear test names - Use Method_Scenario_ExpectedResult naming convention
  10. Assert specific status codes - Don't just check IsSuccessStatusCode

Configuration Management Best Practices

  1. Centralize JWT settings - Use configuration server or secrets management
  2. Environment-specific configurations - Different settings for dev/staging/prod
  3. Rotate secrets regularly - Update JWT keys periodically
  4. Audit configuration access - Track who accesses secrets
  5. 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

  1. JWT tokens enable stateless authentication - Services validate tokens independently without database calls
  2. .NET Identity Core handles password security - Built-in password hashing, validation, and account lockout
  3. WebApplicationFactory enables fast integration tests - Both services run in-process for testing
  4. Shared configuration is critical - All services must use identical JWT settings
  5. Refresh tokens improve UX - Short-lived access tokens with longer refresh tokens balance security and usability
  6. Role-based authorization flows through claims - Roles embedded in JWT are available to all services
  7. Integration tests validate the entire flow - Test real authentication from registration to API calls

Additional Resources


Last Updated: December 2024 Tags: #dotnet #microservices #authentication #jwt #identity-core #integration-testing #webapplicationfactory