From c183da65d6a657f3b468ac93c9cc3b1295d7d9a6 Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Fri, 5 Dec 2025 20:51:59 +0100 Subject: [PATCH 1/4] feat: Add `autoSignupLogin` to signup when user doesn't already exist --- spec/ParseUser.spec.js | 103 ++++++++++++++++++++++++++++ src/Options/Definitions.js | 7 ++ src/Options/docs.js | 1 + src/Options/index.js | 3 + src/Routers/UsersRouter.js | 135 +++++++++++++++++++++++++++++-------- 5 files changed, 221 insertions(+), 28 deletions(-) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 0380589057..ea6d1c0cd5 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -210,6 +210,109 @@ describe('Parse.User testing', () => { done(); }); + describe('autoSignupOnLogin option', () => { + it('does not auto sign up when disabled', async () => { + await reconfigureServer({ autoSignupOnLogin: false }); + await expectAsync(Parse.User.logIn('ghost-user', 'hunter2')).toBeRejectedWith( + jasmine.objectContaining({ code: Parse.Error.OBJECT_NOT_FOUND }) + ); + const count = await new Parse.Query(Parse.User) + .equalTo('username', 'ghost-user') + .count({ useMasterKey: true }); + expect(count).toBe(0); + }); + + it('creates user on login when enabled (username + password)', async () => { + await reconfigureServer({ autoSignupOnLogin: true }); + const user = await Parse.User.logIn('auto-login-user', 'pass1234'); + expect(user.id).toBeDefined(); + expect(user.getSessionToken()).toBeDefined(); + const stored = await new Parse.Query(Parse.User) + .equalTo('username', 'auto-login-user') + .first({ useMasterKey: true }); + expect(stored).toBeTruthy(); + expect(stored.id).toBe(user.id); + }); + + it('creates user on login when enabled with email + password', async () => { + await reconfigureServer({ autoSignupOnLogin: true }); + const email = 'auto-email@example.com'; + const res = await request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + email, + password: 'pass1234', + }, + }); + expect(res.data.username).toBe(email); + expect(res.data.email).toBe(email); + expect(res.data.sessionToken).toBeDefined(); + const stored = await new Parse.Query(Parse.User) + .equalTo('email', email) + .first({ useMasterKey: true }); + expect(stored).toBeTruthy(); + expect(stored.get('username')).toBe(email); + }); + + it('uses existing user when present and does not duplicate', async () => { + await reconfigureServer({ autoSignupOnLogin: true }); + const existing = new Parse.User(); + existing.setUsername('existing-login'); + existing.setPassword('pass123'); + await existing.signUp(); + + const logged = await Parse.User.logIn('existing-login', 'pass123'); + expect(logged.id).toBe(existing.id); + const count = await new Parse.Query(Parse.User) + .equalTo('username', 'existing-login') + .count({ useMasterKey: true }); + expect(count).toBe(1); + }); + + it('respects preventLoginWithUnverifiedEmail when auto-signing up', async () => { + await reconfigureServer({ + appName: 'preventLoginWithUnverifiedEmail', + autoSignupOnLogin: true, + verifyUserEmails: true, + preventLoginWithUnverifiedEmail: true, + emailAdapter: { + sendVerificationMail: () => Promise.resolve(), + sendMail: () => Promise.resolve(), + }, + publicServerURL: 'http://localhost:8378/1', + }); + const email = 'unverified@example.com'; + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/login', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + }, + body: { + email, + password: 'pass1234', + }, + }) + ).toBeRejectedWith( + jasmine.objectContaining({ + data: jasmine.objectContaining({ code: Parse.Error.EMAIL_NOT_FOUND }), + }) + ); + const stored = await new Parse.Query(Parse.User) + .equalTo('email', email) + .first({ useMasterKey: true }); + expect(stored).toBeTruthy(); + expect(stored.get('emailVerified')).toBe(false); + }); + }); + it('should respect ACL without locking user out', done => { const user = new Parse.User(); const ACL = new Parse.ACL(); diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 66c1d8bcea..b1262f0f75 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -113,6 +113,13 @@ module.exports.ParseServerOptions = { 'Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication', action: parsers.objectParser, }, + autoSignupOnLogin: { + env: 'PARSE_SERVER_AUTO_SIGNUP_ON_LOGIN', + help: + 'Set to `true` to allow the login endpoint to automatically create a user with the provided username/email and password when no existing user is found. Default is `false`.', + action: parsers.booleanParser, + default: false, + }, cacheAdapter: { env: 'PARSE_SERVER_CACHE_ADAPTER', help: 'Adapter module for the cache', diff --git a/src/Options/docs.js b/src/Options/docs.js index 9569239ef7..c8bd8716f5 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -22,6 +22,7 @@ * @property {String} appId Your Parse Application ID * @property {String} appName Sets the app name * @property {Object} auth Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication + * @property {Boolean} autoSignupOnLogin Set to `true` to allow the login endpoint to automatically create a user with the provided username/email and password when no existing user is found. Default is `false`. * @property {Adapter} cacheAdapter Adapter module for the cache * @property {Number} cacheMaxSize Sets the maximum size for the in memory cache, defaults to 10000 * @property {Number} cacheTTL Sets the TTL for the in memory cache (in ms), defaults to 5000 (5 seconds) diff --git a/src/Options/index.js b/src/Options/index.js index cdeb7cd846..1a2190ff88 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -193,6 +193,9 @@ export interface ParseServerOptions { Requires option `verifyUserEmails: true`. :DEFAULT: false */ preventSignupWithUnverifiedEmail: ?boolean; + /* Set to `true` to allow the login endpoint to automatically create a user with the provided username/email and password when no existing user is found. Default is `false`. + :DEFAULT: false */ + autoSignupOnLogin: ?boolean; /* Set the validity duration of the email verification token in seconds after which the token expires. The token is used in the link that is set in the email. After the token expires, the link becomes invalid and a new link has to be sent. If the option is not set or set to `undefined`, then the token never expires.

