Skip to content

Repository files navigation

GPeterson.Infrastructure

Reusable, cloud-agnostic infrastructure for ASP.NET Core API and worker services: JWT authentication, CORS, API key gating, standardized error responses, and configuration binding, packaged as four focused NuGet libraries.

Why open source

I originally built this to abstract out infrastructure setup for a separate project, then realized it would generalize well beyond that project if I stripped out the parts that were specific to it. The original version was fairly tightly coupled to Azure (Blob Storage for data protection keys, Application Insights for telemetry) and to that project's own internal package feed. This version removes that coupling entirely: no assumptions about which cloud you're on, no forced telemetry vendor, no hardcoded auth conventions. Every opinionated default is something you can opt out of or override.

I also wanted a public example/portfolio piece of how I approach .NET backend engineering. Building reusable packages with clean abstractions, making the configurable surface explicit, and putting tooling around complicated setup so less experienced developers can get off the ground quickly.

Packages

Package Purpose
GPeterson.Infrastructure ApiApp/WorkerApp builders, the actual infrastructure setup for API and worker services
GPeterson.Infrastructure.Contracts Zero-dependency shared types: Response<T>, ErrorDetails, OptionsConfigurationAttribute, common constants. Safe to reference from anything, including client SDKs, without pulling in ASP.NET Core
GPeterson.Infrastructure.Extensions AddConfiguredOptions<T>(), binds and validates an options class from configuration by convention
GPeterson.Infrastructure.Models API-facing response models (e.g. ApiErrorResponseModel), kept separate from Contracts since these are the actual wire shape returned to clients, not just internal service-to-service types

Targets .NET 10.

ApiApp

Use ApiApp for services with publicly exposed HTTP endpoints.

Quickstart

using GPeterson.Infrastructure.Api;

var app = ApiApp.Initialize(args);

app.AddInfrastructure(options =>
{
    options.UseAuthentication = true;
});

await app.RunAsync();

That gives you controllers, API key gating, CORS, JWT bearer validation, Swagger in Development, and standardized error responses. Everything is opt-out.

See samples/GPeterson.Infrastructure.SampleApi for a complete, runnable example exercising every feature described below. It's also the target the integration test suite runs against.

Installation

<ItemGroup>
    <PackageReference Include="GPeterson.Infrastructure" Version="2.0.0" />
</ItemGroup>

That's the only reference needed. Everything ApiApp uses (JWT bearer auth, data protection, Swagger, hosting) ships as a dependency of the package itself, there's nothing extra to add to your .csproj.

ApiInfrastructureOptions

AddInfrastructure takes a single configuration callback. Every property defaults to the "batteries included" behavior below, so set only what you want to change. Adding a feature to this library is an additive property here rather than a signature change, and call sites never end up passing a run of bare booleans positionally.

Property Default Behavior
AddCors true Registers a default CORS policy. See Configuration for how it's sourced, or set ConfigureCors to build it yourself.
UseAuthentication false Registers JWT bearer authentication and data protection. See Configuration.
UseApiKey true Gates every endpoint behind an API key header. Set false if you're relying on a different auth model (JWT only, or auth enforced upstream by a gateway) and handle it via configureMiddleware (below).
UseExceptionHandler true Registers ApiExceptionHandler, so any unhandled exception returns the standardized 500 shape. See Error handling.
AddSwagger true Registers OpenAPI/Swagger generation. UI is only mapped when running in the Development environment.
ApiKeyHeaderName x-api-key Header the API key middleware checks.
ReadinessPath /readiness Exact GET path that bypasses the API key check, for orchestrator probes that can't present a key. Set to default to disable the bypass.
AppName ApiApp Data protection application name.
ConfigureDataProtection none Callback to configure key persistence (Azure Blob, Redis, a shared file path). If omitted, ASP.NET Core's local file-system default is used, fine for local dev and single-instance deployments, but keys won't be shared across machines.
ConfigureCors none Callback to configure the CORS policy directly, instead of the config-driven default.
ConfigureJson none Callback to configure JSON serialization. If omitted, framework defaults apply (numeric enums).
ConfigureSwaggerGen none Callback to configure Swashbuckle directly (security definitions, XML doc paths, etc).

