Skip to content

Commit 3eb4b05

Browse files
authored
feat: Add cookieSessionStore option to support multi-replica deployments (#3016)
1 parent 5eda26c commit 3eb4b05

File tree

5 files changed

+232
-4
lines changed

5 files changed

+232
-4
lines changed

Parse-Dashboard/Authentication.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,10 @@ function initialize(app, options) {
5555

5656
const cookieSessionSecret = options.cookieSessionSecret || require('crypto').randomBytes(64).toString('hex');
5757
const cookieSessionMaxAge = options.cookieSessionMaxAge;
58+
const cookieSessionStore = options.cookieSessionStore;
5859

5960
app.use(require('body-parser').urlencoded({ extended: true }));
60-
app.use(require('express-session')({
61+
const sessionConfig = {
6162
name: 'parse_dash',
6263
secret: cookieSessionSecret,
6364
resave: false,
@@ -67,7 +68,14 @@ function initialize(app, options) {
6768
httpOnly: true,
6869
sameSite: 'lax',
6970
}
70-
}));
71+
};
72+
73+
// Add custom session store if provided
74+
if (cookieSessionStore) {
75+
sessionConfig.store = cookieSessionStore;
76+
}
77+
78+
app.use(require('express-session')(sessionConfig));
7179
app.use(require('connect-flash')());
7280
app.use(passport.initialize());
7381
app.use(passport.session());

Parse-Dashboard/app.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,11 @@ module.exports = function(config, options) {
8282
const users = config.users;
8383
const useEncryptedPasswords = config.useEncryptedPasswords ? true : false;
8484
const authInstance = new Authentication(users, useEncryptedPasswords, mountPath);
85-
authInstance.initialize(app, { cookieSessionSecret: options.cookieSessionSecret, cookieSessionMaxAge: options.cookieSessionMaxAge });
85+
authInstance.initialize(app, {
86+
cookieSessionSecret: options.cookieSessionSecret,
87+
cookieSessionMaxAge: options.cookieSessionMaxAge,
88+
cookieSessionStore: options.cookieSessionStore
89+
});
8690

8791
// CSRF error handler
8892
app.use(function (err, req, res, next) {

Parse-Dashboard/server.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,13 @@ module.exports = (options) => {
162162
if (allowInsecureHTTP || trustProxy || dev) {app.enable('trust proxy');}
163163

164164
config.data.trustProxy = trustProxy;
165-
const dashboardOptions = { allowInsecureHTTP, cookieSessionSecret, dev, cookieSessionMaxAge };
165+
const dashboardOptions = {
166+
allowInsecureHTTP,
167+
cookieSessionSecret,
168+
dev,
169+
cookieSessionMaxAge,
170+
cookieSessionStore: config.data.cookieSessionStore
171+
};
166172
app.use(mountPath, parseDashboard(config.data, dashboardOptions));
167173
let server;
168174
if(!configSSLKey || !configSSLCert){

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,55 @@ If you create a new user by running `parse-dashboard --createUser`, you will be
803803

804804
Parse Dashboard follows the industry standard and supports the common OTP algorithm `SHA-1` by default, to be compatible with most authenticator apps. If you have specific security requirements regarding TOTP characteristics (algorithm, digit length, time period) you can customize them by using the guided configuration mentioned above.
805805

806+
### Running Multiple Dashboard Replicas
807+
808+
When deploying Parse Dashboard with multiple replicas behind a load balancer, you need to use a shared session store to ensure that CSRF tokens and user sessions work correctly across all replicas. Without a shared session store, login attempts may fail with "CSRF token validation failed" errors when requests are distributed across different replicas.
809+
810+
#### Using a Custom Session Store
811+
812+
Parse Dashboard supports using any session store compatible with [express-session](https://github.com/expressjs/session). The `sessionStore` option must be configured programmatically when initializing the dashboard.
813+
814+
**Suggested Session Stores:**
815+
816+
- [connect-redis](https://www.npmjs.com/package/connect-redis) - Redis session store
817+
- [connect-mongo](https://www.npmjs.com/package/connect-mongo) - MongoDB session store
818+
- [connect-pg-simple](https://www.npmjs.com/package/connect-pg-simple) - PostgreSQL session store
819+
- [memorystore](https://www.npmjs.com/package/memorystore) - Memory session store with TTL support
820+
821+
**Example using connect-redis:**
822+
823+
```js
824+
const express = require('express');
825+
const ParseDashboard = require('parse-dashboard');
826+
const { createClient } = require('redis');
827+
const RedisStore = require('connect-redis').default;
828+
829+
// Instantiate Redis client
830+
const redisClient = createClient({ url: 'redis://localhost:6379' });
831+
redisClient.connect();
832+
833+
// Instantiate Redis session store
834+
const cookieSessionStore = new RedisStore({ client: redisClient });
835+
836+
// Configure dashboard with session store
837+
const dashboard = new ParseDashboard({
838+
apps: [...],
839+
users: [...],
840+
}, {
841+
cookieSessionStore,
842+
cookieSessionSecret: 'your-secret-key',
843+
});
844+
845+
**Important Notes:**
846+
847+
- The `cookieSessionSecret` option must be set to the same value across all replicas to ensure session cookies work correctly.
848+
- If `cookieSessionStore` is not provided, Parse Dashboard will use the default in-memory session store, which only works for single-instance deployments.
849+
- For production deployments with multiple replicas, always configure a shared session store.
850+
851+
#### Alternative: Using Sticky Sessions
852+
853+
If you cannot use a shared session store, you can configure your load balancer to use sticky sessions (session affinity), which ensures that requests from the same user are always routed to the same replica. However, using a shared session store is the recommended approach as it provides better reliability and scalability.
854+
806855
### Separating App Access Based on User Identity
807856
If you have configured your dashboard to manage multiple applications, you can restrict the management of apps based on user identity.
808857

src/lib/tests/SessionStore.test.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*
2+
* Copyright (c) 2016-present, Parse, LLC
3+
* All rights reserved.
4+
*
5+
* This source code is licensed under the license found in the LICENSE file in
6+
* the root directory of this source tree.
7+
*/
8+
jest.dontMock('../../../Parse-Dashboard/Authentication.js');
9+
jest.dontMock('../../../Parse-Dashboard/app.js');
10+
11+
const express = require('express');
12+
const session = require('express-session');
13+
const Authentication = require('../../../Parse-Dashboard/Authentication');
14+
15+
describe('SessionStore Integration', () => {
16+
it('uses default in-memory store when cookieSessionStore is not provided', () => {
17+
const app = express();
18+
const users = [{ user: 'test', pass: 'password' }];
19+
const auth = new Authentication(users, false, '/');
20+
21+
// Mock app.use to capture session configuration
22+
const useSpy = jest.fn();
23+
app.use = useSpy;
24+
25+
auth.initialize(app, {});
26+
27+
// Find the call that sets up express-session
28+
const sessionCall = useSpy.mock.calls.find(call =>
29+
call[0] && call[0].name === 'session'
30+
);
31+
32+
expect(sessionCall).toBeDefined();
33+
// When no store is provided, express-session uses MemoryStore by default
34+
// The session function should be called without a custom store
35+
});
36+
37+
it('uses custom session store when cookieSessionStore is provided', () => {
38+
const app = express();
39+
const users = [{ user: 'test', pass: 'password' }];
40+
const auth = new Authentication(users, false, '/');
41+
42+
// Create a mock session store that implements the Store interface
43+
const Store = session.Store;
44+
class MockStore extends Store {
45+
constructor() {
46+
super();
47+
}
48+
get(sid, callback) {
49+
callback(null, {});
50+
}
51+
set(sid, session, callback) {
52+
callback(null);
53+
}
54+
destroy(sid, callback) {
55+
callback(null);
56+
}
57+
}
58+
59+
const mockStore = new MockStore();
60+
61+
// Mock app.use to capture session configuration
62+
const useSpy = jest.fn();
63+
app.use = useSpy;
64+
65+
auth.initialize(app, { cookieSessionStore: mockStore });
66+
67+
// The session middleware should have been configured
68+
expect(useSpy).toHaveBeenCalled();
69+
70+
// Find the call that sets up express-session
71+
const sessionCall = useSpy.mock.calls.find(call =>
72+
call[0] && call[0].name === 'session'
73+
);
74+
75+
expect(sessionCall).toBeDefined();
76+
});
77+
78+
it('passes cookieSessionStore through app.js to Authentication', () => {
79+
const parseDashboard = require('../../../Parse-Dashboard/app.js');
80+
81+
// Create a mock session store that implements the Store interface
82+
const Store = session.Store;
83+
class MockStore extends Store {
84+
constructor() {
85+
super();
86+
}
87+
get(sid, callback) {
88+
callback(null, {});
89+
}
90+
set(sid, session, callback) {
91+
callback(null);
92+
}
93+
destroy(sid, callback) {
94+
callback(null);
95+
}
96+
}
97+
98+
const mockStore = new MockStore();
99+
100+
const config = {
101+
apps: [
102+
{
103+
serverURL: 'http://localhost:1337/parse',
104+
appId: 'testAppId',
105+
masterKey: 'testMasterKey',
106+
appName: 'TestApp',
107+
},
108+
],
109+
users: [
110+
{
111+
user: 'testuser',
112+
pass: 'testpass',
113+
},
114+
],
115+
};
116+
117+
const options = {
118+
cookieSessionStore: mockStore,
119+
cookieSessionSecret: 'test-secret',
120+
};
121+
122+
// Create dashboard app
123+
const dashboardApp = parseDashboard(config, options);
124+
125+
// The app should be created successfully with the session store
126+
expect(dashboardApp).toBeDefined();
127+
expect(typeof dashboardApp).toBe('function'); // Express app is a function
128+
});
129+
130+
it('maintains backward compatibility without cookieSessionStore option', () => {
131+
const parseDashboard = require('../../../Parse-Dashboard/app.js');
132+
133+
const config = {
134+
apps: [
135+
{
136+
serverURL: 'http://localhost:1337/parse',
137+
appId: 'testAppId',
138+
masterKey: 'testMasterKey',
139+
appName: 'TestApp',
140+
},
141+
],
142+
users: [
143+
{
144+
user: 'testuser',
145+
pass: 'testpass',
146+
},
147+
],
148+
};
149+
150+
const options = {
151+
cookieSessionSecret: 'test-secret',
152+
};
153+
154+
// Create dashboard app without cookieSessionStore option
155+
const dashboardApp = parseDashboard(config, options);
156+
157+
// The app should be created successfully even without session store
158+
expect(dashboardApp).toBeDefined();
159+
expect(typeof dashboardApp).toBe('function');
160+
});
161+
});

0 commit comments

Comments
 (0)