Skip to content

Commit e6fa18a

Browse files
committed
feat(auth): migrate to secure httpOnly cookie-based authentication
- Implement Refresh Token rotation and session persistence in dashboard-api - Add centralized Axios 'api' utility with automatic 401 token refresh logic - Refactor AuthContext to remove localStorage dependency and add session verification - Update all 15+ frontend pages/components to use the new session-based API utility - Fix session persistence race condition in ProtectedRoute on page refresh - Securely clear cookies and server-side tokens on logout - Remove manual JWT parsing and Authorization header management from the dashboard - Update Developer model to support refresh token storage
1 parent 9ac6401 commit e6fa18a

26 files changed

Lines changed: 430 additions & 3664 deletions

apps/dashboard-api/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@
88
"start": "node src/app.js"
99
},
1010
"dependencies": {
11-
"@urbackend/common": "*",
1211
"@kiroo/sdk": "^0.1.2",
1312
"@supabase/supabase-js": "^2.84.0",
13+
"@urbackend/common": "*",
1414
"bcryptjs": "^3.0.2",
1515
"bullmq": "^5.70.1",
16+
"cookie-parser": "^1.4.7",
1617
"cors": "^2.8.5",
1718
"dotenv": "^17.2.3",
1819
"express": "^5.1.0",
@@ -26,4 +27,3 @@
2627
"zod": "^4.1.13"
2728
}
2829
}
29-

apps/dashboard-api/src/app.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ if (process.env.NODE_ENV !== 'test') {
1111
const express = require('express')
1212
const mongoose = require('mongoose')
1313
const cors = require('cors')
14+
const cookieParser = require('cookie-parser');
1415
const rateLimit = require('express-rate-limit');
1516
const app = express();
1617
app.set('trust proxy', 1);
@@ -24,6 +25,7 @@ const { emailQueue, authEmailQueue } = require('@urbackend/common');
2425

2526
app.use(express.json());
2627
app.use(express.urlencoded({ extended: true }));
28+
app.use(cookieParser());
2729

2830
const dashboardLimiter = rateLimit({
2931
windowMs: 15 * 60 * 1000,
@@ -46,7 +48,8 @@ const whitelist = (function() {
4648

4749

4850
app.use(cors({
49-
origin: whitelist.get(),
51+
origin: whitelist.get(),
52+
credentials: true,
5053
}));
5154

5255

apps/dashboard-api/src/controllers/auth.controller.js

Lines changed: 112 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,55 @@ const {
1515
verifyOtpSchema
1616
} = require("@urbackend/common");
1717

18-
const JWT_EXPIRES_IN = '7d';
18+
const ACCESS_TOKEN_EXPIRES_IN = '15m';
19+
const REFRESH_TOKEN_EXPIRES_IN = '7d';
1920
const OTP_MAX_ATTEMPTS = 5;
2021

22+
const sendTokenResponse = async (user, statusCode, res) => {
23+
const accessToken = jwt.sign(
24+
{ _id: user._id, isVerified: user.isVerified, maxProjects: user.maxProjects },
25+
process.env.JWT_SECRET,
26+
{ expiresIn: ACCESS_TOKEN_EXPIRES_IN }
27+
);
28+
29+
const refreshToken = jwt.sign(
30+
{ _id: user._id },
31+
process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET,
32+
{ expiresIn: REFRESH_TOKEN_EXPIRES_IN }
33+
);
34+
35+
// Save refresh token to DB (Rotation)
36+
user.refreshToken = refreshToken;
37+
await user.save();
38+
39+
const cookieOptions = {
40+
httpOnly: true,
41+
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
42+
secure: process.env.NODE_ENV === 'production',
43+
sameSite: 'Strict' // Use Strict for better CSRF protection
44+
};
45+
46+
if (process.env.NODE_ENV === 'production') {
47+
cookieOptions.secure = true;
48+
}
49+
50+
res.status(statusCode)
51+
.cookie('accessToken', accessToken, {
52+
...cookieOptions,
53+
expires: new Date(Date.now() + 15 * 60 * 1000) // 15 mins
54+
})
55+
.cookie('refreshToken', refreshToken, cookieOptions)
56+
.json({
57+
success: true,
58+
user: {
59+
_id: user._id,
60+
email: user.email,
61+
isVerified: user.isVerified,
62+
maxProjects: user.maxProjects
63+
}
64+
});
65+
};
66+
2167
async function createAndStoreOtp(userId) {
2268
const otp = crypto.randomInt(100000, 1000000).toString();
2369

@@ -86,13 +132,7 @@ module.exports.login = async (req, res) => {
86132
const validPass = await bcrypt.compare(password, dev.password);
87133
if (!validPass) return res.status(400).json({ error: "Invalid password" });
88134

89-
// JWT EXPIRE
90-
const token = jwt.sign(
91-
{ _id: dev._id, isVerified: dev.isVerified, maxProjects: dev.maxProjects },
92-
process.env.JWT_SECRET,
93-
{ expiresIn: JWT_EXPIRES_IN }
94-
);
95-
res.json({ token });
135+
await sendTokenResponse(dev, 200, res);
96136
} catch (err) {
97137
if (err instanceof z.ZodError) {
98138
return res.status(400).json({
@@ -188,14 +228,7 @@ module.exports.verifyOtp = async (req, res) => {
188228
existingUser.isVerified = true;
189229
await existingUser.save();
190230

191-
// JWT
192-
const token = jwt.sign(
193-
{ _id: existingUser._id, isVerified: true, maxProjects: existingUser.maxProjects },
194-
process.env.JWT_SECRET,
195-
{ expiresIn: JWT_EXPIRES_IN }
196-
);
197-
198-
res.status(200).json({ message: "OTP verified successfully", token });
231+
await sendTokenResponse(existingUser, 200, res);
199232
} catch (err) {
200233
if (err.status) return res.status(err.status).json({ error: err.message });
201234
if (err instanceof z.ZodError) return res.status(400).json({ error: err.errors });
@@ -250,4 +283,66 @@ module.exports.resetPassword = async (req, res) => {
250283
console.error(err);
251284
res.status(500).json({ error: "Internal Server Error" });
252285
}
253-
}
286+
}
287+
288+
// LOGOUT
289+
module.exports.logout = async (req, res) => {
290+
try {
291+
if (req.user) {
292+
const user = await Developer.findById(req.user._id);
293+
if (user) {
294+
user.refreshToken = null;
295+
await user.save();
296+
}
297+
}
298+
299+
res.cookie('accessToken', 'none', {
300+
expires: new Date(Date.now() + 10 * 1000),
301+
httpOnly: true,
302+
});
303+
res.cookie('refreshToken', 'none', {
304+
expires: new Date(Date.now() + 10 * 1000),
305+
httpOnly: true,
306+
});
307+
308+
res.status(200).json({ success: true, message: "Logged out successfully" });
309+
} catch (err) {
310+
console.error(err);
311+
res.status(500).json({ error: "Internal Server Error" });
312+
}
313+
};
314+
315+
// REFRESH TOKEN
316+
module.exports.refreshToken = async (req, res) => {
317+
try {
318+
const refreshToken = req.cookies.refreshToken;
319+
320+
if (!refreshToken) {
321+
return res.status(401).json({ error: "No refresh token provided" });
322+
}
323+
324+
const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET);
325+
const user = await Developer.findById(decoded._id);
326+
327+
if (!user || user.refreshToken !== refreshToken) {
328+
return res.status(403).json({ error: "Invalid refresh token" });
329+
}
330+
331+
await sendTokenResponse(user, 200, res);
332+
} catch (err) {
333+
console.error(err);
334+
res.status(403).json({ error: "Invalid or expired refresh token" });
335+
}
336+
};
337+
338+
// GET ME
339+
module.exports.getMe = async (req, res) => {
340+
try {
341+
const user = await Developer.findById(req.user._id).select("-password -refreshToken");
342+
if (!user) return res.status(404).json({ error: "User not found" });
343+
res.json({ success: true, user });
344+
} catch (err) {
345+
console.error(err);
346+
res.status(500).json({ error: "Internal Server Error" });
347+
}
348+
};

apps/dashboard-api/src/middlewares/authMiddleware.js

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,20 @@
11
const jwt = require('jsonwebtoken');
22

33
module.exports = function (req, res, next) {
4-
// Retrieve the Authorization header from the request
5-
const authHeader = req.header('Authorization');
4+
// Check for token in cookies (Primary for Web)
5+
let token = req.cookies && req.cookies.accessToken;
66

7-
// Check if the Authorization header exists
8-
if (!authHeader) {
9-
return res.status(401).send('Access Denied: No Token Provided');
7+
// Fallback to Authorization header (For CLI/API)
8+
if (!token) {
9+
const authHeader = req.header('Authorization');
10+
if (authHeader && authHeader.startsWith('Bearer ')) {
11+
token = authHeader.split(' ')[1];
12+
}
1013
}
1114

12-
// Extract the actual token by removing the "Bearer " prefix
13-
// spliting the header value by space and take the second part
14-
const token = authHeader.split(' ')[1];
15-
16-
// If token is missing after splitting, the format is invalid
15+
// Check if any token was provided
1716
if (!token) {
18-
return res.status(401).send('Access Denied: Malformed Token Format');
17+
return res.status(401).json({ error: 'Access Denied: No Token Provided' });
1918
}
2019

2120
try {

apps/dashboard-api/src/routes/auth.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ const {
99
sendOtp,
1010
verifyOtp,
1111
forgotPassword,
12-
resetPassword
12+
resetPassword,
13+
logout,
14+
refreshToken,
15+
getMe
1316
} = require('../controllers/auth.controller');
1417

1518

@@ -46,4 +49,11 @@ router.post('/verify-otp', verifyOtp);
4649
router.post('/forgot-password', forgotPassword);
4750
router.post('/reset-password', resetPassword);
4851

52+
// REFRESH & LOGOUT
53+
router.post('/refresh-token', refreshToken);
54+
router.post('/logout', authorization, logout);
55+
56+
// GET ME
57+
router.get('/me', authorization, getMe);
58+
4959
module.exports = router;

apps/web-dashboard/src/components/ProtectedRoute.jsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,22 @@ import { useAuth } from '../context/AuthContext';
33

44
// This component takes other components as children
55
const ProtectedRoute = ({ children }) => {
6-
const { isAuthenticated } = useAuth();
6+
const { isAuthenticated, isLoading } = useAuth();
7+
8+
if (isLoading) {
9+
return (
10+
<div style={{
11+
display: 'flex',
12+
justifyContent: 'center',
13+
alignItems: 'center',
14+
height: '100vh',
15+
backgroundColor: 'var(--color-bg-main)',
16+
color: 'var(--color-text-main)'
17+
}}>
18+
<div className="loader">Loading Session...</div>
19+
</div>
20+
);
21+
}
722

823
// If not authenticated, redirect to login page
924
if (!isAuthenticated) {

apps/web-dashboard/src/context/AuthContext.jsx

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,44 @@
1-
import { createContext, useState, useContext } from 'react';
1+
import { createContext, useState, useContext, useEffect } from 'react';
2+
import api from '../utils/api';
23

34
const AuthContext = createContext(null);
45

56
export const AuthProvider = ({ children }) => {
6-
const [token, setToken] = useState(localStorage.getItem('devToken') || null);
7-
const [user, setUser] = useState(() => {
7+
const [user, setUser] = useState(null);
8+
const [isLoading, setIsLoading] = useState(true);
9+
10+
useEffect(() => {
11+
const checkAuth = async () => {
12+
try {
13+
const response = await api.get('/api/auth/me');
14+
if (response.data.success) {
15+
setUser(response.data.user);
16+
}
17+
} catch (err) {
18+
console.error("Not authenticated:", err.message);
19+
setUser(null);
20+
} finally {
21+
setIsLoading(false);
22+
}
23+
};
24+
checkAuth();
25+
}, []);
26+
27+
const logout = async () => {
828
try {
9-
const storedUser = localStorage.getItem('devUser');
10-
return storedUser ? JSON.parse(storedUser) : null;
11-
} catch {
12-
return null;
29+
await api.post('/api/auth/logout');
30+
} catch (err) {
31+
console.error("Logout failed:", err);
32+
} finally {
33+
setUser(null);
1334
}
14-
});
15-
16-
// 1. Move logout here (before useEffect)
17-
const logout = () => {
18-
setUser(null);
19-
setToken(null);
20-
localStorage.removeItem('devToken');
21-
localStorage.removeItem('devUser');
2235
};
2336

24-
const login = (userData, authToken) => {
37+
const login = (userData) => {
2538
setUser(userData);
26-
setToken(authToken);
27-
localStorage.setItem('devToken', authToken);
28-
localStorage.setItem('devUser', JSON.stringify(userData));
2939
};
3040

31-
const value = { user, token, login, logout, isAuthenticated: !!token };
41+
const value = { user, login, logout, isAuthenticated: !!user, isLoading };
3242

3343

3444
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

apps/web-dashboard/src/pages/AdminCreateRelease.jsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { useState, useEffect } from 'react';
2-
import axios from 'axios';
3-
import { API_URL, ADMIN_EMAIL } from '../config';
2+
import api from '../utils/api';
3+
import { ADMIN_EMAIL } from '../config';
44
import { useAuth } from '../context/AuthContext';
55
import { useNavigate } from 'react-router-dom';
66
import toast from 'react-hot-toast';
77
import { Send, ArrowLeft, AlertCircle } from 'lucide-react';
88

99
export default function AdminCreateRelease() {
10-
const { user, token } = useAuth();
10+
const { user } = useAuth();
1111
const navigate = useNavigate();
1212
const [loading, setLoading] = useState(false);
1313
const [formData, setFormData] = useState({
@@ -35,9 +35,7 @@ export default function AdminCreateRelease() {
3535
const loadToast = toast.loading("Publishing release and queuing emails...");
3636

3737
try {
38-
const res = await axios.post(`${API_URL}/api/releases`, formData, {
39-
headers: { Authorization: `Bearer ${token}` }
40-
});
38+
const res = await api.post(`/api/releases`, formData);
4139
toast.dismiss(loadToast);
4240
toast.success(res.data.message);
4341
navigate('/releases');

0 commit comments

Comments
 (0)