Skip to content

Commit 40f9dcf

Browse files
committed
fix: suppress verification emails for placeholder sessions in auth flow
1 parent 7b13303 commit 40f9dcf

2 files changed

Lines changed: 401 additions & 15 deletions

File tree

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
/**
2+
* Regression coverage for the placeholder-session email-suppression guard in the
3+
* `callback` (register) and `resendVerification` handlers. When a user attempts to
4+
* register with an already-used email, the server creates a placeholder/temporary
5+
* session and routes the request to /auth/verify to preserve the enumeration
6+
* defense. Historically this also fired a real verification email at the actual
7+
* owner of the inbox — useless (the verify flow just destroys the session) and
8+
* effectively spam. These tests lock in that no email is sent for placeholder
9+
* sessions while still being sent for legitimate new registrations.
10+
*/
11+
import { beforeEach, describe, expect, it, vi } from 'vitest';
12+
import { routeDefinition } from '../auth.controller';
13+
14+
const emailMocks = vi.hoisted(() => ({
15+
sendEmailVerification: vi.fn(),
16+
sendVerificationCode: vi.fn(),
17+
sendWelcomeEmail: vi.fn(),
18+
sendPasswordReset: vi.fn(),
19+
sendAuthenticationChangeConfirmation: vi.fn(),
20+
}));
21+
22+
const authServerMocks = vi.hoisted(() => {
23+
const PLACEHOLDER_USER_ID = '00000000-0000-0000-0000-000000000000';
24+
class AuthError extends Error {
25+
type = 'auth_error';
26+
}
27+
class InvalidSession extends AuthError {}
28+
class InvalidAction extends AuthError {}
29+
class InvalidProvider extends AuthError {}
30+
class InvalidVerificationToken extends AuthError {}
31+
class InvalidParameters extends AuthError {}
32+
class ExpiredVerificationToken extends AuthError {}
33+
class IdentityLinkingNotAllowed extends AuthError {}
34+
class ProviderEmailNotVerified extends AuthError {}
35+
class ProviderNotAllowed extends AuthError {}
36+
37+
return {
38+
PLACEHOLDER_USER_ID,
39+
CURRENT_TOS_VERSION: 'v1',
40+
EMAIL_VERIFICATION_TOKEN_DURATION_HOURS: 24,
41+
PASSWORD_RESET_DURATION_MINUTES: 30,
42+
TOKEN_DURATION_MINUTES: 10,
43+
AuthError,
44+
InvalidSession,
45+
InvalidAction,
46+
InvalidProvider,
47+
InvalidVerificationToken,
48+
InvalidParameters,
49+
ExpiredVerificationToken,
50+
IdentityLinkingNotAllowed,
51+
ProviderEmailNotVerified,
52+
ProviderNotAllowed,
53+
acceptTos: vi.fn(),
54+
clearOauthCookies: vi.fn(),
55+
convertBase32ToHex: vi.fn(),
56+
createOrUpdateOtpAuthFactor: vi.fn(),
57+
createRememberDevice: vi.fn(),
58+
createUserActivityFromReq: vi.fn(),
59+
createUserActivityFromReqWithError: vi.fn(),
60+
discoverSsoConfigByDomain: vi.fn(),
61+
ensureAuthError: vi.fn((error: unknown) => error),
62+
generate2faTotpUrl: vi.fn(),
63+
generatePasswordResetToken: vi.fn(),
64+
generateRandomCode: vi.fn(() => '123456'),
65+
generateRandomString: vi.fn(() => 'random-string'),
66+
getApiAddressFromReq: vi.fn(() => '127.0.0.1'),
67+
getAuthorizationUrl: vi.fn(),
68+
getCookieConfig: vi.fn(() => ({
69+
csrfToken: { name: 'csrfToken', options: {} },
70+
doubleCSRFToken: { name: 'doubleCSRFToken', options: {} },
71+
pkceCodeVerifier: { name: 'pkceCodeVerifier', options: {} },
72+
nonce: { name: 'nonce', options: {} },
73+
linkIdentity: { name: 'linkIdentity', options: {} },
74+
returnUrl: { name: 'returnUrl', options: {} },
75+
rememberDevice: { name: 'rememberDevice', options: {} },
76+
redirectUrl: { name: 'redirectUrl', options: {} },
77+
teamInviteState: { name: 'teamInviteState', options: {} },
78+
})),
79+
getLoginConfiguration: vi.fn(),
80+
getTeamLoginConfigWithSso: vi.fn(),
81+
getTotpAuthenticationFactor: vi.fn(),
82+
handleSignInOrRegistration: vi.fn(),
83+
handleSsoLogin: vi.fn(),
84+
hasRememberDeviceRecord: vi.fn(),
85+
initSession: vi.fn(),
86+
linkIdentityToUser: vi.fn(),
87+
getProviders: vi.fn(() => ({
88+
credentials: { type: 'credentials', provider: 'credentials' },
89+
})),
90+
oidcService: {},
91+
resetUserPassword: vi.fn(),
92+
samlService: {},
93+
setUserEmailVerified: vi.fn(),
94+
timingSafeStringCompare: vi.fn((left: string, right: string) => left === right),
95+
validateCallback: vi.fn(),
96+
validateRedirectUrl: vi.fn((url: string) => url || 'https://client.test'),
97+
verify2faTotpOrThrow: vi.fn(),
98+
verifyCSRFFromRequestOrThrow: vi.fn(),
99+
};
100+
});
101+
102+
vi.mock('@jetstream/api-config', () => ({
103+
ENV: {
104+
JETSTREAM_SERVER_URL: 'https://server.test',
105+
JETSTREAM_CLIENT_URL: 'https://client.test',
106+
USE_SECURE_COOKIES: false,
107+
JETSTREAM_AUTH_SECRET: 'auth-secret',
108+
JETSTREAM_SESSION_SECRET: 'session-secret',
109+
ENVIRONMENT: 'test',
110+
CI: false,
111+
},
112+
getExceptionLog: (error: unknown) => ({ error: error instanceof Error ? error.message : error }),
113+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
114+
prisma: {},
115+
rollbarServer: { error: vi.fn(), warn: vi.fn() },
116+
}));
117+
118+
vi.mock('@jetstream/auth/server', () => authServerMocks);
119+
120+
vi.mock('@jetstream/email', () => emailMocks);
121+
122+
vi.mock('@jetstream/prisma', () => ({
123+
isPrismaError: () => false,
124+
Prisma: {
125+
PrismaClientKnownRequestError: class extends Error {},
126+
PrismaClientUnknownRequestError: class extends Error {},
127+
PrismaClientValidationError: class extends Error {},
128+
},
129+
toTypedPrismaError: () => ({ code: undefined }),
130+
}));
131+
132+
// Stubbed so response.handlers / route.utils don't pull in Prisma-backed DB code.
133+
vi.mock('../../db/salesforce-org.db', () => ({
134+
findByUniqueId_UNSAFE: vi.fn(),
135+
updateOrg_UNSAFE: vi.fn(),
136+
}));
137+
138+
// Stub the response helpers — we only need to observe them, not actually write to a socket.
139+
vi.mock('../../utils/response.handlers', () => ({
140+
redirect: vi.fn(),
141+
sendJson: vi.fn(),
142+
setCsrfCookie: vi.fn(),
143+
sendHtml: vi.fn(),
144+
setCookieHeaders: vi.fn(),
145+
}));
146+
147+
type MockRequest = {
148+
method: string;
149+
headers: { cookie: string };
150+
params: Record<string, string>;
151+
query: Record<string, unknown>;
152+
body: Record<string, unknown>;
153+
session: Record<string, unknown>;
154+
get: (name: string) => string | undefined;
155+
log: { info: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn>; debug: ReturnType<typeof vi.fn> };
156+
ip: string;
157+
};
158+
159+
function makeReq(overrides: Partial<MockRequest> = {}): MockRequest {
160+
return {
161+
method: 'POST',
162+
headers: { cookie: '' },
163+
params: { provider: 'credentials' },
164+
query: {},
165+
body: {},
166+
session: {
167+
id: 'session-id',
168+
destroy: vi.fn((cb?: (err?: unknown) => void) => cb?.()),
169+
save: vi.fn((cb?: (err?: unknown) => void) => cb?.()),
170+
},
171+
get: vi.fn(() => undefined),
172+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
173+
ip: '127.0.0.1',
174+
...overrides,
175+
};
176+
}
177+
178+
function makeRes() {
179+
const res = {
180+
locals: { cookies: {}, requestId: 'request-id', ipAddress: '127.0.0.1' },
181+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
182+
json: vi.fn(),
183+
status: vi.fn(),
184+
set: vi.fn(),
185+
redirect: vi.fn(),
186+
appendHeader: vi.fn(),
187+
cookie: vi.fn(),
188+
};
189+
res.status.mockReturnValue(res as never);
190+
res.json.mockReturnValue(res as never);
191+
return res;
192+
}
193+
194+
const VALID_PASSWORD = 'ValidP@ssw0rd!';
195+
196+
describe('auth.controller - placeholder session email suppression', () => {
197+
beforeEach(() => {
198+
vi.clearAllMocks();
199+
// getCookieConfig / getProviders are cleared by clearAllMocks - re-prime their return values.
200+
authServerMocks.getCookieConfig.mockReturnValue({
201+
csrfToken: { name: 'csrfToken', options: {} },
202+
doubleCSRFToken: { name: 'doubleCSRFToken', options: {} },
203+
pkceCodeVerifier: { name: 'pkceCodeVerifier', options: {} },
204+
nonce: { name: 'nonce', options: {} },
205+
linkIdentity: { name: 'linkIdentity', options: {} },
206+
returnUrl: { name: 'returnUrl', options: {} },
207+
rememberDevice: { name: 'rememberDevice', options: {} },
208+
redirectUrl: { name: 'redirectUrl', options: {} },
209+
teamInviteState: { name: 'teamInviteState', options: {} },
210+
} as never);
211+
authServerMocks.getProviders.mockReturnValue({
212+
credentials: { type: 'credentials', provider: 'credentials' },
213+
} as never);
214+
authServerMocks.generateRandomCode.mockReturnValue('123456');
215+
authServerMocks.ensureAuthError.mockImplementation((error: unknown) => error);
216+
authServerMocks.getApiAddressFromReq.mockReturnValue('127.0.0.1');
217+
authServerMocks.validateRedirectUrl.mockImplementation((url: string) => url || 'https://client.test');
218+
});
219+
220+
describe('register callback', () => {
221+
it('does not send a verification email for a placeholder (already-registered email) session', async () => {
222+
authServerMocks.handleSignInOrRegistration.mockResolvedValue({
223+
user: {
224+
id: authServerMocks.PLACEHOLDER_USER_ID,
225+
email: 'existing@example.com',
226+
userId: 'invalid|existing@example.com',
227+
name: 'Invalid User',
228+
emailVerified: false,
229+
authFactors: [],
230+
teamMembership: null,
231+
tosAcceptedVersion: 'invalid',
232+
},
233+
sessionDetails: { isTemporary: true },
234+
isNewUser: false,
235+
providerType: 'credentials',
236+
provider: 'credentials',
237+
mfaEnrollmentRequired: false,
238+
teamInviteResponse: null,
239+
verificationRequired: { email: true, twoFactor: [] },
240+
});
241+
authServerMocks.initSession.mockImplementation(async (req: any, sessionData: any) => {
242+
req.session.user = sessionData.user;
243+
req.session.sessionDetails = sessionData.sessionDetails;
244+
req.session.pendingVerification = [{ type: 'email', exp: Date.now() + 60_000, token: '123456' }];
245+
});
246+
247+
const req = makeReq({
248+
body: {
249+
action: 'register',
250+
csrfToken: 'csrf-token',
251+
email: 'existing@example.com',
252+
name: 'Test User',
253+
password: VALID_PASSWORD,
254+
tosVersion: 'v1',
255+
},
256+
});
257+
const res = makeRes();
258+
const next = vi.fn();
259+
260+
const handler = routeDefinition.callback.controllerFn();
261+
await handler(req as never, res as never, next);
262+
263+
expect(next).not.toHaveBeenCalled();
264+
expect(authServerMocks.handleSignInOrRegistration).toHaveBeenCalledTimes(1);
265+
expect(authServerMocks.initSession).toHaveBeenCalledTimes(1);
266+
expect(emailMocks.sendEmailVerification).not.toHaveBeenCalled();
267+
expect(emailMocks.sendVerificationCode).not.toHaveBeenCalled();
268+
});
269+
270+
it('sends a verification email for a legitimate new-user registration', async () => {
271+
authServerMocks.handleSignInOrRegistration.mockResolvedValue({
272+
user: {
273+
id: 'real-user-id',
274+
email: 'new@example.com',
275+
userId: 'userid|real-user-id',
276+
name: 'Real User',
277+
emailVerified: false,
278+
authFactors: [],
279+
teamMembership: null,
280+
tosAcceptedVersion: 'v1',
281+
},
282+
sessionDetails: undefined,
283+
isNewUser: true,
284+
providerType: 'credentials',
285+
provider: 'credentials',
286+
mfaEnrollmentRequired: false,
287+
teamInviteResponse: null,
288+
verificationRequired: { email: true, twoFactor: [] },
289+
});
290+
authServerMocks.initSession.mockImplementation(async (req: any, sessionData: any) => {
291+
req.session.user = sessionData.user;
292+
req.session.sessionDetails = sessionData.sessionDetails;
293+
req.session.pendingVerification = [{ type: 'email', exp: Date.now() + 60_000, token: '123456' }];
294+
});
295+
296+
const req = makeReq({
297+
body: {
298+
action: 'register',
299+
csrfToken: 'csrf-token',
300+
email: 'new@example.com',
301+
name: 'New User',
302+
password: VALID_PASSWORD,
303+
tosVersion: 'v1',
304+
},
305+
});
306+
const res = makeRes();
307+
const next = vi.fn();
308+
309+
const handler = routeDefinition.callback.controllerFn();
310+
await handler(req as never, res as never, next);
311+
312+
expect(next).not.toHaveBeenCalled();
313+
expect(emailMocks.sendEmailVerification).toHaveBeenCalledTimes(1);
314+
expect(emailMocks.sendEmailVerification).toHaveBeenCalledWith('new@example.com', '123456', expect.any(Number));
315+
});
316+
});
317+
318+
describe('resendVerification', () => {
319+
it('does not re-send a verification email for a placeholder session', async () => {
320+
const req = makeReq({
321+
body: { csrfToken: 'csrf-token', type: 'email' },
322+
session: {
323+
id: 'session-id',
324+
destroy: vi.fn(),
325+
save: vi.fn(),
326+
user: {
327+
id: authServerMocks.PLACEHOLDER_USER_ID,
328+
email: 'existing@example.com',
329+
},
330+
sessionDetails: { isTemporary: true },
331+
pendingVerification: [{ type: 'email', exp: Date.now() + 60_000, token: 'old-token' }],
332+
},
333+
});
334+
const res = makeRes();
335+
const next = vi.fn();
336+
337+
const handler = routeDefinition.resendVerification.controllerFn();
338+
await handler(req as never, res as never, next);
339+
340+
expect(next).not.toHaveBeenCalled();
341+
expect(emailMocks.sendEmailVerification).not.toHaveBeenCalled();
342+
expect(emailMocks.sendVerificationCode).not.toHaveBeenCalled();
343+
});
344+
345+
it('re-sends a verification email for a real user session', async () => {
346+
const req = makeReq({
347+
body: { csrfToken: 'csrf-token', type: 'email' },
348+
session: {
349+
id: 'session-id',
350+
destroy: vi.fn(),
351+
save: vi.fn(),
352+
user: {
353+
id: 'real-user-id',
354+
email: 'real@example.com',
355+
},
356+
sessionDetails: undefined,
357+
pendingVerification: [{ type: 'email', exp: Date.now() + 60_000, token: 'old-token' }],
358+
},
359+
});
360+
const res = makeRes();
361+
const next = vi.fn();
362+
363+
const handler = routeDefinition.resendVerification.controllerFn();
364+
await handler(req as never, res as never, next);
365+
366+
expect(next).not.toHaveBeenCalled();
367+
expect(emailMocks.sendEmailVerification).toHaveBeenCalledTimes(1);
368+
expect(emailMocks.sendEmailVerification).toHaveBeenCalledWith('real@example.com', '123456', expect.any(Number));
369+
});
370+
});
371+
});

0 commit comments

Comments
 (0)