Skip to content

[wrangler] fix: re-read refresh_token from disk to avoid 401 from sibling-process rotation#13910

Merged
petebacondarwin merged 2 commits into
cloudflare:mainfrom
timoconnellaus:fix/oauth-refresh-stale-token
May 14, 2026
Merged

[wrangler] fix: re-read refresh_token from disk to avoid 401 from sibling-process rotation#13910
petebacondarwin merged 2 commits into
cloudflare:mainfrom
timoconnellaus:fix/oauth-refresh-stale-token

Conversation

@timoconnellaus

@timoconnellaus timoconnellaus commented May 13, 2026

Copy link
Copy Markdown
Contributor

Summary

refreshToken in packages/wrangler/src/user/user.ts uses the refresh token cached in module-level localState, which is populated once at process startup and never re-read. OAuth refresh tokens are single-use: when a sibling wrangler process (another repo, another shell, or a parallel script) refreshes first, it rotates the token server-side and writes the new value to the shared global config file (~/Library/Preferences/.wrangler/config/default.toml on macOS, equivalents elsewhere). The long-lived process — typically wrangler dev — then sends its stale in-memory token on the next refresh and gets 401 Unauthorized from https://dash.cloudflare.com/oauth2/token, falling through to a fresh interactive OAuth login.

This matches every Failed to fetch auth token: 401 Unauthorized report I can find — and reproduces deterministically in a unit test.

Evidence from a real failure

Two wrangler processes on the same machine, started 60 minutes apart in different repos, both sent the same refresh_token=07RHxMHc…:

01:13:36 UTC  wrangler dev (repo A)         reads default.toml → RT_A
                                            holds RT_A in module-level localState
02:13:28 UTC  wrangler deploy (repo B)      reads default.toml → also RT_A
                                            refreshes successfully, CF rotates → RT_B
                                            writes RT_B to default.toml
02:53:41 UTC  wrangler dev (repo A)         access token expired, refresh fires
                                            sends stale RT_A from localState
                                            ← 401 Unauthorized
                                            → empty catch{} swallows the error
                                            → falls through to OAuth login prompt

Fix

Two changes inside refreshToken() (packages/wrangler/src/user/user.ts):

  1. Call reinitialiseAuthTokens() before exchanging. Re-reads default.toml so the refresh request uses whatever value is currently on disk, not whatever was on disk when this process started. Cheap, idiomatic, eliminates the race for the realistic case where the sibling write lands seconds-to-hours before this refresh.
  2. Replace the empty catch {} with a logger.debug call. The current swallowed error is exactly why this bug was hard to diagnose — WRANGLER_LOG=debug will now surface the failure mode.

A proper flock-style fix on default.toml is nicer but materially harder; this gets 99%+ of cases.

Test plan

  • Added unit test in packages/wrangler/src/__tests__/user.test.ts (should re-read refresh_token from disk before refreshing in case a sibling process rotated it) — fails on main, passes with this change
  • Full pnpm test -F wrangler src/__tests__/user.test.ts — 51/51 pass
  • pnpm test -F wrangler src/__tests__/deploy/core.test.ts (other refresh-touching tests) — 48/48 pass
  • pnpm check:type -F wrangler clean
  • oxlint --deny-warnings --type-aware clean
  • oxfmt --check clean

🤖 Generated with Claude Code

  • Tests
    • Tests included/updated
    • Automated tests not possible - manual testing has been completed as follows:
    • Additional testing not necessary because:
  • Public documentation
    • Cloudflare docs PR(s):
    • Documentation not necessary because: bug fix

@changeset-bot

changeset-bot Bot commented May 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 2011b3c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
wrangler Patch
@cloudflare/vite-plugin Patch
@cloudflare/vitest-pool-workers Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@workers-devprod workers-devprod requested review from a team and dario-piotrowicz and removed request for a team May 13, 2026 13:11
@workers-devprod

workers-devprod commented May 13, 2026

Copy link
Copy Markdown
Contributor

Codeowners approval required for this PR:

  • ✅ @cloudflare/wrangler
Show detailed file reviewers

Comment thread packages/wrangler/src/__tests__/user.test.ts Outdated
@pkg-pr-new

pkg-pr-new Bot commented May 13, 2026

Copy link
Copy Markdown
create-cloudflare

npm i https://pkg.pr.new/create-cloudflare@13910

