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).
NewConfigTokenSourcenow wrapsconfigTokenSourcewithReuseTokenSourceWithExpiry(token, src, tokenGracePeriod)instead ofReuseTokenSource(token, src). The stored token is passed as the initial cached value, so the grace is stamped on it andValidon that token honours it.refreshLockedre-reads the token from disk and re-checks it before refreshing. That check now usesvalidWithGrace; otherwise the source asks for a refresh and gets the same token back.- Write-probe fallback. Widening the check routes tokens through
CredentialWriteProbethat were previously just returned. If the probe fails and the token still passesToken.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: trueand 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 plainValidcheck, instead of failing five minutes earlier thanmaindoes. adoptis unchanged. It runs afterinvalid_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.TokenSourcewraps the token it is given in its ownreuseTokenSource, which runs the plainValidcheck before calling the endpoint. So even with the grace on the wrapper and inrefreshLocked, 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.
Related Issues
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