Skip to content

fix(auth): serialize token refresh to prevent self-inflicted logout#991

Merged
m-abdelwahab merged 1 commit into
masterfrom
fix/oauth-refresh-token-race
Jun 29, 2026
Merged

fix(auth): serialize token refresh to prevent self-inflicted logout#991
m-abdelwahab merged 1 commit into
masterfrom
fix/oauth-refresh-token-race

Conversation

@m-abdelwahab

@m-abdelwahab m-abdelwahab commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Problem

The Railway CLI logs users out frequently, forcing repeated railway login.

Root cause is a concurrency race in credential refresh:

  • The CLI persists OAuth credentials to ~/.railway/config.json with a whole-file, last-writer-wins write and no lock.
  • The server rotates the refresh token on every refresh and reuse-detects it: presenting an already-consumed (rotated) refresh token revokes the entire grant.
  • So two concurrent CLI invocations past the 1h access-token expiry (e.g. a long-running railway mcp process alongside an interactive command, or a shell-prompt integration) both read the same refresh token and both POST grant_type=refresh_token. The second presents a now-consumed token → grant revoked → hard logout. Last-writer-wins can also overwrite a freshly-rotated token with a stale one.

Confirmed in production: ~128k/7d refresh token not found failures on the server's token endpoint, 99.6% from the CLI's OAuth client.

Change

  • Serialize refresh behind an exclusive file lock (~/.railway/.config.lock, a dedicated lockfile — never config.json itself). After acquiring the lock, re-read the config and re-check expiry: only the lock winner refreshes; the others pick up its freshly-rotated token.
    • RAII guard releases the lock on every path (including errors / early returns).
    • 10s acquisition timeout with a graceful unlocked fallback (warn + refresh anyway) so a stale lock can never wedge the CLI.
    • Fast path (token still valid) is unchanged and takes no lock.
  • Preserve the existing refresh token when a refresh response omits one, instead of nulling it.

Companion

This is the client-side half. The complementary server-side change (stop rotating public-client refresh tokens on every refresh) is in railwayapp/mono: railwayapp/mono#32205.

Testing

  • cargo check clean; cargo fmt --check clean.
  • cargo test --bin railway config:: (24 passed) and client:: (4 passed).
  • clippy failures in this tree are pre-existing (graphql_client-generated code) and unrelated — confirmed by re-running on a clean tree.

Review notes

  • fs2 advisory locks coordinate correctly between cooperating CLI processes (the race in scope). On network filesystems advisory locking can be unreliable, but ~/.railway is normally local; the unlocked fallback degrades to today's behavior rather than wedging.
  • Lock timeout is 10s (CONFIG_LOCK_TIMEOUT) — comfortably covers a refresh round-trip; bump if refreshes can ever exceed that.

🤖 Generated with Claude Code

The credential refresh is a read-modify-write of ~/.railway/config.json
with no locking, while the server rotates and reuse-detects refresh tokens
on every refresh. Two concurrent CLI invocations past access-token expiry
(e.g. a running `railway mcp` plus an interactive command) both present
the same refresh token; the second presents an already-consumed token, so
the server revokes the entire grant -- a hard logout requiring
`railway login`.

Serialize the refresh behind an exclusive advisory file lock on a
dedicated lockfile (~/.railway/.config.lock). After acquiring the lock we
re-read the config and re-check expiry, so only the lock winner performs
the refresh and the others pick up its freshly-rotated token. A 10s
timeout with a graceful unlocked fallback ensures a stale lock can never
wedge the CLI.

Also preserve the stored refresh token when a refresh response omits one,
rather than nulling it (which would force a re-login on the next refresh).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@m-abdelwahab m-abdelwahab added the release/patch Author patch release label Jun 29, 2026
@m-abdelwahab m-abdelwahab merged commit 831c697 into master Jun 29, 2026
6 of 7 checks passed
@m-abdelwahab m-abdelwahab deleted the fix/oauth-refresh-token-race branch June 29, 2026 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release/patch Author patch release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant