编程 OAuth2 令牌刷新竞态:8 个 worker 同抢一个 refresh token,Salesforce 90 秒后复用就吊销整条连接

2026-11-14 21:31:21

Laravel OAuth refresh token 竞态:6 个进程同时刷新,QuickBooks 会吊销连接

项目地址:dakiquang/laravel-oauth-refresh-lock

什么是 OAuth refresh token 竞态

两个 worker 同时把同一个 refresh token 发给使用轮换机制(refresh token rotation)的 provider。多数轮换型 provider 每次刷新都签发新 refresh token 并让旧的退役。先到的赢,后到的结果由 provider 决定:再发一个有效 token、返回 invalid_grant,或者吊销整个连接。

最严重的后果来自 refresh token 复用检测。RFC 9700 描述了这一机制:当一个已经被轮换过的 refresh token 再次出现时,授权服务器分不清这是攻击者还是合法客户端,于是连当前有效的 refresh token 一起吊销(吊销整个 token family)。你自己起的第二个 worker,从 provider 角度看和攻击者完全一样。

QuickBooks Online

Intuit 每 24 小时更换一次 QBO refresh token 的值,新值签发后旧值立即过期。如果两次刷新用了同一个值,第一个成功、第二个拿到 invalid_grant,并且 Intuit 可能将其视为安全问题而吊销 refresh token。Intuit 的要求就是一次只刷新一个。

实测:QBO sandbox,六个进程在同一时刻用已过期的 token 启动,跑三轮。每轮都是一个进程完成刷新,其余五个使用它存下的 token。没有出现 invalid_grant,最终 token 有效,expires_in 为 3600。"6 个进程,1 次刷新"。没有做无锁对照运行——那有可能直接把 sandbox 连接吊销掉。

Xero

Xero 每次刷新都返回新的 refresh token。如果应用没保存新的,可以在 30 分钟内继续用旧的;超过 30 分钟旧的过期,用户必须重新授权。access token 有效期 30 分钟;旧 refresh token 在轮换后 30 分钟内仍可用;refresh token 最长 60 天。

用这个包实测:六个进程、四轮,每轮一次刷新。无锁对照:六个进程同时用同一个 refresh token 刷新——六个全部成功,Xero 给每一个都发了一个不同的新 refresh token。于是六个 worker 手里握着六个不同的 refresh token,应用只存下最后写入的那个。这些兄弟 token 在 30 分钟后是否仍然有效,没有文档说明,也没有实测。

Salesforce

开启 Refresh Token Rotation 后,每次刷新返回新 refresh token,旧的立刻失效。复用已失效的 refresh token 会吊销当前 refresh token 及其 access token。新的 External Client App 自 2026 年 10 月 22 日起默认开启轮换且无法关闭。

Salesforce 的 token 响应里没有 expires_in;会话超时在 Salesforce 侧配置,应用只能等到 401 才知道 token 已死。为此包里加了 rejected(),用于「401 → 刷新 → 重试」:

$pending = OAuthRefreshLock::for($key);
$accessToken = $pending->accessToken($refresher);
$response = Http::withToken($accessToken)->get($url);
if ($response->status() === 401 && $response->json('0.errorCode') === 'INVALID_SESSION_ID') {
    $accessToken = $pending->rejected($accessToken)->accessToken($refresher);
    $response = Http::withToken($accessToken)->get($url);
}

配置上要注意:Refresh Token Rotation、PKCE 和 30 天 idle refresh token 限制对新应用是开启且锁定的,授权请求必须带 PKCE。另外,全新应用请求 login.salesforce.com 会返回 invalid_client_id,换成 org 的 My Domain 立刻正常。

实测:前三轮每轮六个进程,第四轮三个;每个都先收到 401,其中一个完成刷新,其余使用它的 token,连接保住了。响应里没有 expires_in,每次都返回新的 refresh token。

