Skip to content

Commit ed98c15

Browse files
authored
feat: Add event information to verifyUserEmails, preventLoginWithUnverifiedEmail to identify invoking signup / login action and auth provider (#9963)
1 parent 617de99 commit ed98c15

12 files changed

Lines changed: 350 additions & 27 deletions

resources/buildConfigDefinitions.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,11 @@ function mapperFor(elt, t) {
158158
return wrap(t.identifier('booleanParser'));
159159
} else if (t.isObjectTypeAnnotation(elt)) {
160160
return wrap(t.identifier('objectParser'));
161+
} else if (t.isUnionTypeAnnotation(elt)) {
162+
const unionTypes = elt.typeAnnotation?.types || elt.types;
163+
if (unionTypes?.some(type => t.isBooleanTypeAnnotation(type)) && unionTypes?.some(type => t.isFunctionTypeAnnotation(type))) {
164+
return wrap(t.identifier('booleanOrFunctionParser'));
165+
}
161166
} else if (t.isGenericTypeAnnotation(elt)) {
162167
const type = elt.typeAnnotation.id.name;
163168
if (type == 'Adapter') {

spec/EmailVerificationToken.spec.js

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,15 @@ describe('Email Verification Token Expiration:', () => {
288288
};
289289
const verifyUserEmails = {
290290
method(req) {
291-
expect(Object.keys(req)).toEqual(['original', 'object', 'master', 'ip', 'installationId']);
291+
expect(Object.keys(req)).toEqual([
292+
'original',
293+
'object',
294+
'master',
295+
'ip',
296+
'installationId',
297+
'createdWith',
298+
]);
299+
expect(req.createdWith).toEqual({ action: 'signup', authProvider: 'password' });
292300
return false;
293301
},
294302
};
@@ -349,7 +357,15 @@ describe('Email Verification Token Expiration:', () => {
349357
};
350358
const verifyUserEmails = {
351359
method(req) {
352-
expect(Object.keys(req)).toEqual(['original', 'object', 'master', 'ip', 'installationId']);
360+
expect(Object.keys(req)).toEqual([
361+
'original',
362+
'object',
363+
'master',
364+
'ip',
365+
'installationId',
366+
'createdWith',
367+
]);
368+
expect(req.createdWith).toEqual({ action: 'signup', authProvider: 'password' });
353369
if (req.object.get('username') === 'no_email') {
354370
return false;
355371
}
@@ -384,6 +400,144 @@ describe('Email Verification Token Expiration:', () => {
384400
expect(verifySpy).toHaveBeenCalledTimes(5);
385401
});
386402

403+
it('provides createdWith on signup when verification blocks session creation', async () => {
404+
const verifyUserEmails = {
405+
method: params => {
406+
expect(params.object).toBeInstanceOf(Parse.User);
407+
expect(params.createdWith).toEqual({ action: 'signup', authProvider: 'password' });
408+
return true;
409+
},
410+
};
411+
const verifySpy = spyOn(verifyUserEmails, 'method').and.callThrough();
412+
await reconfigureServer({
413+
appName: 'emailVerifyToken',
414+
verifyUserEmails: verifyUserEmails.method,
415+
preventLoginWithUnverifiedEmail: true,
416+
preventSignupWithUnverifiedEmail: true,
417+
emailAdapter: MockEmailAdapterWithOptions({
418+
fromAddress: 'parse@example.com',
419+
apiKey: 'k',
420+
domain: 'd',
421+
}),
422+
publicServerURL: 'http://localhost:8378/1',
423+
});
424+
425+
const user = new Parse.User();
426+
user.setUsername('signup_created_with');
427+
user.setPassword('pass');
428+
user.setEmail('signup@example.com');
429+
const res = await user.signUp().catch(e => e);
430+
expect(res.message).toBe('User email is not verified.');
431+
expect(user.getSessionToken()).toBeUndefined();
432+
expect(verifySpy).toHaveBeenCalledTimes(2); // before signup completion and on preventLoginWithUnverifiedEmail
433+
});
434+
435+
it('provides createdWith with auth provider on login verification', async () => {
436+
const user = new Parse.User();
437+
user.setUsername('user_created_with_login');
438+
user.setPassword('pass');
439+
user.set('email', 'login@example.com');
440+
await user.signUp();
441+
442+
const verifyUserEmails = {
443+
method: async params => {
444+
expect(params.object).toBeInstanceOf(Parse.User);
445+
expect(params.createdWith).toEqual({ action: 'login', authProvider: 'password' });
446+
return true;
447+
},
448+
};
449+
const verifyUserEmailsSpy = spyOn(verifyUserEmails, 'method').and.callThrough();
450+
await reconfigureServer({
451+
appName: 'emailVerifyToken',
452+
publicServerURL: 'http://localhost:8378/1',
453+
verifyUserEmails: verifyUserEmails.method,
454+
preventLoginWithUnverifiedEmail: verifyUserEmails.method,
455+
preventSignupWithUnverifiedEmail: true,
456+
emailAdapter: MockEmailAdapterWithOptions({
457+
fromAddress: 'parse@example.com',
458+
apiKey: 'k',
459+
domain: 'd',
460+
}),
461+
});
462+
463+
const res = await Parse.User.logIn('user_created_with_login', 'pass').catch(e => e);
464+
expect(res.code).toBe(205);
465+
expect(verifyUserEmailsSpy).toHaveBeenCalledTimes(2); // before login completion and on preventLoginWithUnverifiedEmail
466+
});
467+
468+
it('provides createdWith with auth provider on signup verification', async () => {
469+
const createdWithValues = [];
470+
const verifyUserEmails = {
471+
method: params => {
472+
createdWithValues.push(params.createdWith);
473+
return true;
474+
},
475+
};
476+
const verifySpy = spyOn(verifyUserEmails, 'method').and.callThrough();
477+
await reconfigureServer({
478+
appName: 'emailVerifyToken',
479+
verifyUserEmails: verifyUserEmails.method,
480+
preventLoginWithUnverifiedEmail: true,
481+
preventSignupWithUnverifiedEmail: true,
482+
emailAdapter: MockEmailAdapterWithOptions({
483+
fromAddress: 'parse@example.com',
484+
apiKey: 'k',
485+
domain: 'd',
486+
}),
487+
publicServerURL: 'http://localhost:8378/1',
488+
});
489+
490+
const provider = {
491+
authData: { id: '8675309', access_token: 'jenny' },
492+
shouldError: false,
493+
authenticate(options) {
494+
options.success(this, this.authData);
495+
},
496+
restoreAuthentication() {
497+
return true;
498+
},
499+
getAuthType() {
500+
return 'facebook';
501+
},
502+
deauthenticate() {},
503+
};
504+
Parse.User._registerAuthenticationProvider(provider);
505+
const res = await Parse.User._logInWith('facebook').catch(e => e);
506+
expect(res.message).toBe('User email is not verified.');
507+
// Called once in createSessionTokenIfNeeded (no email set, so _validateEmail skips)
508+
expect(verifySpy).toHaveBeenCalledTimes(1);
509+
expect(createdWithValues[0]).toEqual({ action: 'signup', authProvider: 'facebook' });
510+
});
511+
512+
it('provides createdWith for preventLoginWithUnverifiedEmail function', async () => {
513+
const user = new Parse.User();
514+
user.setUsername('user_prevent_login_fn');
515+
user.setPassword('pass');
516+
user.set('email', 'preventlogin@example.com');
517+
await user.signUp();
518+
519+
const preventLoginCreatedWith = [];
520+
await reconfigureServer({
521+
appName: 'emailVerifyToken',
522+
publicServerURL: 'http://localhost:8378/1',
523+
verifyUserEmails: true,
524+
preventLoginWithUnverifiedEmail: params => {
525+
preventLoginCreatedWith.push(params.createdWith);
526+
return true;
527+
},
528+
emailAdapter: MockEmailAdapterWithOptions({
529+
fromAddress: 'parse@example.com',
530+
apiKey: 'k',
531+
domain: 'd',
532+
}),
533+
});
534+
535+
const res = await Parse.User.logIn('user_prevent_login_fn', 'pass').catch(e => e);
536+
expect(res.code).toBe(205);
537+
expect(preventLoginCreatedWith.length).toBe(1);
538+
expect(preventLoginCreatedWith[0]).toEqual({ action: 'login', authProvider: 'password' });
539+
});
540+
387541
it_id('d812de87-33d1-495e-a6e8-3485f6dc3589')(it)('can conditionally send user email verification', async () => {
388542
const emailAdapter = {
389543
sendVerificationEmail: () => {},
@@ -779,6 +933,7 @@ describe('Email Verification Token Expiration:', () => {
779933
expect(params.master).toBeDefined();
780934
expect(params.installationId).toBeDefined();
781935
expect(params.resendRequest).toBeTrue();
936+
expect(params.createdWith).toBeUndefined();
782937
return true;
783938
},
784939
};

spec/ValidationAndPasswordsReset.spec.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ describe('Custom Pages, Email Verification, Password Reset', () => {
284284
expect(params.ip).toBeDefined();
285285
expect(params.master).toBeDefined();
286286
expect(params.installationId).toBeDefined();
287+
expect(params.createdWith).toEqual({ action: 'login', authProvider: 'password' });
287288
return true;
288289
},
289290
};

spec/buildConfigDefinitions.spec.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,72 @@ describe('buildConfigDefinitions', () => {
133133
expect(result.property.name).toBe('arrayParser');
134134
});
135135

136+
it('should return booleanOrFunctionParser for UnionTypeAnnotation containing boolean (nullable)', () => {
137+
const mockElement = {
138+
type: 'UnionTypeAnnotation',
139+
typeAnnotation: {
140+
types: [
141+
{ type: 'BooleanTypeAnnotation' },
142+
{ type: 'FunctionTypeAnnotation' },
143+
],
144+
},
145+
};
146+
147+
const result = mapperFor(mockElement, t);
148+
149+
expect(t.isMemberExpression(result)).toBe(true);
150+
expect(result.object.name).toBe('parsers');
151+
expect(result.property.name).toBe('booleanOrFunctionParser');
152+
});
153+
154+
it('should return booleanOrFunctionParser for UnionTypeAnnotation containing boolean (non-nullable)', () => {
155+
const mockElement = {
156+
type: 'UnionTypeAnnotation',
157+
types: [
158+
{ type: 'BooleanTypeAnnotation' },
159+
{ type: 'FunctionTypeAnnotation' },
160+
],
161+
};
162+
163+
const result = mapperFor(mockElement, t);
164+
165+
expect(t.isMemberExpression(result)).toBe(true);
166+
expect(result.object.name).toBe('parsers');
167+
expect(result.property.name).toBe('booleanOrFunctionParser');
168+
});
169+
170+
it('should return undefined for UnionTypeAnnotation without boolean', () => {
171+
const mockElement = {
172+
type: 'UnionTypeAnnotation',
173+
typeAnnotation: {
174+
types: [
175+
{ type: 'StringTypeAnnotation' },
176+
{ type: 'NumberTypeAnnotation' },
177+
],
178+
},
179+
};
180+
181+
const result = mapperFor(mockElement, t);
182+
183+
expect(result).toBeUndefined();
184+
});
185+
186+
it('should return undefined for UnionTypeAnnotation with boolean but without function', () => {
187+
const mockElement = {
188+
type: 'UnionTypeAnnotation',
189+
typeAnnotation: {
190+
types: [
191+
{ type: 'BooleanTypeAnnotation' },
192+
{ type: 'VoidTypeAnnotation' },
193+
],
194+
},
195+
};
196+
197+
const result = mapperFor(mockElement, t);
198+
199+
expect(result).toBeUndefined();
200+
});
201+
136202
it('should return objectParser for unknown GenericTypeAnnotation', () => {
137203
const mockElement = {
138204
type: 'GenericTypeAnnotation',

spec/parsers.spec.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ const {
33
numberOrBoolParser,
44
numberOrStringParser,
55
booleanParser,
6+
booleanOrFunctionParser,
67
objectParser,
78
arrayParser,
89
moduleOrObjectParser,
@@ -48,6 +49,23 @@ describe('parsers', () => {
4849
expect(parser(2)).toEqual(false);
4950
});
5051

52+
it('parses correctly with booleanOrFunctionParser', () => {
53+
const parser = booleanOrFunctionParser;
54+
// Preserves functions
55+
const fn = () => true;
56+
expect(parser(fn)).toBe(fn);
57+
const asyncFn = async () => false;
58+
expect(parser(asyncFn)).toBe(asyncFn);
59+
// Parses booleans and string booleans like booleanParser
60+
expect(parser(true)).toEqual(true);
61+
expect(parser(false)).toEqual(false);
62+
expect(parser('true')).toEqual(true);
63+
expect(parser('false')).toEqual(false);
64+
expect(parser('1')).toEqual(true);
65+
expect(parser(1)).toEqual(true);
66+
expect(parser(0)).toEqual(false);
67+
});
68+
5169
it('parses correctly with objectParser', () => {
5270
const parser = objectParser;
5371
expect(parser({ hello: 'world' })).toEqual({ hello: 'world' });

src/Options/Definitions.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -473,8 +473,8 @@ module.exports.ParseServerOptions = {
473473
preventLoginWithUnverifiedEmail: {
474474
env: 'PARSE_SERVER_PREVENT_LOGIN_WITH_UNVERIFIED_EMAIL',
475475
help:
476-
'Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required.<br><br>Default is `false`.<br>Requires option `verifyUserEmails: true`.',
477-
action: parsers.booleanParser,
476+
"Set to `true` to prevent a user from logging in if the email has not yet been verified and email verification is required. Supports a function with a return value of `true` or `false` for conditional prevention. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.<br><br>The `createdWith` values per scenario:<ul><li>Password signup: `{ action: 'signup', authProvider: 'password' }`</li><li>Auth provider signup: `{ action: 'signup', authProvider: '<provider>' }`</li><li>Password login: `{ action: 'login', authProvider: 'password' }`</li><li>Auth provider login: function not invoked; auth provider login bypasses email verification</li></ul>Default is `false`.<br>Requires option `verifyUserEmails: true`.",
477+
action: parsers.booleanOrFunctionParser,
478478
default: false,
479479
},
480480
preventSignupWithUnverifiedEmail: {
@@ -574,6 +574,7 @@ module.exports.ParseServerOptions = {
574574
env: 'PARSE_SERVER_SEND_USER_EMAIL_VERIFICATION',
575575
help:
576576
'Set to `false` to prevent sending of verification email. Supports a function with a return value of `true` or `false` for conditional email sending.<br><br>Default is `true`.<br>',
577+
action: parsers.booleanOrFunctionParser,
577578
default: true,
578579
},
579580
serverCloseComplete: {
@@ -630,7 +631,8 @@ module.exports.ParseServerOptions = {
630631
verifyUserEmails: {
631632
env: 'PARSE_SERVER_VERIFY_USER_EMAILS',
632633
help:
633-
'Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification.<br><br>Default is `false`.',
634+
"Set to `true` to require users to verify their email address to complete the sign-up process. Supports a function with a return value of `true` or `false` for conditional verification. The function receives a request object that includes `createdWith` to indicate whether the invocation is for `signup` or `login` and the used auth provider.<br><br>The `createdWith` values per scenario:<ul><li>Password signup: `{ action: 'signup', authProvider: 'password' }`</li><li>Auth provider signup: `{ action: 'signup', authProvider: '<provider>' }`</li><li>Password login: `{ action: 'login', authProvider: 'password' }`</li><li>Auth provider login: function not invoked; auth provider login bypasses email verification</li><li>Resend verification email: `createdWith` is `undefined`; use the `resendRequest` property to identify those</li></ul>Default is `false`.",
635+
action: parsers.booleanOrFunctionParser,
634636
default: false,
635637
},
636638
webhookKey: {

src/Options/docs.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)