In molte applicazioni ASP.NET Core, soprattutto quando il progetto cresce, l’idea di usare un unico schema di autenticazione smette presto di essere sufficiente. Gli utenti interattivi entrano magari con OpenID Connect tramite Microsoft Entra ID, alcuni collaboratori esterni usano Google OAuth, mentre i client server-to-server o gli script automatici accedono con API key. Finché questi casi restano pochi, si tende a gestirli in modo separato e un po’ artigianale; il problema è che, a un certo punto, iniziano a intrecciarsi con autorizzazione, mapping delle claims, policy di accesso e logica applicativa.
È qui che l’autenticazione multi-schema in ASP.NET Core diventa interessante sul serio. Il framework offre già quasi tutto quello che serve, a patto di impostare bene l’architettura: schemi distinti ma coerenti, selezione runtime quando necessario, claims normalizzate, policy di autorizzazione che riflettano davvero i casi d’uso applicativi, e una separazione chiara tra autenticazione e autorizzazione.
In questo articolo vediamo come costruire un impianto del genere in modo pulito, combinando OIDC, OAuth e API keys, aggiungendo anche alcuni tasselli che nei progetti reali fanno spesso la differenza: AddPolicyScheme per scegliere lo schema a runtime, claims transformation, authorization requirement handlers, uso programmatico di IAuthorizationService.AuthorizeAsync() e gestione del refresh token flow.
Autenticazione vs Autorizzazione
Vale la pena ribadirlo subito, perché è uno di quegli equivoci che continuano a creare architetture confuse. L’autenticazione risponde alla domanda “chi sei?”, l’autorizzazione risponde alla domanda “cosa puoi fare?”. In un sistema multi-schema questa distinzione è ancora più importante, perché lo stesso utente o client può arrivare da canali diversi, ma poi deve essere valutato secondo regole comuni.
Un’applicazione ben progettata può delegare l’identità a provider esterni, usare cookie locali per mantenere la sessione interattiva, accettare API key per chiamate machine-to-machine e poi applicare policy di autorizzazione uniformi basate su ruoli, permessi, tenant, piano sottoscritto o altre caratteristiche di dominio.
Uno scenario tipico
Un modello molto comune è questo:
- OpenID Connect come schema principale per il login interattivo, ad esempio tramite Microsoft Entra ID;
- un provider OAuth come schema secondario, come Google, per utenti esterni o collaboratori che non appartengono al tenant principale;
- API key per integrazioni backend, job automatici, CLI, agent o client non interattivi.
È un’impostazione sensata perché riflette tre bisogni diversi. Gli utenti umani beneficiano del login federato e della sessione gestita dal cookie; gli utenti esterni possono autenticarsi con un provider differente; i sistemi automatici non hanno bisogno di redirect browser, consenso visuale o sessioni interattive, quindi l’API key resta una soluzione pratica, purché venga implementata bene.
Multi-Scheme Auth in ASP.NET Core
ASP.NET Core permette di registrare più schemi contemporaneamente. La parte delicata non è tanto l’aggiunta dei provider, quanto decidere qual è lo schema di default e quando invece conviene selezionarlo dinamicamente.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
builder.Services .AddAuthentication(options => { options.DefaultScheme = "smart"; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options => { options.LoginPath = "/account/login"; options.AccessDeniedPath = "/account/access-denied"; }) .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => { options.Authority = builder.Configuration["EntraId:Authority"]; options.ClientId = builder.Configuration["EntraId:ClientId"]; options.ClientSecret = builder.Configuration["EntraId:ClientSecret"]; options.ResponseType = "code"; options.SaveTokens = true; }) .AddGoogle("Google", options => { options.ClientId = builder.Configuration["Google:ClientId"]; options.ClientSecret = builder.Configuration["Google:ClientSecret"]; }) .AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>("ApiKey", _ => { }); |
In questa configurazione ci sono già quattro pezzi distinti: cookie per la sessione locale, OIDC per il challenge principale, Google come schema alternativo e un handler custom per le API key. La vera domanda, a questo punto, è: chi decide quale schema usare per autenticare la richiesta corrente?
AddPolicyScheme: scegliere lo schema a runtime
Qui entra in gioco AddPolicyScheme, che nei sistemi ibridi è spesso la scelta più elegante. Invece di fissare uno schema statico per tutte le richieste, si definisce uno schema “intelligente” che inoltra la richiesta allo schema corretto in base al contesto. In pratica, si lascia che sia il runtime a capire se la richiesta sta arrivando da un browser autenticato via cookie, da un client con header Authorization: ApiKey ..., o magari da un altro schema ancora.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
builder.Services .AddAuthentication(options => { options.DefaultScheme = "smart"; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddPolicyScheme("smart", "Smart auth scheme", options => { options.ForwardDefaultSelector = context => { var authorization = context.Request.Headers.Authorization.ToString(); if (!string.IsNullOrWhiteSpace(authorization) && authorization.StartsWith("ApiKey ", StringComparison.OrdinalIgnoreCase)) { return "ApiKey"; } return CookieAuthenticationDefaults.AuthenticationScheme; }; }); |
Questo approccio evita parecchi controller pieni di logica condizionale o attributi duplicati. È utile soprattutto quando browser e client automatici convivono sulla stessa applicazione o sulla stessa superficie HTTP. Naturalmente la selezione runtime va progettata con attenzione: la regola deve essere semplice, prevedibile e difficile da ambiguitare.
Se prevedi anche bearer token JWT per API esterne, AddPolicyScheme diventa ancora più utile, perché può instradare verso schema cookie, API key o JWT in base al prefisso dell’header Authorization.
OIDC e OAuth: come "federare" l’identità
Per gli utenti interattivi, OpenID Connect resta la scelta più naturale. In ASP.NET Core viene spesso usato insieme a un cookie locale: il provider esterno autentica l’utente, il middleware valida il token ricevuto e poi materializza la sessione nell’applicazione sotto forma di cookie. È un modello collaudato e molto comodo per applicazioni MVC, Razor Pages e anche per molte web app ibride.
Usare un provider secondario, come Google, è altrettanto semplice dal punto di vista tecnico; la complessità vera arriva quando i dati identitari non sono omogenei. Il subject identifier cambia, le claims disponibili cambiano, il formato di certi attributi cambia, e a volte cambia perfino il modo in cui vengono valorizzati email, nome e gruppi. Ecco perché conviene ragionare presto sulla normalizzazione delle claims.
Claims transformation: uniformare i dati di provider diversi
Quando l’app riceve principal provenienti da provider diversi, una delle cose più utili che si possano fare è trasformare o arricchire le claims in un punto centralizzato. Senza questo passaggio, il rischio è ritrovarsi policy e controller pieni di condizioni ad hoc del tipo “se arriva da Entra usa questa claim, se arriva da Google usa quell’altra”. È una strada che diventa ingestibile in fretta.
IClaimsTransformation permette di intercettare il principal autenticato e produrre una versione più coerente per il resto dell’applicazione. È il posto giusto per normalizzare ruoli, mappare claim custom, aggiungere tenant context, copiare identificatori in tipi standardizzati o trasformare informazioni esterne in permessi applicativi interni.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
public sealed class AppClaimsTransformation : IClaimsTransformation { public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal) { var identity = principal.Identity as ClaimsIdentity; if (identity is null || !identity.IsAuthenticated) { return Task.FromResult(principal); } if (!identity.HasClaim(c => c.Type == ClaimTypes.NameIdentifier)) { var sub = identity.FindFirst("sub")?.Value ?? identity.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (!string.IsNullOrWhiteSpace(sub)) { identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, sub)); } } var email = identity.FindFirst(ClaimTypes.Email)?.Value ?? identity.FindFirst("email")?.Value; if (!string.IsNullOrWhiteSpace(email) && !identity.HasClaim(c => c.Type == "app:email")) { identity.AddClaim(new Claim("app:email", email)); } return Task.FromResult(principal); } } |
|
1 |
builder.Services.AddTransient<IClaimsTransformation, AppClaimsTransformation>(); |
Questa fase è anche un buon punto per collegare identità esterne a un modello locale, per esempio una allowlist, ruoli applicativi persistiti su database, appartenenza a tenant o permessi granulari. È una scelta che nei progetti enterprise tende a pagare molto più di quanto sembri all’inizio.
API key authentication
Le API key continuano a essere una soluzione pratica per i client non interattivi, ma solo se vengono trattate con lo stesso rigore che useremmo per qualsiasi altro materiale sensibile. La cosa da evitare è il classico approccio pigro: chiave generata una volta, salvata in chiaro, confrontata in stringa, senza scope, rotazione o revoca.
Una buona implementazione dovrebbe almeno prevedere:
- chiavi generate in modo robusto e mostrate in chiaro una sola volta;
- persistenza come hash con salt, non in plaintext;
- scope o permessi associati alla chiave;
- revoca immediata e, meglio ancora, scadenza o rotazione;
- audit degli utilizzi e dei fallimenti rilevanti.
Un handler custom può leggere l’header Authorization: ApiKey {key}, validare la chiave e costruire un ClaimsPrincipal coerente con il resto dell’applicazione:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
public sealed class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions> { private readonly IApiKeyValidator _validator; public ApiKeyAuthenticationHandler( IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock, IApiKeyValidator validator) : base(options, logger, encoder, clock) { _validator = validator; } protected override async Task<AuthenticateResult> HandleAuthenticateAsync() { var header = Request.Headers.Authorization.ToString(); if (string.IsNullOrWhiteSpace(header) || !header.StartsWith("ApiKey ", StringComparison.OrdinalIgnoreCase)) { return AuthenticateResult.NoResult(); } var rawKey = header["ApiKey ".Length..].Trim(); var result = await _validator.ValidateAsync(rawKey); if (!result.Succeeded) { return AuthenticateResult.Fail("Invalid API key."); } var claims = new List<Claim> { new Claim(ClaimTypes.NameIdentifier, result.SubjectId), new Claim("auth_type", "api_key") }; foreach (var permission in result.Permissions) { claims.Add(new Claim("permission", permission)); } var identity = new ClaimsIdentity(claims, Scheme.Name); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); return AuthenticateResult.Success(ticket); } } |
Autorizzazione: Policy e Requirement Handlers
Una volta autenticata la richiesta, inizia la parte che interessa di più al dominio: capire se quel principal può eseguire l’operazione richiesta. Per i casi banali bastano ruoli o claim checks direttamente nelle policy, ma appena entrano in gioco tenant, ownership, piani di servizio, permessi composti o regole contestuali, conviene passare ai requirement handlers.
Un requirement custom permette di esprimere una regola di business in modo leggibile e riusabile. È molto meglio di spargere User.HasClaim(...) e User.IsInRole(...) in giro per controller, page model e servizi.
|
1 2 3 4 5 6 7 8 9 |
public sealed class PermissionRequirement : IAuthorizationRequirement { public PermissionRequirement(string permission) { Permission = permission; } public string Permission { get; } } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
public sealed class PermissionRequirementHandler : AuthorizationHandler<PermissionRequirement> { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, PermissionRequirement requirement) { var hasPermission = context.User.Claims.Any(c => c.Type == "permission" && string.Equals(c.Value, requirement.Permission, StringComparison.OrdinalIgnoreCase)); if (hasPermission) { context.Succeed(requirement); } return Task.CompletedTask; } } |
|
1 2 3 4 5 6 7 8 9 10 11 |
builder.Services.AddAuthorization(options => { options.AddPolicy("Documents.Read", policy => policy.RequireAuthenticatedUser() .AddRequirements(new PermissionRequirement("documents.read"))); options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin")); }); builder.Services.AddSingleton<IAuthorizationHandler, PermissionRequirementHandler>(); |
Questo approccio scala bene anche quando i permessi non arrivano direttamente dal token o dalla API key, ma devono essere risolti in base a dati applicativi. In quel caso l’handler può consultare servizi di dominio, database o tenant context, purché lo faccia in modo efficiente e senza trasformare ogni richiesta in una catena di query costose.
IAuthorizationService.AuthorizeAsync
Gli attributi [Authorize] restano comodissimi, ma non coprono tutto. Appena la decisione dipende da una risorsa concreta, da un record caricato a runtime o da una logica applicativa più articolata, conviene usare IAuthorizationService.AuthorizeAsync() in modo programmatico.
È il caso tipico delle policy resource-based: un utente può modificare un documento solo se appartiene al tenant corretto, oppure se è owner della risorsa, oppure se possiede un certo permesso amministrativo.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public sealed class DocumentAuthorizationHandler : AuthorizationHandler<PermissionRequirement, Document> { protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, PermissionRequirement requirement, Document resource) { var isOwner = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value == resource.OwnerId; var hasPermission = context.User.Claims.Any(c => c.Type == "permission" && c.Value == requirement.Permission); if (isOwner || hasPermission) { context.Succeed(requirement); } return Task.CompletedTask; } } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
public sealed class DocumentsController : Controller { private readonly IAuthorizationService _authorizationService; private readonly IDocumentRepository _repository; public DocumentsController( IAuthorizationService authorizationService, IDocumentRepository repository) { _authorizationService = authorizationService; _repository = repository; } public async Task<IActionResult> Edit(Guid id) { var document = await _repository.GetByIdAsync(id); if (document is null) { return NotFound(); } var authResult = await _authorizationService.AuthorizeAsync( User, document, new PermissionRequirement("documents.write")); if (!authResult.Succeeded) { return Forbid(); } return View(document); } } |
Questa forma programmatica è spesso più espressiva e più corretta degli attributi quando la risorsa da proteggere non è nota a compile time.
Refresh token: serve davvero?
Il tema dei refresh token merita un po’ di chiarezza, perché viene spesso evocato anche in scenari dove non serve. In una classica web app ASP.NET Core con login OIDC e cookie locale, spesso è il cookie a rappresentare la sessione applicativa, mentre i token del provider restano dietro le quinte. In questo caso il refresh token può servire se l’app deve chiamare API esterne per conto dell’utente in modo continuativo, non semplicemente per mantenere il login locale.
Se invece stai costruendo una SPA o un client che lavora direttamente con access token e refresh token, allora il flow diventa centrale e va trattato con molta attenzione: storage sicuro, scadenze, revoca, rotazione e protezione dai furti.
Nel contesto OIDC server-side, abilitare il salvataggio dei token è spesso il primo passo:
|
1 2 3 4 5 6 7 8 9 |
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options => { options.Authority = builder.Configuration["EntraId:Authority"]; options.ClientId = builder.Configuration["EntraId:ClientId"]; options.ClientSecret = builder.Configuration["EntraId:ClientSecret"]; options.ResponseType = "code"; options.SaveTokens = true; options.Scope.Add("offline_access"); }); |
La scope offline_access è in genere quella che abilita l’emissione del refresh token, se il provider la supporta e la configurazione lato IdP lo consente. Da lì in avanti, però, il punto non è “avere il refresh token”, ma gestirlo correttamente. Se va conservato lato server, deve essere protetto come qualsiasi altro secret sensibile; se viene usato per ottenere nuovi access token verso API downstream, è bene centralizzare la logica di refresh e gestire gli errori in modo prevedibile.
In molti progetti enterprise conviene evitare implementazioni manuali troppo creative e appoggiarsi alle librerie del provider o a componenti già pensati per token caching e renewal. Il refresh token è uno di quei punti in cui una semplificazione ingenua può trasformarsi rapidamente in un problema di sicurezza serio.
Claims, ruoli locali e modello di autorizzazione
Un sistema multi-schema funziona davvero bene quando le identità esterne vengono ricondotte a un modello locale chiaro. In altre parole, autenticarsi con Entra, Google o API key non dovrebbe cambiare il modo in cui il dominio ragiona su ruoli, permessi, tenant scope e abilitazioni operative. Cambia l’origine dell’identità, non la logica di accesso.
È spesso una buona idea mantenere in locale almeno:
- una allowlist degli utenti o client autorizzati a entrare davvero nel sistema;
- ruoli applicativi distinti da quelli eventualmente forniti dal provider esterno;
- membership o scope legati al tenant corrente;
- audit uniforme per login, accessi negati, uso di chiavi, revoche e operazioni sensibili.
Questa separazione evita di legare troppo il modello autorizzativo alle particolarità del provider identitario scelto in quel momento.
Sign-out, revoca e lifecycle delle credenziali
In un sistema con più schemi di autenticazione, anche il sign-out e la revoca vanno pensati bene. Per le sessioni browser, di solito bisogna chiudere sia il cookie locale sia, quando opportuno, la sessione presso il provider OIDC. Per le API key il concetto è diverso: non c’è sign-out, ma revoca, rotazione, scadenza e audit degli utilizzi successivi.
La tentazione di trattare tutti i canali come equivalenti è comprensibile, ma non funziona. Ogni schema ha un lifecycle diverso, e il codice dovrebbe rifletterlo in modo esplicito.
Conclusioni
Quando si disegna un sistema di autenticazione multi-schema, la cosa più utile che si possa fare è resistere alla voglia di inventare troppo. ASP.NET Core offre già i mattoni giusti per costruire il proprio identity system: provider esterni per OIDC e OAuth, cookie authentication, handler custom, policy scheme, claims transformation, authorization handlers e autorizzazione programmatica. Il vero lavoro sta nel combinarli senza confondere i livelli.
Uno schema ragionevole, nella maggior parte dei casi, è questo: provider esterni per l’identità, cookie per la sessione utente interattiva, API key per i client automatici; il tutto orchestrato da un sistema di claims gestite trasformate in un formato coerente, e relative policy applicative locali per decidere chi può fare cosa. Non è l’unico modello possibile, ma è uno di quelli che reggono meglio quando l’applicazione cresce.
Combinare OIDC, OAuth e API keys in ASP.NET Core non è particolarmente difficile sul piano tecnico; la difficoltà vera sta nel farlo senza introdurre incoerenze tra autenticazione, claims, policy di accesso e logica di dominio. AddPolicyScheme aiuta a scegliere lo schema corretto a runtime, IClaimsTransformation permette di normalizzare identità provenienti da fonti diverse, i requirement handlers rendono l’autorizzazione più espressiva, mentre IAuthorizationService.AuthorizeAsync() copre quei casi in cui le decisioni dipendono da risorse concrete e non da semplici attributi statici.
Come spesso accade in ambito security, il problema non è tanto quello di mettere insieme più componenti, ma impedire che nel tempo si accumulino eccezioni, shortcut e regole implicite difficili da governare: un impianto multi-schema ben progettato, come quello quello che abbiamo presentato in questo articolo, serve esattamente a evitare questo.
Riferimenti
- Overview of ASP.NET Core authentication - Panoramica ufficiale sui meccanismi di autenticazione del framework.
- Introduction to authorization in ASP.NET Core - Introduzione alle policy, ai requirement e ai modelli di autorizzazione supportati dal framework.
- Policy-based authorization in ASP.NET Core - Documentazione ufficiale su policy, requirement handlers e autorizzazione resource-based.
- Claims-based authorization in ASP.NET Core - Guida ufficiale alla gestione delle claims e alla loro integrazione nelle policy.
- External provider authentication in ASP.NET Core - Configurazione dei provider esterni, inclusi Google e altri schemi social/OAuth.
- Configure OpenID Connect Web authentication in ASP.NET Core - Setup pratico di OpenID Connect in una web app ASP.NET Core.
- Refresh tokens in the Microsoft identity platform - Dettagli sul funzionamento dei refresh token nel mondo Microsoft identity.
- OAuth 2.0 Refresh Tokens - Riferimento utile per il refresh token flow in ambito OAuth 2.0.
- OpenID Connect Core 1.0 - Specifica di riferimento per comprendere i meccanismi OIDC alla base dell’autenticazione federata.
