Skip to content

clywell/clywell-security

Repository files navigation

Clywell.Core.Security

Security primitives for .NET — JWT bearer configuration, user context resolution, permission-based authorization, and security headers middleware.

NuGet License: MIT

Installation

dotnet add package Clywell.Core.Security

Table of Contents


Quick Start

1. Register services

builder.Services.AddSecurity(options =>
{
    options.AddJwtBearer()
           .WithOidcProvider("https://your-identity-provider.com", audience: "your-api");
});

2. Configure the middleware pipeline

app.UseAuthentication();
app.UseUserContext();      // Populates ICurrentUser from the resolved identity
app.UseAuthorization();
app.UseSecurityHeaders();  // Adds OWASP-recommended response headers

3. Inject ICurrentUser

public class MyService(ICurrentUser currentUser)
{
    public void DoWork()
    {
        if (!currentUser.IsAuthenticated)
            return;

        Console.WriteLine(currentUser.UserId);
        Console.WriteLine(currentUser.Email);
        Console.WriteLine(currentUser.IpAddress);

        if (currentUser.IsInRole("Admin")) { /* ... */ }
        if (currentUser.HasPermission("articles.edit")) { /* ... */ }
    }
}

JWT Authentication

AddJwtBearer() returns a JwtBearerBuilder. Pick your token source first, then optionally chain transport and advanced settings.

OIDC / External Identity Provider

Use WithOidcProvider when tokens are issued by an external provider (Auth0, Azure AD, Keycloak, etc.). Signing keys are discovered automatically.

options.AddJwtBearer()
       .WithOidcProvider("https://login.example.com", audience: "my-api");

Advanced settings chain naturally:

options.AddJwtBearer()
       .WithOidcProvider("https://login.example.com", audience: "my-api")
       .WithClockSkew(TimeSpan.FromSeconds(30))
       .DisableAudienceValidation();  // only if your IdP omits aud

Self-Hosted JWT (Symmetric Key)

Use WithSymmetricKey when your own service issues JWTs with no external OIDC provider.

Security: The signing key must be at least 32 characters. Read it from a secret manager or environment variable — never hard-code it.

options.AddJwtBearer()
       .WithSymmetricKey(
           signingKey: builder.Configuration["Jwt:SigningKey"], // min 32 chars
           issuer:     "https://my-service.example.com",
           audience:   "my-api");

Self-Hosted JWT (Asymmetric Key / RSA)

Use WithSigningKey when your own service issues JWTs signed with an asymmetric key (RSA, ECDSA). Pass the public SecurityKey derived from your signing key pair. This is the recommended approach for production JWT issuers.

Security: Never include the private key in token validation. Extract only the public parameters before registering — see the example below.

// Load the RSA private key from configuration (secret manager / env var - never hard-code)
using var rsa = RSA.Create();
rsa.ImportFromPem(configuration["Jwt:RsaPrivateKey"]);
var publicKey = new RsaSecurityKey(RSA.Create(rsa.ExportParameters(includePrivateParameters: false)));

builder.Services.AddSecurity(options =>
{
    options.AddJwtBearer()
           .WithSigningKey(publicKey, issuer: "https://my-service.example.com")
           .DisableAudienceValidation();  // omit if you set an audience
});

Token from Cookie or Query String

For transports that cannot send an Authorization header (SignalR WebSockets, SSE), chain WithTokenCookie and/or WithTokenQueryParam. Cookie takes priority over query string when both are present.

Security: The cookie should be HttpOnly and Secure. Query string tokens may appear in server logs — prefer cookies where possible.

options.AddJwtBearer()
       .WithOidcProvider("https://login.example.com", audience: "my-api")
       .WithTokenCookie("access_token")      // HttpOnly, Secure cookie
       .WithTokenQueryParam("access_token"); // fallback when cookie absent

Current User

ICurrentUser is a scoped service populated per-request by UseUserContext(). It exposes:

Member Type Description
UserId string? Primary subject identifier (sub claim by default)
Email string? User email address
DisplayName string? Display / full name
Acr string? Authentication Context Class Reference; "step-up" for step-up tokens (see AcrValues)
OperationContext string? Operation context from step-up proof tokens; identifies the sensitive operation
IsAuthenticated bool true when a valid identity was resolved
IpAddress string? Remote IP from HttpContext.Connection
Roles IReadOnlySet<string> Case-insensitive role set
Permissions IReadOnlySet<string> Case-insensitive permission set
Principal ClaimsPrincipal? Underlying ASP.NET Core principal
IsInRole(role) bool Role membership check
HasPermission(perm) bool Permission check
GetProperty<T>(key) T? Read a custom property stored in UserInfo.Properties

Custom properties on UserInfo

UserInfo accepts an optional ImmutableDictionary<string, object> for arbitrary per-request data (e.g. tenant metadata resolved from the token). Access it via GetProperty<T>():

var userInfo = new UserInfo(
    userId,
    email,
    displayName,
    roles,
    permissions,
    Properties: ImmutableDictionary<string, object>.Empty
        .Add("tenantId", "tenant-abc")
        .Add("plan", "pro"));

// Later, in any service:
var tenantId = currentUser.GetProperty<string>("tenantId");

Permission-Based Authorization

Decorate controllers or actions with [HasPermission]. Multiple attributes require all permissions (AND semantics).

[HasPermission("articles.edit")]
public IActionResult EditArticle(int id) { ... }

[HasPermission("articles.delete")]
[HasPermission("articles.edit")]   // user must have BOTH
public IActionResult DeleteArticle(int id) { ... }

Policies are resolved dynamically by PermissionPolicyProvider — no manual policy registration required.


Step-Up Authentication

Step-up authentication is a re-authentication event: the user proves their identity again for a specific sensitive operation without replacing their existing session token.

Calls continue to send the normal Authorization: Bearer ... header. The step-up proof is sent separately in X-Step-Up-Proof.

Static step-up (endpoint-declared)

Use RequireStepUp() on minimal API endpoints that always require elevated assurance:

app.MapDelete("/account", DeleteAccountHandler)
   .RequireStepUp("delete_account");

