Keycloak FAPI 2.0 金融级 IAM 配置:Client Policy 绑定与破坏性影响 | IDaaS Book
场景与边界
- 安全评审或外部审计给的结论是「必须满足 FAPI 2.0」,但没人说清在 Keycloak 上这条要求具体落到哪个开关、会影响哪些存量应用。
- 你在 Realm Settings → Client Policies 里把某个
fapi-2-*全局 profile 挂到一个策略上,之后出现三种一眼看不出因果的故障:客户端invalid_client、invalid audience in client assertion、用户突然看到同意页。 - 反过来,你以为「客户端用了 PKCE + PAR 就已经是 FAPI 2.0」——FAPI 2.0 是授权服务器与客户端双方都要满足的规范,服务端侧的强制项由 client profile 的执行器(executor)逐条落实,不是靠自己拼参数拼出来的。
适用:需要在 Keycloak 26.x 上落地 FAPI 2.0(或 FAPI 1.0 Advanced / CIBA)的银行、证券、支付、开放银行类项目;正在做金融合规改造、要把存量应用批量纳入合规范围、且必须有回滚路径的团队。
不适用:只想给内部系统加 SSO 的项目——FAPI 2.0 的代价(PAR 强制、client_secret 全废、同意页、full scope 关闭)远大于收益,用 OAuth 2.1 客户端 profile 或单个加固项更合适;只做 client credentials 的服务间调用(没有前端授权请求,PAR/PKCE 不参与)也不需要整套 FAPI profile。
本文基线:Keycloak 26.8.0 标签源码(keycloak-default-client-profiles.json 与 clientpolicy/executor/*.java)与官方文档,核查日期 2026-10-07。下文严格区分规范要求、Keycloak 实现行为与本书建议,源码级推断会单独标注。
先分清你要的是哪个 FAPI 版本
Keycloak 从 26.4.0 起支持 FAPI 2.0 的两份 Final 规范(26.4.0 发布说明原文:FAPI 2 Final: Keycloak now supports the final specifications of FAPI 2.0 Security Profile and FAPI 2.0 Message Signing),并且这些 profile 已通过 FAPI 一致性测试套件。官方文档(Securing applications → OpenID Connect layers → Financial-grade API (FAPI) Support)列出的支持范围是五份规范:FAPI 1.0 Baseline、FAPI 1.0 Advanced、FAPI CIBA、FAPI 2.0 Security Profile (Final)、FAPI 2.0 Message Signing (Final)。
| 你的合规依据 | 该用哪个 Keycloak 全局 profile | 一个必须先知道的硬要求 |
|---|---|---|
| FAPI 1.0 Part 1(Baseline) | fapi-1-baseline | 强制 PKCE(S256);用 PAR 时官方建议 baseline + advanced 一起挂,因为 pkce-enforcer 只在 baseline 里 |
| FAPI 1.0 Part 2(Advanced) | fapi-1-advanced | 强制 confidential 客户端、签名 request object、mTLS 持有者令牌 |
| FAPI CIBA | fapi-1-advanced + fapi-ciba | 单独挂 fapi-ciba 不成立:它只含 CIBA 专用执行器,confidential / HoK 这些要求来自 advanced |
| Open Finance Brasil、Australia CDR | fapi-1-advanced | 两者都比 FAPI 1.0 Advanced 更严(加密 request object、固定算法),官方文档要求在此基础上再收紧 |
| FAPI 2.0 Security Profile (Final) | fapi-2-security-profile(mTLS)或 fapi-2-dpop-security-profile(DPoP) | 强制 PAR、强制 PKCE(S256)、禁止 implicit、客户端认证只允许 private_key_jwt / mTLS |
| FAPI 2.0 Message Signing (Final) | fapi-2-message-signing / fapi-2-dpop-message-signing | 在 Security Profile 之上再要求签名 request object |
一个容易被忽略的边界(官方文档明确写出):客户端侧 Keycloak 帮不了你——“Keycloak adapters do not have any specific support for the FAPI”,服务端只负责校验授权服务器该满足的部分,应用侧是否真的按 FAPI 2.0 发请求,要么自行实现,要么交给第三方 FAPI 客户端库。把 profile 挂上就宣称"通过 FAPI 2.0"是不成立的。
四个 fapi-2 profile 差在哪(源码级对照)
下面这张表不是文档翻译,而是从 26.8.0 的 keycloak-default-client-profiles.json 里逐个 executors 数组还原出来的。差异集中在发送方约束方式(mTLS 还是 DPoP)和是否要求签名 request object:
| 执行器 | fapi-2-security-profile | fapi-2-dpop-security-profile | fapi-2-message-signing | fapi-2-dpop-message-signing |
|---|---|---|---|---|
confidential-client | ✅ | ✅ | ✅ | ✅ |
secure-client-authenticator | ✅ 只允许 client-jwt(private_key_jwt)/ client-x509 | ✅ | ✅ | ✅ |
secure-client-authentication-assertion | ✅ | ✅ | ✅ | ✅ |
secure-client-uris | ✅(只收 HTTPS、不收通配符) | ✅ | ✅ | ✅ |
secure-signature-algorithm | ✅ 默认 PS256 | ✅ | ✅ | ✅ |
pkce-enforcer | ✅(S256) | ✅ | ✅ | ✅ |
reject-implicit-grant | ✅ | ✅ | ✅ | ✅ |
secure-par-content | ✅ | ✅ | ✅ | ✅ |
consent-required / full-scope-disabled | ✅ 自动改写客户端 | ✅ | ✅ | ✅ |
| 发送方约束 | holder-of-key-enforcer(mTLS 证书绑定) | dpop-bind-enforcer | holder-of-key-enforcer | dpop-bind-enforcer |
secure-request-object | ❌ | ❌ | ✅(available-period=3600、verify-nbf=true、不强制加密) | ✅ |
dpop-bind-enforcer 在该 profile 中的配置是 auto-configure=true、enforce-authorization-code-binding-to-dpop=false、allow-only-refresh-token-binding=false。前一项设为 true 时会强制授权请求携带 dpop_jkt(把整条授权码流程提前绑到密钥),这与 DPoP 证明的细节见
OAuth 2.0 DPoP 深度解析。
本书建议:企业自建、有跨机房的资源服务器时选 mTLS 版(holder-of-key-enforcer)——证书链和信任库本来就是运维已有资产,不需要在应用侧改每一次 API 调用的请求头;有移动端、SPA、或无法部署双向 TLS 的第三方接入方时选 DPoP 版。不要把两个 profile 同时挂到一个客户端上,两个执行器会同时要求证书和 DPoP proof。
绑定方式:全局 profile 本身不会生效
两个事实决定了落地步骤,都在官方文档里写明:
- 每个 realm 都有这些全局 profile,但默认没有任何 client policy,全局 profile 也不能被修改。要生效,必须自己建一条 client policy,在条件和 profile 之间建立引用;想微调就把全局 profile 当模板另存一份。
- client policy 不作用于 client scope 上的 protocol mapper 操作(官方文档的 NOTE)。指望它顺带管住 client scope 级别的 mapper 是不成立的。
最小可用的一条策略(Admin Console:Realm settings → Client policies → Create client policy → 加条件 Client Roles,role 名例如 fapi-required → Add profile → 选 fapi-2-dpop-security-profile → Enabled → Save)。用角色条件而不是 Any Client 的原因:角色条件是"按客户端显式登记"的白名单语义,不会被后续新建的无关客户端继承;Any Client 会把 admin-cli、security-admin-console 一起圈进来,配合 full-scope-disabled 的自动改写足以把管理员锁在控制台外(这个坑的完整排除做法见
Keycloak 26.8.0 升级:IAM 破坏性变更排查)。
用 Admin REST API 落地时注意语义,这一点源码里写得很直白:
# 1. 先读出当前策略集合(不要跳过这一步)
curl -s -H "Authorization: Bearer $TOKEN" \
"https://kc.example.com/admin/realms/<realm>/client-policies/policies" > policies.json
# 2. 合并后整体提交(PUT 是「以提交的集合为准」,只提交一条新策略会覆盖掉原有策略)
python3 - <<'PY'
import json
d = json.load(open('policies.json'))
d.setdefault('policies', []).append({
"name": "fapi2-payments",
"description": "FAPI 2.0 for payment clients",
"enabled": True,
"conditions": [{"condition": "client-roles", "configuration": {"roles": ["fapi-required"]}}],
"profiles": ["fapi-2-dpop-security-profile"]
})
json.dump(d, open('policies.json', 'w'), ensure_ascii=False)
PY
# 3. 提交
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
--data @policies.json \
"https://kc.example.com/admin/realms/<realm>/client-policies/policies"端点与语义依据:ClientPoliciesResource 只提供 GET(可选 ?include-global-policies=true 才能看到全局 profile)与 PUT(updatePolicies 接收的是完整的策略集合表示,服务端以提交内容整体写回 realm 属性)。先 GET、再合并、最后 PUT 是唯一安全的写法。引用自定义 profile 时把 profiles 里的名字换成自己另存的名字。
另外两条规则来自官方文档、排查时必须记得:条件求值默认是"至少一个 yes 且没有 no"(abstain 忽略),只有把 policy 设成 STRICT 才要求全部 yes;部分条件只在客户端注册/更新时求值,部分只在运行期请求里求值,所以挂完策略必须实测一遍,不能靠读配置推断。
绑定之后,客户端元数据会被改写
FAPI profile 里带 auto-configure 的执行器会在客户端创建或更新时直接改写客户端表示,而不是只在请求时校验:
| 客户端字段 | 谁改的 | 实际后果 |
|---|---|---|
consentRequired = true | consent-required | 授权码流程的用户会多出一个授权确认页;E2E 测试、prompt=none 场景会破;此后任何一次客户端更新试图把这个开关关掉,都会被拒(Client is required to enable consentRequired) |
fullScopeAllowed = false | full-scope-disabled | token 里的角色/resource_access 只来自客户端已显式分配的 scope,存量应用最常见的表现是"登录成功但后端全 403" |
useDpop / useMtlsHoKToken 等属性 | dpop-bind-enforcer / holder-of-key-enforcer | 客户端被写上使用 DPoP 或 mTLS 绑定令牌的属性,客户端若不配合就取不到可用 token |
| 请求对象/断言签名算法属性 | secure-signature-algorithm | 未显式指定算法时被写成 PS256 |
关键点:改写只发生在"被策略覆盖到的那一次创建/更新"上,不追溯存量客户端。所以同一套 profile 下会长期存在"已合规客户端"和"未合规客户端"两种状态,审计时要按客户端逐个看实际字段,而不是按策略是否启用判断。
三处最容易被误判成「密钥配错」的失败
1. invalid audience in client assertion:aud 只能填 issuer
FAPI 2.0 Security Profile (Final) 对授权服务器的要求是:shall only accept its issuer identifier value (as defined in RFC 8414) as a string in the aud claim received in client authentication assertions。
Keycloak 的实现和这条完全对齐:secure-client-authentication-assertion 执行器要求 client assertion 的 aud 恰好一个值,且等于 realm issuer URL(https://kc.example.com/realms/<realm>,不含 token 端点路径)。而 Keycloak 默认的 JWT 客户端认证校验器是更宽松的——它接受 5 个地址中的任意一个,包括 token 端点、introspection、PAR、CIBA 端点(见
客户端认证方式选型与凭据轮换)。
于是出现最常见的一类事故:同一个基于 RFC 7523 的客户端断言,填 aud=<token endpoint> 时长期工作正常,一旦所在客户端被纳入 fapi-2 策略,就在下一次请求直接 invalid_request: invalid audience in client assertion。这不是配置退化,是合规收紧。修法是让调用方把 aud 收敛成单个 issuer 字符串(多数 FAPI 客户端库有这个开关),不要试图用 allow-multiple-audiences-for-jwt-client-authentication 绕过——那个 SPI 开关放宽的是默认校验器,执行器这一层只看 issuer。
2. Configured client authentication method not allowed for client
secure-client-authenticator 在 fapi-2 四个 profile 里的允许清单只有 client-jwt(private_key_jwt)和 client-x509。它的校验点是(源码 switch 分支):客户端注册/更新时、以及 token、service account token、refresh、revoke、introspect、logout 六类请求运行时。两种报错形态要分得清:
- 注册或更新客户端:
400 invalid_client_metadata+Invalid client metadata: token_endpoint_auth_method - 运行期请求:
400 invalid_request+Configured client authentication method not allowed for client,同时服务端日志(logger.warnf)会打印Client authentication method not allowed for client: <clientId>——这是定位"是哪个客户端"最快的入口
公开客户端豁免这条校验(由 confidential-client 单独把关),所以纯 SPA 不会被这条打回,但会被下一条打回。
3. invalid client access type: public
confidential-client 在授权请求、token、service account token、CIBA 请求上校验访问类型:public 客户端报 invalid_client + invalid client access type: public,bearer-only 客户端报 invalid client access type: bearer only。
这意味着 FAPI profile 无法直接套在公开客户端上——这是规范使然(FAPI 2.0 要求客户端认证)。工程上的选择只有两个:把该客户端改成 confidential 并用 private_key_jwt(SPA 场景即 BFF 模式,见 IAM BFF 模式与 SPA 令牌安全),或者用条件把它排除在策略之外、接受它不属于 FAPI 合规范围。
附带一条:算法白名单里没有 EdDSA
FAPI 2.0 Security Profile 允许的签名算法是 PS256、ES256 或 EdDSA(Ed25519);Keycloak 的 FapiConstant.ALLOWED_ALGORITHMS 是 PS256/PS384/PS512/ES256/ES384/ES512,secure-signature-algorithm 与 secure-signing-algorithm-signed-jwt 两个执行器都按这个清单校验,不在清单内直接 invalid_request: not allowed signature algorithm.(源码级推断:清单用 contains 判断,EdDSA 未列入即被拒)。如果对接方的 FAPI 客户端库默认用 Ed25519 签 request object 或 client assertion,先改成 PS256/ES256 再接入,比排查"为什么签名有效却报错"划算。
FAPI 2.0 请求链路上各执行器拦在哪
flowchart TD
A[客户端 / FAPI 客户端库] -->|1. PAR: POST /ext/par/request<br>必须带 redirect_uri| B[PAR 端点]
B -->|secure-par-content| B1{redirect_uri 存在?}
B1 -->|否| E1[invalid_request:<br>PAR is required to have a 'redirect_uri' parameter]
B -->|secure-client-authenticator| B2{认证方式 ∈ client-jwt / client-x509?}
B2 -->|否| E2[invalid_request:<br>Configured client authentication method not allowed for client]
B -->|secure-client-authentication-assertion| B3{assertion aud == realm issuer?}
B3 -->|否| E3[invalid_request:<br>invalid audience in client assertion]
B -->|request_uri| C[授权端点]
C -->|secure-par-content| C1{query 参数都在 PAR 里?}
C1 -->|否| E4[invalid_request_object:<br>PAR request did not include query parameter]
C -->|confidential-client 拒绝 public / bearer-only| C2{访问类型 OK?}
C2 -->|否| E5[invalid_client:<br>invalid client access type: public]
C -->|reject-implicit-grant / pkce-enforcer| C3{response_type=code<br>code_challenge_method=S256?}
C3 -->|否| E6[invalid_request]
C -->|code| D[令牌端点]
D -->|dpop-bind-enforcer<br>或 holder-of-key-enforcer| D1{DPoP proof 或 mTLS 证书匹配?}
D1 -->|否| E7[invalid_dpop_proof: DPoP proof is missing<br>或 missing for MTLS HoK Token Binding]
D -->|access_token 绑定到密钥/证书| F[资源服务器]
图的读法:同一个失败响应可能由不同执行器产生,报错文本是唯一可靠的分流依据。secure-par-content 在两处生效(PAR 端点校验 redirect_uri;授权端点校验"前端参数不许多于 PAR 内容"),这是 FAPI 2.0 原文 shall require the redirect_uri parameter in pushed authorization requests 与"授权端点只允许 client_id + request_uri“两条要求的实现。request_uri 还是一次性的,重复使用会得到 PAR not found, not issued or used multiple times.——这部分机制在
Keycloak PAR 实战 里已展开,本文不重复。
上线前的存量审计清单
绑定策略前,先用 Admin CLI 把会被打回的客户端筛出来。kcadm.sh 是 Keycloak 发行版 bin/ 里的 Admin CLI(下文用占位符,不要在生产里直接跑 -r 之外的写操作):
# 一眼看清每个客户端的认证方式、访问类型与会被 auto-configure 改写的字段
kcadm.sh get clients -r <realm> \
--fields clientId,publicClient,bearerOnly,clientAuthenticatorType,\
consentRequired,fullScopeAllowed,attributes --format csv > clients.csv对照筛选规则:
| 命中条件 | 说明 | 处理 |
|---|---|---|
clientAuthenticatorType 为 client-secret 或 client-secret-jwt | 会被 secure-client-authenticator 在运行期打回 | 迁到 private_key_jwt(首选)或 client-x509,迁移步骤见
客户端认证方式选型与凭据轮换 |
断言 aud 填了 token 端点 / 多个值 | 会被 secure-client-authentication-assertion 打回 | 改调用方收敛为单个 issuer |
publicClient=true 或 bearerOnly=true | 会被 confidential-client 打回 | 改 confidential / BFF,或用条件排除 |
fullScopeAllowed=true | 一旦被策略覆盖就会变 false | 提前补 role scope mapping,别等线上 403 |
依赖同意页不出现的流程(E2E、prompt=none) | consentRequired 会被写成 true | 要么改流程,要么把这些客户端排除在策略外 |
| 用 Ed25519 签 request object / client assertion | 不在 Keycloak 算法白名单 | 改 PS256 / ES256 |
验证
# 1. 打开策略求值日志(临时,排查完就关)
kc.sh start --log-level=org.keycloak.services.clientpolicy:trace
# 2. 触发 auto-configure 并回读客户端,确认字段真的被改写
curl -s -H "Authorization: Bearer $TOKEN" \
"https://kc.example.com/admin/realms/<realm>/clients?clientId=<clientId>" \
| python3 -c "import sys,json;c=json.load(sys.stdin)[0];print({k:c.get(k) for k in ['consentRequired','fullScopeAllowed','clientAuthenticatorType']})"
# 3. 用未合规的客户端打一次 token 端点,确认报错文本与预期一致(而不是别的错误)
curl -s -X POST "https://kc.example.com/realms/<realm>/protocol/openid-connect/token" \
-d "grant_type=client_credentials" -d "client_id=<clientId>" -d "client_secret=<secret>"
# 期望:invalid_request / Configured client authentication method not allowed for client第 3 步的期望结果是故意失败:能稳定复现预期错误,才说明策略真的作用到了这个客户端;只看到"登录还是正常的"往往意味着条件没命中,而不是策略生效。
回滚
顺序很重要,因为"禁用策略"和"客户端恢复原状"是两件事:
- 禁用 policy(保留 profile 与条件):
enabled: false后立即停止新的拦截与改写,配置还在,便于二次排期。 - 手工恢复已被改写的客户端字段:
consentRequired、fullScopeAllowed、useDpop/useMtlsHoKToken、签名算法属性都是 auto-configure 写进客户端表示的,禁用策略不会把它们改回去。逐个 GET 客户端表示,与审计时的快照对比后恢复。 - 再决定是否回退认证方式:如果为了接入已经做过
client-secret→private_key_jwt的迁移,这一步通常不必回退——它本身是安全提升,回退反而要重新分发票据。 - 验证回滚完成:重复上面第 2 步的回读,确认字段与预期一致;把
org.keycloak.services.clientpolicy的 TRACE 日志关掉。
回滚的边界要说清楚:已经签发的令牌不受影响,DPoP/mTLS 绑定的 access token 会自然过期;但如果资源服务器侧已经开始校验绑定,令牌过期前仍会有失败请求,因此回滚窗口应覆盖 refresh token 的存活期。
IAM FAQ
FAPI 2.0 和 OAuth 2.1 在 Keycloak 上是什么关系?
OAuth 2.1 是把 OAuth 2.0 的既有安全实践收敛进主规范(强制 PKCE、移除 implicit/ROPC);FAPI 2.0 是金融场景的更严档位,在 OAuth 2.1 之上额外强制 PAR、限定客户端认证与断言 aud、要求发送方约束令牌(mTLS 或 DPoP)。Keycloak 两者都做成全局 client profile,区别在于 oauth-2-1-for-* 不带 PAR 与 HoK 强制,梯度对照见
OAuth 2.1 相比 OAuth 2.0 的变化。
挂上 fapi-2 profile 就等于通过 FAPI 2.0 认证了吗?
不等于。Keycloak 只在服务端按 profile 校验自己的行为,官方文档明确 adapters 没有 FAPI 支持,客户端侧是否满足 FAPI 2.0 仍需自行或借助第三方 FAPI 客户端库实现。合规结论应基于一致性测试,而不是"策略已启用”。
为什么绑定策略后,之前一直正常的客户端突然报 aud 错误?
FAPI 2.0 规范要求授权服务器只接受 issuer identifier 作为 client assertion 的 aud;Keycloak 默认校验器接受包括 token 端点在内的 5 个地址。策略把客户端纳入 FAPI 范围后,收紧的那一层开始生效,属于预期行为,修法在调用方而不是 Keycloak 配置。
client policy 能管住 client scope 里的 protocol mapper 吗?
不能。官方文档的 NOTE 写得很明确:client policies 不针对 client scope 上的 protocol mapper 创建、更新、删除操作求值。scope 与 mapper 的治理要靠配置即代码与变更评审,不能指望 client policy。
FAPI 2.0 要求 PKCE,那我们内部系统是不是也能顺手一起开?
可以,但要单独评估:PAR 与 PKCE 本身是低成本的加固,而 FAPI profile 是一整套(含 client_secret 全废、同意页、full scope 关闭)。内部系统推荐按需组合单点加固项,例如 Keycloak 细粒度权限与授权策略 里的 full scope 收敛,而不是整包套用 FAPI profile。
相关章节
- Keycloak PAR 实战:IAM 授权请求参数不该出现在浏览器 URL 里:
secure-par-content的另一种触发方式与request_uri一次性语义 - OAuth 2.0 DPoP 深度解析:
dpop-bind-enforcer的两个细粒度选项与 DPoP proof 结构 - 客户端认证方式选型与凭据轮换:private_key_jwt / mTLS 的差异与默认 aud 校验规则
- Keycloak 26.8.0 升级:IAM 破坏性变更排查:
full-scope-disabled的Any Client排除法与 Full Scope Allowed 弃用 - X.509 客户端证书登录与 IAM mTLS 落地:选择 mTLS 版 FAPI profile 时的证书链与代理配置
来源
- FAPI 2.0 Security Profile (Final):AS 侧强制 PAR、PKCE(S256)、PAR 必须带
redirect_uri、client assertion 的aud只接受 issuer identifier(RFC 8414)、签名算法 PS256/ES256/EdDSA - FAPI 2.0 Message Signing (Final)
- Keycloak — Securing applications and services:Financial-grade API (FAPI) Support:支持的五份规范、PAR 与 baseline/advanced 的组合建议、adapters 无 FAPI 支持、
https-protocols/https-cipher-suites - Keycloak Server Administration Guide — Client Policies:全局 profile 默认不生效、策略条件与
STRICT求值、client scope 上的 protocol mapper 不求值、TRACE 日志类别 - Keycloak 26.8.0 源码:
services/src/main/resources/keycloak-default-client-profiles.json、services/src/main/java/org/keycloak/services/clientpolicy/executor/{SecureClientAuthenticatorExecutor,SecureClientAuthenticationAssertionExecutor,SecureParContentsExecutor,ConfidentialClientAcceptExecutor,ConsentRequiredExecutor,FullScopeDisabledExecutor,HolderOfKeyEnforcerExecutor,DPoPBindEnforcerExecutor,SecureSigningAlgorithmExecutor,FapiConstant}.java、services/src/main/java/org/keycloak/services/resources/admin/ClientPoliciesResource.java - Keycloak 26.4.0 release notes — FAPI 2 Final (supported):FAPI 2.0 两份 Final 规范支持与一致性测试结论