A fuller example:

app.AddInfrastructure(options =>
{
    options.UseAuthentication = true;
    options.ApiKeyHeaderName = "x-tenant-key";
    options.ReadinessPath = new PathString("/health/ready");
    options.ConfigureDataProtection = dp => dp.PersistKeysToStackExchangeRedis(redis);
    options.ConfigureJson = json => json.Converters.Add(new JsonStringEnumConverter());
});

AddInfrastructure may only be called once per ApiApp; a second call throws rather than silently double-registering services and letting the later flags win.

ApiApp.RunAsync(configureMiddleware, cancellationToken) accepts an optional callback to add custom middleware to the pipeline (after the API key check, before authentication), and a CancellationToken for graceful shutdown in tests or hosts that manage their own lifetime.

Configuration

API key, required only if UseApiKey is true (the default).

ApiApp:ApiKeys holds one or more accepted keys, presented in the header named by ApiKeyHeaderName. A request is authorized if it matches any key in the set, which supports rotation (add the new key, remove the old one once callers migrate) without a deploy, and per-caller keys without a code change.

{ "ApiApp": { "ApiKeys": ["your-key-here"] } }

Generate a key:

openssl rand -base64 32 | tr -d '=+/=' | tr '/+' '_-'

Keys are compared exactly, including casing, and in constant time. Case-insensitive matching would cut the effective entropy of a key generated the way above by roughly 25%, and a short-circuiting comparison would let a caller discover a valid key one character at a time by watching response timing. Send the key back precisely as configured.

CORS, only read if AddCors is true (the default) and no ConfigureCors callback was supplied. CorsOptions:AllowedHosts takes a semicolon-separated list of allowed origins. Startup fails with an InvalidOperationException if CORS is enabled and neither source is present, rather than quietly serving a policy with no origins.

{ "CorsOptions": { "AllowedHosts": "https://app.example.com;https://admin.example.com" } }

Authentication, only required if UseAuthentication is true.

ApiAppAuthentication:Issuer, Audience, and SecretKey are all required. This library validates JWTs presented to protected endpoints, it does not issue them; mint tokens with whichever identity provider or auth service you're already using, using these same three values. Missing values fail at startup with an OptionsValidationException naming the offending field, via ValidateOnStart, rather than at the first authenticated request.

openssl rand -base64 32

ApiAppAuthentication:NameClaimType (default sub) and RoleClaimType (default role) are optional; override them if your token issuer uses different claim keys. The defaults match the standard JWT/OIDC claim names (RFC 7519), not the legacy ASP.NET claim URIs. Token lifetime is validated with zero clock skew, so an expired token is rejected immediately rather than staying valid for the framework's default five extra minutes.

Error handling

With UseExceptionHandler enabled (the default), ApiExceptionHandler converts any unhandled exception into a 500 carrying the standardized ApiErrorResponseModel body, logged once with the request method, path, and trace identifier. It runs at the pipeline level, so it covers model binding, filters, and minimal-API handlers, not just controller actions. Your actions do not need try/catch. Let exceptions propagate.

{
  "errorCode": "UnknownException",
  "errorDisplayMessage": "Something unexpected went wrong. Please try again or contact support for more help."
}

The exception message is deliberately not returned to the caller. Internal exception text routinely leaks connection strings, file paths, and SQL, and the caller can't do anything useful with it. Correlate through the logged trace identifier instead.

Expected domain failures are a different thing, and still belong in your code mapped explicitly. See ErrorResponse below.

Controller base classes

Two base classes are available under GPeterson.Infrastructure.Api.Controllers, useful whether or not you use anything else in this library.

