in Architettura Software, Informatica

Come realizzare un Auth Server centralizzato per API multiple in C# 10

Quando si gestiscono più progetti API distribuiti, replicare la logica di autenticazione su ciascun servizio è inefficiente e difficile da mantenere. La soluzione ideale è la creazione di un Authentication Server centralizzato (un unico punto di rilascio dei token) che protegga tutti i microservizi o le API figlie.

Ecco una semplice soluzione su ome impostare l’architettura di base sfruttando le funzionalità di C# 10 e ASP.NET Core.

1. Architettura della Soluzione

Per realizzare un sistema scalabile, separiamo le responsabilità in due macro-componenti:

  • Auth Server: Un’applicazione ASP.NET Core dedicata esclusivamente alla verifica delle credenziali e alla generazione dei token JWT (JSON Web Token).
  • Resource Servers (Le API): I singoli progetti API che si limitano a validare la firma crittografica del token ricevuto, senza interrogare direttamente il database degli utenti.

2. Implementazione del Token Issuer (Auth Server)

In C# 10 possiamo sfruttare i file-scoped namespaces e i tipi record per mantenere il codice pulito e conciso nei DTO di login:

namespace AuthServer.Models;

public record LoginRequest(string Username, string Password);
public record TokenResponse(string Token, DateTime Expiration);

All’interno del controller o di una Minimal API, gestiamo la validazione e la creazione del token firmato:

// Esempio con Minimal API in ASP.NET Core
app.MapPost("/api/auth/login", (LoginRequest request, IConfiguration config) =>
{
    // 1. Validazione credenziali (mock o db)
    if (request.Username != "admin" || request.Password != "password-sicura")
        return Results.Unauthorized();

    // 2. Definizione dei Claims
    var claims = new List<Claim>
    {
        new(ClaimTypes.Name, request.Username),
        new(ClaimTypes.Role, "Administrator")
    };

    // 3. Generazione della chiave e del token
    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"]!));
    var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(claims),
        Expires = DateTime.UtcNow.AddHours(2),
        Issuer = config["Jwt:Issuer"],
        Audience = config["Jwt:Audience"],
        SigningCredentials = credentials
    };

    var tokenHandler = new JwtSecurityTokenHandler();
    var token = tokenHandler.CreateToken(tokenDescriptor);

    return Results.Ok(new TokenResponse(tokenHandler.WriteToken(token), tokenDescriptor.Expires.Value));
});

3. Configurazione dei Resource Servers (Le API)

Ogni progetto API differente che deve sottostare a questo sistema di accesso non ha bisogno di conoscere la logica di login. Nel file Program.cs di ciascuna API basta configurare il middleware di autenticazione JWT:

  • Registrare il pacchetto Microsoft.AspNetCore.Authentication.JwtBearer.
  • Impostare i parametri di validazione (Issuer, Audience, IssuerSigningKey) identici a quelli dell’Auth Server.
  • Applicare l’attributo [Authorize] sui controller o .RequireAuthorization() sulle route delle Minimal API.

Nota di sicurezza: Assicurati di archiviare la chiave segreta (Jwt:Key) in modo sicuro usando le User Secrets in ambiente di sviluppo e le variabili d’ambiente o Azure Key Vault in produzione.

A questo punto utilizzeremo una libreria dedicata che ci consenta di gestire i flussi. La libreria si chiama OpenIddict.

Configurazione dell’Authorization Server

Registrazione dei pacchetti NuGet

Nel progetto dell’Auth Server, installa i pacchetti necessari per Entity Framework Core e OpenIddict:

dotnet add package OpenIddict.AspNetCore
dotnet add package OpenIddict.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer

Configurazione del DbContext

Configura il contesto del database ereditando da DbContext e registrando le entità di OpenIddict:

using Microsoft.EntityFrameworkCore;

namespace AuthServer.Data;

public class ApplicationDbContext : DbContext
{
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
        : base(options) { }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        modelBuilder.UseOpenIddict();
    }
}

Configurazione in Program.cs

Configura i servizi di OpenIddict all’interno del file di bootstrap dell’applicazione:

using AuthServer.Data;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Configurazione Database
builder.Services.AddDbContext<ApplicationDbContext>(options =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
    options.UseOpenIddict();
});

builder.Services.AddControllersWithViews();

// Configurazione OpenIddict
builder.Services.AddOpenIddict()
    .AddCore(options =>
    {
        options.UseEntityFrameworkCore()
               .UseDbContext<ApplicationDbContext>();
    })
    .AddServer(options =>
    {
        options.SetAuthorizationEndpointUis("/connect/authorize")
               .SetLogoutEndpointUis("/connect/logout")
               .SetTokenEndpointUis("/connect/token")
               .SetUserinfoEndpointUis("/connect/userinfo");

        options.AllowAuthorizationCodeFlow()
               .AllowRefreshTokenFlow()
               .AllowClientCredentialsFlow();

        options.RegisterScopes("api", OpenIddict.Abstractions.OpenIddictConstants.Scopes.Email, 
                                     OpenIddict.Abstractions.OpenIddictConstants.Scopes.Profile);

        // In produzione usare certificati X.509 validi
        options.AddDevelopmentEncryptionCertificate()
               .AddDevelopmentSigningCertificate();

        options.UseAspNetCore()
               .EnableAuthorizationEndpointPassthrough()
               .EnableLogoutEndpointPassthrough()
               .EnableTokenEndpointPassthrough()
               .EnableUserinfoEndpointPassthrough()
               .EnableStatusCodePagesIntegration();
    })
    .AddValidation(options =>
    {
        options.UseLocalServer();
        options.UseAspNetCore();
    });

