Skip to main content

Integrating ASP.NET Core Identity with Abblix OIDC Server

Abblix OIDC Server ships no user store: who your users are, how their passwords are verified, and where their profile data lives are the host's to decide. ASP.NET Core Identity is the stock answer to those questions, with password hashing, lockout, security stamps, and two-factor plumbing that nobody should rewrite. This guide wires the two together.

The division of labor is the point to hold onto: Identity owns users and credentials; the library owns the protocol and the OIDC session. They meet at just two seams, and both are small.

  • IUserInfoProvider turns a subject into claims. The library ships no default and registers none, so a host must supply one: without it the app fails at startup in Development (service-provider validation) or at the first ID-token or userinfo build in Production. With Identity, this is an adapter over UserManager.
  • The login flow signs a verified user into the library's session. Identity verifies the password; the library's IAuthSessionService issues the session cookie.

Everything below extends the Getting Started sample (an MVC provider with a demo login page), and the finished result runs as the AspNetIdentitySample project in the Getting Started repository: the same wiring drops into any host.

One rule first: Identity checks the password, the library signs the session

The tempting shortcut is SignInManager.PasswordSignInAsync, Identity's one-call login. Do not use it here. With this wiring it throws at once: it signs into Identity.Application, a cookie scheme the core registration deliberately never registers. And registering Identity's cookies instead does not help: that principal is built by Identity's claims factory and carries none of the claims the library's session adapter requires, and AuthenticationSchemeAdapter.AuthenticateAsync throws when the session id or authentication time is missing. Two cookie regimes fighting over one login is a debugging session you do not need.

The clean split: verify the password with CheckPasswordSignInAsync (it checks the hash, counts failures, and enforces lockout without signing anything in), then build an AuthSession and hand it to IAuthSessionService.SignInAsync, just as the sample already does. One cookie, registered by the host and driven by the library's session service, carrying the claim shape the library expects.

Wire Identity into the container

Use the core registration, not the full AddIdentity: the core variant brings the user store, password hashing, and lockout without registering Identity's cookie schemes, which this integration deliberately does not use.

Two packages carry everything below: Microsoft.AspNetCore.Identity.EntityFrameworkCore (Identity plus its EF Core stores) and Microsoft.EntityFrameworkCore.Sqlite (or the database provider of your choice). The connection string the DbContext reads lives in appsettings.json: "ConnectionStrings": { "Users": "Data Source=users.db" }.

// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("Users")));

builder.Services
.AddIdentityCore<IdentityUser>(options =>
{
options.Lockout.MaxFailedAccessAttempts = 5;
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddSignInManager();

// the library ships no IUserInfoProvider: this registration is mandatory, not optional
builder.Services.AddScoped<IUserInfoProvider, IdentityUserInfoProvider>();

The DbContext is standard Identity fare (AppDbContext : IdentityDbContext<IdentityUser>), created and migrated the way Microsoft's Identity documentation describes; nothing about it is Abblix-specific. Keep the sample's existing AddAuthentication().AddCookie(): that host-registered cookie carries the library's session, and it stays. No app.UseAuthentication() is needed for this flow: the library's session adapter authenticates its cookie scheme explicitly.

If you started from the Getting Started sample, delete its TestUserStorage registration: the adapter below takes over the claims duty, and SignInManager takes over the credential check.

One practical step before anything can log in: the Identity store starts empty, and this provider has no self-registration page, so seed a first user. For a sample, schema creation and seeding at startup keep it runnable out of the box; a real deployment replaces this block with EF migrations and its own registration flow:

// after app = builder.Build(), before app.Run()
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.EnsureCreatedAsync();

var users = scope.ServiceProvider.GetRequiredService<UserManager<IdentityUser>>();
if (await users.FindByEmailAsync("john.doe@example.com") is null)
{
var user = new IdentityUser
{
UserName = "john.doe@example.com",
Email = "john.doe@example.com",
EmailConfirmed = true,
};
var created = await users.CreateAsync(user, "Jd!2024$3cur3");
if (!created.Succeeded)
throw new InvalidOperationException(string.Join("; ", created.Errors.Select(e => e.Description)));

// profile claims Identity does not model live in its claim store:
// the adapter below surfaces them when the profile scope asks
await users.AddClaimAsync(user, new Claim("name", "John Doe"));
}
}

That last line is not decoration: IdentityUser has no name property, so name, given_name, picture, and the rest of the profile claims live in Identity's claim store, and seeding one demonstrates the exact path the adapter reads them through.

The claims adapter

IUserInfoProvider has one method: given the authenticated session and the claim names the request is entitled to, return them as JSON. The claim names arrive already resolved from scopes (email pulls email and email_verified, profile pulls the fourteen OIDC profile claims, phone pulls the two phone claims), so the adapter only maps names to Identity data:

public sealed class IdentityUserInfoProvider(UserManager<IdentityUser> users) : IUserInfoProvider
{
public async Task<JsonObject?> GetUserInfoAsync(AuthSession authSession, IEnumerable<string> requestedClaims)
{
// the subject is the Identity user id: set at login below, resolved here
var user = await users.FindByIdAsync(authSession.Subject);
if (user is null)
return null; // no user, no claims: userinfo answers invalid_token, no ID token is issued

// requestedClaims arrives as a deferred sequence: materialize it once; names match by ordinal comparison
var requested = requestedClaims.ToHashSet(StringComparer.Ordinal);

var claims = new JsonObject();
foreach (var claim in requested)
{
JsonNode? value = claim switch
{
// prefer the session's snapshot when it carries one: it reflects what was true at login
"email" => authSession.Email ?? user.Email,
"email_verified" => authSession.EmailVerified ?? user.EmailConfirmed,
"preferred_username" => user.UserName,
"phone_number" => user.PhoneNumber,
"phone_number_verified" => user.PhoneNumber is not null ? user.PhoneNumberConfirmed : null,
_ => null,
};

if (value is not null)
claims[claim] = value;
}

// profile claims Identity does not model (name, given_name, picture...) live
// in Identity's claim store: surface the ones the request asked for
foreach (var stored in await users.GetClaimsAsync(user))
{
if (requested.Contains(stored.Type) && !claims.ContainsKey(stored.Type))
claims[stored.Type] = stored.Value;
}

return claims;
}
}

Three contract points, all library-enforced:

  • Do not bother emitting sub. The library overwrites it after the call with the session's subject, run through the pairwise-identifier converter when the client asks for pairwise subjects; a provider-supplied sub never survives.
  • Returning null fails the whole response: userinfo answers invalid_token, and no ID token is issued. That is the correct behavior for a deleted user with a live session.
  • If a request marks a claim as essential and the adapter does not return it, the library rejects the whole claim set. Populate what your users actually have; the standard scopes ask only for what they define.

Two cautions about the claim store. Names match by exact ordinal comparison, so store claims under the lowercase OIDC names (name, not Name and not a ClaimTypes URI), or the adapter never finds them. And treat the store as client-facing here: the claims request parameter is not gated by scopes, so a client can ask for any claim by name, and this loop releases whatever string-matches. Never keep authorization data or internal flags under OIDC claim names.

Custom scopes work the same way: declare them with their claim names in OidcOptions.Scopes (a ScopeDefinition per scope), and the names arrive in requestedClaims like the standard ones.

The login controller

The sample's login flow stays intact; only the credential check changes hands. The authorize endpoint redirects an unauthenticated user to OidcOptions.LoginUri with the pending request in the request_uri query parameter; the controller verifies credentials, signs the session in, and sends the user back to the authorize endpoint with the same request_uri.

Four using directives are load-bearing for the snippet below. Without the aliases, the compiler resolves System.UriBuilder (whose Query is a plain string) and System.IO.Path, and SignInResult is ambiguous between the MVC and Identity namespaces:

using Microsoft.AspNetCore.Identity;
using Path = Abblix.Oidc.Server.Mvc.Path;
using SignInResult = Microsoft.AspNetCore.Identity.SignInResult;
using UriBuilder = Abblix.Utils.UriBuilder; // its Query is a parameter collection, not a string
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(
[FromServices] IAuthSessionService authService,
[FromServices] ISessionIdGenerator sessionIdGenerator,
[FromServices] IUriResolver uriResolver,
[FromServices] UserManager<IdentityUser> userManager,
[FromServices] SignInManager<IdentityUser> signIn,
[FromServices] TimeProvider clock,
[FromForm] string email,
[FromForm] string password,
[FromForm] string requestUri)
{
var user = await userManager.FindByEmailAsync(email);

// verifies the hash and enforces lockout, but issues no cookie: the session stays the library's
var result = user is not null
? await signIn.CheckPasswordSignInAsync(user, password, lockoutOnFailure: true)
: SignInResult.Failed;

if (!result.Succeeded)
{
ModelState.AddModelError("", "Invalid username or password");
return View(new { requestUri });
}

var authSession = new AuthSession(
user!.Id, // the subject the adapter resolves later
sessionIdGenerator.GenerateSessionId(),
clock.GetUtcNow(),
CookieAuthenticationDefaults.AuthenticationScheme)
{
Email = user.Email,
EmailVerified = user.EmailConfirmed,
AuthenticationMethodReferences = ["pwd"], // lands in the amr claim
};

await authService.SignInAsync(authSession);

var authorizeUrl = new UriBuilder(uriResolver.Content(Path.Authorize))
{ Query = { [AuthorizationRequest.Parameters.RequestUri] = requestUri } };
return Redirect(authorizeUrl);
}

The subject is user.Id, Identity's stable key: it survives email and username changes, which is exactly what an OIDC sub must do. Setting Email and EmailVerified on the session snapshots them at login time; the adapter above prefers the snapshot over the store, per the provider contract.

Three details around this controller deserve a sentence each. The login POST carries the standard antiforgery pair (the asp-action form tag helper emits the hidden token automatically; [ValidateAntiForgeryToken] on the action enforces it): login CSRF, where a hostile page silently signs your browser into an attacker's account, is a real attack. The lockout state hides behind the same generic "Invalid username or password" message, so a lockout does not confirm an account exists; if your threat model allows friendlier UX, branch on result.IsLockedOut and tell the user when to retry. And a missing email returns instantly while an existing one pays the full hash cost, so response timing can still leak whether an account exists: if enumeration resistance matters, burn an equivalent hash verification on the missing-user path too.

Two-factor slots into this controller, not into the library, and not through the result object: CheckPasswordSignInAsync reports only success, failure, and lockout; RequiresTwoFactor belongs to PasswordSignInAsync and never fires here. After the password succeeds, ask Identity directly: when await userManager.GetTwoFactorEnabledAsync(user) is true, run and verify the second factor before constructing the AuthSession. Building the session after the password alone is a 2FA bypass. Then set AuthenticationMethodReferences from the factors actually verified in this request (for example ["pwd", "otp"]): relying parties may use amr for step-up decisions, so it must reflect what happened, not a fixed literal.

What a password change does not do

The library's session cookie is not security-stamp validated. Identity wires its stamp revalidation only onto its own cookie schemes, which this integration deliberately skips, so a password change or UpdateSecurityStampAsync does not end existing OIDC sessions: they live until the cookie expires, and a bare AddCookie() defaults to a 14-day sliding window.

Two dials close the gap. First, bound the cookie lifetime deliberately (ExpireTimeSpan, SlidingExpiration). Second, if changed-password-must-end-sessions is a requirement, snapshot the stamp into the session at login and check it on every request. The adapter round-trips AuthSession.AdditionalClaims through the cookie, so the snapshot travels with the principal:

// in the Login action, on the AuthSession initializer:
AdditionalClaims = new JsonObject
{
["security_stamp"] = await userManager.GetSecurityStampAsync(user),
},
// Program.cs
builder.Services
.AddAuthentication()
.AddCookie(options =>
{
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.Events.OnValidatePrincipal = async context =>
{
var userManager = context.HttpContext.RequestServices
.GetRequiredService<UserManager<IdentityUser>>();
var subject = context.Principal?.FindFirstValue("sub");
var user = subject is null ? null : await userManager.FindByIdAsync(subject);
var snapshot = context.Principal?.FindFirstValue("security_stamp");

if (user is null || await userManager.GetSecurityStampAsync(user) != snapshot)
{
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(context.Scheme.Name);
}
};
});

A rejected principal sends the user back through login on the next request.

Logout needs no Identity glue

The lifecycle closes on the library's side alone. The end-session endpoint calls IAuthSessionService.SignOutAsync itself, and the client ids the authorize endpoint accumulates into AuthSession.AffectedClientIds drive back-channel and front-channel logout notifications to every client the session touched. The one rule holds in reverse: do not call SignInManager.SignOutAsync, there is nothing for it to sign out of.

Verify the integration

  • Run the provider and complete an authorization-code flow with openid email scope: the ID token must carry sub equal to the Identity user id, and userinfo must return the email and email_verified snapshotted at login (identical to the store values at that moment). With the profile scope, the seeded name claim must arrive too: that proves the claim-store path in the adapter, not just the property mapping.
  • Change the user's email in Identity, sign in again, and confirm the claims follow.
  • Lock the account with repeated wrong passwords: login must refuse before any OIDC redirect happens, proving lockout runs in the credential check.
  • Delete the user while a session cookie is still alive and call userinfo: it must answer invalid_token, proving the adapter's null path.

Where this sits in the bigger picture

This guide covers users and credentials. The clients your users sign in to have their own store (A Durable Client Store), the go-live defaults are in the Production Hardening Checklist, and the login flow this page extends is walked end to end in the Getting Started guide. A first-party ASP.NET Identity package remains on the roadmap; until it ships, this guide is the integration path.