#7166·woodpecker

Restarted pipelines never refresh the forge OAuth token: clones fail with expired netrc credentials

Author: arcaartemCreated Sep 20, 2026Updated Sep 20, 2026

Component

server

Describe the bug

[Correction: the restart path DOES attempt a refresh. PostPipeline (server/api/pipeline.go) calls refreshUserToken before pipeline.Restart, on v3.14.1 and on main. The real defect is one layer down.]

forge.Refresh (server/forge/refresh.go) refreshes tokens expiring within 30 minutes (tokenMinTTL = 1800). But the Gitea and GitLab Refresh implementations build their oauth2 TokenSource with the token's real Expiry:

go
source := config.TokenSource(oauth2Ctx, &oauth2.Token{
	AccessToken:  user.AccessToken,
	RefreshToken: user.RefreshToken,
	Expiry:       time.Unix(user.Expiry, 0),
})
token, err := source.Token()

oauth2's ReuseTokenSource returns a still-valid token unchanged and only performs the refresh exchange within defaultExpiryDelta = 10 seconds of expiry (golang.org/x/oauth2/token.go). So for any token with more than 10s of life left, Refresh is a no-op that still reports success: the 30-minute window is dead intent, and tokens only actually rotate once fully expired.

Net effect: a pipeline (restart, webhook create, or cron) whose clone netrc is baked while the stored token is in its final minutes still ships a nearly-dead token; any queue delay past expiry fails the clone with could not read Username for 'https://<forge>'.

Observed (Gitea, stock 3600s expiry): a restart created 15:16 with a token expiring 15:21 (5min left: refresh no-op), started 16:06 after queueing, clone got an authenticated 401. The forge's OAuth endpoint log shows exchanges only ever happen after full expiry, never inside the 30-minute window.

Steps to reproduce

  1. Gitea with default 3600s token expiry, private repo, busy agent.
  2. Let the stored token reach its final 5-30 minutes (inside tokenMinTTL, outside oauth2's 10s delta).
  3. Restart a pipeline; it queues past the token's expiry.
  4. Clone fails as above.

Expected behavior

forge.Refresh exchanges the token whenever its caller's 30-minute window says so, matching the Refresher interface docs ("If expiring within 30 minutes, Refresh() is called automatically"). The bitbucketdatacenter forge already does this correctly by passing only the RefreshToken.

System Info

bash
$ curl https://<server>/version
{"source":"https://github.com/woodpecker-ci/woodpecker","version":"3.14.1"}

Forge: Gitea with expiring OAuth access tokens ([oauth2] ACCESS_TOKEN_EXPIRATION_TIME, stock 3600s). Kubernetes backend.

Additional context

A second, deeper gap remains after this fix: the netrc is resolved at pipeline creation but consumed at start, so a queue delay longer than the token TTL still fails clones (see #6621). Resolving netrc at dispatch would close that; this issue tracks only the broken early refresh.

Workaround: raise the forge token lifetime past your worst-case queue plus restart delay.

Source: woodpecker-ci/woodpecker