告别静态密钥:tokenex实现Azure动态短期凭证交换

admin 2026-03-03 04:36:50 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 这篇文章介绍开源Go库tokenex,用于实现Azure无密钥访问。通过外部身份提供商的ID令牌交换短期云凭证,消除静态密钥存储风险。文章详述无密钥模型的安全优势,包括缩小爆炸半径、降低凭证泄露价值等,并提供完整的Azure配置步骤和Go应用代码示例,展示端到端的联合身份验证流程。 综合评分: 82 文章分类: 云安全,安全工具,安全建设,安全开发


cover_image

告别静态密钥:tokenex实现Azure动态短期凭证交换

Dubito Dubito

云原生安全指北

2026年2月27日 08:36 美国

注:本文翻译自 Riptides 的文章《Secretless Azure access with tokenex: Federated Identity via User-Assigned Managed Identity》[1],可点击文末“阅读原文”按钮查看英文原文。

全文如下:

一、引言

非人类身份(服务、代理、CI/CD 流水线、工作负载等)已成为现代云系统中的主要参与者。然而,许多系统仍然依赖:

  • • 存储在 CI 系统中的客户端密钥(secrets)
  • • 长期有效的服务主体凭证
  • • 手动轮换的密钥

这种方式不仅运维成本高,而且安全性脆弱。

tokenex[2] 是一个开源的 Go 语言库,它简化了从各种提供商(providers)获取短期凭证的过程。无需嵌入长期有效的密钥,或与特定云厂商的 SDK 认证流程紧密耦合,tokenex[2] 允许你将来自外部身份提供商的身份令牌(identity tokens)交换为短期有效的云原生访问令牌。

换句话说:

你的工作负载使用外部身份提供商来证明 它自己是谁。tokenex[2] 将该身份交换为原生的短期云凭证。随后,目标平台(无论是 Azure、AWS、GCP、OCI,还是其他任何受支持的提供商)会使用其自身的身份原语(如托管身份(Managed Identity)、IAM 角色、服务账户等)及其原生的授权模型(如 RBAC、IAM 策略、资源策略等)来决定该工作负载可以执行什么操作。

这使得 tokenex[2] 成为现代零信任、联邦化、多云环境的理想选择。

二、为什么“无密钥(secretless)”很重要

2.1 “无密钥”到底意味着什么?

“无密钥(secretless)”并不意味着不涉及任何凭证。它意味着:

  • • 没有长期有效的客户端密钥(secrets)
  • • 没有存储的访问密钥、API 密钥、令牌等
  • • 没有写入磁盘的凭证
  • • 没有包含secrets的静态环境变量

取而代之的是,凭证是基于身份动态派生出来的,具有短期有效性,并且仅在所需的最短时间内存在于内存中

2.2 安全优势

传统方法通常依赖:

  • • 存储在 CI/CD 系统中的服务主体密钥
  • • 嵌入到容器镜像中的静态云访问密钥
  • • 写入配置文件的凭证
  • • 作为环境变量注入的长期有效令牌

这些都成为了高价值目标。

如果零日漏洞、依赖项被篡改或供应链攻击,导致工作负载内可执行任意代码,攻击者的第一步几乎总是:

在文件系统和环境中搜索凭证。

如果密钥(secrets)以静态方式存储在文件、配置或环境变量中,它们就可能被窃取并在其他地方被重用。

在无密钥模型下:

  • • 磁盘上不存在静态的云凭证
  • • 工作负载中没有嵌入可重用的长期有效密钥
  • • 访问令牌是短期且受限的
  • • 凭证是即时交换的
  • • 令牌会快速过期,无法无限期重用

即使攻击者获得了运行时执行权限,也无法提取和窃取持久的密钥(secrets)以进行长期滥用。

2.3 为什么短期凭证能改变威胁模型

短期凭证极大地缩小了爆炸半径:

  • • 令牌自动过期
  • • 被窃取的凭证价值迅速降低
  • • 重放攻击的时间窗口很窄
  • • 事件发生后无需轮换静态密钥

这在以下场景中尤其重要:

  • • 零日漏洞利用
  • • 依赖项混淆攻击
  • • 恶意容器基础镜像
  • • CI/CD 流水线被入侵

