Keycloak:从 Realm 到 JWT 验签的本地证据链
一个常见失败现场是:前端已经跳到登录页,用户也输入了密码,回调后却被后端拒绝。解码 access token 后能看到用户名,资源服务日志仍然报 issuer mismatch、Unable to find a signing key 或 Invalid audience。这不是“登录页没接好”,而是 OIDC 链路中的几个对象没有对齐。
本地身份源的任务,是用最小、可销毁的环境复现这条链,而不是在开发机上模拟完整生产身份平台。一次联调需要同时固定 issuer、discovery、JWKS、client、redirect URI 和测试主体;结束后还要删除容器、volume、测试用户、会话和 secret,避免临时身份状态变成团队长期依赖。
登录成功了,接口为什么还是 401
realm / provider 决定对象归属。Keycloak realm 是用户、client、角色、会话和签名策略的隔离边界;authentik 的 Application 与 OAuth2/OIDC Provider 是应用接入对象,不等价于租户隔离。client 表示接入身份源的应用。浏览器应用应使用 public client 与 Authorization Code + PKCE;能保管 secret 的服务端或机器身份才使用 confidential client。
issuer 是 token 的签发者标识。资源服务必须把 JWT 的 iss 当作精确字符串校验,不能用“域名差不多”代替。discovery document 发布 authorization、token、userinfo、JWKS 等端点。客户端应从 issuer 对应的 discovery 获取端点,而不是分别手写一组可能漂移的 URL。JWKS 发布验签公钥。JWT 头部的 kid 要能在 JWKS 中找到;找到 key 只证明签名可验证,iss、aud、exp 等声明仍需继续校验。
redirect URI 是授权码返回应用的地址。scheme、host、port、path 都参与匹配;宽泛通配符会把授权码送到不该接收它的位置。
Keycloak 的 start-dev 是开发入口;authentik 的 Docker Compose 安装可以承载需要 flow、policy 或 outpost 的本地实验。下面的可重建样例固定 Keycloak 26.7.0 与 authentik 版本标识 2026.5.5,镜像和 Compose 输入都不使用浮动 latest。这两个版本只用于隔离的回环协议实验,不能作为共享或生产安全基线;Keycloak 的后续补丁包含安全修复。共享环境应从官方版本入口选定维护补丁,阅读升级说明,再在空环境重跑登录、回调、验签和清理实验。
先用 Keycloak 跑通一条可重建链路
在有 Docker Engine 与 Compose v2 的 Linux 主机上,以有 Docker 权限的普通账号执行;还需要 curl、OpenSSL、jq、Node.js 24、npm 和 unzip,宿主回环端口 8080/5173/8088 应空闲。先创建独占实验目录和 realm 路径。下面生成的三个随机值只进本机 .env;测试用户登录前从该文件读取本次密码,不把它放进命令历史、截图或仓库。
set -euo pipefail
umask 077
mkdir keycloak-oidc-lab
cd keycloak-oidc-lab
mkdir -p keycloak/realm
{
printf 'KEYCLOAK_VERSION=26.7.0\nKEYCLOAK_PORT=8080\nKEYCLOAK_ADMIN_USERNAME=admin\n'
printf 'KEYCLOAK_ADMIN_PASSWORD=%s\n' "$(openssl rand -hex 24)"
printf 'KC_TEST_CLIENT_SECRET=%s\n' "$(openssl rand -hex 24)"
printf 'KC_TEST_USER_PASSWORD=%s\n' "$(openssl rand -hex 16)"
} > .env
chmod 600 .env将下一段保存为当前目录的 compose.yaml,realm JSON 保存为 keycloak/realm/te-realm.json。目录已存在时先核对内容,不要覆盖旧实验;.env 不入 Git,可提交的样例只保留不含真实 secret 的变量名。
services:
keycloak:
image: quay.io/keycloak/keycloak:${KEYCLOAK_VERSION:?set KEYCLOAK_VERSION}
command: start-dev --import-realm
ports:
- "127.0.0.1:${KEYCLOAK_PORT:-8080}:8080"
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USERNAME:?set admin user}
KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set admin password}
KC_TEST_CLIENT_SECRET: ${KC_TEST_CLIENT_SECRET:?set test client secret}
KC_TEST_USER_PASSWORD: ${KC_TEST_USER_PASSWORD:?set test user password}
volumes:
- ./keycloak/realm:/opt/keycloak/data/import:ro
- keycloak-data:/opt/keycloak/data
mem_limit: 1g
volumes:
keycloak-data:Keycloak 容器按可用内存比例计算 JVM heap;不设容器内存上限会让开发机上的实际占用难以预测。1g 是本地演示值,不是生产容量结论。官方容器说明还给出了更小生产实例的内存建议,容量仍要以并发登录、会话数、扩展和实测为准,参见 Running Keycloak in a container。
下面的 realm 文件创建两个 client 和一个测试用户。te-web 是浏览器 public client,启用标准授权码流程并要求 PKCE;te-service 开启服务账号,用于不经过用户登录的 token 实验。Keycloak 在导入时会解析 ${...} 环境变量占位符,因此可提交的 realm 模板只保留变量名,实际 secret 与测试密码留在未入库的 .env 中。
{
"realm": "te",
"enabled": true,
"clients": [
{
"clientId": "te-web",
"name": "Local browser client",
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": ["http://localhost:5173/callback"],
"webOrigins": ["http://localhost:5173"],
"attributes": {"pkce.code.challenge.method": "S256"},
"protocolMappers": [{
"name": "api-audience", "protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {"included.custom.audience": "te-api",
"access.token.claim": "true", "id.token.claim": "false"}
}]
},
{
"clientId": "te-service",
"name": "Local service client",
"publicClient": false,
"secret": "${KC_TEST_CLIENT_SECRET}",
"standardFlowEnabled": false,
"serviceAccountsEnabled": true,
"protocolMappers": [{
"name": "api-audience", "protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"config": {"included.custom.audience": "te-api",
"access.token.claim": "true", "id.token.claim": "false"}
}]
}
],
"users": [
{
"username": "oidc-test-user",
"firstName": "OIDC", "lastName": "Tester",
"email": "oidc-test-user@example.com",
"enabled": true,
"emailVerified": true,
"credentials": [
{"type": "password", "value": "${KC_TEST_USER_PASSWORD}", "temporary": false}
]
}
]
}字段改动会沿着不同路径产生影响:
| 字段 | 改动后的直接结果 | 首个故障证据 |
|---|---|---|
realm | 改变 issuer 和全部 OIDC endpoint 路径 | discovery 404 或 iss 不匹配 |
clientId | 改变授权请求与 token endpoint 的客户端身份 | invalid_client |
publicClient | 决定客户端是否能安全使用 secret | 浏览器泄露 secret,或服务端缺少认证 |
redirectUris | 决定授权码可以回到哪里 | Invalid parameter: redirect_uri |
webOrigins | 控制浏览器跨域请求来源 | 浏览器 CORS 错误 |
serviceAccountsEnabled | 决定 client credentials 是否可用 | unauthorized_client |
temporary | 决定首次登录是否强制改密码 | 登录后进入 required action |
启动前先确认 Compose 能解析,且固定项目名下没有旧容器或残留卷:
test -f compose.yaml && test -f keycloak/realm/te-realm.json
docker compose -p keycloak-oidc-lab config -q
docker compose -p keycloak-oidc-lab ps -a
docker volume ls --filter label=com.docker.compose.project=keycloak-oidc-lab --format '{{.Name}}'容器与卷查询都应为空;若已有资源,暂停并确认归属,必要时更换项目名并在后续命令中保持一致,不覆盖旧实验。确认后启动,读取启动日志:
docker compose -p keycloak-oidc-lab up -d keycloak
docker compose -p keycloak-oidc-lab logs --tail=80 keycloak日志应显示导入 te realm 且服务已启动;看到配置、数据库或 realm 导入错误时先停在此处,不继续请求令牌。
KC_BOOTSTRAP_ADMIN_USERNAME 与 KC_BOOTSTRAP_ADMIN_PASSWORD 只在 master realm 尚不存在的首次启动创建初始管理员。--import-realm 从 /opt/keycloak/data/import 读取 JSON;同名 realm 已存在时会跳过,而不是覆盖身份数据。这个行为保护已有状态,也意味着“改了 JSON 但没生效”首先要查 volume 和启动日志,参见 Importing and exporting realms。
用 discovery、JWKS 和 token 建立正向证据
先等待 realm 的 discovery 真正可用,而不是只看容器状态;循环最多等待约两分钟,超时后查看 docker compose -p keycloak-oidc-lab logs --tail=80 keycloak,不要继续申请 token:
ready=0
for _ in {1..30}; do
if curl -q --ipv4 --noproxy '*' --fail-with-body -sS --connect-timeout 2 --max-time 2 \
-o discovery.json http://localhost:8080/realms/te/.well-known/openid-configuration \
2>/dev/null; then
ready=1
break
fi
sleep 2
done
test "$ready" -eq 1
jq -e '.issuer == "http://localhost:8080/realms/te" and
(.jwks_uri | endswith("/protocol/openid-connect/certs")) and
(.token_endpoint | endswith("/protocol/openid-connect/token"))' discovery.json
curl -q --ipv4 --noproxy '*' --fail-with-body -sS \
http://localhost:8080/realms/te/protocol/openid-connect/certs |
jq -e '.keys | length > 0'两个 jq 命令都应输出 true。discovery 的 issuer 为 http://localhost:8080/realms/te,token_endpoint 应以 /protocol/openid-connect/token 结尾,jwks_uri 指向 /protocol/openid-connect/certs。JWKS 的 keys 非空;每个可用签名 key 至少有 kid、kty 和算法相关字段。若 issuer 不符,先查是否请求了另一个 realm、代理是否改写了外部 hostname,不要关闭签发者校验。
再申请机器 token。不要把 client secret 直接拼进命令参数;下面让 curl 从标准输入读取并进行表单编码:
set -a
. ./.env
set +a
printf '%s' "$KC_TEST_CLIENT_SECRET" | curl -q --ipv4 --noproxy '*' --fail-with-body -sS -X POST \
http://localhost:8080/realms/te/protocol/openid-connect/token \
-H "content-type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=te-service" \
--data-urlencode 'client_secret@-' |
jq -e 'select(.access_token and .token_type == "Bearer") | {token_type,expires_in}'成功时只显示 token_type: Bearer 和 expires_in,令牌本身不输出到终端;若 jq 没有匹配结果,先查 client secret、service account 是否启用和 Keycloak 日志。这个响应属于 te-service 的服务账号,不是 oidc-test-user。不要把令牌粘进在线解码站。解码只能查看声明,接下来由资源接口使用 JWKS 公钥校验签名。
从授权码到真正受保护的接口
te-web 是接收 ID token 的客户端;te-api 是 access token 的目标资源。前面给两个 client 配置的 audience mapper 只把 te-api 加入 access token,不改变 ID token 的 audience。azp 说明哪个客户端取得令牌,不能替代 aud 的资源校验。配置规则见 Keycloak audience。
将完整本地 OIDC 样例下载到当前实验目录,再执行下面的命令;两个 Node 进程以普通宿主用户运行,只监听回环地址。Java 项目可以继续使用后面的 Spring Security 配置,不需要将应用改成 Node。
test -f oidc-local-lab.zip
unzip oidc-local-lab.zip
cd oidc-local-lab
npm ci
export OIDC_ISSUER=http://localhost:8080/realms/te
export API_AUDIENCE=te-api
npm run resource终端显示 resource http://127.0.0.1:8088/api/me。资源端启动时读取 discovery,确认返回的 issuer 与配置完全相等,然后建立 JWKS 缓存。核心校验使用 JOSE 的 jwtVerify:
const { payload } = await jwtVerify(token, keys, {
issuer, audience, algorithms: ['RS256'],
requiredClaims: ['sub', 'exp', 'iat'],
});这里同时验证签名、签发者、接收方和时间声明。样例还要求 access token 的 scope 包含 openid;在这组配置中,ID token 没有这个 access-token scope。生产 API 应使用自己的业务权限,例如 orders:read,并按签发平台的令牌类型规则配置校验,不能把 openid 当作业务授权。
第二个终端进入同一目录,启动带 PKCE 的本地客户端:
export OIDC_ISSUER=http://localhost:8080/realms/te
export OIDC_CLIENT_ID=te-web
npm run client浏览器打开 http://localhost:5173/login,使用 oidc-test-user 和实验根目录 .env 中的测试密码登录;在本机终端可用 sed -n 's/^KC_TEST_USER_PASSWORD=//p' ../.env 读取,不复制到截图或日志。保持 localhost 拼写,不换成 127.0.0.1;redirect URI 的 host 参与精确匹配。客户端保存一次性 state、nonce 和 verifier,发送 S256 challenge,回调后用原 verifier 向 token endpoint 换码。ID token 按 te-web 校验 audience 和 nonce,access token 则通过真正的 HTTP 请求送往 8088/api/me。
若 Node 在 Linux 上优先把 localhost 解析成 ::1,而容器端口仅发布到 127.0.0.1,在两个 Node 终端都设置 NODE_OPTIONS=--dns-result-order=ipv4first 并重启程序。先比较 discovery 返回的精确 issuer;不能只把一侧改成 127.0.0.1,却仍让浏览器与另一侧使用 localhost。
回调页只显示检查结果,不展示令牌原文:
{"idTokenVerified":true,"access":200,"tampered":401,"absent":401,"idTokenAsAccess":401}access:200 来自真实签名令牌;tampered:401 将同一令牌的 sub 改掉但保留原签名,资源端必须拒绝;absent:401 是未提供凭证;最后一项把 ID token 错当 API 凭证,应因 audience 或 access-token scope 不符而拒绝。只打印解码后的用户名不会产生这些结果。若回调页返回 502,先看资源端配置和 Keycloak client 的 audience mapper;若回调返回 400,重新从 /login 发起一次流程,不复用已消费的 code/state。
未登录请求也可以单独检查,HTTP 传输与预期 401 分开判断:
status=$(curl -q --noproxy '*' -sS -o denied.json -w '%{http_code}' \
http://127.0.0.1:8088/api/me) || exit 1
test "$status" = 401 && jq -e '.error == "invalid_token"' denied.jsonNode 样例没有持久化会话、refresh token、业务角色或生产登出实现。它只保留五分钟的一次性登录状态,重启即失效;操作完成在两个终端按 Ctrl+C,浏览器退出 Keycloak 测试会话,再按后面的步骤清理独占 Compose。正式应用使用成熟 OIDC 集成、HTTPS 与服务端会话管理,不把这个回环程序直接部署到公网。
两个反实验把配置错误稳定暴露出来
redirect URI 多一个斜杠
把授权请求中的回调改为:
http://localhost:5173/callback/realm 注册的是不带尾斜杠的地址。预期 Keycloak 拒绝请求并记录无效 redirect URI;这证明 URI 不是“同一页面就行”。修复动作是让应用配置和 client 注册值精确一致,而不是把 * 加到生产 client。Keycloak 对 Valid Redirect URIs 的匹配与通配符风险见 Server Administration Guide。
后端使用容器地址作为 issuer
把资源服务改为 http://keycloak:8080/realms/te,浏览器仍从 http://localhost:8080 登录。token 的 iss 预期仍是浏览器可见地址,严格校验的资源服务会报 issuer 不匹配。不要关闭 issuer 校验;应确定一个浏览器、服务端和身份源都可解析的固定地址,并按 Keycloak hostname 配置处理反向代理与外部 URL。
这个失败也解释了为何只改 hosts 或只改容器 DNS 不够:issuer 是协议身份,不是连接目标别名。资源服务可以从一个网络地址请求 JWKS,但仍必须校验 token 宣称的签发者。
需要 Flow、Policy 或 Outpost 时转入 authentik
authentik 不是 Keycloak 的一个运行模式。它以 Application、Provider、Flow、Policy 和 Outpost 组织接入链,部署输入、身份数据和退出方式也不同。若项目要验证 authentik 自身的登录流程、策略或代理能力,进入 authentik:Application、Provider 与 Flow 如何组成身份入口,不要把两套身份源塞进同一 Compose 并共享数据库或 issuer。
接进项目与自动化测试
资源服务只配置一个权威入口:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${OIDC_ISSUER_URI:http://localhost:8080/realms/te}
audiences: te-api配置写入 Spring Boot 的 application.yaml,并引入 Spring Boot 4 的 spring-boot-starter-security-oauth2-resource-server;spring: 是属性层级的一部分。Boot 4 迁移说明列出了拆分后的 starter,旧项目应按自身 Boot 版本选择依赖。Web 项目还需声明 SecurityFilterChain,让 /api/** 要求认证并启用 .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))。如果项目已有安全链,应在现有链上配置,不能靠新增第二条不匹配的链“启用”保护。完整属性与默认校验行为见 Spring Security JWT Resource Server。随后用同一套有效、篡改、缺失令牌调用业务接口,预期分别为 200/401/401;仅应用启动成功不代表该路由已受保护。
前端配置 public client,不保存 secret:
VITE_OIDC_AUTHORITY=http://localhost:8080/realms/te
VITE_OIDC_CLIENT_ID=te-web
VITE_OIDC_REDIRECT_URI=http://localhost:5173/callback项目测试至少分成三层。第一层用固定私钥或轻量 mock 做快速 token 单元测试;第二层启动 Keycloak,验证 discovery、真实签名、角色映射和错误响应;第三层通过浏览器完成 Authorization Code + PKCE、Cookie、CORS、回调与退出登录。不要让每个单元测试都启动完整身份源,也不要用伪造 JWT 代替集成层。
CI 中以空 volume 启动 Keycloak,等待 discovery 返回预期 issuer 与非空 JWKS,再运行测试;结束时销毁这个独占 Compose project。共享环境则不能用删除 volume 做“更新”,realm、client、用户和 key 都是持久身份数据,应通过受审查的配置导入、Admin API 或声明式管理工具变更。
容器边界不是身份数据边界
Keycloak 开发容器默认可把 realm、client、用户、会话与 key 留在 keycloak-data volume。删除 server 容器不等于删除这些数据;反过来删除 volume 会同时丢失本地用户、client、会话和签名材料。
停止两个 Node 程序后,在最初执行 . ./.env 的资源端终端从 oidc-local-lab 返回上一级实验根目录,确认 compose.yaml、当前项目容器和卷都属于本次实验,再彻底清理:
set -euo pipefail
test "$(basename "$PWD")" = oidc-local-lab
cd ..
test "$(basename "$PWD")" = keycloak-oidc-lab
test -f compose.yaml && test -f keycloak/realm/te-realm.json
docker compose -p keycloak-oidc-lab ps -a
docker volume ls --filter label=com.docker.compose.project=keycloak-oidc-lab --format '{{.Name}}'
docker compose -p keycloak-oidc-lab down -v --remove-orphans
rm -f -- .env
unset KEYCLOAK_ADMIN_PASSWORD KC_TEST_CLIENT_SECRET KC_TEST_USER_PASSWORD该命令会删除本实验的身份数据卷和本机 .env,不能在共享实例执行;若没有按上面的目录顺序操作,先回到保存 compose.yaml 的目录,不盲目执行 cd ..。共享环境的回滚顺序应是恢复上一版应用配置与 redirect URI、撤销新增 client/用户、轮换已经暴露的 secret、按备份策略恢复身份数据库,最后验证旧 token 与新 token 的接受策略。签名 key 回滚尤其敏感:立即移除旧公钥会让尚未过期的 token 全部失效;长期保留旧私钥又扩大泄漏窗口。
共享与生产环境转入独立产品手册
本地 start-dev、一次性 Compose 和空 volume 只能证明项目接入链是否成立,不能证明生产高可用、备份恢复、跨节点会话、外部数据库、密钥轮换或版本迁移。Keycloak 生产入口应使用 start、显式 hostname、HTTPS、外部数据库和预构建的优化镜像;健康与指标管理端口也必须限制在管理网络。authentik 生产化则要固定同版本 server、worker 与 outpost,保护 PostgreSQL、media、签名材料和 Docker socket 权限。团队准备建立共享身份服务时,应分别进入 Keycloak 身份平台 与 authentik 身份平台,按各自对象模型完成部署、容量、HA、升级、回滚和退出验证。
两套产品也不应在一份共享 Compose 中做“并排竞赛”。先从应用需要的协议、生命周期、策略和运维责任选择候选,再为每个候选建立独立数据库、域名、issuer、client 和测试数据。生产资源端只能信任经过批准的 issuer;本地 issuer 不得混进生产 allowlist,测试 signing key 也不得被带入共享环境。
本地权限、凭证与数据边界
管理端口只开放给本机或受控管理网络;业务访问入口与管理入口分离。每个应用独立 client,public 与 confidential client 分开,禁止万能 client 和全局 redirect 通配符。KC_BOOTSTRAP_ADMIN_PASSWORD、AUTHENTIK_SECRET_KEY、PG_PASS、client secret、refresh token、TLS 私钥进入本机 secret 或密钥系统,不进入 Git、日志、截图和 CI artifact。
测试用户使用虚构资料,设置 owner、用途和删除时间;不导入生产用户、邮箱、手机号和组织关系。realm export、authentik blueprint 与数据库备份都可能携带 secret 或身份属性,提交前要结构化检查和脱敏。CI 日志只保留状态码、issuer、kid、错误类型和关联 ID;authorization code、token、cookie、用户属性和 secret 不进入 artifact。
镜像、Compose 文件和 realm/blueprint 输入都固定来源与版本;更新时重建空环境并重新运行正反实验,不复用不明 volume。
当故障再次表现为 401,按证据顺序排查:先取 discovery 的 issuer 与 JWKS URI,再比 token 的 iss、kid、aud、exp,随后确认资源服务实际加载的配置,最后检查反代 Host、X-Forwarded-*、HTTPS 与缓存。这个顺序能把“身份源签错”“应用验错”和“网络访问错”分开,而不是反复重建用户。
上线团队模板前逐项验收
Keycloak 使用团队审查过的明确版本,镜像和 Compose 来源可追踪。从空状态能创建 realm、client、测试用户并访问 discovery。Authorization Code + PKCE 与 client credentials 分别由合适客户端使用。
JWT 的 iss、kid、aud、exp 有自动断言,JWKS 轮换有兼容窗口。redirect URI、Web Origin、外部 hostname 和反代头在目标环境验证过。正向登录与 issuer/redirect URI 反实验都能留下明确故障证据。
管理密码、client secret、token、身份属性和导出文件未进入仓库与日志。个人与 CI 环境能彻底清理,临时 issuer、client、用户、volume 和 secret 均已核销。进入共享或生产环境前,已经转入对应独立产品手册完成 HA、备份、升级与退出设计。
权威资料与规范地址
部署与 Realm 配置
- Keycloak 官方版本入口:https://www.keycloak.org/downloads
- Keycloak 26.7.4 安全修复:https://www.keycloak.org/2026/09/keycloak-2674-released
- Running Keycloak in a container:https://www.keycloak.org/server/containers
- Importing and exporting realms:https://www.keycloak.org/server/importExport
- Keycloak audience:https://www.keycloak.org/docs/latest/server_admin/#_audience
- Server Administration Guide:https://www.keycloak.org/docs/latest/server_admin/
- Keycloak hostname 配置:https://www.keycloak.org/server/hostname
身份、权限与安全
- JOSE 的 jwtVerify:https://github.com/panva/jose/blob/main/docs/jwt/verify/functions/jwtVerify.md
- Spring Security JWT Resource Server:https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html
