Building blocks for modern ASP.NET applications. Includes generic auth, security, and utility components.
A small ASP.NET Core reference app is available at samples/Ashlar.Sample.AspNetCore. It shows the recommended composition for PostgreSQL persistence, Data Protection secret protection, Ashlar session cookies, magic-link sign-in, bootstrap setup, invitations, authorization grants, scoped ASP.NET Core policies, TOTP MFA, recovery codes, the PostgreSQL email outbox, cleanup, audit sink, and rate limiting.
Ashlar does not register persistence by default. The following official packages are available:
- Ashlar.Postgres: PostgreSQL 15+ identity and session persistence using Dapper and DbUp.
Ashlar provides IServiceCollection extensions for registering its core identity services:
// 1. Register persistence (e.g., PostgreSQL)
services.AddAshlarPostgres(connectionString);
// 2. Register secret protection
services.AddDataProtection();
services.AddAshlarDataProtectionSecretProtector();
// 3. Register core identity services
services.AddAshlarIdentity(
options =>
{
options.LastUsedAtUpdateThreshold = TimeSpan.FromMinutes(5);
},
sessionOptions =>
{
sessionOptions.DefaultLifetime = TimeSpan.FromDays(14);
sessionOptions.LastSeenUpdateThreshold = TimeSpan.FromMinutes(5);
sessionOptions.TokenByteLength = 32;
sessionOptions.StoreIpAddress = true;
sessionOptions.StoreUserAgent = true;
sessionOptions.StoreMetadata = true;
sessionOptions.MaxIpAddressLength = 45;
sessionOptions.MaxUserAgentLength = 512;
sessionOptions.MaxMetadataLength = 8192;
});
// 4. Register providers and hashers
services
.AddAuthenticationProvider<LocalPasswordProvider>()
.AddPasswordHasher<PasswordHasherV1>();Applications must provide an IIdentityRepository implementation (either by using an official package above or a custom one).
Applications must also provide secret protection. For ASP.NET Core Data Protection, register Data Protection and call AddAshlarDataProtectionSecretProtector(). Ashlar does not use an insecure fallback protector.
Ashlar models durable authentication sessions through AuthenticationSession, IAuthenticationSessionRepository, and IAuthenticationSessionService.
The session service generates high-entropy raw tokens, hashes them before persistence, updates last-seen timestamps, and revokes sessions. Raw tokens are returned only once from CreateSessionAsync; AuthenticationSession stores only the deterministic token hash. HTTP cookies and ASP.NET authentication middleware are separate integration layers.
Session token generation and hashing use the reusable Ashlar.Security.Tokens primitives registered by AddAshlarIdentity(): ISecureTokenGenerator with SecureTokenGenerator, and ISecureTokenHasher with Sha256TokenHasher. These primitives are intended for high-entropy server-generated tokens such as sessions, magic links, password reset links, and future challenge tokens. They are separate from IPasswordHasher and PasswordHasherV1, which remain for low-entropy user-chosen passwords.
SecureTokenGenerator generates Base64Url tokens from 32 to 192 random bytes. The upper bound keeps generated tokens compatible with the default Sha256TokenHasher input limit. Existing code that customized the old session-specific token generator or hasher should register ISecureTokenGenerator or ISecureTokenHasher instead.
Ashlar includes a framework-neutral email abstraction for identity and security flows that need to send or queue email messages, such as passwordless email sign-in, password reset, MFA recovery, and security notifications.
The abstraction lives in Ashlar.Messaging, not Ashlar.Identity, so authentication providers can depend on message creation without coupling to SMTP, a cloud email vendor, ASP.NET Core, or a persistence outbox.
AddAshlarIdentity() calls AddAshlarMessaging() and registers IEmailSender with NullEmailSender by default. NullEmailSender accepts valid EmailMessage instances and sends nothing, which keeps the core library usable and test-friendly without choosing an email delivery provider.
Applications should replace the default sender with their own implementation before calling AddAshlarIdentity() or AddAshlarMessaging():
services.AddSingleton<IEmailSender, MyEmailSender>();
services.AddAshlarIdentity();EmailMessage contains simple string address fields (To, From, and ReplyTo) plus subject, text and/or HTML body, headers, and metadata. Ashlar intentionally does not implement SMTP, vendor integrations, templates, MIME parsing, address-list handling, or outbox persistence in the core abstraction.
Ashlar includes framework-neutral passwordless email sign-in services for one-time codes and magic links. Both flows use IEmailSender, IAuthenticationRateLimiter, ISecureTokenGenerator, and ISecureTokenHasher, so applications should replace the default NullEmailSender before using them in production.
Register magic-link sign-in with core identity services:
services.AddSingleton<IEmailSender, MyEmailSender>();
services.AddAshlarMagicLinkSignIn(options =>
{
options.LinkLifetime = TimeSpan.FromMinutes(10);
options.LinkTokenParameterName = "token";
options.EmailSubject = "Sign in";
options.EmailTextTemplate = "Click the following link to sign in: {0}";
});Request a link for an active user, then verify the raw token from the callback URL:
var magicLinks = httpContext.RequestServices.GetRequiredService<IMagicLinkSignInService>();
await magicLinks.RequestLinkAsync(
email,
new Uri("https://app.example.com/auth/magic-link/callback"),
new AuthenticationContext(
IpAddress: httpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent: httpContext.Request.Headers.UserAgent.ToString()));
var token = httpContext.Request.Query["token"].ToString();
var authenticationResult = await magicLinks.VerifyLinkAsync(
token,
new AuthenticationContext(
IpAddress: httpContext.Connection.RemoteIpAddress?.ToString(),
UserAgent: httpContext.Request.Headers.UserAgent.ToString()));RequestLinkAsync does not reveal whether an email address belongs to an active user. Generated links are stored as hashed credentials, expire according to LinkLifetime, and the default request and verification rate limits can be changed through MagicLinkSignInOptions.
One-time email codes are available through AddAshlarEmailCodeSignIn() and IEmailCodeSignInService:
services.AddAshlarEmailCodeSignIn();
await emailCodes.RequestCodeAsync(email, context);
var authenticationResult = await emailCodes.VerifyCodeAsync(email, code, context);Ashlar includes services for verifying user email addresses. This is typically used during onboarding or after an email change.
Register email verification services:
services.AddAshlarEmailVerification(options =>
{
options.Expiration = TimeSpan.FromHours(24);
options.Subject = "Verify your email address";
});Request verification for a user:
var verificationService = httpContext.RequestServices.GetRequiredService<IEmailVerificationService>();
await verificationService.RequestVerificationAsync(new EmailVerificationRequest
{
UserId = userId
});Verify the token (e.g., from a link in the email):
var result = await verificationService.VerifyTokenAsync(userId, tokenFromUrl);
if (result.Succeeded)
{
// Email is now marked as verified (user.EmailVerifiedAt is set)
}Ashlar supports a secure two-step email change flow that verifies ownership of the new email address before updating the user record.
Register email change services:
services.AddAshlarEmailChange(options =>
{
options.Expiration = TimeSpan.FromHours(2);
options.Subject = "Confirm your new email address";
options.RevokeSessions = true; // Optional: revoke all user sessions after change
});Step 1: Request an email change:
var emailChangeService = httpContext.RequestServices.GetRequiredService<IEmailChangeService>();
var result = await emailChangeService.RequestChangeAsync(new RequestEmailChangeRequest
{
UserId = userId,
NewEmail = "new-email@example.com"
});Step 2: Confirm the change using the token sent to the NEW email:
var result = await emailChangeService.ConfirmChangeAsync(new ConfirmEmailChangeRequest
{
UserId = userId,
Token = tokenFromNewEmail
});
if (result.Succeeded)
{
// User email has been updated and marked as verified
}Email change requests are automatically throttled, and the process ensures that the new email is not already in use by another user in the same tenant.
Ashlar includes a framework-neutral service for managing and verifying TOTP (Time-based One-Time Password) authenticator factors. These are standard RFC 6238 codes compatible with apps like Google Authenticator, Microsoft Authenticator, and 1Password.
Register TOTP with core identity services:
services.AddAshlarTotp(options =>
{
options.CodeDigits = 6;
options.StepSeconds = 30;
});To enroll a user, generate a new shared secret and an authenticator URI:
// 1. Start enrollment (totpService is ITotpService)
var enrollment = await totpService.StartEnrollmentAsync(userId, "Ashlar", "user@example.com");
// 2. Return enrollment.AuthenticatorUri to the client for QR code generation.
// 3. Keep enrollment.SharedSecret temporarily to verify the first code.The user must verify a code from their authenticator app to finalize enrollment:
// 4. Verify first code and finalize enrollment
bool success = await totpService.VerifyAndEnrollAsync(userId, sharedSecret, userInputCode);VerifyAndEnrollAsync replaces any existing TOTP credential for the user and stores the new secret as a protected credential value.
To verify a TOTP code during sign-in, use the standard AuthenticationPipeline or AuthenticationOrchestrator with a TotpAssertion:
var orchestrator = httpContext.RequestServices.GetRequiredService<IAuthenticationOrchestrator>();
var result = await orchestrator.VerifyFactorAsync(
handshakeToken,
"totp",
new AuthenticationContext(IpAddress: ip),
new TotpAssertion(userInputCode));
if (result.Status == MfaAuthenticationStatus.Succeeded)
{
// TOTP verified!
}To disable TOTP for a user:
await totpService.DisableTotpAsync(userId);TOTP verification is automatically throttled by IAuthenticationRateLimiter to protect against brute-force attacks. Shared secrets are never stored in raw form; they are always encrypted using ISecretProtector.
Ashlar includes a generic invitation and onboarding flow that supports inviting users by email address, even when they do not yet exist in the system.
Register invitation services:
services.AddAshlarInvitations(options =>
{
options.DefaultExpiry = TimeSpan.FromDays(7);
options.EmailSubject = "You're invited!";
options.EmailTextTemplate = "Join us here: {0}";
});Create an invitation:
var invitations = httpContext.RequestServices.GetRequiredService<IInvitationService>();
await invitations.CreateInvitationAsync(
new CreateInvitationRequest
{
Email = "invitee@example.com",
Metadata = "{\"role\": \"editor\"}"
},
new Uri("https://app.example.com/join"));Accept an invitation:
var result = await invitations.AcceptInvitationAsync(
new AcceptInvitationRequest
{
Token = tokenFromUrl,
UserName = "Jane Doe"
});
if (result.Succeeded)
{
var userId = result.UserId.Value;
}CreateInvitationAsync generates a high-entropy token, stores its hash, and sends an invitation link via IEmailSender. When an invitation is accepted, Ashlar automatically creates a new active user if one does not exist, or activates/links an existing inactive user. Acceptance is atomic and single-use.
Ashlar includes generic bootstrap primitives that allow a newly self-hosted application to safely create its first administrative user without manual database edits.
Register bootstrap services:
services.AddAshlarBootstrap(options =>
{
options.Grants.Add(new BootstrapGrantTemplate
{
Role = "admin"
});
});Check bootstrap status and create the first-admin invitation:
var bootstrap = httpContext.RequestServices.GetRequiredService<IBootstrapService>();
if (await bootstrap.GetStatusAsync() == BootstrapStatus.Uninitialized)
{
var result = await bootstrap.CreateBootstrapInvitationAsync(new CreateBootstrapInvitationRequest
{
Email = "admin@example.com"
});
if (result.Succeeded)
{
// Display result.Token to the administrator
}
}Accept the bootstrap invitation:
var result = await bootstrap.AcceptBootstrapInvitationAsync(new AcceptInvitationRequest
{
Token = tokenFromUrl,
UserName = "Admin User"
});
if (result.Succeeded)
{
// The system is now initialized and the user has 'admin' role.
}Bootstrap is available only while the application is uninitialized. Initialization is determined by a persistent marker in the database. Accepting a bootstrap invitation is atomic, single-use, and assigns all configured grants to the new user before marking the system as initialized.
Ashlar includes a framework-neutral service for generating and verifying backup recovery codes. These are typically used as a fallback authentication method when a user loses access to their primary multi-factor authentication device.
Register recovery codes with core identity services:
services.AddAshlarRecoveryCodes(options =>
{
options.CodeCount = 10;
options.CodeLength = 12;
options.GroupSize = 4; // Generates codes like XXXX-XXXX-XXXX
});To generate and retrieve the raw recovery codes for a user:
var recoveryCodes = httpContext.RequestServices.GetRequiredService<IRecoveryCodeService>();
// Generates new codes. Any existing codes are revoked.
var rawCodes = await recoveryCodes.GenerateRecoveryCodesAsync(userId);To verify a recovery code during sign-in, use the standard AuthenticationPipeline with a RecoveryCodeAssertion:
var pipeline = httpContext.RequestServices.GetRequiredService<IAuthenticationPipeline>();
var assertion = new RecoveryCodeAssertion(
code: userInputCode,
ipAddress: httpContext.Connection.RemoteIpAddress?.ToString());
var authenticationResponse = await pipeline.LoginAsync(
new AuthenticationContext(Email: userEmail),
assertion);
if (authenticationResponse.Succeeded)
{
// The recovery code was valid and has been automatically consumed
}Ashlar provides a high-level orchestration layer that connects primary authentication to MFA policy enforcement and handshake management. This allows applications to define generic MFA requirements and manage the multi-step verification process.
Register the orchestration services:
services.AddAshlarMfaOrchestration();By default, Ashlar registers a no-MFA policy. Primary authentication can complete without a handshake unless an authentication provider itself returns AuthenticationStatus.MfaRequired or an application explicitly registers a policy:
services.AddAshlarMfaOrchestration();
// Equivalent explicit policy:
services.AddAshlarNoMfaPolicy();To require TOTP only for users who already have an active TOTP credential, register a credential-backed policy:
services
.AddAshlarTotp()
.AddAshlarRequireMfaWhenCredentialExists(options =>
{
options.CredentialProviderKeys.Add(new AuthenticationProviderKey(ProviderType.Mfa, "totp"));
options.RequiredFactors.Add("totp");
});The credential-backed policy checks configured provider identities only. It uses active, non-revoked, non-expired credentials and does not inspect credential values.
To require MFA for every active user:
services.AddAshlarRequireMfaForAllUsers(options =>
{
options.RequiredFactors.Add("totp");
});Perform a primary authentication that might require MFA:
var orchestrator = httpContext.RequestServices.GetRequiredService<IAuthenticationOrchestrator>();
var result = await orchestrator.AuthenticateAsync(
new AuthenticationContext(IpAddress: ip),
new LocalPasswordAssertion(email, password));
if (result.Status == MfaAuthenticationStatus.MfaRequired)
{
// Primary auth succeeded, but MFA is required.
// Send the raw continuation token and required factors to the client.
return Results.Ok(new {
token = result.HandshakeToken,
factors = result.RequiredFactors
});
}Verify an additional factor using the continuation token:
var result = await orchestrator.VerifyFactorAsync(
tokenFromClient,
"email_code",
new AuthenticationContext(IpAddress: ip),
new EmailCodeAssertion(code));
if (result.Status == MfaAuthenticationStatus.Succeeded)
{
// All factors verified! Now create the session.
await signInManager.SignInAsync(httpContext, result.User.Id, result.Claims);
}The orchestrator ensures that factor verification happens through the same provider machinery as primary authentication. It also aggregates claims from all authentication steps into the final result.
Ashlar includes a generic infrastructure for tracking multi-step authentication flows through "handshakes". This allows primary authentication (like passwords) to be verified while requiring additional factors before a final session is issued.
Register MFA handshake services:
services.AddAshlarMfaHandshakes(options =>
{
options.Expiry = TimeSpan.FromMinutes(15);
});AddAshlarMfaHandshakes() registers the service layer only. Applications must also register an IAuthenticationHandshakeRepository implementation, such as by calling AddAshlarPostgres(connectionString), or provide their own repository.
When a user's primary authentication succeeds but MFA is required, initiate a handshake:
var handshakeService = httpContext.RequestServices.GetRequiredService<IAuthenticationHandshakeService>();
var (handshake, token) = await handshakeService.CreateHandshakeAsync(new CreateAuthenticationHandshakeRequest(
userId,
RequiredFactors: ["totp"]));
// Return the 'token' to the client. It will be needed to verify factors.Verify a factor to continue or complete the handshake:
var result = await handshakeService.VerifyFactorAsync(new VerifyAuthenticationHandshakeRequest(
HandshakeToken: tokenFromClient,
FactorType: "totp"));
if (result.Succeeded && result.Handshake.IsCompleted)
{
// All required factors verified! Create the final session.
await signInManager.SignInAsync(httpContext, result.Handshake.UserId);
}Handshakes are time-limited, single-use, and stored as hashed continuation tokens. They track generic "factor types" allowing applications to implement any MFA method (TOTP, Email Code, Passkeys, etc.) and integrate them into a unified handshake flow.
When supplied to CreateSessionAsync, session IP address, user agent, and metadata are persisted by default. These values can contain personal data, so applications should only pass them when their privacy policy and security requirements allow it. Use AuthenticationSessionOptions.StoreIpAddress, StoreUserAgent, and StoreMetadata to opt out, and tune the max-length options if the defaults do not fit your storage policy.
var createResult = await sessionService.CreateSessionAsync(
authenticationResult.User.Id,
new CreateAuthenticationSessionRequest(
IpAddress: ipAddress,
UserAgent: userAgent));
var rawToken = createResult.Token;
var validation = await sessionService.ValidateSessionAsync(rawTokenFromRequest);
if (validation.Succeeded)
{
var userId = validation.UserId.Value;
}
await sessionService.RevokeSessionAsync(createResult.Session.Id, "signed-out");Ashlar provides user-facing APIs for listing and revoking active sessions. This allows applications to build "Security" or "Devices" pages where users can see their active sessions and sign out of other devices.
Use IAuthenticationSessionService for low-level session management:
// 1. List active sessions for a user
var request = new ListAuthenticationSessionsRequest
{
ActiveOnly = true,
CurrentSessionId = currentSessionId
};
var sessions = await sessionService.ListSessionsForUserAsync(userId, request);
foreach (var summary in sessions)
{
// summary includes Id, CreatedAt, LastSeenAt, IpAddress, UserAgent, IsCurrent, etc.
}
// 2. Revoke a specific session (ownership is enforced)
await sessionService.RevokeSessionForUserAsync(userId, new RevokeAuthenticationSessionRequest
{
SessionId = targetSessionId,
Reason = "user-initiated"
});
// 3. Revoke all other sessions for a user
await sessionService.RevokeOtherSessionsAsync(userId, new RevokeOtherAuthenticationSessionsRequest
{
CurrentSessionId = currentSessionId,
Reason = "security-sweep"
});Use IAshlarSignInManager for simplified management of the currently authenticated user:
// List sessions for the current user
var sessions = await signInManager.ListSessionsForCurrentUserAsync(httpContext);
// Revoke a specific session for the current user
await signInManager.RevokeSessionForCurrentUserAsync(httpContext, targetSessionId);
// Revoke all other sessions for the current user
await signInManager.RevokeOtherSessionsForCurrentUserAsync(httpContext);Session listing is ordered by CreatedAt descending (newest first). Sensitive fields like IP address and user agent are only populated if they were enabled during session creation. Token hashes are never exposed through these APIs.
Ashlar includes framework-neutral authorization primitives for durable grants. Grants are generic: they can assign one normalized role or one normalized permission to a user, optionally within a tenant and explicit scope. Ashlar evaluates these grants, but it does not replace ASP.NET Core Authorization policies or requirements.
services.AddAshlarAuthorization();
services.AddAshlarPostgres(connectionString);Grant a permission:
var grant = await grantService.CreateGrantAsync(new CreateAuthorizationGrantRequest(
UserId: userId,
Permission: "posts.edit"));Grant a scoped role:
await grantService.CreateGrantAsync(new CreateAuthorizationGrantRequest(
UserId: userId,
TenantId: tenantId,
ScopeType: "project",
ScopeId: projectId.ToString("D"),
Role: "reviewer"));Evaluate access:
var result = await authorizationEvaluator.EvaluateAsync(new AuthorizationEvaluationRequest(
UserId: userId,
TenantId: tenantId,
ScopeType: "project",
ScopeId: projectId.ToString("D"),
Permission: "posts.edit"));
if (!result.Succeeded)
{
return Results.Forbid();
}Scope matching is explicit. A scoped grant applies only when the evaluation request uses the same tenant, scope type, and scope id. A global grant is represented by omitting tenant and scope values.
Use Ashlar.AspNetCore to integrate Ashlar grants with standard ASP.NET Core authorization policies:
services.AddAshlarAspNetCoreAuthorization(options =>
{
// Global permission policy
options.AddPermissionPolicy("PostsEdit", "posts.edit");
// Scoped permission policy (resolves postId from route values)
options.AddPermissionPolicy("ProjectMember", "project.member", scope =>
{
scope.ScopeType = "project";
scope.ScopeIdRouteValueName = "projectId";
scope.TenantIdSource = "tenantId"; // Optional: also resolve tenantId from route
});
// Role policy
options.AddRolePolicy("Admin", "admin");
});Use the registered policies in your controllers or minimal APIs:
[Authorize(Policy = "PostsEdit")]
public IActionResult EditPost() => Ok();
[Authorize(Policy = "ProjectMember")]
[HttpGet("/projects/{projectId}/settings")]
public IActionResult ProjectSettings(Guid projectId) => Ok();The integration automatically registers a IAuthorizationHandler that resolves the user ID from ClaimTypes.NameIdentifier, extracts scope/tenant data from route values as configured, and performs a live evaluation using IAuthorizationEvaluator. Scoped checks fail safely if the required route values are missing or invalid.
ASP.NET Core applications can also call IAuthorizationEvaluator directly from custom handlers for more complex logic. Avoid copying all grants into cookies unless you accept stale authorization until the cookie is refreshed.
Use Ashlar.AspNetCore to authenticate Ashlar sessions through the normal ASP.NET Core authentication middleware:
services.AddAshlarPostgres(connectionString);
services.AddDataProtection();
services.AddAshlarDataProtectionSecretProtector();
services.AddAshlarIdentity();
services.AddAshlarAspNetCoreSessions(options =>
{
options.SchemeName = "Ashlar";
options.CookieName = "__Host-Ashlar.Session";
options.LoginPath = "/login";
options.AccessDeniedPath = "/forbidden";
});
app.UseAuthentication();
app.UseAuthorization();After a successful application login, create the backing Ashlar session and append the cookie:
var signInManager = httpContext.RequestServices.GetRequiredService<IAshlarSignInManager>();
await signInManager.SignInAsync(
httpContext,
authenticationResult.User.Id);AddAshlarAspNetCoreSessions registers the "Ashlar" authentication scheme by default. The handler reads the configured cookie, validates it with IAuthenticationSessionService, and creates an authenticated ClaimsPrincipal containing ClaimTypes.NameIdentifier, the Ashlar session id claim, and the authentication method claim.
Cookie defaults are intentionally secure: HttpOnly = true, SecurePolicy = Always, SameSite = Lax, and Path = "/". SameSite=Lax is chosen so normal top-level navigation back to an application login flow keeps working while cross-site subresource and background requests do not carry the session cookie. Applications that need stricter same-site behavior can configure the cookie builder.
Ashlar includes framework-neutral rate limiting primitives to protect sensitive authentication flows. AddAshlarIdentity registers a thread-safe InMemoryAuthenticationRateLimiter by default.
Note: The default in-memory rate limiter is suitable for development and single-instance deployments. Distributed production applications should implement and register a persistent/distributed IAuthenticationRateLimiter.
If your rate limiting strategy depends on the client's IP address, you must protect your endpoints against requests where the IP address cannot be determined (which could bypass the rate limit). Ashlar provides the UseAshlarRequireIpAddress middleware for this purpose:
// Returns a 400 Bad Request if the client IP is missing
app.UseAshlarRequireIpAddress();The Ashlar.Postgres package includes a PostgreSQL-backed implementation that uses row-level locking for atomic distributed limiting. Register it using:
services.AddAshlarPostgres(connectionString);
services.AddAshlarPostgresRateLimiting(options =>
{
options.CleanupInterval = TimeSpan.FromMinutes(5);
options.MaxCleanupRows = 1000;
});The PostgreSQL implementation uses the same schema initialized by InitializeAshlarPostgresSchemaAsync(). It supports opportunistic cleanup of expired entries during active rate limit checks.
Callers should choose rate limit keys carefully (e.g., per-email, per-IP, or composite keys) to isolate flows correctly.
Ashlar can explicitly remove expired or retained operational data from PostgreSQL: expired/revoked sessions and credentials, expired/accepted/revoked invitations, expired/completed/revoked MFA handshakes, expired rate-limit rows, and old audit events. Audit-event retention is disabled by default and must be configured intentionally.
Register the cleanup service and call it from an administrative job or maintenance endpoint:
services.AddAshlarPostgres(connectionString);
services.AddAshlarPostgresCleanup(options =>
{
options.BatchSize = 500;
options.MaxBatchesPerRun = 10;
options.RemoveAuditEventsAfter = TimeSpan.FromDays(365);
});public sealed class MaintenanceJob(IServiceScopeFactory scopeFactory)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
using var scope = scopeFactory.CreateScope();
var cleanup = scope.ServiceProvider.GetRequiredService<IAshlarCleanupService>();
AshlarCleanupResult result = await cleanup.CleanupAsync(cancellationToken);
}
}Applications that want automatic background cleanup can opt in explicitly:
services.AddAshlarPostgres(connectionString);
services.AddAshlarPostgresCleanupHostedService(options =>
{
options.CleanupInterval = TimeSpan.FromHours(1);
options.RemoveExpiredSessionsAfter = TimeSpan.FromDays(7);
});Cleanup uses bounded batches and the application's TimeProvider, so repeated or concurrent runs are safe and deterministic in tests. MaxBatchesPerRun lets one cleanup run catch up on backlog without making the run unbounded.
Ashlar supports scoped database transactions through the IAshlarTransactionProvider abstraction. This allows multiple repository operations within a single service scope to participate in a shared unit of work.
public class MyIdentityService(
IIdentityService identityService,
IAshlarTransactionProvider transactionProvider)
{
public async Task RegisterAndInviteAsync(User user)
{
// Start a transaction for the current scope
await using var transaction = await transactionProvider.BeginTransactionAsync();
try
{
await identityService.CreateUserAsync(user);
await identityService.SetPasswordAsync(user.Id, "...");
// All operations in this scope now share the same transaction
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
}AddAshlarIdentity() registers a NullTransactionProvider by default, which performs no-op transactions. Persistence packages like Ashlar.Postgres provide a functional implementation.
- Scope Bound: Transactions are bound to the
IServiceProviderscope (typically the HTTP request). - Single Transaction: Only one active transaction is supported per scope. Attempting to start a nested transaction will throw an
InvalidOperationException. - Resource Management: Callers MUST call
DisposeAsync(typically viaawait using) to release the underlying connection, even after a commit or rollback.
Ashlar emits structured security audit events for authentication, credential lifecycle, and session lifecycle operations. AddAshlarIdentity() registers ISecurityEventSink with NullSecurityEventSink by default, so events are no-op unless the application provides a sink:
services.AddSingleton<ISecurityEventSink, MySecurityEventSink>();
services.AddAshlarIdentity();Audit event payloads include stable event types, timestamps, user/session ids when known, provider identity, IP address, user agent, correlation id, outcome, failure reason, and string properties. Audit events must not contain raw session tokens, passwords, one-time codes, credential values, or other secrets.
The Ashlar.Postgres package includes a PostgreSQL-backed sink:
services.AddAshlarPostgres(connectionString);
services.AddAshlarPostgresAuditSink();Ashlar includes generic opt-in security notifications to notify users about important account and security events, such as new sign-ins, session revocations, and MFA changes.
Register the notification services:
services.AddAshlarSecurityNotifications(options =>
{
options.Enabled = true;
options.EnabledTypes.Add(SecurityNotificationType.SignIn);
options.EnabledTypes.Add(SecurityNotificationType.TotpEnrolled);
options.EnabledTypes.Add(SecurityNotificationType.TotpDisabled);
// ... other types
});Security notifications use the existing IEmailSender abstraction, so ensure you have registered a functional email sender. Notifications are sent post-commit for transactional flows and include only safe context (no secrets or raw tokens).
Repeated notifications are suppressed by recipient and notification type to avoid user spam. Most notification types default to a 15 minute cooldown; SuspiciousAuthenticationAttempt defaults to a 1 hour cooldown because it can be triggered by hostile verification traffic after authentication rate limits are reached. Applications can override or disable a cooldown:
services.AddAshlarSecurityNotifications(options =>
{
options.Enabled = true;
options.EnabledTypes.Add(SecurityNotificationType.SignIn);
options.Cooldowns[SecurityNotificationType.SignIn] = TimeSpan.FromHours(1);
options.Cooldowns[SecurityNotificationType.TotpDisabled] = TimeSpan.Zero; // always send
});SignIn: New session created.SessionRevoked: A specific session was revoked.AllOtherSessionsRevoked: All other sessions for the user were revoked.AllSessionsRevoked: All sessions for the user were revoked.TotpEnrolled: MFA enrollment completed.TotpDisabled: MFA disabled.RecoveryCodesGenerated: New recovery codes generated.InvitationAccepted: User invitation accepted.BootstrapCompleted: System bootstrap completed.EmailChanged: User email address changed.EmailVerificationCompleted: Email verification successful.SuspiciousAuthenticationAttempt: Rate-limited authentication handshake attempt.
Applications can override the default notification subjects and bodies:
services.AddAshlarSecurityNotifications(options =>
{
options.Enabled = true;
options.EnabledTypes.Add(SecurityNotificationType.SignIn);
options.TemplateOverrides[SecurityNotificationType.SignIn] = new SecurityNotificationTemplate
{
Subject = "Security Alert: New Sign-in",
Body = "We detected a new sign-in to your account at {OccurredAt} from {IpAddress}."
};
});Available placeholders in templates:
{RecipientEmail}: The email address receiving the notification.{OccurredAt}: The timestamp of the event.{Type}: The notification type.{IpAddress}: The approximate IP address (if enabled and available).{UserAgent}: The user agent string (if enabled and available).{SessionId}: The session identifier (if applicable).
Contributions are welcome! Read the contributing guide to get started.
This project is licensed under the MIT License.