Jun 1, 20269 min read/2026/06/01/integrating-keycloak-authentication-into-devexpress-xaf-blazor/

Integrating Keycloak Authentication into a DevExpress XAF Blazor App

Every serious line-of-business app eventually needs Single Sign-On. You want your users to
log in once, against a central identity provider, and have that identity flow into every
app you ship — including the DevExpress XAF apps that run your back office.

This post is the write-up of a small, self-contained reference project I put together for
exactly that: egarim/XafKeycloakAuth — a
DevExpress XAF Blazor Server app that authenticates against Keycloak
over OpenID Connect, auto-provisions XAF users from the token claims, and logs out cleanly
across all three layers. The repo ships the module, the Blazor server host, a WinForms host,
27 PowerShell scripts that stand up the realm for you, and two long-form guides.

I'll walk through the design, the parts that matter, and the one configuration trap that
costs everybody an afternoon the first time.

Stack: .NET 9, DevExpress XAF 25.1+, Keycloak 23.x (via Docker),
Microsoft.AspNetCore.Authentication.OpenIdConnect. XAF Security in Integrated Mode.

The mental model: two security systems, one identity

The thing to understand before you write a single line is that you are gluing together two
separate security systems
that don't know about each other:

  1. ASP.NET Core authentication — handles the OIDC dance with Keycloak, validates the
    token, and drops a ClaimsPrincipal into HttpContext.User.
  2. XAF Security — has its own notion of a logged-in user (ApplicationUser, roles,
    permissions, object-space filtering). It does not care about your ClaimsPrincipal until
    you teach it to.

So the integration is really three jobs:

  • Get a valid Keycloak token into ASP.NET Core (standard OIDC).
  • Translate that authenticated ClaimsPrincipal into an XAF user — creating one on the
    fly the first time we see them.
  • Bridge the ASP.NET Core identity to XAF's identity on every request, and tear all of it
    down on logout.

Everything below maps onto one of those three jobs.

Step 0: Stand up Keycloak

The repo automates this, but conceptually it's just Docker:

# docker-compose.yml
version: '3.8'
services:
  keycloak:
    image: quay.io/keycloak/keycloak:23.0
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin123
    ports:
      - "8080:8080"
    command: start-dev
    volumes:
      - keycloak_data:/opt/keycloak/data

volumes:
  keycloak_data:

Then a realm and a client. I did this with PowerShell against the Keycloak Admin REST API so
the whole environment is reproducible (Phase3-CreateRealm.ps1, Phase4-CreateClient.ps1,
and friends in the repo). The important part is how the client is configured — and that's
where the trap lives. Hold that thought.

Step 1: The OIDC plumbing (and the dual-scheme decision)

appsettings.json holds the Keycloak block:

{
  "Authentication": {
    "Keycloak": {
      "Authority": "http://localhost:8080/realms/xaf-realm",
      "ClientId": "xaf-blazor-app",
      "ClientSecret": "your-client-secret",
      "RequireHttpsMetadata": "false",
      "ResponseType": "code",
      "CallbackPath": "/signin-oidc",
      "SignedOutCallbackPath": "/signout-callback-oidc",
      "GetClaimsFromUserInfoEndpoint": "true",
      "SaveTokens": "true",
      "UsePkce": "false"
    }
  }
}

In Startup.cs, the registration looks ordinary — with two deliberate choices that are easy
to get wrong:

services.AddXaf(Configuration, builder => {
    builder.Security
        .UseIntegratedMode(options => { /* ... */ })
        // CRITICAL: register BOTH auth methods so XAF doesn't auto-re-authenticate
        .AddPasswordAuthentication(options => {
            options.IsSupportChangePassword = true;
        })
        .AddAuthenticationProvider<KeycloakAuthenticationProvider>();
});

var authentication = services.AddAuthentication(options => {
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    // NOTE: no DefaultChallengeScheme — see below
});

authentication.AddCookie(options => {
    options.LoginPath = "/LoginPage";
    options.ExpireTimeSpan = TimeSpan.FromMinutes(60);
    options.SlidingExpiration = true;
});

authentication.AddOpenIdConnect("Keycloak", "Keycloak", options => {
    var kc = Configuration.GetSection("Authentication:Keycloak");
    options.Authority   = kc["Authority"];
    options.ClientId    = kc["ClientId"];
    options.ClientSecret = kc["ClientSecret"];
    options.ResponseType = kc["ResponseType"];
    options.CallbackPath = kc["CallbackPath"];
    options.SignedOutCallbackPath = kc["SignedOutCallbackPath"];
    options.SaveTokens  = true;
    options.UsePkce     = false;   // ← the afternoon-saver. Keep reading.
    options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");
});

