2

I am trying to implement multiple authentication schemes in Blazor WASM. I want my users to be able to login using either Azure AD or Azure B2C and I don't want to use Custom User Flows in Azure B2C as I have found that to be very complex and error-prone to configure. I would like to have 2 x Login buttons ie. Login AD and Login B2C.

Each button on its own is simple to implement using MSAL, but I am struggling to get both working. In Microsoft.Web.Identity, we have the option of defining multiple Authentication Schemes. However, I don't see anything like that in WASM / MSAL.

I have been working on the following concept adjusting the authentication urls for each scheme.

LoginDisplay.razor

@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@inject NavigationManager Navigation

<AuthorizeView>
    <Authorized>
        Hello, @context.User.Identity?.Name!
        <button class="nav-link btn btn-link" @onclick="BeginLogOut">Log out</button>
    </Authorized>
    <NotAuthorized>
        <a href="authenticationAD/login">Log in AD</a>
        <a href="authenticationB2C/login">Log in B2C</a>
    </NotAuthorized>
</AuthorizeView>

@code{
    public void BeginLogOut()
    {
        Navigation.NavigateToLogout("authenticationAD/logout");
    }
}

AuthenticationAD.razor

@page "/authenticationAD/{action}"  /*NOTE Adjusted url*/
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
<RemoteAuthenticatorView Action="@Action"  >
</RemoteAuthenticatorView> 

@code{
    [Parameter] public string? Action { get; set; }
}

AuthenticationB2C.razor

@page "/authenticationB2C/{action}"  /*NOTE Adjusted url*/
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
<RemoteAuthenticatorView Action="@Action"  >
</RemoteAuthenticatorView> 

@code{
    [Parameter] public string? Action { get; set; }
}

Program.cs

var builder = WebAssemblyHostBuilder.CreateDefault(args);

............

builder.Services.AddMsalAuthentication(options =>
{
    builder.Configuration.Bind("AzureB2C", options.ProviderOptions.Authentication);

    options.ProviderOptions.Authentication.PostLogoutRedirectUri = "authenticationB2C/logout-callback";
    options.ProviderOptions.Authentication.RedirectUri = "authenticationB2C/login-callback";

    var webApiScopes = builder.Configuration["AzureB2C:WebApiScopes"];
    var webApiScopesArr = webApiScopes.Split(" ");
    foreach (var scope in webApiScopesArr)
    {
        options.ProviderOptions.DefaultAccessTokenScopes.Add(scope);
    }

    var appPaths = options.AuthenticationPaths;
    appPaths.LogInCallbackPath = "authenticationB2C/login-callback";
    appPaths.LogInFailedPath = "authenticationB2C/login-failed";
    appPaths.LogInPath = "authenticationB2C/login";
    appPaths.LogOutCallbackPath = "authenticationB2C/logout-callback";
    appPaths.LogOutFailedPath = "authenticationB2C/logout-failed";
    appPaths.LogOutPath = "authenticationB2C/logout";
    appPaths.LogOutSucceededPath = "authenticationB2C/logged-out";
    appPaths.ProfilePath = "authenticationB2C/profile";
    appPaths.RegisterPath = "authenticationB2C/register";
});


builder.Services.AddOidcAuthentication(options => //THIS CODE DOES NOT RUN
{
    builder.Configuration.Bind("AzureAd", options.ProviderOptions);

    options.ProviderOptions.PostLogoutRedirectUri = "authenticationAD/logout-callback";
    options.ProviderOptions.RedirectUri = "authenticationAD/login-callback";
    options.ProviderOptions.ResponseType = "code";

    var webApiScopes = builder.Configuration["AzureAd:WebApiScopes"];
    var webApiScopesArr = webApiScopes.Split(" ");
    foreach (var scope in webApiScopesArr)
    {
        options.ProviderOptions.DefaultScopes.Add(scope);
    }

    var appPaths = options.AuthenticationPaths;
    appPaths.LogInCallbackPath = "authenticationAD/login-callback";
    appPaths.LogInFailedPath = "authenticationAD/login-failed";
    appPaths.LogInPath = "authenticationAD/login";
    appPaths.LogOutCallbackPath = "authenticationAD/logout-callback";
    appPaths.LogOutFailedPath = "authenticationAD/logout-failed";
    appPaths.LogOutPath = "authenticationAD/logout";
    appPaths.LogOutSucceededPath = "authenticationAD/logged-out";
    appPaths.ProfilePath = "authenticationAD/profile";
    appPaths.RegisterPath = "authenticationAD/register";
});

await builder.Build().RunAsync();

This works as far as pressing the Login Button routes me to the correct authenticationXX.razor view.

The issue that I'm facing is that the second .AddXXXAuthentication does not run, so the OAuth settings for the second statement are not set. If I change the order, it is always the second statement that doesn't run. Authentication works fine based upon the first statement.

I tried using 2 off .AddMSALAuthentication statements and in that case, both statements did run. However, the ProviderOptions from appsettings.json were just over-written in the second statement. ie. it didn't create two instances of the MSAL Authentication scheme.

I know that I can hand-craft the url strings for each of the oauth steps using tags in the < RemoteAuthenticationView > component, but I was hoping to find a way to use native libraries where-ever possible to reduce the risk of introducing a security weakness in my application.

Has anyone else had experience with this scenario and can point me to some documentation / an example of how it can be done?

