PAYATHON 2026

如何在 Web app 服务端配置 AAD app 调用 Azure Billing APIs?

支付小周

结论

服务端无人值守调用不应使用 Native application / Public client。应在 Microsoft Entra ID 中使用 Web/API 类型应用(Confidential client),并通过 client_id + client_secret 或证书获取 application token。

整个配置涉及两层授权:

  1. Microsoft Entra ID 签发用于访问 Azure Resource Manager 的 token。
  2. Azure RBAC 授权该 service principal 读取指定订阅的用量和费用数据。

成功获取 OAuth token,只能说明身份认证完成,不代表应用有权访问订阅。常见的 401 Unauthorized 通常与 token 的 audience、tenant 或认证流程有关。token 正确但缺少订阅权限时,通常会收到 403 Forbidden。

问题中的 Microsoft.Commerce/UsageAggregates 和 Rate Card 是较早的 Commerce API。新项目更适合使用 Azure Cost Management API。旧接口即使还能调用,返回结果也不一定与 Azure portal 当前显示的费用完全一致。

配置项如何对应

不同版本的 Azure portal、ADAL 示例和文档使用的名称有所不同,对应关系如下:

示例配置名Microsoft Entra 管理中心中的名称含义
ClientIdApplication (client) ID应用注册的 ID
DirectoryId、TenantIdDirectory (tenant) ID应用所属租户的 ID
ClientSecret、AppKeyClient secret 的 Value客户端密码,不是 Secret ID
SubscriptionIdAzure subscription ID要查询费用的 Azure 订阅
Authorityhttps://login.microsoftonline.com/{tenantId}登录机构
Resourcehttps://management.azure.com/旧版 ADAL 获取 token 时使用的资源
Scopehttps://management.azure.com/.defaultMSAL 客户端凭据流程使用的 scope

SubscriptionId 不是 AAD/Entra 应用 ID,也不是 tenant ID。

client secret 也很容易配错。创建 secret 后,必须保存页面上只显示一次的 Value,不能把 Secret ID 当作密码。

创建服务端身份

在 Microsoft Entra ID 中创建应用注册:

  1. 打开 App registrations。

  2. 创建单租户应用,除非业务确实需要跨租户访问。

  3. 记录以下信息:

    • Application (client) ID
    • Directory (tenant) ID
  4. 在 Certificates & secrets 中创建 client secret,或配置证书。

  5. 生产环境优先使用证书。如果 Web app 运行在 Azure 中,可以优先考虑 Managed Identity,避免保存 client secret。

旧 portal 中的 “Web app / API” 指能够安全保存凭据的 confidential client。现代应用注册界面未必继续使用相同的分类名称。配置时应关闭 public client 用法,并采用客户端凭据流程。

服务端场景不需要用户交互登录,也不需要 redirect URI。

授予订阅权限

应用注册完成后,还要将对应的 service principal 添加到 Azure 订阅的访问控制中:

  1. 打开目标 Subscription。
  2. 进入 Access control (IAM)。
  3. 添加角色分配。
  4. 将角色授予刚创建的应用/service principal。

读取传统 Usage Aggregates 和 Rate Card 时,通常至少需要订阅范围的 Reader 权限。使用当前的 Cost Management API 时,应优先选择更符合最小权限原则的计费角色,例如:

  • Cost Management Reader
  • Billing Reader

具体角色取决于查询范围和计费协议。subscription scope、billing account scope 和 billing profile scope 所需的角色并不完全相同。只在 Entra ID 的 API permissions 页面添加权限,不能替代 Azure subscription 或 billing scope 上的 RBAC 角色分配。

旧示例中的 Windows Azure Service Management API 是历史名称。调用 management.azure.com 时,应用能否读取订阅资源,取决于 Azure Resource Manager token 和 Azure RBAC,而不是某个名为 “Billing management” 的普通 delegated permission。

角色分配可能需要一段时间才能生效。刚完成授权后的一次调用失败,不一定说明配置有误。

使用客户端凭据获取 token

使用 MSAL.NET 时,服务端可以通过以下代码获取 token:

using Microsoft.Identity.Client;
using System.Net.Http.Headers;

string tenantId = configuration["AzureAd:TenantId"];
string clientId = configuration["AzureAd:ClientId"];
string clientSecret = configuration["AzureAd:ClientSecret"];
string subscriptionId = configuration["Azure:SubscriptionId"];

IConfidentialClientApplication app =
    ConfidentialClientApplicationBuilder
        .Create(clientId)
        .WithAuthority($"https://login.microsoftonline.com/{tenantId}")
        .WithClientSecret(clientSecret)
        .Build();

AuthenticationResult authResult = await app
    .AcquireTokenForClient(new[]
    {
        "https://management.azure.com/.default"
    })
    .ExecuteAsync();

using HttpClient httpClient = new HttpClient();
httpClient.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", authResult.AccessToken);

客户端凭据流程不应调用交互式登录,也不要通过用户名和密码模拟普通用户。

调用传统 Usage Aggregates API

如果当前订阅和环境仍支持这个旧接口,可以按以下方式发起请求:

string start = Uri.EscapeDataString("2018-06-07T00:00:00Z");
string end = Uri.EscapeDataString("2018-06-08T00:00:00Z");

string requestUri =
    $"https://management.azure.com/subscriptions/{subscriptionId}" +
    "/providers/Microsoft.Commerce/usageAggregates" +
    $"?api-version=2015-06-01-preview" +
    $"&reportedStartTime={start}" +
    $"&reportedEndTime={end}" +
    "&aggregationGranularity=Daily" +
    "&showDetails=true";

