C# 命令行应用使用 MSAL 和 Web Account Manager 机制验证用户身份

工作场景通常需要有身份认证机制保护的 API,客户端在调用这些 API 之前,就需要先进行身份验证,然后使用身份验证得到的 Access Token 去访问这些 API。这篇文章告诉你如何让控制台应用进行交互式身份验证,然后请求受保护的 API。

场景描述

见文档 Desktop app that calls a web API on behalf of a signed-in user

在 EntraId 中创建应用注册

概念和解释见相关文档

  1. Register applications
  2. Quickstart: Register an application with the Microsoft identity platform

详细步骤如下

  1. 进入 Azure Portal
  2. 进入 Microsoft Entra ID
  3. 左侧导航栏找到 App registrations。可能需要展开 Manage 才能看到,如果还是看不到,需要管理员在 Entra ID Portal 里面配置一下。
  4. New registration
  5. 给个名字,剩下全都默认,然后注册即可
  6. 找到你刚写的 app 名字,点进去
  7. 记下来 Application (client) ID 和 Directory (tenant) ID,之后在代码中要用
  8. 展开左边 Manage,点 Authentication
  9. Add a platform,选 Mobile and desktop applications
  10. 在 Redirect URIs 里面添加自定义 URL: ms-appx-web://microsoft.aad.brokerplugin/。这里 client id 就是前面记下来的 Application (client) ID。
  11. 保存

应用程序

新建一个 C# Console App,添加包

<PackageReference Include="Microsoft.Graph" Version="5.55.0" />
<PackageReference Include="Microsoft.Identity.Client.Broker" Version="4.61.2" />

编辑 .csproj 文件

  1. TargetFramework 里面的 net8.0 改成 net8.0-windows
  2. 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 日),已经反馈。