Zum Inhalt springen
Diese Seite wurde noch nicht übersetzt.

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 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 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 while sign-up creates accounts with EmailConfirmed = false, so nothing gates unverified emails and email_verified is honestly false in the tokens; turn it on once the confirmation mail is in place, or the provider keeps issuing tokens for accounts whose email was never proven. The generic !result.Succeeded branch also swallows NotAllowed, so an unconfirmed user just sees "Invalid email 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" }.

In Program.cs that is AddDbContext for your Identity DbContext pointed at that connection string, then AddIdentityCore<IdentityUser> followed by AddEntityFrameworkStores and AddSignInManager. The options delegate of AddIdentityCore is where the sample caps Lockout.MaxFailedAccessAttempts and sets User.RequireUniqueEmail; AddSignInManager is what puts SignInManager in the container, and without it the credential check below has nothing to call. Last, register your IUserInfoProvider implementation as scoped: the library ships none, so this registration is mandatory rather than optional.

C#
// Program.cs
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, so the sample creates its schema at startup, in a scope opened between builder.Build() and app.Run(), by calling EnsureCreatedAsync on the DbContext. A real deployment replaces that with EF migrations.

The same startup block goes on to create a signing key and the client registrations in the OIDC store, for the same reason: both live in the database from here on, and the sample has to put the first ones there.

There is no seeded user. A fresh run starts with an empty Identity store, and the first account is created the way every later one is, through sign-up. The handler builds an IdentityUser whose UserName and Email are both the submitted address and whose EmailConfirmed is false, hands it with the password to UserManager.CreateAsync, and on failure returns Identity's own error descriptions to the screen, so "password too short" or "email already taken" is what the user reads. Then it writes the display name into Identity's claim store with AddClaimAsync under the OIDC claim name name.

CreateAsync is where Identity earns its place: it enforces the password policy and the unique-email rule, and persists only a salted PBKDF2 hash. 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 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.

EmailConfirmed stays false, which is honest - nobody proved control of that mailbox - and the sample still lets the account sign in, flagged with a #warning naming the step a real deployment adds: email a confirmation link and trust the address only once it comes back.

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 writing one at sign-up is 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. The single method, GetUserInfoAsync, receives the AuthSession and the requested claim names and returns a JsonObject of what it can answer.

It resolves the user through UserManager.FindByIdAsync on AuthSession.Subject, which is the Identity user id set at login below, and returns null when there is no such user. From the IdentityUser properties it answers preferred_username from UserName, phone_number from PhoneNumber, and phone_number_verified from PhoneNumberConfirmed, the last only where a phone number is actually stored. For email and email_verified it prefers the values the session carries, falling back to Email and EmailConfirmed in the store, because the session snapshot is what was true at login. Everything else the request asked for it looks up in Identity's claim store through GetClaimsAsync, matching on the claim name and leaving the property-derived values in place where both could answer. Claims with no value are simply absent from the object rather than present and null.

Two implementation details are worth copying. The requested names arrive as a deferred sequence, so materialize them once instead of enumerating repeatedly. And the names are compared ordinally, which the cautions below turn into a storage rule.

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 endpoint

The login flow stays what it was; 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 sample serves a React screen there, which posts JSON to its own auth API; that handler verifies the credentials, signs the session in, and returns the URL that resumes the authorize request.

The handler finds the user by email through UserManager, and where there is one, calls SignInManager.CheckPasswordSignInAsync with lockoutOnFailure set: that verifies the hash and enforces lockout while issuing no cookie, so the session stays the library's. A missing user and a wrong password take the same failure path and the same message. On success it signs the session in and returns the URL that resumes the authorize request, so the browser can follow it. Besides SignInManager and UserManager, the handler needs the library's IAuthSessionService and ISessionIdGenerator, a TimeProvider for the authentication time, and OidcRouteOptions to build the resume URL.

The session itself is built in the tail both login and sign-up share:

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

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.

An MVC host writes the same handler as a controller action taking [FromForm] values and returning Redirect(...); a Minimal API host returns the URL and lets the caller follow it. What must not change either way is the order: verify, then build the session, then resume.

Three details deserve a sentence each. Login CSRF, where a hostile page silently signs your browser into an attacker's account, is a real attack, and a JSON API cannot use the hidden form field an MVC view would emit: the sample configures antiforgery with a header name, issues the request token in a script-readable cookie when it serves the screen, and the SPA echoes it back. The lockout state hides behind the same generic "Invalid email 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 handler, 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.

Making a password change end the session

Identity wires its security-stamp revalidation onto its own cookie schemes, which this integration deliberately skips, so nothing does it for you: out of the box a password change or UpdateSecurityStampAsync leaves existing OIDC sessions alive until the cookie expires, and a bare AddCookie() defaults to a 14-day sliding window.

The sample closes that gap by hand, and it takes both halves: a snapshot nobody compares is dead weight, and a comparison against a claim that was never written rejects every session.

Two dials close it. 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 shared sign-in tail, put the value of UserManager.GetSecurityStampAsync into AdditionalClaims under a name of your own, security_stamp in the sample.

The comparison side belongs to the cookie options in Program.cs, in the OnValidatePrincipal event. It resolves UserManager from the request services, finds the user by the principal's sub claim, and compares the current security stamp with the one the principal carries. If the user is gone or the stamps differ, it calls RejectPrincipal and signs the scheme out. Set ExpireTimeSpan in the same options block, since a bare AddCookie() leaves the 14-day default in place.

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 and end-session paths rather than every page load - userinfo validates an access token and never touches the cookie - 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 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

  • Create an account first, and do it from a client rather than by opening the provider directly: the sign-up screen resumes the authorization request it was pulled out of, so reaching it any other way leaves nothing to resume. Start TestClientApp and open it: its home page requires authentication, so it sends you to the provider, where Create one on the sign-in screen makes the account. That is also the first thing this integration proves, since the account is created through UserManager rather than seeded.
  • 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 name claim written at sign-up 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. 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; until it ships, this guide is the integration path.