Skip to content

Commit 248542f

Browse files
authored
merge: feat(auth): implement GitHub OAuth to distribute API rate limits across authenticated users (#5744)
## Description This PR introduces GitHub OAuth authentication to reduce dependency on the application's global Personal Access Token (PAT) and prevent rate-limit exhaustion under high traffic. Authenticated users can now sign in with GitHub, allowing dashboard-related GitHub API requests to be performed using their personal OAuth access token. The existing server PAT is retained as a fallback for unauthenticated users and README SVG generation endpoints. ## Solution Implemented GitHub OAuth authentication using NextAuth. - Authentication Flow - Added Sign in with GitHub functionality. - Users can authenticate via GitHub OAuth. - GitHub access tokens are stored securely in the session/JWT. - Dashboard requests automatically use the authenticated user's token. - Unauthenticated requests continue using the server PAT as a fallback. Fixes #3679 ## Pillar - [x] 🎨 Pillar 1 — New Theme Design - [x] 📐 Pillar 2 — Geometric SVG Improvement - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [x] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [ ] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents 6172034 + 8eb2fb3 commit 248542f

30 files changed

Lines changed: 527 additions & 164 deletions

.env.local.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ MONGODB_URI=mongodb+srv://<username>:<password>@cluster0.mongodb.net/commitpulse
1313
# Generate at: https://github.com/settings/tokens (No scopes required)
1414
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
1515

16+
# GitHub OAuth App (https://github.com/settings/developers)
17+
# Callback URL: http://localhost:3000/api/auth/callback/github
18+
GITHUB_CLIENT_ID=
19+
GITHUB_CLIENT_SECRET=
20+
21+
# Generate with: openssl rand -base64 32
22+
AUTH_SECRET=
23+
1624
# Required for encrypting stored third-party API tokens.
1725
# Use a unique random secret with at least 32 characters.
1826
ENCRYPTION_KEY=

app/(root)/dashboard/[username]/page.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Metadata } from 'next';
22
import DashboardClient from '@/components/dashboard/DashboardClient';
33
import { getFullDashboardData, fetchUserProfile, fetchUserRepos } from '@/lib/github';
4+
import { getUserGitHubToken } from '@/lib/githubtoken';
5+
46
import type { RepoActivityInfo } from '@/types/dashboard';
57
import { notFound, redirect } from 'next/navigation';
68
import { resolveDashboardPeriod } from '@/utils/dashboardPeriod';
@@ -90,6 +92,7 @@ export default async function DashboardPage({
9092
from: resolvedSearchParams?.from,
9193
to: resolvedSearchParams?.to,
9294
});
95+
const userToken = await getUserGitHubToken();
9396

9497
let data;
9598

