Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
# REDIS_URL=rediss://default:password@your-instance.upstash.io:6379
1 change: 1 addition & 0 deletions backend/model/userModel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
188 changes: 174 additions & 14 deletions backend/routes/authRoutes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);

Expand All @@ -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
Expand All @@ -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);

Expand All @@ -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);

Expand Down
19 changes: 19 additions & 0 deletions backend/routes/recommendations.js
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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({
Expand Down
13 changes: 8 additions & 5 deletions backend/services/recommendationService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
{
Expand All @@ -27,7 +29,7 @@ async function fetchIssues() {
sort: "updated",
direction: "desc",
},
headers: getGithubHeaders(),
headers: getGithubHeaders(githubToken),
},
);

Expand Down Expand Up @@ -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)
Expand Down
32 changes: 32 additions & 0 deletions backend/utils/crypto.js
Original file line number Diff line number Diff line change
@@ -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 "";
}
}
Loading
Loading