From 5e6c0ae5fa93a342b6f73519b3687ac8f0cee2e3 Mon Sep 17 00:00:00 2001 From: prerans Date: Wed, 20 May 2026 21:37:42 +0530 Subject: [PATCH 1/6] fixed the git connection --- frontend/package-lock.json | 14 +------------- frontend/src/pages/AccountCenterPage.jsx | 23 +++++++++++++++++++---- frontend/src/pages/SignupPage.jsx | 1 + frontend/src/services/authService.js | 7 +++++++ server/.env.example | 12 ++++++------ server/modules/auth/controller.js | 17 +++++++++++++++++ server/modules/auth/routes.js | 7 +++++++ server/package-lock.json | 2 -- 8 files changed, 58 insertions(+), 25 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fb9c8e6..b45677a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -59,7 +59,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1224,7 +1223,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1277,7 +1275,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1403,7 +1400,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1857,7 +1853,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3000,7 +2995,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3070,7 +3064,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3080,7 +3073,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3100,7 +3092,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -3191,8 +3182,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -3476,7 +3466,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -3601,7 +3590,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/src/pages/AccountCenterPage.jsx b/frontend/src/pages/AccountCenterPage.jsx index b206356..69274c0 100644 --- a/frontend/src/pages/AccountCenterPage.jsx +++ b/frontend/src/pages/AccountCenterPage.jsx @@ -2,6 +2,7 @@ import { useAuth } from "../context/AuthContext"; import { useState, useEffect } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { getProfile, deleteAccount } from "../services/userService"; +import { getGithubConnectUrl } from "../services/authService"; const API_BASE = import.meta.env.VITE_API_BASE_URL; @@ -104,10 +105,24 @@ function GitHubCard({ user }) { const ghAvatar = user?.profile?.avatar; const [msg, setMsg] = useState(""); - const handleConnect = () => { - // Encode current path so backend redirects back here after connect - const redirectPath = encodeURIComponent("/account-center"); - window.location.href = `${API_BASE}/auth/github/connect?redirectPath=${redirectPath}`; + const handleConnect = async () => { + setMsg(""); + try { + const response = await getGithubConnectUrl("/account-center"); + const authUrl = response?.data?.authUrl; + + if (!authUrl) { + throw new Error("Failed to initiate GitHub connect flow"); + } + + window.location.href = authUrl; + } catch (error) { + setMsg( + error?.response?.data?.message || + error?.message || + "Unable to connect GitHub right now. Please try again." + ); + } }; const handleDisconnect = () => { diff --git a/frontend/src/pages/SignupPage.jsx b/frontend/src/pages/SignupPage.jsx index 9ab95b2..5fb2ab2 100644 --- a/frontend/src/pages/SignupPage.jsx +++ b/frontend/src/pages/SignupPage.jsx @@ -4,6 +4,7 @@ import { useAuth } from "../context/AuthContext"; import * as authService from "../services/authService"; const API_BASE = import.meta.env.VITE_API_BASE_URL; +console.log("backend url",API_BASE) export default function SignupPage() { const [step, setStep] = useState(1); diff --git a/frontend/src/services/authService.js b/frontend/src/services/authService.js index 2eb577a..f2d70a2 100644 --- a/frontend/src/services/authService.js +++ b/frontend/src/services/authService.js @@ -29,3 +29,10 @@ export const resendOtp = async (email, purpose) => { const response = await api.post("/auth/resend-otp", { email, purpose }); return response.data; }; + +export const getGithubConnectUrl = async (redirectPath = "/account-center") => { + const response = await api.get("/auth/github/connect-url", { + params: { redirectPath } + }); + return response.data; +}; diff --git a/server/.env.example b/server/.env.example index 9c02cf3..d6230db 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,6 +1,6 @@ PORT=8000 MONGO_URI=mongodb+srv://@cluster0.fshcjhd.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0 -JWT_SECRET=you-secret +JWT_SECRET=ryzni777 JWT_EXPIRES_IN=7d GEMINI_API_KEY=your_gemini_key_here NVIDIA_API_KEY=your_nvidia_key_here @@ -8,14 +8,14 @@ CLIENT_URL=http://localhost:5173 NODE_ENV=development # GitHub OAuth -GITHUB_CLIENT_ID=your_github_client_id -GITHUB_CLIENT_SECRET=your_github_client_secret +GITHUB_CLIENT_ID=Ov23liMtnw5vGfZthPqg +GITHUB_CLIENT_SECRET=4e1c6ea21a15252f41fe9fbef07dc4e8bc0d7197 GITHUB_CALLBACK_URL=http://localhost:8000/api/auth/github/callback -GITHUB_STATE_SECRET=your_optional_dedicated_state_secret +GITHUB_STATE_SECRET=ryzeni77777777777777777777777 SMTP_HOST=smtp.gmail.com SMTP_PORT=587 -SMTP_USER=your email -SMTP_PASS=your pass +SMTP_USER=ezymail0001@gmail.com +SMTP_PASS=lfdw ipec avyv ipom REDIS_URL=redis://localhost:6379 diff --git a/server/modules/auth/controller.js b/server/modules/auth/controller.js index 9143673..9806833 100644 --- a/server/modules/auth/controller.js +++ b/server/modules/auth/controller.js @@ -92,6 +92,23 @@ class AuthController { } } + static async getGithubConnectUrl(req, res, next) { + try { + const { redirectPath } = req.validatedQuery || req.query; + const authUrl = AuthService.getGithubAuthorizationUrl({ + mode: "connect", + userId: req.user?._id, + redirectPath + }); + + return res.status(200).json( + ApiResponse.success("GitHub connect URL generated", { authUrl }) + ); + } catch (error) { + next(error instanceof ApiError ? error : new ApiError(500, error.message)); + } + } + static async githubCallback(req, res, next) { try { const { code, state } = req.validatedQuery || req.query; diff --git a/server/modules/auth/routes.js b/server/modules/auth/routes.js index 2920816..535d782 100644 --- a/server/modules/auth/routes.js +++ b/server/modules/auth/routes.js @@ -34,6 +34,13 @@ router.post("/forgot-password", validate(forgotPasswordSchema), AuthController.f router.post("/reset-password", validate(resetPasswordSchema), AuthController.resetPassword); router.post("/resend-otp", validate(resendOtpSchema), AuthController.resendOtp); router.get("/github/start", validateQuery(githubStartSchema), AuthController.startGithubAuth); +router.get( + "/github/connect-url", + githubConnectRateLimit, + authMiddleware, + validateQuery(githubStartSchema), + AuthController.getGithubConnectUrl +); router.get( "/github/connect", githubConnectRateLimit, diff --git a/server/package-lock.json b/server/package-lock.json index f315bd4..5cb2039 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -624,7 +624,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -2127,7 +2126,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 9c6d81a1195612cd20f6c1fd20d07d52e8bc9d7c Mon Sep 17 00:00:00 2001 From: PRERAN S Date: Wed, 20 May 2026 21:42:12 +0530 Subject: [PATCH 2/6] Replace sensitive data with placeholders in .env.example Updated sensitive information in the .env.example file to placeholders for security. --- server/.env.example | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/server/.env.example b/server/.env.example index d6230db..9c02cf3 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,6 +1,6 @@ PORT=8000 MONGO_URI=mongodb+srv://@cluster0.fshcjhd.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0 -JWT_SECRET=ryzni777 +JWT_SECRET=you-secret JWT_EXPIRES_IN=7d GEMINI_API_KEY=your_gemini_key_here NVIDIA_API_KEY=your_nvidia_key_here @@ -8,14 +8,14 @@ CLIENT_URL=http://localhost:5173 NODE_ENV=development # GitHub OAuth -GITHUB_CLIENT_ID=Ov23liMtnw5vGfZthPqg -GITHUB_CLIENT_SECRET=4e1c6ea21a15252f41fe9fbef07dc4e8bc0d7197 +GITHUB_CLIENT_ID=your_github_client_id +GITHUB_CLIENT_SECRET=your_github_client_secret GITHUB_CALLBACK_URL=http://localhost:8000/api/auth/github/callback -GITHUB_STATE_SECRET=ryzeni77777777777777777777777 +GITHUB_STATE_SECRET=your_optional_dedicated_state_secret SMTP_HOST=smtp.gmail.com SMTP_PORT=587 -SMTP_USER=ezymail0001@gmail.com -SMTP_PASS=lfdw ipec avyv ipom +SMTP_USER=your email +SMTP_PASS=your pass REDIS_URL=redis://localhost:6379 From f35668bf3c94d8fd686773e36b29f619978f3811 Mon Sep 17 00:00:00 2001 From: prerans Date: Wed, 20 May 2026 21:55:04 +0530 Subject: [PATCH 3/6] removed console log :) --- frontend/src/pages/SignupPage.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/SignupPage.jsx b/frontend/src/pages/SignupPage.jsx index 5fb2ab2..032e1df 100644 --- a/frontend/src/pages/SignupPage.jsx +++ b/frontend/src/pages/SignupPage.jsx @@ -4,7 +4,7 @@ import { useAuth } from "../context/AuthContext"; import * as authService from "../services/authService"; const API_BASE = import.meta.env.VITE_API_BASE_URL; -console.log("backend url",API_BASE) + export default function SignupPage() { const [step, setStep] = useState(1); From 687516e5a963def5b4e5f0e9cecc31e8b19dc282 Mon Sep 17 00:00:00 2001 From: prerans Date: Wed, 20 May 2026 23:07:54 +0530 Subject: [PATCH 4/6] all changes done --- frontend/src/hooks/useCodeforces.js | 24 ++++++---- frontend/src/pages/AccountCenterPage.jsx | 32 +++++++------ frontend/src/pages/GitHubIntelligencePage.jsx | 2 +- frontend/src/services/authService.js | 2 +- gitubAuthCrationJourney.md | 8 ++-- server/modules/auth/controller.js | 22 +++------ server/modules/auth/routes.js | 13 ++--- server/modules/auth/service.js | 48 +++++++++++++++++-- server/modules/auth/validation.js | 16 ++++++- 9 files changed, 106 insertions(+), 61 deletions(-) diff --git a/frontend/src/hooks/useCodeforces.js b/frontend/src/hooks/useCodeforces.js index c715ab5..a6d43b8 100644 --- a/frontend/src/hooks/useCodeforces.js +++ b/frontend/src/hooks/useCodeforces.js @@ -30,22 +30,24 @@ export const useCodeforces = (dashboardOnly = false) => { const [connectLoading, setConnectLoading] = useState(false); const [connectError, setConnectError] = useState(null); + const unwrapApiData = (response) => response?.data?.data ?? response?.data ?? null; + const fetchAll = useCallback(async () => { setLoading(true); setError(null); try { if (dashboardOnly) { - const { data } = await cfGetDashboardSummary(); - setDashboardSummary(data.data); + const response = await cfGetDashboardSummary(); + setDashboardSummary(unwrapApiData(response)); } else { const [profileRes, ratingRes, submissionsRes] = await Promise.all([ cfGetProfile(), cfGetRatingHistory(), cfGetSubmissions(50), ]); - setProfile(profileRes.data.data); - setRatingHistory(ratingRes.data.data || []); - setSubmissions(submissionsRes.data.data || []); + setProfile(unwrapApiData(profileRes)); + setRatingHistory(unwrapApiData(ratingRes) || []); + setSubmissions(unwrapApiData(submissionsRes) || []); } } catch (err) { setError(err.response?.data?.message || "Failed to load Codeforces data"); @@ -63,10 +65,11 @@ export const useCodeforces = (dashboardOnly = false) => { setConnectLoading(true); setConnectError(null); try { - const { data } = await cfInitiateConnection(handle); - setVerificationCode(data.data.verificationCode); + const response = await cfInitiateConnection(handle); + const payload = unwrapApiData(response); + setVerificationCode(payload?.verificationCode); setPendingHandle(handle); - return data.data; + return payload; } catch (err) { const msg = err.response?.data?.message || "Connection failed"; setConnectError(msg); @@ -82,11 +85,12 @@ export const useCodeforces = (dashboardOnly = false) => { setConnectLoading(true); setConnectError(null); try { - const { data } = await cfVerifyConnection(pendingHandle); + const response = await cfVerifyConnection(pendingHandle); + const payload = unwrapApiData(response); setVerificationCode(null); setPendingHandle(null); await fetchAll(); - return data.data; + return payload; } catch (err) { const msg = err.response?.data?.message || "Verification failed"; setConnectError(msg); diff --git a/frontend/src/pages/AccountCenterPage.jsx b/frontend/src/pages/AccountCenterPage.jsx index 69274c0..5be2394 100644 --- a/frontend/src/pages/AccountCenterPage.jsx +++ b/frontend/src/pages/AccountCenterPage.jsx @@ -4,7 +4,7 @@ import { Link, useSearchParams } from "react-router-dom"; import { getProfile, deleteAccount } from "../services/userService"; import { getGithubConnectUrl } from "../services/authService"; -const API_BASE = import.meta.env.VITE_API_BASE_URL; + // ── Helpers ────────────────────────────────────────────────────────────────── @@ -22,9 +22,6 @@ const RANK_COLORS = { unrated: "text-gray-400", }; -const rankColor = (rank = "") => - RANK_COLORS[(rank || "").toLowerCase()] || "text-black"; - // ── Sub-components ──────────────────────────────────────────────────────────── function SectionLabel({ text }) { @@ -104,12 +101,14 @@ function GitHubCard({ user }) { const ghUsername = ghIdentity?.username || user?.handles?.github; const ghAvatar = user?.profile?.avatar; const [msg, setMsg] = useState(""); + const [connecting, setConnecting] = useState(false); const handleConnect = async () => { setMsg(""); + setConnecting(true); try { const response = await getGithubConnectUrl("/account-center"); - const authUrl = response?.data?.authUrl; + const authUrl = response?.authUrl; if (!authUrl) { throw new Error("Failed to initiate GitHub connect flow"); @@ -122,6 +121,8 @@ function GitHubCard({ user }) { error?.message || "Unable to connect GitHub right now. Please try again." ); + } finally { + setConnecting(false); } }; @@ -211,12 +212,13 @@ function GitHubCard({ user }) { )} @@ -288,7 +290,7 @@ function DangerZone({ onLogout }) { try { await deleteAccount(); onLogout(); // Logs the user out and redirects to login - } catch (err) { + } catch { alert("Failed to delete account. Please try again later."); setLoading(false); setConfirm(false); @@ -344,15 +346,17 @@ function DangerZone({ onLogout }) { export default function AccountCenterPage() { const { user, setUser, logout } = useAuth(); const [searchParams] = useSearchParams(); - const [banner, setBanner] = useState(""); + const githubStatus = searchParams.get("githubStatus"); + const githubUsername = searchParams.get("githubUsername"); + const [banner, setBanner] = useState( + githubStatus === "connected" + ? `GitHub account @${githubUsername || "connected"} linked successfully!` + : "" + ); // Backend redirects here after GitHub connect: /account-center?githubStatus=connected useEffect(() => { - const status = searchParams.get("githubStatus"); - const username = searchParams.get("githubUsername"); - - if (status === "connected") { - setBanner(`GitHub account @${username || "connected"} linked successfully!`); + if (githubStatus === "connected") { // Refresh user profile so the card shows the new GitHub identity getProfile() .then((res) => setUser(res.data)) @@ -360,7 +364,7 @@ export default function AccountCenterPage() { // Clean URL window.history.replaceState({}, "", "/account-center"); } - }, [searchParams]); + }, [githubStatus, setUser]); return (
diff --git a/frontend/src/pages/GitHubIntelligencePage.jsx b/frontend/src/pages/GitHubIntelligencePage.jsx index f013d15..b332315 100644 --- a/frontend/src/pages/GitHubIntelligencePage.jsx +++ b/frontend/src/pages/GitHubIntelligencePage.jsx @@ -153,7 +153,7 @@ export default function GitHubIntelligencePage() { useEffect(() => { getGitHubDashboard() - .then(res => setData(res.data.data)) + .then((res) => setData(res?.data?.data ?? res?.data ?? null)) .catch(err => setError(err.response?.data?.message || err.message)) .finally(() => setLoading(false)); }, []); diff --git a/frontend/src/services/authService.js b/frontend/src/services/authService.js index f2d70a2..20923a2 100644 --- a/frontend/src/services/authService.js +++ b/frontend/src/services/authService.js @@ -34,5 +34,5 @@ export const getGithubConnectUrl = async (redirectPath = "/account-center") => { const response = await api.get("/auth/github/connect-url", { params: { redirectPath } }); - return response.data; + return response?.data?.data ?? response?.data; }; diff --git a/gitubAuthCrationJourney.md b/gitubAuthCrationJourney.md index 2658e95..3c13836 100644 --- a/gitubAuthCrationJourney.md +++ b/gitubAuthCrationJourney.md @@ -82,7 +82,7 @@ Implemented end-to-end logic: Added controller methods: - `startGithubAuth` -- `startGithubConnect` +- `getGithubConnectUrl` - `githubCallback` Behavior: @@ -99,10 +99,10 @@ Behavior: Added endpoints: - `GET /api/auth/github/start` (public login/signup via GitHub) -- `GET /api/auth/github/connect` (protected account-link flow) +- `GET /api/auth/github/connect-url` (protected account-link flow, returns OAuth URL) - `GET /api/auth/github/callback` (GitHub callback) -`/github/connect` is protected using existing `authMiddleware`. +`/github/connect-url` is protected using existing `authMiddleware`. --- @@ -175,7 +175,7 @@ Added GitHub id-based lookup + sparse unique index + explicit connect mode flow. 1. Frontend redirects user to: - `GET /api/auth/github/start` for GitHub signup/login - - `GET /api/auth/github/connect` for linking from signed-in account + - `GET /api/auth/github/connect-url` for linking from signed-in account (frontend then redirects to returned GitHub URL) 2. Backend redirects user to GitHub OAuth consent. 3. GitHub redirects back to: - `GET /api/auth/github/callback?code=...&state=...` diff --git a/server/modules/auth/controller.js b/server/modules/auth/controller.js index 9806833..275a014 100644 --- a/server/modules/auth/controller.js +++ b/server/modules/auth/controller.js @@ -77,27 +77,17 @@ class AuthController { } } - static async startGithubConnect(req, res, next) { - try { - const { redirectPath } = req.validatedQuery || req.query; - const authUrl = AuthService.getGithubAuthorizationUrl({ - mode: "connect", - userId: req.user?._id, - redirectPath - }); - - return res.redirect(authUrl); - } catch (error) { - next(error instanceof ApiError ? error : new ApiError(500, error.message)); - } - } - static async getGithubConnectUrl(req, res, next) { try { + const authenticatedUserId = req.user?._id; + if (!authenticatedUserId) { + throw new ApiError(401, "Authentication required for GitHub connect"); + } + const { redirectPath } = req.validatedQuery || req.query; const authUrl = AuthService.getGithubAuthorizationUrl({ mode: "connect", - userId: req.user?._id, + userId: authenticatedUserId, redirectPath }); diff --git a/server/modules/auth/routes.js b/server/modules/auth/routes.js index 535d782..cbb005e 100644 --- a/server/modules/auth/routes.js +++ b/server/modules/auth/routes.js @@ -7,6 +7,7 @@ import { resetPasswordSchema, resendOtpSchema, githubStartSchema, + githubConnectSchema, githubCallbackSchema, validate, validateQuery @@ -19,6 +20,7 @@ const router = Router(); const githubConnectRateLimit = rateLimit({ windowMs: 60 * 1000, max: 20, + keyGenerator: (req) => String(req.user?._id || req.ip), standardHeaders: true, legacyHeaders: false, message: { @@ -36,17 +38,10 @@ router.post("/resend-otp", validate(resendOtpSchema), AuthController.resendOtp); router.get("/github/start", validateQuery(githubStartSchema), AuthController.startGithubAuth); router.get( "/github/connect-url", - githubConnectRateLimit, authMiddleware, - validateQuery(githubStartSchema), - AuthController.getGithubConnectUrl -); -router.get( - "/github/connect", githubConnectRateLimit, - authMiddleware, - validateQuery(githubStartSchema), - AuthController.startGithubConnect + validateQuery(githubConnectSchema), + AuthController.getGithubConnectUrl ); router.get("/github/callback", validateQuery(githubCallbackSchema), AuthController.githubCallback); diff --git a/server/modules/auth/service.js b/server/modules/auth/service.js index 2e71973..2dff625 100644 --- a/server/modules/auth/service.js +++ b/server/modules/auth/service.js @@ -13,6 +13,17 @@ const GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; const GITHUB_USER_URL = "https://api.github.com/user"; const GITHUB_USER_EMAILS_URL = "https://api.github.com/user/emails"; const GITHUB_SCOPE = "read:user user:email"; +const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; +const usedOAuthStateNonces = new Map(); + +const cleanupExpiredNonces = () => { + const now = Date.now(); + for (const [nonce, expiresAt] of usedOAuthStateNonces.entries()) { + if (expiresAt <= now) { + usedOAuthStateNonces.delete(nonce); + } + } +}; class AuthService { static async register({ name, email, password }) { @@ -237,11 +248,11 @@ class AuthService { this.#assertGithubOAuthConfig(); const safeMode = mode === "connect" ? "connect" : "login"; - const defaultRedirectPath = safeMode === "connect" ? "/account-center" : "/login"; + const sanitizedRedirectPath = this.#sanitizeRedirectPath(redirectPath, safeMode); const state = this.#buildGithubStateToken({ mode: safeMode, userId, - redirectPath: redirectPath || defaultRedirectPath + redirectPath: sanitizedRedirectPath }); const query = new URLSearchParams({ @@ -325,18 +336,45 @@ class AuthService { static #buildGithubStateToken(payload) { const secret = process.env.GITHUB_STATE_SECRET || process.env.JWT_SECRET; - return jwt.sign(payload, secret, { expiresIn: "10m" }); + const nonce = crypto.randomUUID(); + return jwt.sign({ ...payload, nonce }, secret, { expiresIn: "10m" }); } static #verifyGithubStateToken(token) { try { const secret = process.env.GITHUB_STATE_SECRET || process.env.JWT_SECRET; - return jwt.verify(token, secret); + const decoded = jwt.verify(token, secret); + + if (!decoded?.nonce) { + throw new ApiError(400, "Invalid GitHub OAuth state nonce"); + } + + cleanupExpiredNonces(); + if (usedOAuthStateNonces.has(decoded.nonce)) { + throw new ApiError(400, "GitHub OAuth state was already used"); + } + + usedOAuthStateNonces.set(decoded.nonce, Date.now() + OAUTH_STATE_TTL_MS); + return decoded; } catch { throw new ApiError(400, "Invalid or expired GitHub OAuth state"); } } + static #sanitizeRedirectPath(path, mode) { + const defaultPath = mode === "connect" ? "/account-center" : "/login"; + if (!path || typeof path !== "string") { + return defaultPath; + } + + const trimmed = path.trim(); + if (!trimmed || !trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("://")) { + return defaultPath; + } + + return trimmed; + } + static async #exchangeGithubCodeForToken(code) { try { const response = await axios.post( @@ -508,7 +546,7 @@ class AuthService { static #buildFrontendRedirectUrl(path, query = {}, fragment = {}) { const baseUrl = process.env.CLIENT_URL || "http://localhost:5173"; - const redirect = new URL(path || "/login", baseUrl); + const redirect = new URL(this.#sanitizeRedirectPath(path, "login"), baseUrl); Object.entries(query).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== "") { diff --git a/server/modules/auth/validation.js b/server/modules/auth/validation.js index e43e7e4..2b63888 100644 --- a/server/modules/auth/validation.js +++ b/server/modules/auth/validation.js @@ -1,5 +1,15 @@ import { z } from "zod"; +const safeRedirectPathSchema = z + .string() + .trim() + .max(200, "redirectPath is too long") + .regex(/^\/[^\\]*$/, "redirectPath must be a safe relative path") + .refine((value) => !value.startsWith("//"), { + message: "redirectPath cannot start with //" + }) + .optional(); + export const registerSchema = z.object({ name: z.string().min(2, "Name must be at least 2 characters"), email: z.string().email("Invalid email address"), @@ -32,7 +42,11 @@ export const resendOtpSchema = z.object({ }); export const githubStartSchema = z.object({ - redirectPath: z.string().optional() + redirectPath: safeRedirectPathSchema +}); + +export const githubConnectSchema = z.object({ + redirectPath: safeRedirectPathSchema }); export const githubCallbackSchema = z.object({ From 709ce77800889183b168b164a1c3dd39d128c89a Mon Sep 17 00:00:00 2001 From: prerans Date: Sun, 24 May 2026 14:18:33 +0530 Subject: [PATCH 5/6] Fix Codeforces unwrap and GitHub OAuth nonce storage --- frontend/src/hooks/useCodeforces.js | 20 +++++- server/config/redis.js | 32 ++++++++++ server/modules/auth/controller.js | 6 +- server/modules/auth/service.js | 89 ++++++++++++++++++++------ server/package-lock.json | 98 +++++++++++++++++++++++++++++ server/package.json | 1 + 6 files changed, 222 insertions(+), 24 deletions(-) create mode 100644 server/config/redis.js diff --git a/frontend/src/hooks/useCodeforces.js b/frontend/src/hooks/useCodeforces.js index a6d43b8..d91592c 100644 --- a/frontend/src/hooks/useCodeforces.js +++ b/frontend/src/hooks/useCodeforces.js @@ -30,7 +30,25 @@ export const useCodeforces = (dashboardOnly = false) => { const [connectLoading, setConnectLoading] = useState(false); const [connectError, setConnectError] = useState(null); - const unwrapApiData = (response) => response?.data?.data ?? response?.data ?? null; + const unwrapApiData = (response) => { + if (!response) { + return null; + } + + if ( + response.data && + typeof response.data === "object" && + Object.prototype.hasOwnProperty.call(response.data, "data") + ) { + return response.data.data; + } + + if (Object.prototype.hasOwnProperty.call(response, "data")) { + return response.data; + } + + return null; + }; const fetchAll = useCallback(async () => { setLoading(true); diff --git a/server/config/redis.js b/server/config/redis.js new file mode 100644 index 0000000..adcc8b5 --- /dev/null +++ b/server/config/redis.js @@ -0,0 +1,32 @@ +import { createClient } from "redis"; + +let redisClient; +let connectPromise; + +export const getRedisClient = async () => { + const redisUrl = process.env.REDIS_URL; + + if (!redisUrl) { + throw new Error("REDIS_URL is not configured"); + } + + if (!redisClient) { + redisClient = createClient({ url: redisUrl }); + redisClient.on("error", (error) => { + console.error("Redis client error:", error); + }); + } + + if (!redisClient.isOpen) { + if (!connectPromise) { + connectPromise = redisClient.connect().catch((error) => { + connectPromise = null; + throw error; + }); + } + + await connectPromise; + } + + return redisClient; +}; \ No newline at end of file diff --git a/server/modules/auth/controller.js b/server/modules/auth/controller.js index 275a014..07d481f 100644 --- a/server/modules/auth/controller.js +++ b/server/modules/auth/controller.js @@ -63,10 +63,10 @@ class AuthController { } } - static async startGithubAuth(req, res, next) { + static async startGithubAuth(req, res, next) { try { const { redirectPath } = req.validatedQuery || req.query; - const authUrl = AuthService.getGithubAuthorizationUrl({ + const authUrl = await AuthService.getGithubAuthorizationUrl({ mode: "login", redirectPath }); @@ -85,7 +85,7 @@ class AuthController { } const { redirectPath } = req.validatedQuery || req.query; - const authUrl = AuthService.getGithubAuthorizationUrl({ + const authUrl = await AuthService.getGithubAuthorizationUrl({ mode: "connect", userId: authenticatedUserId, redirectPath diff --git a/server/modules/auth/service.js b/server/modules/auth/service.js index 2dff625..90309d3 100644 --- a/server/modules/auth/service.js +++ b/server/modules/auth/service.js @@ -7,23 +7,24 @@ import AuthRepository from "./repository.js"; import { generateAccessToken } from "../../utils/tokenHelper.js"; import { generateOTP } from "../../utils/otpHelper.js"; import { sendVerificationOTP, sendPasswordResetOTP } from "../../utils/emailService.js"; +import { getRedisClient } from "../../config/redis.js"; const GITHUB_OAUTH_URL = "https://github.com/login/oauth/authorize"; const GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; const GITHUB_USER_URL = "https://api.github.com/user"; const GITHUB_USER_EMAILS_URL = "https://api.github.com/user/emails"; const GITHUB_SCOPE = "read:user user:email"; -const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; -const usedOAuthStateNonces = new Map(); - -const cleanupExpiredNonces = () => { - const now = Date.now(); - for (const [nonce, expiresAt] of usedOAuthStateNonces.entries()) { - if (expiresAt <= now) { - usedOAuthStateNonces.delete(nonce); - } - } -}; +const OAUTH_STATE_TTL_SECONDS = 10 * 60; +const GITHUB_OAUTH_STATE_NONCE_PREFIX = "oauth:github:state:"; +const GITHUB_OAUTH_STATE_CONSUME_SCRIPT = ` + local value = redis.call("GET", KEYS[1]) + if not value then + return nil + end + + redis.call("DEL", KEYS[1]) + return value +`; class AuthService { static async register({ name, email, password }) { @@ -244,12 +245,12 @@ class AuthService { }; } - static getGithubAuthorizationUrl({ mode = "login", userId = null, redirectPath }) { + static async getGithubAuthorizationUrl({ mode = "login", userId = null, redirectPath }) { this.#assertGithubOAuthConfig(); const safeMode = mode === "connect" ? "connect" : "login"; const sanitizedRedirectPath = this.#sanitizeRedirectPath(redirectPath, safeMode); - const state = this.#buildGithubStateToken({ + const state = await this.#buildGithubStateToken({ mode: safeMode, userId, redirectPath: sanitizedRedirectPath @@ -334,13 +335,16 @@ class AuthService { } } - static #buildGithubStateToken(payload) { + static async #buildGithubStateToken(payload) { const secret = process.env.GITHUB_STATE_SECRET || process.env.JWT_SECRET; const nonce = crypto.randomUUID(); - return jwt.sign({ ...payload, nonce }, secret, { expiresIn: "10m" }); + const token = jwt.sign({ ...payload, nonce }, secret, { expiresIn: "10m" }); + + await this.#storeGithubStateNonce(nonce); + return token; } - static #verifyGithubStateToken(token) { + static async #verifyGithubStateToken(token) { try { const secret = process.env.GITHUB_STATE_SECRET || process.env.JWT_SECRET; const decoded = jwt.verify(token, secret); @@ -349,18 +353,63 @@ class AuthService { throw new ApiError(400, "Invalid GitHub OAuth state nonce"); } - cleanupExpiredNonces(); - if (usedOAuthStateNonces.has(decoded.nonce)) { + const consumedNonce = await this.#consumeGithubStateNonce(decoded.nonce); + if (!consumedNonce) { throw new ApiError(400, "GitHub OAuth state was already used"); } - usedOAuthStateNonces.set(decoded.nonce, Date.now() + OAUTH_STATE_TTL_MS); return decoded; - } catch { + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + throw new ApiError(400, "Invalid or expired GitHub OAuth state"); } } + static async #storeGithubStateNonce(nonce) { + try { + const client = await getRedisClient(); + const key = `${GITHUB_OAUTH_STATE_NONCE_PREFIX}${nonce}`; + const result = await client.set(key, "1", { + NX: true, + EX: OAUTH_STATE_TTL_SECONDS + }); + + if (result !== "OK") { + throw new Error("Failed to reserve GitHub OAuth state nonce"); + } + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + + throw new ApiError(503, "Unable to reserve GitHub OAuth state"); + } + } + + static async #consumeGithubStateNonce(nonce) { + try { + const client = await getRedisClient(); + const key = `${GITHUB_OAUTH_STATE_NONCE_PREFIX}${nonce}`; + + if (typeof client.getDel === "function") { + return await client.getDel(key); + } + + return await client.eval(GITHUB_OAUTH_STATE_CONSUME_SCRIPT, { + keys: [key] + }); + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + + throw new ApiError(503, "Unable to verify GitHub OAuth state"); + } + } + static #sanitizeRedirectPath(path, mode) { const defaultPath = mode === "connect" ? "/account-center" : "/login"; if (!path || typeof path !== "string") { diff --git a/server/package-lock.json b/server/package-lock.json index 5cb2039..6fff900 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -22,6 +22,7 @@ "nodemailer": "^8.0.4", "nodemon": "^3.1.14", "openai": "^6.33.0", + "redis": "^5.12.1", "zod": "^4.3.6" } }, @@ -130,6 +131,78 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, "node_modules/@types/node": { "version": "25.5.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", @@ -396,6 +469,15 @@ "fsevents": "~2.3.2" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1743,6 +1825,22 @@ "node": ">=8.10.0" } }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", diff --git a/server/package.json b/server/package.json index 951eac1..f3f6e8e 100644 --- a/server/package.json +++ b/server/package.json @@ -25,6 +25,7 @@ "nodemailer": "^8.0.4", "nodemon": "^3.1.14", "openai": "^6.33.0", + "redis": "^5.12.1", "zod": "^4.3.6" } } From 4fab2722de0c97013570f345a4e852dcd363a6db Mon Sep 17 00:00:00 2001 From: prerans Date: Sun, 24 May 2026 14:41:22 +0530 Subject: [PATCH 6/6] Fix Redis reconnect promise lifecycle --- server/config/redis.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/config/redis.js b/server/config/redis.js index adcc8b5..214dd42 100644 --- a/server/config/redis.js +++ b/server/config/redis.js @@ -19,9 +19,8 @@ export const getRedisClient = async () => { if (!redisClient.isOpen) { if (!connectPromise) { - connectPromise = redisClient.connect().catch((error) => { + connectPromise = redisClient.connect().finally(() => { connectPromise = null; - throw error; }); }