fix(auth): prevent stale config writes from clobbering rotated OAuth tokens
Heads-up: this fix is AI-generated. An AI coding agent (opencode + GitLab Duo, duo-chat-fable-5) diagnosed the bug, wrote the fix, and wrote the tests. I reviewed the diff and ran the patched binary on my machine for 3 days without a recurrence. If you'd rather take a different approach, feel free to close this — the diagnosis in #8390 stands on its own either way.
Description
GitLab OAuth refresh tokens are single-use. When more than one glab process is alive (for example, a long-lived glab mcp serve plus a regular CLI invocation), a process holding a stale in-memory copy of config.yml could write its whole document back to disk and roll back the oauth2_refresh_token another process had just rotated. The rolled-back token was already consumed, so the next refresh failed with invalid_grant and the user had to run glab auth login again — daily, for heavy MCP users. Full diagnosis with captured timeline in #8390.
Three changes close the race:
fileConfig.Write()no longer clobbers rotated credentials. Before serializing, it re-reads the on-disk config and adopts the on-disk OAuth credentials (token,oauth2_refresh_token,oauth2_expiry_date) for any host whose on-diskoauth2_expiry_dateis newer than the in-memory one. An unrelated write, such as the update checker bumpinglast_update_check_timestamp, can no longer roll back a rotated token.Write()is now a locked read-modify-write. A cross-process lock file (config.yml.lock,O_CREATE|O_EXCL) serializes concurrent writers.renameioalready made the write itself atomic, but not the read-merge-write cycle. Design choices, so they're visible for review:- The lock is only held across file I/O (typically <50 ms), never across network calls.
- Locks older than 2 s are treated as left behind by a crashed process and broken.
- If the lock can't be acquired within 3 s, the write proceeds unlocked rather than failing the command; a notice is printed to stderr after 1 s of waiting.
- Token refresh re-reads the config from disk.
configTokenSource.Token()prefers the on-disk token when it's fresher than the in-memory one, and skips the refresh entirely if that token is still valid — long-lived processes load the config once at startup and would otherwise present an already-consumed refresh token. If a refresh still fails withinvalid_grant(a concurrent process won the race mid-flight), it re-reads the config from disk once and uses the rotated credentials instead of surfacing an error.
Related Issues
Resolves #8390
How has this been tested?
- Unit tests for all three mechanisms:
internal/config/lock_test.go: acquire/release, waiting on a held lock, breaking a stale lock.internal/config/persist_test.go: fresher on-disk credentials survive a stale write; fresher in-memory credentials win over an older disk copy; first write with no disk file.internal/oauth2/token_source_test.go: valid token is reused without refresh; fresher on-disk token is preferred; expired token refresh persists rotated credentials;invalid_grantrecovery re-reads from disk; non-invalid_granterrors surface without retry.
make lint,make test-changed(3392 tests) pass.- 3-day live soak (macOS, gitlab.com, OAuth): patched binary used as both the CLI and a long-lived
glab mcp serveunder an AI coding agent — the exact workload that reproduced the bug daily. A file watcher onconfig.ymland a token prober against/oauth/token/inforan throughout. Multiple 2-hour token rotations, including a stale-process write pattern identical to the one that destroyed the token during diagnosis, completed without a single clobber orinvalid_grant.