Skip to content

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 CookieAccessTokenProvider is auto-detected at startup and retrieves the access token from the cookie session (stored via SaveTokens = true), enabling [ProtectedResource] and IAuthorizationServerClient to 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 ​

  1. User accesses the app → redirected to Keycloak login page
  2. User authenticates → Keycloak issues authorization code
  3. App exchanges code for ID token + access token at Keycloak's token endpoint
  4. App validates the ID token and creates a cookie session (SaveTokens = true persists the tokens in the cookie)
  5. Subsequent requests use the session cookie — no Bearer header needed
  6. When calling protected APIs, CookieAccessTokenProvider reads the access token from the cookie

Getting Started ​

1. Start Keycloak ​

bash
docker compose up -d

This 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 ​

bash
dotnet run

Or press F5 in Visual Studio / Rider. The app listens on https://localhost:44321.

3. Test users ​

UsernamePasswordRealm RoleCan access
admintestAdminAll pages
usertest(none)Home/Index only

4. Try the flows ​

PageURLAccess
Public/Home/PublicAnyone
Home (requires login)/Any authenticated user
Privacy (requires Admin role)/Home/Privacyadmin only
Workspaces list (RPT: workspace:list)/Workspacesadmin only
Workspace details (RPT: workspace:read)/Workspaces/Details/1admin only
Sign in/Account/SignInRedirects to Keycloak
Sign out/Account/SignOutEnds 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/:

FileContents
Test-realm.jsonRealm settings, test-client (confidential, authorization services enabled), Admin role, workspace resource with workspace:list/workspace:read scopes and role-based policies
Test-users-0.jsonTest users with plaintext passwords (dev only)

To export updated realm config from inside the running container:

bash
docker exec -it <container-id> /opt/keycloak/bin/kc.sh export --dir /opt/keycloak/data/import --realm Test

Keycloak 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 ​

cs
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();
cs
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