Keycloak刷新令牌与会话超时机制详解
为什么Keycloak的刷新操作会返回400错误,空闲会话超时与最大会话超时有何区别,以及如何在JavaScript中利用防轮换存储实现刷新令牌的单次使用。
来源: Refresh Tokens and Session Timeouts in Keycloak: Why a 400 Isn’t Always a Bug
基于Keycloak的应用在刷新令牌时,有时会收到400 Bad Request的响应。初步判断是令牌续期功能出了问题。但更多情况下,Keycloak是故意拒绝的:会话已闲置超时、达到最大有效期、已被撤销,或者刷新令牌不再有效。这些情况属于策略设定,并非缺陷。
本指南将详细介绍生产环境中需要关注的关键点:访问令牌和刷新令牌的功能、令牌续期的完整流程、决定刷新是否成功的两个会话计时机制,以及处理相关问题的前端最佳实践与错误做法。
撰写本文的原因
当令牌端点返回400错误时,团队往往会将其归类为认证故障。首先需要问的是是哪条有效期或撤销规则触发了该响应。了解这一原因可以避免将正常的拒绝处理成服务故障。
1. 两种令牌,截然不同的功能
登录后,Keycloak通常会生成一个访问令牌、一个刷新令牌和一个ID令牌。日常操作主要依赖于前两种令牌。
- 访问令牌——用于普通API调用,以证明是谁在发起请求。其有效期很短,通常以分钟计。
- 刷新令牌——仅用于向Keycloak请求生成新的访问令牌。其有效期较长,一般为30分钟或更久。
- 离线令牌——可选的长期有效访问令牌,其有效期可超过常规单点登录会话的时间,由独立的策略进行管理。
打个比方:访问令牌就像每扇门前的临时通行证,而刷新令牌则是用来更换通行证的凭证;它永远不会展示给应用程序API,只能发送到Keycloak的令牌端点。
2. 正常流程
登录后会返回令牌集合。之后客户端在每次调用时都会附加访问令牌:
GET /api/products
Authorization: Bearer eyJ...
访问令牌的有效期很短——通常为5到15分钟。如果用户在09:00登录并获得5分钟的访问令牌,那么到09:05时下一次API调用就会返回401 Unauthorized错误。
这个401错误并不意味着立即登出,而是提示需要刷新令牌。
3. 刷新调用本身
客户端会使用刷新令牌向Keycloak的令牌端点发送请求:
POST /realms/{realm}/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
client_id=<client-id>
refresh_token=<refresh-token>
正常的响应格式如下:
{
"access_token": "new-access-token",
"expires_in": 300,
"refresh_token": "new-refresh-token",
"refresh_expires_in": 1800
}
客户端会将新的访问令牌存储起来,然后重新发起原始请求,用户无需察觉这一过程。
4. 令牌轮换规则:每次都要更换存储的刷新凭证
响应中通常还会包含一个新的刷新令牌。当启用刷新令牌轮换功能时,新令牌一经生成,旧令牌就会立即失效。若重复使用旧令牌,则在下次续期时会失败。
// Correct
saveAccessToken(response.access_token);
if (response.refresh_token) {
saveRefreshToken(response.refresh_token);
}
// Wrong — keeps using the token that rotation just invalidated
const refreshToken = originalRefreshToken;
不仅在登录后,而是在每次操作成功时都将轮换后的刷新令牌保存下来,这正是能让会话安静地持续数小时续期,而非在第二次尝试就失败的关键。
5. 空闲时间与最大时间:控制续期的计时器
Realm至少会提供两个会话计时器,而混淆它们的含义是导致大多数“认证为何失败”问题的原因:
- SSO会话空闲时间——会话在出现无任何活动的情况下还能维持多久,之后Keycloak才会终止该会话。成功的刷新操作被视为一次活动,会重置此计时器。
简而言之:
IDLE = how long since the session was last active
MAX = how long since the user originally logged in
当空闲时间 = 30分钟且最长时间 = 16小时时,频繁刷新可使空闲计时始终处于较短状态,但即便用户1分钟前还在操作,一旦距离登录满16小时,会话就会结束。再完美的刷新时机也无法突破这个上限,只能延长该时间窗口内的会话时长。
每个客户端的空闲/最长时间设置虽然与服务器端计时一致,但仅适用于该客户端,以两者中更严格的那一个为准。
为何示例中会出现16小时的最高限制:那些采用大约12小时工作制的ERP团队可能会选择12小时的工作时间再加上4小时的缓冲时间,这样就能让会话在整个工作日内无需中途重新登录即可正常运行,同时也不会持续过久。这个数字是出于业务考虑而设定的;关键技能在于明白它是受哪种时间规则控制的。
为何30分钟的空闲时间仍会让人惊讶:即便设置了较长的最高限制,漫长的午餐或会议仍可能提前导致空闲时间结束。如果用户无需重新认证即可返回,那么应对空闲时间的处理应作为产品或安全方面的决策——而非仅仅在客户端代码中临时解决。
6. 从令牌端点读取400状态码时无需假设系统故障
刷新失败可能表现为:
{
"error": "invalid_grant",
"error_description": "Token is not active"
}
该响应数据可能对应多种不同情况:
- 刷新凭证已自动过期
这些情况均不属于“API 停止运行”。在做出其他判断之前,请先查看 error 和 error_description 的内容。
7. 正确地在前端处理该问题
一种可靠的解决方案:
401 on API request
→ try refresh, exactly once
→ success: save new tokens, retry the original request
→ failure: clear auth state, redirect to login
不要无限次尝试:出现 401 错误则续期,若仍失败则再次续期。一旦会话失效,就会导致大量令牌请求以及永不停止的加载状态。应将续期失败视为最终结果——丢弃凭证、清除认证状态、引导用户登录,然后停止后续操作。
8. 分支式的401错误需要一次性统一续期
如果控制面板使用已过期的访问令牌同时发起五次请求,就会收到五个401错误响应。如果每个路径都独立轮询刷新,那么第一次成功的刷新会使其他四个路径仍持有的令牌失效——因此即便会话本身正常,也会有四次续期因400 invalid_grant错误而失败。
应统一处理续期操作:第一个401错误会触发一个承诺对象;其他请求则等待同一个承诺对象,在其状态确定后重新尝试。
if (tokenExpired) {
if (!refreshInProgress) {
refreshInProgress = refreshToken();
}
try {
const tokens = await refreshInProgress;
updateTokens(tokens);
return retryOriginalRequest();
} finally {
refreshInProgress = null;
}
}
无论锁机制是手动实现的承诺对象、Axios拦截器队列,还是RTK Query的baseQuery包装器,规则都是固定的:**仅允许一次刷新操作进行中**,无论有多少请求在等待。
9. 看起来相关但实际上无关的设置:登录超时与登录操作超时
还有两个与领域相关的设置容易与会话有效期混淆,但实际上它们仅控制登录流程:
- 登录超时时间——整个认证流程(凭证、多因素验证、所需操作)允许保持开启的状态的时间长度。
- 登录操作超时时间——单个步骤(例如一次性密码输入界面)在未得到响应的情况下允许存在的时长。
这两者都不会延长已通过认证的会话。它们仅在用户仍在登录过程中起作用。
10. 离线会话——针对不同问题的不同解决方案
领域还可以通过scope=offline_access来创建离线会话。即使在正常的单点登录会话结束后,这些会话仍能生成新的访问令牌——其目的是实现“次日及下周保持登录状态”,而非仅适用于单个工作日。
对于日常业务时段的使用(一个工作班次加上缓冲时间),通常无需离线访问功能。调整“会话最大时长”更为简单且有效。只有当确实需要数据在数天或数周内保持持久时,才考虑使用离线功能。
11. 调试检查清单:刷新时返回400错误
请按以下顺序操作:
- 查看响应中的
error和error_description信息。 - 比较登录时间与服务器端及客户端设置的
Session Max值。 - 比较无活动时间与服务器端及客户端设置的
Session Idle值。 - 检查
Clients → → Advanced下的客户端自定义设置。 - 确认是否启用了刷新令牌轮换功能。
- 检查是否有多个请求同时进行刷新操作。
- 确认客户端在每次令牌更新后都会存储最新的刷新令牌,而不仅仅是第一次更新后的那个。
12. 需要牢记的心智模型
分为三层,依赖关系为单向流动:
Access token → used for normal API calls
Refresh token → used to get a new access token
Keycloak session (Idle + Max) → decides whether that refresh is allowed at all
刷新令牌永远无法独立于其对应的会话存在。如果会话已失效——因长时间处于空闲状态、超过最大有效期、被撤销或用户已登出——任何刷新令牌都无法使其恢复。这种拒绝正是系统按设计正常工作的体现。
检查清单
- 将访问令牌的过期问题与会话失效分开处理——在将401错误视为用户登出之前,先尝试进行一次刷新操作。
- 每次操作成功后都要保存已轮换的刷新凭证,而不仅仅是新的访问JWT。
- 牢记两个计时器:用于检测空闲状态的计时器负责触发刷新,而从登录开始计时的计时器则决定最大有效期。
- 将刷新失败视为最终结果——仅尝试一次后即可清除凭证并引导用户重新登录,绝不能循环重试。