让 Agent 安全地“借用别人的钥匙“:AgentCore 上用 Authentik 打通 OAuth 出站的一次实战

一个 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 侧把这个下游当成一个工具来调,完全不碰 token。outboundAuth.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 也知道它黑盒里在干什么。
建立整体认知。整条链路上有两跳,每一跳的认证机制完全不同,这是理解全局的关键。
- 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):加密存储

-
部署后 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托管创建的,因此用户无法直接修改密钥的值。

让 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 和下游验签要用的端点:

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。补上就好:

改完再换,立刻拿到一个 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: 33。uuid.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 事件。

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级别通过环境变量注入的

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

最后一次 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。
更多推荐


所有评论(0)