Bff.AspNetCore 1.11.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package Bff.AspNetCore --version 1.11.0
                    
NuGet\Install-Package Bff.AspNetCore -Version 1.11.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Bff.AspNetCore" Version="1.11.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Bff.AspNetCore" Version="1.11.0" />
                    
Directory.Packages.props
<PackageReference Include="Bff.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Bff.AspNetCore --version 1.11.0
                    
#r "nuget: Bff.AspNetCore, 1.11.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Bff.AspNetCore@1.11.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Bff.AspNetCore&version=1.11.0
                    
Install as a Cake Addin
#tool nuget:?package=Bff.AspNetCore&version=1.11.0
                    
Install as a Cake Tool

Bff.AspNetCore

A reusable Backend-For-Frontend engine for ASP.NET Core. It terminates authentication server-side: the browser only ever receives an opaque, httpOnly session cookie, while the Keycloak access / refresh / id tokens live in a Redis-backed token vault. Downstream API calls are reverse-proxied through YARP with a Bearer token attached server-side.

Each per-app BFF (bff-katalogos, bff-erevna, ...) is a ~20-line Program.cs wrapping this package.

Why

A SPA that holds a refresh token in browser storage is one XSS away from a long-lived account takeover. The BFF removes the token from the browser entirely. The native branded login form is preserved — the SPA posts credentials to its own-origin BFF, which does server-to-server ROPC against Keycloak with a confidential client.

What it provides

