Skip to content

Commit 4009bf7

Browse files
committed
feat(session): add dashboard-managed user session listing and revocation
1 parent 5d3b58d commit 4009bf7

7 files changed

Lines changed: 195 additions & 49 deletions

File tree

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const { authEmailQueue } = require('@urbackend/common');
88
const { loginSchema, signupSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize } = require('@urbackend/common');
99
const { getConnection } = require('@urbackend/common');
1010
const { getCompiledModel } = require('@urbackend/common');
11+
const { getUserActiveSessions, getRefreshSession, revokeSessionChain } = require('@urbackend/common');
1112

1213
const hasRequiredField = (usersColConfig, fieldKey) => {
1314
const model = usersColConfig?.model || [];
@@ -515,3 +516,54 @@ module.exports.updateAdminUser = async (req, res) => {
515516
res.status(500).json({ error: err.message });
516517
}
517518
};
519+
520+
// GET ACTIVE SESSIONS FOR A USER (Admin)
521+
module.exports.listUserSessions = async (req, res) => {
522+
try {
523+
const project = req.project;
524+
const { userId } = req.params;
525+
526+
// Verify the userId actually exists in this project's users collection
527+
const usersColConfig = project.collections.find(c => c.name === 'users');
528+
if (usersColConfig) {
529+
const connection = await getConnection(project._id);
530+
const Model = getCompiledModel(connection, usersColConfig, project._id, project.resources.db.isExternal);
531+
const userExists = await Model.findOne(
532+
{ _id: new mongoose.Types.ObjectId(userId) },
533+
{ _id: 1 }
534+
).lean();
535+
if (!userExists) {
536+
return res.status(404).json({ error: 'User not found in this project' });
537+
}
538+
}
539+
540+
const sessions = await getUserActiveSessions(project._id, userId);
541+
res.json({ sessions });
542+
} catch (err) {
543+
res.status(500).json({ error: err.message });
544+
}
545+
};
546+
547+
// REVOKE A SPECIFIC SESSION FOR A USER (Admin)
548+
module.exports.revokeUserSession = async (req, res) => {
549+
try {
550+
const projectId = String(req.project._id);
551+
const { userId, tokenId } = req.params;
552+
553+
// Fetch the session to verify it really belongs to this project AND this user
554+
const session = await getRefreshSession(tokenId);
555+
556+
if (!session
557+
|| String(session.projectId) !== projectId
558+
|| String(session.userId) !== String(userId)) {
559+
return res.status(404).json({ error: 'Session not found or does not belong to this user' });
560+
}
561+
562+
// Revoke the entire chain starting from this token, cleaning up the user sessions set
563+
await revokeSessionChain(tokenId);
564+
565+
res.json({ message: 'Session revoked successfully' });
566+
} catch (err) {
567+
res.status(500).json({ error: err.message });
568+
}
569+
};

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ const {
3131
updateCollectionRls
3232
} = require("../controllers/project.controller")
3333

34-
const { createAdminUser, resetPassword, getUserDetails, updateAdminUser } = require('../controllers/userAuth.controller');
34+
const { createAdminUser, resetPassword, getUserDetails, updateAdminUser, listUserSessions, revokeUserSession } = require('../controllers/userAuth.controller');
3535

3636
const upload = multer({ storage: storage, limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB Limit
3737

@@ -114,4 +114,8 @@ router.patch('/:projectId/admin/users/:userId/password', authMiddleware, loadPro
114114
router.get('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, getUserDetails);
115115
router.put('/:projectId/admin/users/:userId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, updateAdminUser);
116116

117+
// SESSION MANAGEMENT (Admin)
118+
router.get('/:projectId/admin/users/:userId/sessions', authMiddleware, loadProjectForAdmin, checkAuthEnabled, listUserSessions);
119+
router.delete('/:projectId/admin/users/:userId/sessions/:tokenId', authMiddleware, loadProjectForAdmin, checkAuthEnabled, revokeUserSession);
120+
117121
module.exports = router;

apps/public-api/src/__tests__/userAuth.refresh.test.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ jest.mock('@urbackend/common', () => {
4343
del: jest.fn().mockResolvedValue(1),
4444
incr: jest.fn().mockResolvedValue(1),
4545
expire: jest.fn().mockResolvedValue(1),
46+
sadd: jest.fn().mockResolvedValue(1),
47+
srem: jest.fn().mockResolvedValue(1),
48+
smembers: jest.fn().mockResolvedValue([]),
4649
},
4750
authEmailQueue: { add: jest.fn().mockResolvedValue(undefined) },
4851
loginSchema: z.object({
@@ -68,11 +71,18 @@ jest.mock('@urbackend/common', () => {
6871
getConnection: jest.fn().mockResolvedValue({}),
6972
getCompiledModel: jest.fn(() => mockModel),
7073
__mockModel: mockModel,
74+
// session manager exports
75+
getRefreshSession: jest.fn(),
76+
persistRefreshSession: jest.fn().mockResolvedValue(undefined),
77+
revokeSessionChain: jest.fn().mockResolvedValue(undefined),
78+
getUserActiveSessions: jest.fn().mockResolvedValue([]),
79+
getRefreshSessionKey: jest.fn((tokenId) => `project:auth:refresh:session:${tokenId}`),
80+
getUserSessionsKey: jest.fn((projectId, userId) => `project:${projectId}:user:${userId}:sessions`),
7181
};
7282
});
7383

7484
const bcrypt = require('bcryptjs');
75-
const { Project, redis, __mockModel: mockModel } = require('@urbackend/common');
85+
const { Project, redis, getRefreshSession, persistRefreshSession, __mockModel: mockModel } = require('@urbackend/common');
7686
const controller = require('../controllers/userAuth.controller');
7787

7888
const makeProject = () => ({
@@ -172,7 +182,7 @@ describe('public userAuth refresh flow', () => {
172182
expiresAt: new Date(Date.now() + 60_000).toISOString(),
173183
};
174184

175-
redis.get.mockResolvedValueOnce(JSON.stringify(session)); // getRefreshSession
185+
getRefreshSession.mockResolvedValueOnce(session); // getRefreshSession from common
176186
Project.__chain.lean.mockResolvedValueOnce(makeProject()); // load project
177187
mockModel.findOne.mockReturnValueOnce({
178188
lean: jest.fn().mockResolvedValue({ _id: 'user_1' }),
@@ -210,7 +220,7 @@ describe('public userAuth refresh flow', () => {
210220
expiresAt: new Date(Date.now() + 60_000).toISOString(),
211221
};
212222

213-
redis.get.mockResolvedValueOnce(JSON.stringify(session));
223+
getRefreshSession.mockResolvedValueOnce(session);
214224

215225
const req = makeReq({
216226
cookies: { refreshToken: rawToken },

apps/public-api/src/controllers/userAuth.controller.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,18 @@ const mongoose = require('mongoose');
55
const {redis} = require('@urbackend/common');
66
const {Project} = require('@urbackend/common');
77
const { authEmailQueue } = require('@urbackend/common');
8+
const { getRefreshSession, persistRefreshSession, revokeSessionChain } = require('@urbackend/common');
89
const { loginSchema, userSignupSchema, resetPasswordSchema, onlyEmailSchema, verifyOtpSchema, changePasswordSchema, sanitize } = require('@urbackend/common');
910
const { getConnection } = require('@urbackend/common');
1011
const { getCompiledModel } = require('@urbackend/common');
1112
const {
1213
assertRefreshRateLimits,
1314
clearRefreshCookie,
14-
getRefreshSession,
1515
hashRefreshToken,
1616
issueAuthTokens,
1717
parseRefreshToken,
1818
readRefreshTokenFromRequest,
19-
revokeSessionChain,
20-
shouldExposeRefreshToken,
21-
persistRefreshSession
19+
shouldExposeRefreshToken
2220
} = require('../utils/refreshToken');
2321

2422
const getUsersModel = async (project) => {
@@ -122,6 +120,7 @@ module.exports.signup = async (req, res) => {
122120
const issuedTokens = await issueAuthTokens({
123121
project,
124122
userId: result._id,
123+
req,
125124
res
126125
});
127126

@@ -164,6 +163,7 @@ module.exports.login = async (req, res) => {
164163
const issuedTokens = await issueAuthTokens({
165164
project,
166165
userId: user._id,
166+
req,
167167
res
168168
});
169169

@@ -581,6 +581,7 @@ module.exports.refreshToken = async (req, res) => {
581581
const newTokens = await issueAuthTokens({
582582
project,
583583
userId: user._id,
584+
req,
584585
res,
585586
rotatedFrom: session.tokenId
586587
});

apps/public-api/src/utils/refreshToken.js

Lines changed: 25 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
const jwt = require('jsonwebtoken');
22
const crypto = require('crypto');
33
const { redis } = require('@urbackend/common');
4+
const {
5+
getRefreshSessionKey,
6+
getUserSessionsKey,
7+
getRefreshSession,
8+
persistRefreshSession,
9+
revokeSessionChain
10+
} = require('@urbackend/common');
411

512
const ACCESS_TOKEN_EXPIRES_IN = process.env.PUBLIC_AUTH_ACCESS_TOKEN_TTL || '15m';
613
const REFRESH_TOKEN_TTL_SECONDS = Number(process.env.PUBLIC_AUTH_REFRESH_TOKEN_TTL_SECONDS || 7 * 24 * 60 * 60);
7-
const REFRESH_SESSION_PREFIX = 'project';
814

915
const getRefreshCookieOptions = () => ({
1016
httpOnly: true,
@@ -19,8 +25,6 @@ const clearCookieOptions = () => ({
1925
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax'
2026
});
2127

22-
const getRefreshSessionKey = (tokenId) => `${REFRESH_SESSION_PREFIX}:auth:refresh:session:${tokenId}`;
23-
2428
const hashRefreshToken = (token) => crypto.createHash('sha256').update(token).digest('hex');
2529

2630
const toProjectIdString = (projectId) => projectId?.toString?.() || String(projectId);
@@ -79,7 +83,7 @@ const parseRefreshToken = (rawToken) => {
7983
return { tokenId, tokenSecret };
8084
};
8185

82-
const saveRefreshSession = async ({ tokenId, rawToken, projectId, userId, rotatedFrom = null, isUsed = false, rotatedTo = null }) => {
86+
const saveRefreshSession = async ({ tokenId, rawToken, projectId, userId, rotatedFrom = null, isUsed = false, rotatedTo = null, ip = null, userAgent = null }) => {
8387
const nowIso = new Date().toISOString();
8488
const session = {
8589
tokenId,
@@ -90,29 +94,17 @@ const saveRefreshSession = async ({ tokenId, rawToken, projectId, userId, rotate
9094
rotatedTo,
9195
isUsed,
9296
revokedAt: null,
97+
ip,
98+
userAgent,
9399
createdAt: nowIso,
94100
lastUsedAt: nowIso,
95101
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_SECONDS * 1000).toISOString()
96102
};
97103
await redis.set(getRefreshSessionKey(tokenId), JSON.stringify(session), 'EX', REFRESH_TOKEN_TTL_SECONDS);
104+
await redis.sadd(getUserSessionsKey(projectId, userId), tokenId);
98105
return session;
99106
};
100107

101-
const getRefreshSession = async (tokenId) => {
102-
const raw = await redis.get(getRefreshSessionKey(tokenId));
103-
if (!raw) return null;
104-
try {
105-
return JSON.parse(raw);
106-
} catch {
107-
return null;
108-
}
109-
};
110-
111-
const persistRefreshSession = async (session) => {
112-
const ttl = Math.max(1, Math.floor((new Date(session.expiresAt).getTime() - Date.now()) / 1000));
113-
await redis.set(getRefreshSessionKey(session.tokenId), JSON.stringify(session), 'EX', ttl);
114-
};
115-
116108
const incrementRateCounter = async (key, windowSeconds) => {
117109
const count = await redis.incr(key);
118110
if (count === 1) {
@@ -139,34 +131,31 @@ const assertRefreshRateLimits = async ({ req, tokenId, userId }) => {
139131
return { limited: false };
140132
};
141133

142-
const revokeSessionChain = async (startTokenId) => {
143-
let currentTokenId = startTokenId;
144-
const visited = new Set();
145-
146-
while (currentTokenId && !visited.has(currentTokenId)) {
147-
visited.add(currentTokenId);
148-
const session = await getRefreshSession(currentTokenId);
149-
if (!session) break;
150-
session.revokedAt = new Date().toISOString();
151-
session.lastUsedAt = new Date().toISOString();
152-
await persistRefreshSession(session);
153-
currentTokenId = session.rotatedTo || null;
154-
}
155-
};
134+
156135

157136
const clearRefreshCookie = (res) => {
158137
res.clearCookie('refreshToken', clearCookieOptions());
159138
};
160139

161-
const issueAuthTokens = async ({ project, userId, res, rotatedFrom = null }) => {
140+
const issueAuthTokens = async ({ project, userId, req, res, rotatedFrom = null }) => {
162141
const accessToken = signAccessToken(project, userId);
163142
const { tokenId, rawToken } = generateRefreshToken();
143+
144+
let ip = null;
145+
let userAgent = null;
146+
if (req) {
147+
ip = readRequestIp(req);
148+
userAgent = req.headers?.['user-agent'] || 'unknown';
149+
}
150+
164151
await saveRefreshSession({
165152
tokenId,
166153
rawToken,
167154
projectId: project._id,
168155
userId,
169-
rotatedFrom
156+
rotatedFrom,
157+
ip,
158+
userAgent
170159
});
171160

172161
res.cookie('refreshToken', rawToken, getRefreshCookieOptions());
@@ -181,12 +170,9 @@ const issueAuthTokens = async ({ project, userId, res, rotatedFrom = null }) =>
181170
module.exports = {
182171
assertRefreshRateLimits,
183172
clearRefreshCookie,
184-
getRefreshSession,
185173
hashRefreshToken,
186174
issueAuthTokens,
187175
parseRefreshToken,
188176
readRefreshTokenFromRequest,
189-
revokeSessionChain,
190-
shouldExposeRefreshToken,
191-
persistRefreshSession
177+
shouldExposeRefreshToken
192178
};

packages/common/src/index.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ const { registry, storageRegistry } = require('./utils/registry');
6060
const { getStorage } = require('./utils/storage.manager');
6161
const validateEnv = require('./utils/validateEnv');
6262
const {validateData, validateUpdateData} = require('./utils/validateData')
63-
63+
const sessionManager = require('./utils/session.manager');
6464

6565
module.exports = {
6666
connectDB,
@@ -116,5 +116,6 @@ module.exports = {
116116
deleteProjectById,
117117
validateData,
118118
validateUpdateData,
119-
userSignupSchema
119+
userSignupSchema,
120+
...sessionManager
120121
};

0 commit comments

Comments
 (0)