Skip to content

fix(gatekeeper-github): refresh expiring tokens instead of breaking after 8 hours - #661

Merged
ndisidore merged 6 commits into
mainfrom
nathan/github-expiring-token-refresh
Oct 5, 2026
Merged

ndisidore merged 6 commits into
mainfrom
nathan/github-expiring-token-refresh

Conversation

@ndisidore

@ndisidore ndisidore commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Fixes #641. OAuth apps registered since August 2026 get GitHub access tokens that expire after 8 hours, and the gatekeeper threw away the refresh token, so every connection on those apps broke 8 hours in. The account now keeps its grant in the kit's CredentialCoordinator and refreshes the token a minute before it expires, one refresh at a time. When GitHub rejects the refresh token itself, the Workshop hears about it once and the account shows Reconnect. A 401 on a token that a refresh replaced mid-request no longer marks the account expired: configurator lookups, observer checks and a review apply's follow-up reads retry once with the new token, and every other call fails with "please retry". Tokens that don't expire, including ones stored by the old code, work as before. New workerd tests run against a fake GitHub and cover refresh, rotation, a dead refresh token, a refresh racing a disconnect or an in-flight read, and the migration; one canceled-request warning in the test run is still unexplained.

… after 8 hours

GitHub enables expiring user tokens by default for OAuth apps registered
since 2026-08-14. The gatekeeper kept only the access token, so every
connection on such an app failed with Bad credentials eight hours in.

UserAccount now stores its grant through the kit's CredentialCoordinator,
with the code exchange and refresh going through the kit's OAuthClient:
an expiring grant is refreshed shortly before it expires, concurrent reads
share one refresh, both rotated tokens are stored together, and GitHub's
bad_refresh_token marks the grant dead and notifies the Workshop once.
Non-expiring grants, including ones stored in the old layout, are served
as before.

A 401 is now adjudicated against the token the request presented: a token
a refresh or reconnect replaced mid-request fails as retryable instead of
marking the account expired, and a rejection of the current token
refreshes past it where the grant can.

Fixes #641
…egacy expiry latch

Review follow-ups:
- revoke() now moves the credential fence before its first await, so a
  refresh landing mid-disconnect is discarded and its tokens revoked
  instead of being stored and then deleted unrevoked.
- Migrating a pre-refresh grant re-arms the expiry latch: that layout
  latched before delivering, so a failed delivery left a dead account
  showing as connected.
- prepareReconnect no longer resets the latch by hand; every credential
  replacement re-arms it.
@github-actions github-actions Bot added the gatekeeper Changes to a gatekeeper integration label Oct 5, 2026
@ask-bonk

ask-bonk Bot commented Oct 5, 2026

Copy link
Copy Markdown

LGTM!

github run

@github-actions

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown

Preview: pr661-nathan-github-c186e1c7

https://pr661-nathan-github-c186e1c7-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

devin-ai-integration[bot]

This comment was marked as resolved.

…n in-flight token refresh

A refresh invalidates the token a request already carries, so a configurator
lookup or hasRepoAccess check overlapping one failed with GitHub's 401 although
the replacement token works. Both now run through withAccountApi, which
adjudicates the rejected token with the account and, for these replay-safe
reads, reruns the call once with the replacement. A rejection the account
confirms as grant death now also notifies the Workshop from these paths.
@ask-bonk

ask-bonk Bot commented Oct 5, 2026

Copy link
Copy Markdown

LGTM!

github run

@Maximo-Guk

Maximo-Guk commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

