Zum Inhalt springen
Diese Seite wurde noch nicht übersetzt.

Migrating from IdentityServer4 or Duende to Abblix OIDC Server

This is an engineer's account of one migration, not a description of migration in general. The subject is dotnet/eShop, Microsoft's reference .NET commerce application, whose Identity.API project ships upstream as a Duende IdentityServer host. The result is Abblix/eShop: the same application, the same storefront, the same APIs, with Abblix OIDC Server underneath.

A word on the subject, for readers who have not met it. eShop is the AdventureWorks web store Microsoft maintains as a showcase of a services-based .NET system: a Blazor storefront, catalog, basket and ordering services talking REST and gRPC, a RabbitMQ event bus, PostgreSQL and Redis, a separate webhooks client, a MAUI mobile app, and a .NET Aspire host that orchestrates the lot. One of those services, Identity.API, is the OpenID provider the rest of the system trusts - ASP.NET Core Identity for the users, Duende IdentityServer for the protocol. After the migration the same seat belongs to Abblix OIDC Server.

The storefront and the webhooks client sign users in against Identity.API through the authorization code flow with PKCE; inside that service, Abblix OIDC Server answers the protocol endpoints and hands credential checks to ASP.NET Core Identity, whose users - and, in this fork, the signing key - live in PostgreSQL. Token in hand, the storefront calls the basket and ordering APIs and the webhooks client calls its own API, each request carrying a Bearer access token. The resource APIs never ask the provider about any particular request: each validates tokens on its own, against the discovery document and the key set it fetched once and cached, with no shared secret anywhere. That indirection is what makes the provider replaceable - nothing on the consuming side names an implementation.

That shape is why it was chosen. eShop is public and runs IdentityServer, so a reader can diff this migration against a system they already recognize. It is also realistic in the places migrations actually break: clients of three different kinds, two of which turn out to be dead, custom claims flowing into a checkout form, gRPC resource servers validating tokens. The MIT license is what lets the finished fork sit in public with every decision inspectable.

Each step below says what changed and why it was decided that way; where a real fork existed, it names the alternatives rejected and for what reason. The part that matters most is where the two libraries put the boundary between the library's job and yours, because wherever that boundary sits in a different place, a mechanical translation compiles cleanly and still leaves a decision unmade.

Which Duende, for the reader checking along: upstream eShop pinned 7.3.2, and every statement here about how IdentityServer behaves was read from that version's sources. A later release may answer differently, and a page about somebody else's product ages the moment it ships.

One thing this page is not: an argument for leaving Duende IdentityServer. It has been in production for a decade, and nobody should move off a system that works. What a worked migration adds is the part no feature table reaches: what changes, and what turns out not to.

And if you came here with a reason of your own, or simply because you are curious what the alternatives look like from the inside, then this page and the fork it describes are written for you: a migration somebody has already done, in an application you can run today.

The code is in the fork; this page is the reasoning. One thing to know before you clone it.

The first is that whether the trade is right for you is not a question this page can answer, because it is about eShop. Abblix is the younger product, with no FAPI certification where Duende holds one, no ready-made server-side session store, and no first-party persistence packages yet. The feature-by-feature comparison has those numbers, including the ones that do not flatter us. Read it before this page, not after.

Measure the blast radius before touching anything

The first question is how much of your system knows about the old provider, because that number decides whether the work is a project or an afternoon.

In eShop the answer is: the old provider is named in 36 files. Thirty-four sit inside src/Identity.API; the other two are the central package pins in Directory.Packages.props and a Dependabot group that watches them. No front end, no API, no test names it.

Run that search with a control - a count that comes back zero because the search was wrong looks exactly like a count that comes back zero because there is nothing to find. Search for something you know is present and confirm the tool answers.

The result shapes everything else. eShop's basket, ordering and webhooks APIs authenticate with the standard JWT bearer handler pointed at an authority URL. The storefront and the webhooks client use the standard OpenID Connect handler, also pointed at an authority. None of them names an implementation: they read the discovery document, take the key set from it, and validate what arrives.

One caveat before you conclude the same about your own system. eShop's resource servers set ValidateAudience = false, so the aud claim never mattered here. If yours validate it - which the bearer handler does by default once Audience is set - check the value before cutover. Read the claim out of a token your current server issues, then compare: Duende builds it from the API resources behind the requested scopes, and Abblix has no API-resource concept at all, so with neither an RFC 8707 resource parameter nor an RFC 8693 audience on the request, aud falls back to the issuer. Getting a resource-shaped value back means either that the client sends resource=, which is a client change, or that the server names a default resource indicator, which is one line of configuration and is read by every resource server in the deployment. Decide which side moves before you promise that nobody has to redeploy.

With that caveat stated, the work is nearly confined to one project. Outside Identity.API, the migration touched the package pins, the Dependabot group, the ignore file, the readme, and one orchestration hook the AppHost no longer needed - a hand-rolled forwarded-headers subscriber that Aspire has injected by default since it gained DisableForwardedHeaders as the opt-out. Inside it, the changes fall into three groups: configuration that is mostly mechanical but hides three decisions that are not, a login screen that is a genuine rewrite, and persistence decisions that become yours.

Replacing the packages

Five packages become one. In the central pin file, this:

XML
<PackageVersion Include="Duende.IdentityServer" Version="7.3.2" />
<PackageVersion Include="Duende.IdentityServer.AspNetIdentity" Version="7.3.2" />
<PackageVersion Include="Duende.IdentityServer.EntityFramework" Version="7.3.2" />
<PackageVersion Include="Duende.IdentityServer.EntityFramework.Storage" Version="7.3.2" />
<PackageVersion Include="Duende.IdentityServer.Storage" Version="7.3.2" />

becomes this:

XML
<PackageVersion Include="Abblix.Oidc.Server.Mvc" Version="$(AbblixOidcVersion)" />

Three of the five were storage: the two Entity Framework store packages and the storage abstractions. The fourth, the ASP.NET Identity bridge, holds no store at all: it is the profile service and the resource-owner validator written over ASP.NET Identity, and it goes because the profile service becomes a provider of your own, while ASP.NET Identity itself stays exactly where it was. Duende ships persistence as packages you install, whose schema you migrate and whose tables you then operate. Abblix ships interfaces - with in-memory defaults behind some of them, and behind others, as two later sections insist, nothing at all. The packages disappear because the decision moved into your code, not because the work did. It reappears near the end of this page.

One pin moves with them. eShop pins package versions centrally and enables transitive pinning, which promotes every indirect dependency to the pinned version, so any dependency whose floor sits above your pin is raised in the same edit. Here that is Protobuf: raise the central pin to the version the Abblix package asks for.

Moving the endpoints from middleware onto routes

Duende installs a middleware that inspects every request and answers the paths it recognizes. Abblix ships the protocol endpoints as ordinary attribute-routed MVC controllers, so you map them the way you map your own. One line in the pipeline:

C#
app.UseRouting();
app.UseIdentityServer();
app.UseAuthorization();

becomes three, and each of them is something the old middleware was doing out of sight:

C#
app.UseRouting();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

Because the endpoints are controllers, everything in the pipeline that binds to endpoint metadata applies to them with no plugin model: authorization policies, endpoint-scoped rate limiting and CORS policies, API versioning, your own action filters. A middleware answers before routing reaches an endpoint, so anything endpoint-scoped has to be provided inside the product instead.

Two of those lines are ones the middleware used to cover out of sight, so name them deliberately.

Authentication becomes your call. Duende's middleware authenticated the request internally. With controllers, authentication is the pipeline's job, so UseAuthentication belongs in your startup where you can see it.

CORS belongs there too. Several protocol controllers carry CORS metadata, because browser-based clients read the discovery document, the key set and the token endpoint cross-origin, and ASP.NET Core routing serves an endpoint carrying that metadata only when a CORS middleware is present to honour it. UseCors is what puts it there.

The library registers the policy the controllers name; the middleware call and the origins are yours. Name the origins in the same change, because an empty list means the policy allows any of them. In the fork the list is the two addresses its clients answer on, read from the same registry that declares those clients. eShop has no browser-based client at all, so even that is generous, and it is still narrower than a policy that answers to anyone.

Deciding which endpoints exist at all

The default is the base set: discovery, key set, authorize, token, userinfo, end session and pushed authorization requests. Introspection, revocation, dynamic client registration, check session, backchannel authentication and device authorization stay off until you name them, and naming one without also registering what it needs stops the host at startup rather than serving a half-wired endpoint.

The fork runs on the base set rather than restoring the full surface. Nothing in eShop uses introspection, revocation or the device flow, and an endpoint that is enabled is attack surface whether or not anything calls it.

The default is changing because an endpoint nobody asked for should not answer, and naming the ones you use costs one line each. Duende gets to the same safety by granting each client what it may use, with every endpoint present.

Translating clients and scopes

This is the largest diff, and most of it is mechanical. The client model maps across field by field, and Duende's three resource concepts - identity resources, API resources, API scopes - collapse into a single scope definition.

The registration call shows the shape of the whole step. Before:

C#
builder.Services.AddIdentityServer(options => { /* events, key management */ })
    .AddInMemoryIdentityResources(Config.GetResources())
    .AddInMemoryApiScopes(Config.GetApiScopes())
    .AddInMemoryApiResources(Config.GetApis())
    .AddInMemoryClients(Config.GetClients(builder.Configuration))
    .AddAspNetIdentity<ApplicationUser>()
    .AddDeveloperSigningCredential();

After, with the registry read from configuration and the two seams that have no default at all registered by hand:

C#
builder.Services.AddOidcServices(options =>
{
    options.LoginUri = new Uri("/Account/Login", UriKind.Relative);
    options.Scopes = oidcConfiguration.Scopes;
    options.Clients = oidcConfiguration.ToClientInfos();
});

builder.Services.AddSingleton<IAuthServiceKeysProvider, DatabaseKeysProvider>();
builder.Services.AddScoped<IUserInfoProvider, UserInfoProvider>();

Those two registrations are the story of the second half of this page: the signing key and the claims provider have no defaults to inherit, and each is a section below. The host registers two more things this page reaches later: the CORS origins the protocol endpoints answer to, and the cache that holds authorization codes and pushed requests.

Three things do not map mechanically: two client fields and the scope definition itself.

The client authentication method is registered, and the registered value is enforced

Duende registers a shared secret and accepts it from wherever the client put it: its secret parsers try the Basic header and the request body in turn, and the same registration serves both conventions. Abblix registers one authentication method per client and compares what arrives against it.

The default is client_secret_basic - the variant RFC 6749 requires every authorization server to support, and the one it prefers over the body form. The standard ASP.NET Core OpenID Connect handler, however, posts the client id and secret in the token request body, which is client_secret_post. Registering that value is the whole of the change for such a client.

eShop's inherited registry would have needed both values, which is the clearest illustration of why the field exists: the server-rendered clients use the ASP.NET Core handler and need the body variant, while the mobile client used a library that sends an Authorization header and needed the Basic one. Under Duende that difference was invisible because both were accepted. That mobile registration was later dropped (see below), so the shipped registry names client_secret_post twice - but the day you add a client whose library differs, this field is where the difference is stated rather than discovered.

The difference exists because client registration defines the method as a single value, not a list, and enforcing the registered value buys a client whose authentication method cannot change without a registration change. The cost is one field you must get right, and the way to get it right is to check what the client library actually sends rather than what the old registration tolerated.

Offline access and the refresh grant are two separate permissions

In Duende, one flag both permits the offline-access scope and enables refreshing. Abblix spreads the same permission over three settings, and each refuses at a different moment. OfflineAccessAllowed on the client is false by default, and with it unset the authorization endpoint answers invalid_scope before any list is consulted. The scope in the client's scope list is what makes the request grant a refresh token. The refresh grant in its grant list is what lets the client spend one, checked at the token endpoint on every call.

A client that should keep refreshing therefore needs all three, and each omission fails somewhere else: without the flag at the authorization request, without the scope with no token to spend, without the grant holding a token it may not use.

Receiving a refresh token and being allowed to exchange one are different powers. A server that merges them cannot express the case where existing refresh tokens should stop working while the client keeps everything else; withdrawing the refresh grant is one field here. Duende reaches the same outcome from the other side: its grant store can enumerate and remove a client's tokens directly, which under Abblix is the inventory described at the end of this page.

A scope declares the claims it carries, and this is where a real decision was needed

Abblix has one scope concept: a name plus the claim types that scope asks for. The library registers the six standard OpenID Connect scopes itself, so a host declares only what the standard does not already name. A scope that is not declared is refused as an invalid scope no matter what a client lists as allowed: the client's list narrows what it may request and never introduces a scope.

The second half of that definition changes how you think, because the claim list a scope declares is what the server later asks your user store for. Centralising the mapping costs something: every claim must belong to a scope, so a claim with no natural home needs one found for it.

eShop's user records carry a surname and a shipping address split across five fields, none of them a standard claim name. The checkout page prefills its form from the address fields, and the chatbot reads them plus the surname. Under Duende those were produced by the profile service, which decides for itself what to return, so nothing had to declare them anywhere. Under Abblix a claim that no granted scope declares is never requested, so those claims needed a home.

Three collections in code go away: GetApis and GetApiScopes, which named orders, basket and webhooks twice over as an API resource and again as an API scope, and GetResources, which listed the built-in OpenId and Profile identity resources.

They become one Scopes list in configuration, in which the standard scopes are not restated because the library already declares them:

JSON
"Scopes": [
  { "Scope": "profile", "ClaimTypes": [ "last_name" ] },
  { "Scope": "address", "ClaimTypes": [ "address_city", "address_country", "address_state", "address_street", "address_zip_code" ] },
  { "Scope": "orders" },
  { "Scope": "basket" },
  { "Scope": "webhooks" }
]

The two named entries are the interesting part. They ended up where OpenID Connect already puts data of that kind: the surname extends the standard profile scope, the five address fields extend the standard address scope. A host definition under a standard name extends that scope, the host's claims joining the standard ones, which is what makes this placement expressible at all. The three application scopes are then left declaring no claims, which is what an API scope is.

The price is one entry in one client's scope list: the storefront now asks for address by name, on top of the scope lists both clients state in full for the reason the next section gives. That entry states, in the client's own code, which of the user's data the checkout form needs. The webhooks client asks for profile and not for address, so it never receives a shipping address it has no use for. The exposure was equally narrow before, for a reason that would not have held: the claims rode on an API scope, granted so that a client may call the ordering service, and the next client granted it for that reason would have started receiving a home address with nothing in the grant to say so. On address the grant names what it hands over.

The objection a strict reader will raise is that the names traveling under those scopes are not the standard ones. OpenID Connect Core defines family_name for a surname, and it defines address as a single structured claim whose members are the same five fields eShop keeps flat. So a third-party client asking this server for address and expecting the standard claim receives nothing, because nothing here produces it. Keeping eShop's names is what kept the storefront and the chatbot untouched, which was the point of the exercise, and it is a debt, not a design: where you are not preserving an existing consumer, use the standard names and let the standard scope mean what it says.

Two other homes were considered, and both lose. Hanging the claims on an application scope the client already requests works, and it is what this fork did first, at the price of a scope name that says nothing about the data it carries. Inventing a dedicated scope loses on both counts: every client has to request one more scope, and it names something the standard had already named.

Translating client by client is how you find the dead configuration

The translation is tedious enough that you read every line, and reading every line finds things. eShop's configuration carried three clients registered for a Swagger UI that no longer exists - the project moved to a different API browser, and while the OpenAPI documents still declare an OAuth flow for it, the client ids meant to complete that flow are read by no code. It also carried two scopes for aggregator services the repository no longer contains, which the configuration never declared as API scopes at all: neither library validates a client's allowed list against the declared scopes, because an entry nothing requests is never looked at.

The dead Swagger clients also used the implicit flow, which RFC 9700 now tells clients not to use; the browser-based-applications draft goes further for providers: access tokens are issued only at the token endpoint. Both libraries still support the flow for clients that have not moved. Nothing here needed it.

All the dead clients were dropped rather than ported. This is not tidying. A registered client is a credential that can be used, and carrying one forward because it was in the old file is how a demo credential reaches production. The same pass found two services registered in dependency injection that nothing resolved; those went too, after confirming with a search - not by eye - that nothing referenced them.

The mobile client went with them, for a different reason. The MAUI application lives in the repository and has its own CI, but it takes no part in the system the orchestrator runs: it is built and deployed separately, to a device. Its registration was a credential serving nothing the demo starts. Restoring it is a few lines when somebody actually runs that app.

That left the question of what each surviving client may have. The rule chosen was: what its own code asks for, read carefully. Applied to the two clients, it drops four entries of three kinds that a field-by-field translation would have carried over. Neither client requests offline access, so neither keeps the scope or the refresh grant beside it. The storefront had been allowed a scope it never requests. And the four card claims sitting alongside the address fields are read only by the mobile client whose registration is gone, so they leave with it.

Note the direction of the check: the registry is compared against the client code, not against the registry it was translated from. A faithful translation reproduces the old over-granting perfectly.

What a client asks for is not what its code lists

That rule has a prerequisite: the request a client sends is not the list of lines it adds.