@@ -99,13 +102,15 @@ export default async function DashboardPage({
99102
from: period.from,
100103
to: period.to,
101104
rangeLabel: period.label,
105+
token: userToken,
102106
});
103107
} catch (error) {
104108
if (error instanceof Error && error.message.includes('not found')) {
105109
let fallbackProfile;
106110
try {
107111
fallbackProfile = await fetchUserProfile(username, {
108112
bypassCache,
113+
token: userToken,
109114
});
110115
} catch {
111116
return notFound();
@@ -120,7 +125,7 @@ export default async function DashboardPage({
120125

121126
let allRepos: RepoActivityInfo[] = [];
122127
try {
123-
const reposData = await fetchUserRepos(username, { bypassCache });
128+
const reposData = await fetchUserRepos(username, { bypassCache, token: userToken });
124129
allRepos = reposData.map((r) => ({
125130
name: r.name,
126131
url: `https://github.com/${username}/${r.name}`,
@@ -136,6 +141,7 @@ export default async function DashboardPage({
136141
try {
137142
compareData = await getFullDashboardData(compareUsername, {
138143
bypassCache,
144+
token: userToken,
139145
});
140146
} catch {
141147
compareData = null;

app/(root)/dashboard/[username]/wrapped/page.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { notFound } from 'next/navigation';
22
import type { Metadata } from 'next';
33
import GithubWrapped from '@/components/dashboard/GithubWrapped';
44
import { getFullDashboardData, getWrappedData } from '@/lib/github';
5+
import { getUserGitHubToken } from '@/lib/githubtoken';
56

67
export async function generateMetadata({
78
params,
@@ -25,6 +26,7 @@ export default async function WrappedPage({
2526
const { username } = await params;
2627
const resolvedSearchParams = await searchParams;
2728
const targetYear = resolvedSearchParams?.year || new Date().getFullYear().toString();
29+
const userToken = await getUserGitHubToken();
2830

2931
// 1. Fetch data safely.
3032
// If this fails, the error will bubble up to the nearest error.tsx file.
@@ -33,8 +35,8 @@ export default async function WrappedPage({
3335

3436
try {
3537
[dashboardData, wrappedData] = await Promise.all([
36-
getFullDashboardData(username),
37-
getWrappedData(username, targetYear),
38+
getFullDashboardData(username, { token: userToken }),
39+
getWrappedData(username, targetYear, { token: userToken }),
3840
]);
3941
} catch (error) {
4042
console.error('[Wrapped] Failed to load wrapped data:', error);

app/(root)/dashboard/org/[orgname]/page.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import Heatmap from '@/components/dashboard/Heatmap';
1212
import AIInsights from '@/components/dashboard/AIInsights';
1313
import Achievements from '@/components/dashboard/Achievements';
1414
import { getOrgDashboardData, buildCommitClock, generateAchievements } from '@/lib/github';
15+
import { getUserGitHubToken } from '@/lib/githubtoken';
1516

1617
export const revalidate = 3600; // Cache for 1 hour
1718

@@ -53,11 +54,12 @@ export default async function OrgDashboardPage({
5354
const { orgname } = await params;
5455
const refreshParams = await searchParams;
5556
const bypassCache = refreshParams?.refresh === 'true';
57+
const userToken = await getUserGitHubToken();
5658

5759
let data;
5860

5961
try {
60-
data = await getOrgDashboardData(orgname, { bypassCache });
62+
data = await getOrgDashboardData(orgname, { bypassCache, token: userToken });
6163
} catch (error) {
6264
console.error(error);
6365
return notFound();
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { handlers } from '@/auth';
2+
3+
export const { GET, POST } = handlers;

app/api/achievements/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
AchievementData,
1111
AchievementsResponse,
1212
} from '@/types/achievements';
13+
import { getUserGitHubToken } from '@/lib/githubtoken';
1314

1415
const ACHIEVEMENT_DEFS: AchievementDef[] = [
1516
// 🔥 Contribution
@@ -555,7 +556,8 @@ export async function GET(request: Request) {
555556
}
556557

557558
try {
558-
const dashboardData = await getFullDashboardData(username);
559+
const userToken = await getUserGitHubToken();
560+
const dashboardData = await getFullDashboardData(username, { token: userToken });
559561

560562
const { profile, stats, languages } = dashboardData;
561563
const totalStars = profile.stats.stars;

app/api/ci-analytics/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,6 @@ describe('GET /api/ci-analytics', () => {
4848
const response = await GET(new Request('http://localhost/api/ci-analytics?username=octocat'));
4949

5050
expect(response.status).toBe(200);
51-
expect(fetchCIAnalytics).toHaveBeenCalledWith('octocat');
51+
expect(fetchCIAnalytics).toHaveBeenCalledWith('octocat', undefined);
5252
});
5353
});

app/api/ci-analytics/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextResponse } from 'next/server';
22
import { fetchCIAnalytics } from '@/services/github/ci-analytics';
3+
import { getUserGitHubToken } from '@/lib/githubtoken';
34
import { validateGitHubUsername } from '@/lib/validations';
45
import { RateLimiter } from '@/lib/rate-limit';
56

@@ -25,7 +26,8 @@ export async function GET(request: Request) {
2526
}
2627

2728
try {
28-
const data = await fetchCIAnalytics(username);
29+
const userToken = await getUserGitHubToken();
30+
const data = await fetchCIAnalytics(username, userToken);
2931
return NextResponse.json(data);
3032
} catch (error: unknown) {
3133
console.error('Error fetching CI analytics:', error);

app/api/compare/route.mouse-interactivity.test.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,20 @@ describe('ApiCompareRoute Tests', () => {
4242
expect(json.user1.profile.username).toBe('testuser');
4343
expect(json.user2.profile.username).toBe('testuser');
4444
expect(getFullDashboardData).toHaveBeenCalledTimes(2);
45-
expect(getFullDashboardData).toHaveBeenNthCalledWith(1, 'octocat');
46-
expect(getFullDashboardData).toHaveBeenNthCalledWith(2, 'defunkt');
45+
expect(getFullDashboardData).toHaveBeenNthCalledWith(
46+
1,
47+
'octocat',
48+
expect.objectContaining({
49+
token: undefined,
50+
})
51+
);
52+
expect(getFullDashboardData).toHaveBeenNthCalledWith(
53+
2,
54+
'defunkt',
55+
expect.objectContaining({
56+
token: undefined,
57+
})
58+
);
4759
});
4860

4961
it('returns 404 Not Found when a user does not exist on GitHub', async () => {
@@ -88,7 +100,7 @@ describe('ApiCompareRoute Tests', () => {
88100
it('returns 502 Bad Gateway on unexpected upstream API failures', async () => {
89101
vi.mocked(getFullDashboardData).mockRejectedValueOnce(new Error('Unexpected network crash'));
90102

91-
const request = makeRequest({ user1: 'octocat', user2: 'defunkt' });
103+
const request = makeRequest({ user1: 'octocat', user2: 'defunkt', token: 'sometoken' });
92104
const response = await GET(request);
93105

94106
expect(response.status).toBe(502);

app/api/compare/route.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { NextResponse } from 'next/server';
22
import { getFullDashboardData } from '@/lib/github';
3+
import { getUserGitHubToken } from '@/lib/githubtoken';
34
import { compareParamsSchema } from '@/lib/validations';
45
import crypto from 'crypto';
56

@@ -65,9 +66,10 @@ export async function GET(request: Request) {
6566
const { user1, user2 } = parseResult.data;
6667

6768
try {
69+
const userToken = await getUserGitHubToken();
6870
const [result1, result2] = await Promise.allSettled([
69-
getFullDashboardData(user1),
70-
getFullDashboardData(user2),
71+
getFullDashboardData(user1, { token: userToken }),
72+
getFullDashboardData(user2, { token: userToken }),
7173
]);
7274

7375
if (result1.status === 'rejected') {

0 commit comments

Comments
 (0)