ApiControllerBase provides ErrorResponse(ErrorDetails, Func<string, int> errorCodeMap) for mapping your own domain error codes to specific status codes:

public class MyController(ILogger<MyController> logger) : ApiControllerBase(logger)
{
    [HttpGet("{id:guid}")]
    public async Task<IActionResult> Get(Guid id)
    {
        Response<Customer> result = await customerService.GetAsync(id);

        if (!result.Success)
            return ErrorResponse(result.Error, code => code switch
            {
                "CustomerNotFound" => StatusCodes.Status404NotFound,
                _ => StatusCodes.Status500InternalServerError,
            });

        return Ok(result.Result);
    }
}

ExceptionResponse(e) is also available, for the narrow case where an action must catch an exception to do something else first (compensating work, releasing a resource) and still wants the standard shape, or where you've disabled the pipeline handler. Prefer letting exceptions propagate.

Note the constructor takes a plain ILogger, not ILogger<T>. That's intentional: a base class can't know its derived type's generic parameter, so derived controllers inject ILogger<TSelf> (as above) and pass it up, since ILogger<T> implements ILogger.

ApiAppAuthenticatedControllerBase (: ApiControllerBase) adds identity helpers for authenticated endpoints:

public class MyController(ILogger<MyController> logger) : ApiAppAuthenticatedControllerBase(logger)
{
    [HttpGet]
    [Authorize]
    public IActionResult Get() => Ok(new { userId = UserId() });
}
  • UserId() returns the current user's id, resolved via ClaimsIdentity.Name (governed by NameClaimType, see Configuration). Throws InvalidOperationException if no user is present, so only call it from endpoints that actually require authentication.
  • AnonymousUserId() uses the same resolution but returns null instead of throwing, for endpoints that allow anonymous access but still want to know who's calling if a token was provided.

Both work out of the box against tokens using the standard sub claim, and against non-default claim types if you've overridden NameClaimType, since they read through the same configured value rather than a hardcoded claim key.

Pipeline order

RunAsync builds the pipeline in this order, skipping anything you've disabled:

  1. UseExceptionHandler (first, so everything downstream is covered)
  2. Swagger and Swagger UI, Development only
  3. UseHttpsRedirection
  4. UseCors
  5. ApiKeyMiddleware
  6. Your configureMiddleware callback
  7. UseAuthentication / UseAuthorization
  8. GET / liveness response, then MapControllers

GET / returns {"status": "API is running."} and bypasses the API key check, as does a GET to the configured ReadinessPath. Both bypasses are exact path matches and GET only; nothing else skips the key check.

WorkerApp

Use WorkerApp for background/worker services with no HTTP surface.

Quickstart

using GPeterson.Infrastructure.Worker;

var app = WorkerApp.Initialize(args);

app.AddInfrastructure((services, configuration) =>
{
    services.AddHostedService<MyBackgroundService>();
});

await app.RunAsync();

Installation

Same single reference as ApiApp:

<ItemGroup>
    <PackageReference Include="GPeterson.Infrastructure" Version="2.0.0" />
</ItemGroup>

WorkerApp.Initialize() wires up Host.CreateDefaultBuilder (standard appsettings.json/environment variable/command-line configuration) plus UseWindowsService()/UseSystemd(). Both are safe no-ops unless the app is actually running under that specific host, so a worker deployed as a Windows Service or systemd unit works correctly without any extra setup on your end.

No configuration is required by the library itself. There's no equivalent to ApiApp's API key/CORS/auth surface here, since a worker has no HTTP pipeline to protect.

Options binding

GPeterson.Infrastructure.Extensions ships AddConfiguredOptions<TOptions>(), which the API packages use internally and which is useful on its own. Decorate an options class with [OptionsConfiguration("SectionName")] and the section name travels with the type instead of being repeated at every registration site:

[OptionsConfiguration("Billing")]
public class BillingOptions
{
    [Required]
    public required string ApiBaseUrl { get; init; }

