Keycloak 核心原理详解
以实测回显拆解 Keycloak 的 Realm 模型与认证链、OIDC 授权码+PKCE、Token 体系与 JWKS 验签、SAML 与会话机制,附常见误区。
Keycloak 核心原理详解
> 目标:拆解 Keycloak 的六大核心机制(Realm 模型与认证链、OIDC 授权码+PKCE、Token 体系与 JWKS 验签、授权模式、SAML、会话与 Cookie),每个机制都配有可独立复现的实测命令与真实回显,全部在 Ubuntu 22.04 + Keycloak 26.8.1(OpenJDK 21.0.12.1)环境实测通过。
一、环境信息
本篇所有机制实验均在以下环境中逐条执行,命令与回显逐行照抄自真实输出:
- 操作系统:Ubuntu 22.04(x86_64,内核 6.8.0-138-generic)
- Keycloak:26.8.1(Quarkus 3.40.1 底座),安装目录
/opt/keycloak - Java:OpenJDK 21.0.12.1+1-LTS(Red Hat 发行版,Keycloak 26 要求 JDK 21)
- 服务地址:
http://localhost:8080 - 管理员账号:
admin/q1w2e3r4(master 域) - 业务域:
quzuo-demo,公开客户端quzuo-app,机密客户端quzuo-service(secret:qz-svc-secret-2026) - 测试用户:
zhangsan/q1w2e3r4(已分配 realm 角色viewer、client 角色order-reader)
💡 说明:本篇实验通过命令行完成全部交互(含浏览器阶段的登录表单交互),登录表单交互的还原方式见第四章 4.2 节。
二、机制总览
2.1 全景:一次登录背后的机制链
Keycloak 是一个"协议适配器 + 用户数据库 + 会话管理器"三位一体的独立认证服务。应用(前端或后端)不自己校验密码,而是把用户重定向到 Keycloak 的登录页;登录成功后 Keycloak 发放带签名的 Token,应用拿着 Token 就能确认"这个人是谁、有哪些角色"。这条链路上涉及的机制按发生顺序为:
- Realm 模型决定"这是哪个租户的用户、用哪套客户端与策略";
- 认证链决定"用哪些步骤验证用户身份"(密码、OTP、重置密码邮件等);
- 授权模式(授权码+PKCE、client_credentials 等)决定"应用用什么方式换取 Token";
- Token 体系决定"身份信息以什么格式承载、如何防伪造"(JWT + JWKS 签名验签);
- 会话与 Cookie决定"用户不用重复输密码"(SSO 单点登录);
- SAML/OIDC 协议层决定"与旧系统/新系统分别说什么语言"。
2.2 机制清单
| 机制 | 解决的问题 | 关键载体 | 本篇实测章节 |
|---|---|---|---|
| Realm 模型 | 多租户隔离 | realm / client / role | 第三章 3.1 |
| 认证链 | 身份验证步骤编排 | Authentication Flow | 第三章 3.2 |
| OIDC 授权码+PKCE | Web 应用安全换取 Token | code + code_verifier | 第四章 |
| Token 体系 | 防伪造的身份凭证 | JWT + JWKS | 第五章 |
| 授权模式 | 不同形态应用的取 token 方式 | grant_type | 第六章 |
| SAML | 与遗留系统互通 | SAML 断言 + 元数据 | 第七章 |
| 会话与 Cookie | SSO 单点登录 | KEYCLOAK_IDENTITY 等 | 第八章 |
表后解读:这七个机制不是并列关系,而是"一纵一横"——Realm/认证链/会话是纵向的地基(决定谁能登录、登录态放哪),OIDC/SAML/授权模式/Token 是横向的协议面(决定系统之间怎么传身份)。读后续章节时建议保持这条主线。
三、Realm 模型与认证链
3.1 Realm:多租户隔离的顶层边界
Realm(域)是 Keycloak 中最顶层的隔离单位:一个 Realm 拥有独立的用户库、客户端、角色、会话与签名密钥。Realm 之间互相不可见,一个 Realm 的 token 拿到另一个 Realm 去用会被直接拒绝——因为它由不同的密钥签名、不同的 issuer(签发者)声明。安装后默认存在 master 域(专用于管理员的登录),业务用户与业务客户端应放在自建的业务域中。
💡 技术拓展:多租户隔离粒度不止 Keycloak 的"Realm 实例内隔离"一种,常见方案对比——
方案 原理/特点 适用场景 实例内逻辑隔离(Keycloak Realm) 一套进程/库,逻辑上分租户 中小规模、运维成本敏感 每租户独立实例 隔离最彻底,成本最高 强合规、大客户 库级隔离(分库/分 schema) 数据库层面隔离 自研 IAM 常见 单库共享+租户字段 最省资源,隔离最弱 内部工具 Keycloak 走"实例内逻辑隔离"路线,一个 Realm 即一个租户,这也是它能以单套部署服务多条业务线的原因。
实测:同域通过、跨域拒绝。 先给 quzuo-demo 域用户 lisi 取一个 token,分别访问自己域和 master 域的 userinfo 端点:
# 在 quzuo-demo 域为 lisi 取 token(lisi / q1w2e3r4)
curl -s -X POST http://localhost:8080/realms/quzuo-demo/protocol/openid-connect/token \
-d 'grant_type=password&client_id=quzuo-app&username=lisi&password=q1w2e3r4&scope=openid'
# 用返回的 access_token 调同域 userinfo
curl -s http://localhost:8080/realms/quzuo-demo/protocol/openid-connect/userinfo \
-H "Authorization: Bearer $LT"
实测回显(同域,正常返回用户身份):
{"sub":"9c7370c8-1961-4158-854c-1f003b5a5b64","email_verified":true,"name":"Si Li","preferred_username":"lisi","given_name":"Si","family_name":"Li","email":"lisi@quzuo.net"}
同一个 token 换到 master 域的 userinfo 端点:
curl -s -D - http://localhost:8080/realms/master/protocol/openid-connect/userinfo \
-H "Authorization: Bearer $LT"
实测回显(跨域,直接拒绝):
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="master", error="invalid_token", error_description="Token verification failed"
{"error":"invalid_token","error_description":"Token verification failed"}
验证结论:同域 200 且返回身份声明,跨域 401 且报 Token verification failed——Realm 隔离不是靠业务代码判断,而是在签名密钥与 issuer 校验这一层就完成了。
3.2 认证链(Authentication Flow)与凭证
认证链(Authentication Flow)是 Keycloak 对"登录要做哪几步"的编排描述。默认浏览器登录流(browser flow)的骨架是:cookie(尝试用已有会话免登录)→ kerberos(可选)→ identity provider redirector(可选外部身份源)→ forms(用户名密码表单)→ 之后可插入 OTP/条件节点。每一步是一个 Authenticator,可以配置成 required/conditional/disabled。用户的密码、OTP 等以 Credential 形式挂在用户下,认证链只负责"编排",凭证类型负责"验证"。
💡 说明:凭证(Credential)与认证链(Flow)是可插拔的两种扩展点:密码/OTP/WebAuthn 是内置凭证类型;若要做短信验证码登录,通常实现一个自定义 Authenticator 插入 flow,而不是改凭证存储。
实测:观察登录表单这一步的认证链轨迹。 直接 GET 认证端点,Keycloak 返回登录页 HTML(这一步内部已经创建了会话、走到了 forms 步骤):
curl -s -D - -o /dev/null \
"http://localhost:8080/realms/master/protocol/openid-connect/auth?client_id=account-console&response_type=code&scope=openid&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Frealms%2Fmaster%2Faccount%2F&code_challenge=6S3-9ztV1dGpkkfR8KcoYmv2uGqDlb6S6HDi9FjOvXY&code_challenge_method=S256"
实测回显(节选,可见下发的会话 Cookie):
HTTP/1.1 200 OK
Set-Cookie: AUTH_SESSION_ID=<值节选>
Set-Cookie: KC_AUTH_SESSION_HASH=<值节选>
Set-Cookie: KC_AUTH_SESSION_ID_ONLY=<值节选>
Set-Cookie: KC_RESTART=<值节选>
⚠️ 注意:26.8 版本对内置
account-console客户端已强制要求 PKCE 参数,不带code_challenge/code_challenge_method时会得到 302 且报Missing parameter: code_challenge_method——这也是第四章 PKCE 机制的体现。
验证结论:未登录时 GET 认证端点即返回 200 登录页并预置三个会话 Cookie,说明认证链在浏览器阶段就已启动;Cookie 机制详见第八章。
四、OIDC 授权码 + PKCE 流程拆解(实测)
4.1 设计动因:为什么是授权码 + PKCE
授权码模式把"用户身份验证"与"token 发放"拆成两段:前端只拿到一次性短命的 code,真正有价值的 access_token/refresh_token 只出现在后端与 Keycloak 之间的直连请求里。PKCE(Proof Key for Code Exchange)在此基础上为"这段 code 不会被中间人偷换"加了数学证明:发起方先本地生成随机 code_verifier,把其 SHA-256 摘要 code_challenge 随跳转交给 Keycloak;换 token 时必须出示原始 code_verifier,Keycloak 重算摘要比对一致才发放 token。这样即使 code 在重定向过程中被截获,没有 verifier 也换不到 token。
💡 技术拓展:OAuth 2.1 草案已明确"所有公开客户端必须使用 PKCE"。常见替代/相关方案对比——
方案 原理/特点 适用场景 授权码+PKCE 两段式 + 摘要证明,安全性最高 Web/移动/SPA 全场景(推荐) 隐式模式(implicit) 前端直接拿 token,无 code 交换 已废弃,不再推荐 密码模式(password) 应用代用户提交账密 仅迁移期过渡用 client_credentials 无用户参与,机器对机器 服务间调用 设备授权(device flow) 无键盘设备出码、另设备确认 智能电视/CLI 本篇实测覆盖授权码+PKCE(4.2)、client_credentials(6.2)、password(6.1)三种;SPA 与移动端一律首选授权码+PKCE。
4.2 流程逐步拆解与实测
实验目标:在不借助真实浏览器的情况下,用 curl 还原"浏览器跳转 → 登录表单提交 → 回调带 code → 后端换 token"的完整四步。先在服务器上生成一对 verifier/challenge:
python3 - <<'EOF'
import hashlib, base64, secrets
v = secrets.token_urlsafe(48)
c = base64.urlsafe_b64encode(hashlib.sha256(v.encode()).digest()).rstrip(b"=").decode()
print(v, c)
EOF
实测回显(verifier 与 challenge 一一对应):
5xt0oB1sQAdR90rAuzT4u9enzb6sP7YJhRy9k21wHPMpqa7kz6gkmqLqwTjPp7wq ErujNr22D1adD58Jou4As8xgoznfNlFhF4wY6CO2Zmo
第 1 步:浏览器阶段,GET 认证端点(携带 code_challenge),Keycloak 返回登录表单。
curl -s -c cookies.txt -o login.html -w 'HTTP %{http_code}\n' \
"http://localhost:8080/realms/quzuo-demo/protocol/openid-connect/auth?client_id=quzuo-app&response_type=code&scope=openid&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&state=quzuo-state-123&code_challenge=ErujNr22D1adD58Jou4As8xgoznfNlFhF4wY6CO2Zmo&code_challenge_method=S256"
实测回显:
HTTP 200
登录页 HTML 中包含"Sign in to your account"标题与用户名输入框,以及一个带一次性会话参数的表单 action 地址(实测节选):
Sign in to your account
<input id="username" name="username" value="" type="text" autocomplete="username" autofocus
表单 action 地址(实测节选,包含 session_code 等一次性参数):
http://localhost:8080/realms/quzuo-demo/login-actions/authenticate?session_code=ydOZ16O1ThRqjWo5gcBqwBgO6ULVi8Gvq6QhSXh4...
第 2 步:向表单 action 提交用户名密码(带第 1 步的会话 Cookie),Keycloak 验证通过后 302 回调并发放 code。
ACTION=$(grep -o 'action="[^"]*"' login.html | head -1 | sed 's/^action="//;s/"$//;s/\&/\&/g')
curl -s -b cookies.txt -D resp.txt -o /dev/null -X POST "$ACTION" \
-d 'username=zhangsan&password=q1w2e3r4&credentialId='
grep -iE '^(HTTP|location)' resp.txt
实测回显:
HTTP/1.1 302 Found
Location: http://localhost:8080/callback?state=quzuo-state-123&session_state=NyEzQHaylvSVzFOLaI7fDMFh&iss=http%3A%2F%2Flocalhost%3A8080%2Frealms%2Fquzuo-demo&code=b859506f-d9c9-7ffa-bf51-339acf7ad67d.NyEzQHaylvSVzFOLaI7fDMFh.010343a9-ae98-4d69-b445-2d2f1d6aa626
💡 说明:回调 Location 里的
state=quzuo-state-123原样带回,这正是 OAuth 防 CSRF 的 state 参数——应用端必须校验它与自己发出时一致;code是一次性授权码(形如授权码.会话ID.客户端UUID)。
第 3 步:后端用 code + code_verifier 换 token(这一步 Keycloak 会重算摘要比对)。
curl -s -X POST http://localhost:8080/realms/quzuo-demo/protocol/openid-connect/token \
-d "grant_type=authorization_code&client_id=quzuo-app&code=b859506f-d9c9-7ffa-bf51-339acf7ad67d.NyEzQHaylvSVzFOLaI7fDMFh.010343a9-ae98-4d69-b445-2d2f1d6aa626&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&code_verifier=5xt0oB1sQAdR90rAuzT4u9enzb6sP7YJhRy9k21wHPMpqa7kz6gkmqLqwTjPp7wq"
实测回显(输出节选,token 为超长 JWT,此处截取首段):
HTTP 200
{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJES29MN3ZFdVJMSnJKNDlTXy1wTUJGd1ZyLVdrczljdU52Rl9HNVhCU1Q4In0.eyJleHAiOjE3OTIyMjg0ODEsImlhdCI6MTc5MjIyODE4MSwiYXV0aF90aW1lIjoxNzkyMjI4MTgxLCJqdGkiOiJvbnJ0YWM6NDFlOGU5MzEtM2U2NC1mNDA5LTdkMjAtMzMyODI3ZGIyMjkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo4MDgwL3JlYWxtcy9xdXp1by1kZW1vIiwiYXVkIjpbInF1enVvLXNlcnZpY2UiLCJhY2NvdW50Il0sInN1YiI6IjllODYzYzk2LWY5N2ItNGQ5Yy05ODllLTMwODhhNzJhOGFlMCIsInR5cCI6IkJlYXJlciIsImF6cCI6InF1enVvLWFwcCIsInNpZCI6Ik55RXpRSGF5bHZTVnpGT0xhSTdmRE1GaCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cDovL2xvY2FsaG9zdDo4MDgwIiwiaHR0cDovL2xvY2FsaG9zdDozMDAwIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJ2aWV3ZXIiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIiwiZGVmYXVsdC1yb2xlcy1xdXp1by1kZW1vIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsicXV6dW8tc2VydmljZSI6eyJyb2xlcyI6WyJvcmRlci1yZWFkZXIiXX0sImFjY291bnQiOnsicm9sZXMiOlsibWFuYWdlLWFjY291bnQiLCJtYW5hZ2UtYWNjb3VudC1saW5rcyIsInZpZXctcHJvZmlsZSJdfX0sInNjb3BlIjoib3BlbmlkIHByb2ZpbGUgZW1haWwiLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwibmFtZSI6IlNhbiBaaGFuZyIsInByZWZlcnJlZF91c2VybmFtZSI6InpoYW5nc2FuIiwiZ2l2ZW5fbmFtZSI6IlNhbiIsImZhbWlseV9uYW1lIjoiWmhhbmciLCJlbWFpbCI6InpoYW5nc2FuQHF1enVvLm5ldCJ9.QHnShJCcaetyFoCHQxhBVEJEUNOkJmncwzVshKKOHaNFv9KY-5XF6YzJJ64Mb7urys07MsB8q07-gPoNhCWuQx2r07zD6DsvWCQr-7j3FZyELNojow6K-nlxeMLviNBK63cZLU-lg6VsL78oij9L0u-L8ykiydqLYqrbKegkcRz3dtdt-O6KjPS_dBfGKVkR3Uk8y5Pb1R7ykDX-jzj3rEp7eDUOwxx9JzFn0gJzKu44Dz5tVr8BAixplHv6ntUEwb_Dk-0Ojr-rqaqkF5UoIcOJmF6R1uGvfyiTKeY-phIxvVqZjX_kHhDAwaTiy1FqQ_zatqoU2Yo3VvXBJyUj5w","expires_in":300,"refresh_expires_in":1800,"refresh_token":"...","token_type":"Bearer","id_token":"...","not-before-policy":0,"session_state":"NyEzQHaylvSVzFOLaI7fDMFh","scope":"openid profile email"}
反向验证:错误的 code_verifier 应被拒绝。 重复第 1、2 步取得新 code 后,换 token 时故意提交错误 verifier:
HTTP 400
{"error":"invalid_grant","error_description":"PKCE verification failed: Code mismatch"}
验证结论:verifier 正确 → 200 发 token;verifier 错误 → 400 且明确报 PKCE verification failed: Code mismatch。整条链路中,浏览器侧始终没有接触过 token,只经手了 code 与 challenge,这就是授权码+PKCE 的安全价值。
五、Token 体系:发放、结构与验签(实测)
5.1 Keycloak 发放的四类 token
| Token | 用途 | 签名算法(实测) | 默认有效期(实测 expires_in) |
|---|---|---|---|
| access_token | 调用资源服务的凭证 | RS256(RSA+SHA256) | 300 秒 |
| refresh_token | 换新 access_token,不重新输密码 | HS512(HMAC,域内对称密钥) | 1800 秒 |
| id_token | 向客户端证明"用户是谁"(OIDC 专属) | RS256 | 300 秒 |
| RPT/交换类 token | 授权服务(UMA)与 token 交换场景 | RS256 | 随场景 |
表后解读:注意 access_token 与 refresh_token 签名算法不同——access_token 要给资源服务用,必须用非对称签名(资源服务只需拿 JWKS 公钥即可验);refresh_token 只回到 Keycloak 自己的 token 端点使用,用域内对称密钥 HS512 即可,无需对外公开验证手段。这是"按使用面选签名算法"的典型设计。
5.2 access_token 结构解析(解码实测)
JWT 由 header.payload.signature 三段 Base64URL 组成。对 4.2 节拿到的 token 逐段解码:
import json, base64
tok = open('token.json').read()
def seg(s, i):
p = s.split('.')[i]; p += '=' * (-len(p) % 4)
return json.loads(base64.urlsafe_b64decode(p))
print(json.dumps(seg(tok,0), indent=2)) # header
print(json.dumps(seg(tok,1), indent=2)) # payload
实测回显(header):
{
"alg": "RS256",
"typ": "JWT",
"kid": "DKoL7vEuRLJrJ49S_-pMBFwVr-Wks9cuNvF_G5XBST8"
}
实测回显(payload,完整照抄):
{
"exp": 1792228429,
"iat": 1792228129,
"jti": "onrtro:9cd37832-4ab3-236c-fae7-9f690da680c4",
"iss": "http://localhost:8080/realms/quzuo-demo",
"aud": [
"quzuo-service",
"account"
],
"sub": "9e863c96-f97b-4d9c-989e-3088a72a8ae0",
"typ": "Bearer",
"azp": "quzuo-app",
"sid": "iHDQUbzAZWoU_pSLVfu8wV6H",
"acr": "1",
"allowed-origins": [
"http://localhost:8080",
"http://localhost:3000"
],
"realm_access": {
"roles": [
"viewer",
"offline_access",
"uma_authorization",
"default-roles-quzuo-demo"
]
},
"resource_access": {
"quzuo-service": {
"roles": [
"order-reader"
]
},
"account": {
"roles": [
"manage-account",
"manage-account-links",
"view-profile"
]
}
},
"scope": "openid profile email",
"email_verified": true,
"name": "San Zhang",
"preferred_username": "zhangsan",
"given_name": "San",
"family_name": "Zhang",
"email": "zhangsan@quzuo.net"
}
逐项解读:iss 是签发域的完整 URL(第三章跨域 401 的判据就在这里);sub 是用户在该域内的唯一 ID(与 Admin API 里的用户 UUID 一致,为 9e863c96-f97b-4d9c-989e-3088a72a8ae0);azp 是发起授权的客户端;exp - iat = 300 即 5 分钟有效期;realm_access.roles 与 resource_access.<client>.roles 正是第三章 3.1 分配的 realm 角色 viewer 与 client 角色 order-reader,角色一旦分配即出现在 token 里,资源服务无需回查数据库即可完成鉴权;sid 是服务端会话 ID,登出、会话管理都围绕它。
5.3 JWKS 验签实测(含篡改对照)
资源服务验证 token 的标准姿势:从 /realms/<realm>/protocol/openid-connect/certs 拉 JWKS(JSON Web Key Set),按 header 里的 kid 找到公钥,验 RSA 签名。先拉 JWKS:
curl -s http://localhost:8080/realms/quzuo-demo/protocol/openid-connect/certs
实测回显(节选,共两把密钥):
{"keys":[{"kid":"GHcPH57QtwIem2CwskcJ...","kty":"RSA","alg":"RSA-OAEP","use":"enc",...},{"kid":"DKoL7vEuRLJrJ49S_-pMBFwVr-Wks9cuNvF_G5XBST8","kty":"RSA","alg":"RS256","use":"sig","n":"6uT_-VBqpbnkXjHn...","e":"AQAB",...}]}
用 JWKS 中的公钥真实验签(Python cryptography 库,RSA PKCS1v15 + SHA256):
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
# n/e 从 JWKS 的 kid=DKoL7vEu... 那把 key 解出后构造公钥
pub.verify(sig, f'{h}.{p}'.encode(), padding.PKCS1v15(), hashes.SHA256())
实测回显:
签名验证: kid=DKoL7vEuRLJrJ49S_-pMBFwVr-Wks9cuNvF_G5XBST8 算法=RS256(SHA256+PKCS1v15) → 有效 ✓
篡改 payload 后重验 → 无效 ✗(签名与内容绑定)
验证结论:真实签名通过验证;把 payload 改动任意两个字符后同一签名立即失效。这说明资源服务完全可以"只拿公钥本地验签、零回查 Keycloak"地鉴权——这正是无状态 token 体系的意义。kid 的存在则支持密钥轮换:Keycloak 生成新密钥对后 JWKS 同时挂新旧两把 key,旧 token 依然可验。
六、四种取 token 的授权模式(实测)
6.1 password grant(直接访问授权)
适用迁移期场景:应用老系统里存着用户账密,短期内无法改造为跳转登录,可用密码模式直接向 token 端点提交账密换取 token。quzuo-app 客户端需开启 Direct access grants(Admin API 中 directAccessGrantsEnabled=true)。
curl -s -X POST http://localhost:8080/realms/quzuo-demo/protocol/openid-connect/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=password&client_id=quzuo-app&username=zhangsan&password=q1w2e3r4&scope=openid'
实测回显(输出节选):
{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJES29MN3ZF...(1485 chars)","expires_in":300,"refresh_expires_in":1800,"refresh_token":"eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICIyMDFjNDk2NC1jNDQ4LTRjNzUtYmE3Ny04NWRjZGE1ODRiZmYifQ...(724 chars)","token_type":"Bearer","id_token":"...","not-before-policy":0,"session_state":"iHDQUbzAZWoU_pSLVfu8wV6H","scope":"openid profile email"}
错误对照(密码错误):
curl -s -o /dev/null -w 'HTTP %{http_code}\n' -X POST \
http://localhost:8080/realms/quzuo-demo/protocol/openid-connect/token \
-d 'grant_type=password&client_id=quzuo-app&username=zhangsan&password=wrong-pass'
实测回显:
HTTP 400
{"error":"invalid_grant","error_description":"Invalid user credentials"}
⚠️ 注意:账密错误返回的是 HTTP 400(不是 401)——OAuth 规范把"用户凭证错误"归为 invalid_grant 授权失败而非认证挑战,客户端排查时不要按 401 找 Basic Auth 的问题。
6.2 client_credentials(服务间调用)
适用场景:两个后端服务之间的调用没有"用户"参与,客户端自己就是主体。需要机密客户端(publicClient=false)并开启服务账号(serviceAccountsEnabled=true)。quzuo-service 的 secret 为 qz-svc-secret-2026:
curl -s -X POST http://localhost:8080/realms/quzuo-demo/protocol/openid-connect/token \
-d 'grant_type=client_credentials&client_id=quzuo-service&client_secret=qz-svc-secret-2026'
实测回显(输出节选):
{"access_token":"eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJES29MN3ZF...(1287 chars)","expires_in":300,"refresh_expires_in":0,"token_type":"Bearer","not-before-policy":0,"scope":"profile email"}
解码其 payload 可见关键差异:没有用户身份字段,azp 为 quzuo-service 自身,sub 是 Keycloak 为该客户端自动生成的服务账号用户 ID:
claims: {'iss': 'http://localhost:8080/realms/quzuo-demo', 'azp': 'quzuo-service', 'typ': 'Bearer', 'sub': 'a386a535-a065-4e47-b569-1142dca1dccd', 'scope': 'profile email'}
注意 refresh_expires_in=0:机器凭据可以直接重复申请,不需要 refresh 机制。
6.3 refresh_token 刷新机制
access_token 只有 300 秒寿命,靠 refresh_token 换新而不打断用户。refresh_token 由 HS512 域内密钥签名(见 5.1),只被 Keycloak 自己接受。取新 token:
curl -s -X POST http://localhost:8080/realms/quzuo-demo/protocol/openid-connect/token \
-d "grant_type=refresh_token&client_id=quzuo-app&refresh_token=$RT"
实测回显(输出节选):
HTTP 200
access_token = eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwi...(1485 chars)
refresh_token = eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwi...(724 chars)
{'expires_in': 300, 'refresh_expires_in': 1800, 'session_state': 'iHDQUbzAZWoU_pSLVfu8wV6H', 'scope': 'openid profile email'}
要点:刷新后返回全新的 access_token 与 refresh_token,而 session_state 不变——说明刷新不产生新会话,只是同一会话下的凭证续期;真正控制用户何时必须重新输密码的是域设置里的 SSO Session Idle(默认 30 分钟)与 SSO Session Max(默认 10 小时),refresh_token 的 1800 秒只是它自身的有效期上限。
6.4 实测小结与选型回看
三种模式实测行为汇总:password 与 client_credentials 一步取 token;授权码+PKCE 四步但浏览器不接触 token;refresh 不建新会话。回看 4.1 的对比表:新应用一律授权码+PKCE;服务间用 client_credentials;password 只做迁移期过渡。
七、SAML 协议与元数据(实测)
SAML 2.0 是 OIDC 之前的 Web SSO 标准,大量遗留企业系统只支持 SAML。Keycloak 可同时充当 SAML IdP(身份提供方):为域建一个 SAML 协议客户端即可。通过 Admin API 创建(请求体中 protocol 指定为 saml):
curl -s -D - -o /dev/null -X POST http://localhost:8080/admin/realms/quzuo-demo/clients \
-H "Authorization: Bearer $AT" -H 'Content-Type: application/json' \
-d '{"clientId":"quzuo-legacy-saml","enabled":true,"protocol":"saml","redirectUris":["http://localhost:8080/saml/acs"],"attributes":{"saml.assertion.consumer.url":"http://localhost:8080/saml/acs","saml.server.signature":"true","saml.signature.algorithm":"RSA_SHA256"}}'
实测回显:
HTTP/1.1 201 Created
Location: http://localhost:8080/admin/realms/quzuo-demo/clients/6846f649-c690-438a-8374-b2b4b5cf29a6
SAML 服务提供方接入 IdP 的标准入口是元数据 XML,Keycloak 每个域自动暴露:
curl -s http://localhost:8080/realms/quzuo-demo/protocol/saml/descriptor
实测回显(输出节选,原文为一行 XML,此处按元素断行排版):
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" ...
entityID="http://localhost:8080/realms/quzuo-demo">
<md:IDPSSODescriptor WantAuthnRequestsSigned="true" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyName>DKoL7vEuRLJrJ49S_-pMBFwVr-Wks9cuNvF_G5XBST8</ds:KeyName>
<ds:X509Certificate>MIICozCCAYsCBgGhSR1MxTANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDApxdXp1by1kZW1v...</ds:X509Certificate>
</md:KeyDescriptor>
<md:SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8080/realms/quzuo-demo/protocol/saml"/>
<md:NameIDFormat>urn:oasis:names:tc:SAML:2.0:nameid-format:persistent</md:NameIDFormat>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="http://localhost:8080/realms/quzuo-demo/protocol/saml"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="http://localhost:8080/realms/quzuo-demo/protocol/saml"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>
逐项解读:entityID 即 IdP 的全局唯一标识(与 OIDC 的 issuer 对应);KeyDescriptor 内联了签名证书(KeyName 与 5.3 节 OIDC 验签的 kid 完全相同——两个协议共用同一把域级密钥);实测该元数据含 1 个签名 KeyDescriptor 与 4 个 SingleSignOnService 绑定(HTTP-POST / HTTP-Redirect / HTTP-Artifact / SOAP),SP 侧按自身能力选择绑定方式。遗留系统拿到这份 XML 导入即可完成 IdP 配置。
八、会话与 Cookie 机制(实测)
Keycloak 的 SSO 体验由服务端会话 + 浏览器 Cookie 共同实现:会话(UserSession)存在 Keycloak 内存/Infinispan 中,Cookie 只携带会话引用。实测两个阶段的 Cookie 下发:
登录前(GET 认证端点即下发,见 3.2 实测):
| Cookie | 作用 |
|---|---|
| AUTH_SESSION_ID | 认证过程中的临时会话引用 |
| KC_AUTH_SESSION_HASH | 会话一致性校验 |
| KC_RESTART | 认证中断后恢复流程用 |
登录成功后(提交登录表单的 302 响应中下发,实测):
Set-Cookie: KC_RESTART=<值节选>
Set-Cookie: KEYCLOAK_IDENTITY=<值节选>
Set-Cookie: KEYCLOAK_SESSION=<值节选>
KEYCLOAK_IDENTITY 是加密签名的长期身份 Cookie(SSO 的核心:用户再次访问任何接入该域的应用时,认证链的 cookie 步骤凭它直接放行,不再出现登录页);KEYCLOAK_SESSION 是明文的会话 ID 引用。登出(第六章流程中实测 POST /protocol/openid-connect/logout 返回 204)会撤销服务端会话,此后旧 access_token 调 userinfo 得到:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="quzuo-demo", error="invalid_token", error_description="user_session_not_found"
💡 说明:
user_session_not_found直白地揭示了 access_token 的验证内幕——签名有效性与会话存活性是两回事;Keycloak 默认校验 token 时会回查会话状态,所以"登出即失效"成立。这也是与纯无状态 JWT 方案的关键差异。
九、机制协作全景
把前八章串起来,一次完整的用户登录在 Keycloak 内部的协作顺序为:
- 应用发现用户未登录,带
code_challenge跳转认证端点(授权码+PKCE,第四章); - Keycloak 依 Realm 定位租户,建立认证会话并下发临时 Cookie(第三、八章);
- 认证链走到 forms 步骤,用户提交账密,凭证校验通过(第三章);
- Keycloak 生成 UserSession(sid),下发
KEYCLOAK_IDENTITY/KEYCLOAK_SESSIONCookie(第八章); - 302 回调携带一次性 code,应用后端用 code+verifier 换取 token 三件套(第四、五章);
- 资源服务拿 JWKS 公钥本地验签,读 payload 里的角色做鉴权(第五章);
- access_token 过期后应用用 refresh_token 静默续期,直到 SSO 会话超限(第六章);
- 用户登出 → 会话撤销 → 旧 token 立即 401(第六、八章);
- 遗留系统不走 OIDC,改用同一域的 SAML IdP 与同一把签名密钥接入(第七章)。
这条链上任何一环的配置错误,都会以非常具体的错误回显暴露——下一章的常见误区均来自真实踩坑。
十、常见误区
误区一:以为生产模式不配 hostname 只是"告警"。 实测生产模式 start 不配 hostname 会直接拒绝启动:
ERROR: Failed to start server in (production) mode
ERROR: hostname is not configured; either configure hostname, or set hostname-strict to false
26.x 默认启用 Hostname v2:--hostname 决定 token 里 issuer、前端 URL、回调校验的全部基准。反向错误同样常见——按旧版习惯设置了 KC_HOSTNAME_STRICT_HTTPS=false,在 26.x 该选项已被 Hostname v2 取代,正确做法是 KC_HOSTNAME + KC_HTTP_ENABLED=true(仅限 TLS 终结在代理层的场景)。
误区二:忽略时钟漂移,token 验证"偶发失败"。 JWT 的 exp/iat/auth_time 全部是绝对时间戳(见 5.2 实测值),Keycloak 与资源服务机器时钟不一致时,会出现"签名明明正确却报 token 无效"。Keycloak 客户端默认允许的时钟偏差为 0(Admin 控制台客户端设置中的 Allowed Clock Skew 可调),因此生产环境所有参与验签的机器都应启用 NTP 对时;跨域部署时尤其要把"时差"纳入排查清单——先对时再查签名。
误区三:把 access_token 当长期凭证缓存。 实测 access_token 只有 300 秒(expires_in=300),refresh_token 1800 秒,且登出后立即失效(user_session_not_found)。正确姿势是:access_token 用完即弃、到期用 refresh 换新;需要"记住登录"靠的是 SSO 会话与 refresh 链,而不是把 access_token 存 localStorage 长期复用。
误区四:以为角色改了 token 立即生效。 角色声明在签发时写进 token(见 5.2 的 realm_access),已发出的 token 不会随之变化,要等刷新/重新登录才携带新角色。紧急回收权限时,正确手段是域级 Not Before(推高 not-before-policy)或撤销用户会话,而不是等 token 自然过期。
十一、参考链接
- Keycloak Server Administration Guide(Hostname v2、会话与令牌生命周期):https://www.keycloak.org/documentation
- OIDC Discovery 与端点约定(RFC 8414):https://openid.net/specs/openid-connect-discovery-1_0.html
- PKCE 规范 RFC 7636:https://datatracker.ietf.org/doc/html/rfc7636
- JWT 规范 RFC 7519 与 JWA(RS256 定义):https://datatracker.ietf.org/doc/html/rfc7518
- SAML 2.0 Metadata(OASIS):https://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf
- Keycloak 源码仓库:https://github.com/keycloak/keycloak
- 相关文档:《OpenBao 2.7.1 核心原理详解》《Vaultwarden 安全加固指南》