2.4 通过临时性实现安全

这种模式结合了:

  • • 外部身份确立
  • • 令牌交换
  • • 短期有效的原生云凭证
  • • 无存储的密钥(secrets)

它创建了一种机制:认证是动态的,授权由云平台原生执行,且不会留下可重用的凭证工件。

这不仅仅是运维上的改进;更是安全态势的根本性转变。无密钥无关乎便利性,其核心在于消除作为攻击面的凭证持久化问题。

三、使用 tokenex[2] 的 Go 示例应用

在接下来的部分,我们将通过一个具体示例进行讲解:一个简单的 Go 应用程序,它使用 tokenex[2] 来获取 Azure 访问令牌,然后使用该令牌调用 Azure API 进行身份验证。

为此,我们将:

  • • 配置 Azure,使其信任一个使用联合凭证的外部身份
  • • 将该信任绑定到一个**用户分配的托管身份(User-Assigned Managed Identity, UAMI)**上
  • • 使用 tokenex 的 Azure 凭证提供程序将外部 ID 令牌交换为 Azure 访问令牌
  • • 在 Go 应用程序中使用返回的访问令牌来认证并调用 Azure API

目标是演示一个完整的、端到端的无密钥流程,其中:

  • • 身份由外部系统确立
  • • 凭证被动态交换
  • • 授权由 Azure RBAC 执行

通过这个示例,你将获得一个最小化但具有生产参考价值的案例,展示如何在应用中不存储 Azure 密钥的情况下,安全地调用 Azure API。

3.1 Azure 配置:使用用户分配的托管身份(UAMI)的联合身份

以下是启用联合身份所需的 Azure 配置步骤。

1️⃣ 创建用户分配的托管身份

az identity create --name demo-uami --resource-group demo-rg --location <location>

记录输出的值:

  • • clientId
  • • principalId
  • • id

2️⃣ 为托管身份分配角色

授予该身份访问 demo-rg 资源组中资源的权限。

az role assignment create --assignee <principalId> --role Reader --scope /subscriptions/<subscription-id>/resourceGroups/demo-rg

请根据你的演示需要,调整角色和范围。

3️⃣ 创建联合身份凭证

现在配置 Azure 以信任你的外部身份提供商所颁发的 ID 令牌。

az identity federated-credential create --name demo-fic --identity-name demo-uami --resource-group demo-rg --issuer https://your-idp.example.com --subject <your-subject-claim> --audience api://AzureADTokenExchange

重要字段说明:

  • • issuer → 必须与你的 ID 令牌中的 iss 声明相匹配
  • • subject → 必须与你的 ID 令牌中的 sub 声明相匹配
  • • audience → 必须为 api://AzureADTokenExchange

配置完成后,Azure 将接受来自你外部 IdP 的有效 ID 令牌,并将其交换为针对该用户分配的托管身份的 Azure 访问令牌。

3.2 示例应用

package&nbsp;main

import&nbsp;(
&nbsp; &nbsp; "context"
&nbsp; &nbsp; "log/slog"
&nbsp; &nbsp; "os"
&nbsp; &nbsp; "os/signal"
&nbsp; &nbsp; "sync"
&nbsp; &nbsp; "syscall"
&nbsp; &nbsp; "time"

&nbsp; &nbsp; "github.com/Azure/azure-sdk-for-go/sdk/azcore"
&nbsp; &nbsp; "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
&nbsp; &nbsp; "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
&nbsp; &nbsp; "github.com/go-logr/logr"

&nbsp; &nbsp; "go.riptides.io/tokenex/pkg/azure"
&nbsp; &nbsp; "go.riptides.io/tokenex/pkg/credential"
&nbsp; &nbsp; "go.riptides.io/tokenex/pkg/token"
)

// accessTokenStore 是一个线程安全的 Azure 访问令牌存储器。
type&nbsp;accessTokenStore&nbsp;struct&nbsp;{
&nbsp; &nbsp; azcore.TokenCredential

&nbsp; &nbsp; mu &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;sync.RWMutex
&nbsp; &nbsp; accessToken azcore.AccessToken
}

func&nbsp;(s *accessTokenStore)&nbsp;Set(token *credential.Oauth2Creds) {
&nbsp; &nbsp; s.mu.Lock()
&nbsp; &nbsp; defer&nbsp;s.mu.Unlock()

&nbsp; &nbsp; s.accessToken.Token = token.AccessToken
&nbsp; &nbsp; s.accessToken.ExpiresOn = token.Expiry
}

func&nbsp;(s *accessTokenStore)&nbsp;GetToken(ctx context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken,&nbsp;error) {
&nbsp; &nbsp; s.mu.RLock()
&nbsp; &nbsp; defer&nbsp;s.mu.RUnlock()

&nbsp; &nbsp; return&nbsp;s.accessToken,&nbsp;nil
}

func&nbsp;main()&nbsp;{
&nbsp; &nbsp; ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
&nbsp; &nbsp; defer&nbsp;stop()&nbsp;// clean up signal handler

&nbsp; &nbsp; logger := logr.FromSlogHandler(slog.Default().Handler())
&nbsp; &nbsp; logger.Info("Press Ctrl+C to stop...")

&nbsp; &nbsp; // 初始化凭证提供程序,以接收Azure凭证
&nbsp; &nbsp; credProvider, err := azure.NewCredentialsProvider(ctx, logger)
&nbsp; &nbsp; if&nbsp;err !=&nbsp;nil&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; logger.Error(err,&nbsp;"failed to create Azure credentials provider")

&nbsp; &nbsp; &nbsp; &nbsp; return
&nbsp; &nbsp; }

// staticIDTokenProvider 实现 token.IdentityTokenProvider 接口。
type&nbsp;staticIDTokenProvider&nbsp;struct&nbsp;{
&nbsp; &nbsp; token&nbsp;string
}

func&nbsp;(p *staticIDTokenProvider)&nbsp;GetIdentityToken(ctx context.Context) (string,&nbsp;error) {
&nbsp; &nbsp; return&nbsp;p.token,&nbsp;nil
}

func&nbsp;main()&nbsp;{
&nbsp; &nbsp; ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
&nbsp; &nbsp; defer&nbsp;stop()

&nbsp; &nbsp; // 从环境变量读取配置
&nbsp; &nbsp; subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID")
&nbsp; &nbsp; if&nbsp;subscriptionID ==&nbsp;""&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; log.Fatal("AZURE_SUBSCRIPTION_ID 环境变量未设置")
&nbsp; &nbsp; }
&nbsp; &nbsp; clientID := os.Getenv("AZURE_CLIENT_ID")
&nbsp; &nbsp; if&nbsp;clientID ==&nbsp;""&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; log.Fatal("AZURE_CLIENT_ID 环境变量未设置")
&nbsp; &nbsp; }
&nbsp; &nbsp; tenantID := os.Getenv("AZURE_TENANT_ID")
&nbsp; &nbsp; if&nbsp;tenantID ==&nbsp;""&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; log.Fatal("AZURE_TENANT_ID 环境变量未设置")
&nbsp; &nbsp; }
&nbsp; &nbsp; resourceGroupName := os.Getenv("AZURE_RESOURCE_GROUP_NAME")
&nbsp; &nbsp; if&nbsp;resourceGroupName ==&nbsp;""&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; log.Fatal("AZURE_RESOURCE_GROUP_NAME 环境变量未设置")
&nbsp; &nbsp; }
&nbsp; &nbsp; idTokenJWT := os.Getenv("ID_TOKEN_JWT")
&nbsp; &nbsp; if&nbsp;idTokenJWT ==&nbsp;""&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; log.Fatal("ID_TOKEN_JWT 环境变量未设置")
&nbsp; &nbsp; }

&nbsp; &nbsp; // 初始化令牌存储器
&nbsp; &nbsp; tokenStore := &accessTokenStore{}

&nbsp; &nbsp; // 底层实现中,凭证提供程序使用 Microsoft Entra ID 工作负载联合身份
&nbsp; &nbsp; // 从 Microsoft Entra ID 服务获取用户主体会话令牌
&nbsp; &nbsp; // 凭证提供程序将输入的 ID 令牌交换为 Azure 用户主体会话令牌
&nbsp; &nbsp; // 输入的 ID 令牌可以从任何符合 OIDC 标准的 IdP 获取
&nbsp; &nbsp; // (例如 Google、Microsoft、Auth0、Okta 等)

