From 78a78fa3609a45a3cd131c41541a54a1adf66663 Mon Sep 17 00:00:00 2001 From: esubaalew Date: Fri, 6 Mar 2026 01:35:52 +0300 Subject: [PATCH] fix auth github user fallback and cache consistency Keep cached GitHub profile reads and writes on the same key, and make degraded GitHub user sessions fail safely instead of returning an incomplete object. --- apps/web/src/app/(app)/dashboard/page.tsx | 37 +++- apps/web/src/lib/auth.ts | 220 ++++++++++++++-------- 2 files changed, 172 insertions(+), 85 deletions(-) diff --git a/apps/web/src/app/(app)/dashboard/page.tsx b/apps/web/src/app/(app)/dashboard/page.tsx index 6f0b9ce0..20a7a6a6 100644 --- a/apps/web/src/app/(app)/dashboard/page.tsx +++ b/apps/web/src/app/(app)/dashboard/page.tsx @@ -8,6 +8,7 @@ import { getUserEvents, getTrendingRepos, } from "@/lib/github"; +import type { IssueItem, SearchResult } from "@/lib/github-types"; import { DashboardContent } from "@/components/dashboard/dashboard-content"; import { all } from "better-all"; @@ -15,27 +16,43 @@ export const metadata: Metadata = { title: "Dashboard", }; +const EMPTY_SEARCH_RESULTS: SearchResult = { + items: [], + total_count: 0, +}; + export default async function DashboardPage() { const session = await getServerSession(); if (!session) return redirect("/"); const { githubUser } = session; + const hasGitHubLogin = Boolean(githubUser.login); const { reviewRequests, myOpenPRs, myIssues, repos, notifications, activity, trending } = await all({ reviewRequests: async () => - await searchIssues( - `is:pr is:open review-requested:${githubUser.login}`, - 10, - ), + hasGitHubLogin + ? await searchIssues( + `is:pr is:open review-requested:${githubUser.login}`, + 10, + ) + : EMPTY_SEARCH_RESULTS, myOpenPRs: async () => - await searchIssues(`is:pr is:open author:${githubUser.login}`, 10), + hasGitHubLogin + ? await searchIssues( + `is:pr is:open author:${githubUser.login}`, + 10, + ) + : EMPTY_SEARCH_RESULTS, myIssues: async () => - await searchIssues( - `is:issue is:open assignee:${githubUser.login}`, - 10, - ), + hasGitHubLogin + ? await searchIssues( + `is:issue is:open assignee:${githubUser.login}`, + 10, + ) + : EMPTY_SEARCH_RESULTS, repos: async () => await getUserRepos("updated", 30), notifications: async () => await getNotifications(20), - activity: async () => await getUserEvents(githubUser.login, 20), + activity: async () => + hasGitHubLogin ? await getUserEvents(githubUser.login, 20) : [], trending: async () => await getTrendingRepos(undefined, "weekly", 8), }); diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 0b4d3d6b..aa93b010 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,4 +1,4 @@ -import { betterAuth } from "better-auth"; +import { betterAuth, type BetterAuthPlugin } from "better-auth"; import { prismaAdapter } from "better-auth/adapters/prisma"; import { prisma } from "./db"; import { Octokit } from "@octokit/rest"; @@ -15,16 +15,69 @@ import { getStripeClient, isStripeEnabled } from "./billing/stripe"; import { grantSignupCredits } from "./billing/credit"; import { patSignIn } from "./auth-plugins/pat-signin"; -async function getOctokitUser(token: string) { - const cached = await redis.get>( - `github_user:${token}`, - ); +type GitHubUserProfile = Awaited>["data"]; +type AuthSessionValue = NonNullable>>; +type AuthGitHubUser = GitHubUserProfile & { accessToken: string }; + +function asAuthPlugin(plugin: unknown): BetterAuthPlugin { + return plugin as BetterAuthPlugin; +} + +async function getOctokitUser(token: string): Promise { + const hash = await createHash("SHA-256", "base64").digest(token); + const cacheKey = `github_user:${hash}`; + const cached = await redis.get(cacheKey); if (cached) return cached; const octokit = new Octokit({ auth: token }); const githubUser = await octokit.users.getAuthenticated(); - const hash = await createHash("SHA-256", "base64").digest(token); - waitUntil(redis.set(`github_user:${hash}`, JSON.stringify(githubUser.data), { ex: 3600 })); - return githubUser; + waitUntil(redis.set(cacheKey, JSON.stringify(githubUser.data), { ex: 3600 })); + return githubUser.data; +} + +function buildFallbackGitHubUser(session: AuthSessionValue, accessToken: string): AuthGitHubUser { + return { + id: 0, + login: "", + node_id: "", + avatar_url: session.user.image ?? "", + gravatar_id: "", + url: "", + html_url: "", + followers_url: "", + following_url: "", + gists_url: "", + starred_url: "", + subscriptions_url: "", + organizations_url: "", + repos_url: "", + events_url: "", + received_events_url: "", + type: "User", + site_admin: false, + name: session.user.name ?? "", + company: null, + blog: "", + location: null, + email: session.user.email ?? null, + hireable: null, + bio: null, + twitter_username: null, + notification_email: null, + public_repos: 0, + public_gists: 0, + followers: 0, + following: 0, + created_at: "", + updated_at: "", + private_gists: undefined, + total_private_repos: undefined, + owned_private_repos: undefined, + disk_usage: undefined, + collaborators: undefined, + two_factor_authentication: undefined, + plan: undefined, + accessToken, + }; } export const auth = betterAuth({ @@ -46,36 +99,44 @@ export const auth = betterAuth({ patSignIn(), ...(isStripeEnabled ? [ - stripe({ - stripeClient: getStripeClient(), - stripeWebhookSecret: - process.env.STRIPE_WEBHOOK_SECRET!, - createCustomerOnSignUp: true, - onCustomerCreate: async ({ user }) => { - await grantSignupCredits(user.id); - }, - subscription: { - enabled: true, - plans: [ - { - name: "base", - priceId: process.env - .STRIPE_BASE_PRICE_ID!, - lineItems: [ - { - price: process - .env - .STRIPE_METERED_PRICE_ID!, - }, - ], - }, - ], - }, - }), + asAuthPlugin( + stripe({ + stripeClient: getStripeClient(), + stripeWebhookSecret: + process.env.STRIPE_WEBHOOK_SECRET!, + createCustomerOnSignUp: true, + onCustomerCreate: async ({ user }) => { + await grantSignupCredits(user.id); + }, + subscription: { + enabled: true, + plans: [ + { + name: "base", + priceId: process.env + .STRIPE_BASE_PRICE_ID!, + lineItems: [ + { + price: process + .env + .STRIPE_METERED_PRICE_ID!, + }, + ], + }, + ], + }, + }), + ), ] : []), ...(process.env.VERCEL - ? [oAuthProxy({ productionURL: "https://www.better-hub.com" })] + ? [ + asAuthPlugin( + oAuthProxy({ + productionURL: "https://www.better-hub.com", + }), + ), + ] : []), ], user: { @@ -135,51 +196,60 @@ export const auth = betterAuth({ }, }); -export const getServerSession = cache(async () => { - try { - const { session, account } = await all({ - async session() { - const session = await auth.api.getSession({ - headers: await headers(), - }); - return session; - }, - async account() { - const session = await auth.api.getAccessToken({ - headers: await headers(), - body: { providerId: "github" }, - }); - return session; - }, - }); - if (!session || !account?.accessToken) { - return null; - } - let githubUserData: Record | null = null; +export const getServerSession = cache( + async (): Promise<{ + user: AuthSessionValue["user"]; + session: AuthSessionValue; + githubUser: AuthGitHubUser; + } | null> => { try { - const githubUser = await getOctokitUser(account.accessToken); - githubUserData = githubUser?.data ?? null; - } catch { - // GitHub API may be rate-limited; don't treat as unauthenticated. - } - if (!githubUserData) { + const { session, account } = await all({ + async session() { + const session = await auth.api.getSession({ + headers: await headers(), + }); + return session; + }, + async account() { + const session = await auth.api.getAccessToken({ + headers: await headers(), + body: { providerId: "github" }, + }); + return session; + }, + }); + if (!session || !account?.accessToken) { + return null; + } + let githubUserData: GitHubUserProfile | null = null; + try { + const githubUser = await getOctokitUser(account.accessToken); + githubUserData = githubUser ?? null; + } catch { + // GitHub API may be rate-limited; don't treat as unauthenticated. + } + if (!githubUserData) { + return { + user: session.user, + session, + githubUser: buildFallbackGitHubUser( + session, + account.accessToken, + ), + }; + } return { user: session.user, session, - githubUser: { accessToken: account.accessToken } as any, + githubUser: { + ...githubUserData, + accessToken: account.accessToken, + } satisfies AuthGitHubUser, }; + } catch { + return null; } - return { - user: session.user, - session, - githubUser: { - ...githubUserData, - accessToken: account.accessToken, - }, - }; - } catch { - return null; - } -}); + }, +); export type $Session = NonNullable>>;