OpenBao / Vault OIDC 接入 Keycloak:IAM 运维登录、组映射与 issuer 排错 | IDaaS Book
场景
- 运维团队已经在 Keycloak 里管理账号和组,现在希望用同一套身份登录 OpenBao(或 HashiCorp Vault)拿数据库动态凭据、TLS 证书,而不是每人发一个 root token 或 AppRole。
- 已经照着文档配了
oidc_discovery_url+ client secret,但配置阶段就报错unable to create provider: oidc: issuer did not match the issuer returned by provider,反复对 secret 也没用——这个报错和 secret 无关,是 issuer 校验(见第 4 节)。 - 或者已经能登录,但组权限不生效:
groups_claim配了、Keycloak 里组也建了,Identity 里却没有对应的组别名,策略一条都没绑上。
这篇只处理一件事:把人通过浏览器登录 OpenBao 这条链路(OIDC 角色)从零配通,并在出错时知道该看哪一行日志。机器身份(CI、Pod)的 JWT 角色只在第 1 节做边界划分,不展开。
适用 / 不适用
| 场景 | 是否适用 |
|---|---|
| 运维 / 研发人员用浏览器登录 OpenBao UI 或 CLI,拿动态数据库凭据、读 KV | ✅ 本页主场景(role_type=oidc) |
| CI/CD、脚本用 Keycloak 签发的 JWT 直接换 OpenBao Token | ⚠️ 走另一条链路(role_type=jwt),见 1.1 的边界表 |
| OpenBao 与 Keycloak 之间隔着公网 / 内网两套地址 | ⚠️ 可以,但 discovery URL 必须用「Keycloak 认可的 issuer 域名」,见第 4 节 |
| 企业要求「登出 Keycloak 就必须立刻失效 OpenBao 会话」 | ❌ 两条会话生命周期是解耦的,见 7.2 |
| 只想给 Kubernetes Pod 发身份 | ❌ 用 Kubernetes Auth Method 或 SPIFFE/SPIRE,不需要 OIDC 角色 |
1. 两条链路,一个 mount 只能选一种验签来源
OpenBao 的 jwt 这个 auth method(挂在 oidc 还是 jwt 只是路径名,能力一样)同时支持两类角色,但它们的输入完全不同:
flowchart TD
subgraph 人["人:OIDC 角色"]
U[浏览器] -->|authorization_code + PKCE| K1[Keycloak]
K1 -->|ID token| B1[OpenBao auth/oidc<br/>role_type=oidc]
B1 -->|Identity entity + alias| T1[OpenBao Token<br/>带 policy]
end
subgraph 机["机器:JWT 角色"]
S[CI / 脚本] -->|client_credentials| K2[Keycloak]
K2 -->|access token| B2[OpenBao auth/oidc<br/>role_type=jwt]
B2 --> T2[OpenBao Token<br/>带 policy]
end
B1 -.->|同一个 mount 共用一份 config| C["auth/oidc/config<br/>三选一:oidc_discovery_url<br/>jwks_url / jwt_validation_pubkeys"]
B2 -.-> C
| 维度 | OIDC 角色(role_type=oidc) | JWT 角色(role_type=jwt) |
|---|---|---|
| 谁在用 | 人(浏览器 / CLI 登录) | 服务、CI、脚本 |
| config 必填项 | oidc_discovery_url + oidc_client_id + oidc_client_secret | oidc_discovery_url(或 jwks_url / jwt_validation_pubkeys),client id / secret 留空 |
| 验的是哪张 token | Keycloak 的 ID token | 调用方直接递进来的 JWT(通常是 access token 或 client assertion) |
bound_audiences | 可选,官方明确说明「OIDC 角色通常不需要」(ID token 的 aud 本来就是 client ID) | 只要 token 带 aud 就必须配,且必须与其中某个 aud 值精确相等 |
| 回调地址 | 必须配 allowed_redirect_uris | 不需要 |
| 时序容差 | 不适用 | clock_skew_leeway / expiration_leeway(默认 150 秒)/ not_before_leeway |
两条硬约束,踩过的都知道:
- 验签来源是 mount 级别的,三选一。
oidc_discovery_url、jwks_url、jwt_validation_pubkeys互斥。想同时用 Discovery 和本地 pubkey,不能在一个 mount 上叠,只能bao auth enable jwt -path=jwt-machine再挂一个实例。 bound_audiences的语义在 Vault 1.17 变严格了:jwt 角色只要 token 里有aud,就必须用bound_audiences精确匹配其中一个值(官方 1.17 升级指引专门列了这一条)。Keycloak 的 access token 里有没有aud、值是什么,取决于 mapper 配置(见 2.4),所以「升级 Vault 后 CI 的 jwt 登录突然全挂」是一类典型事故。
OpenBao 2.7.x 与 Vault 的这套 JWT/OIDC API 同名同参,本页命令用
bao,Vault 部署把二进制换成vault即可。核对日期 2026-10-05,OpenBao 最新发布 v2.7.1(2026-10-01)。
2. Keycloak 侧最小配置
2.1 建一个专用 confidential client
| 设置项 | 值 | 说明 |
|---|---|---|
| Client type / Access Type | confidential | OpenBao 服务端要用 secret 换 code,不能用 public client |
| Standard Flow Enabled | ON | 授权码流程;OpenBao 的 OIDC 流程使用 PKCE |
| Root URL | https://bao.example.com | |
| Valid Redirect URIs | 见 2.2 的三条 | 多一条少一条都不行 |
| Credentials → Client Secret | 记下来 | 填进 oidc_client_secret |
一个 OpenBao 实例(一个 auth mount)配一个 client。不要为了省事让 OpenBao 和别的水管工共用同一个 Keycloak client:回调地址和 audience 会互相污染,出问题时你无法判断是哪一侧配错了。
2.2 回调地址:三条固定的字符串,逐字符比对
回调地址由「OpenBao 自己的地址」+「auth mount 的路径」拼成,mount 路径会出现在 URL 里,所以下面 oidc 出现两次是正常的:
| 登录入口 | redirect URI |
|---|---|
| UI | https://bao.example.com/ui/vault/auth/oidc/oidc/callback |
| CLI(默认本地回调) | http://localhost:8250/oidc/callback |
UI(当 oidc_response_mode=form_post) | https://bao.example.com/v1/auth/oidc/oidc/callback |
如果 auth mount 挂在 jwt 而不是 oidc,上面两条 UI 地址里的 auth/oidc 要换成 auth/jwt,CLI 侧用 -path=jwt。
官方 troubleshooting 把常见的四类不匹配列全了,这四条是不报「配置错」,直接报认证失败的:
httpvshttps127.0.0.1vslocalhost- 端口号不一致(UI 是 8200,CLI 是 8250,容易串)
- 结尾斜杠(
/oidc/callbackvs/oidc/callback/)
这三条地址要同时填在 Keycloak 的 Valid Redirect URIs 和 OpenBao 角色的
allowed_redirect_uris里。两边都对、两边一致才有用;只改一边的现象是「从 UI 能登、从 CLI 报redirect_uri不合法」,反之亦然。
2.3 groups claim:full.path 默认打开,组别名会带前导斜杠
Keycloak 的 Group Membership mapper(oidc-group-membership-mapper)默认参数(核对自 keycloak.org 官方 mapper 参考页):
| 属性 | 默认值 | 对 OpenBao 的影响 |
|---|---|---|
claim.name | 无 | 填 groups,与角色的 groups_claim 对应 |
full.path | true | claim 里是 /platform/ops 这种完整路径,Identity 组别名也要带前导斜杠,否则永远匹配不上 |
id.token.claim | true | OIDC 角色验的是 ID token,这一项保持打开 |
userinfo.token.claim | true | 无需额外调;OpenBao 的 OIDC 流程不依赖 userinfo 取组 |
full.path 要不要关,和
Harbor 接入 Keycloak OIDC、
Argo CD 接入 Keycloak OIDC 里的取舍同源:关掉组名干净,但 realm 里存在同名层级组(/platform/ops 与 /legacy/platform/ops)时两组会退化成同一个名字,权限收敛。运维平台(OpenBao 属于这类)建议保留 full.path=true,因为「两个同名组只差一级路径」在权限体系里恰恰是最不能混淆的场景。
2.4 audience:ID token 已经有,access token 要显式加
- ID token 的
aud按 OIDC Core 的硬性要求就是发起授权请求的 client ID——这也是官方说 OIDC 角色「通常不需要bound_audiences」的原因。 - access token 不一定有目标 audience。需要时加
oidc-audience-mapper,填included.client.audience,注意它的 Add to ID token 默认是false、Add to access token 默认是true——反过来配(想让 ID token 带上某个额外 audience 却只勾了 access token)不会报错,只会静默不加。 oidc-audience-resolve-mapper是另一回事:它把「用户拥有 client 角色的那些 client」的 client ID 全部塞进aud,组粒度粗,不适合用来精确限定某个 OpenBao mount。
2.5 scope:不要照抄文档里的 oidc_scopes
OpenBao/Vault 的 troubleshooting 里有一句建议 oidc_scopes="profile,groups"。这句在 Keycloak 上不能直接抄:Keycloak 会校验 scope,realm 里没有同名 client scope 时授权请求直接被拒,报 invalid_scope (Invalid scopes: openid profile groups),日志里还打不出是哪一侧导致的。同类现象在
Harbor 和
GitLab 两篇里都出现过。
结论:组 claim 由 mapper 产生,不需要在授权请求里请求一个叫 groups 的 scope。保持默认的 openid profile 即可;确实要用 groups 这个名字,就先去 realm 里创建同名 client scope 并分配给这个 client,再写进 oidc_scopes。
3. OpenBao 侧最小配置
# 1. 启用 auth method(挂在 oidc 路径,回调地址按 2.2 对齐)
bao auth enable oidc
# 2. 全局配置:discovery URL 是 base URL,不带 .well-known/openid-configuration
bao write auth/oidc/config \
oidc_discovery_url="https://sso.example.com/realms/platform" \
oidc_client_id="openbao" \
oidc_client_secret="<client-secret>" \
bound_issuer="https://sso.example.com/realms/platform" \
default_role="ops-readonly"
# 3. 角色:回调地址两条、user_claim 必填、组 claim 对应 mapper 的 claim.name
bao write auth/oidc/role/ops-readonly \
role_type="oidc" \
user_claim="preferred_username" \
groups_claim="groups" \
allowed_redirect_uris="https://bao.example.com/ui/vault/auth/oidc/oidc/callback" \
allowed_redirect_uris="http://localhost:8250/oidc/callback" \
token_policies="ops-readonly" \
ttl="2h" max_ttl="8h"三个容易写错的地方:
oidc_discovery_url是 base URL,不要粘整条.../.well-known/openid-configuration——服务端会自己在后面拼.well-known/openid-configuration,多贴一段就是 404,报错长得像网络问题。user_claim是唯一的必填 claim。它同时决定 Identity entity 的别名名。填sub最稳(UUID 形式、用户改名不变),填preferred_username可读性好但改名后会生成新 entity;不要在同一个环境里混用两种。- map 类型的参数没法用多个
key=value写。bound_claims这类要整份 JSON 送进去:
bao write auth/oidc/role/ops-admin -<<EOF
{
"role_type": "oidc",
"user_claim": "preferred_username",
"groups_claim": "groups",
"allowed_redirect_uris": "https://bao.example.com/ui/vault/auth/oidc/oidc/callback",
"bound_claims": { "groups": ["/platform/ops-admin"] },
"token_policies": "ops-admin",
"ttl": "1h", "max_ttl": "4h"
}
EOFbound_claims 里值的语义是「命中列表中的任意一项即可」;多个 key 之间是 AND。上例的效果是:只有带 /platform/ops-admin 组的用户能过这个角色——注意这里同样要吃 full.path 的前导斜杠。
3.1 组 → 策略:groups_claim 自己不发策略
groups_claim 的作用只是把组名写成 Identity 组别名,它不会自动授予任何 policy。要让「组」真的带权限,还需要在 Identity 侧建外部组并把别名挂到 auth/oidc 这个 mount 上(identity/group + identity/group-alias,mount_accessor 取自 bao auth list -detailed)。组别名的名字必须与 claim 里的组字符串逐字符相等——这就是 full.path 默认 true 时最常踩的一脚:Keycloak 给 /platform/ops,Identity 里建 platform/ops 或 ops 都不会报错,只是权限永远不生效。
如果只是「几个角色各管一摊、不需要动态增组」,用 bound_claims 直接把角色限定到组更省事,省掉 Identity 组这层。
4. issuer 不匹配:本页最值钱的一节
4.1 症状与日志原文
配置阶段直接失败,bao write auth/oidc/config 或 UI 保存时返回:
unable to create provider: oidc: issuer did not match the issuer returned by provider,
expected "https://<openbao 侧解析到的 keycloak 主机名>/realms/platform"
got "https://<keycloak 前端域名>/realms/platform"(原文形态见 hashicorp/vault#25024,该 issue 到 2026-10 仍为 open,带 bug 标签。)
4.2 根因:期望 issuer 由 discovery URL 的主机名推导
OpenBao/Vault 在初始化 OIDC provider 时会做一次一致性校验:
sequenceDiagram
participant B as OpenBao / Vault
participant K as Keycloak
participant U as 浏览器
Note over B: 配置阶段(write auth/oidc/config)
B->>K: GET https://<discovery_url>/realms/platform/.well-known/openid-configuration
K->>B: issuer = https://sso.example.com/realms/platform
B->>B: 期望 issuer = discovery_url 的 host + 同样的 path
alt 两者相等
B->>B: provider 创建成功
else 主机名不同(内网地址 vs 前端域名)
B->>B: 失败:issuer did not match
end
Note over U,K: 登录阶段
U->>K: 浏览器登录
K->>U: ID token(iss 由 Keycloak hostname 配置决定)
U->>B: code → 换 token
B->>B: 校验 iss 与 bound_issuer / discovery issuer
两个事实拼在一起就是全部原因:
- Keycloak 的
iss由它自己的 hostname 配置决定(frontend URL /hostname,见 Keycloak Hostname v2 配置),和请求是从哪个地址进来的无关; - OpenBao 的期望值由
oidc_discovery_url里的主机名推导。
所以只要「OpenBao 怎么访问 Keycloak」和「Keycloak 认为自己是谁」不是同一个域名——内网 DNS 名、Service 名、LB 内网 IP 都是常见情况——配置阶段就过不去。
bound_issuer 救不了它。 issue 原文明确写了「The error appears even when bound_issuer is configured」:这个校验发生在 provider 初始化阶段,早于 bound_issuer 参与比对。把 bound_issuer 改成前端域名只会让报错里的 expected 值也跟着变,问题依旧。
4.3 三种修法,按推荐顺序
| 修法 | 做法 | 代价 / 适用 |
|---|---|---|
| ① 让 discovery 用同一个域名(推荐) | OpenBao 侧解析 sso.example.com 到内网入口:CoreDNS / 内网 DNS 视图、Pod 的 hostAliases、ServiceEntry、或把 Keycloak 的内部 Service 加一个 sso.example.com 的别名 + 内网 CA 信任 | 需要 DNS 或网关侧有权限;优点是不影响任何用户侧 URL,Keycloak 配置零改动 |
② 把 Keycloak 的 hostname 改成后端域名 | 让 iss 等于 OpenBao 用的内网地址 | ❌ 不推荐:issuer 会被写进所有已签发 token,登录页、邮件链接、其他 Relying Party 全部跟着变,等于把内网名暴露给所有人 |
| ③ 让 OpenBao 走公网访问 Keycloak | oidc_discovery_url 直接填公网域名 | 可用,前提是控制面出网可控、且 OpenBao 拿到的是同一份 discovery;适合已有稳定出口的场景 |
改完之后必须验证两处一致:
# Keycloak 认可的 issuer
curl -sS https://sso.example.com/realms/platform/.well-known/openid-configuration | jq -r .issuer
# OpenBao 侧落库的配置
bao read auth/oidc/config
# 实际 ID token 里的 iss(手工登录一次后取 token 解码)
echo "<id_token>" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq '{iss, aud, exp, groups}'三处出现的 iss 必须完全相同——包括结尾没有斜杠。Keycloak 的 issuer 是 https://<host>/realms/<realm>,多写一个 / 就又是一次 issuer mismatch。
5. 登录与验证
# CLI 登录(默认走本地 8250 回调)
bao login -method=oidc role=ops-readonly # 默认 port=8250
bao login -method=oidc role=ops-readonly port=8400 skip_browser=true # 手动复制 URL 的场合
# 登录后确认身份与策略
bao token lookup
bao read database/creds/readonlyOpenBao 相对 Vault 多了几个回调模式选项,排错时会用到:
| 参数 | 作用 | 注意 |
|---|---|---|
callbackmode=client(默认) | 回调打到 CLI 本地端口 | 只需要 http://localhost:8250/oidc/callback |
callbackmode=direct | 回调打到 OpenBao 服务端 /v1/auth/<path>/oidc/callback | 会先显示一个确认页(展示请求者 IP,防钓鱼,依据 RFC 8628 §5.4);provider 自己已有确认提示时用 oidc_disable_confirmation=true 关掉 |
callbackmode=device | 设备流,无回调 | 适合无浏览器的跳板机 |
调试 claims 用角色的 verbose_oidc_logging=true:它会把收到的 OIDC token 逐字写进服务端日志(需 debug 级日志),确认完 claim 结构后立刻关掉——token 里带着用户身份信息,不该留在生产日志里。
6. 报错对照表
| 症状 | 原因 | 定位动作 |
|---|---|---|
unable to create provider: oidc: issuer did not match... | discovery URL 主机名 ≠ Keycloak 的 issuer 主机名 | 第 4 节;bound_issuer 改不动这个错误 |
| provider 创建失败,报 discovery 404 / redirect | oidc_discovery_url 里多贴了 .well-known/openid-configuration | 只保留到 /realms/<realm> |
登录页报 invalid_scope (Invalid scopes: ... groups) | 授权请求里的 scope 名在 realm 里不是合法 client scope | 见 2.5;去掉 groups 或补建 client scope |
Error exchanging oidc code: ... cannot fetch token: 401 Unauthorized {"error":"access_denied"} | oidc_client_id / oidc_client_secret 为空或与 Keycloak 不一致 | 官方 troubleshooting 列出的两种成因;核对 Credentials 页的 secret 是否被轮换过 |
| 从 UI 能登、从 CLI 报 redirect_uri 不合法 | 其中一条回调地址没登记,或 127.0.0.1 与 localhost 写混 | 按 2.2 的四类差异逐条比对 |
登录成功但没有任何权限(只有 default) | groups_claim 的组名与 Identity 组别名不一致(full.path 前导斜杠)、或根本没建 Identity 组 | bao token lookup 看 policies;bao list identity/group/name 核对别名 |
| jwt 角色在升级后开始报 audience 相关错误 | Vault 1.17 起 jwt 角色必须用 bound_audiences 精确匹配 token 里的某个 aud | 先解码 token 看 aud 实际值,再对齐 Keycloak 的 audience mapper |
日志里出现 failed to verify id token signature / kid 未命中 | Keycloak 轮换签名密钥,OpenBao 侧 JWKS 缓存未更新 | 见 Keycloak 签名密钥轮换排错 |
7. 边界与回滚
7.1 回滚
OIDC 配错不会锁死 OpenBao——前提是你保留了逃生通道:
# 1. 逃生:root token 或 AppRole 的备份凭据(配 OIDC 之前必须具备)
bao login <root-or-recovery-token>
bao token lookup
# 2. 暂停入口:禁用 auth mount(不能只改角色,因为 provider 配置本身可能已坏)
bao auth disable oidc
# 3. 修 Keycloak 侧(hostname / redirect URI / mapper)后重新启用
bao auth enable oidc顺序建议:先禁用 auth mount,再动 Keycloak 的 hostname 或 mapper。反过来做的话,已经登录的会话不受影响,但新的登录会连续失败一段时间,而你还得同时判断是两侧哪一侧没生效。
另外,禁用再启用 mount 会丢掉这个 mount 上的 Identity 组别名(mount_accessor 变了),重建后要重新挂别名并把 alias 指回原 canonical group,否则组员下次登录拿到的是新建的实体、权限为空。这是最容易在「回滚后一切正常」的假象里漏掉的一步。
7.2 会话边界:两条生命周期是解耦的
- OpenBao 签发的 token 与 Keycloak 会话不联动:用户在 Keycloak 点登出、或者管理员在 Keycloak 里踢掉会话,都不会吊销已签发的 OpenBao token;token 只在自身 TTL 到期或显式 revoke 时失效。
- 因此暴露窗口由角色的
ttl/max_ttl决定,运维类角色建议压在小时级(示例里的 2h/8h 是上限而不是目标),高价值路径配合短命策略 + 定期续期,而不是指望 IdP 侧的登出。 - 需要「立刻切断某人的访问」时,动作在 OpenBao 侧:
bao token revoke -accessor <accessor>,或禁用该用户的 Identity entity。这条和 IAM 会话管理 里「应用会话不等于 IdP 会话」是同一个判断。
FAQ
OpenBao 和 Vault 的 OIDC 配置能直接互抄吗?
能。JWT/OIDC auth method 的 config / role 参数在两边的 API 文档里同名同语义(oidc_discovery_url、bound_issuer、user_claim、groups_claim、allowed_redirect_uris 都一致)。差异在周边:OpenBao 多了 callbackmode=direct|device 与 oidc_disable_confirmation 这类回调模式选项,Vault 侧的 CLI 回调只有 localhost 一种。抄配置时把命令名换掉即可,回调地址按各自文档核对。
为什么 Keycloak 说登录成功了,OpenBao 里却只有 default 策略?
因为 OIDC 认证只解决「你是谁」,不解决「你能干什么」。策略来自两处:角色上的 token_policies,以及 Identity 组别名匹配到的外部组。组名在 claim 里带前导斜杠(full.path=true 的默认行为)时,别名少一个斜杠就会静默不匹配——bao token lookup 的 policies 字段是最快的确诊点。
IAM 里已经有 Keycloak 了,OpenBao 的 OIDC 登录算不算重复建设? 不重复,两者管的是不同身份。Keycloak 管人的身份与 SSO 会话;OpenBao 管的是「凭证的签发与租约」——数据库临时账号、TLS 证书、KV 密钥。OIDC 登录是把人的身份带进凭证系统的那座桥:OpenBao 用 Keycloak 的 ID token 认出操作者,再按策略发一张有时限的凭证。机器身份(Pod、CI)则不用人这条路,见 SPIFFE/SPIRE 工作负载身份。
延伸阅读
- OpenBao 身份驱动的密钥管理与加密引擎:Auth Method 与 Secrets Engine 的整体分工、Kubernetes Auth 场景
- Keycloak Hostname v2 配置与 v1 选项迁移:issuer 由哪个配置项决定,以及为什么它不能随手改成内网名
- Keycloak 客户端认证与 IAM 凭据轮换:confidential client 的 secret 轮换、
private_key_jwt与 mTLS 的算法边界 - Harbor 接入 Keycloak OIDC /
GitLab 接入 Keycloak OIDC:
invalid_scope与组 claim 前导斜杠的同类踩坑 - IAM 权限模型:RBAC、ABAC 与 ReBAC 对比:
bound_claims这类基于 claim 的绑定在权限模型里的位置