În articolul anterior am explicat cum funcționează OAuth2 și OpenID Connect la nivel de protocol. Acum facem pasul următor: configurăm un Authorization Server real — fie IdentityServer (soluție .NET), fie Keycloak (soluție Java/open-source) — și integrăm aplicația ASP.NET Core cu el.
Indiferent de alegere, conceptele sunt identice: un server centralizat emite token-uri, aplicațiile tale le validează. Diferă doar tooling-ul de configurare.
1. IdentityServer vs. Keycloak — când alegi ce?
Înainte de orice cod, merită să înțelegi contextul fiecărei soluții.
IdentityServer (Duende IdentityServer)
IdentityServer este un framework open-source pentru .NET, construit exact pe ASP.NET Core. Îl găzduiești tu, îl customizezi în C#, și se integrează natural în orice soluție .NET. Duende Software (compania din spatele lui) oferă licențiere comercială pentru producție — gratuit pentru proiecte mici și open-source.
Când îl alegi:
- Vrei control total în cod C# asupra fluxului de autentificare
- Ai nevoie de customizare profundă (UI propriu, logică de grant custom)
- Stack-ul tău e 100% .NET
- Vrei să distribui propriul tău IdP ca parte dintr-un produs SaaS
Keycloak
Keycloak este o soluție enterprise open-source, dezvoltată de Red Hat. Vine cu o interfață de administrare completă, suport out-of-the-box pentru LDAP/AD, social login, MFA, și este language-agnostic (poate fi folosit de orice aplicație).
Când îl alegi:
- Vrei un IdP gata configurat, fără cod custom
- Ai o echipă mixtă (Java, .NET, Node.js) care partajează același sistem de autentificare
- Ai nevoie de integrare cu Active Directory sau LDAP
- Preferi o consolă de admin vizuală față de configurare în cod
2. Setup IdentityServer — pași concreți
2.1 Instalare
Creezi un nou proiect ASP.NET Core și adaugi pachetul:
dotnet add package Duende.IdentityServer
2.2 Configurare minimă — Program.cs
builder.Services.AddIdentityServer(options =>
{
options.Events.RaiseErrorEvents = true;
options.Events.RaiseSuccessEvents = true;
options.Events.RaiseFailureEvents = true;
})
.AddInMemoryIdentityResources(Config.IdentityResources)
.AddInMemoryApiScopes(Config.ApiScopes)
.AddInMemoryClients(Config.Clients)
.AddInMemoryApiResources(Config.ApiResources)
.AddTestUsers(Config.Users); // doar pentru dev/test
app.UseIdentityServer();
2.3 Definirea resurselor și clienților — Config.cs
public static class Config
{
public static IEnumerable<IdentityResource> IdentityResources =>
[
new IdentityResources.OpenId(),
new IdentityResources.Profile(),
new IdentityResources.Email()
];
public static IEnumerable<ApiScope> ApiScopes =>
[
new ApiScope("myapi", "My API"),
new ApiScope("myapi.read"),
new ApiScope("myapi.write")
];
public static IEnumerable<ApiResource> ApiResources =>
[
new ApiResource("myapi", "My API")
{
Scopes = { "myapi", "myapi.read", "myapi.write" }
}
];
public static IEnumerable<Client> Clients =>
[
// Client tip Machine-to-Machine (Client Credentials Flow)
new Client
{
ClientId = "m2m-client",
ClientName = "Machine to Machine Client",
ClientSecrets = { new Secret("super-secret".Sha256()) },
AllowedGrantTypes = GrantTypes.ClientCredentials,
AllowedScopes = { "myapi", "myapi.read" }
},
// Client tip SPA/Web (Authorization Code + PKCE)
new Client
{
ClientId = "web-client",
ClientName = "Web Application",
ClientSecrets = { new Secret("web-secret".Sha256()) },
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RequireClientSecret = false, // public client
RedirectUris = { "https://localhost:5001/signin-oidc" },
PostLogoutRedirectUris = { "https://localhost:5001/signout-callback-oidc" },
FrontChannelLogoutUri = "https://localhost:5001/signout-oidc",
AllowedScopes =
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
"myapi"
},
AllowOfflineAccess = true // refresh tokens
}
];
}
2.4 Discovery endpoint
Odată pornit, IdentityServer expune automat metadatele la:
https://<your-identityserver>/.well-known/openid-configuration
Aici găsești toate endpoint-urile: authorization_endpoint, token_endpoint, jwks_uri, userinfo_endpoint etc. Aplicațiile client folosesc acest URL pentru auto-configurare.
3. Setup Keycloak — pași concreți
3.1 Pornire cu Docker
Cel mai simplu mod de a rula Keycloak local:
docker run -p 8080:8080 \
-e KEYCLOAK_ADMIN=admin \
-e KEYCLOAK_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:latest start-dev
Accesezi consola de admin la http://localhost:8080/admin.
3.2 Configurare Realm, Client și Scopes
În Keycloak, organizarea se face pe Realms — fiecare realm este un spațiu izolat de autentificare (similar cu un tenant). Pașii sunt:
- Creează un Realm nou (ex:
myapp) — nu folosi realm-ulmasterpentru aplicații - Creează un Client → Clients → Create client:
Client ID:myapp-clientClient type:OpenID ConnectClient authentication: ON (confidential client)Authorization: ON dacă vrei fine-grained authorizationValid redirect URIs:https://localhost:5001/*Web origins:https://localhost:5001
- Creează Users → Users → Add user
- Creează Roles → Realm roles sau Client roles
- Adaugă Scopes custom → Client scopes → Create client scope
3.3 Discovery endpoint Keycloak
http://localhost:8080/realms/myapp/.well-known/openid-configuration
Structura este identică cu orice provider OIDC — asta e frumusețea standardului.
4. Integrare ASP.NET Core — aplicația client (Web App)
Aceasta este partea comună — codul ASP.NET Core este aproape identic indiferent dacă folosești IdentityServer sau Keycloak. Diferă doar Authority.
4.1 Instalare pachete
dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect
4.2 Configurare în Program.cs
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.Cookie.Name = ".myapp.session";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromHours(8);
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
// —— IdentityServer ——
options.Authority = "https://localhost:5000"; // URL-ul IdentityServer-ului
// —— SAU Keycloak ——
// options.Authority = "http://localhost:8080/realms/myapp";
options.ClientId = "web-client";
options.ClientSecret = "web-secret";
options.ResponseType = "code"; // Authorization Code Flow
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("myapi");
options.Scope.Add("offline_access"); // refresh token
options.SaveTokens = true; // stochează tokens în cookie
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "role" // sau "roles" pentru Keycloak
};
});
app.UseAuthentication();
app.UseAuthorization();
4.3 Protejarea unui controller
[Authorize]
public class DashboardController : Controller
{
public async Task<IActionResult> Index()
{
// Accesezi access token-ul stocat în cookie
var accessToken = await HttpContext.GetTokenAsync("access_token");
// ... apel API cu token
return View();
}
[Authorize(Roles = "Admin")]
public IActionResult AdminPanel() => View();
}
5. Integrare ASP.NET Core — aplicația API (Resource Server)
API-ul tău nu face redirect-uri — el doar validează JWT-uri primite în header-ul Authorization: Bearer <token>.
5.1 Instalare pachete
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
5.2 Configurare în Program.cs
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// —— IdentityServer ——
options.Authority = "https://localhost:5000";
// —— SAU Keycloak ——
// options.Authority = "http://localhost:8080/realms/myapp";
// options.MetadataAddress = "http://localhost:8080/realms/myapp/.well-known/openid-configuration";
// options.RequireHttpsMetadata = false; // doar pentru dev local HTTP
options.Audience = "myapi"; // ApiResource name din IdentityServer
// sau Client ID din Keycloak
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ClockSkew = TimeSpan.FromSeconds(30)
};
});
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ReadAccess", policy =>
policy.RequireClaim("scope", "myapi.read"));
options.AddPolicy("WriteAccess", policy =>
policy.RequireClaim("scope", "myapi.write"));
});
app.UseAuthentication();
app.UseAuthorization();
5.3 Protejarea endpoint-urilor
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet]
[Authorize(Policy = "ReadAccess")]
public IActionResult GetAll() => Ok(new[] { "Product A", "Product B" });
[HttpPost]
[Authorize(Policy = "WriteAccess")]
public IActionResult Create([FromBody] ProductDto dto) => Created("/", dto);
}
6. Diferențe cheie IdentityServer vs. Keycloak în integrarea .NET
6.1 Audience validation
Aceasta este cea mai frecventă sursă de confuzie:
- IdentityServer: audience-ul în JWT este name-ul
ApiResourcedefinit înConfig.cs. De exemplu, dacă ainew ApiResource("myapi"), atunciaud = "myapi". - Keycloak: implicit, audience-ul este
accountsau clientId-ul. Pentru API-uri separate, trebuie să adaugi explicit un Audience mapper în Client scopes → <scope> → Mappers → Add mapper → Audience.
6.2 Roluri și claims
Keycloak pune rolurile în structuri nested în token:
// Token JWT Keycloak (decoded)
{
"realm_access": {
"roles": ["Admin", "User"]
},
"resource_access": {
"myapp-client": {
"roles": ["manager"]
}
}
}
ASP.NET Core nu știe implicit să mapeze aceste structuri. Ai nevoie de un ClaimsTransformation custom:
public class KeycloakRolesClaimsTransformation : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var identity = (ClaimsIdentity)principal.Identity!;
// Extrage roluri din realm_access.roles
var realmAccessClaim = identity.FindFirst("realm_access");
if (realmAccessClaim != null)
{
var realmAccess = JsonDocument.Parse(realmAccessClaim.Value);
if (realmAccess.RootElement.TryGetProperty("roles", out var roles))
{
foreach (var role in roles.EnumerateArray())
{
identity.AddClaim(new Claim(ClaimTypes.Role, role.GetString()!));
}
}
}
return Task.FromResult(principal);
}
}
// Înregistrare în Program.cs
builder.Services.AddTransient<IClaimsTransformation, KeycloakRolesClaimsTransformation>();
6.3 Token introspection
Dacă emiți reference tokens (opaci, nu JWT), validarea nu se mai face local. API-ul trebuie să apeleze introspection endpoint-ul pentru a verifica dacă token-ul e valid:
// IdentityServer — suport nativ
dotnet add package IdentityModel.AspNetCore.OAuth2Introspection
.AddOAuth2Introspection("introspection", options =>
{
options.Authority = "https://localhost:5000";
options.ClientId = "myapi";
options.ClientSecret = "api-secret";
});
7. Refresh tokens — gestionare în aplicația web
Dacă ai cerut offline_access, primești un refresh token. ASP.NET Core nu îl folosește automat — trebuie să gestionezi expirarea manual sau printr-un middleware.
Pattern recomandat cu IdentityModel:
dotnet add package IdentityModel.AspNetCore
// Program.cs
builder.Services.AddAccessTokenManagement(options =>
{
options.Client.DefaultClient = new ClientCredentialsTokenRequest
{
Address = "https://localhost:5000/connect/token",
ClientId = "m2m-client",
ClientSecret = "super-secret"
};
});
// Înnoire automată când token-ul expiră — pentru user tokens:
builder.Services.AddUserAccessTokenHttpClient("ApiClient", configureClient: client =>
{
client.BaseAddress = new Uri("https://api.myapp.com");
});
Biblioteca IdentityModel.AspNetCore gestionează automat refresh-ul când token-ul e pe cale să expire, înainte de a face apelul HTTP.
8. Logout corect — end_session_endpoint
Un logout complet (SSO logout) înseamnă trei pași:
- Ștergi cookie-ul local al aplicației
- Revoci token-urile la Authorization Server
- Redirecționezi browser-ul la
end_session_endpointal serverului
public IActionResult Logout()
{
return SignOut(
new AuthenticationProperties
{
RedirectUri = "/"
},
CookieAuthenticationDefaults.AuthenticationScheme,
OpenIdConnectDefaults.AuthenticationScheme // declanșează redirect la end_session_endpoint
);
}
Dacă omiți OpenIdConnectDefaults.AuthenticationScheme din SignOut, ștergi doar cookie-ul local — utilizatorul poate accesa din nou fără login, pentru că sesiunea la IdP e încă activă.
9. Probleme comune și soluțiile lor
| Problemă | Cauza probabilă | Soluție |
|---|---|---|
401 Unauthorized pe API |
Audience greșit în token sau în validare | Verifică options.Audience să coincidă cu aud din JWT decodat |
| IDX20803: Unable to obtain configuration | Authority URL inaccesibil | Verifică că Authority e accesibil din container/server; setează RequireHttpsMetadata = false în dev |
| Rolurile nu sunt recunoscute | Keycloak pune rolurile în realm_access.roles |
Adaugă IClaimsTransformation custom (vezi secțiunea 6.2) |
| Correlation failed | Cookie de corelație pierdut între request-uri | Verifică SameSite policy și că RedirectUri e exact cel din client config |
| Token expirat după refresh | Refresh token nefolosit corect | Folosește IdentityModel.AspNetCore pentru token management automat |
10. Checklist de producție
- ✅ HTTPS obligatoriu pe toate componentele
- ✅
RequireHttpsMetadata = trueîn producție - ✅ Signing keys rotate periodic (IdentityServer are key rotation automat; Keycloak la fel)
- ✅
ClockSkewsetat la minim (30 secunde) pentru token validation - ✅ Secrets stocate în Azure Key Vault sau similar, nu în
appsettings.json - ✅ Refresh tokens cu
AbsoluteRefreshTokenLifetimelimitat - ✅ Endpoint-uri de admin Keycloak (port 9000) izolate în rețea privată
- ✅ Logging activat pentru
IdentityServer Eventsși trimis în Application Insights - ✅ Testezi logout-ul complet (SSO logout), nu doar ștergerea cookie-ului local
Concluzie
Integrarea cu un Authorization Server extern este una dintre deciziile arhitecturale cu cel mai mare impact asupra securității unui sistem. Fie că alegi IdentityServer pentru controlul complet în C#, fie Keycloak pentru o soluție enterprise gata configurată, codul ASP.NET Core de pe partea clientului și a API-ului rămâne remarkabil de similar — dovada că standardele OAuth2 și OpenID Connect și-au atins scopul.
Cele mai frecvente greșeli nu sunt în cod, ci în configurație: audience greșit, roluri care nu se mapează, logout incomplet. Înțelegând ce face fiecare piesă din puzzle, aceste probleme devin ușor de diagnosticat.
În articolul următor vom merge mai departe cu autentificarea prin certificat X509 — cazul în care vrei să elimini complet parolele și să folosești certificate client pentru identitate.