无锁对照:六个进程用同一 refresh token——五个成功,各自拿到不同的新 refresh token,一个得到 concurrent_request;五个 access token 都能用,旧 refresh token 几秒之后仍然能刷新。然后轮换一次,90 秒后复用旧 token:invalid_grant,当前 refresh token 随之失效——整个连接被吊销。

Microsoft 与 Google

Microsoft 的 refresh token 每次使用都会自我替换,但旧 token 不被吊销。重复刷新只是浪费调用,不会断连接。Google 刷新时不返回新 refresh token,保留手里那个即可,不存在轮换竞态。

Microsoft 的 refresh token 失效时返回 invalid_grant 并带 AADSTS 码,例如 AADSTS70008。Google 返回 invalid_grant。

对照表

Provider旧 refresh token重复刷新的后果测试来源
QuickBooks Online过期invalid_grant,可能吊销 token真实 sandbox
Salesforce(开启轮换)几秒内可用,之后失效秒级内:成功或 concurrent_request;稍晚:吊销整个连接真实 Developer Edition org
Xero30 分钟内可用成功,并再发一个 token真实 Demo Company
Microsoft不吊销浪费一次调用仅文档
Google不轮换浪费一次调用仅文档

invalid_grant 不总是竞态

OAuth 2.0 用 invalid_grant 覆盖所有 refresh token 无效、过期、被吊销或签发给其他客户端的情况(RFC 6749 5.2)。排查时先排除这些:

  • 新的 refresh token 没有保存;
  • 太久没用:QBO 100 天滚动窗口、Xero 60 天、Salesforce 30 天 idle、Microsoft AADSTS70008/700082、Google 6 个月未使用(Testing 应用为 7 天);
  • 访问被撤销:用户断开连接、改密码(AADSTS50173);
  • token 数量过多:Google 每个账号每个 client 超过 100 个 refresh token 会失效最旧的;
  • client 或环境不对:用 sandbox 的 key 打生产;
  • Salesforce 策略:IP restricted or invalid login hours 同样返回 invalid_grant,inactive user 有单独的 inactive_user 码;
  • 轮换之后太晚复用。

竞态还是真吊销:从日志判断

  • 同一连接在几秒内出现两条刷新调用;
  • 发出去的 refresh token 已经被轮换过。日志里记发出和收到的 refresh token 的短哈希,绝不记 token 本身;
  • provider 的提示:Salesforce 返回 concurrent_request;Microsoft 在 error_description 里的 AADSTS 码可以区分 AADSTS70008(不活跃)和 AADSTS50173(被吊销)。

修复:每个 token 一把 Cache::lock,再加 compare-and-swap

$token = Cache::lock("oauth-refresh:{$key}", 30)->block(10, function () use ($key, $provider) {
    $token = Token::where('key', $key)->firstOrFail();          // 锁内重新读取
    if ($token->expires_at?->isAfter(now()->addMinutes(2))) {
        return $token;
    }
    $new = $provider->getAccessToken('refresh_token', ['refresh_token' => $token->refresh_token]);
    $token->update([
        'access_token' => $new->getToken(),
        'refresh_token' => $new->getRefreshToken() ?? $token->refresh_token,
        'expires_at' => $new->getExpires() ? now()->setTimestamp($new->getExpires()) : null,
    ]);
    return $token;
});

token 相关列加上 encrypted cast。这能挡住大部分重复刷新,但还不够:锁是租约,刷新耗时超过 30 秒会让第二个 worker 进来;基于旧读的写入可能覆盖更新的 token;网络抖动和真正的 invalid_grant 看起来一样;没有 expires_in 时(Salesforce)expires_at 为 null,这段代码会每次调用都刷新。

$accessToken = OAuthRefreshLock::for("quickbooks:{$realmId}")->accessToken(new LeagueRefresher($provider));

