PixPin_2026-09-09_16-03-41

一个 AI Agent 迟早要去敲别人家的门——调一个需要 OAuth 授权的下游 API、读用户在第三方系统里的数据、代表某个身份去访问受保护的资源。可"敲门"要带钥匙,而这把钥匙(access token)从哪来、存哪、怎么带、谁来验,是一连串绕不开的问题。最省事的做法是把 API key 硬编码进代码,但那等于把钥匙焊在门上——进过 git、进过镜像,谁都能捡走。

Amazon Bedrock AgentCore 给了更体面的答案:凭证托管 + 出站认证。但真上手你会发现,"让 Agent 用 OAuth 访问下游"这句话背后藏着好几层:token 谁去换?换来的钥匙放哪?如果不想让每个 Agent 都自己管钥匙,能不能抽一个中间人?这个中间人又该怎么被别的 Agent 调用?

官方给的第一个答案是 Gateway——一个托管的中间人,你在配置里声明一下,它就替你换 token、带 header,Agent 侧一行认证代码都不用写。省心,但它是个黑盒:你看不到 token 是怎么换的、身份怎么建立、失败时卡在哪一层。所以本文换一条路——自己搭一个 BFF 当中间人,把 Gateway 替你藏起来的那套亲手走一遍,看清每一层信任是怎么建立的。

先看官方答案:Gateway 托管出站认证

在动手自建之前,先看看 AgentCore 官方推荐的省心做法,作为对照组。

AgentCore Gateway 是一个托管的 MCP 网关,坐在 Agent 和下游服务之间。你想让 Agent 出站访问需 OAuth 的下游,只要在 agentcore.json 里给 Gateway 的 target 声明一段 outboundAuth:

"agentCoreGateways": [
  {
    "name": "myGateway""authorizerType": "AWS_IAM""targets": [
      {
        "name": "downstreamApi""targetType": "openApiSchema""endpoint": "https://your-downstream-service""outboundAuth": {
          "type": "OAUTH""credentialName": "authentikBff""scopes": ["openid""profile"]
        }
      }
    ]
  }
]

就这么一段声明,Gateway 会在 Agent 调用这个 target 时,自动credentialName 指向的凭证换 OAuth token、注入 Authorization: Bearer 头、到期自动刷新。Agent 侧把这个下游当成一个工具来调,完全不碰 tokenoutboundAuth.type 除了 OAUTH 还支持 API_KEY(注入 API key)、GATEWAY_IAM_ROLE(用网关执行角色做 SigV4)、JWT_PASSTHROUGH(透传入站 token)、NONE

有个前提得先说清:credentialName: "authentikBff" 是****按名字引用一个已存在的凭证****,不是凭空生效——你得先把这个凭证创建出来(和后文 BFF 路线共用的正是同一步):

agentcore add credential --type oauth --name authentikBff \
 --discovery-url "https://.../.well-known/openid-configuration" \
 --client-id "..." --client-secret "..." --scopes "openid,profile"

这里要分清两个常被混为一谈的东西:Token Vault——它是 AgentCore 每个账户/区域自带的托管资源(token-vault/default),开通即有

用户要提前建的是放进保险箱的那把钥匙,也就是上面这条 add credential 声明的 credential provider,它在 deploy 时自动加密存进 Token Vault。

所以顺序是:先add credential 建凭证(自动进 vault),之后再在 Gateway 的**outboundAuth.credentialName**(或 BFF 代码的@requires_access_token(provider_name=...))里按名引用它。Gateway 只负责"用"凭证换 token,不负责"建"凭证。没先建,这段配置会因找不到凭证名而失败。

这套的好处在于,Agent 零认证代码、token 生命周期托管、改认证方式不用改代码,还顺带能配工具级 Cedar 策略和语义搜索。绝大多数"Agent 要访问几个需授权的下游"的需求,用 Gateway 声明一下就够了。但它是个黑盒, token 到底怎么换的、workload 身份从哪来、换 token 失败时是凭证问题还是网络问题——这些 Gateway 都隐藏起来了。

所以接下来,我们自己当这个中间人——搭一个 BFF,把 Gateway 封装掉的那套亲手实现一遍。走通它之后,你对"出站认证"这条链路的每一环都心里有数,再回头用 Gateway 也知道它黑盒里在干什么。

