azure-ad-b2copenid-connect.net-6.0azure-ad-b2bmicrosoft-identity-web

AddMicrosoftIdentityWebApp with two providers isn't setting IsAuthenticated


I'm trying to get a dual authentication approach working for my .NET6 website. For the front-end, I'm implementing Azure AD B2C, and for the back-end, Azure AD. Here's my code:

Startup.cs

public void ConfigureServices(IServiceCollection services)
{
...
services.AddAuthentication()
        .AddMicrosoftIdentityWebApp(options => {
            options.ResponseType = OpenIdConnectResponseType.Code;
            options.UsePkce = true;
            options.Instance = "Instance1";
            options.TenantId = "TenantId1";
            options.ClientId = "ClientId1";
            options.ClientSecret = "ClientSecret1";
            options.CallbackPath = "/signin-oidc/aadb2b";

            options.Scope.Clear();
            options.Scope.Add(OpenIdConnectScope.OpenId);
            options.Scope.Add(OpenIdConnectScope.OfflineAccess);
            options.Scope.Add(OpenIdConnectScope.Email);
            options.Scope.Add(OpenIdConnectScope.OpenIdProfile);
            options.MapInboundClaims = false;

            options.TokenValidationParameters = new TokenValidationParameters
            {
                NameClaimType = "preferred_username",
                ValidateIssuer = false
            };

            options.Events.OnRedirectToIdentityProvider = ctx =>
            {
                if (ctx.Response.StatusCode == 401)
                {
                    ctx.HandleResponse();
                }

                return Task.CompletedTask;
            };

            options.Events.OnAuthenticationFailed = ctx =>
            {
                ctx.HandleResponse();
                ctx.Response.BodyWriter.WriteAsync(Encoding.ASCII.GetBytes(ctx.Exception.Message));
                return Task.CompletedTask;
            };
        }, options => {
            options.Events.OnSignedIn = async ctx =>
            {
                if (ctx.Principal?.Identity is ClaimsIdentity claimsIdentity)
                {
                    // Syncs user and roles so they are available to the CMS
                    var synchronizingUserService = ctx
                        .HttpContext
                        .RequestServices
                        .GetRequiredService<ISynchronizingUserService>();
                    await synchronizingUserService.SynchronizeAsync(claimsIdentity);
                }
            };
        }, "AADB2B.OpenIdConnect", "AADB2B.Cookies");

services.AddAuthentication()
        .AddMicrosoftIdentityWebApp(options => {
            options.Instance = "Instance2";
            options.Domain = "Domain2";
            options.TenantId = "TenantId2";
            options.ClientId = "ClientId2";
            options.ClientSecret = "ClientSecret2";
            options.SignUpSignInPolicyId = "USUIP";
            options.ResetPasswordPolicyId = "RPP";
            options.EditProfilePolicyId = "EPP";
            options.CallbackPath = "/signin-oidc/aadb2c";

            options.TokenValidationParameters = new TokenValidationParameters
            {
                RoleClaimType = "roles"
            };

            options.Events.OnRedirectToIdentityProvider = ctx =>
            {
                if (ctx.Response.StatusCode == 401)
                {
                    ctx.HandleResponse();
                }

                return Task.CompletedTask;
            };

            options.Events.OnAuthenticationFailed = ctx =>
            {
                ctx.HandleResponse();
                ctx.Response.BodyWriter.WriteAsync(Encoding.ASCII.GetBytes(ctx.Exception.Message));
                return Task.CompletedTask;
            };
        }, options => {
            options.Events.OnSignedIn = async ctx =>
            {
                if (ctx.Principal?.Identity is ClaimsIdentity claimsIdentity)
                {
                    // Syncs user and roles so they are available to the CMS
                    var synchronizingUserService = ctx
                        .HttpContext
                        .RequestServices
                        .GetRequiredService<ISynchronizingUserService>();
                    await synchronizingUserService.SynchronizeAsync(claimsIdentity);
                }
            };
        }, "AADB2C.OpenIdConnect", "AADB2C.Cookies");

// Added as an experiment, doesn't seem to help
services.AddAuthorization(options => 
    options.DefaultPolicy = 
        new AuthorizationPolicyBuilder("AADB2B.OpenIdConnect")
            .RequireAuthenticatedUser()
            .Build());
...
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseNotFoundHandler();
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseGetaCategories();
    app.UseGetaCategoriesFind();

    app.UseAnonymousId();
    app.UseStaticFiles();
    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/LoginPath", async ctx => ctx.Response.Redirect("/")).RequireAuthorization(authorizeData: new AuthorizeAttribute { AuthenticationSchemes = "AADB2B.OpenIdConnect" });
        endpoints.MapGet("/LogoutPath", async ctx => await MapLogout(ctx));

        endpoints.MapControllerRoute(name: "Default", pattern: "{controller}/{action}/{id?}");
        endpoints.MapControllers();
        endpoints.MapRazorPages();
        endpoints.MapContent();
    });
}

public async Task MapLogout(HttpContext ctx)
{
    await ctx.SignOutAsync("AADB2B.OpenIdConnect");
    await ctx.SignOutAsync("AADB2B.Cookies");
    ctx.Response.Redirect("/");
}

Controller.cs

[HttpGet]
[AllowAnonymous]
public IActionResult ExternalLogin(string scheme, string returnUrl)
{
    return Challenge(new AuthenticationProperties { RedirectUri = string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl });
}