&nbsp; &nbsp; // 在本例中,我们使用一个静态 ID 令牌提供程序,它返回由符合 OIDC 标准的 IdP 颁发的硬编码 ID 令牌
&nbsp; &nbsp; // 在实际应用中,你需要实现 `token.IdentityTokenProvider` 接口,创建一个动态的 ID 令牌提供程序,从符合 OIDC 标准的 IdP 获取 ID 令牌
&nbsp; &nbsp; idTokenJwt := os.Getenv("ID_TOKEN_JWT")
&nbsp; &nbsp; if&nbsp;idTokenJwt ==&nbsp;""&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; logger.Error(nil,&nbsp;"ID_TOKEN_JWT environment variable is not set")

&nbsp; &nbsp; &nbsp; &nbsp; return
&nbsp; &nbsp; }
&nbsp; &nbsp; azSubscriptionId := os.Getenv("AZURE_SUBSCRIPTION_ID")
&nbsp; &nbsp; if&nbsp;azSubscriptionId ==&nbsp;""&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; logger.Error(nil,&nbsp;"AZURE_SUBSCRIPTION_ID environment variable is not set")

&nbsp; &nbsp; &nbsp; &nbsp; return
&nbsp; &nbsp; }
&nbsp; &nbsp; azClientId := os.Getenv("AZURE_CLIENT_ID")
&nbsp; &nbsp; if&nbsp;azClientId ==&nbsp;""&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; logger.Error(nil,&nbsp;"AZURE_CLIENT_ID environment variable is not set")

&nbsp; &nbsp; &nbsp; &nbsp; return
&nbsp; &nbsp; }
&nbsp; &nbsp; azTenantId := os.Getenv("AZURE_TENANT_ID")
&nbsp; &nbsp; if&nbsp;azTenantId ==&nbsp;""&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; logger.Error(nil,&nbsp;"AZURE_TENANT_ID environment variable is not set")

&nbsp; &nbsp; &nbsp; &nbsp; return
&nbsp; &nbsp; }

&nbsp; &nbsp; resourseGroupName := os.Getenv("AZURE_RESOURCE_GROUP_NAME")
&nbsp; &nbsp; if&nbsp;resourseGroupName ==&nbsp;""&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; logger.Error(nil,&nbsp;"AZURE_RESOURCE_GROUP_NAME environment variable is not set")

&nbsp; &nbsp; &nbsp; &nbsp; return
&nbsp; &nbsp; }

&nbsp; &nbsp; idTokenProvider := token.NewStaticIdentityTokenProvider(idTokenJwt)

&nbsp; &nbsp; creds, err := credProvider.GetCredentials(ctx,
&nbsp; &nbsp; &nbsp; &nbsp; idTokenProvider,&nbsp;// 为即将使用 Azure 服务主体会话令牌进行认证的应用程序(工作负载)提供由符合 OIDC 标准的 IdP 颁发的 ID 令牌
&nbsp; &nbsp; &nbsp; &nbsp; azure.WithClientID(azClientId),
&nbsp; &nbsp; &nbsp; &nbsp; azure.WithTenantID(azTenantId),
&nbsp; &nbsp; &nbsp; &nbsp; azure.WithScope("https://management.azure.com/.default"),
&nbsp; &nbsp; )
&nbsp; &nbsp; if&nbsp;err !=&nbsp;nil&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; logger.Error(err,&nbsp;"failed to get Azure credentials")

&nbsp; &nbsp; &nbsp; &nbsp; return
&nbsp; &nbsp; }

&nbsp; &nbsp; accessToken := &accessTokenStore{}

&nbsp; &nbsp; // 检索 Azure 凭证,并在其过期前为与提供的 ID 令牌对应的身份进行更新
&nbsp; &nbsp; go&nbsp;func()&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; defer&nbsp;stop()