The ASP.NET Core OpenID Connect handler ships its scope list already holding openid and profile. A client that calls Scope.Add for what it needs extends that list rather than replacing it, so its authorization request carries two scopes that appear nowhere in its own code.

Two ways out, and they differ in what they leave behind. Allow the effective list in the registry, which works and leaves the request half-stated in framework defaults. Or clear the list in the client and name every scope it wants, which is what this fork does: the request is then exactly the list in the code, and the registry on the other side can be reviewed against it by reading. Clearing also asks the question that finds real over-granting, since restating a default forces somebody to say why it is there. The webhooks client keeps profile under that question, because its user menu displays the name that scope carries.

Clearing has two costs, and both are worth knowing before you copy the pattern. It removes openid along with profile, so a cleared list restates both. And it pins the scope list in compiled code, which is the opposite of what the next section does with the server registry, deliberately: a client's scope list is a claim about what its own code reads, so it belongs beside that code, while a redirect path is an operational setting and does not.

Where the client registry lives

The whole registry, clients and scopes alike, moves out of C# and into appsettings.json. The class that held it, Configuration/Config.cs, is deleted rather than translated.

A registry in code means a rebuild to add a client or move a redirect path, and it means the reviewer of an operational change is reading a compiled language. In configuration, the same change is a settings edit, and the file reads as an inventory of who may ask this server for tokens.

A client that used to be written like this:

C#
new Client
{
    ClientId = "webapp",
    ClientSecrets = new List<Secret> { new Secret("secret".Sha256()) },
    ClientUri = $"{configuration["WebAppClient"]}",
    AllowedGrantTypes = GrantTypes.Code,
    AllowOfflineAccess = true,
    AlwaysIncludeUserClaimsInIdToken = true,
    RequirePkce = false,
    RedirectUris = new List<string> { $"{configuration["WebAppClient"]}/signin-oidc" },
    AllowedScopes = new List<string> { "openid", "profile", "offline_access", "orders", "basket", "webshoppingagg", "webhooks" },
    AccessTokenLifetime = 60*60*2,
}

It is now an entry keyed by its own identifier:

JSON
"webapp": {
  "BaseAddress": "https://localhost:7298",
  "RedirectPaths": [ "/signin-oidc" ],
  "PostLogoutRedirectPaths": [ "/signout-callback-oidc" ],
  "Client": {
    "ClientName": "WebApp Client",
    "ClientSecrets": [ { "Sha256HashHex": "2BB80D537B1DA3E38BD30361AA855686BDE0EACD7162FEF6A25FE97BF527A25B" } ],
    "TokenEndpointAuthMethod": "client_secret_post",
    "AllowedGrantTypes": [ "authorization_code" ],
    "AllowedScopes": [ "openid", "profile", "address", "orders", "basket" ],
    "ForceUserClaimsInIdentityToken": true,
    "AccessTokenExpiresIn": "02:00:00",
    "IdentityTokenExpiresIn": "02:00:00",
    "AuthorizationCodeExpiresIn": "00:05:00"
  }
}

The nesting is the whole design. Everything under Client binds straight into the library's own client model, so a property the library gains is configurable the day it ships. Outside it sits the one thing that model has no opinion about: where the client answers, and which paths under that address the server may return a user to.

The secret is a hash written as one string, in hexadecimal because that is what a command-line digest tool prints, so the value can be checked against one without converting it first. Base64 binds equally well; hexadecimal was chosen so that the value in the file is the value a digest tool prints.

Four differences in there are not formatting. The authentication method is now stated, because it is enforced rather than sniffed. The scope list dropped offline_access, a scope for an aggregator service the repository no longer contains, and the webhooks scope this client never requests, and it gained address for the reason the previous section gives. PKCE goes unmentioned because the library requires it by default and this client no longer opts out, which the old registry did. And the lifetime is a duration rather than a count of seconds whose unit lives in the field name: the same two hours, said in a way that cannot be misread.

Four details of that arrangement came out of building it.

Bind the library's own types rather than mirroring them. The scope model and the client model both bind straight from configuration, which is why this fork carries a mirror of neither. What stays in a local type is the shape the library has no opinion about, and that is a base address with paths under it. One member would need help if a client used it: a key set is polymorphic, so it binds through the flat settings type the library ships for it rather than directly. No client here has one.

One property of binding into the library's own model is worth knowing, because it is the opposite of what a mirror type does. The binder fills the instance a property already holds rather than constructing a new one, so every value the file leaves out keeps whatever the library shipped. A mirror type has to reproduce every one of those defaults by hand - an omitted TimeSpan binds to zero, which is not what the file meant - and binding into the real model removes that obligation rather than discharging it.

Keep the address as one scalar and the paths in the file. A client's base address is the one setting that varies by environment; the redirect paths under it do not. Splitting them means a deployment overrides a single value - a plain configuration override, with no positional list indices that retarget when the file is reordered. In the fork the addresses are the fixed development ports the launch profiles assign, which is enough for the demo; a deployment replaces them per client through ordinary configuration.

Restate every token lifetime instead of inheriting a default, because the defaults differ sharply. Duende's are on its client model: the authorization code lives 300 seconds against one minute here, the access token 3600 against ten minutes, the refresh token 2592000 seconds absolute with a 1296000-second sliding window against eight hours and one hour. Refresh token: eight hours absolute with a one-hour sliding window, against thirty days absolute and a fifteen-day sliding window that Duende does not apply unless you switch its refresh expiration to sliding. Every one of them is shorter than the value it replaces, so a registry that restates them is a registry that keeps the behavior it had. Both eShop clients state the three that apply to them, which are the authorization code, the access token and the identity token, as durations, so the unit is part of the value; the old settings file carried bare "minutes" and "days" counts in fields nothing read. The refresh lifetime is stated nowhere because neither client holds the refresh grant, and a registry that has one should restate it too.

