From 1ce50fd780286c013c414b1a818d6f2d8715ebbf Mon Sep 17 00:00:00 2001 From: jaykolate Date: Mon, 13 Jul 2026 22:34:01 +0530 Subject: [PATCH 1/4] feat: implement refresh token authentication --- backend/.env.example | 1 + backend/src/controllers/auth.controller.js | 90 +++++++++++- backend/src/lib/utils.js | 10 ++ backend/src/models/user.model.js | 6 + backend/src/routes/auth.route.js | 2 + backend/test/auth/refresh_test_runner.js | 160 +++++++++++++++++++++ 6 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 backend/test/auth/refresh_test_runner.js diff --git a/backend/.env.example b/backend/.env.example index e4cef80d..8499ebac 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -7,6 +7,7 @@ NODE_ENV=development # Authentication JWT_SECRET=your_jwt_secret +REFRESH_TOKEN_SECRET=your_refresh_token_secret # Frontend URL CLIENT_URL=http://localhost:5173 diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index ce9a7cf5..eae24bf0 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -1,9 +1,10 @@ -import { generateToken } from "../lib/utils.js"; +import { generateToken, generateRefreshToken } from "../lib/utils.js"; import User from "../models/user.model.js"; import bcrypt from "bcryptjs"; import cloudinary from "../lib/cloudinary.js"; import { sendWelcomeEmail, sendOtpEmail, sendVerificationOtpEmail } from "../lib/sendEmail.js"; import crypto from "crypto"; +import jwt from "jsonwebtoken"; //signup // Get fullName, email, password from req.body @@ -196,6 +197,11 @@ export const verifyEmail = async (req, res) => { user.emailVerificationOtp = null; user.emailVerificationOtpExpiry = null; + const token = generateToken(user._id); + const refreshToken = generateRefreshToken(user._id); + const refreshTokenHash = crypto.createHash("sha256").update(refreshToken).digest("hex"); + user.refreshTokenHash = refreshTokenHash; + await user.save(); // Send welcome email asynchronously @@ -213,7 +219,13 @@ export const verifyEmail = async (req, res) => { } }); - const token = generateToken(user._id); + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); + return res.status(200).json({ _id: user._id, fullName: user.fullName, @@ -302,6 +314,18 @@ export const login = async (req, res) => { } const token = generateToken(user._id); + const refreshToken = generateRefreshToken(user._id); + const refreshTokenHash = crypto.createHash("sha256").update(refreshToken).digest("hex"); + + user.refreshTokenHash = refreshTokenHash; + await user.save(); + + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + maxAge: 7 * 24 * 60 * 60 * 1000, + }); res.status(200).json({ _id: user._id, @@ -327,12 +351,24 @@ export const login = async (req, res) => { // Clear JWT cookie by setting empty value // Set cookie expiration to 0 // Send success response -export const logout = (req, res) => { +export const logout = async (req, res) => { try { + const refreshToken = req.cookies?.refreshToken; + if (refreshToken) { + const hash = crypto.createHash("sha256").update(refreshToken).digest("hex"); + await User.findOneAndUpdate({ refreshTokenHash: hash }, { refreshTokenHash: null }); + } + res.cookie("jwt", "", { maxAge: 0, }); + res.clearCookie("refreshToken", { + httpOnly: true, + sameSite: "strict", + secure: process.env.NODE_ENV === "production", + }); + res.status(200).json({ message: "Logged out successfully", }); @@ -348,6 +384,54 @@ export const logout = (req, res) => { } }; +// refresh token +// Read refresh token from HTTP-only cookie +// Verify JWT +// Verify stored refresh token matches the user +// Generate and return a new access token +export const refresh = async (req, res) => { + try { + const refreshToken = req.cookies?.refreshToken; + if (!refreshToken) { + return res.status(400).json({ + message: "Refresh token is missing", + }); + } + + let decoded; + try { + decoded = jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET); + } catch (err) { + return res.status(401).json({ + message: "Invalid or expired refresh token", + }); + } + + const hash = crypto.createHash("sha256").update(refreshToken).digest("hex"); + const user = await User.findOne({ + _id: decoded.userId, + refreshTokenHash: hash, + }); + + if (!user) { + return res.status(401).json({ + message: "Invalid or expired refresh token", + }); + } + + const newAccessToken = generateToken(user._id); + + return res.status(200).json({ + token: newAccessToken, + }); + } catch (error) { + console.error("Refresh error:", error.message); + return res.status(500).json({ + message: "Internal Server Error", + }); + } +}; + //update profile // Get profilePic from req.body // Get authenticated user ID from req.user diff --git a/backend/src/lib/utils.js b/backend/src/lib/utils.js index ddb137e0..4d989fbe 100644 --- a/backend/src/lib/utils.js +++ b/backend/src/lib/utils.js @@ -6,6 +6,16 @@ export const generateToken = (userId) => { } return jwt.sign({ userId }, process.env.JWT_SECRET, { + expiresIn: "15m", + }); +}; + +export const generateRefreshToken = (userId) => { + if (!process.env.REFRESH_TOKEN_SECRET) { + throw new Error("REFRESH_TOKEN_SECRET is not defined"); + } + + return jwt.sign({ userId }, process.env.REFRESH_TOKEN_SECRET, { expiresIn: "7d", }); }; \ No newline at end of file diff --git a/backend/src/models/user.model.js b/backend/src/models/user.model.js index 013c88db..8f66b342 100644 --- a/backend/src/models/user.model.js +++ b/backend/src/models/user.model.js @@ -134,6 +134,12 @@ const userSchema = new mongoose.Schema( type: Date, default: null, }, + + refreshTokenHash: { + type: String, + select: false, + default: null, + }, }, { timestamps: true }, ); diff --git a/backend/src/routes/auth.route.js b/backend/src/routes/auth.route.js index e820d376..ad2be493 100644 --- a/backend/src/routes/auth.route.js +++ b/backend/src/routes/auth.route.js @@ -13,6 +13,7 @@ import { getSecurityQuestions, sendOtp, verifyOtp, + refresh, } from "../controllers/auth.controller.js"; import { protectRoute } from "../middleware/auth.middleware.js"; @@ -25,6 +26,7 @@ router.post("/verify-email", otpRateLimiter, verifyEmail); router.post("/resend-verification-otp", otpRateLimiter, resendVerificationOtp); router.post("/login", login); router.post("/logout", logout); +router.post("/refresh", refresh); router.get("/check", protectRoute, checkAuth); router.put("/update-profile", protectRoute, updateProfile); router.put("/security-questions", protectRoute, setupSecurityQuestions); diff --git a/backend/test/auth/refresh_test_runner.js b/backend/test/auth/refresh_test_runner.js new file mode 100644 index 00000000..e9eba572 --- /dev/null +++ b/backend/test/auth/refresh_test_runner.js @@ -0,0 +1,160 @@ +process.env.NODE_ENV = "test"; +process.env.MONGODB_URI = "mongodb://127.0.0.1:27017/refresh_test_db"; +process.env.JWT_SECRET = "test-jwt-secret-key-do-not-use-in-production"; +process.env.REFRESH_TOKEN_SECRET = "test-refresh-token-secret-key-do-not-use-in-production"; + +import request from "supertest"; +import mongoose from "mongoose"; +import bcrypt from "bcryptjs"; + +// Dynamically import app and models after env variables are set +const { app } = await import("../../src/index.js"); +const { default: User } = await import("../../src/models/user.model.js"); + +const runTests = async () => { + console.log("Connecting to test MongoDB..."); + if (mongoose.connection.readyState === 0) { + await mongoose.connect(process.env.MONGODB_URI); + } + console.log("Connected. Cleaning collections..."); + await User.deleteMany({}); + + // 1. Create a verified user for testing + const password = "password123"; + const hashedPassword = await bcrypt.hash(password, 10); + const user = await User.create({ + fullName: "Test User", + email: "test@example.com", + password: hashedPassword, + isVerified: true, + securityQuestions: [ + { question: "What is your pet name?", answer: await bcrypt.hash("fluffy", 10) }, + { question: "What is your birth city?", answer: await bcrypt.hash("seattle", 10) }, + { question: "What is your favorite color?", answer: await bcrypt.hash("blue", 10) }, + ], + }); + + console.log("Created test user: test@example.com"); + + // 2. Perform Login + console.log("Testing POST /api/auth/login..."); + const loginRes = await request(app) + .post("/api/auth/login") + .send({ email: "test@example.com", password }); + + if (loginRes.status !== 200) { + throw new Error(`Login failed: ${JSON.stringify(loginRes.body)}`); + } + + const { token } = loginRes.body; + if (!token) { + throw new Error("Access token missing from login response"); + } + console.log("✓ Login returned 200 and access token"); + + // Verify cookie is set + const cookies = loginRes.headers["set-cookie"] || []; + const refreshCookie = cookies.find(c => c.startsWith("refreshToken=")); + if (!refreshCookie) { + throw new Error("refreshToken cookie not set in login response headers"); + } + console.log("✓ refreshToken cookie is set in response headers"); + + // Verify hash is saved in DB + const userInDb = await User.findById(user._id).select("+refreshTokenHash"); + if (!userInDb.refreshTokenHash) { + throw new Error("refreshTokenHash is not stored in MongoDB"); + } + console.log("✓ refreshTokenHash successfully stored in MongoDB"); + + // 3. Test POST /api/auth/refresh + console.log("Testing POST /api/auth/refresh with valid token..."); + const refreshRes = await request(app) + .post("/api/auth/refresh") + .set("Cookie", [refreshCookie]); + + if (refreshRes.status !== 200) { + throw new Error(`Refresh failed: ${JSON.stringify(refreshRes.body)}`); + } + + const newAccessToken = refreshRes.body.token; + if (!newAccessToken) { + throw new Error("New access token missing from refresh response"); + } + console.log("✓ Refresh returned 200 and new access token"); + + // 4. Test POST /api/auth/refresh with missing token + console.log("Testing POST /api/auth/refresh with missing token..."); + const missingRes = await request(app) + .post("/api/auth/refresh"); + + if (missingRes.status !== 400) { + throw new Error(`Expected 400 for missing refresh token, got ${missingRes.status}`); + } + console.log("✓ Refresh with missing token returned 400"); + + // 5. Test POST /api/auth/refresh with invalid token + console.log("Testing POST /api/auth/refresh with invalid token..."); + const invalidRes = await request(app) + .post("/api/auth/refresh") + .set("Cookie", ["refreshToken=invalid_token_value_here"]); + + if (invalidRes.status !== 401) { + throw new Error(`Expected 401 for invalid refresh token, got ${invalidRes.status}`); + } + console.log("✓ Refresh with invalid token returned 401"); + + // 6. Test POST /api/auth/logout + console.log("Testing POST /api/auth/logout..."); + const logoutRes = await request(app) + .post("/api/auth/logout") + .set("Cookie", [refreshCookie]); + + if (logoutRes.status !== 200) { + throw new Error(`Logout failed: ${JSON.stringify(logoutRes.body)}`); + } + console.log("✓ Logout returned 200"); + + // Verify cookie is cleared in response headers + const clearedCookies = logoutRes.headers["set-cookie"] || []; + const clearedRefreshCookie = clearedCookies.find(c => c.startsWith("refreshToken=")); + if (!clearedRefreshCookie) { + throw new Error("refreshToken cookie set-cookie header not found in logout response"); + } + const cookieLower = clearedRefreshCookie.toLowerCase(); + if (!cookieLower.includes("max-age=0") && !cookieLower.includes("expires=")) { + throw new Error(`refreshToken cookie not cleared in logout response headers. Got: ${clearedRefreshCookie}`); + } + console.log("✓ refreshToken cookie is cleared in logout response headers"); + + // Verify hash is removed from DB + const loggedOutUserInDb = await User.findById(user._id).select("+refreshTokenHash"); + if (loggedOutUserInDb.refreshTokenHash !== null) { + throw new Error(`refreshTokenHash in MongoDB was not cleared. Got: ${loggedOutUserInDb.refreshTokenHash}`); + } + console.log("✓ refreshTokenHash in MongoDB successfully set to null"); + + // 7. Test POST /api/auth/refresh after logout (using the revoked token) + console.log("Testing POST /api/auth/refresh with revoked token..."); + const revokedRes = await request(app) + .post("/api/auth/refresh") + .set("Cookie", [refreshCookie]); + + if (revokedRes.status !== 401) { + throw new Error(`Expected 401 for revoked refresh token, got ${revokedRes.status}`); + } + console.log("✓ Refresh with revoked token returned 401"); + + console.log("\nALL REFRESH TOKEN TESTS PASSED SUCCESSFULLY! 🎉"); +}; + +runTests() + .then(async () => { + await mongoose.connection.close(); + process.exit(0); + }) + .catch(async (err) => { + console.error("Test failed:", err); + await mongoose.connection.close(); + process.exit(1); + }); From 54b1311fbcf5c3b2868dca8473fc2df72635d60c Mon Sep 17 00:00:00 2001 From: jaykolate Date: Mon, 13 Jul 2026 23:12:30 +0530 Subject: [PATCH 2/4] fix: address refresh token authentication review feedback --- backend/src/controllers/auth.controller.js | 1 + backend/test/auth/refresh_test_runner.js | 63 +++++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index eae24bf0..d51fe76b 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -411,6 +411,7 @@ export const refresh = async (req, res) => { const user = await User.findOne({ _id: decoded.userId, refreshTokenHash: hash, + isBanned: false, }); if (!user) { diff --git a/backend/test/auth/refresh_test_runner.js b/backend/test/auth/refresh_test_runner.js index e9eba572..f6dc93c3 100644 --- a/backend/test/auth/refresh_test_runner.js +++ b/backend/test/auth/refresh_test_runner.js @@ -6,6 +6,7 @@ process.env.REFRESH_TOKEN_SECRET = "test-refresh-token-secret-key-do-not-use-in- import request from "supertest"; import mongoose from "mongoose"; import bcrypt from "bcryptjs"; +import jwt from "jsonwebtoken"; // Dynamically import app and models after env variables are set const { app } = await import("../../src/index.js"); @@ -58,7 +59,24 @@ const runTests = async () => { if (!refreshCookie) { throw new Error("refreshToken cookie not set in login response headers"); } - console.log("✓ refreshToken cookie is set in response headers"); + + // Assert security properties on the cookie + const cookieParts = refreshCookie.split(";").map(part => part.trim().toLowerCase()); + const hasHttpOnly = cookieParts.includes("httponly"); + const hasStrictSameSite = cookieParts.includes("samesite=strict"); + const hasMaxAge7Days = cookieParts.includes("max-age=604800"); + + if (!hasHttpOnly) { + throw new Error("refreshToken cookie is missing 'HttpOnly' attribute"); + } + if (!hasStrictSameSite) { + throw new Error("refreshToken cookie is missing 'SameSite=Strict' attribute"); + } + if (!hasMaxAge7Days) { + throw new Error(`refreshToken cookie is missing 7-day expiration attribute (Max-Age=604800). Got: ${refreshCookie}`); + } + + console.log("✓ refreshToken cookie set with HttpOnly, SameSite=Strict, and 7-day lifetime"); // Verify hash is saved in DB const userInDb = await User.findById(user._id).select("+refreshTokenHash"); @@ -104,6 +122,22 @@ const runTests = async () => { } console.log("✓ Refresh with invalid token returned 401"); + // 5b. Test POST /api/auth/refresh with expired token + console.log("Testing POST /api/auth/refresh with expired token..."); + const expiredToken = jwt.sign( + { userId: user._id.toString() }, + process.env.REFRESH_TOKEN_SECRET, + { expiresIn: "-1h" } + ); + const expiredRes = await request(app) + .post("/api/auth/refresh") + .set("Cookie", [`refreshToken=${expiredToken}`]); + + if (expiredRes.status !== 401) { + throw new Error(`Expected 401 for expired refresh token, got ${expiredRes.status}`); + } + console.log("✓ Refresh with expired token returned 401"); + // 6. Test POST /api/auth/logout console.log("Testing POST /api/auth/logout..."); const logoutRes = await request(app) @@ -145,6 +179,33 @@ const runTests = async () => { } console.log("✓ Refresh with revoked token returned 401"); + // 8. Test POST /api/auth/refresh with a banned user + console.log("Testing POST /api/auth/refresh with a banned user..."); + const newLoginRes = await request(app) + .post("/api/auth/login") + .send({ email: "test@example.com", password }); + + const newCookies = newLoginRes.headers["set-cookie"] || []; + const newRefreshCookie = newCookies.find(c => c.startsWith("refreshToken=")); + if (!newRefreshCookie) { + throw new Error("Failed to obtain a new refresh token cookie for banned user testing"); + } + + // Ban the user in the database + await User.updateOne({ _id: user._id }, { isBanned: true }); + + const bannedRes = await request(app) + .post("/api/auth/refresh") + .set("Cookie", [newRefreshCookie]); + + // Restore the user status + await User.updateOne({ _id: user._id }, { isBanned: false }); + + if (bannedRes.status !== 401) { + throw new Error(`Expected 401 for banned user refresh token, got ${bannedRes.status}`); + } + console.log("✓ Refresh with banned user returned 401"); + console.log("\nALL REFRESH TOKEN TESTS PASSED SUCCESSFULLY! 🎉"); }; From dc84342ef141faf7330feb34da4243eb589a9f00 Mon Sep 17 00:00:00 2001 From: jaykolate Date: Tue, 14 Jul 2026 13:03:12 +0530 Subject: [PATCH 3/4] test: add refresh token secret to test setup --- backend/test/setup.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/test/setup.js b/backend/test/setup.js index aafc9220..6fb93fc6 100644 --- a/backend/test/setup.js +++ b/backend/test/setup.js @@ -2,6 +2,8 @@ process.env.NODE_ENV = "test"; process.env.JWT_SECRET = "test-jwt-secret-key-do-not-use-in-production"; +process.env.REFRESH_TOKEN_SECRET = "test-refresh-secret-key-do-not-use-in-production"; + process.env.GROQ_API_KEY = "gsk_test_key"; process.env.REDIS_URL = "redis://localhost:6379"; From 5aa3ff8095d0a165aad31e4dadf4fb334be74064 Mon Sep 17 00:00:00 2001 From: jaykolate Date: Wed, 15 Jul 2026 14:06:48 +0530 Subject: [PATCH 4/4] fix: normalize Authorization header whitespace in protectRoute --- backend/src/middleware/auth.middleware.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/src/middleware/auth.middleware.js b/backend/src/middleware/auth.middleware.js index 71e49a34..da715056 100644 --- a/backend/src/middleware/auth.middleware.js +++ b/backend/src/middleware/auth.middleware.js @@ -5,11 +5,18 @@ export const protectRoute = async (req, res, next) => { try { const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith("Bearer ")) { + if (!authHeader) { return res.status(401).json({ message: "Unauthorized - No Token" }); } - const token = authHeader.split(" ")[1]; + const trimmedHeader = authHeader.trim(); + const parts = trimmedHeader.split(/\s+/); + + if (parts.length < 2 || parts[0] !== "Bearer") { + return res.status(401).json({ message: "Unauthorized - No Token" }); + } + + const token = parts[1]; const decoded = jwt.verify(token, process.env.JWT_SECRET);