Refresh expired GitHub user access tokens#402
Open
fisherevans wants to merge 1 commit into
Open
Conversation
GitHub App user access tokens expire after 8 hours. The stored token was
read straight from the account row and never refreshed, so once it expired
every getToken() call failed canAccessRepoWithToken() and fell through to a
403 "You do not have permission to access {owner}/{repo}" - even for the repo
owner. Re-login was the only recovery because nothing exercised the stored
refresh token.
Route user-token reads through Better Auth's getAccessToken, which performs
the refresh-token exchange and persists the rotated tokens when the current
one is at/near expiry. Concurrent refreshes are deduplicated per user
(mirroring the existing installation-token in-flight map) because GitHub
invalidates the old refresh token the instant a new one is issued, so racing
refreshes would clobber each other.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
GitHub App user access tokens expire after 8 hours (when "expiring user tokens" is enabled on the App, which it is on
app.pagescms.org). Pages CMS reads the stored token straight from theaccountrow and never refreshes it:Better Auth stores
refresh_tokenandaccess_token_expires_aton the account (db/schema.ts), butgetAccessToken()is never called anywhere in the codebase, so the refresh token is never exercised outside of login.Once the 8-hour token expires,
getToken()inlib/token.tsdegrades into a misleading permission error:canAccessRepoWithTokencatches the 401 and returnsfalse, the owner has nocollaboratorTablerow to fall back on, and they get a 403 rendered as "you do not have permission to access this repository" (app/(main)/[owner]/[repo]/layout.tsx). The Better Auth session is still valid, so the app considers the user logged in and never re-runs the OAuth exchange that would mint a fresh token. Because the token lives in one account row shared across devices, the failure shows up on every device at once, and clearing it requires the session to expire or the user to manually revoke/re-auth.Fix
Route user-token reads through Better Auth's
getAccessToken, which checksaccessTokenExpiresAt, performs the refresh-token exchange via the provider'srefreshAccessToken(the built-in GitHub provider implements it), and persists the rotated tokens back to the account row:Concurrent refreshes are deduplicated per user with an in-flight map, mirroring the existing
installationTokenRefreshInFlightpattern. This matters because GitHub invalidates the old refresh token the instant a new one is issued - when an idle tab wakes and fires several API calls at once, racing refreshes would otherwise clobber each other's tokens and can corrupt the stored refresh token, which is the likely cause of the longer "can't log in on any device" lockouts (a broken refresh token plus retry loops tripping GitHub's secondary rate limits).Accounts with no recorded expiry (non-expiring tokens, i.e. expiring user tokens disabled on the App) short-circuit and use the stored token as-is, so this is a no-op for those installs.
Notes / testing
tsc --noEmitandeslintpass.getAccessTokenreturns the decrypted token, matching how Pages CMS already passes the stored token directly to Octokit, so token encryption settings are unaffected.dev.pagescms.org(let a user token cross its 8-hour expiry, confirm a subsequent edit refreshes instead of 403-ing) would be worthwhile before merge.Targets
developmentperCONTRIBUTING.md.