Refresh-token reuse is the one default that changes behavior in your favour, and the two products disagree on it. Abblix rotates (AllowReuse = false); Duende reuses (RefreshTokenUsage = TokenUsage.ReUse on its client model). A registry arriving from a deployment that relied on a reusable token therefore has to say so explicitly or find its tokens becoming single-use. State it per client either way.

Producing claims: asked versus told

The profile service becomes a user info provider. The signatures differ, but that is not the change worth understanding.

The two signatures say most of it. The old service was handed the request and decided for itself what to answer:

C#
public async Task GetProfileDataAsync(ProfileDataRequestContext context)

The context carries the request; eShop's implementation read the sub claim out of it, loaded the user, and assigned every claim that user had to context.IssuedClaims, without consulting what was asked for.

The new one is handed a list and answers it:

C#
public async Task<JsonObject?> GetUserInfoAsync(AuthSession authSession, IEnumerable<string> requestedClaims)

The fork's implementation loads the user by authSession.Subject, returns null when there is none, and otherwise walks requestedClaims, mapping each requested name to a field of the user record and skipping the ones it has no value for. The returned object holds exactly the claims that were asked for and that the store could answer.

Duende's profile service is advised; Abblix's provider is constrained. Duende computes the requested claim types and hands them to your profile service along with a filtering helper that keeps only those - but nothing enforces the filter, and eShop's implementation ignored the request and returned every claim it had. Abblix computes the claim list from the scopes the request was actually granted and asks your provider for exactly that list, so a claim no scope declares is never requested at all. The library does not police what your provider returns beyond that; answering the list you were handed is the discipline the design expects, not a guarantee it enforces.

One switch needs naming, because the fork sets it. Whether user claims travel inside the identity token or wait at the userinfo endpoint is a per-client choice in both products. The fork turns ForceUserClaimsInIdentityToken on for both clients, for a mechanical reason worth knowing: the ASP.NET Core handler maps every identity-token claim into the principal by default, but maps userinfo fields only through claim actions, and the ones it ships cover sub, name, given_name, family_name, profile and email. Nothing custom, and eShop adds none. So claims delivered in the identity token reach the checkout page, while a surname under a name of eShop's own and five address fields delivered through userinfo would not. The client-side alternative is two claim-action lines per client, which is the framework's own answer to this; it was not taken because leaving the client applications alone was the point of the exercise.

One practical note before the harder one: this registration is mandatory. The library ships no default provider, so who answers for a user's claims is a decision the host states rather than one it can inherit, and a host that has not stated it does not start.

The harder one is the profile service's second method, the one reporting whether a subject is still active. It has no counterpart, and it is the only thing on this page that a mechanical translation loses without leaving a trace. IsActiveAsync(IsActiveContext context) answers a single question by setting context.IsActive: eShop's implementation loaded the user, refused when the security stamp in the subject's claims no longer matched the stored one, and otherwise reported the user active unless a lockout was in force and had not yet expired. An unknown user was refused outright.

Duende consults it at userinfo and on refresh, so disabling a user cuts their tokens off at the next call. Abblix has no equivalent hook. It does reach the host on both paths, through IUserInfoProvider, but only to read claims: what it never asks is whether the subject is still allowed in, and that question is left with the host.

Two things follow, and both are decisions rather than translation. An access token keeps working until it expires, which is what a self-validating token means, and shortening that window is a lifetime decision. Userinfo is the exception rather than a check: a provider that returns nothing for a user who no longer exists makes the endpoint answer invalid_token, and this fork's provider does exactly that, but a user merely disabled still answers. And the server's own session cookie is read the same way, so the question of whether a subject is still allowed in has to be answered on that cookie: AddIdentity places the security-stamp validator on Identity's own cookie, and a host that stops using that cookie states the check on the one it does use.

The fork states it where the session is read, in a RejectUsersNoLongerAllowedIn handler wired to the cookie handler's principal-validation event. It takes the subject from the cookie's principal, loads that user through UserManager, and returns with the session untouched when the user exists and is not locked out. In every other case, a missing subject, a user that no longer exists, or a lockout in force, it calls RejectPrincipal and signs the cookie scheme out.

A lockout reaches an existing session on its next request, and a deleted user with it. A password change deliberately does not: catching it needs the security stamp to travel in the session, and the session's own extension point for custom values is emitted into issued tokens, which is not a place for a stamp. So the session runs on an absolute lifetime rather than a sliding one, and its length is what bounds the window. Closing the remainder properly is the same work as the revocation path below: mark the token ids through a registry every server-side validation consults, described in token inventory and revocation.

Rewriting the login screen

Duende hands the login page a return URL that encodes the authorization request, and the interaction service is what turns it back into a validated request your page can read; your code is therefore a participant in the protocol decision, and it must understand what denying an authorization means. Abblix keeps the pending request on the server and hands the page an opaque reference to it. The host verifies the credentials it owns, tells the library who signed in, and redirects back to the authorize endpoint carrying the same reference - the page has nothing to parse and renders no verdict at all.

The division is that your application owns identity and the library owns the protocol, and the login page sits entirely on your side of it. In practice the rewrite is small: a page with a form, a post action that checks a password, one call to establish the session, and a redirect.

The old action had to understand the request it was interrupting. It opened the return URL with GetAuthorizationContextAsync before doing anything else; on the cancel button it called DenyAuthorizationAsync with an access-denied error and redirected; on the login button it called PasswordSignInAsync, and redirected to the return URL only when the sign-in succeeded and that context had come back non-null, which was what made the URL safe to follow.

The new one carries a reference it never opens. It checks the password with CheckPasswordSignInAsync, redisplaying the form with a model error when that fails. On success it constructs an AuthSession from the user id, a freshly generated session id, the current instant and the cookie scheme name, filling in the email, whether it is confirmed, and the authentication method reference for a password. SignInAsync on the session service records it. Then the action builds a URL to the authorize endpoint carrying the opaque request_uri it was handed, and redirects there. Nothing in the action parses that value or decides anything about the authorization request.