@cloudflare/kv-asset-handler

npm i https://pkg.pr.new/@cloudflare/kv-asset-handler@13910

miniflare

npm i https://pkg.pr.new/miniflare@13910

@cloudflare/pages-shared

npm i https://pkg.pr.new/@cloudflare/pages-shared@13910

@cloudflare/unenv-preset

npm i https://pkg.pr.new/@cloudflare/unenv-preset@13910

@cloudflare/vite-plugin

npm i https://pkg.pr.new/@cloudflare/vite-plugin@13910

@cloudflare/vitest-pool-workers

npm i https://pkg.pr.new/@cloudflare/vitest-pool-workers@13910

@cloudflare/workers-editor-shared

npm i https://pkg.pr.new/@cloudflare/workers-editor-shared@13910

@cloudflare/workers-utils

npm i https://pkg.pr.new/@cloudflare/workers-utils@13910

wrangler

npm i https://pkg.pr.new/wrangler@13910

commit: 2011b3c

@workers-devprod workers-devprod left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codeowners reviews satisfied

@github-project-automation github-project-automation Bot moved this from Untriaged to Approved in workers-sdk May 13, 2026
@dario-piotrowicz

Copy link
Copy Markdown
Member

mh.... something off happened here, would you like some help rebasing this PR @timoconnellaus? 🙂

timoconnellaus and others added 2 commits May 14, 2026 13:32
…ling rotation

OAuth refresh tokens are single-use. When a sibling wrangler process refreshes,
it rotates the token server-side and writes the new value to the shared global
config file. A long-lived process (typically `wrangler dev`) that loaded its
refresh_token at startup then sends the stale in-memory value on its next
refresh and gets `401 Unauthorized` from `/oauth2/token`, falling through to
interactive login and timing out unattended.

`refreshToken` now calls `reinitialiseAuthTokens()` before exchanging so it
picks up whatever the latest write put on disk. The previously empty
`catch {}` in the same function also now logs the underlying error at debug
level so future refresh failures are diagnosable.

Repro test added to `user.test.ts` (fails without this change, passes with it).
@petebacondarwin petebacondarwin force-pushed the fix/oauth-refresh-stale-token branch from 324414e to 2011b3c Compare May 14, 2026 12:36
@petebacondarwin petebacondarwin merged commit bf688f7 into cloudflare:main May 14, 2026
59 of 62 checks passed
@github-project-automation github-project-automation Bot moved this from Approved to Done in workers-sdk May 14, 2026
petebacondarwin added a commit that referenced this pull request May 19, 2026
…riority

Wrangler previously read its OAuth state from the user auth config file
eagerly at module-import time, before `.env` files have been loaded. The
in-memory state therefore always held the OAuth tokens even when the
user only wanted to authenticate via `CLOUDFLARE_API_TOKEN`. If that
stored OAuth token happened to be expired, Wrangler would try to refresh
it (and fail), aborting the command with 'Failed to fetch auth token:
400 Bad Request' / 'Not logged in.' — even though a valid API token was
in scope.

Read the auth config file on demand from every site that needs it.
`isAccessTokenExpired()` short-circuits to `false` when env-based
credentials are present, so the OAuth refresh endpoint is no longer
called when env auth is going to be used. Sibling-process refresh-token
rotation is also handled naturally because every check reads the
current file contents.

Drive-by cleanups:
- Drop the dead in-memory mutations in `exchangeRefreshTokenForAccessToken`
  and `exchangeAuthCodeForAccessToken` — they were redundant with the
  `writeAuthConfigFile` call that immediately follows.
- Drop the dead inner `!localState.accessToken && localState.refreshToken`
  branch in `logout()` plus its accidental double-revoke.
- Remove the now-redundant `reinitialiseAuthTokens()` call from the top
  of `refreshToken()` (introduced in #13910 for sibling rotation; now
  naturally subsumed).
- Drop the in-process selected-account cache in favour of the existing
  file-based `wrangler-account.json` cache, which is naturally per-cwd.
- Remove the exported `reinitialiseAuthTokens()` function — there is no
  module-level cache left to invalidate.

Fixes #13744.
penalosa pushed a commit that referenced this pull request May 28, 2026
…ling-process rotation (#13910)

Co-authored-by: Pete Bacon Darwin <pbacondarwin@cloudflare.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants