Using multiple external identity providers from ASP.NET Core Identity and Duende IdentityServer

Many enterprise applications need to authenticate users coming from more than one identity source. You might have employees signing in through Microsoft Entra ID, partners coming through Auth0, and a handful of legacy accounts still using local ASP.NET Core Identity credentials. Getting all three to work cleanly through a single OpenID Connect front door is not as obvious as it looks, and a few wrong assumptions about schemes and callback paths can break the whole flow.

This article walks through a setup where ASP.NET Core Identity acts as the identity provider for a client UI, with Duende IdentityServer issuing the tokens and handling the OpenID Connect protocol work. The same pattern applies if you swap Duende for OpenIddict, since the external provider and claims mapping concerns stay the same either way. The Identity application supports local user accounts as well as federated logins from multiple external providers, and every downstream client application only ever talks to the one OIDC endpoint.

How the pieces fit together

The Identity application sits between the client UI and the external providers. It exposes a single OpenID Connect endpoint to client applications through Duende, while internally it manages sign-in flows against Auth0, Microsoft Entra ID, or any other OIDC-compliant provider you configure. Client applications never need to know which external provider a given user actually authenticated with.

The Identity application brokers between the client UI, Duende IdentityServer, and the external OIDC providers.
The Identity application brokers between the client UI, Duende IdentityServer, and the external OIDC providers.

The callback UI inside the Identity application is the piece that absorbs all the complexity. Every external sign-in, regardless of provider, lands back at this UI, and it is responsible for turning provider-specific claims into a consistent identity that Duende can hand off to clients.

One shared scheme versus one scheme per provider

When you wire up several external providers in ASP.NET Core Identity, you have a choice: give each provider its own authentication scheme end to end, or have all of them share a single scheme once the user is signed in externally. Both approaches work, but they come with different trade-offs.

Using a separate scheme per provider all the way through means your callback and logout logic has to branch based on which scheme was used, since each one behaves slightly differently. That adds conditional logic in places you would rather keep simple. Sharing one scheme for the external sign-in session, on the other hand, keeps the callback and logout code provider agnostic, and you push the provider-specific work into claims mapping instead, which is usually a smaller and more contained piece of logic.

The setup in this article follows the second approach, which also matches the pattern used in Duende’s own samples. Every external provider signs in using its own OpenID Connect scheme during the challenge, but the resulting session is persisted using one common external cookie scheme.

Configuring the external providers

Here is what the authentication configuration looks like with two external providers, Auth0 and Microsoft Entra ID, both funnelling into the same external scheme:

builder.Services.AddAuthentication(options =>
{
   options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
   options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
   options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
})
.AddOpenIdConnect("Auth0Scheme", "Auth0", options =>
{
   // SignInScheme must match the scheme(s) used in the Identity callback
   options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
   options.SignOutScheme = IdentityConstants.ApplicationScheme;
 
   // paths must be different for each client
   options.CallbackPath = new PathString("/signin-oidc-auth0");
   options.RemoteSignOutPath = new PathString("/signout-callback-oidc-auth0");
   options.SignedOutCallbackPath = new PathString("/signout-oidc-auth0");
 
   // more oidc options ...
})
.AddOpenIdConnect("EntraID", "EntraID", oidcOptions =>
{
   builder.Configuration.Bind("AzureAd", oidcOptions);
   oidcOptions.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
   oidcOptions.SignOutScheme = IdentityConstants.ApplicationScheme;
 
   oidcOptions.CallbackPath = new PathString("/signin-oidc-entraid");
   oidcOptions.RemoteSignOutPath = new PathString("/signout-callback-oidc-entraid");
   oidcOptions.SignedOutCallbackPath = new PathString("/signout-oidc-entraid");
 
   // more oidc options ...
});

Two things in this code matter more than they look. First, both providers set SignInScheme to IdentityServerConstants.ExternalCookieAuthenticationScheme, which is the shared external scheme discussed above. This is what lets the callback UI handle any provider the same way. Second, every provider gets its own CallbackPath, RemoteSignOutPath, and SignedOutCallbackPath.

This second point trips people up more often than the scheme choice does. If two providers end up with the same callback path, ASP.NET Core has no reliable way to tell which handler should process an incoming request, and you will see authentication state get corrupted or logins silently fail for one of the providers. Always generate these paths from the scheme name so collisions are structurally impossible.