&nbsp; &nbsp; &nbsp; &nbsp; for&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; select&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; case&nbsp;<-ctx.Done():
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; case&nbsp;credentialEvent := <-creds:
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if&nbsp;credentialEvent.Err !=&nbsp;nil&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; logger.Error(credentialEvent.Err,&nbsp;"failed to get Azure credentials")

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; token, ok := credentialEvent.Credential.(*credential.Oauth2Creds)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if&nbsp;!ok {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; logger.Error(err,&nbsp;"failed to assert credential type")

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; // 更新应用用于向 Azure 服务认证的访问令牌
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; accessToken.Set(token)

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; logger.Info("received Azure credentials",&nbsp;"expiry", token.Expiry)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }()

&nbsp; &nbsp; go&nbsp;func()&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; defer&nbsp;stop()

&nbsp; &nbsp; &nbsp; &nbsp; // 模拟应用运行,并使用 Azure 凭证向 Azure 服务进行认证
&nbsp; &nbsp; &nbsp; &nbsp; // 定期检查并打印资源组中出现的资源,以演示凭证正在被刷新,并且可用于向 Azure 服务进行认证

&nbsp; &nbsp; &nbsp; &nbsp; armresourcesClient, err := armresources.NewClient(azSubscriptionId, accessToken,&nbsp;nil)
&nbsp; &nbsp; &nbsp; &nbsp; if&nbsp;err !=&nbsp;nil&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; logger.Error(err,&nbsp;"failed to create Azure Resource Management client")

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return

&nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; ticker := time.NewTicker(2&nbsp;* time.Second)
&nbsp; &nbsp; &nbsp; &nbsp; defer&nbsp;ticker.Stop()