Controller is receiving a hyperlink with the QueryString scheme=AADB2B.OpenIdConnect and scheme=AADB2C.OpenIdConnect respectively.

Upon clicking the hyperlinks, the browser is properly redirected to the signin page for AAD B2C or AAD respectively, and then properly redirected back to the website. A breakpoint in the OnSignedIn event properly shows that the Principal.Identity is indeed a ClaimsIdentity, and IsAuthenticated is true. When arriving in the website, the cookies seem to exist:

Cookies

However, after the page finishes loading, checking IHttpContextAccessor on subsequent pages shows that the HttpContext.User seems to be a brand-new one, and not the one that exists after the above authentication call.

I tried changing to this:

[HttpGet]
[AllowAnonymous]
public IActionResult ExternalLogin(string scheme, string returnUrl)
{
    return Challenge(new AuthenticationProperties { RedirectUri = Url.Action("ExternalLoginCallback", new { scheme = scheme, returnUrl = returnUrl }) }, scheme);
}

[Authorize(AuthenticationSchemes = "AADB2B.OpenIdConnect,AADB2C.OpenIdConnect")]
public async Task<ActionResult> ExternalLoginCallback(string scheme, string returnUrl)
{
    var authenticate = await HttpContext.AuthenticateAsync(scheme);
    if (authenticate.Succeeded)
        User.AddIdentity((ClaimsIdentity)authenticate.Principal.Identity);

    return Redirect(string.IsNullOrEmpty(returnUrl) ? "/" : returnUrl);
}

On the authenticate.Succeeded line, I see that my user was properly authenticated. The User.AddIdentity line properly adds the identity to that user. However, when I look on the subsequent page load, the above identity is gone.

I'm at wits end. Any suggestions would be greatly appreciated. Thanks!

Update 1

Navigating directly to a page that is decorated with [Authorize(AuthenticationSchemes = "AADB2C.OpenIdConnect")] DOES properly result in the page recognizing the user as being authenticated. However, from there, navigating anywhere else then shows them no longer being authenticated.

Update 2

Calling IHttpContextAccessor.HttpContext?.AuthenticateAsync("AADB2C.OpenIdConnect") in places where I couldn't decorate with the Authorize flag (due to requiring access for non-authenticated users as well) properly fetches the authenticated user and their information. So, now the only piece of this puzzle I need to solve is finding a way to get Authorize into areas of the code which I can't access, due to being hidden behind proprietary third-party code.

Update 3

I'm unsure why, but it appears as though if I use AddOpenIdConnect instead of AddMicrosoftIdentityWebApp, it ... works? It defaults to that and my back-end now properly recognizes my authentication.

services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    options.DefaultAuthenticateScheme = null;
    options.DefaultSignInScheme = null;
}).AddCookie(options =>
{
    options.Events.OnSignedIn = async ctx =>
    {
        if (ctx.Principal?.Identity is ClaimsIdentity claimsIdentity)
        {
            // Syncs user and roles so they are available to the CMS
            var synchronizingUserService = ctx
                .HttpContext
                .RequestServices
                .GetRequiredService<ISynchronizingUserService>();

            await synchronizingUserService.SynchronizeAsync(claimsIdentity);
        }
    };
}).AddOpenIdConnect(options =>
{
    options.ResponseType = OpenIdConnectResponseType.Code;
    options.UsePkce = true;
    options.Authority = $"MyAuthority";
    options.ClientId = "MyClientId";
    options.ClientSecret = "MyClientSecret";
    options.CallbackPath = "/signin-oidc/aadb2b";

    options.Scope.Clear();
    options.Scope.Add(OpenIdConnectScope.OpenId);
    options.Scope.Add(OpenIdConnectScope.OfflineAccess);
    options.Scope.Add(OpenIdConnectScope.Email);
    options.Scope.Add(OpenIdConnectScope.OpenIdProfile);
    options.MapInboundClaims = false;

    options.TokenValidationParameters = new TokenValidationParameters
    {
        RoleClaimType = "roles",
        NameClaimType = "preferred_username",
        ValidateIssuer = false
    };

    options.Events.OnRedirectToIdentityProvider = ctx =>
    {
        if (ctx.Response.StatusCode == 401)
        {
            ctx.HandleResponse();
        }

        return Task.CompletedTask;
    };

    options.Events.OnAuthenticationFailed = ctx =>
    {
        ctx.HandleResponse();
        ctx.Response.BodyWriter.WriteAsync(Encoding.ASCII.GetBytes(ctx.Exception.Message));
        return Task.CompletedTask;
    };
});

Solution

  • So, to summarize the steps that I took to resolve this:

    1. Adding [Authorize(AuthenticationSchemes = "MyScheme")] to controllers will properly force authentication when navigating using that controller route.
    2. Calling IHttpContextAccessor.HttpContext?.AuthenticateAsync("MyScheme") returns details of the authenticated principal, allowing code-based control in places where the [Authorize] approach won't work (because it needs to allow both anonymous and authenticated users, and renders differently based on that condition).
    3. For the specific back-end code I couldn't access due to it being hidden behind third-party proprietary code (EPiServer in this case), I was able to resolve the issue by switching to use AddOpenIdConnect instead of AddMicrosoftIdentityWebApp. I'm unsure why this worked, but for the moment I'm not going to question it further.