给 AI Agent 装上门禁:打通 authentik 到 Bedrock AgentCore 的入站 JWT 认证
当你把一个 AI Agent 部署到 Amazon Bedrock AgentCore 上,它默认是"裸奔"的——任何拿到 ARN 和 AWS 凭证的人都能调用它。对一个内部实验也许无所谓,但只要这个 Agent 开始接触真实业务、真实用户、真实数据,一个问题就绕不开了:到底谁有资格调用它?
AgentCore 给了两种入站认证(Inbound Auth):AWS_IAM(用 AWS 凭证做 SigV4 签名)和 CUSTOM_JWT(用 OIDC 身份提供方签发的 JWT)。前者把授权绑死在 AWS IAM 体系里;后者让你能接入自己的身份体系——企业 SSO、自建 IdP、第三方 OAuth。本文走的是后者:用自建的 authentik 作为 OIDC 身份提供方,给一个 AgentCore harness 装上 JWT 门禁。我们可以看到authentik 签发的 JWT 里有什么、Bedrock 凭什么信任它、aud 这个字段到底在防什么、为什么明明配的是 scope 却冒出来一个 audience。
一张图看懂全局
先建立整体认知。这套系统有四个角色:调用方、authentik(IdP)、一个公网 HTTPS 入口(ALB)、AgentCore harness。它们的协作关系是这样的:
一次完整调用的流程是,调用方先拿 client_id + client_secret 向 authentik 换一张 JWT;然后带着这张 JWT(Authorization: Bearer ...)去调 harness;AgentCore 收到后,会去拉 authentik 的 discovery URL 拿公钥验签、核对 aud 等字段,通过了才真正执行 Agent。
把 authentik 跑起来
authentik 是一个开源的身份提供方,用 Docker Compose 部署,标准配置是四个容器:server(Web/API/OIDC 端点)、worker(后台任务)、postgresql(数据持久化)、redis(缓存与队列)。
compose 文件(关键部分):
services:
postgresql:
image: docker.io/library/postgres:16-alpine
restart: unless-stopped
volumes:
- database:/var/lib/postgresql/data
environment:
POSTGRES_PASSWORD: ${PG_PASS:?database password required}
POSTGRES_USER: ${PG_USER:-authentik}
POSTGRES_DB: ${PG_DB:-authentik}
redis:
image: docker.io/library/redis:alpine
command: --save 60 1 --loglevel warning
restart: unless-stopped
volumes:
- redis:/data
server:
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2026.8.1}
command: server
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
ports:
- "${COMPOSE_PORT_HTTP:-9000}:9000"
- "${COMPOSE_PORT_HTTPS:-9443}:9443"
depends_on:
postgresql: { condition: service_healthy }
redis: { condition: service_healthy }
worker:
image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG:-2026.8.1}
command: worker
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_REDIS__HOST: redis
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock
volumes:
database: { driver: local }
redis: { driver: local }
配套的 .env:
PG_USER=authentik
PG_DB=authentik
PG_PASS=<数据库密码>
AUTHENTIK_SECRET_KEY=<用 openssl rand -base64 60 生成>
AUTHENTIK_TAG=2026.8.1
COMPOSE_PORT_HTTP=9000
COMPOSE_PORT_HTTPS=9443
注意 server 只把 9000/9443 映射到宿主机——它自己不管对外 TLS,那是下一层 ALB 的活。启动后访问 http://<host>:9000/if/flow/initial-setup/ 设初始管理员,再用健康检查确认活着:
docker compose up -d
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:9000/-/health/live/ # 期望 200
到这里 authentik 活了,但它只在 EC2 本机的 9000 端口上,Bedrock 还够不着它。这就引出了下一层——为什么需要一个 ALB。
ALB 一个绕不过去的中间人
最初的直觉是:给域名解析到 EC2 的公网 IP,再让 authentik 自己跑 HTTPS,不就完了?两个硬性障碍立刻把这条路堵死。
-
ACM 证书装不上 EC2。 我手上覆盖
*.example.com的证书是 ACM 托管的(AMAZON_ISSUED),而 ACM 托管证书的私钥不可导出——它只能绑定到 AWS 托管服务(ALB、CloudFront、API Gateway),没法塞进 EC2 上的 authentik 容器。 -
Bedrock 从公网来。 AgentCore 校验 JWT 时,是它的控制面主动发起一个 HTTPS GET 去拉 discovery URL 验签。这个请求来自 AWS 管理网络,走公网。所以 IdP 必须公网可达、且必须是 HTTPS(HTTP 直接不接受)。
两个障碍合起来,答案只有一个:在 authentik 前面放一个 internet-facing ALB,证书装 ALB 上,ALB 用私有 IP 把流量转发给后端的 authentik:9000。
ALB 这一层关键资源包括:ALB 安全组(只放 443/80)、一条允许 ALB 访问实例 9000 的入站规则、internet-facing 的 LoadBalancer、TargetGroup(TargetType=ip,健康检查路径 /-/health/live/)、443 的 HTTPS Listener(挂证书)、80 跳 443 的 Listener、以及一条 Route53 A 记录 alias 到 ALB。
这里有个关键约束值得单独强调——ALB 的入站安全组必须让 Bedrock 进得来。Bedrock 拉 discovery 的源 IP 不固定(来自 AWS 管理网络),所以最简单可行的做法是入站 443 源用 0.0.0.0/0(或用 AWS 官方发布的 IP 段收敛)。
ALB 就位后,https://auth.example.com 就能从公网访问到你的 authentik 了。
让 authentik 签出正确的 JWT
在 authentik 里,OIDC 认证由两个对象组成:Provider(定义 OAuth2/OIDC 的技术参数)和 Application(对外的应用实体,绑定一个 Provider)。
建 Provider 和 Application
Provider(Applications > Providers > Create > OAuth2/OpenID Provider):
- Client type 选 Confidential(client_credentials 流需要 client secret)
- 记下自动生成的 Client ID / Client Secret
- Redirect URIs 在 M2M 场景可留空

创建Application
- Slug 设为
firstagent——这个 slug 决定了 discovery URL 的路径 - Provider 绑上一步建的那个

aud:这张票是给谁的门
Provider 建好了,取一张 token 解码看看,你会发现一个问题:AgentCore 要校验的 aud(audience)字段不对。
先搞清楚 aud 到底是什么、为什么 AgentCore 非要校验它。aud 是 JWT 标准里的 audience claim,语义是"这张 token 是签发给谁消费的"。AgentCore 校验它,是为了防止 token 被挪用——设想你的 authentik 同时给内部 Wiki、报表系统、AgentCore 都签发 token,如果 AgentCore 不查 aud,那么一张"本来是给 Wiki 用的"合法 token,被人拿来调 AgentCore 也会放行,因为签名、issuer、有效期全都合法。aud 校验就是那个检票员,确认"你这张票确实是我这个门的票",而不是随便一张真票都放进。
所以 AgentCore 侧配 allowedAudience: ["agentcore-runtime"],就要求 token 的 aud 里必须包含 agentcore-runtime。但 authentik 默认签发的 token,aud 是 client_id,不是你想要的 agentcore-runtime。两者对不上,一律被拒。
那怎么让 authentik 往 token 里塞 aud=agentcore-runtime?authentik 没有一个直白的"Audience 字段"给你填。它往 token 加自定义 claim 的唯一机制叫 Scope Mapping——而这个名字非常有误导性。
Customization > Property Mappings > Create > Scope Mapping:
-
Name:
agentcore-audience -
Scope name:
agentcore-aud -
Expression(必须有
return,否则什么都不输出):return {"aud": "agentcore-runtime"}

然后回到 Provider 的 Advanced protocol settings > Scope Mappings,在保留 email/openid/profile 的基础上,把 agentcore-audience 也加进去。

为什么"配了一个 scope,却产出了一个 aud"? 这是最让人困惑的地方。authentik 的 "Scope Mapping" 其实是个"claim 注入器",它有两个独立部分:
- Scope name(触发器):
agentcore-aud。取 token 时请求里带上这个 scope 名,这个 mapping 才会被执行。 - Expression(产出):
return {"aud": ...}。被执行时往 token 里加什么 claim,完全由表达式决定,跟 scope 名叫什么无关。你写return {"aud": ...}它就加aud,你写return {"foo": "bar"}它就加foo。
所以"scope 创建了 aud"的真相是一条触发链,而不是"scope 等于 aud":
1. 请求 scope=agentcore-aud
2. 该 scope 名触发对应的 Scope Mapping
3. authentik 执行表达式 return {"aud": "agentcore-runtime"}
4. token 里被写入 aud=agentcore-runtime
还有一个容易忽略的细节:自定义 scope mapping 的 claim 只进 access token,不进 ID token。ID token 的 aud 恒等于 client_id,覆盖不了。而 AgentCore 验证的是 access token,所以这套才成立。
验证 token
取一张 token 解码,确认三样东西对上:
CID="<client_id>"; CSEC="<client_secret>"
curl -s -X POST "https://auth.example.com/application/o/token/" \
-d "grant_type=client_credentials" -d "client_id=$CID" -d "client_secret=$CSEC" \
-d "scope=openid agentcore-aud"
解码 access_token 的 payload,应该看到:
iss = https://auth.example.com/application/o/firstagent/ # https 域名
aud = agentcore-runtime # 表达式产出的受众
azp = <client_id> # 注意:client_id 在 azp,不在 client_id claim
scope = openid agentcore-aud # 触发器
同时确认经公网的 discovery 文档:
curl -s "https://auth.example.com/application/o/firstagent/.well-known/openid-configuration"
# issuer 必须是 https 域名,且与上面 token 的 iss 完全一致
这一步能踩到第二个大坑:如果你在 EC2 本机直连 localhost:9000 拉 discovery,会看到 issuer: http://localhost:9000/...——这是错的。因为 authentik 按请求的 Host 头生成 issuer,本机直连时 Host 是 localhost。一定要经 ALB 用真实域名访问,这时它才生成正确的 https issuer。AgentCore 要求 discovery 的 issuer 和 token 的 iss 完全一致,对不上就验签失败。
接线并部署
最后把它们接到 harness 配置里。harness.json 的最终形态:
{
"name": "MyHarness",
"executionRoleArn": "arn:aws:iam::<ACCOUNT_ID>:role/<your-harness-exec-role>",
"authorizerType": "CUSTOM_JWT",
"authorizerConfiguration": {
"customJwtAuthorizer": {
"discoveryUrl": "https://auth.example.com/application/o/firstagent/.well-known/openid-configuration",
"allowedAudience": ["agentcore-runtime"]
}
},
"model": { "provider": "bedrock", "modelId": "openai.gpt-oss-120b", "apiFormat": "chat_completions" },
"tools": [],
"skills": [],
"memory": { "mode": "disabled" },
"maxIterations": 10
}
几个要点:
discoveryUrl / allowedAudience / allowedClients / allowedScopes里至少配一个做授权判据(光有 discoveryUrl 不够,那只是验签基础)。- 千万别配
allowedClients。allowedClients校验的是 token 的client_idclaim,但前面验证时看到了,authentik 把 client_id 放在azp,根本没有client_idclaim。配了allowedClients会直接报Claim 'client_id' value mismatch with configuration.。用allowedAudience就够了,它足以唯一标识合法调用方。
部署(agentcore deploy 会合成 CDK 并部署):
agentcore validate # 期望 Valid
agentcore deploy
合法放行,非法拦截
部署成功不等于认证生效。真正的验证要做三重测试——光测"能进"不够,还得测"进不来",否则你根本不知道门禁是真在拦还是形同虚设。
分两步:先从 authentik 拿 JWT,再带着这个 token 调 harness。
第 1 步:从 authentik 取 JWT
用 client credentials 流,拿到 access token:
CID="z8CtfralpPVBJhTM9EavdJhzNh0Ff7LSoeEjhI9R"
CSEC="<你的 client_secret>"
TOKEN=$(curl -s -X POST "https://auth.example.com/application/o/token/" \
-d "grant_type=client_credentials" \
-d "client_id=$CID" \
-d "client_secret=$CSEC" \
-d "scope=openid agentcore-aud" \
| python3 -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
关键点:scope 必须带上 agentcore-aud,否则 token 里不会有 aud=agentcore-runtime,harness 会拒。
第 2 步:带 --bearer-token 调 harness
用 agentcore invoke 的 --bearer-token 参数(它会把 token 放进 Authorization: Bearer 头,绕过 SigV4):
agentcore invoke \
--harness-arn "arn:aws:bedrock-agentcore:ap-northeast-1:xxxxxxx:harness/firstagent_MyHarness-k222T3mj8l" \
--region ap-northeast-1 \
--bearer-token "$TOKEN" \
--prompt "xxxxxxxxxxxxx"
测试结果如下
| 测试 | 做法 | 期望 | 实测 |
|---|---|---|---|
| 合法 JWT | agentcore invoke ... --bearer-token "$TOKEN" |
正常响应 | 通过(Agent 正常回复) |
| 不带 token | agentcore invoke ...(无 token) |
403 requires OAuth Bearer token | 通过(被拒) |
| 无效 token | --bearer-token "<伪造>" |
拒绝(验签失败/过期) | 通过(被拒) |
三个测试全绿,才能说这道门禁真正立住了:合法凭证进得来,无凭证和假凭证都进不来。
顺带厘清一个概念边界:JWT 校验其实是多关的,aud 只是其中一关。AgentCore 会同时校验签名(用 discovery 的 jwks 公钥)、iss(签发方)、exp(过期)、aud(受众)——任何一关不过都拒绝。所以准确说法是:"aud 不匹配会拒,但 aud 匹配也不代表放行,签名/iss/exp 也都得过。"
结语
给一个 AI Agent 加认证,表面上是"填几个字段、配一个 IdP"的事。但真把它从零搭一遍,你会看清这条链路上每一环在做什么、为什么这么做:
- 一张 JWT 是怎么被签发、怎么被 Bedrock 验签、
aud又怎么一步步变成"这个调用方能不能进"的判断; - 为什么明明配的是 scope,token 里却冒出 aud——authentik 的 Scope Mapping 本质是个 claim 注入器,scope 是触发器、表达式才是产出;
- 为什么一张 ACM 证书能逼你凭空多搭一个 ALB——托管证书私钥不可导出这个约束,决定了架构形状;
这些原理一旦内化,你面对任何 Agent 的认证接入——无论是 authentik、Keycloak、Cognito,还是企业自有的 SSO——都不再是对着文档抄配置,而是能看透每一层在验什么、信任从哪来、边界在哪里。门禁不再是一个黑盒开关,而是一条你完全理解的信任链。

浙公网安备 33010602011771号