RO EN

Integrare cu IdentityServer și Keycloak în ASP.NET Core

Integrare cu IdentityServer și Keycloak în ASP.NET Core
Doru Bulubașa
17 aprilie 2026

Î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:

  1. Creează un Realm nou (ex: myapp) — nu folosi realm-ul master pentru aplicații
  2. Creează un ClientClients → Create client:
    • Client ID: myapp-client
    • Client type: OpenID Connect
    • Client authentication: ON (confidential client)
    • Authorization: ON dacă vrei fine-grained authorization
    • Valid redirect URIs: https://localhost:5001/*
    • Web origins: https://localhost:5001
  3. Creează UsersUsers → Add user
  4. Creează RolesRealm roles sau Client roles
  5. Adaugă Scopes customClient 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 ApiResource definit în Config.cs. De exemplu, dacă ai new ApiResource("myapi"), atunci aud = "myapi".
  • Keycloak: implicit, audience-ul este account sau 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:

  1. Ștergi cookie-ul local al aplicației
  2. Revoci token-urile la Authorization Server
  3. Redirecționezi browser-ul la end_session_endpoint al 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)
  • ClockSkew setat la minim (30 secunde) pentru token validation
  • ✅ Secrets stocate în Azure Key Vault sau similar, nu în appsettings.json
  • ✅ Refresh tokens cu AbsoluteRefreshTokenLifetime limitat
  • ✅ 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.