Links
- Example application
- If you want to see the complete example app
- Azure AD B2C documentation
- If you need help creating the directory / policies
What I have already done
I have already created a B2C directory, registered an application there, along with all of the policies. I registered a reply url per each policy, since they will all have individiual callback paths.
Setting up the project
For setting up the project, you can check my article on using Azure AD with ASP.NET Core 1.0: link.
The basic dependencies are not different, the main difference is that we are using Open ID Connect authentication middleware multiple times.
Configuring Open ID Connect
Since I had setup all 5 different types of policies in Azure AD B2C, I needed to add the Open ID Connect authentication middleware 5 times to the pipeline, once per each policy. I made an extension method for IApplicationBuilder to add the handlers. When used in Startup.cs (after registering cookie authentication, and before the MVC middleware) it looks like this:
app.UseAzureAdB2CAuthentication(new AzureAdB2CAuthenticationOptions
{
SignUpPolicyId = aadConfig.SignUpPolicyId,
SignUpCallbackPath = aadConfig.SignUpCallbackPath,
ForgotPasswordPolicyId = aadConfig.ForgotPwPolicyId,
ForgotPasswordCallbackPath = aadConfig.ForgotPwCallbackPath,
EditProfilePolicyId = aadConfig.UserProfilePolicyId,
EditProfileCallbackPath = aadConfig.UserProfileCallbackPath,
SignInPolicyId = aadConfig.SignInPolicyId,
SignInCallbackPath = aadConfig.SignInCallbackPath,
SignUpOrInPolicyId = aadConfig.SignUpOrInPolicyId,
SignUpOrInCallbackPath = aadConfig.SignUpOrInCallbackPath,
AzureAdInstance = aadConfig.AadInstance,
Tenant = aadConfig.Tenant,
ClientId = aadConfig.ClientId,
PostLogoutRedirectUri = aadConfig.RedirectUri
});
All the settings are pulled from a configuration object. There are individual callback paths defined for each policy. The reason for this is that we will register multiple authentication handlers which can't process the authentication messages meant for the other handlers. Another option would be to have the handlers skip unrecognized requests, but I think this is cleaner. The extension method is quite simple, here it is:
public static IApplicationBuilder UseAzureAdB2CAuthentication(
this IApplicationBuilder app,
AzureAdB2CAuthenticationOptions options)
{
if(app == null)
{
throw new ArgumentNullException(nameof(app), "Application builder can't be null");
}
if(options == null)
{
throw new ArgumentNullException(nameof(options));
}
if(options.SignUpPolicyId != null && options.SignUpCallbackPath != null)
{
app.UseOpenIdConnectAuthentication(
CreateOidConnectOptionsForPolicy(
options.SignUpPolicyId,
options.SignUpCallbackPath,
options,
automaticChallenge: false));
}
if(options.ForgotPasswordPolicyId != null && options.ForgotPasswordCallbackPath != null)
{
app.UseOpenIdConnectAuthentication(
CreateOidConnectOptionsForPolicy(
options.ForgotPasswordPolicyId,
options.ForgotPasswordCallbackPath,
options,
automaticChallenge: false));
}
if(options.EditProfilePolicyId != null && options.EditProfileCallbackPath != null)
{
app.UseOpenIdConnectAuthentication(
CreateOidConnectOptionsForPolicy(
options.EditProfilePolicyId,
options.EditProfileCallbackPath,
options,
automaticChallenge: false));
}
if(options.SignInPolicyId != null && options.SignInCallbackPath != null)
{
app.UseOpenIdConnectAuthentication(
CreateOidConnectOptionsForPolicy(
options.SignInPolicyId,
options.SignInCallbackPath,
options,
automaticChallenge: false));
}
if(options.SignUpOrInPolicyId != null && options.SignUpOrInCallbackPath != null)
{
app.UseOpenIdConnectAuthentication(
CreateOidConnectOptionsForPolicy(
options.SignUpOrInPolicyId,
options.SignUpOrInCallbackPath,
options,
automaticChallenge: true));
}
return app;
}
As you can see, if both the id and callback path are specified for a policy, Open ID Connect middleware is registered for it. The order of registering them does not really matter. What does matter however, is that only one of them should have AutomaticChallenge set to true. The one that has it set will be the one to respond to challenges not targeting a specific middleware.
For me it makes the most sense to make the sign up or in middleware the one responsible. Since those kinds of challenges would pop up when a user hits a route and AuthorizeAttribute blocks them, it makes sense to give the user a choice to either sign in or sign up, not just one of them.
I also made a utility function to create the Open ID Connect middleware options for each policy.
private static OpenIdConnectOptions CreateOidConnectOptionsForPolicy(
string policyId,
string callbackPath,
AzureAdB2CAuthenticationOptions options,
bool automaticChallenge)
{
if(options.AzureAdInstance == null)
{
throw new ArgumentNullException("options.AzureAdInstance");
}
if(options.ClientId == null)
{
throw new ArgumentNullException("options.ClientId");
}
if(options.PostLogoutRedirectUri == null)
{
throw new ArgumentNullException("options.PostLogoutRedirectUri");
}
if(options.Tenant == null)
{
throw new ArgumentNullException("options.Tenant");
}
var opts = new OpenIdConnectOptions
{
AuthenticationScheme = policyId,
AutomaticChallenge = automaticChallenge,
CallbackPath = callbackPath,
ClientId = options.ClientId,
MetadataAddress = string.Format(options.AzureAdInstance, options.Tenant, policyId),
PostLogoutRedirectUri = options.PostLogoutRedirectUri,
TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name"
}
};
return opts;
}
I used the policy id as the authentication scheme, this way I can issue a challenge to one of them by using the policy id. The post-logout redirect URI I set to be the root of the app. The metadata address is quite important. It's different for each policy, and looks like this: https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration?p={policyid}. Replace {tenant} with the tenant id or the tenant domain name (tenant.onmicrosoft.com).
Setting up the AccountController
As is slightly traditional in ASP.NET MVC, I made an AccountController class to contain actions like SignIn, SignOut etc. Implementing sign in, sign up, reset password, and sign out was easy. Here is how they look like:
public IActionResult SignIn()
{
return Challenge(new AuthenticationProperties
{
RedirectUri = "/"
},
config.SignInPolicyId);
}
public IActionResult SignUp()
{
return Challenge(new AuthenticationProperties
{
RedirectUri = "/"
},
config.SignUpPolicyId);
}
public IActionResult ForgotPassword()
{
return Challenge(new AuthenticationProperties
{
RedirectUri = "/"
},
config.ForgotPwPolicyId);
}
public IActionResult SignOut()
{
string returnUrl = Url.Action(
action: nameof(SignedOut),
controller: "Account",
values: null,
protocol: Request.Scheme);
return SignOut(new AuthenticationProperties
{
RedirectUri = returnUrl
},
config.ForgotPwPolicyId,
config.SignUpOrInPolicyId,
config.UserProfilePolicyId,
config.SignUpPolicyId,
config.SignInPolicyId,
CookieAuthenticationDefaults.AuthenticationScheme);
}
public IActionResult SignedOut()
{
return View();
}
Quite simple, most of them just issue a challenge to one of the handlers. But I ran into a problem with sending the user to edit their profile. The problem is that if you issue a challenge when the user is authenticated, the framework assumes you did not have the rights to do that operation, and redirects the user to the access denied URL.
So what does a developer do when faced with a problem in the framework? Raise a GitHub issue :)
I went through the code for MVC Core and found the lines that cause the problem. The problem was that ChallengeResult does not allow us to specify the ChallengeBehavior value, which defaults to Automatic. This leads to the behavior I mentioned earlier, user getting redirected to the access denied URL. But what is needed is to use ChallengeBehavior.Unauthorized, which causes the middleware to redirect the user to Azure AD no matter if the user is authenticated already or not.
This is how the EditProfile action looks like for me:
public IActionResult EditProfile()
{
return this.Challenge(new AuthenticationProperties
{
RedirectUri = "/"
},
ChallengeBehavior.Unauthorized,
config.UserProfilePolicyId);
}
I made an extension method that returns my custom ChallengeResult, which actually allows specifying the ChallengeBehavior. If the issue I raised is solved in MVC Core, you will be able to do this with the usual Challenge function. The extension method looks like this:
public static IActionResult Challenge(
this Controller controller,
AuthenticationProperties authenticationProperties,
ChallengeBehavior challengeBehavior,
params string[] authenticationSchemes)
{
return new MyChallengeResult(
authenticationProperties,
challengeBehavior,
authenticationSchemes);
}
I know the name of the result is bad... Any way, the signature is pretty similar to the framework functions. The custom action result looks like this:
public class MyChallengeResult : IActionResult
{
private readonly AuthenticationProperties authenticationProperties;
private readonly string[] authenticationSchemes;
private readonly ChallengeBehavior challengeBehavior;
public MyChallengeResult(
AuthenticationProperties authenticationProperties,
ChallengeBehavior challengeBehavior,
string[] authenticationSchemes)
{
this.authenticationProperties = authenticationProperties;
this.challengeBehavior = challengeBehavior;
this.authenticationSchemes = authenticationSchemes;
}
public async Task ExecuteResultAsync(ActionContext context)
{
AuthenticationManager authenticationManager =
context.HttpContext.Authentication;
foreach (string scheme in authenticationSchemes)
{
await authenticationManager.ChallengeAsync(
scheme,
authenticationProperties,
challengeBehavior);
}
}
}
It doesn't have the same amount of error checking and logging as
the framework ChallengeResult, but it works for now. The important
part is where authenticationManager.ChallengeAsync()
is called.
This is where the framework ChallengeResult calls it with just the
first two arguments.
Summary / TL;DR
- Define Open ID Connect middleware per each policy that you use
- In order to make the edit profile policy work, at the moment you must
call up to the authentication manager yourself as the framework's default
behavior that breaks it cannot be changed at the moment
- This can be done with a custom action result