AWS Machine Learning Blog

Implement on-behalf-of token exchange for multi-tenant agents with Amazon Bedrock AgentCore Gateway

6.9内容质量
Implement on-behalf-of token exchange for multi-tenant agents with Amazon Bedrock AgentCore Gateway

TL;DR · AI 摘要

Implement on-behalf-of token exchange for multi-tenant agents with Amazon Bedrock AgentCore Gateway Artificial Intellige...

核心要点

  • 主题聚焦:Implement on-behalf-of token exchange for multi-
  • 来源:AWS Machine Learning Blog,建议结合原文判断细节。
  • AI 分析暂不可用,本条为保底评分与摘要。
#AI#编程#后端#云计算#安全
打开原文

使用 Amazon Bedrock AgentCore Gateway 为多租户代理实现代用户令牌交换 | 人工智能

使用 Amazon Bedrock AgentCore Gateway 为多租户代理实现代用户令牌交换

当您将生成式 AI 代理部署到多租户生产架构时,会面临一个特定的身份问题:当代理代表用户调用下游 API 时,调用过程中携带的是谁的身份?以代理服务身份运行调用会破坏审计追踪,因为每个下游系统都必须无条件信任代理。直接转发用户令牌则会使每个下游工具都陷入“困惑的副官”问题。当一个代理代表多个租户运行,且工具调用时用户不在场时,这两种方案都无法扩展。

OAuth 2.0 令牌交换规范(RFC 8693)正好解决了这个问题,而 Amazon Bedrock AgentCore 身份验证原生支持该规范作为凭证提供者的授权类型。结合 Amazon Bedrock AgentCore 和 Bedrock AgentCore Gateway 拦截器实现的细粒度访问控制,为代理系统中的代用户(OBO)令牌交换奠定了概念基础。本文是实现指南,将逐步演示如何在 Okta 上完成完整的多租户 OBO 配置,展示每跳 JSON Web Token(JWT)声明的转换过程,并说明受众绑定如何通过多租户扩展实现纵深防御。

参考实现 TravelBot 是一个多租户预订助手,为两个示例租户(Acme 和 Globex)提供服务。本文的参考实现将在发布后通过 aws-samples/sample-obo-flow-poc 仓库提供。

当代理代表多个下游服务或租户运行,且入站令牌的受众与任何单一下游 API 不匹配时,OBO 模式至关重要。对于入站受众已与下游服务匹配的单租户代理,直接令牌转发可能已足够。本文其余部分将聚焦于多租户场景。

AgentCore 身份验证中的代用户令牌交换

Amazon Bedrock AgentCore 身份验证原生支持 OAuth 2.0 令牌交换(RFC 8693)作为凭证提供者的授权类型。通过此功能,AgentCore Gateway 可在调用下游工具前透明地将入站用户令牌转换为新的受众绑定令牌,无需代理本身实现此转换。

此功能提供以下优势:

  • 跨租户边界的身份数字传播 – 原始调用者的身份通过 sub 声明端到端保留,即使受众按租户变化。
  • 加密最小权限 – 每个下游调用通过 aud 声明携带绑定到单一下游服务的令牌。为一个租户颁发的令牌无法在另一个租户使用。
  • 无需代理端交换逻辑 – 代理代码只需获取单个入站令牌并调用工具。所有交换均由 AgentCore 完成。
  • 标准化实现 – 实现基于 RFC 8693 令牌交换授权。支持相同授权类型且请求格式兼容的授权服务器均可作为凭证提供者。

代理 AI 中的困惑副官问题

考虑一个处理“显示我的预订”等请求的代理,该请求来自多个不同租户的用户。有三种实现选择,但只有一种是正确的。

  • 服务账户伪装 – 代理以自身身份进行身份验证,并在请求头或路径参数中声明用户身份。每个下游 API 必须无条件信任代理。如果代理被攻破,它可以代表任何用户对任何租户执行操作。这就是教科书中的“混淆副官”问题。
  • 直接用户令牌转发 – 代理重用传入的用户令牌调用下游 API。此方法仅在传入令牌的受众已与下游 API 匹配时有效。在多租户系统中,这种情况很少见;当代理作为工具网关的前端时,这种情况从未发生过。
  • 代表用户令牌交换 – 代理的授权代理将传入的主体令牌转换为新令牌,新令牌的 sub(主体)是原始调用者,aud(受众)是下游 API,签名来自下游 API 信任的授权服务器。结果是一个经过加密限定的令牌,仅用于代表单个用户对单个下游服务进行一次调用。

OBO(代表用户)是唯一能够端到端保留用户身份、在受众边界强制最小权限原则,并生成下游 API 可独立验证而无需信任代理的令牌的方案。实现 RFC 8693 需要代理运行时、授权服务器和下游 API 之间的协调一致。当其中任何一个组件配置错误时,安全态势会悄无声息地退化。