var app = builder.Build();

app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();
app.Run();

Inizializzazione Automatica dei Client (Seed Data)

Per registrare dinamicamente le applicazioni client all’avvio del server:

using AuthServer.Data;
using Microsoft.Extensions.DependencyInjection;
using OpenIddict.Abstractions;

namespace AuthServer.Worker;

public class Worker : IHostedService
{
    private readonly IServiceProvider _serviceProvider;

    public Worker(IServiceProvider serviceProvider) => _serviceProvider = serviceProvider;

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        using var scope = _serviceProvider.CreateScope();
        
        var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
        await context.Database.EnsureCreatedAsync(cancellationToken);

        var manager = scope.ServiceProvider.GetRequiredService<IOpenIddictApplicationManager>();

        if (await manager.FindByClientIdAsync("client-mvc", cancellationToken) == null)
        {
            await manager.CreateAsync(new OpenIddictApplicationDescriptor
            {
                ClientId = "client-mvc",
                ClientSecret = "secret-chiave-super-sicura",
                ConsentType = OpenIddict.Abstractions.OpenIddictConstants.ConsentTypes.Explicit,
                DisplayName = "Applicazione Client MVC",
                RedirectUris = { new Uri("https://localhost:7001/signin-oidc") },
                PostLogoutRedirectUris = { new Uri("https://localhost:7001/signout-callback-oidc") },
                Permissions =
                {
                    OpenIddict.Abstractions.OpenIddictConstants.Permissions.Endpoints.Authorization,
                    OpenIddict.Abstractions.OpenIddictConstants.Permissions.Endpoints.Logout,
                    OpenIddict.Abstractions.OpenIddictConstants.Permissions.Endpoints.Token,
                    OpenIddict.Abstractions.OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
                    OpenIddict.Abstractions.OpenIddictConstants.Permissions.GrantTypes.RefreshToken,
                    OpenIddict.Abstractions.OpenIddictConstants.Permissions.Scopes.Email,
                    OpenIddict.Abstractions.OpenIddictConstants.Permissions.Scopes.Profile,
                    OpenIddict.Abstractions.OpenIddictConstants.Permissions.Prefixes.Scope + "api"
                }
            }, cancellationToken);
        }
    }

    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

Gestione dei Flussi nel Controller di Autorizzazione

Crea un controller dedicato per gestire le richieste agli endpoint di autorizzazione e rilascio token:

using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using System.Security.Claims;

namespace AuthServer.Controllers;

public class AuthorizationController : Controller
{
    [HttpGet("~/connect/authorize")]
    [HttpPost("~/connect/authorize")]
    [IgnoreAntiforgeryToken]
    public async Task<IActionResult> Authorize()
    {
        var request = HttpContext.GetOpenIddictServerRequest() ??
            throw new InvalidOperationException("La richiesta OpenID Connect non può essere recuperata.");

        var result = await HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);

        if (!result.Succeeded)
        {
            return Challenge(
                authenticationSchemes: CookieAuthenticationDefaults.AuthenticationScheme,
                properties: new AuthenticationProperties
                {
                    RedirectUri = Request.PathBase + Request.QueryString
                });
        }

        var claims = new List<Claim>
        {
            new(OpenIddictConstants.Claims.Subject, result.Principal.Identity!.Name!),
            new(ClaimTypes.Name, result.Principal.Identity!.Name!),
            new(ClaimTypes.Role, "User")
        };

        var claimsIdentity = new ClaimsIdentity(claims, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
        var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);

        claimsPrincipal.SetScopes(request.GetScopes());

        return SignIn(claimsPrincipal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
    }

    [HttpPost("~/connect/token")]
    [IgnoreAntiforgeryToken]
    public async Task<IActionResult> Exchange()
    {
        var request = HttpContext.GetOpenIddictServerRequest() ??
            throw new InvalidOperationException("La richiesta OpenID Connect non può essere recuperata.");

        if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
        {
            var principal = (await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme)).Principal;
            return SignIn(principal!, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
        }

        throw new InvalidOperationException("Il tipo di grant specificato non è supportato.");
    }
}

Protezione dei Resource Servers (Le API)

Nei singoli progetti API dipendenti, configura il middleware di validazione di OpenIddict per leggere i token JWT:

// In Program.cs del Resource Server
builder.Services.AddAuthentication(OpenIddict.Validation.AspNetCoreOpenIddictValidationDefaults.AuthenticationScheme)
    .AddOpenIddictValidation(options =>
    {
        options.SetIssuer("https://localhost:5001/");
        options.AddAudiences("rs-api");

        options.UseIntrospection();
        options.UseSystemNetHttp();
        options.UseAspNetCore();
    });

builder.Services.AddAuthorization();

In conclusione, adottare un Authorization Server centralizzato basato su OpenIddict in .NET ti permette di disaccoppiare la logica di identità e sicurezza dai singoli microservizi, garantendo un’architettura scalabile, standardizzata e pronta per qualsiasi tipo di client (Web, SPA o Mobile).

Gestire i flussi OAuth2 e OIDC in questo modo richiede un investimento iniziale di configurazione, ma ripaga ampiamente in termini di manutenibilità, flessibilità e sicurezza complessiva dell’ecosistema API.