gwruck
  • 341
  • 2
  • 9
  • did you ever find a solution for this? I am trying to solve exactly the same problem. – open-collar May 02 '23 at 15:13
  • No, I didn't find a solution using MSAL. While not mentioned in my original question, I was also trying to get a solution that would work without alteration on either Blazor Server or WASM. I ended up building everything from first principals. – gwruck May 03 '23 at 23:00
  • I've ended up using a flag in local storage to indicate whether to initialize as either B2C or AD; when I start up I check the flag and initialise as appropriate. The login screen has a button for each option and will set the flag and then reload if necessary. This seems work well (although it is a bit "busy" when it is switch authentication mode). I am now trying to debug problems in b2c though, which has included a forced upgrade to .NET 7! – open-collar Jun 22 '23 at 09:45
  • I started out using a flag, but then I realised that I could determine which scheme I had authenticated with by looking at the instance (eg. https://sabrepmB2C.b2clogin.com for B2C) on the HttpContext User.Identity for an authenticated user. I have two buttons for login (AD and B2C), but one button for logout that inspects the instance type to determine the logout url. (It can get a bit tricky accessing the httpcontext depending upon Server / WASM, but that is another story!) – gwruck Jun 22 '23 at 23:38

1 Answers1

0

I was finally able to get this working using OpenIdConnect.

Following is my code in Program.cs

builder.Services.AddAuthentication(options =>
    {
        options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.DefaultChallengeScheme =  $"UNKNOWN";
    })
    .AddCookie()
    .AddOpenIdConnect($"{OpenIdConnectDefaults.AuthenticationScheme}_B2C", options =>
    {
        var instance = config["AzureAdB2C:Instance"];
        var tenantId = config["AzureAdB2C:TenantId"];
        var domain = config["AzureAdB2C:Domain"];
        var signUpSignInPolicyId = config["AzureAdB2C:SignUpSignInPolicyId"];
        //B2C authority
        options.Authority = $"https://{domain}.b2clogin.com/{domain}.onmicrosoft.com/{signUpSignInPolicyId}/";

        options.MetadataAddress = $"https://{domain}.b2clogin.com/{domain}.onmicrosoft.com/{signUpSignInPolicyId}/v2.0/.well-known/openid-configuration";

        options.ClientId = config["Secret_AzureAdB2C:ClientId"] ;
        options.ClientSecret = config["Secret_AzureAdB2C:ClientSecret"];
        options.ResponseType = OpenIdConnectResponseType.Code;
        options.ResponseMode = OpenIdConnectResponseMode.Query;
        options.SignInScheme = $"{CookieAuthenticationDefaults.AuthenticationScheme}";
        options.CallbackPath = config["AzureAdB2C:CallbackPath"];
        options.SignedOutCallbackPath = config["AzureAdB2C:SignedOutCallbackPath"] ;
       
        var scopes = config["AzureAdB2C:Scopes"];
        if (!string.IsNullOrEmpty(scopes))
        {
            foreach (var scope in scopes.Split(" "))
            {
                options.Scope.Add(scope);
            }
        }
        else
        {
            options.Scope.Add("openid");
            options.Scope.Add("offline_access");
        }
        options.TokenValidationParameters = new TokenValidationParameters()
        {
            
            ValidateIssuer = false,
            ValidateAudience = true,
            ValidAudience = config["AzureAdB2C:ClientId"],

        };
        
        //options.Events = new OpenIdConnectEvents()
        //options.Events = oidcEvents;
    })
    .AddOpenIdConnect($"{OpenIdConnectDefaults.AuthenticationScheme}_AD", options =>
    {
        var instance = config["AzureAd:Instance"];
        var tenantId = config["AzureAd:TenantId"];

        options.Authority = $"{instance}/{tenantId}/v2.0/";
        options.MetadataAddress = $"{instance}/{tenantId}/v2.0/.well-known/openid-configuration";
        options.ClientId = config["Secret_AzureAd:ClientId"];
        options.ClientSecret = config["Secret_AzureAd:ClientSecret"];
        options.ResponseType = OpenIdConnectResponseType.CodeIdToken;
        options.ResponseMode = OpenIdConnectResponseMode.Query;
        options.SignInScheme = $"{CookieAuthenticationDefaults.AuthenticationScheme}";
        //options.Prompt = "consent";
        options.CallbackPath = config["AzureAd:CallbackPath"];
        options.SignedOutCallbackPath = config["AzureAd:SignedOutCallbackPath"];
        var scopes = config["AzureAd:Scopes"];
        if (!string.IsNullOrEmpty(scopes))
        {
            foreach (var scope in scopes.Split(" "))
            {
                options.Scope.Add(scope);
            }
        }
        else
        {
            options.Scope.Add("openid");
            options.Scope.Add("offline_access");
        } //https://login.microsoftonline.com/ba640e9e-12a5-4424-92e6-ac14d4b27967/v2.0


        options.TokenValidationParameters = new TokenValidationParameters()
        {
            ValidateIssuer = false,
            //ValidIssuers = new []{ options.Authority, $"https://login.microsoftonline.com/{validTenantId}/v2.0" } ,
            ValidateAudience = true,
            ValidAudiences = new[]{options.ClientId},
            ValidateIssuerSigningKey = true,
            IssuerSigningKeys = new List<SecurityKey>()
            {
                new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config["Secret_AzureAd:ClientSecret"]))
            }
        };
        //options.Events = oidcEvents;
    });

If you are using the MicrosoftIdentity.UI, you can login to the different authentication schemes by suffixing the Scheme name to the standard. (It took me a while to work out that you could do this.)

<a href="MicrosoftIdentity/Account/SignIn/OpenIdConnect_B2C">Log in B2C</a>
<a href="MicrosoftIdentity/Account/SignIn/OpenIdConnect_AD">Log in AD</a>

While slightly off-topic, I found this article use-multiple-identity-providers-from-a-blazor-wasm-asp-net-core-app-secured-using-bff very helpful for the final solution that I ended up using. It enabled me to implement a separate Account Controller and have finer-grained control over the login process. (I wanted to understand more about what was gong on behind the MSAL magic).

gwruck
  • 341
  • 2
  • 9