Skip to content

Refresh expired GitHub user access tokens#402

Open
fisherevans wants to merge 1 commit into
hunvreus:developmentfrom
fisherevans:issue/refresh-github-user-token
Open

Refresh expired GitHub user access tokens#402
fisherevans wants to merge 1 commit into
hunvreus:developmentfrom
fisherevans:issue/refresh-github-user-token

Conversation

@fisherevans

Copy link
Copy Markdown

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 the account row and never refreshes it:

// lib/github-account.ts - a plain DB read, no expiry check, no refresh
const getGithubAccount = cache(async (userId) =>
  db.query.accountTable.findFirst({ where: ... providerId "github" }));

Better Auth stores refresh_token and access_token_expires_at on the account (db/schema.ts), but getAccessToken() is never called anywhere in the codebase, so the refresh token is never exercised outside of login.

Once the 8-hour token expires, getToken() in lib/token.ts degrades into a misleading permission error:

const githubAccount = await getGithubAccount(user.id);
if (githubAccount?.accessToken) {
  const hasGithubAccess = await canAccessRepoWithToken(githubAccount.accessToken, owner, repo);
  // expired token -> octokit 401 -> canAccessRepoWithToken swallows it -> false
  ...
}
// repo owner is not in collaboratorTable, so:
if (!permission) throw createHttpError(`You do not have permission to access "${owner}/${repo}".`, 403);

canAccessRepoWithToken catches the 401 and returns false, the owner has no collaboratorTable row 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 checks accessTokenExpiresAt, performs the refresh-token exchange via the provider's refreshAccessToken (the built-in GitHub provider implements it), and persists the rotated tokens back to the account row:

const { accessToken } = await auth.api.getAccessToken({
  body: { providerId: "github", userId },
});

Concurrent refreshes are deduplicated per user with an in-flight map, mirroring the existing installationTokenRefreshInFlight pattern. 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 --noEmit and eslint pass.
  • getAccessToken returns the decrypted token, matching how Pages CMS already passes the stored token directly to Octokit, so token encryption settings are unaffected.
  • I could not run the full refresh path end-to-end against a live GitHub App with expiring tokens. Maintainer verification on 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 development per CONTRIBUTING.md.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant