BFF.Auth.Keycloak
0.1.0-preview.7
dotnet add package BFF.Auth.Keycloak --version 0.1.0-preview.7
NuGet\Install-Package BFF.Auth.Keycloak -Version 0.1.0-preview.7
<PackageReference Include="BFF.Auth.Keycloak" Version="0.1.0-preview.7" />
<PackageVersion Include="BFF.Auth.Keycloak" Version="0.1.0-preview.7" />
<PackageReference Include="BFF.Auth.Keycloak" />
paket add BFF.Auth.Keycloak --version 0.1.0-preview.7
#r "nuget: BFF.Auth.Keycloak, 0.1.0-preview.7"
#:package BFF.Auth.Keycloak@0.1.0-preview.7
#addin nuget:?package=BFF.Auth.Keycloak&version=0.1.0-preview.7&prerelease
#tool nuget:?package=BFF.Auth.Keycloak&version=0.1.0-preview.7&prerelease
BFF.Auth.Keycloak
ASP.NET Core library that wires a Backend-For-Frontend (BFF) authentication stack against a Keycloak realm. The browser only ever sees an HttpOnly cookie; OIDC tokens stay on the server, session is encrypted and stored in Redis, refresh is transparent — AddKeycloakBffAuth() + MapKeycloakBffEndpoints() is all the consuming app needs.
What you get out of the box:
- OIDC Authorization Code + PKCE flow against a Keycloak realm.
- HttpOnly cookie session with
SameSite=Strict,Secure,__Host-prefix by default. - Server-side session store in Redis with tickets encrypted via ASP.NET Core Data Protection.
- Transparent token refresh in
OnValidatePrincipal— access tokens are refreshed 30 seconds before expiry; cookie lifetime tracks Keycloak'srefresh_expires_in(8-hour fallback when Keycloak does not return it). Concurrent requests hitting expiry at the same time are de-duplicated (in-process single-flight), so refresh-token rotation does not spuriously sign users out. - Refresh resilient to Keycloak outages — only a definitive rejection from Keycloak (4xx, e.g.
invalid_grant) ends the session. Transient failures (5xx, timeout, network error) keep the session alive: the request proceeds with the expired access token and the failure is negative-cached for 20 seconds, so a downed Keycloak is not hammered with one token request per incoming request. Token endpoint calls have an explicit 10-second timeout, and every failure is logged with the status code and response body. - OIDC Back-Channel Logout 1.0 with JWT signature/issuer/audience/
events/iatvalidation and Redis-backedjtireplay protection (atomicSET NX, marked only after the sessions were actually removed — a storage failure returns503 temporarily_unavailableand leaves the token retryable). Logging a user out of Keycloak terminates every session of that user in this BFF (across devices). - Role mapping from Keycloak's
resource_access[clientId].rolestoClaimsIdentity.RoleClaimType—[Authorize(Roles="admin")]andUser.IsInRole(...)just work. Roles and user profile claims (email, name, …) are re-synchronized on every token refresh from the fresh ID token returned by Keycloak, so a role granted or revoked in Keycloak propagates to the BFF within the access-token lifetime (typically ~5 minutes) instead of requiring a re-login. Immediate revocation still goes through back-channel logout. The mapping is pluggable viamapRoles— a single delegate covers both sign-in and refresh, so the two paths cannot drift apart. ClaimsPrincipalextensions (User.UserId(),User.Email(),User.KeycloakRoles(), …) — discoverable via IntelliSense, no need to remember claim names likesuborpreferred_username.- 401/403 instead of redirects — fits SPA frontends that detect 401 and navigate to
/auth/loginthemselves. - No global service hijacking — the library's Redis connection and cache are registered as keyed services private to the library, and the session store is wired directly into the cookie handler rather than registered as a global
ITicketStore. Your ownIDistributedCache/AddStackExchangeRedisCache/ITicketStoreregistrations (prefix, connection, everything) are left untouched.
Ready-to-use HTTP endpoints exposed under /auth:
| Method | Path | Description |
|---|---|---|
GET |
/auth/login |
Starts OIDC challenge or local-redirects when already authenticated. |
POST |
/auth/logout |
RP-Initiated Logout (clears cookie + signs out of Keycloak). |
GET |
/auth/users/me |
Returns the logged-in user as UserInfoDto (Cache-Control: no-store). |
POST |
/auth/backchannel-logout |
Server-to-server endpoint invoked by Keycloak on SSO logout. |
/auth/loginand/auth/logoutrequire top-level browser navigation — do not call them withfetch/XHR. Both reply with a302to Keycloak. Insidefetchthe cross-origin redirect fails the CORS check, and even if it did not,fetchnever changes the browser's location, soPostLogoutRedirectUriwould never take effect. Navigate for login, submit a form for logout:// login window.location.href = '/auth/login?returnUrl=' + encodeURIComponent(location.pathname); // logout — POST, so it needs a form rather than window.location const form = document.createElement('form'); form.method = 'post'; form.action = '/auth/logout'; document.body.appendChild(form); form.submit();
/auth/users/meis the only endpoint meant to be called withfetch.
Getting started locally
Prerequisites
- .NET 10 SDK
- A reachable Redis instance (local Docker container, managed service, or remote)
- A configured Keycloak realm with an OIDC client (see Keycloak configuration)
Install
The package is published on NuGet as a prerelease:
dotnet add package BFF.Auth.Keycloak --prerelease
Configuration (appsettings.json)
Add a KeycloakAuth section:
{
"KeycloakAuth": {
"Authority": "https://keycloak.example.com/realms/my-realm",
"ClientId": "my-bff",
"ClientSecret": "REPLACE-ME",
"RedisConnectionString": "redis:6379,password=...",
"SessionKeyNamespace": "my-bff",
"CookieName": "__Host-session",
"PostLogoutRedirectUri": "/",
"LoginFailureRedirectUri": "/"
}
}
| Key | Required | Notes |
|---|---|---|
Authority |
yes | Full realm URL: https://<host>/realms/<realm>. Must be https; http is accepted only for loopback hosts (localhost, 127.0.0.1) — a dev Keycloak on http://localhost:8080 works, http://keycloak:8080 from a compose network does not. Validated at startup. |
ClientId |
yes | OIDC client ID from Keycloak Admin Console. |
ClientSecret |
yes | Credentials → Secret in Keycloak Admin Console. |
RedisConnectionString |
yes | StackExchange.Redis connection string. The library forces AbortOnConnectFail=false regardless of the string: when Redis is down the multiplexer keeps reconnecting in the background instead of throwing, and commands issued meanwhile wait in the backlog up to AsyncTimeout (5 s by default). |
SessionKeyNamespace |
yes | Prefix for Redis keys. Lowercase, alphanumeric, hyphens allowed, max 32 characters. Changing it invalidates existing sessions. |
CookieName |
no | Defaults to __Host-session. The __Host- prefix mandates Secure and no Domain. |
PostLogoutRedirectUri |
no | Local path the user lands on after full logout. Must start with /. Defaults to /. |
LoginFailureRedirectUri |
no | Local path the user lands on when login does not complete — Keycloak unreachable during the code exchange, correlation cookie expired (login form left open longer than RemoteAuthenticationTimeout, 15 min by default), or an error returned on the callback. Must start with /. Defaults to /. Without it those cases surface as HTTP 500. |
All required fields are validated at startup via AddOptionsWithValidateOnStart — missing values throw an exception instead of failing silently at runtime.
Minimal Program.cs
using System.Security.Claims;
using BFF.Auth.Keycloak;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddKeycloakBffAuth();
// Optional: if your app runs behind nginx/Traefik — see "Behind a reverse proxy" below.
builder.Services.AddReverseProxyForwardedHeaders();
var app = builder.Build();
app.UseForwardedHeaders(); // only if AddReverseProxyForwardedHeaders was called
app.UseAuthentication();
app.UseAuthorization();
app.MapKeycloakBffEndpoints(); // /auth/login, /auth/logout, /auth/users/me, /auth/backchannel-logout
// Your own endpoints — use ClaimsPrincipal and the library's extension methods:
app.MapGet("/profile", (ClaimsPrincipal user) =>
Results.Ok(new { UserId = user.UserId(), Email = user.Email(), Roles = user.KeycloakRoles() }))
.RequireAuthorization();
app.Run();
Behind a reverse proxy
When TLS terminates at a proxy (nginx, Traefik, Nginx Proxy Manager), the app sees plain
http://<container>:8080 requests. The OIDC callback URLs are built from the request's scheme and
host, so without forwarded headers Keycloak rejects them as unregistered redirect URIs.
AddReverseProxyForwardedHeaders() processes X-Forwarded-For, X-Forwarded-Proto and
X-Forwarded-Host from any address — you don't need to know or pin the proxy's network
(Docker address pools, Kubernetes, cloud load balancers). You still have to call
app.UseForwardedHeaders() before UseAuthentication().
Security relies on network isolation. The app's port must be reachable only through the proxy — in Docker, don't publish it (no
ports:in compose; the proxy reaches the container over a shared network). If clients can reach the app directly, they can spoof their IP, scheme and host.
To trust specific proxies only, skip the helper and configure ForwardedHeadersOptions yourself:
using Microsoft.AspNetCore.HttpOverrides;
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor |
ForwardedHeaders.XForwardedProto |
ForwardedHeaders.XForwardedHost;
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
options.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("172.18.0.0/16")); // your proxy network(s)
});
Data Protection key ring (persisting sessions across restarts and replicas)
The session cookie, the Redis ticket, and (as of the Redis-backed refresh lock — see Deployment caveats) the shared token-refresh result are all encrypted with ASP.NET Core Data Protection. By default the encryption keys are generated inside the container and lost whenever the container is recreated — i.e. on every redeploy — which silently signs out every user. Worse, each replica generates its own keys, so with more than one instance a request that lands on a different replica than the one that issued the cookie cannot decrypt it either.
Persist the key ring to Redis — the same server this library already requires — so every replica shares one key ring regardless of how many there are or which node(s) they run on:
// Requires the Microsoft.AspNetCore.DataProtection.StackExchangeRedis package.
var dataProtectionRedis = ConnectionMultiplexer.Connect("<same connection string as KeycloakAuth:RedisConnectionString>");
builder.Services.AddDataProtection()
.PersistKeysToStackExchangeRedis(dataProtectionRedis, "my-bff-dp-keys")
.SetApplicationName("my-bff"); // keep stable across deploys
dataProtectionRedis is a plain connection your own code owns — it is not the library's
internal Redis connection (that one is registered as a private keyed service and deliberately not
exposed to your container), so create it yourself, pointed at the same Redis server. The string
passed to PersistKeysToStackExchangeRedis is the Redis key the key ring is stored under — pick
something that will not collide with anything else in that Redis instance, and, like
SetApplicationName, keep it identical across every replica.
Skipping this does not break anything on a single instance — the app behaves exactly as without this config: keys land in the container's ephemeral filesystem and users are signed out the next time the container is recreated. With more than one replica it is no longer optional: without a shared key ring, requests bounce between replicas that cannot read each other's cookies, sessions, or refresh locks, and users are signed out at random regardless of load balancing.
Prefer
PersistKeysToFileSystemwith a durable (not necessarily shared) path instead if you run a single instance and would rather not put the key ring in Redis — a mounted volume works fine there and keeps the keys out of the same store as the encrypted tickets. It stops being a valid option the moment you scale to more than one replica in Swarm/Kubernetes, since a plain named volume is local to one node and is not synchronized to the others.
Customizing OIDC options
AddKeycloakBffAuth accepts an optional Action<OpenIdConnectOptions> callback that runs after the library's own defaults, so anything you set in it wins. Typical use case is requesting extra scopes:
builder.Services.AddKeycloakBffAuth(oidc =>
{
oidc.Scope.Add("phone");
oidc.Scope.Add("address");
});
offline_accessis not supported. Offline tokens come back withrefresh_expires_in = 0, which the library reads as "no lifetime given" — the cookie falls back to 8 hours at sign-in and refreshes never extend it, so the session dies 8 hours after login regardless of how long the refresh token stays valid.
Scopes already requested by the library: openid, profile, email, roles. The first three back the ClaimsPrincipal extensions (Email(), GivenName(), …); roles ensures resource_access reaches the token regardless of whether the scope is Default or Optional in the realm.
Custom role mapping
By default the library reads resource_access[<ClientId>].roles from the ID token into ClaimsIdentity.RoleClaimType ("roles"). Pass mapRoles to replace that mapping — for example to also honour realm roles:
public delegate IEnumerable<string> KeycloakRoleMapper(
IReadOnlyCollection<Claim> tokenClaims, // every claim of the freshly issued ID token
string clientId); // the configured OIDC ClientId
The returned names replace every existing role claim, so an empty result revokes all roles — same contract as the default mapping.
builder.Services.AddKeycloakBffAuth(mapRoles: (tokenClaims, clientId) =>
{
string? ClaimValue(string type) => tokenClaims.FirstOrDefault(c => c.Type == type)?.Value;
var roles = new List<string>();
// client roles — {"resource_access":{"<clientId>":{"roles":["admin"]}}}
if (ClaimValue("resource_access") is { } resourceAccess)
{
using var doc = JsonDocument.Parse(resourceAccess);
if (doc.RootElement.TryGetProperty(clientId, out var client) &&
client.TryGetProperty("roles", out var clientRoles))
{
roles.AddRange(clientRoles.EnumerateArray().Select(r => r.GetString()!));
}
}
// realm roles — {"realm_access":{"roles":["admin"]}}, prefixed so a realm role cannot
// silently satisfy an [Authorize] written for a client role of the same name
if (ClaimValue("realm_access") is { } realmAccess)
{
using var doc = JsonDocument.Parse(realmAccess);
if (doc.RootElement.TryGetProperty("roles", out var realmRoles))
{
roles.AddRange(realmRoles.EnumerateArray()
.Select(r => r.GetString())
.Where(r => r is not null && !r.StartsWith("default-roles-"))
.Select(r => $"realm:{r}"));
}
}
return roles;
});
Realm roles reach the ID token only if you enable Add to ID token on the realm's realm roles mapper (Client scopes → roles → Mappers) — the same switch the client roles mapper needs. Keycloak also puts offline_access, uma_authorization and default-roles-<realm> in there; filter what you do not want, as above.
Do not let the mapper throw. The built-in mapping treats a malformed resource_access claim as zero roles; a custom mapper has no such net. On sign-in a throw surfaces as a redirect to LoginFailureRedirectUri, which is survivable. On the refresh path it is not caught: the request fails with a 500 and the rotated tokens are never persisted, so the next request retries the refresh with the already-spent refresh token — with Keycloak's refresh-token rotation enabled that returns invalid_grant and signs the user out. Parse defensively.
Three things worth knowing:
- The delegate runs at exactly two points — sign-in and every successful token refresh, i.e. whenever Keycloak issues a new ID token. It is deliberately not an
IClaimsTransformationand does not run per request: the principal comes from the session and its claims only change together with a new ID token, so per-request mapping would compute the same answer at the cost of parsing a token on every request. - The same delegate serves both paths. Anything it cannot reconstruct from the ID token alone (an external lookup, the access token, request state) will survive sign-in and then vanish at the first refresh — within minutes, in production, long after the code looked correct in testing.
- The "no
resource_accessclaim" warning is suppressed once a custom mapper is registered, sinceresource_accessis then no longer necessarily where roles come from.
Reading user info
The library exposes KeycloakClaimsExtensions on ClaimsPrincipal so you do not have to remember OIDC claim names. The same accessors work in MVC controllers (via User), minimal API handlers (ClaimsPrincipal parameter), and application services (pass ClaimsPrincipal as a method parameter — keeps the service testable without mocking HttpContext):
app.MapGet("/profile", (ClaimsPrincipal user) => Results.Ok(new
{
UserId = user.UserId(),
Username = user.Username(),
Email = user.Email(),
GivenName = user.GivenName(),
Roles = user.KeycloakRoles(),
})).RequireAuthorization();
Available accessors: UserId, Username, Email, IsEmailVerified, DisplayName, GivenName, FamilyName, KeycloakRoles.
Custom shape. If UserInfoDto (returned by /auth/users/me) does not fit, register your own endpoint with whatever claims you need:
app.MapGet("/auth/me", (ClaimsPrincipal user) => Results.Ok(new
{
UserId = user.UserId(),
Email = user.Email(),
BirthDate = DateOnly.TryParse(user.FindFirstValue("birth_date"), out var b) ? b : (DateOnly?)null,
})).RequireAuthorization();
Keycloak configuration
In Keycloak Admin Console → Clients → <your client> → Settings:
- Valid Redirect URIs:
https://<bff-host>/signin-oidc - Valid Post Logout Redirect URIs:
https://<bff-host>/signout-callback-oidc— this is thepost_logout_redirect_urithe OIDC handler sends to Keycloak's end-session endpoint (always the absolute URL of the sign-out callback, regardless of thePostLogoutRedirectUrisetting, which is only used for the final in-app redirect). If it is not registered here, Keycloak shows an "Invalid redirect uri" error page after logout. - Backchannel Logout URL:
https://<bff-host>/auth/backchannel-logout - Backchannel Logout Session Required:
ON - Backchannel Logout Revoke Offline Sessions: optional, depending on your offline-token policy
- Client roles must be enabled in the ID token mapper if you rely on
IsInRole/[Authorize(Roles=...)].
Behind a reverse proxy: the sign-in/sign-out callback URLs above are built from the incoming request's scheme and host. Without correctly configured forwarded headers (
AddReverseProxyForwardedHeaders+app.UseForwardedHeaders()) the handler buildshttp://<internal-host>/..., which will not match the URIs registered in Keycloak. See Behind a reverse proxy.
Deployment caveats
- Multiple replicas — no sticky sessions required, but the key ring must be shared. Token
refreshes are de-duplicated across replicas with a Redis lock (the same Redis the library already
requires): only one replica ever calls Keycloak for a given refresh token, so Docker Swarm's
routing mesh (which load-balances round-robin, with no built-in sticky sessions) is safe. The
losing replica reuses the winner's result instead of racing it — but only if it can decrypt
that result, which requires every replica to share the same Data Protection key ring (see
above). Without a
shared key ring the lock still prevents two replicas calling Keycloak concurrently, but each
ends up performing its own sequential refresh once the lock is released — which still hits
invalid_grantunder Keycloak's refresh-token rotation ("Revoke Refresh Token"). In short: a shared key ring is not optional once you run more than one replica, on Swarm or otherwise. - Persist the Data Protection key ring (see above) — otherwise every redeploy signs out all users, and (per the previous point) scaling out to multiple replicas signs users out at random regardless of load balancing.
Out of scope
Things this library deliberately leaves to the consumer:
- CSRF/antiforgery for
POSTendpoints. Cross-site CSRF is already covered by the session cookie'sSameSite=Strict— a form posted fromevil.comdoes not carry the cookie. (The__Host-prefix does not contribute here; it only enforcesSecure,Path=/and noDomain, which prevents a subdomain from overwriting the cookie.) The gapSameSiteleaves is that it is scoped to the site (eTLD+1), not the origin: if the BFF runs onapp.example.com, a request from any*.example.com— an XSS on a sibling app, a taken-over subdomain — is same-site and the cookie is sent. WireAddAntiforgeryon your own mutating endpoints if the BFF shares a domain with other applications. The library's own surface needs nothing:/auth/backchannel-logoutauthenticates with Keycloak's JWT rather than the cookie, and a forgedPOST /auth/logoutonly signs the user out. - Forwarding the access token to downstream APIs — the token is stored in the session ticket and
kept fresh by the transparent refresh, so read it with the standard framework API wherever an
authenticated
HttpContextis available (await ctx.GetTokenAsync("access_token")fromMicrosoft.AspNetCore.Authentication) and attach it as aBearerheader yourself — e.g. in a smallDelegatingHandleron yourHttpClient. Two rules: the token is only available inside a user's request (background/hosted services have noHttpContext— machine-to-machine calls should use the client credentials flow instead), and it must never be returned to the browser — that would defeat the point of the BFF pattern. - Data Protection key ring persistence — the library does not configure it; see Data Protection key ring for the recommended volume setup.
- Redis health checks — add
AspNetCore.HealthChecks.Redisin the consumer if needed. - Front-channel logout — only back-channel is supported.
- Pushed Authorization Requests (PAR) — PKCE is considered sufficient for the BFF flow.
- A Keycloak Admin REST client — out of scope for an auth library.
- HTTPS redirection / forwarded headers pipeline middleware — controlled by the consumer (the library only offers the optional
AddReverseProxyForwardedHeaders()options helper).
Project status
Early development. Published on NuGet as 0.1.0-preview.x — the API may still change between previews. The library compiles cleanly on net10.0 (0 errors, 0 warnings) and ships with an automated test suite (101 tests: token-refresh single-flight (in-process and cross-replica via Redis lock), races and failure classification, back-channel logout token validation, replay protection and storage-failure handling, RedisTicketStore round-trips and per-user session index, cookie-event role and profile-claim re-sync, role extraction and custom role mapping, URL safety, forwarded headers). CI runs build and tests on every push and pull request; a version tag (v*) additionally packs and publishes to NuGet.
Changelog
Breaking changes and notable fixes per release are documented in
CHANGELOG.md.
While the version stays 0.x, breaking changes may land in any preview release.
License
MIT — see LICENSE.txt.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.Authentication.OpenIdConnect (>= 10.0.4)
- Microsoft.Extensions.Caching.StackExchangeRedis (>= 10.0.7)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0-preview.7 | 44 | 9/29/2026 |
| 0.1.0-preview.6 | 47 | 9/28/2026 |
| 0.1.0-preview.5 | 70 | 7/31/2026 |
| 0.1.0-preview.4 | 69 | 7/31/2026 |
| 0.1.0-preview.3 | 73 | 7/25/2026 |
| 0.1.0-preview.2 | 66 | 7/23/2026 |
| 0.1.0-preview.1 | 64 | 7/21/2026 |