fix(auth): refresh OAuth tokens with a five-minute grace period

Description

The bug

glab auth credential-helper and glab auth git-credential hand git whatever access token is stored, as long as x/oauth2 still considers it valid, which is until 10 seconds before expiry. A git push that takes longer than that fails mid-transfer with an auth error on a credential glab just vouched for.

A five-minute grace period once existed in the credential helper but never applied: ReuseTokenSourceWithExpiry(nil, …) sets the delta on the source, while Token.Valid reads the delta on the token. !3798 (merged) then removed it, so main has no grace period at all.

Changes

All in internal/oauth2/token_source.go, so every OAuth caller gets it (option 2 from the issue).

  • NewConfigTokenSource now wraps configTokenSource with ReuseTokenSourceWithExpiry(token, src, tokenGracePeriod) instead of ReuseTokenSource(token, src). The stored token is passed as the initial cached value, so the grace is stamped on it and Valid on that token honours it.
  • refreshLocked re-reads the token from disk and re-checks it before refreshing. That check now uses validWithGrace; otherwise the source asks for a refresh and gets the same token back.
  • Write-probe fallback. Widening the check routes tokens through CredentialWriteProbe that were previously just returned. If the probe fails and the token still passes Token.Valid's default 10-second margin, it is returned instead of an error, so read-only configs, locked keyrings and sandboxes keep working as they do today.
  • Access-token-only sessions. A host with is_oauth2: true and no refresh token (the Docker credential helper builds this shape) has nothing to refresh with. Inside the grace window it is returned while it still passes the plain Valid check, instead of failing five minutes earlier than main does.
  • adopt is unchanged. It runs after invalid_grant, when the refresh token is already spent; a usable token the winning process saved must not be rejected for having under five minutes left.
  • Surfaced during implementation: the refresh call now passes only the refresh token. Config.TokenSource wraps the token it is given in its own reuseTokenSource, which runs the plain Valid check before calling the endpoint. So even with the grace on the wrapper and in refreshLocked, a 4-minute token was returned unchanged from the refresh call itself, with no request made. Passing &oauth2.Token{RefreshToken: token.RefreshToken} leaves it nothing to return, so it refreshes.

Every OAuth caller now refreshes five minutes early, not only the git helpers. Same one refresh per token lifetime, shifted earlier.

Resolves #8522 (closed)

How has this been tested?

Each change is pinned by a test, in internal/oauth2/token_source_test.go:

Test Pins
TestNewConfigTokenSource_RefreshesInsideGracePeriod a 4-minute token is refreshed when going through NewConfigTokenSource; the other tests build configTokenSource directly
TestToken_RefreshesInsideGracePeriod refreshLocked refreshes a 4-minute token with exactly one request to the token endpoint
TestToken_ReturnsCurrentTokenWhenProbeFailsInsideGracePeriod unwritable config dir with a 4-minute token: no refresh request, that token is returned
TestToken_ReturnsProbeErrorWhenTokenIsExpired unwritable config dir with an expired token: still an error
TestToken_AdoptsWinnerTokenInsideGracePeriod adopt accepts a winner's token with 4 minutes left
TestValidWithGrace 4 minutes → not valid, 1 hour → valid, no expiry → valid
TestToken_ReturnsAccessTokenOnlyTokenInsideGracePeriod no refresh token, 4-minute token: no request, that token is returned

The check inside Config.TokenSource was reproduced before the fix with a counting httptest server: a 4-minute token produced 0 requests to the token endpoint, and the refresh-token-only call produced 1.

Screenshots (if appropriate):

None; no user-visible output changes.

/assign-reviewer @phikai

Edited by Aditya Parida

Merge request reports

Loading
Loading