External identity providers sit on this side of the line too. The old host carried a controller for upstream providers, deleted here because nothing used it; a host that signs users in through Google, Entra or an upstream SAML provider writes that challenge-and-callback pair on the standard ASP.NET Core external authentication handlers, and the library's contract is unchanged - you report an identity, it re-evaluates the stored request.

Three things disappear with the return URL. There is nothing to validate before redirecting to it, because it is an opaque reference the server minted and can only resolve to a request it is already holding. There is no denial path in the page, because refusing is not something the page decides. And PasswordSignInAsync becomes CheckPasswordSignInAsync: the same lockout handling without issuing Identity's own cookie, which the section below explains.

Abblix's session adapter reads and writes the standard cookie authentication scheme. eShop registered ASP.NET Identity with the full registration, which brings Identity's own cookie schemes and signs into them.

The fork switches to the core Identity registration plus a plain cookie handler. AddIdentity gives way to AddIdentityCore, with roles, the Entity Framework stores, the sign-in manager and the default token providers each added back by name, so the host takes the pieces it uses and none of the cookie schemes. Separately, AddAuthentication followed by AddCookie registers the standard cookie scheme, and the only option it sets is OnValidatePrincipal, pointed at the RejectUsersNoLongerAllowedIn handler above.

That second registration is where AddIdentity used to hand you three cookie schemes and the security-stamp validation on top of them. Losing the schemes is the point; losing the validation is not, which is why the event is wired back by hand.

The session's lifetime moves out of code in the same step. The old host set it in the provider's own options block, as options.Authentication.CookieLifetime = TimeSpan.FromHours(2) beside the protocol settings; it now sits in appsettings.json under a Session section that binds onto the cookie options, and holds two keys: ExpireTimeSpan, the same two hours written as a duration, and SlidingExpiration, off.

How long a session lives is a deployment decision, and a deployment should be able to shorten it without a rebuild. The validation event stays in code deliberately: it is behavior rather than a setting, and a deployment able to switch it off is a deployment able to keep locked-out users signed in without saying so.

Why not keep the full registration and point the library at Identity's scheme, which would bring the stamp validation back with it? The adapter does take a scheme name, so the wiring is possible; what does not survive is the claim shape. The library writes the session itself, as a subject, a session id and an authentication time, because those are what it reads back on the next authorization request. Identity's sign-in manager writes a different principal, keyed on a name identifier and carrying the security stamp. Point the validator at a cookie the library wrote and it finds neither the user id it looks for nor a stamp to compare, so it rejects every session it inspects. Point the library at a cookie Identity wrote and there is no session id or authentication time to rebuild from.

That is why the two halves are split the way they are, and why the protection had to be re-expressed rather than inherited. It is also why the sign-in path uses the check that applies lockout without issuing a cookie: Identity verifies the password, the library records who signed in, and exactly one component owns the session.

In the login action that is one line: the check that applies lockout without issuing a cookie, in place of the one that signs the user in.

eShop registers every client without consent, and Abblix's default consent provider grants automatically, so this step needed no work. That is true for first-party clients and stops being true the moment you have third-party ones.

The defaults differ for a storage reason. Duende persists consent decisions in its grant store, so remembered consent works as soon as the storage package is installed and configured. Abblix has no store to remember anything in, so consent is a provider you implement, together with its storage and its screen.

Register it before the library's registration call, so that the decorator the library wraps around the registered provider - the part that honours prompt=consent - wraps yours.

Existing consent decisions do not migrate either. If you rely on remembered consent, plan it as a feature to rebuild and size it before you start. It is the largest thing on this page that eShop did not need.

Logout

eShop's Duende host had a logout controller, a logout prompt, a logged-out page and an identifier threaded through all three. In this migration all of it was deleted, because the storefront signs out with an identity token hint and the end-session endpoint can act on that alone: it validates the request, terminates the session itself, notifies the other clients in the session over the back channel and the front channel, and redirects to the client's post-logout address.

What was deleted is the interaction contract, not the confirmation. RP-Initiated Logout requires the provider to ask the user when no id_token_hint accompanies the request, and Abblix enforces it: the end-session endpoint answers confirmation_required until the host echoes the user's confirmation back. A client that sends the hint never triggers it, and the ASP.NET Core handler attaches one whenever the identity token was saved, which is what SaveTokens buys and what eShop sets. That is why eShop ships no logout page at all. If your clients can reach the endpoint without a hint, the confirmation page is host code, the same way the login page is.

Signing keys: the library will not make one for you

Duende's developer signing credential writes a key file and reuses it, and its key management feature rotates keys in a signing-key store of its own - a key directory on disk by default, a table of its own when you configure EF storage. Abblix does neither: it never generates a signing key and has no store to keep one in. Keys are configuration you supply, or a provider you implement over wherever your keys actually live.

The seam has first-party custodians for HashiCorp Vault / OpenBao Transit and Azure Key Vault, in either of two postures: the keys live inside the vault, non-exportable, and every signature is a vault round-trip - or they are minted in-process and sealed to a vault key, so signing stays local and only sealed copies leave the process. Rotation comes with them, on the vault's own policy or on a schedule the server drives.

So supplying the key is a decision the migration has to make explicitly, and the host says so at startup: with neither a key in configuration nor a provider registered, it stops while mapping its endpoints, naming both ways to supply one.

One call goes away: AddDeveloperSigningCredential, which the sample's own comment described as not recommended for production. What replaces it is a class the host owns. The key lives in the identity database, generated on first use, with the insert guarded, because two replicas starting cold would otherwise each mint a key and disagree about which one signs.

