client credentials 与令牌交换(亲手搭建 STS)
目标
在 Keycloak 中用 client credentials 获取并确认服务令牌,用本地 STS 亲手搭建 Keycloak 26.0.7 所没有的标准令牌交换(RFC 8693),把用户令牌换成下游专用的委托令牌,然后用真实请求证明:下游会检查 aud 和 act,伪造的令牌会被拒绝。
为什么重要
服务代表用户去调用其他服务时,如果把收到的用户令牌原样转发,下游要接受它就得关掉 aud 检查,也不知道是谁代为调用的,权限也没有缩小。一处泄露的令牌就成了整条链的钥匙。如果只使用服务自己名义的令牌(client credentials),这次又会丢失“这是为谁发的请求”。令牌交换保留用户身份,同时把 audience 收窄到下游,并在 act 中留下行为者,从而同时解决这两个问题。这时如果 STS 不验证 subject_token,就会变成一台用自己的签名把伪造令牌洗白的机器;如果下游不看 aud 和 act,交换就没有意义。Keycloak 从 26.2 起正式支持标准交换,所以版本不足时需要自己动手做什么,也会在本实验中看到。
步骤
- 如果 Keycloak 是关着的,就用
lab-start-keycloak启动,并用/root/st/exchange/wait.sh最多等 240 秒,直到http://127.0.0.1:8080/realms/labhub返回 200,然后在/root/st/exchange/ready.txt中写入ready_seconds=<정수>(占位符为整数)。 - 用机密客户端
api-svc获取 client credentials 令牌,保存到/root/st/exchange/svc_token.txt,验证签名后,在/root/st/exchange/svc_claims.txt中写入azp=、aud=、username=、roles=、refresh_token=。 - 向 Keycloak 发出 token-exchange grant 请求,把结果以
kc_exchange_error=、token_exchange_advertised=写入/root/st/exchange/kc_exchange.txt,并在 127.0.0.1:8308 上启动本地 STS/root/st/exchange/sts.py(/health、/jwks、签名密钥/root/st/exchange/sts_key.pem)。 - 以 dev1 令牌为 subject_token、api-svc 令牌为 actor_token,换取
audience=orders-api的交换令牌,放到/root/st/exchange/exchanged_token.txt,并把验证过的值写入/root/st/exchange/exchanged.txt。辅助脚本做成/root/st/exchange/tokens.sh。 - 在 127.0.0.1:8309 上启动下游服务
/root/st/exchange/downstream.py,用四种令牌调用GET /orders,把状态码写入/root/st/exchange/downstream.txt。 - 把伪造、篡改、无签名的 subject_token、缺少 actor_token、未知的 audience,以及正常的对照组发给 STS,写入
/root/st/exchange/reject.txt。 - 用
/root/st/exchange/e2e.sh测试整个流程,在/root/st/exchange/e2e.out中留下七行。
参考
- Realm 信息(令牌 URL、客户端、用户、密钥)用
cat /opt/fixtures/kc/realm-info.env查看。不要把密钥抄写到文件或代码里。 - 令牌类型标识符是
urn:ietf:params:oauth:token-type:access_token,grant 是urn:ietf:params:oauth:grant-type:token-exchange。 - 这个镜像里没有 python-multipart。STS 用
http.server编写,form 用urllib.parse.parse_qs读取。 - 常见错误 1:STS 用
jwt.decode(..., options={"verify_signature": False})只读取 subject_token——第 6 步的伪造令牌会全部通过。 - 常见错误 2:下游只看签名而不看 aud 和 act——连 Keycloak 原始令牌或其他下游用的令牌也会接受。
等待 Keycloak 就绪
如果 Keycloak 是关着的,就用 lab-start-keycloak 启动,并用 /root/st/exchange/wait.sh 最多等 240 秒,直到 http://127.0.0.1:8080/realms/labhub 返回 200,然后把耗时按 ready_seconds=<정수>(占位符为整数)写入 /root/st/exchange/ready.txt。
Keycloak 是 JVM,需要 40–90 秒。不要用固定的 sleep,而要用轮询状态码的循环。用 lab-status 可以看到什么在运行。
用 client credentials 获取服务令牌
用机密客户端 api-svc 获取 grant_type=client_credentials 令牌,保存到 /root/st/exchange/svc_token.txt 的第一行,用 Realm 的 JWKS 验证签名后,把 azp=、aud=(用逗号连接)、username=、roles=、refresh_token=present|absent 写入 /root/st/exchange/svc_claims.txt。客户端密钥从 /opt/fixtures/kc/realm-info.env 的 KC_CONFIDENTIAL_SECRET 中读取,不要抄写到文件里。
client credentials 是不依赖用户、以服务自己名义获取的令牌(RFC 6749 第 4.4 节)。令牌端点是 realm-info.env 中的 KC_TOKEN_URL。这是谁的令牌,由 azp 和 preferred_username 说明。也请确认响应 JSON 里有没有 refresh_token。
确认 Keycloak 的局限并启动本地 STS
向 Keycloak 以 grant_type=urn:ietf:params:oauth:grant-type:token-exchange 发出请求,把返回的 error 值以 kc_exchange_error= 写入 /root/st/exchange/kc_exchange.txt,并把 discovery 文档的 grant_types_supported 中是否有该 grant,以 token_exchange_advertised=yes|no 一并写入。然后在 127.0.0.1:8308 上启动本地 STS /root/st/exchange/sts.py。GET /health 返回 {"ok":true},GET /jwks 返回带 kid 的 RSA 公钥,签名私钥放在 /root/st/exchange/sts_key.pem。POST /token 负责处理 RFC 8693 交换(在第 4、6 步评分)。
这个 Pod 的 Keycloak 是 26.0.7,标准令牌交换(V2)从 26.2 开始提供。discovery 地址是在签发方后面加 /.well-known/openid-configuration。STS 必须用 Keycloak 的 JWKS 验证 subject_token(PyJWT 的 PyJWKClient),form 正文用 parse_qs 读取。
把用户令牌交换成 orders-api 专用令牌
把 dev1 的用户令牌(web-app,password grant)作为 subject_token,把 api-svc 的 client credentials 令牌作为 actor_token,向 STS 发送 POST /token(audience=orders-api),得到交换令牌。新令牌要沿用原用户的 sub,在 act 中放入行为者的 sub 和 client_id,roles 只放原用户角色中该下游所需要的,寿命必须比原令牌(900 秒)短。把令牌放到 /root/st/exchange/exchanged_token.txt,用 STS 公钥验证,并把 issued_token_type=、iss=、aud=、sub_matches=yes|no、act_client_id=、roles=、ttl= 写入 /root/st/exchange/exchanged.txt。签发和交换的辅助脚本做成 /root/st/exchange/tokens.sh,在之后的步骤中使用。
在 RFC 8693 中,subject_token 和 subject_token_type 是必填的,响应中必须有 issued_token_type。令牌类型标识符是 urn:ietf:params:oauth:token-type:access_token。curl 请用 --data-urlencode 传值。
让下游检查 aud 和 act
在 127.0.0.1:8309 上启动下游服务 /root/st/exchange/downstream.py。GET /orders 只对由 STS 签名、aud=orders-api 且 act.client_id 为 api-svc 的令牌返回 200 和 {"user":..., "actor":...},其余一律返回 401。把四个请求的状态码按 exchanged=(正常交换令牌)、keycloak_direct=(Keycloak 的原始用户令牌)、other_aud=(billing-api 用的交换令牌)、no_act=(用 STS 密钥签名但没有 act 的令牌)写入 /root/st/exchange/downstream.txt。
给 PyJWT 的 jwt.decode 传入 audience 和 issuer,就会一并检查 aud 和 iss。act 是 PyJWT 不认识的声明,必须自己检查。RFC 8693 要求访问控制只使用最外层的 act。
拒绝伪造的 subject_token 和未知的 audience
测试 STS 是否会拒绝验证失败的请求,并写入 /root/st/exchange/reject.txt。tampered=(保留签名只改了 sub 的令牌)、forged_key=(用另一把 RSA 密钥签名、只是模仿 Keycloak 的 kid 的令牌)、alg_none=(无签名令牌)、no_actor=(没有 actor_token)写状态码,bad_audience= 写 audience=admin-api 时的 error 值,control= 写正常请求的状态码。
RFC 8693 规定,subject_token 无效时用 invalid_request,无法为所请求的 audience 签发时用 invalid_target。不做签名验证、只做解码的 STS,以及没有固定 algorithms 的 STS,会在这里暴露。评分器还会发送用 Realm 密钥签名的过期令牌和 aud 不同的令牌。
一次性证明完整流程
用 /root/st/exchange/e2e.sh 一次性测试整个流程,在 /root/st/exchange/e2e.out 中留下 keycloak_exchange=(Keycloak 拒绝交换的 error)、exchange=、act=、downstream=、direct_keycloak=、forged_subject=(签名被破坏的 subject_token 的状态码)、bad_audience= 七行。
用 tokens.sh 辅助脚本把前面几步串起来即可。所有值都不要手写进文件,而要通过请求结果得到。评分器除了看文件,还会亲自再确认一次交换→下游的流程。