diff --git a/backend/.env.example b/backend/.env.example index 02200e47..a556bec5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -57,6 +57,13 @@ RATE_LIMIT_USER_MAX=200 RATE_LIMIT_ADMIN_MAX=100 # =========================================== +# GitHub OAuth Configuration +# =========================================== +GITHUB_CLIENT_ID=your_github_client_id_here +GITHUB_CLIENT_SECRET=your_github_client_secret_here +GITHUB_CALLBACK_URL=http://localhost:3000/api/auth/github/callback +FRONTEND_URL=http://localhost:5173 + # Redis (Optional — shared rate limiting) # =========================================== # If set, rate limit counters are shared across all app instances via Redis @@ -65,4 +72,4 @@ RATE_LIMIT_ADMIN_MAX=100 # for local development. # Free instance: https://console.upstash.com/ (use the TCP/node-redis # connection string, which starts with rediss://, not the REST API URL+token) -REDIS_URL=rediss://default:password@your-instance.upstash.io:6379 \ No newline at end of file +# REDIS_URL=rediss://default:password@your-instance.upstash.io:6379 diff --git a/backend/model/userModel.js b/backend/model/userModel.js index 9224b603..5d239fab 100644 --- a/backend/model/userModel.js +++ b/backend/model/userModel.js @@ -60,6 +60,7 @@ const userSchema = new mongoose.Schema( resetPasswordToken: {type: String, select: false}, resetPasswordExpire: {type: Date, select: false}, + githubAccessToken: { type: String, select: false }, }, { timestamps: true, diff --git a/backend/routes/authRoutes.js b/backend/routes/authRoutes.js index 8f4867ac..b61c34b2 100644 --- a/backend/routes/authRoutes.js +++ b/backend/routes/authRoutes.js @@ -13,9 +13,153 @@ import { import validateRequest from "../middleware/validateRequest.js"; import { registerSchema, loginSchema } from "../validators/authSchemas.js"; import { authIpLimiter, otpIpLimiter } from "../middleware/rateLimiters.js"; +import protect from "../middleware/isAuth.js"; +import { encrypt, decrypt } from "../utils/crypto.js"; +import axios from "axios"; +import jwt from "jsonwebtoken"; +import User from "../model/userModel.js"; +import RefreshToken from "../model/RefreshToken.js"; +import bcrypt from "bcryptjs"; const authRoutes = express.Router(); +authRoutes.get("/github", (req, res) => { + const { token } = req.cookies; + if (!token) { + return res.status(401).json({ message: "Unauthorized: Please log in first to connect GitHub" }); + } + try { + jwt.verify(token, process.env.JWT_SECRET); + const clientId = process.env.GITHUB_CLIENT_ID; + const redirectUri = process.env.GITHUB_CALLBACK_URL; + const scope = "repo,read:user"; + const githubUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${scope}&state=${token}`; + return res.redirect(githubUrl); + } catch (_err) { + return res.status(401).json({ message: "Invalid or expired token" }); + } +}); + +authRoutes.get("/github/callback", async (req, res) => { + const { code, state } = req.query; + if (!code) { + return res.status(400).json({ message: "Authorization code missing" }); + } + + try { + let userId; + try { + const decoded = jwt.verify(state, process.env.JWT_SECRET); + userId = decoded.userId; + } catch (_err) { + return res.status(401).json({ message: "Invalid OAuth state / session expired" }); + } + + const tokenResponse = await axios.post( + "https://github.com/login/oauth/access_token", + { + client_id: process.env.GITHUB_CLIENT_ID, + client_secret: process.env.GITHUB_CLIENT_SECRET, + code, + redirect_uri: process.env.GITHUB_CALLBACK_URL, + }, + { + headers: { + Accept: "application/json", + }, + } + ); + + const { access_token } = tokenResponse.data; + if (!access_token) { + return res.status(400).json({ message: "Failed to retrieve access token from GitHub" }); + } + + await axios.get("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${access_token}`, + "User-Agent": "RIVETO-App", + }, + }); + + const encryptedToken = encrypt(access_token); + const user = await User.findByIdAndUpdate( + userId, + { githubAccessToken: encryptedToken }, + { new: true } + ); + + if (!user) { + return res.status(404).json({ message: "User not found" }); + } + + const newAccessToken = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: "15m" }); + const newRefreshToken = jwt.sign({ userId: user._id }, process.env.JWT_SECRET, { expiresIn: "7d" }); + + await RefreshToken.findOneAndUpdate( + { userId: user._id }, + { + tokenHash: await bcrypt.hash(newRefreshToken, 10), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }, + { upsert: true } + ); + + res.cookie("token", newAccessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", + maxAge: 15 * 60 * 1000, + }); + + res.cookie("refreshToken", newRefreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + return res.redirect(`${frontendUrl.replace(/\/+$/, "")}/recommendations`); + } catch (error) { + console.error("GitHub OAuth Callback error:", error); + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + return res.redirect(`${frontendUrl.replace(/\/+$/, "")}/recommendations?error=oauth_failed`); + } +}); + +authRoutes.get("/github/profile", protect, async (req, res) => { + try { + const user = await User.findById(req.userId).select("+githubAccessToken"); + if (!user || !user.githubAccessToken) { + return res.json({ connected: false }); + } + + const decryptedToken = decrypt(user.githubAccessToken); + if (!decryptedToken) { + return res.json({ connected: false }); + } + + const profileResponse = await axios.get("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${decryptedToken}`, + "User-Agent": "RIVETO-App", + }, + }); + + const { avatar_url, login, public_repos } = profileResponse.data; + return res.json({ + connected: true, + avatar_url, + login, + public_repos, + }); + } catch (error) { + console.error("Failed to fetch GitHub profile:", error.message); + return res.status(500).json({ error: "Failed to fetch GitHub profile" }); + } +}); + authRoutes.post("/send-otp", otpIpLimiter, validateRequest(registerSchema), sendOTP); authRoutes.post("/verify-otp", otpIpLimiter, verifyOTP); @@ -42,17 +186,24 @@ authRoutes.post("/verify-otp", otpIpLimiter, verifyOTP); * responses: * 200: * description: Login successful + * headers: + * Set-Cookie: + * description: Contains httpOnly 'token' and 'refreshToken' cookies + * schema: + * type: string * content: * application/json: * schema: * type: object * properties: - * token: + * _id: + * type: string + * name: * type: string - * example: "eyJhbGciOiJIUzI1Ni..." - * refreshToken: + * email: + * type: string + * authProvider: * type: string - * example: "eyJhbGciOiJIUzI1Ni..." */ authRoutes.post("/login", authIpLimiter, validateRequest(loginSchema), login); @@ -66,7 +217,7 @@ authRoutes.post("/login", authIpLimiter, validateRequest(loginSchema), login); * 200: * description: Successfully logged out */ -authRoutes.post("/logout", logOut); +authRoutes.post("/logout", protect, logOut); /** * @swagger @@ -77,17 +228,24 @@ authRoutes.post("/logout", logOut); * responses: * 200: * description: Login successful + * headers: + * Set-Cookie: + * description: Contains httpOnly 'token' and 'refreshToken' cookies + * schema: + * type: string * content: * application/json: * schema: * type: object * properties: - * token: + * _id: + * type: string + * name: * type: string - * example: "eyJhbGciOiJIUzI1Ni..." - * refreshToken: + * email: + * type: string + * authProvider: * type: string - * example: "eyJhbGciOiJIUzI1Ni..." */ authRoutes.post("/googlelogin", authIpLimiter, googleLogin); @@ -100,17 +258,19 @@ authRoutes.post("/googlelogin", authIpLimiter, googleLogin); * responses: * 200: * description: Admin login successful + * headers: + * Set-Cookie: + * description: Contains httpOnly 'adminToken' and 'adminRefreshToken' cookies + * schema: + * type: string * content: * application/json: * schema: * type: object * properties: - * token: - * type: string - * example: "eyJhbGciOiJIUzI1Ni..." - * refreshToken: + * message: * type: string - * example: "eyJhbGciOiJIUzI1Ni..." + * example: "Admin logged in successfully" */ authRoutes.post("/adminlogin", authIpLimiter, adminLogin); diff --git a/backend/routes/recommendations.js b/backend/routes/recommendations.js index ae8c1bcc..8f86c911 100644 --- a/backend/routes/recommendations.js +++ b/backend/routes/recommendations.js @@ -1,5 +1,8 @@ import express from "express"; import { getRecommendations } from "../services/recommendationService.js"; +import jwt from "jsonwebtoken"; +import User from "../model/userModel.js"; +import { decrypt } from "../utils/crypto.js"; import logger from "../config/logger.js"; const router = express.Router(); @@ -19,11 +22,27 @@ router.get("/", async (req, res) => { .map((item) => item.trim()) .filter(Boolean) : []; + + let githubToken = null; + const { token } = req.cookies; + if (token) { + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + const user = await User.findById(decoded.userId).select("+githubAccessToken"); + if (user && user.githubAccessToken) { + githubToken = decrypt(user.githubAccessToken); + } + } catch (_err) { + // Ignore invalid tokens for recommendations + } + } + const results = await getRecommendations({ stack: userStack, level, search, history: historyTerms, + githubToken, }); res.json({ diff --git a/backend/services/recommendationService.js b/backend/services/recommendationService.js index 69d9d713..0a140d5f 100644 --- a/backend/services/recommendationService.js +++ b/backend/services/recommendationService.js @@ -5,19 +5,21 @@ const REPO_OWNER = process.env.GITHUB_REPO_OWNER || "Nsanjayboruds"; const REPO_NAME = process.env.GITHUB_REPO_NAME || "RIVETO"; const GITHUB_TOKEN = process.env.GITHUB_TOKEN; -function getGithubHeaders() { +function getGithubHeaders(githubToken = null) { const headers = { Accept: "application/vnd.github+json", }; - if (GITHUB_TOKEN) { + if (githubToken) { + headers.Authorization = `token ${githubToken}`; + } else if (GITHUB_TOKEN) { headers.Authorization = `Bearer ${GITHUB_TOKEN}`; } return headers; } -async function fetchIssues() { +async function fetchIssues(githubToken = null) { const res = await axios.get( `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/issues`, { @@ -27,7 +29,7 @@ async function fetchIssues() { sort: "updated", direction: "desc", }, - headers: getGithubHeaders(), + headers: getGithubHeaders(githubToken), }, ); @@ -83,8 +85,9 @@ async function getRecommendations({ level = "all", search = "", history = [], + githubToken = null, }) { - const issues = await fetchIssues(); + const issues = await fetchIssues(githubToken); const historyTerms = Array.isArray(history) ? history : String(history) diff --git a/backend/utils/crypto.js b/backend/utils/crypto.js new file mode 100644 index 00000000..a352ad6d --- /dev/null +++ b/backend/utils/crypto.js @@ -0,0 +1,32 @@ +import crypto from "crypto"; + +const ALGORITHM = "aes-256-cbc"; +const SECRET = process.env.JWT_SECRET || "fallback_secret_key_for_oauth"; + +// Generate a 32-byte key from our JWT_SECRET +const KEY = crypto.createHash("sha256").update(SECRET).digest(); + +export function encrypt(text) { + if (!text) return text; + const iv = crypto.randomBytes(16); + const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv); + let encrypted = cipher.update(text, "utf8", "hex"); + encrypted += cipher.final("hex"); + return `${iv.toString("hex")}:${encrypted}`; +} + +export function decrypt(text) { + if (!text) return text; + try { + const [ivHex, encryptedHex] = text.split(":"); + if (!ivHex || !encryptedHex) return ""; + const iv = Buffer.from(ivHex, "hex"); + const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv); + let decrypted = decipher.update(encryptedHex, "hex", "utf8"); + decrypted += decipher.final("utf8"); + return decrypted; + } catch (error) { + console.error("Decryption failed:", error); + return ""; + } +} diff --git a/frontend/src/components/IssueRecommendations.jsx b/frontend/src/components/IssueRecommendations.jsx index d5a8d838..8d64c89a 100644 --- a/frontend/src/components/IssueRecommendations.jsx +++ b/frontend/src/components/IssueRecommendations.jsx @@ -26,6 +26,26 @@ export default function IssueRecommendations() { const [stack, setStack] = useState([]); const [history, setHistory] = useState(''); const [retryCount, setRetryCount] = useState(0); + const [githubProfile, setGithubProfile] = useState(null); + const [checkingGithub, setCheckingGithub] = useState(true); + + useEffect(() => { + const fetchGithubProfile = async () => { + try { + const res = await apiConfig.get('/auth/github/profile'); + if (res.data && res.data.connected) { + setGithubProfile(res.data); + } else { + setGithubProfile(null); + } + } catch (err) { + console.error('Error fetching github profile:', err); + } finally { + setCheckingGithub(false); + } + }; + fetchGithubProfile(); + }, []); const toggleStack = (tech) => { setStack((prev) => @@ -101,6 +121,64 @@ export default function IssueRecommendations() { signals, stack matching, and contribution history keywords.

+ {/* GitHub OAuth Connection Status */} +
+ {checkingGithub ? ( +
+ + + + + Checking connection... +
+ ) : githubProfile && githubProfile.connected ? ( +
+
+ {githubProfile.login} +
+

+ Connected to GitHub +

+

+ Logged in as @{githubProfile.login} • {githubProfile.public_repos} public repos +

+
+
+ + + + +
+ ) : ( +
+
+

+ Personalize recommendations +

+

+ Connect your GitHub account to bypass rate limits and match issues to your repositories. +

+
+ +
+ )} +
+