diff --git a/lib/token.ts b/lib/token.ts index 846f40231..75d7ea7b9 100644 --- a/lib/token.ts +++ b/lib/token.ts @@ -12,11 +12,58 @@ import { } from "@/db/schema"; import { eq } from "drizzle-orm"; import { User } from "@/types/user"; +import { auth } from "@/lib/auth"; import { getGithubAccount } from "@/lib/github-account"; import { createHttpError } from "@/lib/api-error"; import { collaboratorMatchesUserForRepo } from "@/lib/collaborator-access"; const installationTokenRefreshInFlight = new Map>(); +const userTokenRefreshInFlight = new Map>(); + +// Return a usable GitHub user access token, refreshing it via Better Auth when +// the stored token is at/near expiry. GitHub App user tokens expire after 8 +// hours; without this the stored token simply dies and the user (including repo +// owners) gets a misleading "no permission" error. Better Auth's getAccessToken +// performs the refresh-token exchange and persists the rotated tokens. We +// deduplicate concurrent refreshes per user because GitHub invalidates the old +// refresh token the moment a new one is issued, so racing refreshes would +// clobber each other's tokens. +const getUserAccessToken = async (userId: string): Promise => { + const githubAccount = await getGithubAccount(userId); + if (!githubAccount?.accessToken) return null; + + // No recorded expiry means non-expiring tokens (expiring user tokens disabled + // on the GitHub App); use the stored token as-is. + if (!githubAccount.accessTokenExpiresAt) return githubAccount.accessToken; + + const inFlight = userTokenRefreshInFlight.get(userId); + if (inFlight) return inFlight; + + const refreshJob = (async () => { + try { + const { accessToken } = await auth.api.getAccessToken({ + body: { providerId: "github", userId }, + }); + return accessToken ?? githubAccount.accessToken; + } catch (error) { + // Refresh failed (e.g. the refresh token was revoked). Fall back to the + // stored token so the caller surfaces a real auth error rather than + // masking it as a transient failure. + console.warn("[token] github user token refresh failed", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + return githubAccount.accessToken; + } + })(); + + userTokenRefreshInFlight.set(userId, refreshJob); + try { + return await refreshJob; + } finally { + userTokenRefreshInFlight.delete(userId); + } +}; // Get a token for a user (including collagborators who need to provide an owner/repo scope). const getToken = cache(async ( @@ -25,11 +72,11 @@ const getToken = cache(async ( repo: string, verifyGithubAccess: boolean = false, ) => { - const githubAccount = await getGithubAccount(user.id); - if (githubAccount?.accessToken) { - const hasGithubAccess = await canAccessRepoWithToken(githubAccount.accessToken, owner, repo); + const userAccessToken = await getUserAccessToken(user.id); + if (userAccessToken) { + const hasGithubAccess = await canAccessRepoWithToken(userAccessToken, owner, repo); if (hasGithubAccess) return { - token: githubAccount.accessToken, + token: userAccessToken, source: "user" as const, }; @@ -136,10 +183,10 @@ const getInstallationToken = cache(async (owner: string, repo: string) => { // Get the GitHub user token. const getUserToken = cache(async (userId: string) => { - const githubAccount = await getGithubAccount(userId); - if (!githubAccount?.accessToken) throw new Error(`GitHub token not found for user ${userId}.`); + const accessToken = await getUserAccessToken(userId); + if (!accessToken) throw new Error(`GitHub token not found for user ${userId}.`); - return githubAccount.accessToken; + return accessToken; }); const canAccessRepoWithToken = async (