C# 命令行应用使用 MSAL 和 Web Account Manager 机制验证用户身份
记录 C# 控制台应用如何结合 MSAL 与 Web Account Manager 完成交互式登录,并获取访问受保护 API 所需的 Access Token。
工作场景通常需要有身份认证机制保护的 API,客户端在调用这些 API 之前,就需要先进行身份验证,然后使用身份验证得到的 Access Token 去访问这些 API。这篇文章告诉你如何让控制台应用进行交互式身份验证,然后请求受保护的 API。
场景描述 #
见文档 Desktop app that calls a web API on behalf of a signed-in user
在 EntraId 中创建应用注册 #
概念和解释见相关文档
详细步骤如下
- 进入 Azure Portal
- 进入 Microsoft Entra ID
- 左侧导航栏找到 App registrations。可能需要展开 Manage 才能看到,如果还是看不到,需要管理员在 Entra ID Portal 里面配置一下。
- New registration
- 给个名字,剩下全都默认,然后注册即可
- 找到你刚写的 app 名字,点进去
- 记下来 Application (client) ID 和 Directory (tenant) ID,之后在代码中要用
- 展开左边 Manage,点 Authentication
- Add a platform,选 Mobile and desktop applications
- 在 Redirect URIs 里面添加自定义 URL: ms-appx-web://microsoft.aad.brokerplugin/
。这里 client id 就是前面记下来的 Application (client) ID。 - 保存
应用程序 #
新建一个 C# Console App,添加包
<PackageReference Include="Microsoft.Graph" Version="5.55.0" />
<PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.61.2" />编辑 .csproj 文件
- 将
TargetFramework里面的net8.0改成net8.0-windows - 在
PropertyGroup里面增加<AllowUnsafeBlocks>true</AllowUnsafeBlocks>,Interop 获取 Console Window native handle 的时候用
辅助类 Interop
#
这个类用于获取当前窗口(即便是 Console App)的 Windows native 句柄,Web Account Manager 需要用到这个。
using System.Runtime.InteropServices;
namespace OboClient
{
internal static partial class Interop
{
internal enum GetAncestorFlags
{
GetParent = 1,
GetRoot = 2,
GetRootOwner = 3
}
[LibraryImport("user32.dll")]
internal static partial IntPtr GetAncestor(IntPtr hwnd, GetAncestorFlags flags);
[LibraryImport("kernel32.dll")]
internal static partial IntPtr GetConsoleWindow();
internal static IntPtr GetConsoleOrTerminalWindow()
{
IntPtr consoleHandle = GetConsoleWindow();
IntPtr handle = GetAncestor(consoleHandle, GetAncestorFlags.GetRootOwner);
return handle;
}
}
}配置 Client #
ClientId 和 TenantId 就是之前在注册 Application 的时候记下来的两条数据。原则上应该从配置文件里读,这里为了简化示例就硬编码进来了。如果你用的不是 Azure 公有云,而是国家云或者世纪互联什么的,需要调整 AzureCloudInstance.AzurePublic 这个参数。
var scopes = new[] { "User.Read" };
var applicationClientId = "<client id>";
var tenantId = "<tenant id>";
BrokerOptions options = new(BrokerOptions.OperatingSystems.Windows)
{
Title = "<arbitrary application name display to end user>"
};
IPublicClientApplication app = PublicClientApplicationBuilder
.Create(applicationClientId)
.WithAuthority(AzureCloudInstance.AzurePublic, tenantId)
.WithDefaultRedirectUri()
.WithParentActivityOrWindow(Interop.GetConsoleOrTerminalWindow)
.WithBroker(options)
.Build();如果对 Public Client 的概念感兴趣可以看文档 Public client and confidential client applications。
使用 Client 认证用户身份 #
IEnumerable<IAccount> accounts = await app.GetAccountsAsync();
IAccount? existingAccount = accounts.FirstOrDefault();
try
{
if (existingAccount != null)
{
result = await app
.AcquireTokenSilent(scopes, existingAccount)
.ExecuteAsync();
}
else
{
result = await app
.AcquireTokenSilent(scopes, PublicClientApplication.OperatingSystemAccount)
.ExecuteAsync();
}
}
// Can't get a token silently, try with interactive
catch (MsalUiRequiredException)
{
result = await app
.AcquireTokenInteractive(scopes)
.ExecuteAsync();
}result 里面包含 AccessToken 可以用于访问受保护的 API,但是需要注意,这个 Token 是有过期时间限制的,过期之后需要再次获取。所以简单来说,每次你需要用 Token 的时候,都需要走一遍上面的流程。这个流程貌似(我没试验过)会在本地 cache Token,并且检查过期,见文档 Get a token from the token cache using MSAL.NET — Desktop, command-line, and mobile applications。
使用 AccessToken 访问受保护的 API #
这里以 Microsoft Graph RESTful API 为例,其他应用大同小异。
HttpClient httpClient = new()
{
DefaultRequestHeaders =
{
Authorization = new AuthenticationHeaderValue("Bearer", result.AccessToken)
}
};
GraphServiceClient graphServiceClient = new(httpClient);
var user = await graphServiceClient.Me.GetAsync();
Console.WriteLine($"Hello {user?.DisplayName}");参考资料 #
Using MSAL.NET with Web Account Manager (WAM) 注意,截止至成文时这个文档的例子走不通(2024 年 6 月 4 日),已经反馈。