&nbsp; &nbsp; &nbsp; &nbsp; trackedResourceIDs :=&nbsp;make(map[string]struct{})&nbsp;// 用于跟踪已见资源,仅记录新资源
&nbsp; &nbsp; &nbsp; &nbsp; for&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; select&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; case&nbsp;<-ctx.Done():
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; case&nbsp;<-ticker.C:

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; resourceIds :=&nbsp;make(map[string]*armresources.GenericResourceExpanded)

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; pager := armresourcesClient.NewListByResourceGroupPager(resourseGroupName,&nbsp;nil)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; for&nbsp;pager.More() {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; page, err := pager.NextPage(context.Background())
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if&nbsp;err !=&nbsp;nil&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; logger.Error(err,&nbsp;"failed to get resources from Azure Resource Management API")

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; for&nbsp;_, resource :=&nbsp;range&nbsp;page.Value {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if&nbsp;resource ==&nbsp;nil&nbsp;{
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; continue
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; resourceIds[*resource.ID] = resource
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; for&nbsp;id, resource :=&nbsp;range&nbsp;resourceIds {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if&nbsp;_, seen := trackedResourceIDs[id]; !seen {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; logger.Info("new resource",&nbsp;"name", *resource.Name,&nbsp;"type", *resource.Type,&nbsp;"id", id)

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; trackedResourceIDs[id] =&nbsp;struct{}{}
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; for&nbsp;id :=&nbsp;range&nbsp;trackedResourceIDs {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; if&nbsp;_, exists := resourceIds[id]; !exists {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; logger.Info("resource removed",&nbsp;"id", id)

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; delete(trackedResourceIDs, id)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }

&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; ticker.Reset(1&nbsp;* time.Minute)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; }()

&nbsp; &nbsp; <-ctx.Done()&nbsp;// 等待停止信号
&nbsp; &nbsp; logger.Info("exiting...")
}

运行应用

1️⃣ 配置环境变量

$&nbsp;export&nbsp;AZURE_SUBSCRIPTION_ID=<subscription-id>
$&nbsp;export&nbsp;AZURE_CLIENT_ID=<demo-uami-client-id>
$&nbsp;export&nbsp;AZURE_TENANT_ID=<tenant-id>
$&nbsp;export&nbsp;AZURE_RESOURCE_GROUP_NAME=demo-rg
$&nbsp;export&nbsp;ID_TOKEN_JWT=<id-token>

2️⃣ 运行应用

$ go run main.go

输出示例(成功访问)

2026/02/21 17:15:28 INFO Press Ctrl+C to stop...
2026/02/21 17:15:28 INFO received Azure credentials expiry=2026-02-22T17:15:27.786Z
2026/02/21 17:15:31 INFO new resource name=demo-uami type=Microsoft.ManagedIdentity/userAssignedIdentities id=/subscriptions/<YOUR-SUBSCRIPTION-ID>/resourceGroups/blog/providers/Microsoft.ManagedIdentity/userAssignedIdentities/demo-uami

授权失败场景

使用 demo-uami 用户分配的托管身份无法访问的资源组再次运行应用:

$&nbsp;export&nbsp;AZURE_RESOURCE_GROUP_NAME=test-resource-group-1

$ go run main.go

输出示例(授权失败)

2026/02/21 17:21:36 INFO Press Ctrl+C to stop...
2026/02/21 17:21:36 INFO received Azure credentials expiry=2026-02-22T17:21:35.948Z
2026/02/21 17:21:38 ERROR failed to get resources from Azure Resource Management API err="GET https://management.azure.com/subscriptions/<subscription-id>/resourceGroups/test-resource-group-1/resources\n--------------------------------------------------------------------------------\nRESPONSE 403: 403 Forbidden\nERROR CODE: AuthorizationFailed\n--------------------------------------------------------------------------------\n{\n &nbsp;\"error\": {\n &nbsp; &nbsp;\"code\": \"AuthorizationFailed\",\n &nbsp; &nbsp;\"message\": \"The client '<demo-uami-client-id>' with object id '<demo-uami-object-id>' does not have authorization to perform action 'Microsoft.Resources/subscriptions/resourceGroups/resources/read' over scope '/subscriptions/<subscription-id>/resourceGroups/test-resource-group-1' or the scope is invalid. If access was recently granted, please refresh your credentials.\"\n &nbsp;}\n}\n--------------------------------------------------------------------------------\n"
2026/02/21 17:21:38 INFO exiting...
  • • 当身份拥有适当访问权限时:

  • • 应用会列出新检测到的资源。

  • • 凭证会自动刷新。

  • • 资源的添加/移除会被跟踪记录。

  • • 当访问被拒绝时:

  • • Azure 返回 AuthorizationFailed (403) 错误。

  • • 应用记录错误并优雅退出。

3.3 流程工作原理

  1. 1. 你的应用从外部 IdP 获取一个 ID 令牌
  2. 2. tokenex[2] 将该令牌发送到 Azure 的令牌端点。
  3. 3. Azure 验证:
  • • 颁发者(issuer)
  • • 主题(subject)
  • • 受众(audience)
  • • 联合凭证配置
  1. 4. Azure 颁发一个绑定到用户分配的托管身份的 访问令牌
  2. 5. 你的应用使用该令牌调用 Azure API。

3.4 架构模型

在高层次上:

外部 IdP (OIDC) ↓ ID 令牌 (JWT) ↓ tokenex ↓ Azure OAuth 令牌端点 ↓ 托管身份访问令牌 ↓ Azure 资源 API

关键职责分离:

  • • 外部 IdP:确立身份(认证)
  • • Azure UAMI:定义授权边界(RBAC)
  • • tokenex[2]:执行 OAuth 令牌交换和刷新处理
  • • Azure 资源管理器/Graph/其他 API:实施 RBAC

四、总结

联合工作负载身份正成为云中非人类身份认证的标准方法。Azure 对绑定到用户分配的托管身份的联合凭证的支持,使得安全的、无密钥的认证模式成为可能。

将其与 tokenex[2] 结合使用,你将获得:

  • • 云凭证交换的简洁抽象
  • • 自动的令牌刷新处理
  • • 跨多个云服务提供商的统一接口
  • • 降低的运维复杂性
  • • 提升的安全态势

如果你正在构建多云或与外部 IdP 集成的系统,tokenex[2] 提供了一个实用、生产就绪的方式,用于在 Azure 上实现安全的工作负载联合。

引用链接

[1] 《Secretless Azure access with tokenex: Federated Identity via User-Assigned Managed Identity》: https://blog.riptides.io/secretless-az-access-with-tokenex/ [2] tokenex: https://github.com/riptideslabs/tokenex

交流群


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:云原生安全指北 Dubito Dubito《告别静态密钥:tokenex实现Azure动态短期凭证交换》

评论:0   参与:  0