Skip to content

Commit cfac0c7

Browse files
fix(auth): optional JWT on /api/auth/config, central rate limiter passthrough, org list 403
- /api/auth/config returns oAuth availability and minimal config for guests, extended org config for authenticated users - Rate limiter middleware returns passthrough when config section is missing (dev) - GET /api/organizations no longer requires policy.isAllowed (JWT auth suffices) - Migration handles null userId and works without MongoDB replica set (no transactions) - Add integration test for org list endpoint
1 parent c7734c6 commit cfac0c7

7 files changed

Lines changed: 160 additions & 32 deletions

File tree

ERRORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ Use this file as a compact memory of recurring AI mistakes.
2323
- [2026-03-13] architecture: middleware using mongoose.model() directly -> use module's Service or Repository
2424
- [2026-03-13] tests: test file placed outside its module (e.g. lib/middlewares/tests/) -> tests belong in modules/{module}/tests/
2525
- [2026-03-13] docs: duplicate doc files covering the same topic (e.g. MIGRATION.md + MIGRATIONS.md) -> single file, no duplication
26+
- [2026-03-14] middleware: assuming config section exists in all environments (e.g. `config.rateLimit`) -> always handle missing config gracefully (passthrough/no-op); dev config often omits sections that only prod defines

lib/middlewares/rateLimiter.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
11
/**
22
* Centralized rate limiter middleware.
33
* Reads named profiles from config.rateLimit and exports a limiter for each.
4+
* When a profile is missing (e.g. in dev), returns a passthrough middleware.
45
*/
56
import rateLimit from 'express-rate-limit';
67

78
import config from '../../config/index.js';
89

9-
const limiters = {};
10-
for (const [name, opts] of Object.entries(config.rateLimit || {})) {
11-
limiters[name] = rateLimit(opts);
12-
}
10+
const passthrough = (req, res, next) => next();
11+
12+
const limiters = new Proxy({}, {
13+
get(target, name) {
14+
if (name in target) return target[name];
15+
const opts = config.rateLimit?.[name];
16+
if (opts) {
17+
target[name] = rateLimit(opts);
18+
return target[name];
19+
}
20+
return passthrough;
21+
},
22+
});
1323

1424
export default limiters;

modules/auth/controllers/auth.controller.js

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -370,23 +370,36 @@ const oauthCallback = async (req, res, next) => {
370370
* @param {Object} res - Express response object
371371
* @returns {void} Sends the public auth configuration in the HTTP response
372372
*/
373-
const getConfig = (_req, res) => {
374-
responses.success(res, 'Auth config')({
373+
const getConfig = (req, res) => {
374+
const data = {
375375
sign: {
376376
in: !!config.sign.in,
377377
up: !!config.sign.up,
378378
},
379+
oAuth: {
380+
google: !!config.oAuth?.google?.clientID,
381+
apple: !!config.oAuth?.apple?.clientID,
382+
},
379383
organizations: {
380384
enabled: !!config.organizations?.enabled,
381-
autoCreate: !!config.organizations?.autoCreate,
382-
domainMatching: !!config.organizations?.domainMatching,
383-
roles: config.organizations?.roles || [],
384-
roleDescriptions: config.organizations?.roleDescriptions || {},
385385
},
386386
mail: {
387387
configured: isMailerConfigured(),
388388
},
389-
});
389+
};
390+
391+
// Authenticated users get extended org config
392+
if (req.user) {
393+
data.organizations = {
394+
...data.organizations,
395+
autoCreate: !!config.organizations?.autoCreate,
396+
domainMatching: !!config.organizations?.domainMatching,
397+
roles: config.organizations?.roles || [],
398+
roleDescriptions: config.organizations?.roleDescriptions || {},
399+
};
400+
}
401+
402+
responses.success(res, 'Auth config')(data);
390403
};
391404

392405
/**

modules/auth/routes/auth.routes.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,14 @@ import authPassword from '../controllers/auth.password.controller.js';
1616
export default (app) => {
1717
const authLimiter = limiters.auth;
1818

19-
// Public auth config (no authentication required, rate-limited)
20-
app.route('/api/auth/config').get(authLimiter, auth.getConfig);
19+
// Auth config — optional JWT: public fields for everyone, org details for authenticated users
20+
const optionalJwt = (req, res, next) => {
21+
passport.authenticate('jwt', { session: false }, (err, user) => {
22+
if (user) req.user = user;
23+
next();
24+
})(req, res, next);
25+
};
26+
app.route('/api/auth/config').get(authLimiter, optionalJwt, auth.getConfig);
2127

2228
// Setting up the users password api
2329
app.route('/api/auth/forgot').post(authLimiter, authPassword.forgot);

modules/organizations/migrations/20260310120000-organizations-init.js

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ export async function up() {
2222

2323
// ── Step 1: Find users who don't have any membership yet ──────────
2424
const allMemberships = await Membership.find({}, 'userId').lean();
25-
const usersWithOrganization = new Set(allMemberships.map((m) => m.userId.toString()));
25+
const usersWithOrganization = new Set(allMemberships.filter((m) => m.userId).map((m) => m.userId.toString()));
2626
const usersWithoutOrganization = await User.find({
2727
_id: { $nin: [...usersWithOrganization] },
2828
}).lean();
2929

3030
// ── Step 2: Create a default organization + owner membership per user
31-
// Uses a session/transaction to prevent duplicate or orphaned orgs on retry
31+
// No transactions — compatible with standalone MongoDB (no replica set required)
3232
for (const user of usersWithoutOrganization) {
3333
// Double-check: skip if a membership was created since the initial query
3434
const alreadyHas = await Membership.findOne({ userId: user._id }).lean();
@@ -37,32 +37,23 @@ export async function up() {
3737
const firstName = user.firstName || 'User';
3838
const lastName = user.lastName || '';
3939

40+
let organization;
4041
const maxRetries = 3;
4142
for (let attempt = 0; attempt < maxRetries; attempt += 1) {
4243
const slug = await generateOrganizationSlug(firstName, lastName);
43-
const session = await mongoose.startSession();
4444
try {
45-
await session.withTransaction(async () => {
46-
const [organization] = await Organization.create(
47-
[{ name: `${firstName}'s organization`, slug, createdBy: user._id }],
48-
{ session },
49-
);
50-
51-
await Membership.create(
52-
[{ userId: user._id, organizationId: organization._id, role: 'owner' }],
53-
{ session },
54-
);
55-
56-
await User.updateOne({ _id: user._id }, { currentOrganization: organization._id }, { session });
57-
});
45+
organization = await Organization.create({ name: `${firstName}'s organization`, slug, createdBy: user._id });
5846
break; // Success — exit retry loop
5947
} catch (err) {
6048
if (err?.code === 11000 && attempt < maxRetries - 1) continue; // Slug collision — retry
6149
throw err;
62-
} finally {
63-
await session.endSession();
6450
}
6551
}
52+
53+
if (organization) {
54+
await Membership.create({ userId: user._id, organizationId: organization._id, role: 'owner' });
55+
await User.updateOne({ _id: user._id }, { currentOrganization: organization._id });
56+
}
6657
}
6758

6859
// ── Step 2b: Backfill currentOrganization for users who have a membership but no currentOrganization

modules/organizations/routes/organizations.routes.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export default (app) => {
1515
// Organization CRUD
1616
app
1717
.route('/api/organizations')
18-
.all(passport.authenticate('jwt', { session: false }), policy.isAllowed)
18+
.all(passport.authenticate('jwt', { session: false }))
1919
.get(organizations.list)
2020
.post(policy.isAllowed, model.isValid(organizationsSchema.Organization), organizations.create);
2121

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import request from 'supertest';
5+
import path from 'path';
6+
7+
import { bootstrap } from '../../../lib/app.js';
8+
import mongooseService from '../../../lib/services/mongoose.js';
9+
import config from '../../../config/index.js';
10+
11+
/**
12+
* Integration tests for organization routes.
13+
* Verifies that authenticated users can access organization endpoints
14+
* without receiving 403 errors.
15+
*/
16+
describe('Organizations integration tests:', () => {
17+
let UserService;
18+
let OrganizationsRepository;
19+
let MembershipRepository;
20+
let agent;
21+
22+
// Store original config
23+
const originalOrganizations = { ...config.organizations };
24+
25+
/**
26+
* @description Clean up a user and their associated organizations/memberships.
27+
* @param {Object} user - The user object to clean up.
28+
*/
29+
const cleanupUser = async (user) => {
30+
if (!user) return;
31+
try {
32+
const memberships = await MembershipRepository.list({ userId: user.id || user._id });
33+
for (const m of memberships) {
34+
const orgId = m.organizationId._id || m.organizationId;
35+
await MembershipRepository.deleteMany({ organizationId: orgId });
36+
await OrganizationsRepository.deleteMany({ _id: orgId });
37+
}
38+
await UserService.remove(user);
39+
} catch (_) { /* cleanup — ignore errors */ }
40+
};
41+
42+
// init
43+
beforeAll(async () => {
44+
try {
45+
const init = await bootstrap();
46+
UserService = (await import(path.resolve('./modules/users/services/users.service.js'))).default;
47+
OrganizationsRepository = (await import(path.resolve('./modules/organizations/repositories/organizations.repository.js'))).default;
48+
MembershipRepository = (await import(path.resolve('./modules/organizations/repositories/organizations.membership.repository.js'))).default;
49+
agent = request.agent(init.app);
50+
} catch (err) {
51+
console.log(err);
52+
expect(err).toBeFalsy();
53+
}
54+
});
55+
56+
describe('GET /api/organizations', () => {
57+
let user;
58+
59+
beforeAll(async () => {
60+
config.organizations = { enabled: true, autoCreate: true, domainMatching: false };
61+
try {
62+
const result = await agent
63+
.post('/api/auth/signup')
64+
.send({
65+
firstName: 'OrgList',
66+
lastName: 'User',
67+
email: 'orglist@test.com',
68+
password: 'W@os.jsI$Aw3$0m3',
69+
provider: 'local',
70+
})
71+
.expect(200);
72+
user = result.body.user;
73+
} catch (err) {
74+
console.log(err);
75+
expect(err).toBeFalsy();
76+
}
77+
});
78+
79+
test('should return 200 and an array for an authenticated user', async () => {
80+
try {
81+
const result = await agent.get('/api/organizations').expect(200);
82+
83+
expect(result.body.type).toBe('success');
84+
expect(result.body.message).toBe('organization list');
85+
expect(result.body.data).toBeInstanceOf(Array);
86+
} catch (err) {
87+
console.log(err);
88+
expect(err).toBeFalsy();
89+
}
90+
});
91+
92+
afterAll(async () => {
93+
await cleanupUser(user);
94+
});
95+
});
96+
97+
// Mongoose disconnect
98+
afterAll(async () => {
99+
config.organizations = { ...originalOrganizations };
100+
try {
101+
await mongooseService.disconnect();
102+
} catch (err) {
103+
console.log(err);
104+
expect(err).toBeFalsy();
105+
}
106+
});
107+
});

0 commit comments

Comments
 (0)