Umbraco ASP.NET Core app with Amazon Cognito authentication caught in redirect loop

Hello. We’re attempting to change the authentication from our Umbraco 17 ASP.NET Core web site from Azure AD B2C to Amazon Cognito. As far as we can tell, Amazon Cognito is configured correctly, and we are able to authenticate via the login screen. However, the application seems to be caught in a loop, where it keeps refreshing and the ufprt token in the URL continues to grow until the web page throws an error stating the HTTP request is too large. Is anyone able to provide any guidance on how we get past this issue? I am posting what I consider to be the relevant code but let me know if you would like any more information. Thanks.

//Program.cs
// using statements

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;

builder.Services
    .AddDbContext<MemberServicesContext>()
    .AddScoped<INotificationRepository, NotificationRepository>()
    .AddScoped<INotificationService, NotificationService>()
    .AddBugsnag(configuration =>
    {
        configuration.ApiKey = config["BugsnagApiKey"] ?? string.Empty;
        configuration.ReleaseStage = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? string.Empty;
    });

builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddOpenIdConnectAuthentication(config)
    .AddServices(config)
    .AddWebsite()
    .AddDeliveryApi()
    .AddComposers()
    .SetCustomMemberLoginPath()
    .Build();

WebApplication app = builder.Build();

await app.BootUmbracoAsync();

if (app.Environment.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseHsts();
}

app.UseAuthentication();
app.UseAuthorization();

app.UseXfo(options => options.SameOrigin());

app.UseUmbraco()
    .WithMiddleware(u =>
    {
        u.UseBackOffice();
        u.UseWebsite();
    })
    .WithEndpoints(u =>
    {
        u.UseBackOfficeEndpoints();
        u.UseWebsiteEndpoints();
    });

app.Use(async (context, next) =>
{
    context.Response.Headers?.Append("X-Content-Type-Options", "nosniff");
    context.Response.Headers?.Append("X-Frame-Options", "SAMEORIGIN");
    await next();
});


await app.RunAsync();

// ServiceExtensions.cs
// using statements

namespace OurApp;

public static class ServiceExtensions
{
    public static IUmbracoBuilder AddOpenIdConnectAuthentication(this IUmbracoBuilder builder, IConfiguration config)
    {
         builder.Services.ConfigureOptions<OpenIdConnectMemberExternalLoginProviderOptions>();
        builder.Services.AddSingleton<MemberCookieManager>();
        builder.Services.ConfigureOptions<CustomCookieAuthenticationOptions>();

        builder.Services.AddMemoryCache();
        builder.Services.AddSingleton<ITicketStore, MemoryCacheTicketStore>();

        builder.AddMemberExternalLogins(logins =>
        {
            logins.AddMemberLogin(
                memberAuthenticationBuilder =>
                {
                    memberAuthenticationBuilder.AddOpenIdConnect(
                        memberAuthenticationBuilder.SchemeForMembers("UmbracoMembers." + "OpenIdConnect." + config["OpenIdConnectMember:AuthSchemeName"]),

                        options =>
                        {
                            //// pass configured options along
                            options.MetadataAddress = config["OpenIdConnectMember:MetadataAddress"];
                            options.ClientId = config["OpenIdConnectMember:ClientId"];
                            options.ClientSecret = config["OpenIdConnectMember:ClientSecret"];
                            options.CallbackPath = config["OpenIdConnectMember:CallbackPath"];
                            options.RemoteSignOutPath = config["OpenIdConnectMember:RemoteSignOutPath"];
                            options.Authority = config["OpenIdConnectMember:Authority"];
                            options.ClaimsIssuer = config["OpenIdConnectMember:IssuerUrl"];

                            string? signedOutRedirectUri = config["OpenIdConnectMember:SignedOutRedirectUri"];
                            if (signedOutRedirectUri != null)
                            {
                                options.SignedOutRedirectUri = signedOutRedirectUri;
                            }

                            // Use the authorization code flow
                            options.ResponseType = "code";

                            // map claims
                            options.TokenValidationParameters.NameClaimType = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress";
                            options.TokenValidationParameters.RoleClaimType = "role";

                            options.RequireHttpsMetadata = true;
                            options.SaveTokens = true;

                            string[] scopes = config["OpenIdConnectMember:Scopes"]?.Split(' ') ?? [];

                            foreach (string scope in scopes)
                            {
                                options.Scope.Add(scope);
                            }

                            options.UsePkce = true;

                            options.Events.OnRedirectToIdentityProvider = context =>
                            {
                                return Task.CompletedTask;
                            };
                            options.Events.OnTokenValidated = async context =>
                            {
                                var claims = context?.Principal?.Claims.ToList();
                                var email = (claims?.SingleOrDefault(x => x.Type == ClaimTypes.Email))
                                    ?? throw new Exception("Email claim is required for auto linking, but was not found.");
                                var name = claims?.SingleOrDefault(x => x.Type == "name");
                                var nameTypeClaim = claims?.SingleOrDefault(x => x.Type == ClaimTypes.Name);
                                if (name != null && name.Value != nameTypeClaim?.Value)
                                {
                                    // The name claim is required for auto linking.
                                    // So get it from another claim and put it in the name claim.
                                    if (nameTypeClaim != null)
                                    {
                                        claims?.Remove(nameTypeClaim);
                                    }
                                    claims?.Add(new Claim(ClaimTypes.Name, name.Value));

                                    if (context != null)
                                    {
                                        // Since we added new claims create a new principal.
                                        var authenticationType = context.Principal?.Identity?.AuthenticationType;
                                        context.Principal = new ClaimsPrincipal(new ClaimsIdentity(claims, authenticationType));
                                    }
                                }

                                await Task.FromResult(0);
                            };
                            options.Events.OnRemoteSignOut = context =>
                            {
                                return Task.CompletedTask;
                            };
                            options.Events.OnRemoteFailure = context =>
                            {
                                context.Response.Redirect($"/");
                                context.HandleResponse();

                                return Task.CompletedTask;
                            };
                            options.Events.OnSignedOutCallbackRedirect = context =>
                            {
                                return Task.FromResult(true);
                            };
                            options.Events.OnRedirectToIdentityProviderForSignOut = notification =>
                            {
                                return Task.CompletedTask;
                            };
                        });
                });

        });

        return builder;
    }

