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。
排查顺序
可以依次检查:
- 确认 AccessToken 不为空,并去掉首尾空白字符。
- 解码 JWT,检查
aud、tid、appid/azp和exp。 - 确认
tenantId是目标 subscription 所属的租户。 - 在 subscription 的 IAM 中查找应用的服务主体,而不是个人用户。
- 确认该服务主体拥有
Reader,或拥有包含Microsoft.Commerce/RateCard/read的自定义角色。 - 检查
Microsoft.Commerce是否已经注册。 - 重新获取令牌,再次调用接口。
- 读取 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 可用于查询公开零售价,但它不代表某个客户合同下的实际结算价格。
备注:内容仅供参考。