The caller must send a valid proof token (issued by the IAM service's step-up verify endpoint) in X-Step-Up-Proof.

operationContext is optional, but recommended. It scopes the proof token to exactly this operation and helps prevent replay against other step-up endpoints.

Dynamic step-up (handler-determined)

Inject IStepUpProofValidator into command handlers or services when step-up depends on runtime conditions:

public class TransferFundsHandler(IStepUpProofValidator stepUpValidator)
{
    public Result Handle(TransferFundsCommand command)
    {
        if (command.Amount > 10_000)
        {
            var proof = stepUpValidator.Validate("high_value_transfer");
            if (proof != StepUpProofValidationResult.Valid)
                return Result.Failure("Step-up required for high-value transfers.");
        }

        // ... proceed
    }
}

StepUpProofValidationResult

Value Meaning
Valid Proof token is valid, has acr=step-up, and required operation context (if any) matches
Missing X-Step-Up-Proof header is missing
Invalid Token is malformed, failed validation, or is not a step-up token
ContextMismatch Token operation_context does not match the required value
Expired Proof token is expired

Header constant

Use SecurityHeaderNames.StepUpProof ("X-Step-Up-Proof") when constructing client requests instead of hardcoding the header name.


Custom User Context Resolver

The default ClaimsUserContextResolver reads identity data straight from JWT claims. To load roles or permissions from a database (or any other source), implement IUserContextResolver.

Type-Parameter Registration

public class DatabaseUserContextResolver(
    IUserRepository userRepo) : IUserContextResolver
{
    public async Task<UserInfo?> ResolveAsync(HttpContext context)
    {
        if (context.User.Identity?.IsAuthenticated != true)
            return null;

        var userId = context.User.FindFirstValue("sub");
        if (userId is null) return null;

        var user = await userRepo.GetByIdAsync(userId);
        if (user is null) return null;

        return new UserInfo(
            userId,
            user.Email,
            user.DisplayName,
            user.Roles.ToHashSet(),
            user.Permissions.ToHashSet());
    }
}

// Registration
builder.Services.AddSecurity(options =>
    options.UseResolver<DatabaseUserContextResolver>());

Factory Registration

Use the factory overload when the resolver depends on services not available at configuration time:

builder.Services.AddSecurity(options =>
    options.UseResolver(sp =>
    {
        var repo = sp.GetRequiredService<IUserRepository>();
        return new DatabaseUserContextResolver(repo);
    }));

Custom Claim Mapping

ClaimsUserContextResolver reads claims using the names defined in UserClaimMapping. Override any of them via ConfigureClaimMapping() if your identity provider uses non-standard claim types:

builder.Services.AddSecurity(options =>
{
    options.AddJwtBearer(jwt => { /* ... */ });

    options.ConfigureClaimMapping(mapping =>
    {
        mapping.UserId      = "oid";              // Azure AD object ID
        mapping.Email       = "preferred_username";
        mapping.DisplayName = "name";             // default — shown for clarity
        mapping.Roles       = "roles";            // Azure AD app roles
        mapping.Permissions = "scp";              // OAuth 2 scopes as permissions
    });
});

Default claim type mapping:

Property Default claim type
UserId sub
Email email
DisplayName name
Roles role
Permissions permission

Security Headers

UseSecurityHeaders() adds OWASP-recommended response headers and strips server-identifying headers. Call it with no arguments to apply the defaults, or supply a configuration action to customise any aspect.

Default headers

Header Default value
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy Disables accelerometer, camera, geolocation, gyroscope, magnetometer, microphone, USB
Content-Security-Policy default-src 'self'; frame-ancestors 'none'

Server and X-Powered-By are removed from every response.

Default usage (no configuration needed)

app.UseSecurityHeaders();

Customising individual headers

Pass an Action<SecurityHeadersOptions> to override any value. Set a property to null to suppress that header entirely.

app.UseSecurityHeaders(options =>
{
    options.FrameOptions = "SAMEORIGIN"; // relax framing restriction
    options.ReferrerPolicy = "no-referrer";
    options.PermissionsPolicy = null;    // suppress the header
});

Configuring Content-Security-Policy

Use the fluent CspBuilder or supply a raw string:

app.UseSecurityHeaders(options =>
{
    options.WithContentSecurityPolicy(csp => csp
        .Default("'self'")
        .Script("'self'")
        .Style("'self'")
        .Image("'self'", "data:")
        .Font("'self'")
        .Connect("'self'")
        .FrameAncestors("'none'"));
});

Route-specific CSP overrides

Apps that serve a developer UI (e.g. Scalar API reference) at a specific path can register a per-route policy that overrides the global one only for requests under that prefix:

app.UseSecurityHeaders(options =>
{
    // Scalar injects inline scripts/styles — allow them only on that route
    options.AddRouteContentSecurityPolicy("/scalar", csp => csp
        .Default("'self'")
        .Script("'self'", "'unsafe-inline'")
        .Style("'self'", "'unsafe-inline'")
        .Image("'self'", "data:", "https:")
        .Font("'self'", "data:")
        .Connect("'self'")
        .FrameAncestors("'none'"));
});

Development-only overrides

Use IWebHostEnvironment at the call site to apply settings that should only apply in development:

app.UseSecurityHeaders(options =>
{
    var connectSrc = app.Environment.IsDevelopment()
        ? ["'self'", "ws://localhost:*", "wss://localhost:*"]   // allow browser-refresh WebSocket
        : ["'self'"];

    options.WithContentSecurityPolicy(csp => csp
        .Default("'self'")
        .Connect(connectSrc)
        .FrameAncestors("'none'"));
});

Adding and removing custom headers

app.UseSecurityHeaders(options =>
{
    options.AddHeader("X-App-Version", "2.1.0");   // add a custom header
    options.RemoveHeader("X-AspNet-Version");       // strip an additional header
});

API Reference

SecurityOptions

Method Description
AddJwtBearer() Returns a JwtBearerBuilder to configure JWT bearer authentication
UseResolver<TResolver>() Register a custom IUserContextResolver by type
UseResolver(Func<IServiceProvider, IUserContextResolver>) Register a custom resolver via factory
ConfigureClaimMapping(Action<UserClaimMapping>) Override claim type names read by ClaimsUserContextResolver

JwtBearerBuilder

Method Description
WithOidcProvider(authority, audience?) Validate tokens from an external OIDC provider
WithSymmetricKey(signingKey, issuer, audience?) Validate locally-issued tokens with a symmetric key
WithSigningKey(signingKey, issuer, audience?) Validate tokens signed with a pre-built SecurityKey (RSA, ECDSA, etc.). Pass the public key for asymmetric schemes.
WithTokenCookie(cookieName) Read bearer token from an HttpOnly cookie (SignalR / SSE)
WithTokenQueryParam(parameterName) Fallback: read bearer token from a query string parameter
DisableHttpsMetadataRequirement() Allow HTTP for OIDC discovery. Never in production.
DisableIssuerValidation() Skip iss claim validation
DisableAudienceValidation() Skip aud claim validation
DisableLifetimeValidation() Skip token expiry check. Never in production.
WithClockSkew(TimeSpan) Override clock skew tolerance (default: 1 minute)
PreserveInboundClaimTypes() Keep WS-Federation claim type URIs instead of mapping to short names

ICurrentUser

Scoped service available after UseUserContext() runs in the pipeline. See the Current User section for the full member table.

SecurityHeadersOptions

Configure via app.UseSecurityHeaders(options => { ... }).

Member Description
ContentTypeOptions X-Content-Type-Options value; null suppresses the header (default: nosniff)
FrameOptions X-Frame-Options value; null suppresses (default: DENY)
ReferrerPolicy Referrer-Policy value; null suppresses (default: strict-origin-when-cross-origin)
PermissionsPolicy Permissions-Policy value; null suppresses
WithContentSecurityPolicy(string?) Set a raw CSP string, or null to suppress
WithContentSecurityPolicy(Action<CspBuilder>) Build a CSP using the fluent builder
AddRouteContentSecurityPolicy(string, string) Override CSP for requests under a path prefix
AddRouteContentSecurityPolicy(string, Action<CspBuilder>) Same, using the builder
AddHeader(name, value) Inject an additional response header
RemoveHeader(name) Remove a response header (in addition to Server / X-Powered-By)

CspBuilder

Method CSP directive
Default(sources) default-src
Script(sources) script-src
Style(sources) style-src
Image(sources) img-src
Font(sources) font-src
Connect(sources) connect-src
FrameAncestors(sources) frame-ancestors
Media(sources) media-src
Object(sources) object-src
Worker(sources) worker-src
FormAction(sources) form-action
Build() Returns the assembled CSP string

UserInfo

public sealed record UserInfo(
    string UserId,
    string? Email = null,
    string? DisplayName = null,
    IReadOnlySet<string>? Roles = null,
    IReadOnlySet<string>? Permissions = null,
    ImmutableDictionary<string, object>? Properties = null,
    string? Acr = null,
    string? OperationContext = null);

Returned by IUserContextResolver.ResolveAsync() to describe the resolved identity for the current request.

SecurityClaimTypes

Constants for common JWT claim type names:

SecurityClaimTypes.Subject    // "sub"
SecurityClaimTypes.Email      // "email"
SecurityClaimTypes.Name       // "name"
SecurityClaimTypes.Role       // "role"
SecurityClaimTypes.Permission // "permission"
SecurityClaimTypes.Acr        // "acr"
SecurityClaimTypes.OperationContext // "operation_context"

AcrValues

Well-known Authentication Context Class Reference (acr) values:

AcrValues.Password // "pwd"
AcrValues.Mfa      // "mfa"
AcrValues.StepUp   // "step-up"
AcrValues.Social   // "social"
AcrValues.ApiKey   // "api_key"

SecurityHeaderNames

Header name constants used by the security infrastructure:

SecurityHeaderNames.StepUpProof // "X-Step-Up-Proof"

Dependencies


License

MIT — see LICENSE.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages