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:

  1. A long-lived process holds a stale in-memory copy from startup and refreshes off it.
  2. Two processes race the refresh; one wins and rotates the token, the other gets invalid_grant because its refresh token was already consumed, forcing the user to run glab auth login again (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_date stored 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_grant with 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/... -race

True cross-process contention (many real subprocesses against a real keyring) is planned with mechanism A (the flock) per the design.

Merge request reports

Loading
Loading