如何在 Web app 服务端配置 AAD app 调用 Azure Billing APIs?
结论
服务端无人值守调用不应使用 Native application / Public client。应在 Microsoft Entra ID 中使用 Web/API 类型应用(Confidential client),并通过 client_id + client_secret 或证书获取 application token。
整个配置涉及两层授权:
- Microsoft Entra ID 签发用于访问 Azure Resource Manager 的 token。
- 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 管理中心中的名称 | 含义 |
|---|---|---|
ClientId | Application (client) ID | 应用注册的 ID |
DirectoryId、TenantId | Directory (tenant) ID | 应用所属租户的 ID |
ClientSecret、AppKey | Client secret 的 Value | 客户端密码,不是 Secret ID |
SubscriptionId | Azure subscription ID | 要查询费用的 Azure 订阅 |
Authority | https://login.microsoftonline.com/{tenantId} | 登录机构 |
Resource | https://management.azure.com/ | 旧版 ADAL 获取 token 时使用的资源 |
Scope | https://management.azure.com/.default | MSAL 客户端凭据流程使用的 scope |
SubscriptionId 不是 AAD/Entra 应用 ID,也不是 tenant ID。
client secret 也很容易配错。创建 secret 后,必须保存页面上只显示一次的 Value,不能把 Secret ID 当作密码。
创建服务端身份
在 Microsoft Entra ID 中创建应用注册:
-
打开 App registrations。
-
创建单租户应用,除非业务确实需要跨租户访问。
-
记录以下信息:
- Application (client) ID
- Directory (tenant) ID
-
在 Certificates & secrets 中创建 client secret,或配置证书。
-
生产环境优先使用证书。如果 Web app 运行在 Azure 中,可以优先考虑 Managed Identity,避免保存 client secret。
旧 portal 中的 “Web app / API” 指能够安全保存凭据的 confidential client。现代应用注册界面未必继续使用相同的分类名称。配置时应关闭 public client 用法,并采用客户端凭据流程。
服务端场景不需要用户交互登录,也不需要 redirect URI。
授予订阅权限
应用注册完成后,还要将对应的 service principal 添加到 Azure 订阅的访问控制中:
- 打开目标 Subscription。
- 进入 Access control (IAM)。
- 添加角色分配。
- 将角色授予刚创建的应用/service principal。
读取传统 Usage Aggregates 和 Rate Card 时,通常至少需要订阅范围的 Reader 权限。使用当前的 Cost Management API 时,应优先选择更符合最小权限原则的计费角色,例如:
Cost Management ReaderBilling 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 更容易得到一致的结果。
备注:内容仅供参考。
