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