fix(auth): avoid keyring write contention on concurrent OAuth refresh
What does this MR do?
Makes glab's OAuth token refresh resilient to concurrent and long-lived
processes when credentials are stored in the OS keyring (the default since
!3528 (merged)). This is mechanism B of the plan for #8476 (keyring write
contention); the cross-process file lock is a planned follow-up.
Why
GitLab OAuth refresh tokens are single-use. When more than one glab
process is alive (for example a long-lived glab mcp serve plus per-tool-call
glab subprocesses, i.e. parallel AI agents), two failure modes appear:
- A long-lived process holds a stale in-memory copy from startup and refreshes off it.
- Two processes race the refresh; one wins and rotates the token, the other
gets
invalid_grantbecause its refresh token was already consumed, forcing the user to runglab auth loginagain (reported as near-daily in #8390).
The earlier config-file-only fix (!3501 (closed)) doesn't reach this now that keyring is
the default: oauth2_refresh_token lives in the keyring, keyring writes bypass
config.Write(), and keyring.Set() is last-writer-wins.
What changed
configTokenSource.Token() (internal/oauth2/token_source.go) now:
- Re-reads the freshest disk + keyring state before acting, so a long-lived
process does not refresh off its startup-time copy. Keyring secrets are
already read live; this also picks up the fresh
oauth2_expiry_datestored in the config file. Falls back to the in-memory config when there is no backing directory (in-memory configs, tests). - Skips the network refresh (and the redundant credential write) when the freshest token is already valid, collapsing the thundering-herd of refreshes near expiry.
- Adopts a concurrently-rotated token on
invalid_grantwith a small bounded retry, instead of erroring, so the losing process self-heals. A genuinely revoked or expired session still surfaces the error rather than looping.
Writes go back through the freshly-read config so a stale in-memory document
does not clobber the file. Adds fileConfig.Dir() so the token source can
locate the backing directory (via an optional interface; the config.Config
interface is unchanged).
How to test
Unit tests in internal/oauth2/token_source_test.go cover: skip-when-valid,
refresh-rotates-and-persists, adopt-on-invalid_grant, error-on-revoked, and
isInvalidGrant. They use the mocked keyring plus an httptest token endpoint.
go test ./internal/oauth2/... -raceTrue cross-process contention (many real subprocesses against a real keyring) is planned with mechanism A (the flock) per the design.
Related
- Part of #8476
- Follow-up to #8390 and the closed !3501 (closed), adapted for keyring-by-default (!3528 (merged))