The guard is a PostgreSQL advisory lock. The provider opens a transaction, takes pg_advisory_xact_lock on a constant lock id, and only then re-reads the signing key table, oldest row first. Finding a key there means another instance won the race, and the provider returns that key; finding none means it generates one, saves it, and commits. The whole sequence runs inside an execution strategy obtained from CreateExecutionStrategy.

Two details in there came out of running it. The advisory lock is transaction-scoped, so it is released by the commit and needs no cleanup path. And the whole thing sits inside an execution strategy because the Aspire Npgsql integration turns on retries, and a retrying strategy requires the transaction to be opened through it.

Two other places were considered and rejected. A key file on disk would mirror the developer credential it replaces, but the application runs in containers, where a file written into the image layer is gone on the next start and a mounted volume is one more thing to provision. A key in configuration would put a private key in a settings file, which is exactly the habit a reference application should not teach.

Encrypting the stored key with the data protection stack was considered and deliberately not done: the key ring then needs its own persistence and its own protection, which is a second migration this sample does not need to teach.

One property of that class does not scale with the rest of it. The library resolves the signing key for every token it signs and for every key-set request, and caches nothing, so this provider reads the database on both paths. For a demo it is invisible. Anything real caches the key inside the provider and invalidates on rotation.

The database row is what it is - the posture of a developer signing credential, not a production answer. Production keeps the private key in a key management service or an HSM, which is what the custodian packages above are for. Writing your own provider against the same seam stays an option, and then rotation is yours to run: a new key id, an overlap window covering your longest token lifetime, then retirement. It is worked through in persisting JWT signing keys in production, alongside the multi-instance startup the guard above addresses.

The library stays out of it because where a signing key lives decides who can read it, how it rotates, and what a compromise costs, and those answers differ between a laptop, a container and a regulated environment. A library that owns key storage has to be told to stop before the key can move into an HSM; exposing the seam from the start costs one small class.

Nothing else breaks. Resource servers that resolve keys from the discovery document pick up the new key set on their next refresh with no configuration change. Only a service pinning a static key or a hardcoded path needs touching - and if you find one, fixing it during the migration is cheaper than after.

Operational state belongs in a cache

Authorization codes, pushed authorization requests and interrupted login sessions need somewhere to live between two HTTP requests. Abblix uses the standard distributed cache abstraction.

The in-memory implementation is single-node only, and this is a production requirement: across two replicas, a code issued by one instance is unknown to the other, and the token exchange fails for whichever request lands on the wrong node. eShop already runs Redis, so pointing this at it is a one-line change when the demo becomes a deployment. One decision travels with that change: entries reach the cache as the store hands them over, so a cache shared with other workloads wants encryption at the storage seam.

Why a cache and not tables. Duende keeps authorization codes in its persisted-grant table and pushed requests in a table of their own - schema, migrations, and a cleanup job it ships behind a flag - while an interrupted login is not stored at all: it rides in the return URL. Every value here is short-lived and single-use, which is what a cache with an expiry models; expiry becomes the store's job. The trade is that you must configure a shared and durable cache yourself, because the default distributed cache is not distributed.

One decision that was not about the migration at all

Reading the user store closely enough to write the claims provider surfaced something unrelated: the application never configures the password hasher, so its work factor is whatever the framework default happened to be on the day it was written.

So the iteration count moves into appsettings.json, as a PasswordHasher section with a single IterationCount of 220,000, and the reason for the number is written beside it in a comment rather than left for somebody to reconstruct: the OWASP figure for the algorithm actually in use, against a framework default that does not move when the guidance does.

Three things decide that number and all three are checkable. The algorithm is PBKDF2 with HMAC-SHA512, which is what Identity's version 3 hashing uses and what the stored hash header says. OWASP publishes a per-algorithm iteration count, and for that pairing it currently reads 220,000; the figure for SHA-256 is different, so the row has to be chosen by the algorithm in use. And the framework's own default is 100,000, which is not wrong so much as fixed: it was chosen once and does not follow the guidance, which is exactly the argument for stating the value instead of inheriting it. Password hashing is the parameter that has to move over a decade, which is the argument for stating it where a deployment can raise it.

Then verify it. Decoding an actual stored hash from the running database confirms the format, the per-user random salt, the hash function, and that the configured iteration count is the one that was used. Reading it from the row is a check that can fail. Reading it from the documentation is not.

What did not change, and how that was confirmed

No resource server was modified and no authority URL changed anywhere. The client libraries moved only with the platform: the same pass that took the repository to the current .NET 10 release carried the OpenID Connect and JWT bearer handlers with it, which is servicing rather than migration. What the migration itself changed in the clients is four statements, a pair in each of the two client set-ups: each application now states the scopes it requests in full instead of extending the handler's default list, and the storefront's list includes address. Nothing else in a front end or an API was touched. The mobile application was not edited at all; what was dropped is its registration on the server, and restoring those few registry lines is what it needs before it can sign in again.

The rest was confirmed by running the fork end to end rather than asserting it. Signing in redirects to the new login page and back, on both clients. The checkout form prefills the shipping address from claims, which exercises the whole chain from the scope declaration through the claims provider to the identity token the storefront consumes. Adding an item to the basket exercises a gRPC service validating a token this server issued - the basket write, note, since the read is anonymous. Placing an order exercises a second service and produces a real order. Signing out through the end-session endpoint returns the site to its anonymous state.

The CORS list was checked the way a list of permitted things has to be: from both sides. A request carrying a registered client's origin comes back with that origin echoed, and one carrying a stranger's comes back with no such header at all. Only the pair proves anything, because a policy that has stopped working also refuses the stranger.

The endpoint paths eShop actually uses came out identical to the previous host. The key set did not: it sits at /.well-known/jwks rather than under the discovery path. Nothing noticed, because every consumer takes jwks_uri from the discovery document - which is the real lesson. Verify the paths against your own client set rather than assuming them, and treat any client that hardcodes one as a finding.

Does the result still match the backend-for-frontend pattern

Check this after any provider swap, because the pattern's guarantees live partly in the client and partly in what the provider allows. The OAuth for browser-based applications draft gives the backend for a frontend three responsibilities, and eShop's storefront satisfies all three after the migration.

It acts as a confidential client: registered with credentials, using the authorization code flow with PKCE. Tokens are never handed to browser script: the storefront renders server-side, the access token is read from the authentication ticket on the server and attached as a bearer header by a delegating handler. And it is the component that forwards requests to the resource servers, adding the token on the way.

One shape the draft explicitly supports, one set of inherited deviations, and one point of compliance that was checked:

  • The tokens live inside the authentication cookie rather than in a server-side store. The draft supports this shape - a client-side session, which it asks to be encrypted, and this cookie is encrypted and HttpOnly - so it is a supported variant rather than a violation. A server-side ticket store is the stricter form, trading scale for tighter session control.
  • The session cookie's attributes are a host decision that the swap does not make for you, and the draft names four: Secure and HttpOnly as MUSTs, SameSite=Strict, and a __Host- prefix. HttpOnly holds by default and the sample leaves the rest to the default cookie policy, so a deployment states all four in one place.
  • Cross-site request forgery protection holds: the antiforgery middleware is in the pipeline and the form handlers are named, which is what makes the logout post safe.

What to expect at cutover

The first of these happens whether you plan for it or not. The other five are decisions to make before the switch, not after.

  • Everybody signs in again. Access and refresh tokens minted by the previous server are not honoured, and a live session cookie carries a user across only if the cookie scheme is unchanged - which the cookie decision above changes. Schedule the switch as a user-visible event, the way a key rotation is scheduled.
  • Decide whether you need a license at all. With none configured the server runs on the free tier: one issuer, and no ceiling on client applications, users or nodes, with every protocol available. eShop's registry fits, and so does most of what a single company runs. A license is what you buy when the company passes the free thresholds or a second independent issuer is genuinely needed. If you do supply one, set it from a secret rather than a settings file, and alert on the license log events: production hardening checklist.
  • Issue one secret per client. Both eShop registrations carry the same hash, inherited from a sample that used one string everywhere; a secret shared by two consumers cannot be rotated for either of them.
  • Inventory the endpoints you turned off. Anything that calls introspection, revocation, dynamic client registration, check session, backchannel authentication (CIBA) or device authorization gets a 404 on the day, so grep your resource servers and partners before it rather than after.
  • Build the consent provider first if you have third-party clients. Until it exists every client is auto-granted, so a client that used to see a consent screen receives tokens without one.
  • Decide what you alert on. Structured logging exists on both sides, with a numbered event identifier per event, and that is what dashboards are built on; the persisted audit trail Duende ships is on the roadmap. Token-endpoint error rates, the license events and key-loading failures are the three worth having on the first day.

The persistence you now own

Three seams ship with in-memory or pass-through defaults that are honest for a demo and are not a production answer; eShop replaced none of them, and its one piece of genuinely durable state was the signing key store above. A production system usually replaces all three:

  • The client store. A read interface and a write interface, served from configuration here - enough for a fixed set of first-party applications. A system that registers clients at runtime backs them with a database, and the schema, indexing and caching are yours. Abblix's own implementation is short enough to read in one sitting; see the durable client store guide.
  • Consent storage, if you need consent remembered, as described above.
  • A refresh-token inventory, if you need to enumerate or revoke active grants - including the is-active replacement discussed earlier. This is the one of the three that is security code rather than data access, because what it answers decides whether a presented token is still good: rotation with reuse detection revokes the old token and issues the new one in one step, or a stolen token is replayable in the gap. Rotation is on unless a client turns it off, so what to check here is that nothing in your registry turns it off.

First-party Entity Framework Core and ASP.NET Identity stores are on the roadmap for Q4 2026. Until they land, this adapter layer is the part of a migration that is genuinely code.

What this migration says about yours

The work divided unevenly, and not where the estimate would have put the line. Translating the registry, the endpoints and the packages was only an afternoon, and none of it needed a decision. Nothing outside that project moved: no resource server was modified, and no authority URL changed anywhere.

What took the time was four questions the library declines to answer: where a claim lives once a scope has to declare it, which component owns the session, where the signing key sleeps, and what a lockout is still supposed to reach once the session is no longer Identity's.

Each has several defensible answers, and which one is right depends on the system rather than on the protocol. A student or pet project settles all four in an afternoon and is right to. A bank or a telecoms operator answers the same four against auditors, key custody rules and a fleet of instances, arrives somewhere else entirely, and is equally right. A provider that picked for either of them would be a provider the other one argues with.

So the useful estimate is how many of those questions your system has already answered, and how loudly it would tell you if one of them were answered wrong. Here neither the build nor the test suite had anything to say about any of the four, and each was settled by running the application and watching what it did.

That is the part worth copying, whichever provider you land on.

Run it yourself

The fork is Abblix/eShop, and its main is the migrated application: clone it and run it. The change also stands on its own as one pull request against the last upstream commit, so the whole diff is one page, and the branch behind it keeps the steps as separate commits in the order this page describes.

You need Docker Desktop running and the .NET 10 SDK. Everything else is orchestrated:

Bash
git clone https://github.com/Abblix/eShop.git
cd eShop
dotnet run --project src/eShop.AppHost/eShop.AppHost.csproj

The console prints a login link for the Aspire dashboard, which lists every service and its address. Sign in to the storefront as alice with Pass123$, and the path this page describes runs in front of you: the login page is the host's own, the checkout form fills itself from claims the provider issued, and the basket write goes through a gRPC service that validated the token on its own.

Next steps


IdentityServer and Duende IdentityServer are products of Duende Software; the names are used here only to identify those products. Abblix is not affiliated with, sponsored by or endorsed by Duende Software. All other product names belong to their respective owners.