# 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; miss it and the app fails fast rather than silently, at startup in Development (service-provider validation) or on the first [ID-token](https://www.abblix.com/en/docs/glossary-overview#id-token-identity-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](https://github.com/Abblix/Oidc.Server.GettingStarted): 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 OIDC session claims the library's adapter requires (subject, session id, authentication time). `AuthenticationSchemeAdapter.AuthenticateAsync` treats such a cookie as no session and returns null by design, so the library bounces the request back to the login page: a silent login loop, not a crash you can catch. 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.

`CheckPasswordSignInAsync` also runs Identity's pre-sign-in check first: set `options.SignIn.RequireConfirmedEmail = true` (or `RequireConfirmedAccount`) and it returns `NotAllowed` for an unconfirmed account before the password is even tested. The sample leaves this off and seeds `EmailConfirmed = true`, so nothing gates unverified emails; a host that adds its own registration must turn it on, or the provider issues tokens for accounts whose email was never proven. The generic `!result.Succeeded` branch also swallows `NotAllowed`, so an unconfirmed user just sees "Invalid username or password"; branch on `result.IsNotAllowed` to tell them to confirm instead.

## 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" }`.

```csharp
// 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](https://learn.microsoft.com/aspnet/core/security/authentication/identity) 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:

```csharp
// 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,
        };
        // generate the seed password instead of hardcoding one: Identity stores only its
        // salted PBKDF2 hash, and nothing sensitive lands in source
        var password = GeneratePassword();
        var created = await users.CreateAsync(user, password);
        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"));

        // show it once, on the console, so the sample is runnable
#warning DEV convenience only: even the console reaches container stdout and log sinks; a real deployment never surfaces a password.
        Console.WriteLine($"Seeded john.doe@example.com with a one-time password: {password}");

        // pause so an operator can copy it before startup logs scroll it away;
        // skipped when stdin is not interactive so a headless start never hangs
        if (!Console.IsInputRedirected)
        {
            Console.WriteLine("Copy it now, then press Enter to start the server...");
            Console.ReadLine();
        }
    }
}
```

The password is generated, not hardcoded, so nothing sensitive sits in source, and Identity persists only its salted [PBKDF2](https://www.abblix.com/en/docs/glossary-overview#pbkdf2) hash: the plaintext exists just long enough to be printed once. `GeneratePassword` is a few lines of crypto-random in the sample. That hash is only as strong as its work factor. Identity's default hasher on .NET 10 is PBKDF2-HMAC-SHA512, so check `PasswordHasherOptions.IterationCount` against the current [OWASP PBKDF2 guidance](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2) for that algorithm and raise it if needed, or swap in a memory-hard `IPasswordHasher<TUser>`. The sample binds `IterationCount` from `appsettings.json`, so it moves without a recompile. Printing a credential is a development-only shortcut, flagged with a `#warning` in the sample, and even on the console it reaches container stdout and log sinks, so treat it as exposed. In this sample only, copy the generated password the console prints at startup (the sample pauses on an interactive terminal so it does not scroll past); a real deployment seeds the account with no usable password and emails a reset link, or forces a change on first login.

The `AddClaim` call is not decoration: `IdentityUser` has no name property, so `name`, `given_name`, `picture`, and the profile claims Identity has no property for 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:

```csharp
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 returns any claim the client names. Only store client-safe values under OIDC claim names: never authorization data or internal flags.

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:

```csharp
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
```

```csharp
[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](https://www.abblix.com/en/docs/glossary-overview#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 would bypass the second factor. 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, so set `ExpireTimeSpan` before go-live.

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:

```csharp
// in the Login action, on the AuthSession initializer:
AdditionalClaims = new JsonObject
{
    ["security_stamp"] = await userManager.GetSecurityStampAsync(user),
},
```

```csharp
// 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.

This trades cost for immediacy: unlike Identity's `SecurityStampValidator`, which revalidates on a 30-minute `ValidationInterval` to avoid a store hit per authentication, this checks the store every time the session cookie is authenticated. For an OIDC provider that is the authorize, userinfo, and end-session paths rather than every page load, so the volume is usually modest, but if it is not, cache the stamp with a short interval or accept the validator's window instead of instant revocation. The `AspNetIdentitySample` ships this dial on; drop it if your threat model does not need it.

## 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](https://www.abblix.com/en/docs/glossary-overview#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](https://www.abblix.com/en/docs/glossary-overview#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](https://www.abblix.com/en/docs/durable-client-store)), the go-live defaults are in the [Production Hardening Checklist](https://www.abblix.com/en/docs/production-hardening-checklist), and the login flow this page extends is walked end to end in the [Getting Started guide](https://www.abblix.com/en/docs/getting-started-guide). Out of scope here, each its own integration: external and social logins (the `GetExternalLoginInfoAsync` path, which meets the same `IAuthSessionService.SignInAsync` seam but not through `CheckPasswordSignInAsync`), password reset and change flows, and claims transformation; they attach to the same session service, but are not shown. A first-party ASP.NET Identity package remains on the [roadmap](https://www.abblix.com/en/docs/product-roadmap); until it ships, this guide is the integration path.