Component Responsibility
Cookie auth + Redis session store __Host-bff-{app} cookie carries only a session id; tokens live in Redis at bff:{app}:sess:{id}
Redis-backed DataProtection key ring Key ring persisted to Redis at bff:{app}:dataprotection-keys so cookies survive pod restarts and work across replicas; per-app scoped via SetApplicationName("bff-{app}")
Server-side ROPC client grant_type=password against KC using the confidential client
YARP token-forwarding transform /bff/api/* → downstream services with Authorization: Bearer attached
Silent refresh + SETNX lock Stale access tokens refreshed server-side; a per-session Redis lock prevents concurrent-tab races
Auth endpoints /bff/config, /bff/login, /bff/logout, /bff/me, /bff/otp/request, /bff/otp/verify, /bff/pin/login, /bff/register, /bff/forgot-password, /bff/reset-password
Anti-forgery (CSRF) Custom X-BFF-Csrf header + Origin/Referer allow-list on state-changing requests
Hardening No request-body logging, TLS to KC, token/secret redaction in logs

Quick start

A complete per-app BFF Program.cs:

using Bff.AspNetCore.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddBffServices(builder.Configuration);

var app = builder.Build();
app.UseBff();
app.Run();

appsettings.json (secrets come from environment / K8s Secret):

{
  "Bff": {
    "AppName": "katalogos",
    "Keycloak": {
      "Authority": "https://identity.dloizides.com",
      "Realm": "onlinemenu",
      "ClientId": "bff-katalogos-client",
      "ClientSecret": "${BFF_CLIENT_SECRET}"
    },
    "Redis": { "ConnectionString": "redis:6379" },
    "Proxy": {
      "SpaUpstream": "http://katalogos-web",
      "Downstreams": [
        { "Segment": "menus", "Upstream": "http://onlinemenu-api" }
      ]
    },
    "Csrf": {
      "AllowedOrigins": [ "https://katalogos.dloizides.com" ]
    },
    "Methods": {
      "Default": [ "Password" ],
      "RoleOverrides": {
        "door-staff": [ "Pin" ]
      }
    },
    "TenantProxy": {
      "TenantServiceUpstream": "http://tenant-api"
    },
    "Registration": {
      "Enabled": null
    }
  }
}

Registration — is self-serve signup a product you offer?

Bff:Registration:Enabled is a nullable bool gating POST /bff/register and the registrationEnabled capability on GET /bff/config (one resolution, so the endpoint and the advertised capability can never disagree):

Value Behaviour
unset (default) Follow TenantProxy.TenantServiceUpstream — the historical behaviour. Signup is on iff an upstream is configured.
false Off, regardless of the upstream. /bff/register answers 501. OTP login, magic-link, forgot / reset password and email verification are unaffected.
true On; asserts an upstream exists. true with no TenantServiceUpstream throws at startup rather than booting a config that claims a feature it cannot serve.

Set false on a product that is sold rather than signed up for. Do not instead delete the TenantProxy section: that upstream is plumbing shared by OTP, magic-link and password recovery, and removing it silently breaks all of them. An upstream hostname must never imply a product decision.

Demo — publishing a demo credential on the login page

🔴 Bff:Demo:Username + Bff:Demo:Password are served verbatim to anonymous callers in the demo block of GET /bff/config, so a login page can print them and a visitor can sign in immediately:

{ "demo": { "publishedUsername": "demo", "publishedPassword": "TryMe!2026" } }

Both keys are required; set neither (the default) and the block is "demo": null — no banner, nothing published. A half-configured section publishes nothing rather than leaking a lone username. Never point this at a real user's credential: whatever is configured here is readable by anyone who can type a URL. It is for a throwaway account that is expected to be abused, whose blast radius is one disposable seeded demo tenant.

The optional Methods section declares which login methods this BFF offers. Default is the set every role gets; RoleOverrides restricts (or grants) a named role a different set. When Methods is omitted the BFF falls back to Password only. Listing Otp in Default opens the /bff/otp/* endpoints; without it they return 501. Listing Pin in Default opens /bff/pin/login (the event-scoped PIN login); without it it returns 501.

Public API

// Registration — bind + validate the `Bff` config section and register
// the whole engine (Redis store, ROPC client, YARP proxy, CSRF, endpoints).
IServiceCollection AddBffServices(
    this IServiceCollection services,
    IConfiguration configuration,
    Action<BffOptions>? configure = null);

// Pipeline — anti-forgery middleware + auth endpoints + reverse proxy.
WebApplication UseBff(this WebApplication app);

The SPA contract

The SPA must:

  1. Send credentials to the BFF, not KeycloakPOST /bff/login with { username, password }. The response body is { user: { ...claims } }; it never contains a token.
  2. Call downstream APIs through /bff/api/* — the cookie is sent automatically; the BFF attaches the Bearer.
  3. Send X-BFF-Csrf: 1 on every state-changing request (POST/PUT/PATCH/DELETE) to /bff/* and /bff/api/*.
  4. Treat 401 from /bff/api/* or /bff/me as "session ended" — redirect to the login form.

Endpoints

Method + path Purpose
GET /bff/config Anonymous capability descriptor — enabled login methods + registrationEnabled + the opt-in published demo credential; read by the SPA before login
POST /bff/login ROPC login; creates the session, sets the cookie
POST /bff/logout KC end-session, deletes the Redis session, clears the cookie
GET /bff/me Current user's sanitised claims, or 401
POST /bff/otp/request Proxied to TenantService send-OTP; 501 when Otp is not an enabled method
POST /bff/otp/verify Email-OTP login (KC direct-grant); creates the session, sets the cookie; 501 when Otp is disabled
POST /bff/pin/login Event-scoped PIN login (KC direct-grant with pin + eventExternalId); creates the session, sets the cookie; 501 when Pin is disabled
POST /bff/register Proxied to TenantService; 501 when Registration:Enabled is false (or no upstream is configured)
POST /bff/forgot-password Proxied to TenantService
POST /bff/reset-password Proxied to TenantService
/bff/api/{segment}/* Reverse-proxied downstream with Bearer attached
/* Reverse-proxied to the SPA's nginx upstream

Security notes

  • The session cookie is __Host--prefixed: httpOnly, Secure, SameSite=Lax, Path=/. It carries only the opaque session id.
  • Refresh-token rotation at the realm level is deferred (Phase 6). The per-session SETNX refresh lock is the concurrency protection until then.
  • Request bodies are never logged — login bodies contain passwords.
  • Set Keycloak.SkipTlsValidation only for staging's self-signed cert.

License

MIT

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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
1.16.1 100 7/22/2026
1.16.0 91 7/22/2026
1.15.0 95 7/22/2026
1.14.0 91 7/22/2026
1.13.1 102 7/20/2026
1.13.0 89 7/20/2026
1.12.0 107 7/19/2026
1.11.1 90 7/19/2026
1.11.0 97 7/19/2026
1.10.0 99 7/17/2026
1.9.1 104 7/14/2026
1.9.0 102 7/14/2026
1.8.0 97 7/13/2026
1.7.0 112 7/9/2026
1.6.0 102 7/9/2026
1.5.0 104 7/6/2026
1.4.0 108 7/4/2026
1.2.5 109 5/23/2026
1.2.4 107 5/23/2026
1.2.3 110 5/23/2026
Loading failed