Amazon Bedrock AgentCore 网关和 AgentCore 身份服务消除了这种协调负担。网关拦截工具调用,识别目标租户,并在下游调用发出前指示身份服务与租户的授权服务器执行交换。

伪装与代表用户的对比

在 OBO 交换中,传入令牌的 sub(主体)声明被保留,而 aud(受众)声明被重写为下游服务。交换的执行者记录在单独的声明中(RFC 8693 中的 act,Okta 中的 cid),因此下游 API 可以通过单个令牌回答两个问题:谁正在被代表?(sub 声明)以及谁正在执行操作?(执行者声明)。授权决策应基于 sub。审计日志和限流决策应基于执行者。如需深入了解伪装与代表用户的概念差异,请参阅《使用 Bedrock AgentCore 网关拦截器实现细粒度访问控制》。

下图通过 TravelBot 的预订工具对比了两种模式。在直接用户令牌转发中,传入令牌和下游令牌是同一个令牌。在 OBO 模式中,每个跳转都携带一个绑定到不同租户预订 API 的独立令牌。

图 1. TravelBot 预订工具的直接用户令牌转发与代表用户令牌流对比。

在直接转发模式(顶部),代理不变地转发用户的令牌。由于令牌的 aud 声明是为代理的 API 颁发的,下游工具必须跳过受众验证,或接受上游服务提供的任何受众。这两种选择都会重新引入“混淆副官”问题。在代表用户模式(底部),AgentCore 在每个跳转中将令牌交换为新令牌,新令牌的 aud 声明绑定到单个下游服务,scp(范围)缩减为最小必需权限。sub 声明在跳转间保持不变,因此审计和授权决策仍指向原始用户,而执行者声明记录 AgentCore 作为执行交换的代理。

解决方案概述

在整个文章中,以下标识符在TravelBot参考实现中对应特定的Okta资源。

标识符 | 角色 --- | --- TravelBot Provider | 向代理颁发入站JWT的Okta授权服务器 ACME Travel API | 为Acme租户生成OBO令牌的Okta授权服务器 Globex Travel API | 为Globex租户生成OBO令牌的Okta授权服务器 TravelBot Agent Client | 代理机器到机器路径(遗留/测试)的Okta API服务应用 TravelBot User Client | 3-legged用户登录的Okta OpenID Connect(OIDC)应用 AgentCore Delegate | AgentCore Identity用于执行令牌交换的凭证Okta应用

TravelBot参考实现使用Okta作为交换双方的授权服务器。一个Okta授权服务器(TravelBot Provider)用于验证代理身份。另外两个(ACME Travel API和Globex Travel API)分别为每个租户生成OBO令牌。这三个都是Okta自定义授权服务器。Okta内置的Org授权服务器不支持自定义受众或作用域,无法满足这种模式的需求。

其他具备等效能力的身份提供商(IdP)也可以扮演相同角色。由于RFC 8693在授权服务器层级实现,相同架构应适用于其他支持令牌交换授权的授权服务器。AgentCore Identity发送的具体请求参数(subject_token_type、受众、actor-token存在性及客户端认证方式)通过customParameters映射按凭证提供方配置,因此适配不同IdP只需配置变更而非代码变更。建议您根据具体部署环境进行验证。

Auth0(通过其自定义令牌交换功能)和Keycloak(每个领域启用令牌交换功能)均声明支持RFC 8693并兼容grantType: TOKEN_EXCHANGE。AWS IAM Identity Center通过其受信任令牌颁发者功能支持类似模式,但请求和响应格式与RFC 8693不同。这与之前描述的Okta受信任服务器关系存在差异。Microsoft Entra ID的on-behalf-of流程使用grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer且requested_token_use=on_behalf_of(RFC 7523),而非RFC 8693授权类型;AgentCore Identity通过grantType: JWT_AUTHORIZATION_GRANT在凭证提供方原生支持该流程。

Amazon Cognito用户池可作为验证入站代理调用的身份提供商。如果计划使用Cognito担任消费者端OBO角色,请参阅AgentCore Identity文档确认当前授权类型支持情况。

AgentCore Identity支持通过公共网络和虚拟私有云(VPC)私有连接访问租户授权服务器,包括部署在您VPC内部的身份提供商。有关配置模式,请参阅AgentCore开发者指南中的《连接到私有身份提供商》章节。

