文章总结: 这篇文章介绍开源Go库tokenex,用于实现Azure无密钥访问。通过外部身份提供商的ID令牌交换短期云凭证,消除静态密钥存储风险。文章详述无密钥模型的安全优势,包括缩小爆炸半径、降低凭证泄露价值等,并提供完整的Azure配置步骤和Go应用代码示例,展示端到端的联合身份验证流程。 综合评分: 82 文章分类: 云安全,安全工具,安全建设,安全开发
告别静态密钥: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 main
import (
"context"
"log/slog"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
"github.com/go-logr/logr"
"go.riptides.io/tokenex/pkg/azure"
"go.riptides.io/tokenex/pkg/credential"
"go.riptides.io/tokenex/pkg/token"
)
// accessTokenStore 是一个线程安全的 Azure 访问令牌存储器。
type accessTokenStore struct {
azcore.TokenCredential
mu sync.RWMutex
accessToken azcore.AccessToken
}
func (s *accessTokenStore) Set(token *credential.Oauth2Creds) {
s.mu.Lock()
defer s.mu.Unlock()
s.accessToken.Token = token.AccessToken
s.accessToken.ExpiresOn = token.Expiry
}
func (s *accessTokenStore) GetToken(ctx context.Context, _ policy.TokenRequestOptions) (azcore.AccessToken, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.accessToken, nil
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() // clean up signal handler
logger := logr.FromSlogHandler(slog.Default().Handler())
logger.Info("Press Ctrl+C to stop...")
// 初始化凭证提供程序,以接收Azure凭证
credProvider, err := azure.NewCredentialsProvider(ctx, logger)
if err != nil {
logger.Error(err, "failed to create Azure credentials provider")
return
}
// staticIDTokenProvider 实现 token.IdentityTokenProvider 接口。
type staticIDTokenProvider struct {
token string
}
func (p *staticIDTokenProvider) GetIdentityToken(ctx context.Context) (string, error) {
return p.token, nil
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// 从环境变量读取配置
subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID")
if subscriptionID == "" {
log.Fatal("AZURE_SUBSCRIPTION_ID 环境变量未设置")
}
clientID := os.Getenv("AZURE_CLIENT_ID")
if clientID == "" {
log.Fatal("AZURE_CLIENT_ID 环境变量未设置")
}
tenantID := os.Getenv("AZURE_TENANT_ID")
if tenantID == "" {
log.Fatal("AZURE_TENANT_ID 环境变量未设置")
}
resourceGroupName := os.Getenv("AZURE_RESOURCE_GROUP_NAME")
if resourceGroupName == "" {
log.Fatal("AZURE_RESOURCE_GROUP_NAME 环境变量未设置")
}
idTokenJWT := os.Getenv("ID_TOKEN_JWT")
if idTokenJWT == "" {
log.Fatal("ID_TOKEN_JWT 环境变量未设置")
}
// 初始化令牌存储器
tokenStore := &accessTokenStore{}
// 底层实现中,凭证提供程序使用 Microsoft Entra ID 工作负载联合身份
// 从 Microsoft Entra ID 服务获取用户主体会话令牌
// 凭证提供程序将输入的 ID 令牌交换为 Azure 用户主体会话令牌
// 输入的 ID 令牌可以从任何符合 OIDC 标准的 IdP 获取
// (例如 Google、Microsoft、Auth0、Okta 等)
// 在本例中,我们使用一个静态 ID 令牌提供程序,它返回由符合 OIDC 标准的 IdP 颁发的硬编码 ID 令牌
// 在实际应用中,你需要实现 `token.IdentityTokenProvider` 接口,创建一个动态的 ID 令牌提供程序,从符合 OIDC 标准的 IdP 获取 ID 令牌
idTokenJwt := os.Getenv("ID_TOKEN_JWT")
if idTokenJwt == "" {
logger.Error(nil, "ID_TOKEN_JWT environment variable is not set")
return
}
azSubscriptionId := os.Getenv("AZURE_SUBSCRIPTION_ID")
if azSubscriptionId == "" {
logger.Error(nil, "AZURE_SUBSCRIPTION_ID environment variable is not set")
return
}
azClientId := os.Getenv("AZURE_CLIENT_ID")
if azClientId == "" {
logger.Error(nil, "AZURE_CLIENT_ID environment variable is not set")
return
}
azTenantId := os.Getenv("AZURE_TENANT_ID")
if azTenantId == "" {
logger.Error(nil, "AZURE_TENANT_ID environment variable is not set")
return
}
resourseGroupName := os.Getenv("AZURE_RESOURCE_GROUP_NAME")
if resourseGroupName == "" {
logger.Error(nil, "AZURE_RESOURCE_GROUP_NAME environment variable is not set")
return
}
idTokenProvider := token.NewStaticIdentityTokenProvider(idTokenJwt)
creds, err := credProvider.GetCredentials(ctx,
idTokenProvider, // 为即将使用 Azure 服务主体会话令牌进行认证的应用程序(工作负载)提供由符合 OIDC 标准的 IdP 颁发的 ID 令牌
azure.WithClientID(azClientId),
azure.WithTenantID(azTenantId),
azure.WithScope("https://management.azure.com/.default"),
)
if err != nil {
logger.Error(err, "failed to get Azure credentials")
return
}
accessToken := &accessTokenStore{}
// 检索 Azure 凭证,并在其过期前为与提供的 ID 令牌对应的身份进行更新
go func() {
defer stop()
for {
select {
case <-ctx.Done():
return
case credentialEvent := <-creds:
if credentialEvent.Err != nil {
logger.Error(credentialEvent.Err, "failed to get Azure credentials")
return
}
token, ok := credentialEvent.Credential.(*credential.Oauth2Creds)
if !ok {
logger.Error(err, "failed to assert credential type")
return
}
// 更新应用用于向 Azure 服务认证的访问令牌
accessToken.Set(token)
logger.Info("received Azure credentials", "expiry", token.Expiry)
}
}
}()
go func() {
defer stop()
// 模拟应用运行,并使用 Azure 凭证向 Azure 服务进行认证
// 定期检查并打印资源组中出现的资源,以演示凭证正在被刷新,并且可用于向 Azure 服务进行认证
armresourcesClient, err := armresources.NewClient(azSubscriptionId, accessToken, nil)
if err != nil {
logger.Error(err, "failed to create Azure Resource Management client")
return
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
trackedResourceIDs := make(map[string]struct{}) // 用于跟踪已见资源,仅记录新资源
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
resourceIds := make(map[string]*armresources.GenericResourceExpanded)
pager := armresourcesClient.NewListByResourceGroupPager(resourseGroupName, nil)
for pager.More() {
page, err := pager.NextPage(context.Background())
if err != nil {
logger.Error(err, "failed to get resources from Azure Resource Management API")
return
}
for _, resource := range page.Value {
if resource == nil {
continue
}
resourceIds[*resource.ID] = resource
}
}
for id, resource := range resourceIds {
if _, seen := trackedResourceIDs[id]; !seen {
logger.Info("new resource", "name", *resource.Name, "type", *resource.Type, "id", id)
trackedResourceIDs[id] = struct{}{}
}
}
for id := range trackedResourceIDs {
if _, exists := resourceIds[id]; !exists {
logger.Info("resource removed", "id", id)
delete(trackedResourceIDs, id)
}
}
ticker.Reset(1 * time.Minute)
}
}
}()
<-ctx.Done() // 等待停止信号
logger.Info("exiting...")
}
运行应用
1️⃣ 配置环境变量
$ export AZURE_SUBSCRIPTION_ID=<subscription-id>
$ export AZURE_CLIENT_ID=<demo-uami-client-id>
$ export AZURE_TENANT_ID=<tenant-id>
$ export AZURE_RESOURCE_GROUP_NAME=demo-rg
$ export 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 用户分配的托管身份无法访问的资源组再次运行应用:
$ export 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 \"error\": {\n \"code\": \"AuthorizationFailed\",\n \"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 }\n}\n--------------------------------------------------------------------------------\n"
2026/02/21 17:21:38 INFO exiting...
-
• 当身份拥有适当访问权限时:
-
• 应用会列出新检测到的资源。
-
• 凭证会自动刷新。
-
• 资源的添加/移除会被跟踪记录。
-
• 当访问被拒绝时:
-
• Azure 返回
AuthorizationFailed (403)错误。 -
• 应用记录错误并优雅退出。
3.3 流程工作原理
- 1. 你的应用从外部 IdP 获取一个 ID 令牌。
- 2. tokenex[2] 将该令牌发送到 Azure 的令牌端点。
- 3. Azure 验证:
- • 颁发者(issuer)
- • 主题(subject)
- • 受众(audience)
- • 联合凭证配置
- 4. Azure 颁发一个绑定到用户分配的托管身份的 访问令牌。
- 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动态短期凭证交换》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论