Keycloak 核心原理详解

以实测回显拆解 Keycloak 的 Realm 模型与认证链、OIDC 授权码+PKCE、Token 体系与 JWKS 验签、SAML 与会话机制,附常见误区。

KeycloakKeycloak2026/10/17

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 就能确认"这个人是谁、有哪些角色"。这条链路上涉及的机制按发生顺序为:

  1. Realm 模型决定"这是哪个租户的用户、用哪套客户端与策略";
  2. 认证链决定"用哪些步骤验证用户身份"(密码、OTP、重置密码邮件等);
  3. 授权模式(授权码+PKCE、client_credentials 等)决定"应用用什么方式换取 Token";
  4. Token 体系决定"身份信息以什么格式承载、如何防伪造"(JWT + JWKS 签名验签);
  5. 会话与 Cookie决定"用户不用重复输密码"(SSO 单点登录);
  6. SAML/OIDC 协议层决定"与旧系统/新系统分别说什么语言"。

2.2 机制清单

机制解决的问题关键载体本篇实测章节
Realm 模型多租户隔离realm / client / role第三章 3.1
认证链身份验证步骤编排Authentication Flow第三章 3.2
OIDC 授权码+PKCEWeb 应用安全换取 Tokencode + code_verifier第四章
Token 体系防伪造的身份凭证JWT + JWKS第五章
授权模式不同形态应用的取 token 方式grant_type第六章
SAML与遗留系统互通SAML 断言 + 元数据第七章
会话与 CookieSSO 单点登录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/\&amp;/\&/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 专属)RS256300 秒
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 配置。

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 内部的协作顺序为:

  1. 应用发现用户未登录,带 code_challenge 跳转认证端点(授权码+PKCE,第四章);
  2. Keycloak 依 Realm 定位租户,建立认证会话并下发临时 Cookie(第三、八章);
  3. 认证链走到 forms 步骤,用户提交账密,凭证校验通过(第三章);
  4. Keycloak 生成 UserSession(sid),下发 KEYCLOAK_IDENTITY/KEYCLOAK_SESSION Cookie(第八章);
  5. 302 回调携带一次性 code,应用后端用 code+verifier 换取 token 三件套(第四、五章);
  6. 资源服务拿 JWKS 公钥本地验签,读 payload 里的角色做鉴权(第五章);
  7. access_token 过期后应用用 refresh_token 静默续期,直到 SSO 会话超限(第六章);
  8. 用户登出 → 会话撤销 → 旧 token 立即 401(第六、八章);
  9. 遗留系统不走 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 自然过期。

十一、参考链接