Skip to content

Commit ec3de30

Browse files
fix(auth): canonicalize invited signup email to invite (single-use under concurrent case-variants)
1 parent 4fd1edc commit ec3de30

2 files changed

Lines changed: 21 additions & 0 deletions

File tree

modules/auth/controllers/auth.controller.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ const signup = async (req, res) => {
8585
}
8686
// Force default role on public signup — clients must not self-assign admin
8787
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;
8893
const user = await UserService.create(safeBody);
8994

9095
// Handle email verification — rollback user on failure to avoid orphaned accounts
@@ -455,6 +460,7 @@ const checkOAuthUserProfile = async (profil, key, provider) => {
455460
const error = model.checkError(result);
456461
if (error) throw new AppError('Schema validation error', { code: 'VALIDATION_ERROR', details: { message: error } });
457462
// else return req.body with the data after Zod validation
463+
if (oauthInvite) result.value.email = oauthInvite.email;
458464
const createdUser = await UserService.create(result.value);
459465
if (oauthInvite) await InvitationService.consume(oauthInvite.id);
460466
return createdUser;

modules/auth/tests/auth.invitation.integration.tests.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe('Signup invitations:', () => {
2727
'inv-admin@test.com',
2828
'inv-user@test.com',
2929
'inv-admin2@test.com',
30+
'canon@example.com',
3031
]) {
3132
try {
3233
const existing = await UserService.getBrut({ email });
@@ -223,6 +224,20 @@ describe('Signup invitations:', () => {
223224
const recheck = await request(app).get(`/api/auth/invitations/verify/${token}`);
224225
expect(recheck.body.data.valid).toBe(true); // not consumed
225226
});
227+
228+
test('invited signup canonicalizes account email to the invite (case-insensitive pin → lowercased)', async () => {
229+
config.sign.up = false; config.sign.cap = null;
230+
const adminAgent = await createAdminAndSignin();
231+
const created = await adminAgent.post('/api/auth/invitations').send({ email: 'canon@example.com' });
232+
const { token } = created.body.data;
233+
// sign up with an UPPER-CASE variant of the invited email
234+
const res = await request(app)
235+
.post(`/api/auth/signup?inviteToken=${token}`)
236+
.send({ email: 'CANON@Example.com', password: 'Sup3rStr0ng!' });
237+
expect(res.status).toBe(200);
238+
// account email must be the invite's canonical lowercased value, not the submitted case-variant
239+
expect(res.body.user.email).toBe('canon@example.com');
240+
});
226241
});
227242

228243
describe('OAuth signup gate (cap + email-matched invite)', () => {

0 commit comments

Comments
 (0)