PAYATHON 2026

Azure RateCard Billing API 返回 AuthenticationFailed 403

数据小许

结论

Authorization: Bearer <access_token> 这种写法没有问题。更可能的原因是:令牌属于 Azure AD 应用对应的服务主体,而 subscription 权限授予了你的个人账户。

使用 client_credentials 获取令牌时,Azure 不会继承当前登录用户的 Owner、Contributor 等权限。你需要将应用对应的服务主体单独添加到目标 subscription 的 Azure RBAC,并授予包含 Microsoft.Commerce/RateCard/read 操作的角色。排查时,可以先在 subscription 范围授予 Reader 角色。

直接在浏览器地址栏打开 RateCard URL 时,浏览器不会自动附带 Bearer Token。此时返回“Authorization-Header 缺失”是正常的,不能说明 Java 请求也没有发送该请求头。

需要检查的权限

在目标 subscription 的“访问控制(IAM)”中,为应用注册对应的 Enterprise Application / Service Principal 添加角色:

  • 范围:目标 subscription
  • 成员:调用接口的应用服务主体,而不是开发者的个人账户
  • 角色:可以先用 Reader 验证;如果使用自定义角色,至少需要:
{
  "actions": [
    "Microsoft.Commerce/RateCard/read"
  ]
}

角色刚刚授予时,Azure RBAC 可能需要一段时间才能完成传播。请重新获取 AccessToken 后再测试,不要继续复用授权前取得的令牌。

同时检查 subscription 是否注册了相应的资源提供程序。可以在 subscription 的 Resource providers 页面查看 Microsoft.Commerce。如果租户策略允许,且它尚未注册,可以进行注册。

AccessToken 应满足的条件

当前代码使用 OAuth 2.0 v1 token endpoint:

https://login.microsoftonline.com/{tenantId}/oauth2/token

对应的资源参数是:

resource=https://management.azure.com/

这是调用 Azure Resource Manager API 所需的 audience。解码 JWT 后,重点检查以下字段:

  • aud 是否为 https://management.azure.com/
  • tid 是否为 subscription 所属租户
  • appid 或 azp 是否对应预期的应用
  • exp 是否仍在有效期内

不要把 AccessToken、client secret 或完整 JWT 写入日志。JWT 即使只在有效期内泄露,也可能被直接用来调用接口。

改进 AccessToken 请求

表单中的每个值都应分别进行 URL 编码,不要只在 main 方法中预先编码 clientSecret。否则,secret 中的 +、&、=、% 等字符可能改变表单含义,后续重构时也容易出现重复编码。

import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

private static String formEncode(String value) {
    return URLEncoder.encode(value, StandardCharsets.UTF_8);
}

public static String getAccessToken(
        String tenantId,
        String clientId,
        String clientSecret) throws Exception {

    String endpoint =
            "https://login.microsoftonline.com/"
            + formEncode(tenantId)
            + "/oauth2/token";

    String postBody =
            "grant_type=" + formEncode("client_credentials")
            + "&client_id=" + formEncode(clientId)
            + "&client_secret=" + formEncode(clientSecret)
            + "&resource=" + formEncode("https://management.azure.com/");

    byte[] body = postBody.getBytes(StandardCharsets.UTF_8);

    HttpURLConnection conn =
            (HttpURLConnection) new URL(endpoint).openConnection();

    conn.setRequestMethod("POST");
    conn.setRequestProperty(
            "Content-Type",
            "application/x-www-form-urlencoded; charset=UTF-8");
    conn.setRequestProperty("Accept", "application/json");
    conn.setDoOutput(true);
    conn.setFixedLengthStreamingMode(body.length);

    conn.getOutputStream().write(body);

    int status = conn.getResponseCode();
    InputStream stream =
            status >= 200 && status < 300
                    ? conn.getInputStream()
                    : conn.getErrorStream();

    String response = new String(
            stream.readAllBytes(),
            StandardCharsets.UTF_8);

    if (status < 200 || status >= 300) {
        throw new IOException(
                "Token request failed, HTTP " + status + ": " + response);
    }

    JsonFactory factory = new JsonFactory();
    try (JsonParser parser = factory.createParser(response)) {
        while (parser.nextToken() != null) {
            if (parser.currentToken() == JsonToken.FIELD_NAME
                    && "access_token".equals(parser.currentName())) {
                parser.nextToken();
                return parser.getValueAsString();
            }
        }
    }

    throw new IOException("Token response does not contain access_token");
}

