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:
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!
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.
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.
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;
};
});
So, to summarize the steps that I took to resolve this:
[Authorize(AuthenticationSchemes = "MyScheme")]
to controllers will properly force authentication when navigating using that controller route.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).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.