    [Range(1, 30)]
    public int RetryCount { get; init; } = 3;
}

services.AddConfiguredOptions<BillingOptions>();

That binds the section, validates its DataAnnotations, and runs that validation at startup rather than on first resolution, so a misconfigured deployment fails immediately instead of at the first request that happens to need the value. Pass an explicit sectionName argument to override the attribute, or to bind a type you don't own.

The type is registered both as IOptions<TOptions>/IOptionsMonitor<TOptions> and as a plain singleton, so consumers that don't care about reload-on-change can inject BillingOptions directly without the wrapper. Inject IOptionsMonitor<TOptions> when you do want live updates.

Response<T>

GPeterson.Infrastructure.Contracts ships a small result type for service boundaries where a failure is an expected outcome rather than a fault, so callers branch instead of paying for exceptions as control flow:

public async Task<Response<Customer>> GetAsync(Guid id)
{
    Customer? customer = await repository.FindAsync(id);

    if (customer is null)
        return new ErrorDetails
        {
            ErrorCode = "CustomerNotFound",
            ErrorDisplayMessage = "We couldn't find that customer.",
        };

    return customer;
}

Implicit conversions mean neither branch has to name the wrapper, and [MemberNotNullWhen] on Success means that after if (result.Success) the compiler knows Result is non-null without a null-forgiving operator, and knows Error is non-null in the else.

Nothing in this repository consumes Response<T>; the infrastructure packages don't depend on it. It lives in Contracts because that package is zero-dependency and safe to reference from anything, including client SDKs and workers that never touch ASP.NET Core. It's published as shared vocabulary for consumers designing their own service interfaces. The wire shape actually returned to HTTP clients is ApiErrorResponseModel in the Models package, deliberately kept separate.

Building and testing

dotnet build
dotnet test --settings coverlet.runsettings --collect:"XPlat Code Coverage" --results-directory ./coverage

Two test tiers, both run in CI:

  • Unit tests (tests/GPeterson.Infrastructure.Tests) inspect DI registrations and middleware behavior directly, using InternalsVisibleTo rather than booting a host.
  • Integration tests (tests/GPeterson.Infrastructure.IntegrationTests) run against the sample API through WebApplicationFactory, and prove the feature flags change end-to-end pipeline behavior rather than just which services got registered.

Coverage is collected on every pull request, summarized on the job page, and enforced against a floor. Packages publish from main through a reusable workflow using NuGet Trusted Publishing, so no long-lived API key is stored in the repository.

Future considerations

Things worth revisiting, not commitments:

  • A built-in readiness/health endpoint. ApiKeyMiddleware bypasses the configured ReadinessPath, but no endpoint ships at that path yet, so consumers have to map their own. Shipping one over Microsoft.Extensions.Diagnostics.HealthChecks would close the loop.
  • A generic AddHttpClient abstraction. Typed clients with sane default resilience policies (retry, circuit breaker) would be a natural fit for this library's scope. Not yet built.
  • Automated package versioning. <Version> is currently bumped manually per .csproj before a release-worthy merge. Worth automating (e.g. Nerdbank.GitVersioning) once release cadence picks up.
  • Swagger vs. API key middleware ordering. Swagger's middleware runs before ApiKeyMiddleware, so Swagger UI is reachable without an API key in Development. That's probably fine (Development environments aren't usually public, and OpenAPI docs aren't typically sensitive), but it was never a deliberate choice, just an artifact of registration order. Worth confirming intentionally rather than leaving implicit.
  • Analyzer and format enforcement in CI. No TreatWarningsAsErrors, no dotnet format --verify-no-changes gate, and no Dependabot yet.

License

MIT. See LICENSE.

About

Cloud-agnostic ASP.NET Core infrastructure for API and worker services. JWT auth, CORS, API key gating, and standardized error handling, all optional and configurable. Four focused NuGet packages: Infrastructure, Contracts, Extensions, Models.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages