Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions lib/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, Promise<string>>();
const userTokenRefreshInFlight = new Map<string, Promise<string | null>>();

// 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<string | null> => {
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 (
Expand All @@ -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,
};

Expand Down Expand Up @@ -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 (
Expand Down