Skip to content

fix(auth): stop refreshing with a superseded refresh token - #106

Merged
yurenju merged 3 commits into
mainfrom
claude/refresh-token-reuse-fix
Aug 3, 2026
Merged

fix(auth): stop refreshing with a superseded refresh token#106
yurenju merged 3 commits into
mainfrom
claude/refresh-token-reuse-fix

Conversation

@yurenju

@yurenju yurenju commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

為什麼

同事回報 CLI 突然「登入已過期」(WSPC todo tod_01KYXPN96YQRJ2DJAEG2Q4ZYN5,Sentry WSPC-AUTH-WORKER-1)。不是 token 自然過期,是 server 的 refresh token 重用偵測撤銷了整個 family。

Sentry 事件的 gap_ms 是 501465(約 8.4 分鐘),遠超 server 的 5 分鐘 grace window。所以是某個 interceptor 拿著 8 分鐘前就已經被 rotate 掉的 token 去 refresh,不是毫秒級的並發競態(那個 grace window 本來就會吸收,回 grace_tolerated 不撤銷)。

interceptor 把 token 存在 closure 裡當 source of truth,refresh 前從不重讀 config。每個指令各建一個 interceptor,drive watch 每次重連還會再建一個——所以第二個 interceptor 很容易握著別人已經 rotate 掉的 token,幾分鐘後才拿去用。

改了什麼

  • 主修:refresh 前重讀 config,磁碟上較新就採用;採用到的 access token 還能用就完全不 rotate。
  • single-flight:並發請求共用同一次 rotation(server 的 grace 本來就吃得下並發,這條是省浪費不是止血)。
  • stale-401 保護:request 送出後若 token 已被別人換掉,那個 401 是舊的,直接用新 token 重試而不是再 rotate 一次。
  • ConfigStore.write() 改成 write-then-rename:因為 refresh 現在每次都重讀,讀取會頻繁跟別的 process 的寫入重疊,而原本是 truncate-in-place,reader 可能讀到寫到一半的檔案。
  • 錯誤訊息:搭配 server 端新的 error_description,把 reuse 撤銷跟一般過期分開講。

修改全部在 src/handwritten/,沒有碰任何 generated code(interceptor 是透過 createConfig({ fetch }) 注入 generated client 的,npm run generate 不會洗掉)。

需要搭配 sadcoderlabs/wspc 的對應 PR 才會有分辨得出來的錯誤訊息;沒有它也能運作(會 fall back 成原本的格式)。

Test plan

  • npm run typecheck — 乾淨
  • npm test — 62 檔 630 測試通過
  • 新增的 regression test 都驗證過會紅:
    • stale token 與並發雙 refresh 各一個,用一個會 rotate 並偵測重用的假 server 驅動
    • load-sdk-client 的接線測試——實際把 loadPersisted 接線拿掉跑過,確認轉紅(只測 sdk-auth.ts 的話,接線被刪掉測試仍會全綠而 bug 回歸)
    • config 原子寫入的測試在未修前 3/3 次都紅,吐出真的 SyntaxError: Unterminated string in JSON

yurenju added 3 commits August 3, 2026 14:58
Refresh tokens are single-use: the server rotates on every refresh and
revokes the whole family when a superseded one is presented outside its
5 minute grace window (auth.refresh_reuse_revoked). Users saw this as a
sudden "login expired" with no way to tell it from ordinary expiry.

The interceptor treated its in-memory copy as the source of truth and
never re-read the store before refreshing. Every command builds its own
interceptor and `drive watch` rebuilds one per reconnect, so a second
interceptor could hold a token another had already rotated and present
it minutes later -- matching the ~8 minute gap_ms on the reported event.

Re-read the persisted tokens immediately before refreshing and adopt
them when they are newer, skipping the rotation entirely if the adopted
access token is still usable. Also single-flight the refresh so a
Promise.all of expired requests shares one rotation, and skip the
reactive refresh when a concurrent one already replaced the token the
401 came back for.

Covers both with regression tests: a concurrent double refresh and a
stale-copy refresh, each driven through a mock that rotates and revokes
the way the server does.
Re-reading the config before every refresh means reads now happen
constantly while other `wspc` processes are writing, and `write` was
truncating the file in place. A reader could observe a half-written
config and take a JSON parse error out through `loadPersisted`, killing
the command instead of refreshing.

Write a sibling file and rename over the target so a reader always sees
one whole config or the other. Covered by a test that reads in a loop
against concurrent writes: it fails on every run without the rename.

Also share the token-endpoint fake between the two reuse tests and route
the proactive-refresh check through the same freshness helper the adopt
path uses, so the skew arithmetic exists once.
The server now names the reason in `error_description` rather than
sending a constant, so a revoked session no longer has to be reported as
"token refresh failed (invalid_grant)". Map the reasons a user acts on
differently — a reuse revocation means something is holding a stale copy
of the credentials, not that the session lapsed — and keep echoing the
raw code for anything this build doesn't recognise, so an older or newer
server still produces a usable message.
@yurenju
yurenju merged commit 54b6aeb into main Aug 3, 2026
2 checks passed
@yurenju
yurenju deleted the claude/refresh-token-reuse-fix branch August 3, 2026 06:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant