场景

  • 运维团队已经在 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_secretoidc_discovery_url(或 jwks_url / jwt_validation_pubkeys),client id / secret 留空
验的是哪张 tokenKeycloak 的 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

两条硬约束,踩过的都知道:

  1. 验签来源是 mount 级别的,三选一。oidc_discovery_url、jwks_url、jwt_validation_pubkeys 互斥。想同时用 Discovery 和本地 pubkey,不能在一个 mount 上叠,只能 bao auth enable jwt -path=jwt-machine 再挂一个实例。
  2. 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 TypeconfidentialOpenBao 服务端要用 secret 换 code,不能用 public client
Standard Flow EnabledON授权码流程;OpenBao 的 OIDC 流程使用 PKCE
Root URLhttps://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
UIhttps://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 把常见的四类不匹配列全了,这四条是不报「配置错」,直接报认证失败的:

  • http vs https
  • 127.0.0.1 vs localhost
  • 端口号不一致(UI 是 8200,CLI 是 8250,容易串)
  • 结尾斜杠(/oidc/callback vs /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.pathtrueclaim 里是 /platform/ops 这种完整路径,Identity 组别名也要带前导斜杠,否则永远匹配不上
id.token.claimtrueOIDC 角色验的是 ID token,这一项保持打开
userinfo.token.claimtrue无需额外调;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"

三个容易写错的地方:

  1. oidc_discovery_url 是 base URL,不要粘整条 .../.well-known/openid-configuration——服务端会自己在后面拼 .well-known/openid-configuration,多贴一段就是 404,报错长得像网络问题。
  2. user_claim 是唯一的必填 claim。它同时决定 Identity entity 的别名名。填 sub 最稳(UUID 形式、用户改名不变),填 preferred_username 可读性好但改名后会生成新 entity;不要在同一个环境里混用两种。
  3. 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"
}
EOF

bound_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 走公网访问 Keycloakoidc_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/readonly

OpenBao 相对 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 / redirectoidc_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 工作负载身份。

延伸阅读