PAYATHON 2026

如何从 OpenID Connect 获取 Azure billing API 的 refresh_token

技术老齐

结论

长期无人值守的后台任务不应依赖某个用户的 refresh_token。更合适的做法是使用 Microsoft Entra ID 的应用身份:

  • 任务运行在 Azure 上时,优先使用 Managed Identity。
  • 任务运行在 Azure 外部时,使用 Service Principal 和证书,通过 OAuth 2.0 client_credentials flow 获取令牌。
  • 只有 API 必须以用户身份调用时,才使用 authorization code flow,并由 MSAL 管理令牌缓存。不要自行提取、持久化或刷新 refresh_token。

如果当前只是排查 .NET OpenID Connect 应用无法取得 refresh_token 的问题,除了请求 offline_access,还要启用 SaveTokens,再从认证会话的 AuthenticationProperties 中读取令牌。不过,这种方式不适合作为长期后台服务的身份方案。

为什么看得到 refresh_token,应用中却取不到

ASP.NET Core 的 OpenID Connect handler 默认不会把所有令牌保存到认证 cookie。即使令牌端点返回了 refresh_token,应用通常也只能在认证事件中短暂访问它。后续调用 HttpContext.GetTokenAsync("refresh_token") 时,可能无法取得该令牌。

要让应用保存并读取 refresh_token,需要满足这些条件:

  • 使用 authorization code flow,例如 ResponseType = "code"。
  • 请求 offline_access scope。
  • 设置 SaveTokens = true。
  • 应用注册允许相应的 delegated permissions。
  • 用户或管理员已完成 consent。
  • 身份提供方确实为该请求签发了 refresh_token。

示例:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

builder.Services
    .AddAuthentication(options =>
    {
        options.DefaultScheme = "Cookies";
        options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
    })
    .AddCookie("Cookies")
    .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
    {
        options.Authority =
            "https://login.microsoftonline.com/{tenant-id}/v2.0";

        options.ClientId = "{client-id}";
        options.ClientSecret = "{client-secret}";

        options.ResponseType = OpenIdConnectResponseType.Code;
        options.UsePkce = true;
        options.SaveTokens = true;

        options.Scope.Add("offline_access");

        // 示例:Azure Resource Manager delegated permission scope
        options.Scope.Add("https://management.azure.com/user_impersonation");
    });

登录完成后,可以读取认证会话中保存的令牌:

string? accessToken =
    await HttpContext.GetTokenAsync("access_token");

string? refreshToken =
    await HttpContext.GetTokenAsync("refresh_token");

也可以在认证事件中访问令牌端点返回的数据:

options.Events = new OpenIdConnectEvents
{
    OnTokenResponseReceived = context =>
    {
        string? refreshToken = context.TokenEndpointResponse?.RefreshToken;

        // 不建议在这里把 refresh_token 以明文写入数据库或日志。
        return Task.CompletedTask;
    }
};

启用 SaveTokens = true 后,令牌通常会进入认证属性,而认证属性可能被序列化到 cookie 中。令牌较大时会让 cookie 膨胀,也会增加敏感凭据的暴露风险。因此,这项配置更适合用于排查和验证问题,不是后台服务存储令牌的首选方式。

推荐方案:使用 Managed Identity

如果后台任务运行在 Azure Functions、App Service、Virtual Machines、Container Apps 或其他支持 Managed Identity 的服务上,可以为计算资源启用 system-assigned 或 user-assigned managed identity。

随后,为该身份分配所需的 Azure RBAC 角色。代码无须保存 client secret、access token 或 refresh_token:

using Azure.Core;
using Azure.Identity;

var credential = new DefaultAzureCredential();

AccessToken token = await credential.GetTokenAsync(
    new TokenRequestContext(
        new[] { "https://management.azure.com/.default" }),
    cancellationToken);

string accessToken = token.Token;

还可以把 credential 直接交给 Azure SDK 客户端,由 SDK 自动获取和更新 access token:

using Azure.Identity;
using Azure.ResourceManager;

var credential = new DefaultAzureCredential();
var armClient = new ArmClient(credential);

await foreach (var subscription in armClient.GetSubscriptions().GetAllAsync())
{
    Console.WriteLine($"{subscription.Data.DisplayName}: {subscription.Data.SubscriptionId}");
}

在 Azure 中,DefaultAzureCredential 可以使用 Managed Identity。在本地开发环境中,它可以使用开发人员已登录的 Azure CLI、Visual Studio 等身份。生产环境仍需明确配置并验证实际使用的凭据来源。

Azure 外部运行:Service Principal 与证书

如果任务不在支持 Managed Identity 的 Azure 环境中,可以创建应用注册和对应的 Service Principal,使用证书执行 client_credentials flow。

使用 Azure.Identity 的示例:

