1

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;
    };
});
Kiran Ramaswamy
  • 605
  • 1
  • 8
  • 19

1 Answers1

0

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.
Kiran Ramaswamy
  • 605
  • 1
  • 8
  • 19