Skip to content

Commit f0e7a83

Browse files
feat(auth): invite-only / capped signup gate (#3715)
* feat(users): add exact count() for signup cap enforcement * feat(auth): scaffold signup invitation model, schema, and CASL policy * fix(auth): drop duplicate token unique index on Invitation (keep explicit index) * feat(auth): invitation repository + service (token gen, validation, consume) * feat(auth): admin invitation CRUD + public verify endpoint + email template * fix(auth): inline invitation routes into auth.routes (single reg, before :strategy wildcard) Removes auth.invitation.routes.js so the glob auto-loader never picks it up, eliminating the double-registration bug (two DB hits per DELETE, duplicate app.param binding). Invitation routes are now declared once inside auth.routes.js, before the greedy /api/auth/:strategy wildcard. * feat(auth): gate local signup by cap + invite token, consume on success Two AND-ed guards on POST /api/auth/signup: (1) capacity ceiling (config.sign.cap, invited users count toward the cap) blocks everyone when total >= cap; (2) eligibility requires config.sign.up OR a valid inviteToken query param (token consumed after account is fully provisioned). Config gains sign.cap and sign.inviteExpiresInDays. * feat(auth): gate OAuth signup by cap + email-matched invite, consume on success * docs(auth): document signup access control (cap + invitations) in README + OpenAPI * chore(auth): verify fixups for invitation signup gate - fix: optional chain req.query?.inviteToken to guard against missing query object (broke analytics unit tests that inject req without a query key) - fix: mock InvitationService + add UserService.count mock in analytics.identify.unit.tests to prevent MissingSchemaError on lazy mongoose.model('Invitation') call at repository import time - test: extend auth.invitation.unit.tests — email-send branch, findValidByEmail null-guard, list/get/revoke delegation, invitationAbilities admin/non-admin paths, invitationSubjectRegistration predicate (policy 100% coverage) * fix(auth): enforce invite email-pin on no-email signup; hide token from admin list * fix(auth): address CodeRabbit/Copilot review findings on invite gate - email template: add non-empty <title> to signup-invite.html - auth.controller: short-circuit UserService.count() when cap is null - auth.controller: coerce sign.cap to Number + guard non-finite at gate - auth.controller: only consume invite when it actually opened the gate (sign.up=true = invite not required, burning it would be wrong) - auth.invitation.controller: add full JSDoc (@param/@returns) to all controller functions per project standard - auth.invitations.yml: add InvitationListItem schema (token omitted); admin list endpoint now references it instead of Invitation - auth.invitation.model.mongoose.js: add JSDoc to addID virtual getter - auth.invitation.integration.tests.js: add @returns to test helpers; fix cap test to create admin before setting cap (was order-dependent) - auth.signout.controller.unit.tests.js: complete InvitationService mock - auth.silent.catch.unit.tests.js: complete InvitationService mock * fix(auth): canonicalize invited signup email to invite (single-use under concurrent case-variants) * test(users): isolate count unit test from Organization model (deterministic CI) Add repository-level mocks for organizations.repository.js and organizations.membership.repository.js so mongoose.model('Organization') is never evaluated at module-link time with --maxWorkers=2. Service-level mocks intercept call paths but ESM static imports on the real service files are still resolved by the V8 VM module linker in CI, causing MissingSchemaError when the Organization schema hasn't been registered yet in that worker's context. * fix(auth): require verified provider email before OAuth invite matching (gate bypass)
1 parent 56b17a0 commit f0e7a83

21 files changed

Lines changed: 1308 additions & 8 deletions

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Designed to be cloned into downstream projects and kept up-to-date via `git merg
3636
- **User data privacy** : delete all - get all - send all by mail
3737
- **Admin** : list users - get user - edit user - delete user
3838
- **Organizations** : multi-tenant organization management - create, update, delete orgs - member invite, role management (owner/admin/member) - platform admin org listing
39+
- **Signup access control** : invite-only signup (single-use token links) + hard account cap (beta gating) - admin-managed invitations, public signup auto-locks at the cap
3940
- **CASL v2 Authorization** : document-level permission checks via [@casl/ability](https://casl.js.org/) - replaces route-level role rules with per-document conditions (ownership, org scope)
4041
- **Migration System** : automatic database migrations at boot - tracks executed scripts in MongoDB - idempotent reruns
4142

@@ -197,6 +198,33 @@ Both file types are optional and can be used independently or together. Per-modu
197198

198199
> See [MIGRATIONS.md](MIGRATIONS.md) for the full migration guide from route-level CASL to document-level CASL v2.
199200
201+
## :lock: Signup Access Control (cap + invitations)
202+
203+
Signup is governed by two AND-ed gates in `auth.controller.js`:
204+
205+
- **Capacity**`config.sign.cap` is a hard ceiling on the **total** number of accounts (invited users included). Once reached, *all* signup is locked.
206+
- **Eligibility**`config.sign.up` (public self-serve) **OR** a valid invitation re-opens signup for a specific email.
207+
208+
Invitations are **single-use** and **expiring** (`config.sign.inviteExpiresInDays`, default 14). Local signup carries the token as a query param (`/signup?inviteToken=…`); OAuth signup matches the invite on the provider's verified email.
209+
210+
| Key | Default | Effect |
211+
|-----|---------|--------|
212+
| `sign.up` | `true` | Public self-serve signup enabled |
213+
| `sign.cap` | `null` | `null` = unlimited; integer = hard ceiling on total accounts (invited included) |
214+
| `sign.inviteExpiresInDays` | `14` | Invite link validity (days) |
215+
216+
Common setups:
217+
- **Invite-only:** `sign.up = false` → only invitation holders can sign up.
218+
- **Beta cap (e.g. 50):** `sign.cap = 50` → open self-serve until 50 accounts, then locked.
219+
220+
| Method | Endpoint | Auth | Description |
221+
| -------- | ----------------------------------------- | --------- | ------------------------------------ |
222+
| `POST` | `/api/auth/invitations` | JWT+Admin | Create + email a signup invitation |
223+
| `GET` | `/api/auth/invitations` | JWT+Admin | List invitations |
224+
| `DELETE` | `/api/auth/invitations/:invitationId` | JWT+Admin | Revoke an invitation |
225+
| `GET` | `/api/auth/invitations/verify/:token` | Public | Check a token → `{ valid, email }` |
226+
| `POST` | `/api/auth/signup?inviteToken=…` | Public | Signup; a valid token bypasses closed signup |
227+
200228
## :credit_card: Billing — Version Namespace Contract
201229

202230
When `meterMode` is enabled, three values must be aligned so `getPlanByVersion` resolves correctly and plan ratios are applied (not the ratio=1 fallback):
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!DOCTYPE html>
2+
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
3+
<head>
4+
<title>You've been invited — complete your signup</title>
5+
</head>
6+
<body>
7+
<p>Hello,</p>
8+
<p>You've been invited to join <b>{{appName}}</b>.</p>
9+
<p>Click here to create your account: <a href="{{url}}">{{url}}</a></p>
10+
<p>The <b>{{appName}}</b> Team.</p>
11+
<br />
12+
<i style="color: #9b9b9b"
13+
>If you weren't expecting this invitation you can ignore this email. Please
14+
do not reply to this email, you can contact us
15+
<a href="mailto:{{appContact}}">here</a>.</i
16+
>
17+
</body>
18+
</html>

lib/services/tests/analytics.identify.unit.tests.js

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ describe('Analytics identify on auth events:', () => {
4343
getBrut: jest.fn().mockResolvedValue({ ...mockUser }),
4444
update: jest.fn().mockResolvedValue(mockUser),
4545
remove: jest.fn(),
46+
count: jest.fn().mockResolvedValue(0),
47+
},
48+
}));
49+
50+
jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({
51+
default: {
52+
findValid: jest.fn().mockResolvedValue(null),
53+
findValidByEmail: jest.fn().mockResolvedValue(null),
54+
consume: jest.fn().mockResolvedValue(null),
55+
create: jest.fn(),
56+
list: jest.fn(),
57+
get: jest.fn(),
58+
revoke: jest.fn(),
4659
},
4760
}));
4861

@@ -227,7 +240,19 @@ describe('Analytics identify on auth events:', () => {
227240
};
228241

229242
jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
230-
default: { getBrut: jest.fn(), update: jest.fn() },
243+
default: { getBrut: jest.fn(), update: jest.fn(), count: jest.fn().mockResolvedValue(0) },
244+
}));
245+
246+
jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({
247+
default: {
248+
findValid: jest.fn().mockResolvedValue(null),
249+
findValidByEmail: jest.fn().mockResolvedValue(null),
250+
consume: jest.fn().mockResolvedValue(null),
251+
create: jest.fn(),
252+
list: jest.fn(),
253+
get: jest.fn(),
254+
revoke: jest.fn(),
255+
},
231256
}));
232257

233258
jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({
@@ -331,7 +356,19 @@ describe('Analytics identify on auth events:', () => {
331356
};
332357

333358
jest.unstable_mockModule('../../../modules/users/services/users.service.js', () => ({
334-
default: { getBrut: jest.fn(), update: jest.fn() },
359+
default: { getBrut: jest.fn(), update: jest.fn(), count: jest.fn().mockResolvedValue(0) },
360+
}));
361+
362+
jest.unstable_mockModule('../../../modules/auth/services/auth.invitation.service.js', () => ({
363+
default: {
364+
findValid: jest.fn().mockResolvedValue(null),
365+
findValidByEmail: jest.fn().mockResolvedValue(null),
366+
consume: jest.fn().mockResolvedValue(null),
367+
create: jest.fn(),
368+
list: jest.fn(),
369+
get: jest.fn(),
370+
revoke: jest.fn(),
371+
},
335372
}));
336373

337374
jest.unstable_mockModule('../../../modules/organizations/services/organizations.service.js', () => ({

modules/auth/config/auth.development.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ const config = {
1313
sign: {
1414
in: true, // disable signin
1515
up: true, // disable signup
16+
cap: null, // null = unlimited; integer = hard ceiling on TOTAL accounts (invited included)
17+
inviteExpiresInDays: 14, // signup invite link validity
1618
},
1719
// jwt is for token authentication
1820
jwt: {

modules/auth/controllers/auth.controller.js

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import passport from 'passport';
66
import jwt from 'jsonwebtoken';
77

88
import UserService from '../../users/services/users.service.js';
9+
import InvitationService from '../services/auth.invitation.service.js';
910
import config from '../../../config/index.js';
1011
import model from '../../../lib/middlewares/model.js';
1112
import mails from '../../../lib/helpers/mailer/index.js';
@@ -64,9 +65,31 @@ const sendVerificationEmail = async (user, verificationToken) => {
6465
*/
6566
const signup = async (req, res) => {
6667
try {
67-
if (!config.sign.up) return responses.error(res, 404, 'Signup error', 'Registration is currently deactivated')();
68+
// Two AND-ed gates: (1) capacity — a hard ceiling on total accounts,
69+
// invited users included; (2) eligibility — public signup open OR a valid
70+
// invite token (read from query: model.isValid strips unknown body keys).
71+
// Short-circuit count() when cap is not set (null = unlimited) to avoid a
72+
// collection-wide count on every signup request for uncapped deployments.
73+
const cap = config.sign.cap != null ? Number(config.sign.cap) : null;
74+
const capReached = cap != null && Number.isFinite(cap) && (await UserService.count()) >= cap;
75+
let invite = null;
76+
if (req.query?.inviteToken) {
77+
invite = await InvitationService.findValid(req.query.inviteToken, req.body.email);
78+
// An invite is bound to its email; never honor it for a signup that supplies
79+
// no email to match. findValid stays lenient on a falsy email because the
80+
// public verify endpoint reuses it, so enforce the pin here.
81+
if (invite && invite.email && !req.body.email) invite = null;
82+
}
83+
if (capReached || (!config.sign.up && !invite)) {
84+
return responses.error(res, 404, 'Signup error', 'Registration is currently deactivated')();
85+
}
6886
// Force default role on public signup — clients must not self-assign admin
6987
const safeBody = { ...req.body, roles: ['user'] };
88+
// Invite-gated signup: canonicalize the account email to the invite's pinned
89+
// (lowercased) email. Enforces the pin exactly AND makes the case-sensitive
90+
// unique-email index a reliable single-use backstop — concurrent case-variant
91+
// signups on the same invite collide on the index instead of creating two accounts.
92+
if (invite) safeBody.email = invite.email;
7093
const user = await UserService.create(safeBody);
7194

7295
// Handle email verification — rollback user on failure to avoid orphaned accounts
@@ -122,6 +145,11 @@ const signup = async (req, res) => {
122145
});
123146
} catch (_) { /* analytics must not break auth */ }
124147

148+
// Single-use: consume only when the invite actually opened the gate (signup
149+
// was closed, so the invite was required). When signup is open, a token can
150+
// be presented but is not required — consuming it would silently burn it.
151+
if (invite && !config.sign.up) await InvitationService.consume(invite.id);
152+
125153
const token = jwt.sign({ userId: user.id }, config.jwt.secret, {
126154
expiresIn: config.jwt.expiresIn,
127155
});
@@ -404,7 +432,17 @@ const checkOAuthUserProfile = async (profil, key, provider) => {
404432
}
405433
// 4. No match → create new user
406434
try {
407-
if (!config.sign.up) {
435+
// Same two gates as local signup. OAuth can't carry an invite token through
436+
// the redirect, so the invite is matched on the provider's verified email.
437+
// Short-circuit count() when cap is not set (null = unlimited).
438+
const oauthCap = config.sign.cap != null ? Number(config.sign.cap) : null;
439+
const capReached = oauthCap != null && Number.isFinite(oauthCap) && (await UserService.count()) >= oauthCap;
440+
// Only honor an OAuth invite when the provider verified the email — otherwise a
441+
// provider returning an arbitrary unverified email could open the gate on an
442+
// invite meant for someone else.
443+
const hasVerifiedProviderEmail = !!(profil.email && profil.emailVerifiedByProvider);
444+
const oauthInvite = config.sign.up || !hasVerifiedProviderEmail ? null : await InvitationService.findValidByEmail(profil.email);
445+
if (capReached || (!config.sign.up && !oauthInvite)) {
408446
// Mirror the local signup endpoint's error shape so clients see the same
409447
// `message`/`description` regardless of signup method (see `signup` above).
410448
throw new AppError('Signup error', {
@@ -426,7 +464,10 @@ const checkOAuthUserProfile = async (profil, key, provider) => {
426464
const error = model.checkError(result);
427465
if (error) throw new AppError('Schema validation error', { code: 'VALIDATION_ERROR', details: { message: error } });
428466
// else return req.body with the data after Zod validation
429-
return await UserService.create(result.value);
467+
if (oauthInvite) result.value.email = oauthInvite.email;
468+
const createdUser = await UserService.create(result.value);
469+
if (oauthInvite) await InvitationService.consume(oauthInvite.id);
470+
return createdUser;
430471
} catch (err) {
431472
if (err instanceof AppError) throw err;
432473
throw new AppError('oAuth', { code: 'CONTROLLER_ERROR', details: err.details || err });
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import errors from '../../../lib/helpers/errors.js';
5+
import responses from '../../../lib/helpers/responses.js';
6+
import InvitationService from '../services/auth.invitation.service.js';
7+
8+
/**
9+
* @desc Admin: create + email a signup invitation
10+
* @param {Object} req - Express request object
11+
* @param {string} req.body.email - Email address to invite
12+
* @param {Object} req.user - Authenticated admin user
13+
* @param {Object} res - Express response object
14+
* @returns {Promise<void>} Sends HTTP 200 with created invitation or 422 on error
15+
*/
16+
const create = async (req, res) => {
17+
try {
18+
const invitation = await InvitationService.create(req.body.email, req.user);
19+
responses.success(res, 'invitation created')(invitation);
20+
} catch (err) {
21+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
22+
}
23+
};
24+
25+
/**
26+
* @desc Admin: list all signup invitations
27+
* @param {Object} req - Express request object
28+
* @param {Object} res - Express response object
29+
* @returns {Promise<void>} Sends HTTP 200 with invitation array or 422 on error
30+
*/
31+
const list = async (req, res) => {
32+
try {
33+
const invitations = await InvitationService.list();
34+
responses.success(res, 'invitation list')(invitations);
35+
} catch (err) {
36+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
37+
}
38+
};
39+
40+
/**
41+
* @desc Admin: revoke an invitation
42+
* @param {Object} req - Express request object
43+
* @param {Object} req.invitation - Loaded invitation document (set by invitationByID middleware)
44+
* @param {string} req.invitation.id - Invitation id
45+
* @param {Object} res - Express response object
46+
* @returns {Promise<void>} Sends HTTP 200 with deleted id or 422 on error
47+
*/
48+
const remove = async (req, res) => {
49+
try {
50+
await InvitationService.revoke(req.invitation.id);
51+
responses.success(res, 'invitation deleted')({ id: req.invitation.id });
52+
} catch (err) {
53+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
54+
}
55+
};
56+
57+
/**
58+
* @desc Public: report whether a token is a valid invite (+ prefill email)
59+
* @param {Object} req - Express request object
60+
* @param {string} req.params.token - Invitation token to verify
61+
* @param {Object} res - Express response object
62+
* @returns {Promise<void>} Sends HTTP 200 with { valid, email } or 422 on error
63+
*/
64+
const verify = async (req, res) => {
65+
try {
66+
const invite = await InvitationService.findValid(req.params.token);
67+
responses.success(res, 'invitation verify')({ valid: !!invite, email: invite ? invite.email : null });
68+
} catch (err) {
69+
responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err);
70+
}
71+
};
72+
73+
/**
74+
* @desc Middleware to load an invitation into req.invitation by id param
75+
* @param {Object} req - Express request object
76+
* @param {Object} res - Express response object
77+
* @param {Function} next - Express next middleware function
78+
* @param {string} id - Invitation id from URL param
79+
* @returns {Promise<void>} Calls next() with invitation on req, or sends 404
80+
*/
81+
const invitationByID = async (req, res, next, id) => {
82+
try {
83+
const invitation = await InvitationService.get(id);
84+
if (!invitation) return responses.error(res, 404, 'Not Found', 'No invitation with that identifier has been found')();
85+
req.invitation = invitation;
86+
next();
87+
} catch (err) {
88+
next(err);
89+
}
90+
};
91+
92+
export default { create, list, remove, verify, invitationByID };

0 commit comments

Comments
 (0)