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:
- ASP.NET Core authentication — handles the OIDC dance with Keycloak, validates the
token, and drops aClaimsPrincipalintoHttpContext.User. - XAF Security — has its own notion of a logged-in user (
ApplicationUser, roles,
permissions, object-space filtering). It does not care about yourClaimsPrincipaluntil
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
ClaimsPrincipalinto 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
ApplicationUserLoginInforow 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
Defaultrole (created if missing). In a real deployment you'd map Keycloak
realm/client roles onto XAF roles here. given_name,family_name,email,nameclaims are copied onto the user object
reflectively, so the same provider works whether or not yourApplicationUserhas 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:
- XAF
SignInManagersign-out. - ASP.NET Core cookie sign-out.
- Keycloak front-channel sign-out (redirect to the IdP's
end_sessionendpoint 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
IAuthenticationProviderV2is 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 atunauthorized_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.