You are currently viewing Azure AD B2C and MSAL with .NET Core WPF App

Azure AD B2C and MSAL with .NET Core WPF App

In this article, let’s have a look at how to enable authentication in .NET Core WPF application.

Prerequisites

For following all steps in this article, we will need Azure Subscription. If you don’t have an Azure subscription, create a free account before you begin.

We will also need instance of Azure AD B2C instance. We will also need API and Angular App already registered in that instance.

Below is the list of those posts which explains all the steps to get there:

You should have Azure AD B2C instance, let’s say samplead.b2clogin.com and user flows B2C_1_SignUpSignIn for signing up or signing in the application.

Azure AD B2C

In this section, let’s register the application in Azure AD B2C tenant. The WPF application is not created yet. We will create it in next step.

App Registration

Login to Azure Portal and switch to the Azure AD B2C instance you want to use. Next search for Azure AD B2C in top navigation search box and then go to App registrations screen. On this new screen, click on New registration.

On the new registration panel, enter below details:

  • Name, a display name for app registration
  • Supported account types, to configure which accounts would be used for logging in. Let’s keep this input to default for now.
  • Redirect URIs, this is interesting input. We have discussed little bit more in the next subsection. For now, we just have to select Public client/native (mobile and desktop), option from the dropdown. Also, enter http://localhost in redirect URIs.
  • Permissions, grant admin permissions for openid and offline_access permissions. This should be kept default. Please note that you need to be administrator to grant these permissions.

Then click on Register button to complete the registration.

Redirect URI

The redirect URI is an interesting input here. As we know, we are trying to register a WPF application, not a web application. So, how can it have an URI ?

So, if you take a look at the documentation here, it tells us how the redirect URIs should be set for the UWP.NET Framework and .NET Core applications. Below is the image from the documentation

  • UWP, for these applications, the redirect URI should be set to https://login.microsoftonline.com/common/oauth2/nativeclient
  • .NET Framework, for these applications, the redirect URI should be set to the return value of WebAuthenticationBroker.GetCurrentApplicationCallbackUri()
  • .NET Core, this should be set to http://localhost. If you specify anything apart from loopback address, the client application (WPF) will throw an exception during log in.

If you use .NET framework application, the application opens a popup which shows login screen from Azure AD B2C. But things are not the same for .NET Core application.

For .NET Core application, the system browser is used for redirecting user to Azure AD B2C login screen. It means when we create WPF application, and we click on login button from our application, it will open the default browser on our machine (either edge or chrome, or Mozilla, whatever is the default browser is).

I am talking about Microsoft.Identity.Client v4.14.0. And as per documentation, for .NET Core, we are setting the value to the local host to enable the user to use the system browser for interactive authentication since .NET Core does not have a UI for the embedded web view at the moment.

Additional limitations are documented here.

How does it work ?

MSAL needs to listen on on http://localhost:port and intercept the code that AAD sends when the user is done authenticating.

So, in app registration, we can specify http://localhost in the redirect URI without any port in it. Then while setting up the Public Client Application, we need to specify the same.

MSAL will find the random available port and will use it.

This was not working with B2C until few days back, but this is available now.

WPF Application

Create a .NET Core WPF application using Visual Studio.

NuGet Packages

Add references to below NuGet packages:

Configurations

The application needs details of :

The WPF application creates the instance of PublicClientApplication, at the startup, in the App constructor.

public partial class App : Application
{
private static readonly string Tenant = "samplead.onmicrosoft.com";
private static readonly string AzureAdB2CHostname = "samplead.b2clogin.com";
private static readonly string ClientId = "888fff1d-16c3-4de6-92af-3d4ab54a860a";
private static readonly string RedirectUri = "http://localhost";
public static string PolicySignUpSignIn = "B2C_1_SignUpSignIn";
public static string PolicyEditProfile = "B2C_1_Edit_Profile";
public static string PolicyResetPassword = "B2C_1_Pwd_Reset";
public static string[] Scopes = { "openid", "profile" };
private static string AuthorityBase = $"https://{AzureAdB2CHostname}/tfp/{Tenant}/";
public static string AuthoritySignUpSignIn = $"{AuthorityBase}{PolicySignUpSignIn}";
public static string AuthorityEditProfile = $"{AuthorityBase}{PolicyEditProfile}";
public static string AuthorityResetPassword = $"{AuthorityBase}{PolicyResetPassword}";
public static IPublicClientApplication PublicClientApp { get; private set; }
static App()
{
PublicClientApp = PublicClientApplicationBuilder.Create(ClientId)
.WithB2CAuthority(AuthoritySignUpSignIn)
.WithRedirectUri(RedirectUri)
.WithLogging(Log, LogLevel.Verbose, false) //PiiEnabled set to false
.Build();
TokenCacheHelper.Bind(PublicClientApp.UserTokenCache);
}
private static void Log(LogLevel level, string message, bool containsPii)
{
string logs = ($"{level} {message}");
StringBuilder sb = new StringBuilder();
sb.Append(logs);
File.AppendAllText(System.Reflection.Assembly.GetExecutingAssembly().Location + ".msalLogs.txt", sb.ToString());
sb.Clear();
}
}
view raw app.xaml.cs hosted with ❤ by GitHub

Sign in and Sign out

All of the sign in and sign out related code is located in MainWindow.xaml.cs. The actual location of this code may vary depending on the design of your application.

In summary, the application tries to acquire token using AcquireTokenInteractive API, which should open an instance of default browser and should redirect user to the login screen.

public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
//// Get Account details, which is input for acquring token silently
private IAccount GetAccountByPolicy(IEnumerable<IAccount> accounts, string policy)
{
foreach (var account in accounts)
{
string accountIdentifier = account.HomeAccountId.ObjectId.Split('.')[0];
if (accountIdentifier.EndsWith(policy.ToLower())) return account;
}
return null;
}
//// Sign In
private async void SignInButton_Click(object sender, RoutedEventArgs e)
{
AuthenticationResult authResult = null;
var app = App.PublicClientApp;
try
{
ResultText.Text = "";
authResult = await (app as PublicClientApplication).AcquireTokenInteractive(App.Scopes)
.ExecuteAsync();
DisplayUserInfo(authResult);
UpdateSignInState(true);
}
catch (MsalException ex)
{
try
{
if (ex.Message.Contains("AADB2C90118"))
{
authResult = await (app as PublicClientApplication).AcquireTokenInteractive(App.Scopes)
.WithPrompt(Prompt.SelectAccount)
.WithB2CAuthority(App.AuthorityResetPassword)
.ExecuteAsync();
}
else
{
ResultText.Text = $"Error Acquiring Token:{Environment.NewLine}{ex}";
}
}
catch (Exception exe)
{
ResultText.Text = $"Error Acquiring Token:{Environment.NewLine}{exe}";
}
}
catch (Exception ex)
{
ResultText.Text = $"Error Acquiring Token:{Environment.NewLine}{ex}";
}
}
//// To Sign out
private async void SignOutButton_Click(object sender, RoutedEventArgs e)
{
IEnumerable<IAccount> accounts = await App.PublicClientApp.GetAccountsAsync();
try
{
while (accounts.Any())
{
await App.PublicClientApp.RemoveAsync(accounts.FirstOrDefault());
accounts = await App.PublicClientApp.GetAccountsAsync();
}
UpdateSignInState(false);
}
catch (MsalException ex)
{
ResultText.Text = $"Error signing-out user: {ex.Message}";
}
}
//// To acquire token when application is restarted without sing out
private async void Window_Loaded(object sender, RoutedEventArgs e)
{
try
{
var app = App.PublicClientApp;
IEnumerable<IAccount> accounts = await App.PublicClientApp.GetAccountsAsync();
AuthenticationResult authResult = await app.AcquireTokenSilent(App.Scopes,
GetAccountByPolicy(accounts, App.PolicySignUpSignIn))
.ExecuteAsync();
DisplayUserInfo(authResult);
UpdateSignInState(true);
}
catch (MsalUiRequiredException)
{
// Ignore, user will need to sign in interactively.
ResultText.Text = "You need to sign-in first, and then Call API";
}
catch (Exception ex)
{
ResultText.Text = $"Error Acquiring Token Silently:{Environment.NewLine}{ex}";
}
}
//// Method to toggle visibility of Sign In button
private void UpdateSignInState(bool signedIn)
{
if (signedIn)
{
SignOutButton.Visibility = Visibility.Visible;
SignInButton.Visibility = Visibility.Collapsed;
}
else
{
ResultText.Text = "";
TokenInfoText.Text = "";
SignOutButton.Visibility = Visibility.Collapsed;
SignInButton.Visibility = Visibility.Visible;
}
}
//// Method to display claims and their values
private void DisplayUserInfo(AuthenticationResult authResult)
{
TokenInfoText.Text = "";
var jwtHandler = new JwtSecurityTokenHandler();
var jwtInput = authResult.IdToken;
//Check if readable token (string is in a JWT format)
var readableToken = jwtHandler.CanReadToken(jwtInput);
if (readableToken != true)
{
TokenInfoText.Text = "The token doesn't seem to be in a proper JWT format.";
}
if (readableToken == true)
{
var token = jwtHandler.ReadJwtToken(jwtInput);
//Extract the headers of the JWT
var headers = token.Header;
var jwtHeader = "{";
foreach (var h in headers)
{
jwtHeader += '"' + h.Key + "\":\"" + h.Value + "\",";
}
jwtHeader += "}";
TokenInfoText.Text = "Header:\r\n" + JToken.Parse(jwtHeader).ToString(Formatting.Indented);
//Extract the payload of the JWT
var claims = token.Claims;
var jwtPayload = "{";
foreach (Claim c in claims)
{
jwtPayload += '"' + c.Type + "\":\"" + c.Value + "\",";
}
jwtPayload += "}";
TokenInfoText.Text += "\r\nPayload:\r\n" + JToken.Parse(jwtPayload).ToString(Formatting.Indented);
}
}
}
view raw MainWindow.xaml.cs hosted with ❤ by GitHub