Two non-obvious decisions:

  • I register AddPasswordAuthentication() and the Keycloak provider. Having more than
    one authentication method registered is what stops XAF from silently re-authenticating a
    user the moment they hit a protected page — which is what makes a real logout possible.
  • I deliberately do not set DefaultChallengeScheme. If you set it to "Keycloak", every
    unauthenticated request bounces straight to Keycloak and the user never gets to choose. By
    leaving it unset, the standard XAF login page still works and the "Login with Keycloak"
    button works.

Step 2: Turn a token into an XAF user

This is the heart of it. XAF exposes IAuthenticationProviderV2 precisely so you can plug an
external identity into its security pipeline. My KeycloakAuthenticationProvider reads the
claims, looks for an existing ApplicationUserLoginInfo, and — if it's a first-time login —
auto-provisions the XAF user from the Keycloak claims:

public class KeycloakAuthenticationProvider : IAuthenticationProviderV2
{
    private readonly IPrincipalProvider principalProvider;
    public KeycloakAuthenticationProvider(IPrincipalProvider principalProvider)
        => this.principalProvider = principalProvider;

    public object Authenticate(IObjectSpace objectSpace)
    {
        if (!CanHandlePrincipal(principalProvider.User)) return null;

        var claimsPrincipal = (ClaimsPrincipal)principalProvider.User;

        // Keycloak puts the stable user id in "sub"
        var userIdClaim = claimsPrincipal.FindFirst("sub")
            ?? claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)
            ?? throw new InvalidOperationException("Unknown user id - missing 'sub'");

        var providerUserKey   = userIdClaim.Value;
        var loginProviderName = claimsPrincipal.Identity.AuthenticationType;
        var userName = claimsPrincipal.FindFirst("preferred_username")?.Value
            ?? claimsPrincipal.Identity.Name;

        // Returning user?
        var existing = FindUserLoginInfo(objectSpace, loginProviderName, providerUserKey);
        if (existing != null) return existing.User;

        // First login → create the ApplicationUser from claims
        return CreateApplicationUser(objectSpace, claimsPrincipal,
                                     userName, loginProviderName, providerUserKey);
    }
    // ...
}

A few details worth calling out from CreateApplicationUser:

  • The link between "this Keycloak subject" and "this XAF user" is an
    ApplicationUserLoginInfo row keyed on (LoginProviderName, ProviderUserKey) — created via
    ((ISecurityUserWithLoginInfo)user).CreateUserLoginInfo(...). That's the same mechanism XAF
    uses for Google/Microsoft external login.
  • XAF requires a password on ApplicationUser, so I set a throwaway one:
    user.SetPassword(Guid.NewGuid().ToString()). The user never uses it — they authenticate
    through Keycloak — but the object model demands a value.
  • New users get a Default role (created if missing). In a real deployment you'd map Keycloak
    realm/client roles onto XAF roles here.
  • given_name, family_name, email, name claims are copied onto the user object
    reflectively, so the same provider works whether or not your ApplicationUser has those
    properties.

Step 3: Bridge the identity on every request

There's a subtlety: even after the OIDC handshake succeeds, the ClaimsPrincipal on
HttpContext.User carries Keycloak's authentication type, not XAF's. XAF's downstream code
expects an identity issued under SecurityDefaults.Issuer. So I run a small middleware after
UseXaf() that detects a Keycloak principal and re-issues an equivalent XAF-typed identity:

public async Task InvokeAsync(HttpContext context)
{
    // Never touch logout paths — let the sign-out flow run untouched
    if (IsLogoutPath(context.Request.Path)) { await _next(context); return; }

    if (context.User?.Identity?.IsAuthenticated == true
        && IsKeycloakAuthentication(context.User)
        && context.User.Identity.AuthenticationType != SecurityDefaults.Issuer)
    {
        var identity = new ClaimsIdentity(SecurityDefaults.Issuer);

        foreach (var type in new[] {
            ClaimTypes.NameIdentifier, ClaimTypes.Name, ClaimTypes.Email,
            ClaimTypes.GivenName, ClaimTypes.Surname,
            "sub", "preferred_username", "email", "given_name", "family_name" })
        {
            var claim = context.User.FindFirst(type);
            if (claim != null)
                identity.AddClaim(new Claim(type, claim.Value, claim.ValueType,
                                            SecurityDefaults.Issuer));
        }

        context.User = new ClaimsPrincipal(identity);
    }

    await _next(context);
}

Registration order matters — the bridge has to come after XAF is initialized:

app.UseAuthentication();
app.UseAuthorization();
app.UseXaf();
app.UseMiddleware<KeycloakXafBridgeMiddleware>();  // AFTER UseXaf()

The "skip logout paths" guard is not optional. If the bridge keeps re-asserting an
authenticated identity while you're trying to sign out, the user can never actually leave.

Step 4: Logout is a three-layer problem

This is the part people underestimate. A complete logout for this setup means tearing down
all three of the systems you stacked up:

  1. XAF SignInManager sign-out.
  2. ASP.NET Core cookie sign-out.
  3. Keycloak front-channel sign-out (redirect to the IdP's end_session endpoint so the
    Keycloak SSO session dies too — otherwise the next login is silent and "logout" feels
    broken).

The OIDC events wire up the Keycloak side:

options.Events = new OpenIdConnectEvents
{
    OnRedirectToIdentityProviderForSignOut = ctx => {
        // about to bounce to Keycloak's end-session endpoint
        return Task.CompletedTask;
    },
    OnSignedOutCallbackRedirect = ctx => {
        ctx.Response.Redirect("/");
        ctx.HandleResponse();
        return Task.CompletedTask;
    }
};

Miss any one layer and you get the classic "I clicked logout but I'm still logged in" bug —
usually because the Keycloak SSO session is still alive and the app silently re-authenticates.

The trap: PKCE + a confidential client

Now the afternoon-saver. The single most common failure when wiring Keycloak to a Blazor
Server app is this error during the token exchange:

OpenIdConnectProtocolException: Message contains error: 'unauthorized_client',
error_description: 'Unexpected error when authenticating client'

The redirect to Keycloak works. The user logs in. And then the callback blows up at
RedeemAuthorizationCodeAsync. The culprit is almost always PKCE fighting with a
confidential client
.

Here's the conflict in one paragraph: PKCE (Proof Key for Code Exchange) was designed for
public clients — SPAs, mobile, desktop — that can't keep a secret. A Blazor Server app
is a confidential client
: it runs on the server and authenticates with a client secret.
Keycloak expects either a client secret or PKCE, not both at once. Many OIDC libraries and
client templates flip PKCE on by default, so you end up sending a code_challenge and a
secret, and Keycloak rejects the combination.

The fix is to be consistent on both sides:

// ✅ Blazor Server = confidential client
options.ClientId     = "xaf-blazor-app";
options.ClientSecret = "your-client-secret";
options.UsePkce      = false;   // confidential client → no PKCE

And in the Keycloak client: Client authentication = ON, and on the Advanced tab leave
Proof Key for Code Exchange Code Challenge Method empty. The repo even ships a
Fix-PKCE-Simple.ps1 to flip it programmatically.

A quick decision table, because this comes up constantly:

Client type Can store a secret? Use PKCE Use client secret
SPA No ✅ Yes ❌ No
Mobile / native No ✅ Yes ❌ No
Blazor Server Yes ❌ No ✅ Yes
MVC web app Yes ❌ No ✅ Yes

If you remember one thing from this post: server-rendered apps are confidential clients —
turn PKCE off and use the secret.

What's in the repo

egarim/XafKeycloakAuth is meant to be cloned and
run:

  • XafKeycloakAuth.Module — the shared XAF module + business objects.
  • XafKeycloakAuth.Blazor.Server — the Blazor host with the auth provider, the bridge
    middleware, and the logout controller.
  • XafKeycloakAuth.Win — a WinForms head, to prove the module is host-agnostic.
  • 27 PowerShell scripts — phased Keycloak setup (clean slate → realm → client → fixes) so
    you can reproduce the whole identity environment from scratch.
  • KEYCLOAK_IMPLEMENTATION_GUIDE.md — the full step-by-step, far more detailed than this post.
  • PKCE-Confidential-Client-Conflict-Guide.md — a deep dive on the trap above, with detection
    and debugging techniques.

Takeaways

  • Treat it as two security systems plus a translation layer, not "add OIDC and hope."
  • An IAuthenticationProviderV2 is the right XAF seam to auto-provision users from token
    claims.
  • A small bridge middleware after UseXaf() is what makes XAF actually recognize the
    Keycloak identity — and it must ignore logout paths.
  • Logout is three layers (XAF, ASP.NET Core, Keycloak). Skip one and logout silently fails.
  • PKCE off + client secret on for Blazor Server. This one line is the difference between
    "it works" and an afternoon staring at unauthorized_client.

Clone it, point it at a local Keycloak, and you've got SSO into XAF in an afternoon — the good
kind of afternoon. Questions or war stories of your own? Find me on the links in the
about page.