Using:
Microsoft.Identity.Web
Microsoft.Identity.Web.UI
(controllers and Razor pages)I've converted my project from Microsoft identity to Azure AD B2C. We have our internal employees so that they can log in with an employee custom policy and clients log in with a different custom policy (which is all working). I've made it far enough that I can listen for all of the custom MicrosoftIdentityOptions
events that are available as well as override any I need to (as well as Microsoftidentity/account/signin
etc.).
The problem I'm trying to solve is how to have one login page that can dynamically switch which custom policy is used at the beginning of authentication based on the username given (employees have just one domain their email is based off of so identifying them is easy).
It seemed easiest to override the initial redirect to Azure and take the user to my own login page first (hosted on the app's server), which only asks for username. Then the user hits next. Based on the username given, the app then redirects to Azure AD B2C's hosted login page using the correct custom policy (https://...?p=employeePolicy
or ?p=clientPolicy ...
) and auto-filling the given username with the login_hint
parameter (which seems to work fine).
However, I can't seem to redirect to my own login page first, it keeps redirecting to Azure's hosted login page. If I turn off authorization on the page the unauthenticated user is trying to hit, I can successfully redirect them to my own login page first. However, all my pages (other than the login pages) require authentication.
I've tried every way I can think of to configure Microsoft's AuthenticationBuilder
(services.AddAuthentication
... AddMicrosoftIdentityWebApp...
) so that if a user tries to hit any protected page within the solution that they are redirected to my own login page first to enter their username before being redirected to Azure. The redirect to Azure seems to happen no matter what I do.
Could this be a side-effect of using Microsoft.Identity.Web
instead of MSAL 2.0? Maybe I'm stuck in this workflow?
I'm at a loss as to whether this is going to work or if I'm better off creating a custom (and default) authentication that sits in front and routes users to my login page first. Once the user logs in and is redirected back to my site, I can log them into this custom auth scheme (which seems like the wrong approach, I'd rather the auth scheme just be Azure's OpenIdConnect scheme).
Another approach is to simply have one login page for employees and another login page for clients, but I'd rather not do that since it may get confusing on who should use which login page.
Some seemed to have had luck getting Home Realm Discovery (HRD) and Domain Hints to work together with a custom policy, so maybe that's the smarter approach I'm not sure. The engineer that assembled the custom policy is out, so I was trying to solve this within the solution.
If anyone can give me some guidance on how I may override the initial redirect to Azure, before a user has logged in, would be great. Examples in github or wherever might be good too. I feel like I'm not overriding the behavior correctly and all of my searches so far have not worked for me.
I've tried adding my own redirects within the RedirectToIdentityProvider
event that I'm intercepting, but redirects such as context.ProtocolMessage.RedirectUri
& context.ProtocolMessage.PostLogoutRedirectUri
seem to configure redirects that should happen after authentication versus before. I've tried changing the CallbackPath
and other settings within the services.Configure<MicrosoftIdentityOptions>(OpenIdConnectDefaults.AuthenticationScheme, options => ...
without luck.
I think I figured it out. I'll post snippets of what I did, hopefully it makes sense.
Generally the relevant methods traversed during login are this in this order:
RedirectToIdentityProvider -> AppLogin -> MSSignIn -> RedirectToIdentityProvider -> AzureLogin -> AuthorizationCodeReceived -> TokenValidated -> AppDashboard
RedirectToIdentityProvider
if (!items.TryGetValue("policy", out string policy))
{
protocolMessage.IssuerAddress = $"{domain}/identity/account/login";
//protocolMessage.Parameters.Remove
} else if (policy == employeePolicyId)
{
protocolMessage.Parameters.Add("p", policy.ToString().ToLower());
} else
{
protocolMessage.Parameters.Add("p", clientPolicyId);
}
if (items.TryGetValue("login_hint", out string login_hint))
{
protocolMessage.Parameters.Add("login_hint", login_hint);
}
This checks for a custom policy name if one exists. If not, we forward to our app's custom login page.
AppLogin
This is just a copy of the identity login template with the password field removed. We offer to remember the user and if checked, we write a cookie. Also, we read the username given and if they are an employee, we redirect to MSSignIn with the right policy:
return LocalRedirect($"/microsoftidentity/account/signin?policy={targetPolicy}");
MSSignIn
(microsoftidentity/account/signin)
[Route("signin", Name = "SignIn")]
[HttpGet("{scheme?}")]
public IActionResult SignIn([FromRoute] string scheme, [FromQuery] string policy)
{
scheme ??= OpenIdConnectDefaults.AuthenticationScheme;
policy ??= _configuration["Azure:AdB2C:SignUpSignInPolicyId"];
var redirectUrl = Url.Content("~/");
AuthenticationProperties properties = new()
{
RedirectUri = redirectUrl,
Items =
{
["policy"] = policy,
["customPolicy"] = policy
}
};
// Microsoft.Identity.Web removes the policy item as part of the redirect to IdP but we need to update the token URL when using code flow
string usernameKey = $"{Request.Host.Value.Replace(":", "")}_username";
string usernameCookie = _cookieService.GetCookie(usernameKey, true);
if (usernameCookie != null)
{
properties.Items.Add("login_hint", usernameCookie);
}
return Challenge(properties, scheme);
}
RedirectToIdentityProvider
This time we see the policy as well as the login_hint and add it to the protocolMessage accordingly. This sends it off to Azure's login.
AzureLogin
We are redirected to the Azure-hosted login page using the correct policy and it also receives the login_hint to auto-populate the username.
AuthorizationCodeReceived
if (context.Properties?.Items.TryGetValue("customPolicy", out var customPolicy) == true)
{
context.TokenEndpointRequest.TokenEndpoint = $"{_configuration["Azure:AdB2C:Instance"]}/{_configuration["Azure:AdB2C:Domain"]}/{customPolicy}/oauth2/v2.0/token";
}
TokenValidated
This has a lot of proprietary code, but I'm basically saving the access and refresh tokens and adding (and removing) claims. I'm also creating new identities and saving them all under the same ClaimsPrincipal object which is saved as a cookie. I had to be careful about what I stuffed in here since I didn't want my cookie to chunk (break apart into multiple pieces after the 4k limit).
Once I figured out that IssuerAddress is the address it uses to forward us to the identity provider the rest of the pieces started falling into place.
Also, I utilized the "EventsType" in my startup to gain access to some of those listening methods above, such as RedirectToIdentityProvider, AuthorizationCodeReceived and TokenValidated. If you utilize .Events instead, yours would be more like OnRedirectToIdentityProvider, OnAuthorizationCodeReceived, etc.
services.Configure<MicrosoftIdentityOptions>(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.EventsType = typeof(CustomOpenIdAuthenticationEvents);
});
It seems to be working so far ... I'm sure there will be additional tweaking. Feedback welcome. This has very much been a nasty experiment of google searches, documentation reading and trial and error.