给 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。它们的协作关系是这样的:

flowchart TD caller["调用方"] authentik["authentik OIDC IdP<br>EC2 Docker server worker postgres redis<br>端口 9000"] alb["ALB 443 挂 ACM 证书<br>DNS auth.example.com<br>转发到私有IP 9000"] bedrock["Bedrock AgentCore 控制面"] harness["harness MyHarness<br>CUSTOM_JWT 入站认证 加 自建执行角色"] caller -->|"1 client_id 加 secret 取 JWT"| authentik bedrock -->|"2 拉 discoveryUrl 验签 校验 aud"| alb alb --> authentik caller -->|"3 带 Bearer JWT 调用"| harness harness -->|"校验入站 JWT"| bedrock

一次完整调用的流程是,调用方先拿 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。

flowchart LR dns["auth.example.com"] --> alb["ALB 443<br>挂 ACM 证书"] alb -->|"转发到私有IP 9000"| authentik["authentik 容器"]

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 场景可留空

image-20260907175121025

创建Application

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

创建 Application 并绑定 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"}
    

创建 audience scope mapping

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

把 agentcore-audience 加入 provider 的已选作用域

为什么"配了一个 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_id claim,但前面验证时看到了,authentik 把 client_id 放在 azp,根本没有 client_id claim。配了 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——都不再是对着文档抄配置,而是能看透每一层在验什么、信任从哪来、边界在哪里。门禁不再是一个黑盒开关,而是一条你完全理解的信任链。

posted @ 2026-09-07 17:56  zhaojie10  阅读(12)  评论(0)    收藏  举报