b17s.Porta

b17s.Porta

Backend-for-Frontend library for ASP.NET Core

CI
NuGet
License: MIT

Porta is your way to create completely customizable, easy-to-implement, boiler-plate code free Backend-For-Frontend (BFF) services with opinionated, sensible defaults. Its architecture hooks into ASP.Net Core's minimal API design, and extends it via transformer-based API aggregation with multi-frontend/backend authentication support.

IMPORTANT Porta is not yet battle-hardened. For this reason it is advised to not use it on edge yet, but have a reverse proxy in front of it.

Features

  • Transformer Pattern: Clean separation between API contracts and backend calls
  • Multi-Backend Aggregation: Combine data from multiple backends in a single endpoint
  • Per-Backend Authentication: Configure different auth policies for each backend
  • Startup Validation: Catch configuration errors at application startup, not runtime
  • OIDC Endpoints: Opt-in login, logout, and back-channel logout middleware
  • Session Administration: Opt-in REST endpoints for session management
  • Raw Forwarding: Zero-code proxy endpoints for binary content, files, and non-JSON APIs
  • GraphQL Support: REST facade over GraphQL backends
  • OpenTelemetry: Automatic distributed tracing for all transformers and backend calls

Installation

dotnet add package b17s.Porta

Quick Start

Minimal Setup (Mapped Passthrough; No Auth)

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddPortaCore();

var app = builder.Build();

app.MapPassThrough<ProductsResponse>()
    .FromGet("/api/products")
    .ToGet("https://products-api.internal/products")
    .AllowAnonymous()
    .Build();

app.Run();

Note: endpoints require authorization by default (PortaCoreOptions.RequireAuthorizationByDefault = true). Drop .AllowAnonymous() only when you've configured an authentication scheme. Flip the option to false to invert the default if you'd rather opt into auth per endpoint.

With OIDC Authentication

Same pass-through endpoint as above, but now it requires a logged-in user and forwards their access token to the backend. The only new ingredients are the OIDC pipeline registration (AddPortaAuthentication, bound from configuration) and dropping .AllowAnonymous() in favour of .RequireAuth(). See the OIDC Endpoints guide for the login/logout middleware.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddPortaCore(options => {
    options.TrustedHosts = ["https://products-api.internal"];
});

// Wire up the OIDC pipeline (cookie + OIDC handler + ticket store) from configuration.
builder.Services.AddPortaAuthentication(builder.Configuration);

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

app.MapPassThrough<ProductsResponse>()
    .FromGet("/api/products")
    .ToGet("https://products-api.internal/products")
    .RequireAuth()                                      // 401 unless the user is logged in
    .WithBackendAuth(BackendAuthPolicies.BearerToken)   // forward the user's token to the backend
    .Build();

app.Run();

Raw Passthrough (Binary, Files, Non-JSON)

When you don't want to parse or reshape the response — file downloads, streaming, XML — use MapRawForward(). The body is streamed straight through; no response type is needed. See Raw Forwarding for header handling and size/timeout limits.

app.MapRawForward()
    .FromGet("/api/files/{id}")
    .ToGet("https://files-api.internal/files/{id}")
    .RequireAuth()
    .WithBackendAuth(BackendAuthPolicies.BearerToken)
    .Build();