建立整体认知。整条链路上有两跳,每一跳的认证机制完全不同,这是理解全局的关键。

B,AC,BAuthentik IDPToken VaultBffAgent runtimeCallerAgent runtimeB,AC,BAuthentik IDPToken VaultBffAgent runtimeCallerAgent runtime第一跳:AWS IAM / SigV4第二跳:OAuth 2.0① InvokeAgentRuntime(SigV4 + runtimeUserId)② @requires_access_token按名取凭证返回托管的 client 凭证③ client_credentials 换 token签发 RS256 JWT④ 逐层返回 token 元信息
  • Caller → BFF:走 AWS 的 IAM / SigV4。调用方用自己执行角色的凭证签名,AgentCore 校验。
  • BFF → Authentik:走 OAuth 2.0。BFF 用托管的凭证向 Authentik 换 token。

调用方访问BFF 是"AWS 内部的信任"(IAM),BFF访问下游则是"跨系统的信任"(OAuth)。这两套东西的凭证来源、校验方式、失败模式都不一样。

那把钥匙,到底存在哪

动手之前先破一个疑惑。我们在 agentcore.json 里声明了一个 OAuth 凭证:

{
  "authorizerType": "OAuthCredentialProvider""name": "authentikBff""discoveryUrl": "https://<authentik>/application/o/agentcore-bff/.well-known/openid-configuration""scopes": ["openid""profile"]"vendor": "CustomOauth2"
}

但翻遍这个 JSON,找不到 client secret。钥匙去哪了?它分三处存,一处都不明文落进代码:

  • agentcore.json:只有名字和 discovery URL,没有 secret

  • .env.local(gitignored):加密存储

    image-20260909152041079

  • 部署后 AWS Token Vault是 真正的托管处

这就是 OAuthCredentialProvider 的意义:

  • 声明要用哪把钥匙,AgentCore 负责把钥匙本体加密托管在 Token Vault。
  • Agent 运行时凭 workload 身份按名字取回,全程 secret 不进 git、不进镜像、不进日志。
  • vendor: "CustomOauth2" 是给非内置 provider 用的——Authentik 不在 AgentCore 内置的 26 个 vendor(Github/Google/Okta…)里,所以走通用的 CustomOauth2 + 手填 discovery URL。
  • 密钥最终仍旧存储在secret manager中,只不过这个资源是由agentcore托管创建的,因此用户无法直接修改密钥的值。

控制台查看vault

让 IDP 那头点头:Authentik 的配置

要让 BFF 能换 token,Authentik 侧得先建一个 OAuth2 Provider。这里藏着第一个坑,而且很反直觉。

下面是本次实际建好的 agentcore-bff Provider(Authentik 管理台 → Applications → Providers)——注意它的 Client Type: Confidential、自动生成的 Client ID,以及右侧的 Token URL / OpenID Configuration URL / JWKS URL,这些正是 BFF 换 token 和下游验签要用的端点:

image-20260909152341798

Authentik 的文档写道:“machine clients do not authenticate using the typical client_id + client_secret combination”,实际上它的 client_credentials 流,底层是"用 client_secret 自动生成一个 service account"来签发 token。

值得注意的是,新建的 Provider 默认没启用 client_credentials。第一次换 token,我拿到的是:

{ "error": "invalid_grant""error_description": "The provided authorization grant ... is invalid" }

错误信息含糊得让人以为是 secret 错了。翻容器日志(docker logs,按 request_id 搜)才看到真相:

"event": "Invalid grant_type for provider", "grant_type": "client_credentials", "provider": "agentcore-bff"

Provider 的 grant_types 字段是个空数组,压根没允许 client_credentials。补上就好:

image-20260909152457560

改完再换,立刻拿到一个 2728 字节的 RS256 JWT。

为什么偏偏是 client_credentials,别的授权类型不行?授权类型(grant type)决定 token 代表谁、怎么拿到。而 client_credentials 是应用用自己的身份(client_id + client_secret)直接换 token,全程没有用户参与。

之所以用client_credentials,因为 BFF 换 token 时现场根本没有用户。它是被 Caller agent 程序化触发的后台服务:没有浏览器弹登录页、没有用户输密码点授权,整个过程必须无人值守、自动完成。

