From 1f9576bf0fc783f1dfc03ab905583195c17cda87 Mon Sep 17 00:00:00 2001 From: Vexx Date: Sat, 23 May 2026 15:15:21 +0530 Subject: [PATCH 1/6] fix(auth): implement server-side GitHub access token retrieval and validation --- src/app/api/metrics/issues/route.ts | 12 +++++------- src/app/api/metrics/languages/route.ts | 19 +++++++++++++++---- src/app/api/metrics/pinned-repos/route.ts | 9 ++++++--- src/app/api/metrics/pr-breakdown/route.ts | 8 ++++++-- src/app/api/metrics/weekly-summary/route.ts | 18 +++++++++++------- src/lib/auth.ts | 5 +++-- src/lib/server-github-token.ts | 13 +++++++++++++ src/lib/validate-github-username.ts | 3 ++- src/types/next-auth.d.ts | 3 ++- 9 files changed, 63 insertions(+), 27 deletions(-) create mode 100644 src/lib/server-github-token.ts diff --git a/src/app/api/metrics/issues/route.ts b/src/app/api/metrics/issues/route.ts index cc6441a6..9ffd5d7f 100644 --- a/src/app/api/metrics/issues/route.ts +++ b/src/app/api/metrics/issues/route.ts @@ -8,28 +8,26 @@ import { metricsCacheKey, withMetricsCache, } from "@/lib/metrics-cache"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } - // 1. Check if the user is forcing a refresh const bypass = isMetricsCacheBypassed(req); - - // 2. Generate a unique cache key for this user's issues const key = metricsCacheKey(session.githubId ?? session.githubLogin, "issues"); try { - // 3. Wrap the GitHub fetch in our bulletproof cache! const metrics = await withMetricsCache( { bypass, key, ttlSeconds: METRICS_CACHE_TTL_SECONDS.issues }, - () => fetchIssuesMetrics(session.accessToken!) + () => fetchIssuesMetrics(accessToken) ); - + return Response.json(metrics); } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); diff --git a/src/app/api/metrics/languages/route.ts b/src/app/api/metrics/languages/route.ts index 53b6e3b0..beb82970 100644 --- a/src/app/api/metrics/languages/route.ts +++ b/src/app/api/metrics/languages/route.ts @@ -2,23 +2,34 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; +interface RepoItem { + repository: { full_name: string }; +} + export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 }); + const accessToken = await getGitHubAccessToken(req); + if (!session?.githubLogin || !accessToken) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } const bypass = isMetricsCacheBypassed(req); const key = metricsCacheKey(session.githubId ?? session.githubLogin, "languages" as any); try { const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { - const headers = { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }; + const headers = { + Authorization: `Bearer ${accessToken}`, + Accept: "application/vnd.github+json", + }; const since = new Date(); since.setDate(since.getDate() - 90); - + const searchRes = await fetch( `${GITHUB_API}/search/commits?q=author:${session.githubLogin}+author-date:>=${since.toISOString().slice(0, 10)}&per_page=100&sort=author-date&order=desc`, { headers, cache: "no-store" } @@ -26,7 +37,7 @@ export async function GET(req: NextRequest) { if (!searchRes.ok) throw new Error("API Error"); const raw = await searchRes.json(); - const repoNames = Array.from(new Set(raw.items.map((i: any) => i.repository.full_name))); + const repoNames = Array.from(new Set(raw.items.map((i: RepoItem) => i.repository.full_name))); const langTotals: Record = {}; await Promise.all( diff --git a/src/app/api/metrics/pinned-repos/route.ts b/src/app/api/metrics/pinned-repos/route.ts index a7de84ef..9e323b1e 100644 --- a/src/app/api/metrics/pinned-repos/route.ts +++ b/src/app/api/metrics/pinned-repos/route.ts @@ -1,5 +1,7 @@ import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -34,9 +36,10 @@ const PINNED_REPOS_QUERY = ` } `; -export async function GET() { +export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) { + const accessToken = await getGitHubAccessToken(req); + if (!session?.githubId || !accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -45,7 +48,7 @@ export async function GET() { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ query: PINNED_REPOS_QUERY }), cache: "no-store", diff --git a/src/app/api/metrics/pr-breakdown/route.ts b/src/app/api/metrics/pr-breakdown/route.ts index 6c919b05..985c7748 100644 --- a/src/app/api/metrics/pr-breakdown/route.ts +++ b/src/app/api/metrics/pr-breakdown/route.ts @@ -2,6 +2,7 @@ import { getServerSession } from "next-auth"; import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; @@ -10,7 +11,10 @@ interface PRItem { state: string; draft?: boolean; pull_request?: { merged_at: s export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) return Response.json({ error: "Unauthorized" }, { status: 401 }); + const accessToken = await getGitHubAccessToken(req); + if (!session?.githubId || !accessToken) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } const bypass = isMetricsCacheBypassed(req); const key = metricsCacheKey(session.githubId ?? "unknown", "pr-breakdown" as any); @@ -18,7 +22,7 @@ export async function GET(req: NextRequest) { try { const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { const res = await fetch(`${GITHUB_API}/search/issues?q=type:pr+author:@me&per_page=100`, { - headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, + headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store", }); if (!res.ok) throw new Error("API Error"); diff --git a/src/app/api/metrics/weekly-summary/route.ts b/src/app/api/metrics/weekly-summary/route.ts index ad82a38b..d91a3936 100644 --- a/src/app/api/metrics/weekly-summary/route.ts +++ b/src/app/api/metrics/weekly-summary/route.ts @@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth"; import { GITHUB_API } from "@/lib/github"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; import { dateDiffDays, toDateStr } from "@/lib/dateUtils"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -26,7 +27,7 @@ function calculateCurrentStreak(activeDates: Set): number { for (let i = 1; i < commitDays.length; i++) { const diff = dateDiffDays(commitDays[i - 1], commitDays[i]); - if (diff === 1) { currentRun++; } + if (diff === 1) { currentRun++; } else { runs.push({ end: commitDays[i - 1], length: currentRun }); currentRun = 1; } } runs.push({ end: commitDays[commitDays.length - 1], length: currentRun }); @@ -65,7 +66,10 @@ async function fetchActiveDates(githubLogin: string, token: string): Promise=${fourteenDaysAgoStr}&per_page=100`, - { headers: { Authorization: `Bearer ${session.accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store" } + { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json" }, cache: "no-store" } ); -const commitsData = (await commitsRes.json()) as { + const commitsData = (await commitsRes.json()) as { items: Array<{ commit: { author: { date: string } }; repository: { full_name: string }; @@ -122,7 +126,7 @@ const commitsData = (await commitsRes.json()) as { `${GITHUB_API}/search/issues?q=type:pr+author:@me+created:>=${fourteenDaysAgoStr}&per_page=100`, { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json", }, cache: "no-store", @@ -162,7 +166,7 @@ const commitsData = (await commitsRes.json()) as { } } - const streakDates = await fetchActiveDates(session.githubLogin!, session.accessToken!); + const streakDates = await fetchActiveDates(session.githubLogin!, accessToken); const commitDelta = commitsThisWeek - commitsPrevWeek; return { @@ -188,4 +192,4 @@ const commitsData = (await commitsRes.json()) as { } catch { return Response.json({ error: "GitHub API error" }, { status: 502 }); } -} \ No newline at end of file +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 74cf2da7..3d954e0d 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -52,8 +52,9 @@ export const authOptions: NextAuthOptions = { return token; }, async session({ session, token }) { - if (typeof token.accessToken === "string") - session.accessToken = token.accessToken; + // accessToken is intentionally NOT copied here — it must never be sent + // to the browser via /api/auth/session. Retrieve it server-side only + // using getGitHubAccessToken(req) from @/lib/server-github-token. if (typeof token.githubId === "string") session.githubId = token.githubId; if (typeof token.githubLogin === "string") diff --git a/src/lib/server-github-token.ts b/src/lib/server-github-token.ts new file mode 100644 index 00000000..3bd9b23a --- /dev/null +++ b/src/lib/server-github-token.ts @@ -0,0 +1,13 @@ +import { getToken } from "next-auth/jwt"; +import type { NextRequest } from "next/server"; + +/** + * Retrieves the GitHub OAuth access token from the server-side JWT (httpOnly cookie). + * The token is never exposed to the browser — call this only in server-side API routes. + */ +export async function getGitHubAccessToken( + req: NextRequest +): Promise { + const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET }); + return typeof token?.accessToken === "string" ? token.accessToken : null; +} diff --git a/src/lib/validate-github-username.ts b/src/lib/validate-github-username.ts index 495da286..2c828f20 100644 --- a/src/lib/validate-github-username.ts +++ b/src/lib/validate-github-username.ts @@ -1,6 +1,7 @@ const GITHUB_USERNAME_RE = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i; -export function isValidGitHubUsername(username: string): boolean { +export function isValidGitHubUsername(username: unknown): username is string { + if (typeof username !== "string") return false; return GITHUB_USERNAME_RE.test(username); } diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 6fba03ba..452aa88a 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -2,7 +2,8 @@ import type { DefaultSession } from "next-auth"; declare module "next-auth" { interface Session { - accessToken?: string; + // accessToken is deliberately absent — it lives only in the server-side + // JWT (httpOnly cookie). Use getGitHubAccessToken(req) in API routes. githubId?: string; githubLogin?: string; gitlabToken?: string; From a824f4161c4208db0e8e888d397dff85bb74994f Mon Sep 17 00:00:00 2001 From: Vexx Date: Sat, 23 May 2026 20:05:04 +0530 Subject: [PATCH 2/6] fix(auth): remove session.accessToken from all remaining API routes Migrate all metrics API routes to retrieve the GitHub OAuth token server-side via getGitHubAccessToken(req) instead of reading the now-removed session.accessToken field. Covers contributions, prs, pr-review-time, streak, repos, repo-health, ci, and compare routes. --- src/app/api/metrics/ci/route.ts | 16 ++++++----- src/app/api/metrics/compare/route.ts | 30 ++++++++++----------- src/app/api/metrics/contributions/route.ts | 12 +++++---- src/app/api/metrics/pr-review-time/route.ts | 14 +++++----- src/app/api/metrics/prs/route.ts | 14 +++++----- src/app/api/metrics/repo-health/route.ts | 12 ++++++--- src/app/api/metrics/repos/route.ts | 10 ++++--- src/app/api/metrics/streak/route.ts | 10 ++++--- 8 files changed, 68 insertions(+), 50 deletions(-) diff --git a/src/app/api/metrics/ci/route.ts b/src/app/api/metrics/ci/route.ts index 8017c64b..91298d0d 100644 --- a/src/app/api/metrics/ci/route.ts +++ b/src/app/api/metrics/ci/route.ts @@ -6,6 +6,7 @@ import { GITHUB_API } from "@/lib/github"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -27,7 +28,7 @@ async function fetchCIAnalyticsForAccount(token: string, githubLogin: string): P const searchRes = await fetch(`${GITHUB_API}/search/commits?q=author:${githubLogin}+author-date:>=${toIsoDate(30)}&per_page=100&sort=author-date&order=desc`, { headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json" }, cache: "no-store" }); if (!searchRes.ok) throw new Error("API error"); const data = await searchRes.json(); - + const repoMap = new Map(); for (const item of data.items) { const n = item.repository.full_name; repoMap.set(n, (repoMap.get(n) ?? 0) + 1); } const repos = Array.from(repoMap.entries()).map(([name, commits]) => ({ name, commits })).sort((a, b) => b.commits - a.commits).slice(0, 5); @@ -58,7 +59,10 @@ async function fetchCIAnalyticsForAccount(token: string, githubLogin: string): P export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 }); + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } const accountId = req.nextUrl.searchParams.get("accountId"); const bypass = isMetricsCacheBypassed(req); @@ -66,20 +70,20 @@ export async function GET(req: NextRequest) { try { const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { - if (!accountId) return await fetchCIAnalyticsForAccount(session.accessToken!, session.githubLogin!); - + if (!accountId) return await fetchCIAnalyticsForAccount(accessToken, session.githubLogin!); + const userRow = await resolveAppUser(session.githubId!, session.githubLogin!); if (!userRow) throw new Error("User not found"); if (accountId === "combined") { - const accounts = await getAllAccounts({ token: session.accessToken!, githubId: session.githubId!, githubLogin: session.githubLogin! }, userRow.id); + const accounts = await getAllAccounts({ token: accessToken, githubId: session.githubId!, githubLogin: session.githubLogin! }, userRow.id); const results = await Promise.allSettled(accounts.map((a) => fetchCIAnalyticsForAccount(a.token, a.githubLogin))); const merged = mergeMetrics(results, mergeCIAnalytics); if (!merged) throw new Error("Merge failed"); return merged; } - if (accountId === session.githubId) return await fetchCIAnalyticsForAccount(session.accessToken!, session.githubLogin!); + if (accountId === session.githubId) return await fetchCIAnalyticsForAccount(accessToken, session.githubLogin!); const accountToken = await getAccountToken(userRow.id, accountId); if (!accountToken) throw new Error("Token missing"); diff --git a/src/app/api/metrics/compare/route.ts b/src/app/api/metrics/compare/route.ts index fbcd933f..52bbb943 100644 --- a/src/app/api/metrics/compare/route.ts +++ b/src/app/api/metrics/compare/route.ts @@ -3,6 +3,7 @@ import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { dateDiffDays, toDateStr } from "@/lib/dateUtils"; import { normalizeGitHubUsername } from "@/lib/validate-github-username"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -10,7 +11,8 @@ const GITHUB_API = "https://api.github.com"; export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -37,7 +39,7 @@ export async function GET(req: NextRequest) { // 1. Verify user exists const userRes = await fetch(`${GITHUB_API}/users/${encodedUsername}`, { - headers: { Authorization: `Bearer ${session.accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store", }); @@ -50,7 +52,7 @@ export async function GET(req: NextRequest) { const since90 = new Date(); since90.setDate(since90.getDate() - 90); const since90Str = since90.toISOString().slice(0, 10); - + const since30 = new Date(); since30.setDate(since30.getDate() - 30); const since30Str = since30.toISOString().slice(0, 10); @@ -66,7 +68,7 @@ export async function GET(req: NextRequest) { const commitsRes = await fetch(commitsUrl.toString(), { headers: { - Authorization: `Bearer ${session.accessToken}`, + Authorization: `Bearer ${accessToken}`, Accept: "application/vnd.github+json", }, cache: "no-store", @@ -75,11 +77,11 @@ export async function GET(req: NextRequest) { let streak = 0; let commits30d = 0; let topLanguage = "Unknown"; - + if (commitsRes.ok) { const commitsData = await commitsRes.json(); const items = commitsData.items || []; - + const daySet: Record = {}; for (const item of items) { const dateStr = item.commit.author.date.slice(0, 10); @@ -89,22 +91,20 @@ export async function GET(req: NextRequest) { } } const commitDays = Object.keys(daySet).sort(); - + if (commitDays.length > 0) { let currentRun = 1; - let runs: { end: string; length: number }[] = []; - let runStart = commitDays[0]; + const runs: { end: string; length: number }[] = []; for (let i = 1; i < commitDays.length; i++) { if (dateDiffDays(commitDays[i - 1], commitDays[i]) === 1) { currentRun++; } else { runs.push({ end: commitDays[i - 1], length: currentRun }); - runStart = commitDays[i]; currentRun = 1; } } runs.push({ end: commitDays[commitDays.length - 1], length: currentRun }); - + const today = toDateStr(new Date()); const yesterday = toDateStr(new Date(Date.now() - 86400000)); const lastRun = runs[runs.length - 1]; @@ -118,10 +118,10 @@ export async function GET(req: NextRequest) { reposUrl.searchParams.set("sort", "pushed"); const reposRes = await fetch(reposUrl.toString(), { - headers: { Authorization: `Bearer ${session.accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store", }); - + if (reposRes.ok) { const reposData = await reposRes.json(); const langCounts: Record = {}; @@ -140,7 +140,7 @@ export async function GET(req: NextRequest) { prsUrl.searchParams.set("per_page", "1"); const prsRes = await fetch(prsUrl.toString(), { - headers: { Authorization: `Bearer ${session.accessToken}` }, + headers: { Authorization: `Bearer ${accessToken}` }, cache: "no-store", }); let prs = 0; @@ -154,6 +154,6 @@ export async function GET(req: NextRequest) { streak, commits30d, topLanguage, - prs + prs, }); } diff --git a/src/app/api/metrics/contributions/route.ts b/src/app/api/metrics/contributions/route.ts index 007c123f..a9cb12e2 100644 --- a/src/app/api/metrics/contributions/route.ts +++ b/src/app/api/metrics/contributions/route.ts @@ -16,6 +16,7 @@ import { import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; import { normalizeGitHubUsername } from "@/lib/validate-github-username"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -276,7 +277,8 @@ async function mergeGitLabContributions( export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -313,7 +315,7 @@ export async function GET(req: NextRequest) { if (username) { try { const result = await fetchContributionsForAccount( - session.accessToken, + accessToken, username, days, { bypass, userId: session.githubId ?? session.githubLogin }, @@ -328,7 +330,7 @@ export async function GET(req: NextRequest) { if (!accountId) { try { const result = await fetchContributionsForAccount( - session.accessToken, + accessToken, session.githubLogin, days, { bypass, userId: session.githubId ?? session.githubLogin }, @@ -363,7 +365,7 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin, }, @@ -408,7 +410,7 @@ export async function GET(req: NextRequest) { if (accountId === session.githubId) { try { const result = await fetchContributionsForAccount( - session.accessToken, + accessToken, session.githubLogin, days, { bypass, userId: session.githubId }, diff --git a/src/app/api/metrics/pr-review-time/route.ts b/src/app/api/metrics/pr-review-time/route.ts index b7a8852d..66fd85b4 100644 --- a/src/app/api/metrics/pr-review-time/route.ts +++ b/src/app/api/metrics/pr-review-time/route.ts @@ -14,6 +14,7 @@ import { withMetricsCache, } from "@/lib/metrics-cache"; import { resolveAppUser } from "@/lib/resolve-user"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -229,8 +230,9 @@ function formatTrendWeeks(weeks: TrendWeek[]) { export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); + const accessToken = await getGitHubAccessToken(req); - if (!session?.accessToken) { + if (!accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -239,9 +241,9 @@ export async function GET(req: NextRequest) { if (!accountId) { try { - const result = await fetchPRReviewTrendForAccount(session.accessToken, { + const result = await fetchPRReviewTrendForAccount(accessToken, { bypass, - userId: session.githubId ?? session.githubLogin ?? "primary", + userId: session?.githubId ?? session?.githubLogin ?? "primary", }); return Response.json(formatTrendWeeks(result)); @@ -250,7 +252,7 @@ export async function GET(req: NextRequest) { } } - if (!session.githubId || !session.githubLogin) { + if (!session?.githubId || !session.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -263,7 +265,7 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin, }, @@ -290,7 +292,7 @@ export async function GET(req: NextRequest) { const token = accountId === session.githubId - ? session.accessToken + ? accessToken : await getAccountToken(userRow.id, accountId); if (!token) { diff --git a/src/app/api/metrics/prs/route.ts b/src/app/api/metrics/prs/route.ts index addf55d5..97e61bbf 100644 --- a/src/app/api/metrics/prs/route.ts +++ b/src/app/api/metrics/prs/route.ts @@ -15,6 +15,7 @@ import { } from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; interface ReviewMetrics { @@ -464,7 +465,8 @@ async function fetchReviewMetrics(token: string): Promise { } export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -480,9 +482,9 @@ export async function GET(req: NextRequest) { if (!accountId) { try { - const result = await fetchCachedPRMetrics(session.accessToken, { + const result = await fetchCachedPRMetrics(accessToken, { bypass, - userId: session.githubId ?? session.githubLogin ?? "primary", + userId: session?.githubId ?? session?.githubLogin ?? "primary", }); const [gitlab, reviews] = await Promise.all([ getGitLabMetrics(gitlabToken, gitlabCacheContext), @@ -494,7 +496,7 @@ export async function GET(req: NextRequest) { } } - if (!session.githubId || !session.githubLogin) { + if (!session?.githubId || !session.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -507,7 +509,7 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin, }, @@ -565,7 +567,7 @@ export async function GET(req: NextRequest) { const token = accountId === session.githubId - ? session.accessToken + ? accessToken : await getAccountToken(userRow.id, accountId); if (!token) { diff --git a/src/app/api/metrics/repo-health/route.ts b/src/app/api/metrics/repo-health/route.ts index 5a8ee0f6..f7bbb9ef 100644 --- a/src/app/api/metrics/repo-health/route.ts +++ b/src/app/api/metrics/repo-health/route.ts @@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth"; import { computeHealthScore } from "@/lib/repo-health"; import { isMetricsCacheBypassed, metricsCacheKey, withMetricsCache } from "@/lib/metrics-cache"; import type { RepoHealthResponse, RepoHealthSignals, RepoHealthScore } from "@/types/repo-health"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; const GITHUB_API = "https://api.github.com"; @@ -60,21 +61,24 @@ async function fetchSignalsForRepo(token: string, repoFullName: string, days: nu export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) return Response.json({ error: "Unauthorized" }, { status: 401 }); + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } const requestedDays = parseInt(req.nextUrl.searchParams.get("days") ?? "30", 10); const days = requestedDays === 7 || requestedDays === 30 || requestedDays === 90 ? requestedDays : 30; - + const bypass = isMetricsCacheBypassed(req); const key = metricsCacheKey(session.githubId ?? session.githubLogin, "repo-health" as any, { days }); try { const data = await withMetricsCache({ bypass, key, ttlSeconds: 10 * 60 }, async () => { - const topRepos = (await fetchReposForAccount(session.accessToken!, session.githubLogin!, days)).repos; + const topRepos = (await fetchReposForAccount(accessToken, session.githubLogin!, days)).repos; const scores: RepoHealthScore[] = []; for (const repo of topRepos) { try { - const signals = await fetchSignalsForRepo(session.accessToken!, repo.name, days); + const signals = await fetchSignalsForRepo(accessToken, repo.name, days); scores.push(computeHealthScore(repo.name, signals)); } catch {} } diff --git a/src/app/api/metrics/repos/route.ts b/src/app/api/metrics/repos/route.ts index ee3fd1cb..4c99308f 100644 --- a/src/app/api/metrics/repos/route.ts +++ b/src/app/api/metrics/repos/route.ts @@ -15,6 +15,7 @@ import { } from "@/lib/metrics-cache"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -168,7 +169,8 @@ async function fetchReposForAccount( export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -181,7 +183,7 @@ export async function GET(req: NextRequest) { if (!accountId) { try { const result = await fetchReposForAccount( - session.accessToken, + accessToken, session.githubLogin, days, { bypass, userId: session.githubId ?? session.githubLogin } @@ -205,7 +207,7 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin, }, @@ -236,7 +238,7 @@ export async function GET(req: NextRequest) { if (accountId === session.githubId) { try { const result = await fetchReposForAccount( - session.accessToken, + accessToken, session.githubLogin, days, { bypass, userId: session.githubId } diff --git a/src/app/api/metrics/streak/route.ts b/src/app/api/metrics/streak/route.ts index c461cf1b..1b82dae5 100644 --- a/src/app/api/metrics/streak/route.ts +++ b/src/app/api/metrics/streak/route.ts @@ -12,6 +12,7 @@ import { import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; import { dateDiffDays, toDateStr } from "@/lib/dateUtils"; +import { getGitHubAccessToken } from "@/lib/server-github-token"; export const dynamic = "force-dynamic"; @@ -138,7 +139,8 @@ function calculateStreakFromDates( export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); - if (!session?.accessToken || !session.githubLogin || !session.githubId) { + const accessToken = await getGitHubAccessToken(req); + if (!accessToken || !session?.githubLogin || !session.githubId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } @@ -176,7 +178,7 @@ export async function GET(req: NextRequest) { try { const activeDates = await fetchActiveDates( session.githubLogin, - session.accessToken, + accessToken, { bypass, userId: session.githubId } ); return Response.json( @@ -194,7 +196,7 @@ export async function GET(req: NextRequest) { if (accountId === "combined") { const accounts = await getAllAccounts( { - token: session.accessToken, + token: accessToken, githubId: session.githubId, githubLogin: session.githubLogin, }, @@ -220,7 +222,7 @@ export async function GET(req: NextRequest) { return Response.json(calculateStreakFromDates(unifiedDates, freezeDates)); } - let resolvedToken = session.accessToken; + let resolvedToken = accessToken; let resolvedLogin = session.githubLogin; if (accountId !== session.githubId) { From 1b8d1d827b8a6fc47321fe079719bf17fff22a4d Mon Sep 17 00:00:00 2001 From: Vexx Date: Sat, 23 May 2026 20:08:58 +0530 Subject: [PATCH 3/6] fix(csp): eliminate unsafe-inline script-src and add strict security headers Replace the dangerouslySetInnerHTML theme-init script in layout.tsx with a server-side cookie read so the dark class is applied on before the page is sent to the browser. ThemeContext writes the theme cookie on every toggle so the server stays in sync. With no inline script remaining, next.config.mjs can declare a strict CSP with script-src 'self' and no unsafe-inline. Also adds frame-ancestors 'none', X-Frame-Options, DENY, X-Content-Type-Options, and Referrer-Policy headers. Closes #858 --- next.config.mjs | 46 ++++++++++++++++++++++++++------- src/app/layout.tsx | 37 ++++++++------------------ src/components/ThemeContext.tsx | 32 ++++++++++++++++------- 3 files changed, 71 insertions(+), 44 deletions(-) diff --git a/next.config.mjs b/next.config.mjs index 185ff817..fda015e0 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,40 @@ /** @type {import("next").NextConfig} */ + +const cspDirectives = [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' https://avatars.githubusercontent.com data: blob:", + "font-src 'self' data:", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", +].join("; "); + +const securityHeaders = [ + { + key: "Content-Security-Policy", + value: cspDirectives, + }, + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, +]; + const nextConfig = { images: { remotePatterns: [ @@ -12,15 +48,7 @@ const nextConfig = { return [ { source: "/(.*)", - headers: [ - { key: "X-Frame-Options", value: "DENY" }, - { key: "X-Content-Type-Options", value: "nosniff" }, - { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, - { - key: "Permissions-Policy", - value: "camera=(), microphone=(), geolocation=()", - }, - ], + headers: securityHeaders, }, ]; }, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index eb8aea4f..7a97bc7d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from "next"; import { Inter } from "next/font/google"; +import { cookies } from "next/headers"; import Footer from "@/components/Footer"; import Providers from "./providers"; import PWARegister from "@/components/pwa-register"; @@ -42,32 +43,16 @@ export default function RootLayout({ }: { children: React.ReactNode; }) { - return ( - - -