The problem

We're using IdentityServer from Duende in one project at work, it works great.
Up until today, we've only had one external provider, but today we added a new one. And immediately ran into some problems. ๐Ÿ˜ƒ

TL;DR, we configured our external providers in a bad way, use unique CallbackPaths.

This is how we registered the external providers:

public class Provider : Enumeration
{
    private static readonly Lazy<Dictionary<string, Provider>> AuthenticationSchemeMap;

    public static readonly Provider Provider1;
    public static readonly Provider Provider2;

    static Provider()
    {
        Provider1 = new Provider(1,
            "Provider 1",
            AuthenticationSchemes.Provider1,
            "Provider 1",
            new Provider1Claims());
        Provider2 = new Provider(2,
            "Provider 2",
            AuthenticationSchemes.Provider2,
            "Provider 2",
            new Provider2Claims());
        AuthenticationSchemeMap = new Lazy<Dictionary<string, Provider>>(() =>
        {
            var allItems = GetAll<Provider>();
            return allItems.ToDictionary(x => x.AuthenticationScheme, x => x);
        });
    }

    private Provider(
        int id,
        string description,
        string authenticationScheme,
        string displayName,
        ProviderClaims claims) : base(id, description)
    {
        AuthenticationScheme = authenticationScheme;
        DisplayName = displayName;
        Claims = claims;
    }

    public string AuthenticationScheme { get; }
    public string DisplayName { get; }
    public ProviderClaims Claims { get; }
   .......
}

public static void AddExternalProviders(this AuthenticationBuilder builder, IConfiguration configuration)
{
    .......
    builder.AddExternalProvider(Provider.Provider1, true, provider1Config);
    builder.AddExternalProvider(Provider.Provider2, true, provider2Config);
    }

    private static void AddExternalProvider(
        this AuthenticationBuilder builder,
        Provider provider,
        bool requireHttpsMetadata,
        ClientConfiguration clientConfiguration)
    {
        builder.AddOpenIdConnect(
            provider.AuthenticationScheme, provider.DisplayName, options =>
            {
                
                .............
                options.Authority = clientConfiguration.Authority;
                options.CallbackPath = "/redirect"
                options.ClientId = clientConfiguration.ClientId;
                options.ClientSecret = clientConfiguration.ClientSecret;;
                options.UsePkce = true;
                .............
            });
    }

We have an enumeration class where we configure our external providers. We then register them by calling our AddExternalProviders extension method.
In the AddExternalProvider we have some common code to setup the providers.

Today, I added the following row:

builder.AddExternalProvider(Provider.Provider2, true, provider2Config);

When I then tried to login with that provider, I got the following error in IdentityServer when the callback was called.

Unable to unprotect the message.State

The solution

Turns out, it's not a good idea to use the same CallbackPath for different clients.

As you can see, we were using the same CallbackPath for all our providers, /redirect.

Basically what happened was that the wrong handler ran when the callback was executed, and it, correctly, failed to unpack the message.

builder.AddOpenIdConnect(
    provider.AuthenticationScheme, provider.DisplayName, options =>
    {
                
        .............
        options.Authority = clientConfiguration.Authority;
        options.CallbackPath = "/redirect" // <-- NOT GOOD
        options.ClientId = clientConfiguration.ClientId;
        options.ClientSecret = clientConfiguration.ClientSecret;
        options.UsePkce = true;
        .............
    });

The fix is simple; use a unique value for the CallbackPath.
I added a new property, CallbackPath to our Provider enumeration like this:

public class Provider : Enumeration
{
    private static readonly Lazy<Dictionary<string, Provider>> AuthenticationSchemeMap;

    public static readonly Provider Provider1;
    public static readonly Provider Provider2;

    static Provider()
    {
        Provider1 = new Provider(1,
            "Provider 1",
            AuthenticationSchemes.Provider1,
            "Provider 1",
            new Provider1Claims(),
            "/redirect-provider-1");
        Provider2 = new Provider(2,
            "Provider 2",
            AuthenticationSchemes.Provider2,
            "Provider 2",
            new Provider2Claims(),
            "/redirect-provider-2");
        AuthenticationSchemeMap = new Lazy<Dictionary<string, Provider>>(() =>
        {
            var allItems = GetAll<Provider>();
            return allItems.ToDictionary(x => x.AuthenticationScheme, x => x);
        });
    }

    private Provider(
        int id,
        string description,
        string authenticationScheme,
        string displayName,
        ProviderClaims claims,
        string callbackPath) : base(id, description)
    {
        AuthenticationScheme = authenticationScheme;
        DisplayName = displayName;
        Claims = claims;
        CallbackPath = callbackPath;
    }

    public string AuthenticationScheme { get; }
    public string DisplayName { get; }
    public ProviderClaims Claims { get; }
    public string CallbackPath {get; } // <-- New property
   .......
}

And then I replaced the hardcoded CallbackPath like this:

builder.AddOpenIdConnect(
    provider.AuthenticationScheme, provider.DisplayName, options =>
    {
                
        .............
        options.Authority = clientConfiguration.Authority;
        options.CallbackPath = provider.CallbackPath; // <-- Fixed
        options.ClientId = clientConfiguration.ClientId;
        options.ClientSecret = clientConfiguration.ClientSecret;
        options.UsePkce = true;
        .............
    });