Skip to content

Commit 9e8daca

Browse files
Merge pull request #485 from vansh2604-star/integrate-GitHub-OAuth-and-optimize-developer-recommendations-portal--#479---solve
feat: integrate GitHub OAuth and optimize recommendations portal
2 parents 0404ced + bc4ccc2 commit 9e8daca

7 files changed

Lines changed: 320 additions & 20 deletions

File tree

backend/.env.example

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ RATE_LIMIT_USER_MAX=200
5757
RATE_LIMIT_ADMIN_MAX=100
5858

5959
# ===========================================
60+
# GitHub OAuth Configuration
61+
# ===========================================
62+
GITHUB_CLIENT_ID=your_github_client_id_here
63+
GITHUB_CLIENT_SECRET=your_github_client_secret_here
64+
GITHUB_CALLBACK_URL=http://localhost:3000/api/auth/github/callback
65+
FRONTEND_URL=http://localhost:5173
66+
6067
# Redis (Optional — shared rate limiting)
6168
# ===========================================
6269
# If set, rate limit counters are shared across all app instances via Redis
@@ -65,4 +72,4 @@ RATE_LIMIT_ADMIN_MAX=100
6572
# for local development.
6673
# Free instance: https://console.upstash.com/ (use the TCP/node-redis
6774
# connection string, which starts with rediss://, not the REST API URL+token)
68-
REDIS_URL=rediss://default:password@your-instance.upstash.io:6379
75+
# REDIS_URL=rediss://default:password@your-instance.upstash.io:6379