该架构包含六个组件:

  • 提供方授权服务器 – 向代理颁发的入站JWT,代理将其呈递给网关。在TravelBot中,这是名为TravelBot Provider的Okta授权服务器。
  • AgentCore网关 – 验证入站JWT与提供方的JSON Web Key Set(JWKS),将工具调用路由至正确租户目标,并协调OBO交换。
  • AgentCore 身份验证 – 保存代理客户端凭证,并向租户授权服务器执行 RFC 8693 令牌交换。
  • 每租户授权服务器 – 为每个租户的受众生成 OBO 令牌。在 TravelBot 中,ACME 旅行 API 和 Globex 旅行 API 是两个独立的 Okta 授权服务器,各自拥有不同的受众和访问策略。
  • 每租户 API 接口 – 一个 Amazon API Gateway HTTP API,每个租户配置一个 JWT 认证器。每个认证器验证签发者、受众和所需权限范围,是防止跨租户令牌重用的最后一道防线。
  • 租户业务逻辑 – 一个 AWS Lambda 函数,接收已验证的 OBO 令牌并读取声明以进行租户特定的决策。它强制要求通过 per-user authorized_scopes 声明验证写入权限,并将预订信息存储在按 sub 声明分区的 Amazon DynamoDB 中,确保用户只能读取自己的记录。

本文其余部分将按照请求经过这些组件的顺序逐步讲解这些组件。下图总结了请求流程:

图 2. AgentCore 网关的端到端 OBO 请求流程。

该工作流程包含以下步骤:

  • 用户通过三要素授权码登录在提供方授权服务器进行身份验证,代理接收绑定到网关的入站 JWT。
  • 代理通过模型上下文协议(MCP)调用工具,将入站 JWT 作为承载令牌提交。
  • 网关获取提供方的 JWKS,验证 JWT 签名,并确认受众匹配 travelbot-provider。
  • 网关选择与 Acme 目标关联的凭证提供方,并向身份验证模块请求 OBO 令牌。
  • 身份验证模块向 Acme 的授权服务器发送 RFC 8693 令牌交换请求。主体令牌是入站 JWT,请求的受众是 https://api.acme-travel.example。
  • Okta 验证 Acme 的授权服务器是否信任提供方签发者,对代理客户端应用访问策略,计算 per-user authorized_scopes 声明,并签署 OBO JWT。
  • 身份验证模块将 OBO JWT 返回给网关。
  • 网关调用 API Gateway 的 /acme 路由,将 OBO JWT 作为承载令牌提交。
  • API Gateway 的 JWT 认证器验证签发者、受众和所需权限范围后,将请求转发给 AWS Lambda 函数。
  • Lambda 函数解码 OBO 声明,根据 authorized_scopes 强制执行写入权限,查询按用户 sub 分区的 DynamoDB,并返回租户特定的响应。
  • 响应通过 API Gateway 和 AgentCore 网关返回给代理。

三要素入站登录

由于 TravelBot 需要验证真实用户,代理在与网关通信前会运行 OAuth 2.0 授权码流程:

  • 代理生成一个随机状态值,并构建一个 Okta /authorize URL,包含 response_type=code、scope=openid email gateway/invoke 和指向本地回调监听器的 redirect_uri。
  • 代理启动回调监听器,然后打开浏览器。
  • 用户在 Okta 进行身份验证。
  • Okta 将用户重定向到回调地址,附带授权码和状态值。
  • 回调监听器验证状态值以防御跨站请求伪造(CSRF),并在 Okta 的 /v1/token 端点将代码兑换为入站访问令牌。
  • 代理使用该访问令牌(其sub字段为已认证用户)作为每次网关调用的承载令牌。

在生产环境的Web应用中,本地回调监听器会被替换为应用后端负载均衡器后的普通路由,redirect_uri变为该公共HTTPS端点。交换逻辑保持不变,仅重定向的目标地址发生变化。

令牌转换深入解析

下表总结了入站令牌与OBO令牌之间每个JWT声明的转换方式。sub声明端到端保持不变,其余内容均由租户授权服务器重写或添加。

声明

阶段1(入站)

阶段3(OBO)

变更

code
iss

提供商授权服务器

租户授权服务器

重写

code
aud
code
travelbot-provider
code
https://api.acme-travel.example
code
sub
code
alice@acme-travel.example

保持不变

code
cid

提供商客户端(TravelBot代理客户端)

委托客户端(AgentCore委托)

重写(执行者)

code
scp

[openid email

code
gateway/invoke

]