包的执行顺序:

  1. 快路径:读已存 token,如果有效期超过 skew(默认两分钟,skew_seconds)且没有被报告为 rejected,直接返回,不进锁。
  2. 每个 token key 一把 Cache::lock,任何支持原子锁的 store 都行(Redis、Memcached、DynamoDB、database)。
  3. 锁内重新读:排队的 worker 会拿到第一个 worker 存下的 token。
  4. 带版本号的 compare-and-swap 写入;连接/重连是无条件写,从断开后的时间基准版本开始,这样持有旧读的 worker 不会覆盖更新的连接。
  5. 错误分类:网络错误、HTTP 408/429/5xx 保留 token 并抛 TransientRefreshFailure;只有 invalid_grant 标记为 revoked。

Laravel 里几种锁方案的差别

方案跨服务器锁内重读备注
Redis/Memcached/DynamoDB/database 上的 Cache::lock是(共享 store)是覆盖队列任务、Web 请求和命令
DB 事务里的 lockForUpdate()是是事务在 HTTP 调用期间一直开着(SQLite 忽略该锁)
WithoutOverlapping 中间件—否只对队列任务有效
file cache lock否—仅单服务器
array cache lock否—仅单进程

锁还是条件写:两者都要

条件写保护存储,但两个 worker 仍然都会去调 provider——Xero 会发出六个兄弟 refresh token,Salesforce 会先回 concurrent_request,稍晚复用就吊销。只有锁也不行:租约可能在刷新中途到期。

0s   A 拿到锁,发出 RT1(调用很慢)
30s  租约结束;A 的调用还在路上
31s  B 拿到锁,重读:还是 RT1,发出 RT1 -> provider 看到 RT1 两次
32s  B 收到 invalid_grant,记录该版本被吊销
35s  A 收到 RT2;compare-and-swap 失败,看到 B 已把 A 轮换的那个版本标记为吊销,存下 RT2

刷新的 HTTP 超时要设得明显小于租约时间。WithoutOverlapping 不能替代锁:锁负责「同一时刻只有一个 worker 打 provider」,版本号负责「旧读不能覆盖新写」,缺哪个都还在赌时序。

每个新 refresh token 都要保存

access token、refresh token、过期时间要在一次写入里一起落库。Intuit 明确要求保留每次响应里最新的 refresh token。如果响应里没有 refresh_token(Google),就保留现有的那个。

收到 invalid_grant 之后

  1. 先重读一次存储的 token:可能别的 worker 已经刷新过,你手里的是旧的;
  2. 停止重试:同一个死 token 不可能成功,复用检测还会造成更大伤害。标记为 revoked,后续调用直接抛异常;
  3. 让用户重新连接,监听 TokenRevokedByProvider 事件。

提前刷新、401 刷新,还是定时刷新

provider 给了过期时间,就在过期前不久刷;不给(Salesforce),或者 token 会提前失效,就在 401 时刷。定时命令本身就是又一个 worker,照样需要锁;而对 Salesforce 来说,也没有可用来定时的依据。

测试方式

并发测试在屏障处 fork 出八个 PHP 进程,同时请求同一个即将过期(或被 rejected)的 token,跑三轮。SQLite 上用 database 锁,CI 里同时跑 database 锁和 MySQL 上的 Redis 锁。无锁的对照运行必须出现多于一次刷新,否则测试不算数。

CI 抓到一个真实 bug:Laravel 10 上 database cache 锁在 MySQL 上、多个 worker 抢同一行锁时会死锁(Laravel 11/12 共用这段锁代码,修复同样覆盖)。获取锁时遇到并发错误按「输掉竞态」处理,worker 继续等待;一旦刷新已经开始,就不再重试。

限制

  • 协调的是一个应用内部共享同一个 cache store 的 worker;两个不同应用共用同一条 OAuth 连接时不协调;
  • rejected() 通过新的 access token 值识别「刷新已完成」;
  • Laravel 10/11 上是尽力而为。

安装

composer require dakiquang/laravel-oauth-refresh-lock league/oauth2-client
php artisan vendor:publish --tag=oauth-refresh-lock-migrations

仓库:https://github.com/dakiquang/laravel-oauth-refresh-lock

复制全文 生成海报 OAuth2 接口对接 Laravel Redis 并发

推荐文章

程序员茄子在线接单