授权类型token 代表谁需要什么BFF 能用吗
client_credentials应用自己只要 client_id/secret是,无人参与,正合适
authorization_code(授权码)某个用户用户在浏览器登录 + 点授权否,后台服务没有浏览器、没有用户来点
implicit(隐式,已废弃)某个用户用户在浏览器否,同上,且不安全已淘汰
password(密码,已不推荐)某个用户要持有用户的账号密码否,你没有、也不该持有用户密码

反过来,如果哪天需求变成"BFF 代表某个具体用户去访问他的私有资源",那就得换成 authorization_code(对应 SDK 里 auth_flow="USER_FEDERATION"),那时会真的弹出 Authentik 登录页让用户授权。

BFF:一个只干一件事的中间人

Authentik 那头点头之后,BFF 的代码只做一件事:按名字取 token。

from bedrock_agentcore.runtime import BedrockAgentCoreApp
from bedrock_agentcore.identity.auth import requires_access_token

app = BedrockAgentCoreApp()

@requires_access_token(
    provider_name="authentikBff"# 对应 agentcore.json 里声明的凭证名
    scopes=["openid""profile"],
    auth_flow="M2M"# 机器身份,无用户参与
    into="access_token")
async def _fetch(*, access_token: str) -> str:
    return access_token

@app.entrypoint
async def invoke(payload, context):
    token = await _fetch()
    return {"ok": True"token_prefix": token[:24]"token_length": len(token)}

@requires_access_token 这个装饰器是第二跳的全部魔法:它内部建一个 IdentityClient,拿着 BFF 的 workload 身份去 Token Vault 换 Authentik token,注入到 access_token 参数。token 的换取、缓存、过期刷新全归它管。BFF 代码里看不到一个 client secret,也没有一行 OAuth 协议代码。

单独部署 BFF 测调用,这里 BFF 收到请求后无视 payload 内容:不管你传 “get token”、“hello” 还是空的:它都只做一件事:调 _fetch_authentik_token() 换 Authentik token 并返回元信息。所以 --prompt 传什么都一样

agentcore deploy --yes
agentcore invoke --runtime BffAgent --prompt "get token"

调用结果成功:

{ "ok": true"token_prefix": "eyJhbGciOiJSUzI1NiIsImtp""token_length": 2728 }

BedrockAgentCoreApp 顺手满足了 AgentCore Runtime 的服务契约(8080 端口 + POST /invocations + GET /ping),所以这个 BFF 本身就是一个标准的 runtime,有自己的 ARN,能被别人调。这一步跑通,意味着"BFF → Authentik"这跳彻底打通了。

别的 Agent 怎么调它:runtime-to-runtime 的问题

BFF 是个 runtime,不是普通 HTTP 服务。所以 Caller 调它不能 httpx.post,得走 AWS 的 InvokeAgentRuntime——和你 agentcore invoke --runtime 底层是同一个 API。代码本身很短:

resp = boto3.client("bedrock-agentcore", region_name=REGION).invoke_agent_runtime(
    agentRuntimeArn=os.environ["BFF_RUNTIME_ARN"],
    runtimeSessionId=f"caller-{uuid.uuid4().hex}",
    runtimeUserId="caller-agent",
    payload=json.dumps({"prompt": "get token"}).encode())
return {"ok": True"bff_response": json.loads(resp["response"].read())}

短归短,这条 runtime-to-runtime 链路遇到了很多问题

session id 太短

ParamValidationError: Invalid length for parameter runtimeSessionId, value: 32, valid min length: 33uuid.uuid4().hex 正好 32 位,差一位。加个前缀凑到 33+ 就行(caller-<hex>)。

IAM 没授权

遇到报错

AccessDeniedException: not authorized to perform: bedrock-agentcore:InvokeAgentRuntime
on resource: .../runtime/firstagent_BffAgent-xxxx/runtime-endpoint/DEFAULT

明明在 Caller 的 spec 里声明了 connections:

"connections": [{"id":"bff""to":{"type":"runtime""arn":"arn:...:runtime/firstagent_BffAgent-xxxx""exec":true}}]

