diff --git a/spec/AuthDataUniqueIndex.spec.js b/spec/AuthDataUniqueIndex.spec.js new file mode 100644 index 000000000..d975a4220 --- /dev/null +++ b/spec/AuthDataUniqueIndex.spec.js @@ -0,0 +1,210 @@ +'use strict'; + +const request = require('../lib/request'); +const Config = require('../lib/Config'); + +describe('AuthData Unique Index', () => { + const fakeAuthProvider = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + + beforeEach(async () => { + await reconfigureServer({ auth: { fakeAuthProvider } }); + }); + + it('should prevent concurrent signups with the same authData from creating duplicate users', async () => { + const authData = { fakeAuthProvider: { id: 'duplicate-test-id', token: 'token1' } }; + + // Fire multiple concurrent signup requests with the same authData + const concurrentRequests = Array.from({ length: 5 }, () => + request({ + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + url: 'http://localhost:8378/1/users', + body: { authData }, + }).then( + response => ({ success: true, data: response.data }), + error => ({ success: false, error: error.data || error.message }) + ) + ); + + const results = await Promise.all(concurrentRequests); + const successes = results.filter(r => r.success); + const failures = results.filter(r => !r.success); + + // All should either succeed (returning the same user) or fail with "this auth is already used" + // The key invariant: only ONE unique objectId should exist + const uniqueObjectIds = new Set(successes.map(r => r.data.objectId)); + expect(uniqueObjectIds.size).toBe(1); + + // Failures should be "this auth is already used" errors + for (const failure of failures) { + expect(failure.error.code).toBe(208); + expect(failure.error.error).toBe('this auth is already used'); + } + + // Verify only one user exists in the database with this authData + const query = new Parse.Query('_User'); + query.equalTo('authData.fakeAuthProvider.id', 'duplicate-test-id'); + const users = await query.find({ useMasterKey: true }); + expect(users.length).toBe(1); + }); + + it('should prevent concurrent signups via batch endpoint with same authData', async () => { + const authData = { fakeAuthProvider: { id: 'batch-race-test-id', token: 'token1' } }; + + const response = await request({ + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + url: 'http://localhost:8378/1/batch', + body: { + requests: Array.from({ length: 3 }, () => ({ + method: 'POST', + path: '/1/users', + body: { authData }, + })), + }, + }); + + const results = response.data; + const successes = results.filter(r => r.success); + const failures = results.filter(r => r.error); + + // All successes should reference the same user + const uniqueObjectIds = new Set(successes.map(r => r.success.objectId)); + expect(uniqueObjectIds.size).toBe(1); + + // Failures should be "this auth is already used" errors + for (const failure of failures) { + expect(failure.error.code).toBe(208); + expect(failure.error.error).toBe('this auth is already used'); + } + + // Verify only one user exists in the database with this authData + const query = new Parse.Query('_User'); + query.equalTo('authData.fakeAuthProvider.id', 'batch-race-test-id'); + const users = await query.find({ useMasterKey: true }); + expect(users.length).toBe(1); + }); + + it('should allow sequential signups with different authData IDs', async () => { + const user1 = await Parse.User.logInWith('fakeAuthProvider', { + authData: { id: 'user-id-1', token: 'token1' }, + }); + const user2 = await Parse.User.logInWith('fakeAuthProvider', { + authData: { id: 'user-id-2', token: 'token2' }, + }); + + expect(user1.id).toBeDefined(); + expect(user2.id).toBeDefined(); + expect(user1.id).not.toBe(user2.id); + }); + + it('should still allow login with authData after successful signup', async () => { + const authPayload = { authData: { id: 'login-test-id', token: 'token1' } }; + + // Signup + const user1 = await Parse.User.logInWith('fakeAuthProvider', authPayload); + expect(user1.id).toBeDefined(); + + // Login again with same authData — should return same user + const user2 = await Parse.User.logInWith('fakeAuthProvider', authPayload); + expect(user2.id).toBe(user1.id); + }); + + it('should skip startup index creation when createIndexAuthDataUniqueness is false', async () => { + const config = Config.get('test'); + const adapter = config.database.adapter; + const spy = spyOn(adapter, 'ensureAuthDataUniqueness').and.callThrough(); + + // Temporarily set the option to false + const originalOptions = config.database.options.databaseOptions; + config.database.options.databaseOptions = { createIndexAuthDataUniqueness: false }; + + await config.database.performInitialization(); + expect(spy).not.toHaveBeenCalled(); + + // Restore original options + config.database.options.databaseOptions = originalOptions; + }); + + it('should handle calling ensureAuthDataUniqueness multiple times (idempotent)', async () => { + const config = Config.get('test'); + const adapter = config.database.adapter; + + // Both calls should succeed (index creation is idempotent) + await adapter.ensureAuthDataUniqueness('fakeAuthProvider'); + await adapter.ensureAuthDataUniqueness('fakeAuthProvider'); + }); + + it('should log warning when index creation fails due to existing duplicates', async () => { + const config = Config.get('test'); + const adapter = config.database.adapter; + + // Spy on the adapter to simulate a duplicate value error + spyOn(adapter, 'ensureAuthDataUniqueness').and.callFake(() => { + return Promise.reject( + new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'duplicates exist') + ); + }); + + const logSpy = spyOn(require('../lib/logger').logger, 'warn'); + + // Re-run performInitialization — should warn but not throw + await config.database.performInitialization(); + expect(logSpy).toHaveBeenCalledWith( + jasmine.stringContaining('Unable to ensure uniqueness for auth data provider'), + jasmine.anything() + ); + }); + + it('should prevent concurrent signups with same anonymous authData', async () => { + const anonymousId = 'anon-race-test-id'; + const authData = { anonymous: { id: anonymousId } }; + + const concurrentRequests = Array.from({ length: 5 }, () => + request({ + method: 'POST', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + url: 'http://localhost:8378/1/users', + body: { authData }, + }).then( + response => ({ success: true, data: response.data }), + error => ({ success: false, error: error.data || error.message }) + ) + ); + + const results = await Promise.all(concurrentRequests); + const successes = results.filter(r => r.success); + const failures = results.filter(r => !r.success); + + // All successes should reference the same user + const uniqueObjectIds = new Set(successes.map(r => r.data.objectId)); + expect(uniqueObjectIds.size).toBe(1); + + // Failures should be "this auth is already used" errors + for (const failure of failures) { + expect(failure.error.code).toBe(208); + expect(failure.error.error).toBe('this auth is already used'); + } + + // Verify only one user exists in the database with this authData + const query = new Parse.Query('_User'); + query.equalTo('authData.anonymous.id', anonymousId); + const users = await query.find({ useMasterKey: true }); + expect(users.length).toBe(1); + }); +}); diff --git a/spec/DatabaseController.spec.js b/spec/DatabaseController.spec.js index b1ccc0d58..ac83c11dd 100644 --- a/spec/DatabaseController.spec.js +++ b/spec/DatabaseController.spec.js @@ -415,6 +415,11 @@ describe('DatabaseController', function () { email_1: { email: 1 }, _email_verify_token: { _email_verify_token: 1 }, _perishable_token: { _perishable_token: 1 }, + _auth_data_custom_id: { '_auth_data_custom.id': 1 }, + _auth_data_facebook_id: { '_auth_data_facebook.id': 1 }, + _auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 }, + _auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 }, + _auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 }, }); } ); @@ -441,6 +446,11 @@ describe('DatabaseController', function () { email_1: { email: 1 }, _email_verify_token: { _email_verify_token: 1 }, _perishable_token: { _perishable_token: 1 }, + _auth_data_custom_id: { '_auth_data_custom.id': 1 }, + _auth_data_facebook_id: { '_auth_data_facebook.id': 1 }, + _auth_data_myoauth_id: { '_auth_data_myoauth.id': 1 }, + _auth_data_shortLivedAuth_id: { '_auth_data_shortLivedAuth.id': 1 }, + _auth_data_anonymous_id: { '_auth_data_anonymous.id': 1 }, }); } ); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 038058905..ca0dded5e 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -337,7 +337,7 @@ describe('Parse.User testing', () => { expect(newUser).not.toBeUndefined(); }); - it('should be let masterKey lock user out with authData', async () => { + it_only_db('mongo')('should reject duplicate authData when masterKey locks user out (mongo)', async () => { const response = await request({ method: 'POST', url: 'http://localhost:8378/1/classes/_User', @@ -353,15 +353,13 @@ describe('Parse.User testing', () => { }); const body = response.data; const objectId = body.objectId; - const sessionToken = body.sessionToken; - expect(sessionToken).toBeDefined(); + expect(body.sessionToken).toBeDefined(); expect(objectId).toBeDefined(); const user = new Parse.User(); user.id = objectId; const ACL = new Parse.ACL(); user.setACL(ACL); await user.save(null, { useMasterKey: true }); - // update the user const options = { method: 'POST', url: `http://localhost:8378/1/classes/_User/`, @@ -377,8 +375,61 @@ describe('Parse.User testing', () => { }, }, }; - const res = await request(options); - expect(res.data.objectId).not.toEqual(objectId); + try { + await request(options); + fail('should have thrown'); + } catch (err) { + expect(err.data.code).toBe(208); + expect(err.data.error).toBe('this auth is already used'); + } + }); + + it_only_db('postgres')('should reject duplicate authData when masterKey locks user out (postgres)', async () => { + await reconfigureServer(); + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/classes/_User', + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + key: 'value', + authData: { anonymous: { id: '00000000-0000-0000-0000-000000000001' } }, + }, + }); + const body = response.data; + const objectId = body.objectId; + expect(body.sessionToken).toBeDefined(); + expect(objectId).toBeDefined(); + const user = new Parse.User(); + user.id = objectId; + const ACL = new Parse.ACL(); + user.setACL(ACL); + await user.save(null, { useMasterKey: true }); + const options = { + method: 'POST', + url: `http://localhost:8378/1/classes/_User/`, + headers: { + 'X-Parse-Application-Id': Parse.applicationId, + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: { + key: 'otherValue', + authData: { + anonymous: { id: '00000000-0000-0000-0000-000000000001' }, + }, + }, + }; + try { + await request(options); + fail('should have thrown'); + } catch (err) { + expect(err.data.code).toBe(208); + expect(err.data.error).toBe('this auth is already used'); + } }); it('user login with files', done => { diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 50fd34886..a6ac012f3 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -530,6 +530,13 @@ export class MongoStorageAdapter implements StorageAdapter { if (matches && Array.isArray(matches)) { err.userInfo = { duplicated_field: matches[1] }; } + // Check for authData unique index violations + if (!err.userInfo) { + const authDataMatch = error.message.match(/index:\s+(_auth_data_[a-zA-Z0-9_]+_id)/); + if (authDataMatch) { + err.userInfo = { duplicated_field: authDataMatch[1] }; + } + } } throw err; } @@ -605,10 +612,27 @@ export class MongoStorageAdapter implements StorageAdapter { .then(result => mongoObjectToParseObject(className, result, schema)) .catch(error => { if (error.code === 11000) { - throw new Parse.Error( + logger.error('Duplicate key error:', error.message); + const err = new Parse.Error( Parse.Error.DUPLICATE_VALUE, 'A duplicate value for a field with unique values was provided' ); + err.underlyingError = error; + if (error.message) { + const matches = error.message.match( + /index:[\sa-zA-Z0-9_\-\.]+\$?([a-zA-Z_-]+)_1/ + ); + if (matches && Array.isArray(matches)) { + err.userInfo = { duplicated_field: matches[1] }; + } + if (!err.userInfo) { + const authDataMatch = error.message.match(/index:\s+(_auth_data_[a-zA-Z0-9_]+_id)/); + if (authDataMatch) { + err.userInfo = { duplicated_field: authDataMatch[1] }; + } + } + } + throw err; } throw error; }) @@ -764,6 +788,32 @@ export class MongoStorageAdapter implements StorageAdapter { .catch(err => this.handleError(err)); } + // Creates a unique sparse index on _auth_data_.id to prevent + // race conditions during concurrent signups with the same authData. + ensureAuthDataUniqueness(provider: string) { + return this._adaptiveCollection('_User') + .then(collection => + collection._mongoCollection.createIndex( + { [`_auth_data_${provider}.id`]: 1 }, + { unique: true, sparse: true, background: true, name: `_auth_data_${provider}_id` } + ) + ) + .catch(error => { + if (error.code === 11000) { + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'Tried to ensure field uniqueness for a class that already has duplicates.' + ); + } + // Ignore "index already exists with same name" or "index already exists with different options" + if (error.code === 85 || error.code === 86) { + return; + } + throw error; + }) + .catch(err => this.handleError(err)); + } + // Used in tests _rawFind(className: string, query: QueryType) { return this._adaptiveCollection(className) diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 7eaafcbde..6adf0b170 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1479,9 +1479,15 @@ export class PostgresStorageAdapter implements StorageAdapter { ); err.underlyingError = error; if (error.constraint) { - const matches = error.constraint.match(/unique_([a-zA-Z]+)/); - if (matches && Array.isArray(matches)) { - err.userInfo = { duplicated_field: matches[1] }; + // Check for authData unique index violations first + const authDataMatch = error.constraint.match(/_User_unique_authData_([a-zA-Z0-9_]+)_id/); + if (authDataMatch) { + err.userInfo = { duplicated_field: `_auth_data_${authDataMatch[1]}` }; + } else { + const matches = error.constraint.match(/unique_([a-zA-Z]+)/); + if (matches && Array.isArray(matches)) { + err.userInfo = { duplicated_field: matches[1] }; + } } } error = err; @@ -1801,7 +1807,30 @@ export class PostgresStorageAdapter implements StorageAdapter { const whereClause = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; const qs = `UPDATE $1:name SET ${updatePatterns.join()} ${whereClause} RETURNING *`; - const promise = (transactionalSession ? transactionalSession.t : this._client).any(qs, values); + const promise = (transactionalSession ? transactionalSession.t : this._client) + .any(qs, values) + .catch(error => { + if (error.code === PostgresUniqueIndexViolationError) { + const err = new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'A duplicate value for a field with unique values was provided' + ); + err.underlyingError = error; + if (error.constraint) { + const authDataMatch = error.constraint.match(/_User_unique_authData_([a-zA-Z0-9_]+)_id/); + if (authDataMatch) { + err.userInfo = { duplicated_field: `_auth_data_${authDataMatch[1]}` }; + } else { + const matches = error.constraint.match(/unique_([a-zA-Z]+)/); + if (matches && Array.isArray(matches)) { + err.userInfo = { duplicated_field: matches[1] }; + } + } + } + throw err; + } + throw error; + }); if (transactionalSession) { transactionalSession.batch.push(promise); } @@ -2044,6 +2073,31 @@ export class PostgresStorageAdapter implements StorageAdapter { }); } + // Creates a unique index on authData->->>'id' to prevent + // race conditions during concurrent signups with the same authData. + async ensureAuthDataUniqueness(provider: string) { + const indexName = `_User_unique_authData_${provider}_id`; + const qs = `CREATE UNIQUE INDEX IF NOT EXISTS $1:name ON "_User" (("authData"->$2::text->>'id')) WHERE "authData"->$2::text->>'id' IS NOT NULL`; + await this._client.none(qs, [indexName, provider]).catch(error => { + if ( + error.code === PostgresDuplicateRelationError && + error.message.includes(indexName) + ) { + // Index already exists. Ignore error. + } else if ( + error.code === PostgresUniqueIndexViolationError && + error.message.includes(indexName) + ) { + throw new Parse.Error( + Parse.Error.DUPLICATE_VALUE, + 'Tried to ensure field uniqueness for a class that already has duplicates.' + ); + } else { + throw error; + } + }); + } + // Executes a count. async count( className: string, diff --git a/src/Controllers/DatabaseController.js b/src/Controllers/DatabaseController.js index 0f154693b..a7ed99aab 100644 --- a/src/Controllers/DatabaseController.js +++ b/src/Controllers/DatabaseController.js @@ -1838,6 +1838,30 @@ class DatabaseController { throw error; }); } + // Create unique indexes for authData providers to prevent race conditions + // during concurrent signups with the same authData + if ( + databaseOptions.createIndexAuthDataUniqueness !== false && + typeof this.adapter.ensureAuthDataUniqueness === 'function' + ) { + const authProviders = Object.keys(this.options.auth || {}); + if (this.options.enableAnonymousUsers !== false) { + if (!authProviders.includes('anonymous')) { + authProviders.push('anonymous'); + } + } + await Promise.all( + authProviders.map(provider => + this.adapter.ensureAuthDataUniqueness(provider).catch(error => { + logger.warn( + `Unable to ensure uniqueness for auth data provider "${provider}": `, + error + ); + }) + ) + ); + } + await this.adapter.updateSchemaWithIndexes(); } diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 6a9ca20df..03db895da 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -110,7 +110,7 @@ module.exports.ParseServerOptions = { auth: { env: 'PARSE_SERVER_AUTH_PROVIDERS', help: - 'Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication', + "Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication

Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_`), which must comply with Parse Server's field naming rules.", action: parsers.objectParser, }, cacheAdapter: { @@ -1248,6 +1248,13 @@ module.exports.DatabaseOptions = { 'The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout.', action: parsers.numberParser('connectTimeoutMS'), }, + createIndexAuthDataUniqueness: { + env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_AUTH_DATA_UNIQUENESS', + help: + 'Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.

\u26A0\uFE0F When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server.', + action: parsers.booleanParser, + default: true, + }, createIndexRoleName: { env: 'PARSE_SERVER_DATABASE_CREATE_INDEX_ROLE_NAME', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index 89922709f..7df315850 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -21,7 +21,7 @@ * @property {Adapter} analyticsAdapter Adapter module for the analytics * @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 {Object} auth Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication

Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_`), which must comply with Parse Server's field naming rules. * @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) @@ -278,6 +278,7 @@ * @property {Number} autoSelectFamilyAttemptTimeout The MongoDB driver option to specify the amount of time in milliseconds to wait for a connection attempt to finish before trying the next address when using the autoSelectFamily option. If set to a positive integer less than 10, the value 10 is used instead. * @property {Union} compressors The MongoDB driver option to specify an array or comma-delimited string of compressors to enable network compression for communication between this client and a mongod/mongos instance. * @property {Number} connectTimeoutMS The MongoDB driver option to specify the amount of time, in milliseconds, to wait to establish a single TCP socket connection to the server before raising an error. Specifying 0 disables the connection timeout. + * @property {Boolean} createIndexAuthDataUniqueness Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server. * @property {Boolean} createIndexRoleName Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. * @property {Boolean} createIndexUserEmail Set to `true` to automatically create indexes on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. * @property {Boolean} createIndexUserEmailCaseInsensitive Set to `true` to automatically create a case-insensitive index on the email field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. diff --git a/src/Options/index.js b/src/Options/index.js index d36aafb4b..7de4eb768 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -164,7 +164,7 @@ export interface ParseServerOptions { :ENV: PARSE_SERVER_ALLOW_CUSTOM_OBJECT_ID :DEFAULT: false */ allowCustomObjectId: ?boolean; - /* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication + /* Configuration for your authentication providers, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#oauth-and-3rd-party-authentication

Provider names must start with a letter and contain only letters, digits, and underscores (`/^[A-Za-z][A-Za-z0-9_]*$/`). This is because each provider name is used to construct a database field (`_auth_data_`), which must comply with Parse Server's field naming rules. :ENV: PARSE_SERVER_AUTH_PROVIDERS */ auth: ?{ [string]: AuthAdapter }; /* Enable (or disable) insecure auth adapters, defaults to true. Insecure auth adapters are deprecated and it is recommended to disable them. @@ -775,6 +775,9 @@ export interface DatabaseOptions { /* Set to `true` to automatically create a case-insensitive index on the username field of the _User collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. :DEFAULT: true */ createIndexUserUsernameCaseInsensitive: ?boolean; + /* Set to `true` to automatically create unique indexes on the authData fields of the _User collection for each configured auth provider on server start, including `anonymous` when anonymous users are enabled. These indexes prevent race conditions during concurrent signups with the same authData. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the indexes, keep in mind that the otherwise automatically created indexes may change in the future to be optimized for the internal usage by Parse Server. + :DEFAULT: true */ + createIndexAuthDataUniqueness: ?boolean; /* Set to `true` to automatically create a unique index on the name field of the _Role collection on server start. Set to `false` to skip index creation. Default is `true`.

⚠️ When setting this option to `false` to manually create the index, keep in mind that the otherwise automatically created index may change in the future to be optimized for the internal usage by Parse Server. :DEFAULT: true */ createIndexRoleName: ?boolean; diff --git a/src/RestWrite.js b/src/RestWrite.js index a0de5577a..0f5f8ec61 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -514,6 +514,16 @@ RestWrite.prototype.getUserId = function () { } }; +RestWrite.prototype._throwIfAuthDataDuplicate = function (error) { + if ( + this.className === '_User' && + error?.code === Parse.Error.DUPLICATE_VALUE && + error.userInfo?.duplicated_field?.startsWith('_auth_data_') + ) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } +}; + // Developers are allowed to change authData via before save trigger // we need after before save to ensure that the developer // is not currently duplicating auth data ID @@ -644,12 +654,17 @@ RestWrite.prototype.handleAuthData = async function (authData) { // uses the `doNotSave` option. Just update the authData part // Then we're good for the user, early exit of sorts if (Object.keys(this.data.authData).length) { - await this.config.database.update( - this.className, - { objectId: this.data.objectId }, - { authData: this.data.authData }, - {} - ); + try { + await this.config.database.update( + this.className, + { objectId: this.data.objectId }, + { authData: this.data.authData }, + {} + ); + } catch (error) { + this._throwIfAuthDataDuplicate(error); + throw error; + } } } } @@ -1545,6 +1560,10 @@ RestWrite.prototype.runDatabaseOperation = function () { false, this.validSchemaController ) + .catch(error => { + this._throwIfAuthDataDuplicate(error); + throw error; + }) .then(response => { response.updatedAt = this.updatedAt; this._updateResponseWithData(response, this.data); @@ -1579,6 +1598,8 @@ RestWrite.prototype.runDatabaseOperation = function () { throw error; } + this._throwIfAuthDataDuplicate(error); + // Quick check, if we were able to infer the duplicated field name if (error && error.userInfo && error.userInfo.duplicated_field === 'username') { throw new Parse.Error( diff --git a/src/defaults.js b/src/defaults.js index 07eeb5136..4f0e62611 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -38,6 +38,7 @@ export const DefaultMongoURI = DefinitionDefaults.databaseURI; // before passing to MongoDB client export const ParseServerDatabaseOptions = [ 'allowPublicExplain', + 'createIndexAuthDataUniqueness', 'createIndexRoleName', 'createIndexUserEmail', 'createIndexUserEmailCaseInsensitive',