Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@ $ npm i
$ npm start
```

### Backend Environment Variables

Create `backend/.env` for local backend runs:

| Variable | Required | Purpose |
| --- | --- | --- |
| `MONGO_URI` | Yes | MongoDB connection string used by Mongoose and the persistent session store |
| `SESSION_SECRET` | Yes | Secret used to sign Express session cookies |
| `NODE_ENV` | No | Set to `production` to send session cookies only over HTTPS |

## 🧪 Backend Unit & Integration Testing with Jasmine

This project uses the Jasmine framework for backend unit and integration tests. The tests cover:
Expand Down
32 changes: 32 additions & 0 deletions backend/config/session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const MongoStore = require('connect-mongo');

const SESSION_TTL_SECONDS = 14 * 24 * 60 * 60;

function createSessionConfig({
mongoUrl = process.env.MONGO_URI,
sessionSecret = process.env.SESSION_SECRET,
nodeEnv = process.env.NODE_ENV,
storeFactory = MongoStore,
} = {}) {
if (!mongoUrl) {
throw new Error('MONGO_URI is required to configure the session store');
}

return {
secret: sessionSecret,
resave: false,
Comment on lines +11 to +17
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In express-session v1.18.x, is the secret option required, and what happens when it is omitted?

💡 Result:

In express-session v1.18.x, the secret option is a required configuration [1][2][3]. It is used to sign the session ID cookie, which helps prevent tampering [3][4]. If the secret option is omitted, the middleware will fail to initialize properly. Specifically, the library explicitly checks for the presence of a secret during request handling and will trigger an error if none is found [5]. When a request is made, if no secret is provided (and no fallback is available), the middleware will call next with an error: "secret option required for sessions" [5]. Previous versions of the library may have allowed a fallback to req.secret (often provided by cookie-parser), but this practice is deprecated and will result in a warning; relying on it is not supported [6][7][5].

Citations:


Fail fast if SESSION_SECRET is missing before configuring express-session.

express-session requires the secret option; passing an undefined sessionSecret will cause the middleware to error when handling requests (instead of clear config validation in this module).

Proposed fix
 function createSessionConfig({
   mongoUrl = process.env.MONGO_URI,
   sessionSecret = process.env.SESSION_SECRET,
   nodeEnv = process.env.NODE_ENV,
   storeFactory = MongoStore,
 } = {}) {
   if (!mongoUrl) {
     throw new Error('MONGO_URI is required to configure the session store');
   }
+  if (!sessionSecret) {
+    throw new Error('SESSION_SECRET is required to configure session middleware');
+  }

   return {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/config/session.js` around lines 11 - 17, Validate that sessionSecret