connections 是一个声明式的依赖 + 授权机制——在 spec 里声明"这个 agent 需要访问哪些外部 AgentCore 资源"(runtime / memory / gateway / browser / codeInterpreter),AgentCore 部署时就据此自动给这个 agent 的执行角色生成对应的 IAM 权限,省去手写策略、算 action 名和 Resource ARN。

字段含义:

  • to.type 是资源类别(这里 runtime)
  • to.arn 是目标资源
  • exec: true 表示授予"调用执行"权限(InvokeAgentRuntime)——不加 exec 就只是弱依赖声明、不给调用权。

查生成的 IAM 策略,发现 connections 确实给了 InvokeAgentRuntime 权限——但 Resource 只到 runtime/firstagent_BffAgent-xxxx,而报错里的实际资源是它的子资源 .../runtime-endpoint/DEFAULT。策略 ARN 差了一层通配。补一条 additionalPolicies,Resource 同时含 runtime/xxx runtime/xxx/*,才覆盖到 endpoint:

{ "Effect":"Allow""Action":["bedrock-agentcore:InvokeAgentRuntime""bedrock-agentcore:InvokeAgentRuntimeForUser"]"Resource":["arn:...:runtime/firstagent_BffAgent-xxxx""arn:...:runtime/firstagent_BffAgent-xxxx/*"] }

BFF 拿不到自己的身份。

IAM 通了,Caller 终于连上 BFF,可 BFF 这次自己炸了:

ValueError: Workload access token has not been set. If invoking agent runtime via SIGV4
inbound auth, please specify the X-Amzn-Bedrock-AgentCore-Runtime-User-Id header

BFF 里的 @requires_access_token 要去 Token Vault 换 Authentik token,前提是 BFF 得先证明"我是谁"(workload 身份)。而这个身份来自调用方传的 runtimeUserId——Caller 没传,BFF 就建立不起 workload 身份,自然取不了钥匙。给 Caller 的调用加上 runtimeUserId="caller-agent" 即可。

因果链是这样的:

Caller 调 BFF,传 runtimeUserId="caller-agent"
      │
      ▼
AgentCore 据此为 BFF 这次执行现场建立 workload identity  
      │
      ▼
BFF 里 @requires_access_token 拿着这个 workload token去 Token Vault 换 Authentik 凭证

关键在于workload 身份是"每次调用、按 runtimeUserId 现场建立"的,不是 BFF 自带的静态身份。 所以调用方一旦不传 runtimeUserId,BFF 里任何"凭身份去 vault 取东西"的操作(@requires_access_token / @requires_api_key)全都失败。之所以这么设计,是因为 AgentCore 的凭证托管是按"谁在用"来隔离和授权的——runtimeUserId 让平台知道这次取 token 是代表哪个用户/工作负载,从而能做审计、隔离、乃至多租户下按用户发不同的凭证。这也是它为什么叫 ...Runtime-User-Id:它承载的正是"发起这次调用的身份"。

那"审计、隔离"到底在哪能看到?这条信息两头都留痕——AgentCore 侧和 IDP 侧各记一部分:

IDP(Authentik)侧最直观:M2M 换 token 时:Authentik 会自动创建一个 service account(命名规则 ak-<provider名>-client_credentials:本例即 ak-agentcore-bff-client_credentials):并给每次换 token 记一条 login 事件。

image-20260909154151041

AgentCore 侧:每次调用会在运行时日志里留下 sessionId / requestId / traceId——agentcore logs --runtime <name>(或 CloudWatch 日志组 /aws/bedrock-agentcore/runtimes/<runtime-id>-DEFAULT)里每条 invoke 记录都带这几个字段。runtimeUserId 本身并不会出现在这条运行时日志里runtimeUserId 是作为入站请求头(X-Amzn-Bedrock-AgentCore-Runtime-User-Id)在建立 workload 身份时被消费掉的

{
  "timestamp": "2026-09-09T07:42:52.305Z""level": "INFO""message": "BFF invoked: fetching Authentik token""logger": "bedrock_agentcore.app""requestId": "b148c3f5-fd85-4db4-aede-235c6e201c57""sessionId": "caller-439e12d4229545efa9d36e23fae443a5"
}

隔离则体现在:runtimeUserId 不同 → workload 身份不同 → 可按用户从 Token Vault 取不同的凭证。本例传的是固定的 caller-agent,是单一身份;多租户场景传真实用户 ID,就能做到"用户 A 取 A 的 token、用户 B 取 B 的",互不串号。

见证时刻:全链路跑通

caller agent的实现如下

BFF_RUNTIME_ARN = os.environ.get("BFF_RUNTIME_ARN", "")
REGION = os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "ap-northeast-1"))


@app.entrypoint
async def invoke(payload, context):
    log.info("Caller invoking BFF runtime")
    if not BFF_RUNTIME_ARN:
        return {"ok": False, "error": "BFF_RUNTIME_ARN not set"}

    client = boto3.client("bedrock-agentcore", region_name=REGION)
    resp = client.invoke_agent_runtime(
        agentRuntimeArn=BFF_RUNTIME_ARN,
        runtimeSessionId=f"caller-{uuid.uuid4().hex}",
        runtimeUserId="caller-agent",
        payload=json.dumps({"prompt": "get token"}).encode("utf-8"),
    )
    body = resp["response"].read()
    return {"ok": True, "bff_response": json.loads(body)}

此处的BFF_RUNTIME_ARN是在runtime级别通过环境变量注入的

image-20260909155626881

控制台查看注入结果

CREDENTIAL_*NAME 是 AgentCore 平台的"约定注入" —— 每声明一个 credential,平台就给所有 runtime 注入一个 CREDENTIAL<大写名>_NAME = <凭证名> 环境变量。设计意图是给代码一个"发现凭证名"的标准入口

image-20260909155242717

最后一次 invoke Caller:

agentcore invoke --runtime CallerAgent --prompt "go"

返回

{
  "ok": true"bff_response": {
    "ok": true"token_prefix": "eyJhbGciOiJSUzI1NiIsImtp"// Authentik 签发的 RS256 JWT
    "token_length": 2728
  }
}

这里没有接真实下游服务,而是让 BFF 换到 token 后直接返回它的 prefix 和 length。这足以证明"OAuth 链路通了"——真实下游无非是多带着这个 Bearer token 去 httpx.get 一下,而那属于下游服务的校验范畴(它得自己拉 Authentik 的 JWKS 验签),不是本链路要证明的事。

回头看:Gateway 与 BFF

开头我们用 Gateway 声明式地打发了出站认证,然后自己搭 BFF 把同一件事重走了一遍。现在把两条路并排放,分野只在谁来当中间人:

维度AgentCore Gateway自建 BFF(本文)
中间人是谁AWS 托管的 MCP 网关你自己的 runtime
Agent 侧认证代码零(都收在 BFF 里)
token 换取/刷新Gateway 托管BFF 里的 @requires_access_token 托管
调用协议MCP 工具调用InvokeAgentRuntime
额外资源一个 Gateway(有成本)一个 runtime(自维护)
治理(语义搜索/Cedar 策略)是,内置否,自己接
掌控度平台黑盒完全自控(能塞任意逻辑)
出问题时黑盒,只能看平台给的错误每一层都在你代码/日志里,可断点可插日志

走通了 BFF 这条"手动挡",你再回头用 Gateway 这个"自动挡",就知道它黑盒里到底在替你干什么**——换 token、建 workload 身份、注入 header、刷新过期,一步都没少,只是被 Gateway 藏了起来。

结语

让 Agent 用 OAuth 访问下游,表面上是"换个 token"的事。但把这条链路完整走一遍,你会看清每一层在建立什么样的信任:

  • 钥匙(client secret)从不出现在代码里,它加密躺在 Token Vault,Agent 凭 workload 身份按名取回;
  • BFF 一行 @requires_access_token 的背后,是"证明身份 → 去 vault 换 token → 缓存刷新"的一整套被封装了起来——而这个身份的源头,是调用方传来的 runtimeUserId;
  • 别的 Agent 调 BFF 走的是 InvokeAgentRuntime,不是 HTTP;它要闯过"请求合法、IAM 授权、身份建立"三道关;
  • 而"自建 BFF 还是用 Gateway"这个选择,区别只在中间人是 AWS 的黑盒,还是你自己的 runtime。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