For example, to expire the token after 2 hours, set a value of 7200 seconds (= 60 seconds * 60 minutes * 2 hours). diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 3828e465e7..280e70c8c1 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -61,38 +61,47 @@ export class UsersRouter extends ClassesRouter { } } + /** + * Extract and validate login payload from request + * @param {Object} req The request + * @returns {{ username: string | void, email: string | void, password: string, ignoreEmailVerification: boolean | void }} + * @private + */ + _getLoginPayload(req) { + let payload = req.body || {}; + if ( + (!payload.username && req.query && req.query.username) || + (!payload.email && req.query && req.query.email) + ) { + payload = req.query; + } + const { username, email, password, ignoreEmailVerification } = payload; + + if (!username && !email) { + throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username/email is required.'); + } + if (!password) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.'); + } + if ( + typeof password !== 'string' || + (email && typeof email !== 'string') || + (username && typeof username !== 'string') + ) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + } + + return { username, email, password, ignoreEmailVerification }; + } + /** * Validates a password request in login and verifyPassword * @param {Object} req The request * @returns {Object} User object - * @private */ _authenticateUserFromRequest(req) { return new Promise((resolve, reject) => { - // Use query parameters instead if provided in url - let payload = req.body || {}; - if ( - (!payload.username && req.query && req.query.username) || - (!payload.email && req.query && req.query.email) - ) { - payload = req.query; - } - const { username, email, password, ignoreEmailVerification } = payload; - - // TODO: use the right error codes / descriptions. - if (!username && !email) { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username/email is required.'); - } - if (!password) { - throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.'); - } - if ( - typeof password !== 'string' || - (email && typeof email !== 'string') || - (username && typeof username !== 'string') - ) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); - } + const { username, email, password, ignoreEmailVerification } = this._getLoginPayload(req); let user; let isValidPassword = false; @@ -170,6 +179,58 @@ export class UsersRouter extends ClassesRouter { }); } + /** + * Auto sign-up when login misses existing user and option is enabled + * @param {Object} req The request + * @returns {{ user: Object, authDataResponse: any }} + */ + async _autoSignupOnLogin(req) { + const { username, email, password } = this._getLoginPayload(req); + const inferredUsername = username || email; + const data = { username: inferredUsername, password }; + if (email) { + data.email = email; + } + + const { response } = await new RestWrite( + req.config, + req.auth, + '_User', + null, + data, + null, + req.info.clientSDK, + req.info.context + ).execute(); + + // Fetch fresh user object to return a login-like response with username/email + const createdUserResults = await req.config.database.find( + '_User', + { objectId: response.objectId }, + {}, + Auth.master(req.config) + ); + const createdUser = createdUserResults[0]; + if (!createdUser) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + } + createdUser.sessionToken = response.sessionToken; + + if ( + req.config.verifyUserEmails && + req.config.preventLoginWithUnverifiedEmail && + createdUser.email && + createdUser.emailVerified !== true + ) { + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); + } + + UsersRouter.removeHiddenProperties(createdUser); + await req.config.filesController.expandFilesInObject(req.config, createdUser); + + return { user: createdUser, authDataResponse: response.authDataResponse }; + } + handleMe(req) { if (!req.info || !req.info.sessionToken) { throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', req.config); @@ -201,8 +262,28 @@ export class UsersRouter extends ClassesRouter { } async handleLogIn(req) { - const user = await this._authenticateUserFromRequest(req); + let user; + let authDataResponse; + let validatedAuthData; + + try { + user = await this._authenticateUserFromRequest(req); + } catch (error) { + if ( + req.config.autoSignupOnLogin && + error && + error.code === Parse.Error.OBJECT_NOT_FOUND + ) { + const autoSignup = await this._autoSignupOnLogin(req); + user = autoSignup.user; + authDataResponse = autoSignup.authDataResponse; + } else { + throw error; + } + } + const authData = req.body && req.body.authData; + // Check if user has provided their required auth providers Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( req, @@ -211,8 +292,6 @@ export class UsersRouter extends ClassesRouter { req.config ); - let authDataResponse; - let validatedAuthData; if (authData) { const res = await Auth.handleAuthDataValidation( authData, From 25d7b8417da58bcf054ac89c3253592ce3bdaa68 Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:03:03 +0100 Subject: [PATCH 2/4] fix: feedbacks --- src/Routers/UsersRouter.js | 52 ++++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 280e70c8c1..8db1f0daed 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -214,7 +214,6 @@ export class UsersRouter extends ClassesRouter { if (!createdUser) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } - createdUser.sessionToken = response.sessionToken; if ( req.config.verifyUserEmails && @@ -222,13 +221,28 @@ export class UsersRouter extends ClassesRouter { createdUser.email && createdUser.emailVerified !== true ) { + // Best-effort session cleanup to avoid leaving an orphaned token + if (createdUser.sessionToken) { + await req.config.database.destroy( + '_Session', + { sessionToken: createdUser.sessionToken }, + { acl: undefined } + ); + } + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); } UsersRouter.removeHiddenProperties(createdUser); await req.config.filesController.expandFilesInObject(req.config, createdUser); - return { user: createdUser, authDataResponse: response.authDataResponse }; + // Attach the session token created during signup; tell caller to skip creating another session + return { + user: createdUser, + authDataResponse: response.authDataResponse, + sessionToken: response.sessionToken, + skipSessionCreation: true, + }; } handleMe(req) { @@ -265,6 +279,7 @@ export class UsersRouter extends ClassesRouter { let user; let authDataResponse; let validatedAuthData; + let autoSignupResult; try { user = await this._authenticateUserFromRequest(req); @@ -274,9 +289,12 @@ export class UsersRouter extends ClassesRouter { error && error.code === Parse.Error.OBJECT_NOT_FOUND ) { - const autoSignup = await this._autoSignupOnLogin(req); - user = autoSignup.user; - authDataResponse = autoSignup.authDataResponse; + autoSignupResult = await this._autoSignupOnLogin(req); + user = autoSignupResult.user; + authDataResponse = autoSignupResult.authDataResponse; + if (autoSignupResult.sessionToken) { + user.sessionToken = autoSignupResult.sessionToken; + } } else { throw error; } @@ -367,18 +385,20 @@ export class UsersRouter extends ClassesRouter { ); } - const { sessionData, createSession } = RestWrite.createSession(req.config, { - userId: user.objectId, - createdWith: { - action: 'login', - authProvider: 'password', - }, - installationId: req.info.installationId, - }); - - user.sessionToken = sessionData.sessionToken; + // Create a session only if not already created by auto-signup + if (!autoSignupResult || !autoSignupResult.skipSessionCreation) { + const { sessionData, createSession } = RestWrite.createSession(req.config, { + userId: user.objectId, + createdWith: { + action: 'login', + authProvider: 'password', + }, + installationId: req.info.installationId, + }); - await createSession(); + user.sessionToken = sessionData.sessionToken; + await createSession(); + } const afterLoginUser = Parse.User.fromJSON(Object.assign({ className: '_User' }, user)); await maybeRunTrigger( From f16762421cbf0bdfcdbd4b4bdd53ce86568a8df1 Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:11:08 +0100 Subject: [PATCH 3/4] fix: feedbacks and improve test --- spec/ParseUser.spec.js | 6 +++ src/Routers/UsersRouter.js | 75 +++++++++++++++++++++++++++----------- 2 files changed, 59 insertions(+), 22 deletions(-) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index ea6d1c0cd5..bc79ac1f0c 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -310,6 +310,12 @@ describe('Parse.User testing', () => { .first({ useMasterKey: true }); expect(stored).toBeTruthy(); expect(stored.get('emailVerified')).toBe(false); + + // Ensure no session persists for the rejected login + const sessions = await new Parse.Query('_Session') + .equalTo('user', stored) + .find({ useMasterKey: true }); + expect(sessions.length).toBe(0); }); }); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 8db1f0daed..f88c44ae3c 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -61,6 +61,40 @@ export class UsersRouter extends ClassesRouter { } } + /** + * Resolve email verification flags; supports boolean or async function options. + * @param {Object} req The request + * @param {Object} userObj The user object to pass into config callbacks + * @returns {Promise<{verifyUserEmails: boolean, preventLoginWithUnverifiedEmail: boolean, preventSignupWithUnverifiedEmail: boolean}>} + * @private + */ + async _resolveEmailVerificationFlags(req, userObj) { + const request = { + master: req.auth.isMaster, + ip: req.config.ip, + installationId: req.auth.installationId, + object: Parse.User.fromJSON(Object.assign({ className: '_User' }, userObj)), + }; + const verifyUserEmails = + req.config.verifyUserEmails === true || + (typeof req.config.verifyUserEmails === 'function' && + (await Promise.resolve(req.config.verifyUserEmails(request))) === true); + const preventLoginWithUnverifiedEmail = + req.config.preventLoginWithUnverifiedEmail === true || + (typeof req.config.preventLoginWithUnverifiedEmail === 'function' && + (await Promise.resolve(req.config.preventLoginWithUnverifiedEmail(request))) === true); + const preventSignupWithUnverifiedEmail = + req.config.preventSignupWithUnverifiedEmail === true || + (typeof req.config.preventSignupWithUnverifiedEmail === 'function' && + (await Promise.resolve(req.config.preventSignupWithUnverifiedEmail(request))) === true); + + return { + verifyUserEmails, + preventLoginWithUnverifiedEmail, + preventSignupWithUnverifiedEmail, + }; + } + /** * Extract and validate login payload from request * @param {Object} req The request @@ -148,23 +182,14 @@ export class UsersRouter extends ClassesRouter { if (!req.auth.isMaster && user.ACL && Object.keys(user.ACL).length == 0) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } - // Create request object for verification functions - const request = { - master: req.auth.isMaster, - ip: req.config.ip, - installationId: req.auth.installationId, - object: Parse.User.fromJSON(Object.assign({ className: '_User' }, user)), - }; // If request doesn't use master or maintenance key with ignoring email verification if (!((req.auth.isMaster || req.auth.isMaintenance) && ignoreEmailVerification)) { - - // Get verification conditions which can be booleans or functions; the purpose of this async/await - // structure is to avoid unnecessarily executing subsequent functions if previous ones fail in the - // conditional statement below, as a developer may decide to execute expensive operations in them - const verifyUserEmails = async () => req.config.verifyUserEmails === true || (typeof req.config.verifyUserEmails === 'function' && await Promise.resolve(req.config.verifyUserEmails(request)) === true); - const preventLoginWithUnverifiedEmail = async () => req.config.preventLoginWithUnverifiedEmail === true || (typeof req.config.preventLoginWithUnverifiedEmail === 'function' && await Promise.resolve(req.config.preventLoginWithUnverifiedEmail(request)) === true); - if (await verifyUserEmails() && await preventLoginWithUnverifiedEmail() && !user.emailVerified) { + const { + verifyUserEmails, + preventLoginWithUnverifiedEmail, + } = await this._resolveEmailVerificationFlags(req, user); + if (verifyUserEmails && preventLoginWithUnverifiedEmail && !user.emailVerified) { throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); } } @@ -215,17 +240,23 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } - if ( - req.config.verifyUserEmails && - req.config.preventLoginWithUnverifiedEmail && - createdUser.email && - createdUser.emailVerified !== true - ) { + const { + verifyUserEmails, + preventLoginWithUnverifiedEmail, + preventSignupWithUnverifiedEmail, + } = await this._resolveEmailVerificationFlags(req, createdUser); + + if (verifyUserEmails && preventLoginWithUnverifiedEmail && createdUser.email && createdUser.emailVerified !== true) { + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); + } + + // Enforce preventSignupWithUnverifiedEmail by cleaning up the session and failing the login + if (verifyUserEmails && preventSignupWithUnverifiedEmail && createdUser.email && createdUser.emailVerified !== true) { // Best-effort session cleanup to avoid leaving an orphaned token - if (createdUser.sessionToken) { + if (response.sessionToken) { await req.config.database.destroy( '_Session', - { sessionToken: createdUser.sessionToken }, + { sessionToken: response.sessionToken }, { acl: undefined } ); } From 1894c600e09c16dfdf0658ce70c36a8593a6f7d1 Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:19:30 +0100 Subject: [PATCH 4/4] fix: feedbacks --- spec/ParseUser.spec.js | 8 +++----- src/Routers/UsersRouter.js | 26 +++++++++++++++++--------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index bc79ac1f0c..c937dc4c15 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -305,15 +305,13 @@ describe('Parse.User testing', () => { data: jasmine.objectContaining({ code: Parse.Error.EMAIL_NOT_FOUND }), }) ); - const stored = await new Parse.Query(Parse.User) + const storedCount = await new Parse.Query(Parse.User) .equalTo('email', email) - .first({ useMasterKey: true }); - expect(stored).toBeTruthy(); - expect(stored.get('emailVerified')).toBe(false); + .count({ useMasterKey: true }); + expect(storedCount).toBe(0); // Ensure no session persists for the rejected login const sessions = await new Parse.Query('_Session') - .equalTo('user', stored) .find({ useMasterKey: true }); expect(sessions.length).toBe(0); }); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index f88c44ae3c..2e037de89c 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -240,6 +240,21 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } + const cleanupAutoSignup = async () => { + if (response.sessionToken) { + await req.config.database.destroy( + '_Session', + { sessionToken: response.sessionToken }, + { acl: undefined } + ); + } + await req.config.database.destroy( + '_User', + { objectId: response.objectId }, + { acl: undefined } + ); + }; + const { verifyUserEmails, preventLoginWithUnverifiedEmail, @@ -247,20 +262,13 @@ export class UsersRouter extends ClassesRouter { } = await this._resolveEmailVerificationFlags(req, createdUser); if (verifyUserEmails && preventLoginWithUnverifiedEmail && createdUser.email && createdUser.emailVerified !== true) { + await cleanupAutoSignup(); throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); } // Enforce preventSignupWithUnverifiedEmail by cleaning up the session and failing the login if (verifyUserEmails && preventSignupWithUnverifiedEmail && createdUser.email && createdUser.emailVerified !== true) { - // Best-effort session cleanup to avoid leaving an orphaned token - if (response.sessionToken) { - await req.config.database.destroy( - '_Session', - { sessionToken: response.sessionToken }, - { acl: undefined } - ); - } - + await cleanupAutoSignup(); throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, 'User email is not verified.'); }