backend/model/userModel.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const userSchema = new mongoose.Schema(
6060

6161
resetPasswordToken: {type: String, select: false},
6262
resetPasswordExpire: {type: Date, select: false},
63+
githubAccessToken: { type: String, select: false },
6364
},
6465
{
6566
timestamps: true,

backend/routes/authRoutes.js

Lines changed: 174 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,153 @@ import {
1313
import validateRequest from "../middleware/validateRequest.js";
1414
import { registerSchema, loginSchema } from "../validators/authSchemas.js";
1515
import { authIpLimiter, otpIpLimiter } from "../middleware/rateLimiters.js";
16+
import protect from "../middleware/isAuth.js";
17+
import { encrypt, decrypt } from "../utils/crypto.js";
18+
import axios from "axios";
19+
import jwt from "jsonwebtoken";
20+
import User from "../model/userModel.js";
21+
import RefreshToken from "../model/RefreshToken.js";
22+
import bcrypt from "bcryptjs";
1623

1724
const authRoutes = express.Router();
1825

26+
authRoutes.get("/github", (req, res) => {
27+
const { token } = req.cookies;
28+
if (!token) {
29+
return res.status(401).json({ message: "Unauthorized: Please log in first to connect GitHub" });
30+
}
31+
try {
32+
jwt.verify(token, process.env.JWT_SECRET);
33+
const clientId = process.env.GITHUB_CLIENT_ID;
34+
const redirectUri = process.env.GITHUB_CALLBACK_URL;
35+
const scope = "repo,read:user";
36+
const githubUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scope}&state=${token}`;
37+
return res.redirect(githubUrl);
38+
} catch (_err) {
39+
return res.status(401).json({ message: "Invalid or expired token" });
40+
}
41+
});
42+
43+
authRoutes.get("/github/callback", async (req, res) => {
44+
const { code, state } = req.query;
45+
if (!code) {
46+
return res.status(400).json({ message: "Authorization code missing" });
47+
}
48+
49+
try {
50+
let userId;
51+
try {
52+
const decoded = jwt.verify(state, process.env.JWT_SECRET);
53+
userId = decoded.userId;
54+
} catch (_err) {
55+
return res.status(401).json({ message: "Invalid OAuth state / session expired" });
56+
}
57+
58+
const tokenResponse = await axios.post(
59+
"https://github.com/login/oauth/access_token",
60+
{
61+
client_id: process.env.GITHUB_CLIENT_ID,
62+
client_secret: process.env.GITHUB_CLIENT_SECRET,
63+
code,
64+
redirect_uri: process.env.GITHUB_CALLBACK_URL,
65+
},
66+
{
67+
headers: {
68+
Accept: "application/json",
69+
},
70+
}
71+
);
72+
73+
const { access_token } = tokenResponse.data;
74+
if (!access_token) {
75+
return res.status(400).json({ message: "Failed to retrieve access token from GitHub" });
76+
}
77+
78+
await axios.get("https://api.github.com/user", {
79+
headers: {
80+
Authorization: `Bearer ${access_token}`,
81+
"User-Agent": "RIVETO-App",
82+
},
83+
});
84+
85+
const encryptedToken = encrypt(access_token);
86+
const user = await User.findByIdAndUpdate(
87+
userId,
88+
{ githubAccessToken: encryptedToken },
89+
{ new: true }
90+
);
91+
92+
if (!user) {
93+
return res.status(404).json({ message: "User not found" });
94+
}
95+
96+
const newAccessToken = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: "15m" });
97+
const newRefreshToken = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: "7d" });
98+
99+
await RefreshToken.findOneAndUpdate(
100+
{ userId: user._id },
101+
{
102+
tokenHash: await bcrypt.hash(newRefreshToken, 10),
103+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
104+
},
105+
{ upsert: true }
106+
);
107+
108+
res.cookie("token", newAccessToken, {
109+
httpOnly: true,
110+
secure: process.env.NODE_ENV === "production",
111+
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
112+
maxAge: 15 * 60 * 1000,
113+
});
114+
115+
res.cookie("refreshToken", newRefreshToken, {
116+
httpOnly: true,
117+
secure: process.env.NODE_ENV === "production",
118+
sameSite: process.env.NODE_ENV === "production" ? "none" : "lax",
119+
maxAge: 7 * 24 * 60 * 60 * 1000,
120+
});
121+
122+
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
123+
return res.redirect(`${frontendUrl.replace(/\/+$/, "")}/recommendations`);
124+
} catch (error) {
125+
console.error("GitHub OAuth Callback error:", error);
126+
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
127+
return res.redirect(`${frontendUrl.replace(/\/+$/, "")}/recommendations?error=oauth_failed`);
128+
}
129+
});
130+
131+
authRoutes.get("/github/profile", protect, async (req, res) => {
132+
try {
133+
const user = await User.findById(req.userId).select("+githubAccessToken");
134+
if (!user || !user.githubAccessToken) {
135+
return res.json({ connected: false });
136+
}
137+
138+
const decryptedToken = decrypt(user.githubAccessToken);
139+
if (!decryptedToken) {
140+
return res.json({ connected: false });
141+
}
142+
143+
const profileResponse = await axios.get("https://api.github.com/user", {
144+
headers: {
145+
Authorization: `Bearer ${decryptedToken}`,
146+
"User-Agent": "RIVETO-App",
147+
},
148+
});
149+
150+
const { avatar_url, login, public_repos } = profileResponse.data;
151+
return res.json({
152+
connected: true,
153+
avatar_url,
154+
login,
155+
public_repos,
156+
});
157+
} catch (error) {
158+
console.error("Failed to fetch GitHub profile:", error.message);
159+
return res.status(500).json({ error: "Failed to fetch GitHub profile" });
160+
}
161+
});
162+
19163
authRoutes.post("/send-otp", otpIpLimiter, validateRequest(registerSchema), sendOTP);
20164
authRoutes.post("/verify-otp", otpIpLimiter, verifyOTP);
21165

@@ -42,17 +186,24 @@ authRoutes.post("/verify-otp", otpIpLimiter, verifyOTP);
42186
* responses:
43187
* 200:
44188
* description: Login successful
189+
* headers:
190+
* Set-Cookie:
191+
* description: Contains httpOnly 'token' and 'refreshToken' cookies
192+
* schema:
193+
* type: string
45194
* content:
46195
* application/json:
47196
* schema:
48197
* type: object
49198
* properties:
50-
* token:
199+
* _id:
200+
* type: string
201+
* name:
51202
* type: string
52-
* example: "eyJhbGciOiJIUzI1Ni..."
53-
* refreshToken:
203+
* email:
204+
* type: string
205+
* authProvider:
54206
* type: string
55-
* example: "eyJhbGciOiJIUzI1Ni..."
56207
*/
57208
authRoutes.post("/login", authIpLimiter, validateRequest(loginSchema), login);
58209

@@ -66,7 +217,7 @@ authRoutes.post("/login", authIpLimiter, validateRequest(loginSchema), login);
66217
* 200:
67218
* description: Successfully logged out
68219
*/
69-
authRoutes.post("/logout", logOut);
220+
authRoutes.post("/logout", protect, logOut);
70221

71222
/**
72223
* @swagger
@@ -77,17 +228,24 @@ authRoutes.post("/logout", logOut);
77228
* responses:
78229
* 200:
79230
* description: Login successful
231+
* headers:
232+
* Set-Cookie:
233+
* description: Contains httpOnly 'token' and 'refreshToken' cookies
234+
* schema:
235+
* type: string
80236
* content:
81237
* application/json:
82238
* schema:
83239
* type: object
84240
* properties:
85-
* token:
241+
* _id:
242+
* type: string
243+
* name:
86244
* type: string
87-
* example: "eyJhbGciOiJIUzI1Ni..."
88-
* refreshToken:
245+
* email:
246+
* type: string
247+
* authProvider:
89248
* type: string
90-
* example: "eyJhbGciOiJIUzI1Ni..."
91249
*/
92250
authRoutes.post("/googlelogin", authIpLimiter, googleLogin);
93251

@@ -100,17 +258,19 @@ authRoutes.post("/googlelogin", authIpLimiter, googleLogin);
100258
* responses:
101259
* 200:
102260
* description: Admin login successful
261+
* headers:
262+
* Set-Cookie:
263+
* description: Contains httpOnly 'adminToken' and 'adminRefreshToken' cookies
264+
* schema:
265+
* type: string
103266
* content:
104267
* application/json:
105268
* schema:
106269
* type: object
107270
* properties:
108-
* token:
109-
* type: string
110-
* example: "eyJhbGciOiJIUzI1Ni..."
111-
* refreshToken:
271+
* message:
112272
* type: string
113-
* example: "eyJhbGciOiJIUzI1Ni..."
273+
* example: "Admin logged in successfully"
114274
*/
115275
authRoutes.post("/adminlogin", authIpLimiter, adminLogin);
116276

backend/routes/recommendations.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import express from "express";
22
import { getRecommendations } from "../services/recommendationService.js";
3+
import jwt from "jsonwebtoken";
4+
import User from "../model/userModel.js";
5+
import { decrypt } from "../utils/crypto.js";
36
import logger from "../config/logger.js";
47

58
const router = express.Router();
@@ -19,11 +22,27 @@ router.get("/", async (req, res) => {
1922
.map((item) => item.trim())
2023
.filter(Boolean)
2124
: [];
25+
26+
let githubToken = null;
27+
const { token } = req.cookies;
28+
if (token) {
29+
try {
30+
const decoded = jwt.verify(token, process.env.JWT_SECRET);
31+
const user = await User.findById(decoded.userId).select("+githubAccessToken");
32+
if (user && user.githubAccessToken) {
33+
githubToken = decrypt(user.githubAccessToken);
34+
}
35+
} catch (_err) {
36+
// Ignore invalid tokens for recommendations
37+
}
38+
}
39+
2240
const results = await getRecommendations({
2341
stack: userStack,
2442
level,
2543
search,
2644
history: historyTerms,
45+
githubToken,
2746
});
2847

2948
res.json({

backend/services/recommendationService.js

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,21 @@ const REPO_OWNER = process.env.GITHUB_REPO_OWNER || "Nsanjayboruds";
55
const REPO_NAME = process.env.GITHUB_REPO_NAME || "RIVETO";
66
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
77

8-
function getGithubHeaders() {
8+
function getGithubHeaders(githubToken = null) {
99
const headers = {
1010
Accept: "application/vnd.github+json",
1111
};
1212

13-
if (GITHUB_TOKEN) {
13+
if (githubToken) {
14+
headers.Authorization = `token ${githubToken}`;
15+
} else if (GITHUB_TOKEN) {
1416
headers.Authorization = `Bearer ${GITHUB_TOKEN}`;
1517
}
1618

1719
return headers;
1820
}
1921

20-
async function fetchIssues() {
22+
async function fetchIssues(githubToken = null) {
2123
const res = await axios.get(
2224
`${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/issues`,
2325
{
@@ -27,7 +29,7 @@ async function fetchIssues() {
2729
sort: "updated",
2830
direction: "desc",
2931
},
30-
headers: getGithubHeaders(),
32+
headers: getGithubHeaders(githubToken),
3133
},
3234
);
3335

@@ -83,8 +85,9 @@ async function getRecommendations({
8385
level = "all",
8486
search = "",
8587
history = [],
88+
githubToken = null,
8689
}) {
87-
const issues = await fetchIssues();
90+
const issues = await fetchIssues(githubToken);
8891
const historyTerms = Array.isArray(history)
8992
? history
9093
: String(history)

backend/utils/crypto.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import crypto from "crypto";
2+
3+
const ALGORITHM = "aes-256-cbc";
4+
const SECRET = process.env.JWT_SECRET || "fallback_secret_key_for_oauth";
5+
6+
// Generate a 32-byte key from our JWT_SECRET
7+
const KEY = crypto.createHash("sha256").update(SECRET).digest();
8+
9+
export function encrypt(text) {
10+
if (!text) return text;
11+
const iv = crypto.randomBytes(16);
12+
const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
13+
let encrypted = cipher.update(text, "utf8", "hex");
14+
encrypted += cipher.final("hex");
15+
return `${iv.toString("hex")}:${encrypted}`;
16+
}
17+
18+
export function decrypt(text) {
19+
if (!text) return text;
20+
try {
21+
const [ivHex, encryptedHex] = text.split(":");
22+
if (!ivHex || !encryptedHex) return "";
23+
const iv = Buffer.from(ivHex, "hex");
24+
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv);
25+
let decrypted = decipher.update(encryptedHex, "hex", "utf8");
26+
decrypted += decipher.final("utf8");
27+
return decrypted;
28+
} catch (error) {
29+
console.error("Decryption failed:", error);
30+
return "";
31+
}
32+
}

0 commit comments

Comments
 (0)