is present and throw a clear error before returning the express-session config:
add a check for sessionSecret (the value derived from SESSION_SECRET) similar to
the mongoUrl check and throw new Error('SESSION_SECRET is required to configure
the session store') if missing so you fail fast before express-session is
configured; keep this validation alongside the existing mongoUrl check and prior
to returning the object containing secret: sessionSecret.

saveUninitialized: false,
store: storeFactory.create({
mongoUrl,
ttl: SESSION_TTL_SECONDS,
}),
cookie: {
secure: nodeEnv === 'production',
},
Comment on lines +23 to +25
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

For modern browsers, what SameSite value is required for cookies to be sent on cross-site fetch/XHR requests with credentials?

💡 Result:

For modern browsers, to ensure that cookies are sent on cross-site fetch or XHR requests (typically initiated via APIs like fetch or XMLHttpRequest), the cookie must be set with the SameSite=None attribute [1][2][3]. Additionally, when SameSite=None is used, the following requirements must be met: 1. The Secure attribute must also be included, meaning the cookie will only be transmitted over encrypted (HTTPS) connections [1][4][3]. 2. For the request itself, the client must explicitly opt-in to sending credentials. For fetch, this is done by setting the credentials option to 'include' [5][4][6]. For XMLHttpRequest, the withCredentials property must be set to true [4]. 3. The server must also correctly configure Cross-Origin Resource Sharing (CORS) headers, specifically setting Access-Control-Allow-Credentials to true [5][4]. Without setting SameSite=None, modern browsers will typically default to Lax, which restricts cookies to same-site contexts and top-level navigations, excluding them from cross-site subresource requests like fetch/XHR [1][7][2].

Citations:


Set sameSite for production session cookies when using cross-site credentialed requests.

secure alone isn’t sufficient for modern browsers to send cookies on cross-site fetch/XHR with credentials; that requires SameSite=None (and Secure). Add sameSite: 'none' in production (and keep secure enabled there).

Proposed fix
     cookie: {
       secure: nodeEnv === 'production',
+      sameSite: nodeEnv === 'production' ? 'none' : 'lax',
+      httpOnly: true,
     },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
cookie: {
secure: nodeEnv === 'production',
},
cookie: {
secure: nodeEnv === 'production',
sameSite: nodeEnv === 'production' ? 'none' : 'lax',
httpOnly: true,
},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/config/session.js` around lines 23 - 25, The session cookie config
currently sets only secure; update the cookie options in the session
configuration (the cookie object) to include sameSite: 'none' when nodeEnv ===
'production' so cross-site credentialed requests work; keep secure: nodeEnv ===
'production' and for non-production environments leave sameSite unset or set to
a more restrictive value (e.g., 'lax') as appropriate. Ensure this change is
applied to the cookie object used by your session middleware (the cookie
property in the session config).

};
}

module.exports = {
SESSION_TTL_SECONDS,
createSessionConfig,
};
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.3",
"connect-mongo": "^5.1.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
Expand Down
10 changes: 5 additions & 5 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const passport = require('passport');
const bodyParser = require('body-parser');
require('dotenv').config();
const cors = require('cors');
const { createSessionConfig } = require('./config/session');

// Passport configuration
require('./config/passportConfig');
Expand All @@ -28,11 +29,10 @@ app.use(cors({

// Middleware
app.use(bodyParser.json());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
}));
if (process.env.NODE_ENV === 'production') {
app.set('trust proxy', 1);
}
app.use(session(createSessionConfig()));
app.use(passport.initialize());
app.use(passport.session());

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@primer/octicons-react": "^19.25.0",
"@vitejs/plugin-react": "^4.3.3",
"axios": "^1.7.7",
"connect-mongo": "^5.1.0",
"express": "^5.2.1",
"framer-motion": "^12.23.12",
"lucide-react": "^0.525.0",
Expand Down
115 changes: 115 additions & 0 deletions spec/session.config.spec.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
const express = require('express');
const request = require('supertest');
const session = require('express-session');

const { SESSION_TTL_SECONDS, createSessionConfig } = require('../backend/config/session');

class TestSessionStore extends session.Store {
constructor() {
super();
this.sessions = new Map();
}

get(sid, callback) {
const sessionData = this.sessions.get(sid);
callback(null, sessionData ? JSON.parse(sessionData) : null);
}

set(sid, sessionData, callback) {
this.sessions.set(sid, JSON.stringify(sessionData));
callback(null);
}

destroy(sid, callback) {
this.sessions.delete(sid);
callback(null);
}
}

function createStoreFactory(store = new TestSessionStore()) {
const calls = [];

return {
calls,
store,
create(options) {
calls.push(options);
return store;
},
};
}

describe('Session configuration', () => {
it('initializes connect-mongo with the configured Mongo URL and TTL', () => {
const storeFactory = createStoreFactory();

const config = createSessionConfig({
mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test',
sessionSecret: 'test-secret',
storeFactory,
});

expect(storeFactory.calls).toEqual([{
mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test',
ttl: SESSION_TTL_SECONDS,
}]);
expect(config.store).toBe(storeFactory.store);
expect(SESSION_TTL_SECONDS).toBe(14 * 24 * 60 * 60);
});

it('does not fall back to express-session MemoryStore', () => {
const config = createSessionConfig({
mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test',
sessionSecret: 'test-secret',
storeFactory: createStoreFactory(),
});

expect(config.store).toBeDefined();
expect(config.store instanceof session.MemoryStore).toBeFalse();
});

it('uses secure cookies in production only', () => {
const productionConfig = createSessionConfig({
mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test',
sessionSecret: 'test-secret',
nodeEnv: 'production',
storeFactory: createStoreFactory(),
});
const developmentConfig = createSessionConfig({
mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test',
sessionSecret: 'test-secret',
nodeEnv: 'development',
storeFactory: createStoreFactory(),
});

expect(productionConfig.cookie.secure).toBeTrue();
expect(developmentConfig.cookie.secure).toBeFalse();
});

it('requires MongoDB configuration for persistent sessions', () => {
expect(() => createSessionConfig({
mongoUrl: '',
sessionSecret: 'test-secret',
storeFactory: createStoreFactory(),
})).toThrowError(/MONGO_URI/);
});

it('persists session data through the configured store', async () => {
const app = express();

app.use(session(createSessionConfig({
mongoUrl: 'mongodb://127.0.0.1:27017/github_tracker_test',
sessionSecret: 'test-secret',
storeFactory: createStoreFactory(),
})));
app.get('/count', (req, res) => {
req.session.views = (req.session.views || 0) + 1;
res.json({ views: req.session.views });
});

const agent = request.agent(app);

await agent.get('/count').expect(200, { views: 1 });
await agent.get('/count').expect(200, { views: 2 });
});
});
Loading