调用该方法时传入原始 secret,不要提前编码:

String accessToken =
        getAccessToken(tenantId, clientId, clientSecret);

正确构造 RateCard 请求

使用 setRequestProperty 明确设置请求头,并在响应不是 2xx 时读取 getErrorStream()。原代码直接调用 getInputStream(),遇到 403 会直接抛出异常,Azure 返回的详细错误信息也可能因此被忽略。

import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.nio.charset.StandardCharsets;

public static String getRateCard(
        String subscriptionId,
        String apiVersion,
        String offerId,
        String currency,
        String locale,
        String region,
        String accessToken) throws Exception {

    String filter = String.format(
            "OfferDurableId eq '%s' and Currency eq '%s'"
            + " and Locale eq '%s' and RegionInfo eq '%s'",
            offerId, currency, locale, region);

    String endpoint =
            "https://management.azure.com/subscriptions/"
            + subscriptionId
            + "/providers/Microsoft.Commerce/RateCard"
            + "?api-version="
            + URLEncoder.encode(apiVersion, StandardCharsets.UTF_8)
            + "&$filter="
            + URLEncoder.encode(filter, StandardCharsets.UTF_8)
                    .replace("+", "%20");

    HttpURLConnection conn =
            (HttpURLConnection) URI.create(endpoint).toURL().openConnection();

    conn.setRequestMethod("GET");
    conn.setRequestProperty(
            "Authorization",
            "Bearer " + accessToken);
    conn.setRequestProperty("Accept", "application/json");

    int status = conn.getResponseCode();
    InputStream stream =
            status >= 200 && status < 300
                    ? conn.getInputStream()
                    : conn.getErrorStream();

    String response = stream == null
            ? ""
            : new String(stream.readAllBytes(), StandardCharsets.UTF_8);

    if (status < 200 || status >= 300) {
        throw new IOException(
                "RateCard request failed, HTTP "
                + status
                + ": "
                + response);
    }

    return response;
}

GET 请求没有 JSON 请求体,因此不必设置 Content-Type: application/json。这里使用 Accept: application/json 更准确,它表示客户端希望服务器返回 JSON。

排查顺序

可以依次检查:

  1. 确认 AccessToken 不为空,并去掉首尾空白字符。
  2. 解码 JWT,检查 aud、tid、appid/azp 和 exp。
  3. 确认 tenantId 是目标 subscription 所属的租户。
  4. 在 subscription 的 IAM 中查找应用的服务主体,而不是个人用户。
  5. 确认该服务主体拥有 Reader,或拥有包含 Microsoft.Commerce/RateCard/read 的自定义角色。
  6. 检查 Microsoft.Commerce 是否已经注册。
  7. 重新获取令牌,再次调用接口。
  8. 读取 403 响应体,以及 x-ms-request-id、x-ms-correlation-request-id 等响应头,用于进一步诊断。

还可以使用 curl,判断问题是否出在 Java 请求的构造方式上:

curl --request GET \
  --header "Authorization: Bearer ACCESS_TOKEN" \
  --header "Accept: application/json" \
  "https://management.azure.com/subscriptions/SUBSCRIPTION_ID/providers/Microsoft.Commerce/RateCard?api-version=2015-06-01-preview&%24filter=OfferDurableId%20eq%20%27MS-AZR-0017P%27%20and%20Currency%20eq%20%27EUR%27%20and%20Locale%20eq%20%27de-DE%27%20and%20RegionInfo%20eq%20%27DE%27"

如果 curl 也返回 403,问题基本在令牌、租户或服务主体的 RBAC 授权,而不是 HttpURLConnection。

还需注意 RateCard API 的适用范围

Microsoft.Commerce/RateCard 和 2015-06-01-preview 都是较旧的计费接口。即使认证成功,具体的 offer、订阅类型或计费协议也不一定受该接口支持。OfferDurableId、货币、区域和区域设置必须与该 subscription 的实际报价信息一致。

如果权限检查通过后,接口返回 offer、计费账户或请求参数相关错误,应根据当前订阅类型选择适用的 Cost Management、Consumption、Pricesheet 或 Retail Prices API。Retail Prices API 可用于查询公开零售价,但它不代表某个客户合同下的实际结算价格。

备注:内容仅供参考。