首页 / 文章

Keycloak刷新令牌与会话超时机制详解

为什么Keycloak的刷新操作会返回400错误,空闲会话超时与最大会话超时有何区别,以及如何在JavaScript中利用防轮换存储实现刷新令牌的单次使用。

发布者:REACTAPP.TOP阅读约 7 分钟1636 词

来源: 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才会终止该会话。成功的刷新操作被视为一次活动,会重置此计时器。
  • SSO会话最长时间——从首次登录开始计算的总时长上限。刷新操作不会重置该时间,任何方式都无法改变它。
  • 简而言之:

    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"
    }
    

    该响应数据可能对应多种不同情况:

    • 刷新凭证已自动过期
  • 空闲策略已关闭 SSO 会话
  • 登录后的最大有效时间已到达
  • 最终用户或操作员手动登出了会话
  • 管理员操作撤销了该会话
  • 轮换机制使客户端仍保留的旧刷新值失效
  • 并发续期请求发生竞争,其中一个请求重新使用了另一个请求已更换的凭证
  • 这些情况均不属于“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错误

    请按以下顺序操作:

    1. 查看响应中的error和error_description信息。
    2. 比较登录时间与服务器端及客户端设置的Session Max值。
    3. 比较无活动时间与服务器端及客户端设置的Session Idle值。
    4. 检查Clients → → Advanced下的客户端自定义设置。
    5. 确认是否启用了刷新令牌轮换功能。
    6. 检查是否有多个请求同时进行刷新操作。
    7. 确认客户端在每次令牌更新后都会存储最新的刷新令牌,而不仅仅是第一次更新后的那个。
  • 检查会话是否是由用户或管理员手动结束的。
  • 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。
    • 牢记两个计时器:用于检测空闲状态的计时器负责触发刷新,而从登录开始计时的计时器则决定最大有效期。
    • 将刷新失败视为最终结果——仅尝试一次后即可清除凭证并引导用户重新登录,绝不能循环重试。
  • 将多个并发的401错误合并为一个共享的续期请求,而非发起五个独立的调用。
  • 将登录超时时间和登录操作超时时间归类到登录流程相关项中;它们不属于空闲状态或最大值相关项。
  • 为需要多日保持登录状态的场景预留离线会话,而非用于普通工作时段的单一登录功能。
  • 首先查看令牌端点的错误信息;400错误通常表示特定的策略拒绝,而非系统崩溃。