using Azure.Core;
using Azure.Identity;
using System.Security.Cryptography.X509Certificates;

var certificate = new X509Certificate2(
    "/secure/path/application.pfx",
    "{certificate-password}");

var credential = new ClientCertificateCredential(
    "{tenant-id}",
    "{client-id}",
    certificate);

AccessToken token = await credential.GetTokenAsync(
    new TokenRequestContext(
        new[] { "https://management.azure.com/.default" }),
    cancellationToken);

如果只能使用 client secret:

using Azure.Core;
using Azure.Identity;

var credential = new ClientSecretCredential(
    "{tenant-id}",
    "{client-id}",
    "{client-secret}");

AccessToken token = await credential.GetTokenAsync(
    new TokenRequestContext(
        new[] { "https://management.azure.com/.default" }),
    cancellationToken);

生产环境通常更适合使用证书,而不是长期有效的 client secret。证书私钥仍需保存在安全存储中,例如 Azure Key Vault,并制定轮换方案。

client_credentials flow 不会签发 refresh_token。access token 过期前后,服务使用自身凭据重新申请即可。

如何让应用枚举 subscriptions

Service Principal 或 Managed Identity 不会自动继承创建者、管理员或某个用户的订阅权限。必须将 Azure RBAC 角色明确授予该应用身份。

例如,可以在订阅范围内授予 Reader:

az role assignment create \
  --assignee-object-id "<service-principal-object-id>" \
  --assignee-principal-type ServicePrincipal \
  --role "Reader" \
  --scope "/subscriptions/<subscription-id>"

如果需要访问多个订阅,可以采用以下方式:

  • 分别在每个 subscription scope 上授权。
  • 在共同的 management group scope 上授权,让权限向下继承。
  • 根据 API 要求授予权限范围更窄的自定义角色,遵循最小权限原则。

“枚举某个用户能访问的所有订阅”与”枚举某个应用身份能访问的所有订阅”采用不同的权限模型。改用应用身份后,返回的是该 Service Principal 或 Managed Identity 已获授权的订阅,不是某个管理员用户个人可见的全部订阅。

如果业务要求管理员选择允许应用访问的订阅,可以在初始配置阶段让管理员登录并完成 consent 或资源授权,之后的后台任务仍使用应用身份运行。

Billing API 还需要单独授权

能够读取 Azure Resource Manager 中的 subscription,不代表能够读取所有 billing data。Azure Billing、Cost Management 和 Consumption API 可能采用不同的授权范围和角色,例如 subscription、billing account、billing profile 或 invoice section。

应先确认实际调用的是哪类 API:

  • Azure Resource Manager subscription API。
  • Cost Management API。
  • Consumption API。
  • Azure Billing API。
  • Microsoft Graph 或其他资源 API。

确认后,再在对应的资源范围内为应用身份授予所需角色。某些 billing 操作是否支持 application identity,以及支持哪些协议和角色,会因计费协议和 API 而异。如果特定接口只支持 delegated permission,才需要保留用户授权模型。

如果确实必须使用用户身份

当目标 API 只允许 delegated access,或业务语义要求”代表某个用户操作”时,建议使用 Microsoft Authentication Library(MSAL)完成 authorization code flow,并持久化 MSAL token cache,不要自行保存裸 refresh_token。

MSAL 会负责处理:

  • access token 缓存。
  • refresh token 使用。
  • refresh token rotation。
  • token endpoint 调用。
  • 令牌失效和重新认证条件。

概念性配置如下:

using Microsoft.Identity.Web;

builder.Services
    .AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
    .AddMicrosoftIdentityWebApp(
        builder.Configuration.GetSection("AzureAd"))
    .EnableTokenAcquisitionToCallDownstreamApi(
        new[] { "https://management.azure.com/user_impersonation" })
    .AddDistributedTokenCaches();

分布式或数据库 token cache 中的内容必须加密,并限制读取权限。不要将 access token 或 refresh token 写入普通日志、浏览器存储或未加密的数据库字段。

关于”refresh token 最长 90 天”

后台任务不应建立在固定的”90 天 refresh token 有效期”之上。Microsoft Entra ID 的令牌行为还会受到 refresh token rotation、撤销、用户状态、密码变更、Conditional Access、sign-in frequency 和租户策略等因素影响。即使令牌可以续期,也无法保证某个用户的 refresh token 永久有效。

以下事件都可能让无人值守任务突然要求用户重新登录:

  • 用户被禁用或删除。
  • 用户权限被撤销。
  • 管理员撤销 consent。
  • refresh token 被撤销。
  • Conditional Access 要求重新认证或 MFA。
  • 应用注册、凭据或租户策略发生变化。

因此,长期后台作业应使用 Managed Identity 或 Service Principal。只有确实需要 delegated user context 时,才应采用用户登录和 refresh token。

备注:内容仅供参考。