Tutorial: Authenticate users to your WPF desktop application
This tutorial is the final part of a series that demonstrates building a Windows Presentation Form (WPF) desktop app and preparing it for authentication using the Microsoft Entra admin center. In Part 1 of this series, you registered an application and configured user flows in your external tenant. This tutorial demonstrates how to build your .NET WPF desktop app and sign in and sign out a user using Microsoft Entra External ID.
In this tutorial, you'll:
- Configure a WPF desktop app to use it's app registration details.
- Build a desktop app that signs in a user and acquires a token on behalf of the user.
Prerequisites
- Tutorial: Prepare your external tenant to sign in user in .NET WPF application.
- .NET 7.0 SDK or later.
- Although any integrated development environment (IDE) that supports React applications can be used, this tutorial uses Visual Studio Code.
Create a WPF desktop application
Open your terminal and navigate to the folder where you want your project to live.
Initialize a WPF desktop app and navigate to its root folder.
dotnet new wpf --language "C#" --name sign-in-dotnet-wpf cd sign-in-dotnet-wpf
Install packages
Install configuration providers that help our app to read configuration data from key-value pairs in our app settings file. These configuration abstractions provide the ability to bind configuration values to instances of .NET objects.
dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.Json
dotnet add package Microsoft.Extensions.Configuration.Binder
Install the Microsoft Authentication Library (MSAL) that contains all the key components that you need to acquire a token. You also install the MSAL broker library that handles interactions with desktop authentication brokers.
dotnet add package Microsoft.Identity.Client
dotnet add package Microsoft.Identity.Client.Broker
Create appsettings.json file and add registration configs
Create appsettings.json file in the root folder of the app.
Add app registration details to the appsettings.json file.
{ "AzureAd": { "Authority": "https://<Enter_the_Tenant_Subdomain_Here>.ciamlogin.com/", "ClientId": "<Enter_the_Application_Id_Here>" } }
- Replace
Enter_the_Tenant_Subdomain_Here
with the Directory (tenant) subdomain. - Replace
Enter_the_Application_Id_Here
with the Application (client) ID of the app you registered earlier.
- Replace
After creating the app settings file, we'll create another file called AzureAdConfig.cs that will help you read the configs from the app settings file. Create the AzureAdConfig.cs file in the root folder of the app.
In the AzureAdConfig.js file, define the getters and setters for the
ClientId
andAuthority
properties. Add the following code:namespace sign_in_dotnet_wpf { public class AzureAdConfig { public string Authority { get; set; } public string ClientId { get; set; } } }
Use custom URL domain (Optional)
Use a custom domain to fully brand the authentication URL. From a user perspective, users remain on your domain during the authentication process, rather than being redirected to ciamlogin.com domain name.
Follow these steps to use a custom domain:
Use the steps in Enable custom URL domains for apps in external tenants to enable custom URL domain for your external tenant.
Open appsettings.json file:
- Update the value of the
Authority
property to https://Enter_the_Custom_Domain_Here/Enter_the_Tenant_ID_Here. ReplaceEnter_the_Custom_Domain_Here
with your custom URL domain andEnter_the_Tenant_ID_Here
with your tenant ID. If you don't have your tenant ID, learn how to read your tenant details. - Add
knownAuthorities
property with a value [Enter_the_Custom_Domain_Here].
- Update the value of the
After you make the changes to your appsettings.json file, if your custom URL domain is login.contoso.com, and your tenant ID is aaaabbbb-0000-cccc-1111-dddd2222eeee, then your file should look similar to the following snippet:
{
"AzureAd": {
"Authority": "https://login.contoso.com/aaaabbbb-0000-cccc-1111-dddd2222eeee",
"ClientId": "Enter_the_Application_Id_Here",
"KnownAuthorities": ["login.contoso.com"]
}
}
Modify the project file
Navigate to the sign-in-dotnet-wpf.csproj file in the root folder of the app.
In this file, take the following two steps:
- Modify the sign-in-dotnet-wpf.csproj file to instruct your app to copy the appsettings.json file to the output directory when the project is compiled. Add the following piece of code to the sign-in-dotnet-wpf.csproj file:
- Set the target framework to target windows10.0.19041.0 build to help with reading cached token from the token cache as you'll see in the token cache helper class.
<Project Sdk="Microsoft.NET.Sdk"> ... <!-- Set target framework to target windows10.0.19041.0 build --> <PropertyGroup> <OutputType>WinExe</OutputType> <TargetFramework>net7.0-windows10.0.19041.0</TargetFramework> <!-- target framework --> <RootNamespace>sign_in_dotnet_wpf</RootNamespace> <Nullable>enable</Nullable> <UseWPF>true</UseWPF> </PropertyGroup> <!-- Copy appsettings.json file to output folder. --> <ItemGroup> <None Remove="appsettings.json" /> </ItemGroup> <ItemGroup> <EmbeddedResource Include="appsettings.json"> <CopyToOutputDirectory>Always</CopyToOutputDirectory> </EmbeddedResource> </ItemGroup> </Project>
Create a token cache helper class
Create a token cache helper class that initializes a token cache. The application attempts to read the token from the cache before it attempts to acquire a new token. If the token isn't found in the cache, the application acquires a new token. Upon signing-out, the cache is cleared of all accounts and all corresponding access tokens.
Create a TokenCacheHelper.cs file in the root folder of the app.
Open the TokenCacheHelper.cs file. Add the packages and namespaces to the file. In the following steps, you populate this file with the code logic by adding the relevant logic to the
TokenCacheHelper
class.using System.IO; using System.Security.Cryptography; using Microsoft.Identity.Client; namespace sign_in_dotnet_wpf { static class TokenCacheHelper{} }
Add constructor to the
TokenCacheHelper
class that defines the cache file path. For packaged desktop apps (MSIX packages, also called desktop bridge) the executing assembly folder is read-only. In that case we need to useWindows.Storage.ApplicationData.Current.LocalCacheFolder.Path + "\msalcache.bin"
that is a per-app read/write folder for packaged apps.namespace sign_in_dotnet_wpf { static class TokenCacheHelper { static TokenCacheHelper() { try { CacheFilePath = Path.Combine(Windows.Storage.ApplicationData.Current.LocalCacheFolder.Path, ".msalcache.bin3"); } catch (System.InvalidOperationException) { CacheFilePath = System.Reflection.Assembly.GetExecutingAssembly().Location + ".msalcache.bin3"; } } public static string CacheFilePath { get; private set; } private static readonly object FileLock = new object(); } }
Add code to handle token cache serialization. The
ITokenCache
interface implements the public access to cache operations.ITokenCache
interface contains the methods to subscribe to the cache serialization events, while the interfaceITokenCacheSerializer
exposes the methods that you need to use in the cache serialization events, in order to serialize/deserialize the cache.TokenCacheNotificationArgs
contains parameters used byMicrosoft.Identity.Client
(MSAL) call accessing the cache.ITokenCacheSerializer
interface is available inTokenCacheNotificationArgs
callback.Add the following code to the
TokenCacheHelper
class:static class TokenCacheHelper { static TokenCacheHelper() {...} public static string CacheFilePath { get; private set; } 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 (args.HasStateChanged) { lock (FileLock) { File.WriteAllBytes(CacheFilePath, ProtectedData.Protect(args.TokenCache.SerializeMsalV3(), null, DataProtectionScope.CurrentUser) ); } } } } internal static void EnableSerialization(ITokenCache tokenCache) { tokenCache.SetBeforeAccess(BeforeAccessNotification); tokenCache.SetAfterAccess(AfterAccessNotification); }
In the
BeforeAccessNotification
method, you read the cache from the file system, and if the cache isn't empty, you deserialize it and load it. TheAfterAccessNotification
method is called afterMicrosoft.Identity.Client
(MSAL) accesses the cache. If the cache has changed, you serialize it and persist the changes to the cache.The
EnableSerialization
contains theITokenCache.SetBeforeAccess()
andITokenCache.SetAfterAccess()
methods:ITokenCache.SetBeforeAccess()
sets a delegate to be notified before any library method accesses the cache. This gives an option to the delegate to deserialize a cache entry for the application and accounts specified in theTokenCacheNotificationArgs
.ITokenCache.SetAfterAccess()
sets a delegate to be notified after any library method accesses the cache. This gives an option to the delegate to serialize a cache entry for the application and accounts specified in theTokenCacheNotificationArgs
.
Create the WPF desktop app UI
Modify the MainWindow.xaml file to add the UI elements for the app. Open the MainWindow.xaml file in the root folder of the app and add the following piece of code with the <Grid></Grid>
control section.
<StackPanel Background="Azure">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right">
<Button x:Name="SignInButton" Content="Sign-In" HorizontalAlignment="Right" Padding="5" Click="SignInButton_Click" Margin="5" FontFamily="Segoe Ui"/>
<Button x:Name="SignOutButton" Content="Sign-Out" HorizontalAlignment="Right" Padding="5" Click="SignOutButton_Click" Margin="5" Visibility="Collapsed" FontFamily="Segoe Ui"/>
</StackPanel>
<Label Content="Authentication Result" Margin="0,0,0,-5" FontFamily="Segoe Ui" />
<TextBox x:Name="ResultText" TextWrapping="Wrap" MinHeight="120" Margin="5" FontFamily="Segoe Ui"/>
<Label Content="Token Info" Margin="0,0,0,-5" FontFamily="Segoe Ui" />
<TextBox x:Name="TokenInfoText" TextWrapping="Wrap" MinHeight="70" Margin="5" FontFamily="Segoe Ui"/>
</StackPanel>
This code adds key UI elements. The methods and objects that handle the functionality of the UI elements are defined in the MainWindow.xaml.cs file that we create in the next step.
- A button that signs in the user.
SignInButton_Click
method is called when the user selects this button. - A button that signs out the user.
SignOutButton_Click
method is called when the user selects this button. - A text box that displays the authentication result details after the user attempts to sign in. Information displayed here's returned by the
ResultText
object. - A text box that displays the token details after the user successfully signs in. Information displayed here's returned by the
TokenInfoText
object.
Add code to the MainWindow.xaml.cs file
The MainWindow.xaml.cs file contains the code that provides th runtime logic for the behavior of the UI elements in the MainWindow.xaml file.
Open the MainWindow.xaml.cs file in the root folder of the app.
Add the following code in the file to import the packages, and define placeholders for the methods we create.
using Microsoft.Identity.Client; using System; using System.Linq; using System.Windows; using System.Windows.Interop; namespace sign_in_dotnet_wpf { public partial class MainWindow : Window { string[] scopes = new string[] { }; public MainWindow() { InitializeComponent(); } private async void SignInButton_Click(object sender, RoutedEventArgs e){...} private async void SignOutButton_Click(object sender, RoutedEventArgs e){...} private void DisplayBasicTokenInfo(AuthenticationResult authResult){...} } }
Add the following code to the
SignInButton_Click
method. This method is called when the user selects the Sign-In button.private async void SignInButton_Click(object sender, RoutedEventArgs e) { AuthenticationResult authResult = null; var app = App.PublicClientApp; ResultText.Text = string.Empty; TokenInfoText.Text = string.Empty; IAccount firstAccount; var accounts = await app.GetAccountsAsync(); firstAccount = accounts.FirstOrDefault(); try { authResult = await app.AcquireTokenSilent(scopes, firstAccount) .ExecuteAsync(); } catch (MsalUiRequiredException ex) { try { authResult = await app.AcquireTokenInteractive(scopes) .WithAccount(firstAccount) .WithParentActivityOrWindow(new WindowInteropHelper(this).Handle) .WithPrompt(Prompt.SelectAccount) .ExecuteAsync(); } catch (MsalException msalex) { ResultText.Text = $"Error Acquiring Token:{System.Environment.NewLine}{msalex}"; } catch (Exception ex) { ResultText.Text = $"Error Acquiring Token Silently:{System.Environment.NewLine}{ex}"; return; } if (authResult != null) { ResultText.Text = "Sign in was successful."; DisplayBasicTokenInfo(authResult); this.SignInButton.Visibility = Visibility.Collapsed; this.SignOutButton.Visibility = Visibility.Visible; } } }
GetAccountsAsync()
returns all the available accounts in the user token cache for the app. TheIAccount
interface represents information about a single account.To acquire tokens, the app attempts to acquire the token silently using the
AcquireTokenSilent
method to verify if an acceptable token is in the cache. TheAcquireTokenSilent
method might fail, for example, because the user signed out. When MSAL detects that the issue can be resolved by requiring an interactive action, it throws anMsalUiRequiredException
exception. This exception causes the app to acquire a token interactively.Calling the
AcquireTokenInteractive
method results in a window that prompts users to sign in. Apps usually require users to sign in interactively the first time they need to authenticate. They might also need to sign in when a silent operation to acquire a token. AfterAcquireTokenInteractive
is executed for the first time,AcquireTokenSilent
becomes the usual method to use to obtain tokensAdd the following code to the
SignOutButton_Click
method. This method is called when the user selects the Sign-Out button.private async void SignOutButton_Click(object sender, RoutedEventArgs e) { var accounts = await App.PublicClientApp.GetAccountsAsync(); if (accounts.Any()) { try { await App.PublicClientApp.RemoveAsync(accounts.FirstOrDefault()); this.ResultText.Text = "User has signed-out"; this.TokenInfoText.Text = string.Empty; this.SignInButton.Visibility = Visibility.Visible; this.SignOutButton.Visibility = Visibility.Collapsed; } catch (MsalException ex) { ResultText.Text = $"Error signing-out user: {ex.Message}"; } } }
The
SignOutButton_Click
method clears the cache of all accounts and all corresponding access tokens. The next time the user attempts to sign in, they'll have to do so interactively.Add the following code to the
DisplayBasicTokenInfo
method. This method displays basic information about the token.private void DisplayBasicTokenInfo(AuthenticationResult authResult) { TokenInfoText.Text = ""; if (authResult != null) { TokenInfoText.Text += $"Username: {authResult.Account.Username}" + Environment.NewLine; TokenInfoText.Text += $"{authResult.Account.HomeAccountId}" + Environment.NewLine; } }
Add code to the App.xaml.cs file
App.xaml is where you declare resources that are used across the app. It's the entry point for your app. App.xaml.cs is the code behind file for App.xaml. App.xaml.cs also defines the start window for your application.
Open the App.xaml.cs file in the root folder of the app, then add the following code into it.
using System.Windows;
using System.Reflection;
using Microsoft.Identity.Client;
using Microsoft.Identity.Client.Broker;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration.Json;
namespace sign_in_dotnet_wpf
{
public partial class App : Application
{
static App()
{
CreateApplication();
}
public static void CreateApplication()
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream("sign_in_dotnet_wpf.appsettings.json");
AppConfiguration = new ConfigurationBuilder()
.AddJsonStream(stream)
.Build();
AzureAdConfig azureADConfig = AppConfiguration.GetSection("AzureAd").Get<AzureAdConfig>();
var builder = PublicClientApplicationBuilder.Create(azureADConfig.ClientId)
.WithAuthority(azureADConfig.Authority)
.WithDefaultRedirectUri();
_clientApp = builder.Build();
TokenCacheHelper.EnableSerialization(_clientApp.UserTokenCache);
}
private static IPublicClientApplication _clientApp;
private static IConfiguration AppConfiguration;
public static IPublicClientApplication PublicClientApp { get { return _clientApp; } }
}
}
In this step, you load the appsettings.json file. The configuration builder helps you read the app configs defined in the appsettings.json file. You also define the WPF app as a public client app since it's a desktop app. The TokenCacheHelper.EnableSerialization
method enables the token cache serialization.
Run the app
Run your app and sign in to test the application
In your terminal, navigate to the root folder of your WPF app and run the app by running the command
dotnet run
in your terminal.After you launch the sample, you should see a window with a Sign-In button. Select the Sign-In button.
On the sign-in page, enter your account email address. If you don't have an account, select No account? Create one, which starts the sign-up flow. Follow through this flow to create a new account and sign in.
Once you sign in, you'll see a screen displaying successful sign-in and basic information about your user account stored in the retrieved token. The basic information is displayed in the Token Info section of the sign-in screen