WebApp MVC
An ASP.NET Core Web app signing-in users with Keycloak.AuthServices
Scenario
This sample shows how to build a .NET Core MVC Web app that uses OpenID Connect to sign in users. It demonstrates:
- Cookie-based authentication via OIDC (
AddKeycloakWebAppAuthentication) - Authorization policies based on Keycloak realm roles
- Cookie-based access token retrieval — the
CookieAccessTokenProvideris auto-detected at startup and retrieves the access token from the cookie session (stored viaSaveTokens = true), enabling[ProtectedResource]andIAuthorizationServerClientto work in a Web App context without requiring a Bearer token in request headers. - Protected resources via Keycloak Authorization Server — the
[ProtectedResource]attribute triggers an RPT (Requesting Party Token) evaluation against Keycloak, demonstrating fine-grained resource-scope authorization.
Authentication Flow
- User accesses the app → redirected to Keycloak login page
- User authenticates → Keycloak issues authorization code
- App exchanges code for ID token + access token at Keycloak's token endpoint
- App validates the ID token and creates a cookie session (
SaveTokens = truepersists the tokens in the cookie) - Subsequent requests use the session cookie — no Bearer header needed
- When calling protected APIs,
CookieAccessTokenProviderreads the access token from the cookie
Getting Started
1. Start Keycloak
docker compose up -dThis starts Keycloak at http://localhost:8080 and automatically imports the Test realm with a pre-configured test-client and test users.
2. Run the application
dotnet runOr press F5 in Visual Studio / Rider. The app listens on https://localhost:44321.
3. Test users
| Username | Password | Realm Role | Can access |
|---|---|---|---|
admin | test | Admin | All pages |
user | test | (none) | Home/Index only |
4. Try the flows
| Page | URL | Access |
|---|---|---|
| Public | /Home/Public | Anyone |
| Home (requires login) | / | Any authenticated user |
| Privacy (requires Admin role) | /Home/Privacy | admin only |
Workspaces list (RPT: workspace:list) | /Workspaces | admin only |
Workspace details (RPT: workspace:read) | /Workspaces/Details/1 | admin only |
| Sign in | /Account/SignIn | Redirects to Keycloak |
| Sign out | /Account/SignOut | Ends session and redirects |
Log in as user and navigate to Privacy or Workspaces — you'll see the Access Denied page. Log in as admin and navigate to Privacy or Workspaces — access is granted.
The Workspaces pages use [ProtectedResource("workspace", "workspace:list")] / [ProtectedResource("workspace", "workspace:read")] which cause the middleware to call Keycloak's Authorization Server, exchange the cookie access token for an RPT, and evaluate the resource policies before granting access.
Keycloak Configuration
The realm is imported automatically from KeycloakConfiguration/:
| File | Contents |
|---|---|
Test-realm.json | Realm settings, test-client (confidential, authorization services enabled), Admin role, workspace resource with workspace:list/workspace:read scopes and role-based policies |
Test-users-0.json | Test users with plaintext passwords (dev only) |
To export updated realm config from inside the running container:
docker exec -it <container-id> /opt/keycloak/bin/kc.sh export --dir /opt/keycloak/data/import --realm TestKeycloak Admin Console
Open http://localhost:8080 and log in with admin / admin.
Role Mapping
WARNING
By default Keycloak doesn't map roles to id_token, so we need an access_token in this case, access_token is NOT available in all OAuth flows. For example, "Implicit Flow" is based on id_token and access_token is not retrieved at all.
Code
using Keycloak.AuthServices.Authentication;
using Keycloak.AuthServices.Authorization;
using Keycloak.AuthServices.Authorization.AuthorizationServer;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<CookiePolicyOptions>(options =>
{
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.Unspecified;
});
builder
.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddKeycloakWebApp(
builder.Configuration.GetSection(KeycloakAuthenticationOptions.Section),
configureOpenIdConnectOptions: options =>
{
// we need this for front-channel sign-out
options.SaveTokens = true;
options.ResponseType = OpenIdConnectResponseType.Code;
options.Events = new OpenIdConnectEvents
{
OnSignedOutCallbackRedirect = context =>
{
context.Response.Redirect("/Home/Public");
context.HandleResponse();
return Task.CompletedTask;
},
};
}
);
builder
.Services.AddKeycloakAuthorization(builder.Configuration)
.AddAuthorizationBuilder()
.AddPolicy("PrivacyAccess", policy => policy.RequireRealmRoles("Admin"));
// AddAuthorizationServer registers IKeycloakAccessTokenProvider with auto-detection:
// - If cookie auth is registered → CookieAccessTokenProvider (for Web Apps with SaveTokens = true)
// - Otherwise → HttpContextAccessTokenProvider (for Web APIs with Bearer tokens)
// UseProtectedResourcePolicyProvider enables dynamic [ProtectedResource] policy resolution.
builder.Services.AddAuthorizationServer(builder.Configuration);
builder.Services.Configure<KeycloakAuthorizationServerOptions>(options =>
options.UseProtectedResourcePolicyProvider = true
);
builder.Services.AddControllersWithViews(options => options.AddProtectedResources());
builder.Services.AddRazorPages();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseCookiePolicy();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}")
.RequireAuthorization();
app.MapRazorPages();
app.Run();namespace WebApp_OpenIDConnect_DotNet.Controllers;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
public class AccountController(ILogger<AccountController> logger) : Controller
{
private readonly ILogger<AccountController> logger = logger;
[AllowAnonymous]
public IActionResult SignIn()
{
if (!this.User.Identity!.IsAuthenticated)
{
return this.Challenge(OpenIdConnectDefaults.AuthenticationScheme);
}
return this.RedirectToAction("Index", "Home");
}
[AllowAnonymous]
public async Task<IActionResult> SignOutAsync()
{
if (!this.User.Identity!.IsAuthenticated)
{
return this.Challenge(OpenIdConnectDefaults.AuthenticationScheme);
}
var idToken = await this.HttpContext.GetTokenAsync("id_token");
var authResult = this
.HttpContext.Features.Get<IAuthenticateResultFeature>()
?.AuthenticateResult;
var tokens = authResult!.Properties!.GetTokens();
var tokenNames = tokens.Select(token => token.Name).ToArray();
this.logger.LogInformation("Token Names: {TokenNames}", string.Join(", ", tokenNames));
return this.SignOut(
new AuthenticationProperties
{
RedirectUri = "/",
Items = { { "id_token_hint", idToken } },
},
CookieAuthenticationDefaults.AuthenticationScheme,
OpenIdConnectDefaults.AuthenticationScheme
);
}
[AllowAnonymous]
public IActionResult AccessDenied() => this.RedirectToAction("AccessDenied", "Home");
}See sample source code: keycloak-authorization-services-dotnet/tree/main/samples/WebApp