Token Cache

This below token cache implementation is attached to the PublicClientApplication instance.

This implementation caches the token in the form of file. In the execution directory (where exe file is located), a new file is created with name B2CWPFApp.dll.msalcache.bin, which holds the token.

static class TokenCacheHelper
{
/// <summary>
/// Path to the token cache
/// </summary>
public static readonly string CacheFilePath = System.Reflection.Assembly.GetExecutingAssembly().Location + ".msalcache.bin";
private static readonly object FileLock = new object();
public static void BeforeAccessNotification(TokenCacheNotificationArgs args)
{
lock (FileLock)
{
args.TokenCache.DeserializeMsalV3(File.Exists(CacheFilePath)
? ProtectedData.Unprotect(File.ReadAllBytes(CacheFilePath),
null,
DataProtectionScope.CurrentUser)
: null);
}
}
public static void AfterAccessNotification(TokenCacheNotificationArgs args)
{
// if the access operation resulted in a cache update
if (args.HasStateChanged)
{
lock (FileLock)
{
// reflect changesgs in the persistent store
File.WriteAllBytes(CacheFilePath,
ProtectedData.Protect(args.TokenCache.SerializeMsalV3(),
null,
DataProtectionScope.CurrentUser));
}
}
}
internal static void Bind(ITokenCache tokenCache)
{
tokenCache.SetBeforeAccess(BeforeAccessNotification);
tokenCache.SetAfterAccess(AfterAccessNotification);
}
}
view raw TokenCacheHelper.cs hosted with ❤ by GitHub

Run and Verify

When we run the application and click in sign in button, the default browser will open. This browser will show Azure AD B2C tenant’s login screen. After successful login, the WPF application will show the token JSON in the text box as shown in below image.

The system browser usage in the authentication flow may not last long for .NET Core and I think there may be update to change this behavior.

I hope you enjoyed the article. Let me know your thoughts.

Leave a Reply

This Post Has One Comment