using HttpResponseMessage response = await httpClient.GetAsync(requestUri);
string responseBody = await response.Content.ReadAsStringAsync();

if (!response.IsSuccessStatusCode)
{
    throw new InvalidOperationException(
        $"Azure returned {(int)response.StatusCode} " +
        $"{response.ReasonPhrase}: {responseBody}");
}

这里的 API version 只是旧示例中的常见版本,不能据此判断所有订阅目前仍支持该接口。开发新实现时,应根据目标云环境和 Azure 当前文档确认可用版本。

如果响应包含 nextLink 或 continuation token,必须继续请求后续页面,否则汇总金额可能遗漏部分记录。

Rate Card 的作用和限制

传统 Usage Aggregates 返回用量,Rate Card 返回价格信息。简单的费用计算通常如下:

estimatedCost = Σ(usageQuantity × applicableRate)

实际计费不一定等于单价乘以用量。阶梯价格、免费额度、货币、税费、预留实例、Savings Plan、企业协议折扣、Azure Marketplace、信用额度、舍入规则和账期调整,都可能导致计算结果与 portal 不一致。

因此,Usage Aggregates 和 Rate Card 的组合更适合估算历史用量,不能保证结果与 portal 账单严格一致。

新项目建议使用 Cost Management Query API

如果需要在内部 dashboard 中展示订阅每天的实际费用或摊销费用,应优先查询 Cost Management,不要自行组合 usage 和 rate card。

典型请求如下:

POST https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.CostManagement/query?api-version={supported-api-version}
Authorization: Bearer {access-token}
Content-Type: application/json

示例请求体:

{
  "type": "ActualCost",
  "timeframe": "Custom",
  "timePeriod": {
    "from": "2018-06-07T00:00:00Z",
    "to": "2018-06-08T00:00:00Z"
  },
  "dataset": {
    "granularity": "Daily",
    "aggregation": {
      "totalCost": {
        "name": "Cost",
        "function": "Sum"
      }
    },
    "grouping": [
      {
        "type": "Dimension",
        "name": "Currency"
      }
    ]
  }
}

C# 调用示例:

using System.Text;
using System.Text.Json;

var requestBody = new
{
    type = "ActualCost",
    timeframe = "Custom",
    timePeriod = new
    {
        from = "2018-06-07T00:00:00Z",
        to = "2018-06-08T00:00:00Z"
    },
    dataset = new
    {
        granularity = "Daily",
        aggregation = new
        {
            totalCost = new
            {
                name = "Cost",
                function = "Sum"
            }
        }
    }
};

string json = JsonSerializer.Serialize(requestBody);

using var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
request.Headers.Authorization =
    new AuthenticationHeaderValue("Bearer", authResult.AccessToken);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");

using HttpResponseMessage response = await httpClient.SendAsync(request);
string responseBody = await response.Content.ReadAsStringAsync();

if (!response.IsSuccessStatusCode)
{
    throw new InvalidOperationException(
        $"Azure returned {(int)response.StatusCode}: {responseBody}");
}

api-version 应从当前 Azure Cost Management REST 文档中选择受支持的版本,不要沿用旧 Commerce API 的版本号。

排查 401 的顺序

1. 检查 token 的 audience

解码 access token 以检查 claims,但不要记录或公开完整 token。目标 audience 应为 Azure Resource Manager:

{
  "aud": "https://management.azure.com/"
}

如果 aud 指向 Microsoft Graph、旧 Service Management endpoint 或应用自身的 client ID,该 token 就不能用于上述 REST endpoint。

2. 检查 tenant

token 的 tid 应与目标订阅所属的 Microsoft Entra tenant 一致。应用注册在某个 tenant 中,不代表它能自动访问其他 tenant 的订阅。

3. 检查认证流程

服务端应使用:

AcquireTokenForClient(...)

不要使用需要用户登录的 delegated flow。客户端凭据 token 通常包含 roles claim,而不是表示用户权限的 scp claim。

4. 检查 RBAC

确认角色分配的主体是应用对应的 Enterprise application/service principal,授权范围是目标 subscription 或正确的 billing scope。

错误响应通常可以帮助区分问题:

  • 401:token 无效、audience 错误、token 过期、tenant 错误,或 Authorization header 格式错误。
  • 403:token 已被接受,但 service principal 缺少对应作用域的权限。
  • 404:API version、provider、资源范围或旧接口支持情况可能不匹配。

5. 检查请求头

请求头必须采用以下格式:

Authorization: Bearer eyJ...

不要添加引号,不要把 client secret 当成 bearer token,也不要将 ID token 用作 API access token。

6. 检查订阅和 provider

URL 中必须使用真正的 subscription ID:

/subscriptions/{subscriptionId}/...

同时确认该身份能够看到目标订阅,并且请求使用的 resource provider 和 API version 对这个订阅有效。

与 Azure portal 数据对齐时的注意事项

即使认证和授权都正确,dashboard 与 portal 的数据仍可能暂时不同:

  • 用量和成本数据存在入账延迟。
  • “实际成本”和“摊销成本”采用不同口径。
  • portal 可能使用本地时区,而 API 查询使用 UTC。
  • 查询结束时间通常应按半开区间处理,避免重复统计相邻日期。
  • portal 中的筛选条件、货币和计费范围必须与 API 请求一致。
  • 当天数据通常尚未最终结算,后续可能调整。

如果界面要显示“某日预计账单”,应明确标注币种、时间范围、成本类型和数据更新时间。新系统使用 Cost Management Query API,并采用与 portal 相同的 scope、日期、筛选条件和成本口径,通常比组合旧版 Usage Aggregates 与 Rate Card 更容易得到一致的结果。

备注:内容仅供参考。