🤖 ( take it or leave it! )
P2 A token refresh can cause an approved review to be posted twice — github.ts:1838–1840 (packages/gatekeeper-github/src/github.ts#L1838-L1840)
GitHubGatekeeperImpl.#withApi() disables replay for every call, including safe follow-up reads. When applyAction("postReview") successfully creates a review with inline comments, it fetches those comments before marking the action approved.

Comment thread packages/gatekeeper-github/src/github-api.ts
…a token refresh

applyAction(postReview) creates the review, then reads its comments back before
recording the action applied. A refresh that replaced the token one of those
reads carried failed the apply after GitHub had accepted the review, leaving
the action pending, so approving it again posted a second review. Those reads
(and the review-comment sync they can fall back to) now go through #readApi,
which reruns a read once with the replacement token. Mutations stay
non-replayable.
devin-ai-integration[bot]

This comment was marked as resolved.

@ask-bonk

ask-bonk Bot commented Oct 5, 2026

Copy link
Copy Markdown

LGTM!

github run

Under the 2026-09-04 compatibility date (rpc_params_dup_stubs semantics) a
caller keeps ownership of stubs it passes as params, so it must dispose them.
@ask-bonk

ask-bonk Bot commented Oct 5, 2026

Copy link
Copy Markdown

LGTM!

github run

@ndisidore
ndisidore merged commit 9a90fbd into main Oct 5, 2026
15 checks passed
@ndisidore
ndisidore deleted the nathan/github-expiring-token-refresh branch October 5, 2026 15:59

@santoislamsh-eng santoislamsh-eng left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

**or have **

ndisidore added a commit that referenced this pull request Oct 7, 2026
UserAccount hand-rolled what the kit's CredentialCoordinator exists for --
a refresh mutex, a transient-failure cooldown, and grantId, credentialId
and deadGrantId fences -- and the plan deferred adopting it for reasons
that stopped holding once gatekeeper-github adopted it (#661). The grant
now lives in the coordinator, as GitHub's does:

- A refresh redeems the single-use refresh token once however many
  callers wait, and stores the rotated pair before serving either.
  invalid_grant in a 4xx other than 429 (the kit's isInvalidGrant rule)
  is the grant's death, recorded with it and announced once through the
  credential-expiry latch. Every other failure -- network, 429, 5xx
  whatever its body, an Access redirect -- is transient, and #mintFailure
  repeats it to a burst of callers for a minute.
- A refused token is judged against the token the request sent
  (reportTokenRejected). One the account has since replaced failed stale:
  a replayable read reruns once and any other call fails as retryable. A
  refusal of the live token is refreshed past before the grant is called
  dead. Permission 401s from merge and approve are still never reported.
- Connects and reconnects record the connection generation they began
  under and commit through connect(grant, { ifGeneration }), so a
  disconnect or a faster reconnect wins and the loser's tokens are
  revoked. The observer probe's cached user id is fenced to the
  generation.
- A refresh a disconnect overtook has the pair it minted revoked
  (discardMint): the disconnect revoked only the pair it found, which the
  refresh had already rotated out. One a reconnect overtook is dropped
  unrevoked, since GitLab does not document that revoking one refresh
  token spares the rest of the authorization.
- The stub-era keys migrate into the coordinator's record on first read;
  a grant with no recorded scopes is still offered a reconnect.

The connect flow uses the kit's handshake (putInitiation, advanceToOAuth,
claimOAuth) and createPkce; /oauth answers 400 for a malformed state id
and consumes the nonce when GitLab refuses. The refresh request no longer
sends redirect_uri, which RFC 6749 does not define for it. Account reads,
the viewer read and the configurators go through withAccountApi as
replayable reads.

storage-schema.md, the README's troubleshooting, and the plan's credential
decisions, storage layout, OAuth helpers and deferred items describe the
coordinator; the deferred "adopt kit credentials" item is removed.

Tests: the account suite drives the real paths -- refresh and rotation,
one redemption for concurrent callers, death only on invalid_grant and
not in a 429 or 5xx body, a token replaced in flight replayed or failed
as retryable, a refused live token refreshed past, reconnects overtaken
or committed after a refresh, a refresh overtaken by a disconnect
(revoked) or a reconnect (not), and the stub-era migration. The observer
probe does not admit a user reconnected mid-read on the previous user's
membership, and the OAuth relay refuses a malformed state id and consumes
a refused attempt.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gatekeeper Changes to a gatekeeper integration

Projects

None yet

Development

Successfully merging this pull request may close these issues.

gatekeeper-github: connections die after 8 hours on new OAuth apps (expiring user tokens are now GitHub's default; refresh_token is ignored)

3 participants