[

code
booking/read
code
booking/write
code
authorized_scopes

新增

三个声明承载了安全逻辑:

  • sub声明端到端保持不变,因此审计日志和授权决策可追溯到原始调用者。
  • aud声明重写为租户API,通过加密方式将令牌绑定到单一下游服务。
  • cid声明记录执行交换的委托方,实现执行者与调用者的分离。

关于跨IdP环境声明命名的说明:Okta将作用域作为scp数组声明,将执行者作为cid。RFC 8693和OAuth 2.0使用scope(空格分隔字符串)和act(嵌套对象)。在跨多个IdP标准化审计日志时,需同时考虑这两种格式而非假设规范形式。

在TravelBot参考实现中,入站令牌通过三方授权码登录获取,因此sub声明为已认证的人类用户。在机器到机器流程(client_credentials授权)中,sub则会是调用方应用的客户端ID。

阶段1 — 由提供商授权服务器签发的入站令牌:

code
{
  "iss": "https://example.okta.com/oauth2/aus<provider-id>",
  "aud": "travelbot-provider",
  "sub": "alice@acme-travel.example",
  "cid": "0oa<provider-client-id>",
  "scp": ["openid","email","gateway/invoke"],
  "exp": 1748395200
}

代理通过三方授权码登录获取该令牌。该令牌绑定到网关预期的受众(travelbot-provider),携带单一粗粒度作用域gateway/invoke,仅授权调用网关而无其他权限。

阶段2 — AgentCore Identity向Acme租户授权服务器发送的RFC 8693令牌交换请求:

code
POST /oauth2/aus<acme-id>/v1/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<阶段1的入站JWT>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&audience=https://api.acme-travel.example
&scope=booking/read+booking/write
&client_id=<委托客户端ID>
&client_secret=<委托客户端密钥>

两个参数的设置并不显而易见。subject_token_type参数必须设置为access_token。AgentCore Identity默认将subject_token_type设为jwt,因为RFC 8693允许多种有效的令牌类型URI,且不同身份提供商接受的类型不同。Okta要求使用access_token。通过在凭证提供者的customParameters映射中设置subject_token_type来覆盖默认值(参见实现步骤中的第3步)。如果没有覆盖,默认情况下Okta会以invalid_request拒绝该交换请求。

对于Okta而言,audience参数同样是强制性参数,且必须与租户授权服务器上配置的audience完全匹配。这种匹配关系将生成的令牌绑定到特定的下游API。

AgentCore Delegate通过在交换请求体中直接提供其client_id和client_secret进行内联认证(根据凭证提供者的配置使用CLIENT_SECRET_POST方式)。不需要预先进行client_credentials的往返交互。该交换过程只需向租户授权服务器发起一次调用。

阶段3 — 由租户授权服务器颁发的OBO令牌:

code
{
  "iss": "https://example.okta.com/oauth2/aus<acme-id>",
  "aud": "https://api.acme-travel.example",
  "sub": "alice@acme-travel.example",
  "cid": "0oa<delegate-client-id>",
  "scp": ["booking/read", "booking/write"],
  "authorized_scopes": "booking/read",
  "exp": 1748395800
}

与阶段1的令牌相比,iss和aud现在指向Acme的授权服务器和API,sub保持不变,cid现在是代理的客户端ID,scp携带API期望的预订范围。authorized_scopes声明携带用户的实际权限,该权限根据用户所属的组成员关系计算;其作用将在下一节解释。该令牌仅在https://api.acme-travel.example有效。

捕获示例:以下代码块是TravelBot演示运行期间为alice@acme-travel.example(属于Okta的acme-readonly组)颁发的OBO令牌示例。该令牌完全虚构,不能用于认证。JWT正文中间部分已截断以提高可读性:

code
EXAMPLE1234567890abcdefEXAMPLERzdhODFzTXpKWmtoWjJVTWZacmtFUURqNXdvSURLbDMwIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULjBlMm50WUk3...
[为提高可读性已截断]
...EXAMPLE1234567890abcdefEXAMPLE

解码后的声明:

code
{
  "iss": "https://<your-okta-org>.okta.com/oauth2/aus<acme-id>",
  "aud": "https://api.acme-travel.example",
  "sub": "alice@acme-travel.example",
  "cid": "0oa<delegate-client-id>",
  "scp": ["booking/read", "booking/write"],
  "authorized_scopes": "booking/read",
  "iat": 1781492407,
  "exp": 1781496007
}

注意两个与作用域相关的声明之间的不对称性。scp列出了booking/read和booking/write,因为凭证提供者在每次交换时都会请求这两个作用域,但authorized_scopes仅包含booking/read。该按用户计算的值由Alice的组成员关系通过Expression声明生成。属于acme-fullaccess组的用户将获得相同的scp,并且authorized_scopes将设置为“booking/read booking/write”。资源服务器读取authorized_scopes,这就是为什么Lambda允许Alice读取但拒绝写入的原因。

下图展示了当为一个租户颁发的OBO令牌提交给另一个租户的API时会发生什么情况。租户边界通过令牌的aud声明进行密码学强制,而不是通过应用程序逻辑。

图3. 在API网关处拒绝跨租户令牌。

一个 aud 声明为 https://api.acme-travel.example 的令牌无法通过 /globex 路由的 JWT 授权器,因为该授权器的 JwtConfiguration.Audience 设置为 https://api.globex-travel.example。API Gateway 在 Lambda 函数被调用之前返回 HTTP 401 错误,没有任何应用代码执行。

两条腿,两种授权类型

多租户 OBO 部署结合了两种不同的 OAuth 交互方式,提前为它们命名有助于理解其行为差异:

入站腿(用户到网关):用户进行交互式认证。TravelBot 使用三-legged authorization_code 登录方式,因此入站令牌的 sub 声明是真实的人类用户(例如 alice@acme-travel.example)。此处也可以使用机器到机器的 client_credentials 授权类型,在这种情况下 sub 是调用应用的客户端 ID,其余流程完全相同。

交换腿(网关/身份到租户授权服务器):这是 RFC 8693 规范定义的令牌交换流程,授权服务器将其作为机器到机器授权处理:通过委托方的客户端凭证进行认证,用户仅以 subject_token 载荷形式存在,而非交互式会话。这种区别在实践中非常重要:由于交换被视为机器到机器(M2M)交互,授权服务器不会执行登录时会进行的交互式组到作用域评估。下一节将详细说明这一差异带来的影响。

关于令牌交换期间用户级作用域的说明

在多租户系统中,一个自然的目标是根据角色为每个用户授予不同的作用域。例如,acme-readonly 组获得 booking/read 作用域,而 acme-fullaccess 组同时获得 booking/read 和 booking/write 作用域。直观的实现方式是为每个组设置一个 Okta 访问策略规则,限制授予的作用域。

但这种方式在令牌交换授权期间无法生效,原因与两条腿的区分有关。在交互式 authorization_code 登录期间,Okta 会根据访问策略规则评估用户的组成员资格,并移除用户无权访问的作用域。然而,OBO 交换被作为机器到机器授权处理:通过委托方的客户端凭证进行认证,用户仅以 subject_token 载荷形式存在。Okta 不会将该 subject 用户映射回其组以进行作用域过滤。因此,OBO 令牌的 scp 声明将包含客户端请求的所有作用域,无论用户所属组,只读用户的令牌在 scp 上与全访问用户的令牌无法区分。交换的系统日志条目会显示 grantedScopes 等于 requestedScopes,这是诊断指标。

解决方法是使用声明而非作用域。尽管 Okta 在交换期间不会按用户过滤 scp,但它会在生成令牌时评估 Expression 类型声明,并且 Expression 声明可以读取组成员资格。在每个租户授权服务器上添加一个自定义声明,用于计算用户的有效权限:

  • 名称:authorized_scopes
  • 令牌类型:访问令牌
  • 值类型:表达式
  • 值(Acme):isMemberOfGroupName("acme-fullaccess") ? "booking/read booking/write" : (isMemberOfGroupName("acme-readonly") ? "booking/read" : "")
  • 包含于:任何作用域

OBO 令牌同时包含一个宽松的 scp(信息性)声明和一个反映用户实际权限的 authorized_scopes 声明。资源服务器根据 authorized_scopes 做出决策。使用 isMemberOfGroupName(...) 进行组检查。单纯的 Groups.contains(...) 表达式在交换过程中无法评估,会导致整个 mint 操作中止并返回 user_claim_evaluation_failure 错误。

Lambda 将 authorized_scopes 视为突变操作的权威来源:

code
# GET 请求按租户 + sub 对 DynamoDB 进行分区查询
# POST 请求还需要在 authorized_scopes 中包含 booking/write 权限:
if method == "POST":
    authorized = obo_claims.get("authorized_scopes") or []
    if isinstance(authorized, str):
        authorized = authorized.split()
    if "booking/write" not in authorized:
        return {
            "statusCode": 403,
            "headers": {"Content-Type": "application/json"},
            "body": json.dumps({
                "error": "forbidden",
                "message": f"用户 {username} 没有创建预订的权限。",
            }),
        }
# ... 继续写入预订

这是细粒度授权的标准分工:身份提供者(IdP)验证用户身份并声明其属性,资源服务器负责做出允许/拒绝的决策。有两种替代方案可以实现相同效果。Okta Token Inline Hook 在 mint 过程中调用外部端点,根据用户组修补 scp 声明,但需要承担托管钩子端点的成本,以保持 scp 的权威性。或者,如果代理层已经知道用户的权限,可以只请求用户被允许的范围,这样交换过程将恰好授予该范围的权限。

下图展示了用户级别的决策点。交换(M2M)返回宽松的 scp,Expression 声明根据用户的组计算出 authorized_scopes,资源服务器根据该声明进行授权。

图4. 授权决策基于 authorized_scopes Expression 声明而非 scp。

使用 sub 声明实现用户级别的数据隔离

只有下游系统对身份传播做出响应时,身份传播才有价值。TravelBot Lambda 将预订存储在按用户身份分区的 DynamoDB 中:

  • 分区键(pk):"{tenant}#{sub}",例如 acme#alice@acme-travel.example
  • 排序键(booking_id):预订ID

读取时,Lambda 会针对调用者的 pk 发起限定范围的查询。写入时,会在相同 pk 下插入数据项。其效果是用户无法获取其他用户的预订数据。这不是因为应用层过滤,而是因为查询是根据调用者自己的 sub 声明构建的,该声明由 Okta 签名并在 Lambda 运行前由 API Gateway 授权器验证。OBO sub 声明成为数据模型的主键,这正是“sub 声明作为可信主体”的实际含义。

图5. 通过 sub 声明分区实现数据隔离。用户的查询只能访问其分区键下的数据行。

在大规模场景中,按 sub 分区可能导致高活跃用户产生热点分区。对于高吞吐量工作负载,可以考虑对分区键进行写入分片(例如 acme#alice@acme-travel.example#{shard}),并在读取时跨分片查询。

实现过程解析

在 AgentCore 网关上实现 OBO 需要通过 bedrock-agentcore-control API 使用 AWS SDK for Python (Boto3) 执行三个操作:创建带有入站授权器的网关、为每个租户创建一个凭证提供者、以及为每个租户附加一个目标并设置 audience 参数。这三个资源之间的关系决定了入站检查使用哪个授权服务器、执行令牌交换的是哪个服务器,以及最终 OBO 令牌会发送到哪个下游 API。

新增租户需要创建一个凭证提供者和一个带有新租户 audience 的网关目标。无需修改任何代理代码。

图 6. AgentCore 配置关系。

网关持有入站授权器。每个目标将 OpenAPI 工具接口绑定到一个凭证提供者。每个凭证提供者保存一个租户授权服务器的委托凭据。这三个职责被刻意分离,以确保 audience 绑定、作用域缩减和 IdP 轮换可以作为独立操作进行。

步骤 1:使用自定义 JWT 授权器创建网关

入站授权器在工具调用到达编排层之前验证提供方的 JWT。对于 Okta 颁发的令牌,需要配置 allowedAudience。Okta 将客户端身份放在 cid 声明而非 client_id 中,因此网关的 allowedClients 机制不适用。

code
agentcore.create_gateway(
    name="travelbot-obo-gateway",
    roleArn=role_arn,
    protocolType="MCP",
    authorizerType="CUSTOM_JWT",
    authorizerConfiguration={
        "customJWTAuthorizer": {
            "discoveryUrl": f"{provider_issuer}/.well-known/openid-configuration",
            "allowedAudience": [provider_audience],  # "travelbot-provider"
        }
    },
)

发现 URL 指向提供方的授权服务器,而非租户的授权服务器。入站授权器的职责是验证代理是否有权限使用网关,而非授权具体的下游调用。

步骤 2:为每个租户创建一个 OAuth2 凭证提供者

每个租户的授权服务器都需注册为独立的凭证提供者。凭证提供者保存委托方的客户端凭据(执行令牌交换的 AgentCore 身份)和租户授权服务器的发现配置。大多数现代 IdP 都会在 /.well-known/openid-configuration 发布 OpenID Connect 文档,AgentCore 可通过 discoveryUrl 直接获取。对于仅在 /.well-known/oauth-authorization-server 发布 RFC 8414 元数据文档的纯 OAuth 2.0 授权服务器,请使用 static authorizationServerMetadata 形状配置凭证提供者,而非 discoveryUrl。

code
agentcore.create_oauth2_credential_provider(
    name="travelbot-cred-acme",
    credentialProviderVendor="CustomOauth2",
    oauth2ProviderConfigInput={
        "customOauth2ProviderConfig": {
            "oauthDiscovery": {
                "discoveryUrl": f"{acme_issuer}/.well-known/openid-configuration"
            },
            "clientId": delegate_client_id,
            "clientSecret": delegate_client_secret,
            "clientAuthenticationMethod": "CLIENT_SECRET_POST",
            "onBehalfOfTokenExchangeConfig": {
                "grantType": "TOKEN_EXCHANGE",
                "tokenExchangeGrantTypeConfig": {
                    "actorTokenContent": "NONE",
                },
            },
        }
    },
)

将 actorTokenContent 设置为 NONE 指示 Identity 仅使用主体令牌和委托客户端认证进行交换,不使用任何演员令牌。这种结构符合 Okta 预期的请求格式。为每个租户重复此操作。

第 3 步:为每个租户创建一个 Gateway 目标

目标将 OpenAPI 工具接口绑定到凭证提供者,并配置每次调用的 OBO 交换参数。customParameters 映射用于将受众和修正后的 subject_token_type 注入到每次交换中:

code
agentcore.create_gateway_target(
    gatewayIdentifier=gateway_id,
    name="travelbot-acme-booking",
    targetConfiguration={
        "mcp": {"openApiSchema": {"inlinePayload": acme_openapi_spec}}
    },
    credentialProviderConfigurations=[{
        "credentialProviderType": "OAUTH",
        "credentialProvider": {
            "oauthCredentialProvider": {
                "providerArn": acme_credential_provider_arn,
                "scopes": ["booking/read", "booking/write"],
                "grantType": "TOKEN_EXCHANGE",
                "customParameters": {
                    "audience": "https://api.acme-travel.example",
                    "subject_token_type":
                        "urn:ietf:params:oauth:token-type:access_token",
                },
            }
        },
    }],
)

完成这三个操作后,代理代码本身不再包含任何令牌交换逻辑。它获取一个提供者令牌,通过 Gateway 打开 MCP 会话并调用工具。Gateway 和 Identity 会在每次工具调用时透明地执行交换。

通过受众绑定实现纵深防御

多租户代理系统必须确保为一个租户颁发的令牌无法用于访问其他租户的资源。在 TravelBot 中,这一原则在三个独立位置强制执行。其中任何一个位置都能有效阻止跨租户访问尝试。

  • 在 Gateway 目标处 – 每个目标的 customParameters.audience 值是独立设置的。服务于 Acme 工具的目标无法生成带有 Globex 受众的令牌。
  • 在租户授权服务器处 – Okta 会在签署 OBO 令牌前验证交换请求中的受众参数是否已在授权服务器上注册。
  • 在 API Gateway JWT 授权器处 – 每个路由(/acme、/globex)都绑定到一个授权器,其 JwtConfiguration.Audience 为租户的受众。如果令牌的 aud 声明不匹配,会在调用 Lambda 函数前以 HTTP 401 拒绝请求。

三层强制执行机制是 OBO 提供的安全核心。aud 约束直接编码在令牌中,无需应用层代码进行强制执行,路径上的每个组件都可以独立验证该约束。默认情况下,每一层都采用“失败时关闭”策略。单一层级的配置错误会导致请求被拒绝,而非静默通过。

常见陷阱

TravelBot 的实现揭示了在 Okta 集成中反复出现的一系列问题,这些内容在实现此模式时值得了解:

  • 在令牌交换过程中,基于组的范围限制会被绕过。由于 Okta 将 OBO 授权视为机器对机器通信,它不会将 subject_token 用户映射到其组以进行 scp 过滤。不要依赖访问策略的范围规则来生成每用户的 scp。应在资源服务器使用每用户的表达式声明(authorized_scopes)、内联令牌钩子或精确的客户端范围请求来强制执行。
  • 提供者和代理 Okta 应用都必须禁用 DPoP。证明拥有权(DPoP)绑定将令牌与原始客户端持有的私钥关联。AgentCore Identity 是一个令牌中继,不持有该密钥,因此在交换时要求 DPoP 会导致 invalid_dpop_proof 错误。禁用 DPoP 会失去密钥持有者保护:被盗的持有者令牌可以在过期前被重放。可通过签发短生命周期的 OBO 令牌、在每个跳转点强制 TLS,以及将令牌紧密绑定到单一受众来弥补,这样被盗令牌仅对一个下游服务有用。
  • Okta 在 cid 声明中携带客户端身份,而非 client_id。网关的 allowedClients 与 client_id 匹配,而 Okta 访问令牌不携带该字段。应使用 allowedAudience,因为 Okta 会发出 aud 声明。
  • subject_token_type 参数必须为 access_token,而非 jwt。多个 SDK 默认使用 jwt 进行 RFC 8693 交换,Okta 会拒绝并返回 invalid_request。通过网关目标的 customParameters 覆盖该值。例如:customParameters={"subject_token_type": "urn:ietf:params:oauth:token-type:access_token"}。
  • 提供者的授权服务器必须在每个租户授权服务器上注册为受信任的发行者。这在 Okta 管理控制台的“受信任服务器”部分为每个租户授权服务器进行配置。信任方向为单向:每个租户授权服务器必须信任提供者,但提供者无需信任租户。缺少这种信任关系时,即使构造正确的交换请求也会失败,因为租户授权服务器会拒绝接受外部颁发的 subject 令牌。
  • 代理 Okta 应用必须在其允许的授权类型中列出 urn:ietf:params:oauth:grant-type:token-exchange,同时需要在每个租户授权服务器的访问策略中分配。这两个控制项都是必需的。应用级别的授权声明表明客户端被允许请求令牌交换。访问策略声明特定的授权服务器将处理该请求。缺少任一方面都会导致 Okta 返回 unauthorized_client 错误,且难以判断是哪个控制项拒绝了请求。
  • 三重登录的入站登录有自己的设置要求。redirect_uri 必须在 OIDC 应用程序中注册为登录重定向 URI,并且在发送时不要进行过度编码(保留 : 和 /)。用户或用户所属的组必须被分配到 OIDC 应用程序,且提供方授权服务器的访问策略规则必须将该应用程序列为允许的客户端。在演示多个用户时,使用 prompt=login(或专用浏览器窗口),以防止缓存会话静默验证错误身份。

最佳实践

在生产环境中部署 OBO 与 AgentCore Gateway 时,建议遵循以下实践:

  • 每个租户使用一个凭证提供方和一个委托客户端 – 避免在租户之间共享凭证提供方。租户边界确保凭证轮换、范围变更和租户下架作为独立操作。
  • 显式绑定受众 – 在每个 Gateway 目标上设置 customParameters.audience,在每个 API Gateway 授权器上设置 JwtConfiguration.Audience。不要依赖默认值。
  • 颁发短时效的 OBO 令牌 – 配置租户授权服务器颁发 OBO 令牌,时效长度为应用程序可容忍的最短时间到生存期(TTL)。短时效是缓解禁用 DPoP 引入的令牌重放风险的主要手段。
  • 将 AgentCore 委托客户端的密钥视为系统中最敏感的凭证 – 泄露的委托密钥允许未经授权的用户在委托分配到的每个租户授权服务器上为任意 sub 值生成 OBO 令牌。
  • 严格轮换和限制委托密钥范围 – 按固定周期轮换委托密钥,将委托限制为最小租户集合,并将密钥存储在启用轮换功能的 AWS Secrets Manager 中,而非参数存储中。
  • 基于 sub 和用户声明进行授权决策,而非 actor 声明 – 原始调用者是主体。执行交换的委托是操作者。应用层授权应解析到 sub。actor 声明(Okta 中的 cid,RFC 8693 中的 act)应记录在审计日志中。
  • 在 AWS Secrets Manager 或 AWS Systems Manager Parameter Store 中存储 IdP 凭证 – 在 AWS CloudFormation 或 AWS Cloud Development Kit (AWS CDK) 中通过 Amazon 资源名称(ARN)引用它们。永远不要将它们嵌入源代码中。
  • 将声明名称而非原始令牌写入应用日志 – 记录 sub、actor(Okta 中的 cid)、aud 和令牌的 jti(如果存在)。永远不要记录原始 Authorization 请求头值或承载令牌字符串。包含有效 OBO 令牌的泄露日志行等同于该令牌生命周期内的凭证泄露。
  • 规划令牌交换的开销 – 每次 OBO 交换都会联系租户授权服务器。AgentCore Identity 可能在其生命周期内缓存 OBO 令牌。在 AgentCore 文档中确认当前行为。为最大化令牌重用,颁发安全模型允许的最长 TTL 的 OBO 令牌。租户授权服务器的速率限制也适用于交换端点,因此需将其视为工具调用性能预算的一部分。
  • 优雅处理交换失败 – 当租户授权服务器无法访问或拒绝交换时,网关会将底层错误返回给代理。在代理层将常见情况(invalid_request、unauthorized_client、受众不匹配)映射为用户友好消息,而非直接向终端用户暴露原始 OAuth 错误。
  • 将代币交换健康状况作为生产信号进行监控 – 跟踪交换成功率、API网关中的受众不匹配401错误、租户授权服务器的unauthorized_client错误,以及Expression声明上的用户声明评估失败率。这些指标的突增通常表明租户配置错误,而非运行时缺陷。

结论

On-behalf-of代币交换是多租户AI代理的正确身份模式,Amazon Bedrock AgentCore Gateway无需代理自身实现RFC 8693即可将其落地。通过将网关的受众绑定凭证提供者与API网关的每个租户JWT授权者结合,您可以实现用户身份的端到端保护,在受众边界实施最小权限原则,并生成能够区分代理与用户的审计轨迹。实际运营优势显著:保持一致的用户审计轨迹、降低下游工具或其代币被入侵时的影响范围,并通过配置实现租户接入。sub声明是一个可信的主体,下游服务可以将其传递给如Amazon Verified Permissions这样的细粒度授权层,这是AWS用于资源级决策的基于策略的细粒度授权服务。

要开始使用,请克隆TravelBot参考实现,按照README中的说明填充/travelbot/okta/* SSM参数,运行setup_infra.py以创建AWS资源,并针对Acme或Globex租户运行代理。完整的端到端流程可在单个AWS账户和一个Okta租户中运行。

关于作者

'"`