    public static IUmbracoBuilder AddServices(this IUmbracoBuilder builder, IConfiguration config)
    {
        builder.Services.AddTransient<ISearchService, SearchService>();

        return builder;
    }
}

// OpenIdConnectMemberExternalLoginProviderOptions.cs
// using statements

namespace MyApp;

public class OpenIdConnectMemberExternalLoginProviderOptions : IConfigureNamedOptions<MemberExternalLoginProviderOptions>
{
    private readonly ILogger<OpenIdConnectMemberExternalLoginProviderOptions> _logger;
    private readonly IConfiguration _config;

    public string? _schemeName;

    public OpenIdConnectMemberExternalLoginProviderOptions(ILogger<OpenIdConnectMemberExternalLoginProviderOptions> logger, IConfiguration config)
    {
        _logger = logger;
        _config = config;
        _schemeName = _config["OpenIdConnectMember:AuthSchemeName"];
    }

    public void Configure(string? name, MemberExternalLoginProviderOptions options)
    {
        if (name != Constants.Security.MemberExternalAuthenticationTypePrefix + "OpenIdConnect." + _schemeName)
        {
            return;
        }

        Configure(options);
    }

    public void Configure(MemberExternalLoginProviderOptions options)
    {
        options.AutoLinkOptions = new MemberExternalSignInAutoLinkOptions(
            autoLinkExternalAccount: true,
            defaultCulture: null,
            defaultIsApproved: true,
            defaultMemberTypeAlias: "Member"
        )
        {
            OnAutoLinking = (autoLinkUser, loginInfo) =>
            {
                OnAutoLinking(autoLinkUser, loginInfo);
            },
            OnExternalLogin = (user, loginInfo) =>
            {
                OnExternalLogin(user, loginInfo);
                return true;
            }
        };
    }

    public void OnAutoLinking(MemberIdentityUser autoLinkUser, ExternalLoginInfo loginInfo)
    {
        string? jwtToken = loginInfo.AuthenticationTokens?.Where(t => t.Name == "access_token").Select(token => token.Value).FirstOrDefault();

        var handler = new JwtSecurityTokenHandler();
        var jsonToken = handler.ReadToken(jwtToken);
        var tokenFromIdentityProvider = (JwtSecurityToken)jsonToken;

        var roles = tokenFromIdentityProvider.Claims.Where(c => c.Type == "user_roles");

        autoLinkUser.Roles.Clear();

        if (!roles.Any())
        {
            _logger.LogInformation("No user_roles found in user claims");
        }

        // add roles to autoLinkUser for each role in tokenFromIdentityProvider
    }

    public void OnExternalLogin(MemberIdentityUser user, ExternalLoginInfo loginInfo)
    {
        string? jwtToken = loginInfo.AuthenticationTokens?.Where(t => t.Name == "access_token").Select(token => token.Value).FirstOrDefault();

        var handler = new JwtSecurityTokenHandler();
        var jsonToken = handler.ReadToken(jwtToken);
        var tokenFromIdentityProvider = (JwtSecurityToken)jsonToken;

        var roles = tokenFromIdentityProvider.Claims.Where(c => c.Type == "user_roles");

        user.Roles.Clear();
        
        // add roles to user for each role in tokenFromIdentityProvider

        user.Claims.Clear();

        // add claims to user for each claim in tokenFromIdentityProvider
}

Our team found the issue. We needed to change the value we were passing to the memberAuthenticationBuilder.SchemeForMembers setting (as well as the Configure name parameter check in OpenIdConnectMemberExternalLoginProviderOptions) to a value other than what we were using in our Azure AD B2C configuration, and we were able to authenticate successfully.

2 Likes