Catch-all routes must use catch-all syntax on both sides. A plain {name} placeholder is encoded as a single path segment — any / in the value becomes %2F (secure by default, so a route value can't introduce new path segments). To proxy a multi-segment subtree, use {**name} in both the route pattern and the backend URL. Mixing them (catch-all route, plain backend placeholder) encodes the slashes and 404s at the backend:

app.MapRawForward()
    .FromGet("/files/{**rest}")
    .ToGet("https://files-api.internal/files/{**rest}")  // not {rest}
    .Build();
// /files/a/b/c.pdf  ->  https://files-api.internal/files/a/b/c.pdf

Introducing a Transformer

A transformer is where you reshape the backend response. Inherit a base class (here PassThroughTransformer<T>, overriding TransformResponse to strip an internal field), register it with AddTransformer<T>(), then map it with MapTransformer<,>(). See Transformers for the full hierarchy.

public class ProductsTransformer : PassThroughTransformer<ProductsResponse>
{
    // Strip the internal cost price the frontend should never see.
    protected override ProductsResponse TransformResponse(ProductsResponse response, TransformerContext context)
        => response with { Items = response.Items.Select(i => i with { CostPrice = null }).ToList() };
}
builder.Services.AddTransformer<ProductsTransformer>();

app.MapTransformer<ProductsTransformer, ProductsResponse>()
    .FromGet("/api/products")
    .ToGet("https://products-api.internal/products")
    .AllowAnonymous()
    .Build();

Documentation

TopicDescription
ConfigurationService registration, options, and production settings
TransformersWriting transformers, base classes, and error handling
EndpointsEndpoint builder, routing, and authorization
AuthenticationUser auth providers and backend auth handlers
OIDC EndpointsLogin, logout, back-channel logout, and session admin
Raw ForwardingBinary content, file proxying, and streaming
CachingOutput caching endpoints, distributed (HA) cache, and per-leg aggregation caching
GraphQLCalling GraphQL backends from REST endpoints
TelemetryOpenTelemetry traces and metrics
HA DeploymentRunning multiple replicas behind a load balancer without sticky sessions
API Gateway UseWhen to use this library as a gateway, and when to pick something else
AdvancedAPI versioning, MapGroup, OpenAPI metadata, and composing framework features

Try it (runnable demo)

A self-contained, run-out-of-the-box demo lives in demo/. One command brings up a complete BFF topology against two identity providers — Keycloak and Zitadel — orchestrated by .NET Aspire, plus end-to-end login tests against both:

cd demo
aspire run          # or: dotnet run --project Demo.AppHost

A container runtime (Docker Desktop or Podman) is the only prerequisite — the IdPs, database, and seeded test users are provisioned automatically. See demo/README.md for the topology, seeded credentials, exposed endpoints, and how to run the functional/E2E suite.

Transformer Hierarchy

Choose the right base class for your use case. Each links to its guide and a worked example:

Base ClassUse CaseCode Required
MapPassThrough<T>()Zero-code pass-throughConfig only
PassThroughTransformer<T>Simple pass-through1 line
AuthenticatedTransformer<T>Auth required, no request body1 line
AuthenticatedTransformer<TReq, TRes>Auth + backend request body2-3 lines
AggregatingTransformer<T>Multi-backend aggregation~15 lines
TransformerBase<T>Full custom controlVaries

Backend Authentication

Built-in policies:

PolicyDescriptionRequires User
NoneNo authenticationNo
BasicAuthHTTP Basic auth from configNo
BearerTokenForward user's tokenYes
TokenExchangeExchange for backend token (needs an audience - see Authentication)Yes
CustomAnything else (HMAC, API keys, client credentials) — implement IBackendAuthHandlerDepends

Per-backend configuration:

app.MapTransformer<MyTransformer, Response>()
    .FromGet("/api/data")
    .ToBackends(b => b
        .ToGet("Users", "https://users.internal/api").WithAuth(BackendAuthPolicies.BearerToken)
        .ToGet("Products", "https://products.internal/api").WithAuth(BackendAuthPolicies.BasicAuth))
    .Build();

Custom Policies

The built-in policies aren't a closed set. Implement IBackendAuthHandler to add your own (HMAC signing, API keys, client-credentials, etc.). The handler exposes a PolicyName, and ApplyAuthAsync mutates the outgoing request — add headers, sign the body, attach a token. Register it with AddPortaAuthHandler<T>(), then reference it by name via WithBackendAuth("..."):

public class HmacAuthHandler : IBackendAuthHandler
{
    public string PolicyName => "HmacAuth";

    public Task ApplyAuthAsync(HttpRequestMessage request, BackendAuthContext context)
    {
        request.Headers.Add("X-Signature", ComputeHmacSignature(request));
        request.Headers.Add("X-Timestamp", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString());
        return Task.CompletedTask;
    }
}

// Registration
builder.Services.AddPortaAuthHandler<HmacAuthHandler>();

// Usage — reference the custom handler by its PolicyName
app.MapPassThrough<Response>()
    .FromGet("/api/partner-data")
    .ToGet("https://partner-api.example.com/data")
    .WithBackendAuth("HmacAuth")
    .Build();

See Authentication for the full BackendAuthContext contract and registering multiple handlers.