Once this is in place, adding a third or fourth provider is mechanical: copy the pattern, change the scheme name, change the three paths, and point it at the new provider’s configuration.

The Microsoft.Identity.Web exception

If one of your providers is Microsoft Entra ID and you reach for the Microsoft.Identity.Web NuGet package instead of a plain AddOpenIdConnect call, the shared scheme approach does not apply cleanly. The AddMicrosoftIdentityWebApp extension method creates and manages its own scheme internally, and it does not let you redirect that scheme to the shared external cookie scheme that Identity expects.

In practice this means your callback UI needs an extra branch of logic specifically for the Microsoft.Identity.Web scheme, both for the callback handling and for logout. It is not a blocker, but it is worth knowing up front, because it breaks the otherwise clean rule that every provider behaves the same way. If you have a choice and Entra ID is just one provider among several, using a plain OpenID Connect handler configured the same way as your other providers, as shown in the code above, keeps the overall design simpler.

Mapping claims with Duende’s IProfileService

Once a user is authenticated through any of the external providers, their claims need to be normalized before Duende issues tokens to the client applications. Every external provider returns claims in its own shape, and client applications should not have to know or care which provider a user came from. Duende’s IProfileService interface is the extension point for this.

public class ProfileService: IProfileService
{
	public async Task GetProfileDataAsync(ProfileDataRequestContext context)
	{
        // context.Subject is the user for whom the result is being made
        // context.Subject.Claims is the claims collection from the user's session cookie at login time
        // context.IssuedClaims is the collection of claims that your logic has decided to return in the response
 
        if (context.Caller == IdentityServerConstants.ProfileDataCallers.ClaimsProviderAccessToken)
        {
            // access_token
        }
        if (context.Caller == IdentityServerConstants.ProfileDataCallers.ClaimsProviderIdentityToken)
        {
            // id_token
            var oid = context.Subject.Claims.FirstOrDefault(t => t.Type == "oid");
            if(oid != null)
            {
                context.IssuedClaims.Add(new Claim("oid", oid.Value));
            }
        }
        if (context.Caller == IdentityServerConstants.ProfileDataCallers.UserInfoEndpoint)
        {
            // user_info endpoint
        }
 
        // ALL
        context.IssuedClaims.Add(new Claim("test", "A"));
        return;
	}
}

GetProfileDataAsync is not called once per login. Duende calls it separately for the access token, the identity token, and the userinfo endpoint, and context.Caller tells you which one triggered the current call. This matters because it is easy to write logic that assumes a single call and ends up adding the same claim multiple times, once per caller, which then shows up as duplicate claims on the client side.

A couple of practical rules help here. Keep the identity token lean, since it travels in the browser and gets logged more often than people expect, so stuffing it with every claim you have available is a bad habit. Route bulkier claims through the userinfo endpoint or the access token instead, where they are fetched on demand rather than shipped with every redirect. And when you support several client applications, resist the temptation to return different claims depending on which external provider the user came from. Standardize on a common claim set and do the provider-specific translation inside IProfileService, not in the client applications.

Claims mapping without an OIDC server

Not every project needs the full IdentityServer or OpenIddict layer. If ASP.NET Core Identity is being used directly, without an OIDC server sitting in front of it, you do not have access to IProfileService. The equivalent mechanism here is IClaimsTransformation, which lets you intercept the ClaimsPrincipal after sign-in and normalize claims from the different external providers before the rest of the application sees them.

The underlying problem is identical to the Duende scenario: different external providers hand you different claim types and values, and your application code should not have to special-case each one. Whether you solve it with IProfileService or IClaimsTransformation depends only on whether an OIDC server is part of your architecture.

When this pattern is worth the extra setup

This shared-scheme design pays off once you are past two or three external providers, or once you know the provider list is going to grow. For a single external provider, the extra indirection of a shared cookie scheme and a centralized profile service is probably more machinery than you need, and a simpler direct integration will do.

It is also worth being deliberate about testing the logout flow for each provider individually. Federated logout is where these setups tend to break in production, since some providers support front-channel logout cleanly and others do not, and a shared scheme can mask a provider-specific logout failure until you actually test it end to end.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading