From 57ac3fdb128442428645f8a1d14472e8669b1811 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Mon, 6 Jun 2022 14:01:42 +0200 Subject: [PATCH 01/18] feat: new auth adapter interface --- DEPRECATIONS.md | 1 + package-lock.json | 27 +- spec/AuthenticationAdapters.spec.js | 1136 ++++++++++++++++- spec/ParseGraphQLServer.spec.js | 159 ++- spec/ParseUser.spec.js | 154 ++- src/Adapters/Auth/AuthAdapter.js | 102 +- src/Adapters/Auth/index.js | 86 +- .../Postgres/PostgresStorageAdapter.js | 8 +- src/Auth.js | 164 ++- src/Config.js | 8 + src/Deprecator/Deprecations.js | 1 + src/GraphQL/loaders/parseClassTypes.js | 15 +- src/GraphQL/loaders/usersMutations.js | 93 +- src/LiveQuery/ParseLiveQueryServer.js | 8 +- src/Options/Definitions.js | 6 + src/Options/docs.js | 1 + src/Options/index.js | 3 + src/RestQuery.js | 4 +- src/RestWrite.js | 310 ++--- src/Routers/UsersRouter.js | 145 ++- 20 files changed, 2147 insertions(+), 284 deletions(-) diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index 589cbe320d..6d8ad97ef5 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -9,6 +9,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h | DEPPS3 | Config option `enforcePrivateUsers` defaults to `true` | [#7319](https://github.com/parse-community/parse-server/pull/7319) | 5.0.0 (2022) | 6.0.0 (2023) | deprecated | - | | DEPPS4 | Remove convenience method for http request `Parse.Cloud.httpRequest` | [#7589](https://github.com/parse-community/parse-server/pull/7589) | 5.0.0 (2022) | 6.0.0 (2023) | deprecated | - | | DEPPS5 | Config option `allowClientClassCreation` defaults to `false` | [#7925](https://github.com/parse-community/parse-server/pull/7925) | 5.3.0 (2023) | 7.0.0 (2024) | deprecated | - | +| DEPPS6 | Allow login with expired authData token | [#7079](https://github.com/parse-community/parse-server/pull/7079) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - | [i_deprecation]: ## "The version and date of the deprecation." [i_removal]: ## "The version and date of the planned removal." diff --git a/package-lock.json b/package-lock.json index 9f2e99df7d..0f81c47de8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1354,25 +1354,6 @@ "tslib": "~2.3.0" }, "dependencies": { - "@graphql-tools/merge": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.0.1.tgz", - "integrity": "sha512-YAozogbjC2Oun+UcwG0LZFumhlCiHBmqe68OIf7bqtBdp4pbPAiVuK/J9oJqRVJmzvUqugo6RD9zz1qDTKZaiQ==", - "requires": { - "@graphql-tools/utils": "8.1.1", - "tslib": "~2.3.0" - }, - "dependencies": { - "@graphql-tools/utils": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-8.1.1.tgz", - "integrity": "sha512-QbFNoBmBiZ+ej4y6mOv8Ba4lNhcrTEKXAhZ0f74AhdEXi7b9xbGUH/slO5JaSyp85sGQYIPmxjRPpXBjLklbmw==", - "requires": { - "tslib": "~2.3.0" - } - } - } - }, "@graphql-tools/schema": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-8.1.1.tgz", @@ -3459,7 +3440,7 @@ "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, "array-ify": { "version": "1.0.0", @@ -10721,7 +10702,7 @@ "mkdirp": "^0.5.1", "mongodb": "^3.4.0", "mongodb-dbpath": "^0.0.1", - "mongodb-tools": "github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", + "mongodb-tools": "mongodb-tools@github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", "mongodb-version-manager": "^1.4.3", "untildify": "^4.0.0", "which": "^2.0.1" @@ -10759,8 +10740,8 @@ } }, "mongodb-tools": { - "version": "github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", - "from": "github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", + "version": "git+ssh://git@github.com/mongodb-js/mongodb-tools.git#0d1a90f49796c41f6d47c7c7999fe384014a16a0", + "from": "mongodb-tools@github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", "dev": true, "requires": { "debug": "^2.2.0", diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index bfb64502cd..64fe88ac4c 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -3,6 +3,8 @@ const Config = require('../lib/Config'); const defaultColumns = require('../lib/Controllers/SchemaController').defaultColumns; const authenticationLoader = require('../lib/Adapters/Auth'); const path = require('path'); +const Auth = require('../lib/Auth'); + const responses = { gpgames: { playerId: 'userId' }, instagram: { id: 'userId' }, @@ -18,6 +20,16 @@ const responses = { microsoft: { id: 'userId', mail: 'userMail' }, }; +// A simple wrapper to allow usage of +// expectAsync().toBeRejectedWithError(errorMessage) +const requestWithExpectedError = async params => { + try { + return await request(params); + } catch (e) { + throw new Error(e.data.error); + } +}; + describe('AuthenticationProviders', function () { [ 'apple', @@ -256,6 +268,49 @@ describe('AuthenticationProviders', function () { .catch(done.fail); }); + it('should support loginWith with session token and with/without mutated authData', async () => { + const fakeAuthProvider = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + const payload = { authData: { id: 'user1', token: 'fakeToken' } }; + const payload2 = { authData: { id: 'user1', token: 'fakeToken2' } }; + await reconfigureServer({ auth: { fakeAuthProvider } }); + const user = await Parse.User.logInWith('fakeAuthProvider', payload); + const user2 = await Parse.User.logInWith('fakeAuthProvider', payload, { + sessionToken: user.getSessionToken(), + }); + const user3 = await Parse.User.logInWith('fakeAuthProvider', payload2, { + sessionToken: user2.getSessionToken(), + }); + expect(user.id).toEqual(user2.id); + expect(user.id).toEqual(user3.id); + }); + + it('should support sync/async validateAppId', async () => { + const syncProvider = { + validateAppId: () => true, + appIds: 'test', + validateAuthData: () => Promise.resolve(), + }; + const asyncProvider = { + appIds: 'test', + validateAppId: () => Promise.resolve(true), + validateAuthData: () => Promise.resolve(), + }; + const payload = { authData: { id: 'user1', token: 'fakeToken' } }; + const syncSpy = spyOn(syncProvider, 'validateAppId'); + const asyncSpy = spyOn(asyncProvider, 'validateAppId'); + + await reconfigureServer({ auth: { asyncProvider, syncProvider } }); + const user = await Parse.User.logInWith('asyncProvider', payload); + const user2 = await Parse.User.logInWith('syncProvider', payload); + expect(user.getSessionToken()).toBeDefined(); + expect(user2.getSessionToken()).toBeDefined(); + expect(syncSpy).toHaveBeenCalledTimes(1); + expect(asyncSpy).toHaveBeenCalledTimes(1); + }); + it('unlink and link with custom provider', async () => { const provider = getMockMyOauthProvider(); Parse.User._registerAuthenticationProvider(provider); @@ -339,10 +394,10 @@ describe('AuthenticationProviders', function () { }); validateAuthenticationHandler(authenticationHandler); - const validator = authenticationHandler.getValidatorForProvider('customAuthentication'); + const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication'); validateValidator(validator); - validator(validAuthData).then( + validator(validAuthData, {}, {}).then( () => { expect(authDataSpy).toHaveBeenCalled(); // AppIds are not provided in the adapter, should not be called @@ -362,12 +417,15 @@ describe('AuthenticationProviders', function () { }); validateAuthenticationHandler(authenticationHandler); - const validator = authenticationHandler.getValidatorForProvider('customAuthentication'); + const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication'); validateValidator(validator); - - validator({ - token: 'my-token', - }).then( + validator( + { + token: 'my-token', + }, + {}, + {} + ).then( () => { done(); }, @@ -387,12 +445,16 @@ describe('AuthenticationProviders', function () { }); validateAuthenticationHandler(authenticationHandler); - const validator = authenticationHandler.getValidatorForProvider('customAuthentication'); + const { validator } = authenticationHandler.getValidatorForProvider('customAuthentication'); validateValidator(validator); - validator({ - token: 'valid-token', - }).then( + validator( + { + token: 'valid-token', + }, + {}, + {} + ).then( () => { done(); }, @@ -2144,3 +2206,1055 @@ describe('facebook limited auth adapter', () => { } }); }); + +describe('Auth Adapter features', () => { + const baseAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + }; + const baseAdapter2 = { + validateAppId: appIds => (appIds[0] === 'test' ? Promise.resolve() : Promise.reject()), + validateAuthData: () => Promise.resolve(), + appIds: ['test'], + options: { anOption: true }, + }; + + const doNotSaveAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve({ doNotSave: true }), + }; + + const additionalAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + policy: 'additional', + }; + + const soloAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + policy: 'solo', + }; + + const challengeAdapter = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + challenge: () => Promise.resolve({ token: 'test' }), + options: { + anOption: true, + }, + }; + + const modernAdapter = { + validateAppId: () => Promise.resolve(), + validateSetUp: () => Promise.resolve(), + validateUpdate: () => Promise.resolve(), + validateLogin: () => Promise.resolve(), + }; + + const modernAdapter2 = { + validateAppId: () => Promise.resolve(), + validateSetUp: () => Promise.resolve(), + validateUpdate: () => Promise.resolve(), + validateLogin: () => Promise.resolve(), + }; + + const wrongAdapter = { + validateAppId: () => Promise.resolve(), + }; + + const headers = { + 'Content-Type': 'application/json', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }; + + it('should ensure no duplicate auth data id after before save', async () => { + await reconfigureServer({ + auth: { baseAdapter }, + cloud: () => { + Parse.Cloud.beforeSave('_User', async request => { + request.object.set('authData', { baseAdapter: { id: 'test' } }); + }); + }, + }); + + const user = new Parse.User(); + await user.save({ authData: { baseAdapter: { id: 'another' } } }); + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ baseAdapter: { id: 'test' } }); + + const user2 = new Parse.User(); + await expectAsync( + user2.save({ authData: { baseAdapter: { id: 'another' } } }) + ).toBeRejectedWithError('this auth is already used'); + }); + + it('should ensure no duplicate auth data id after before save in case of more than one result', async () => { + await reconfigureServer({ + auth: { baseAdapter }, + cloud: () => { + Parse.Cloud.beforeSave('_User', async request => { + request.object.set('authData', { baseAdapter: { id: 'test' } }); + }); + }, + }); + + const user = new Parse.User(); + await user.save({ authData: { baseAdapter: { id: 'another' } } }); + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ baseAdapter: { id: 'test' } }); + + let i = 0; + const originalFn = Auth.findUsersWithAuthData; + spyOn(Auth, 'findUsersWithAuthData').and.callFake((...params) => { + // First call is triggered during authData validation + if (i === 0) { + i++; + return originalFn(...params); + } + // Second call is triggered after beforeSave. A developer can modify authData during beforeSave. + // To perform a determinist login, parse need to ensure uniqueness of the authData.id into the database. + // A developer with a direct access to the database could break something and duplicate authData.id. + // In this case, if parse detect 2 matching users for a singe authData.id; login/register will be canceled. + // Promise.resolve([true, true]) simulate this case with 2 matching users. + return Promise.resolve([true, true]); + }); + const user2 = new Parse.User(); + await expectAsync( + user2.save({ authData: { baseAdapter: { id: 'another' } } }) + ).toBeRejectedWithError('this auth is already used'); + }); + + it('should ensure no duplicate auth data id during authData validation in case of more than one result', async () => { + await reconfigureServer({ + auth: { baseAdapter }, + cloud: () => { + Parse.Cloud.beforeSave('_User', async request => { + request.object.set('authData', { baseAdapter: { id: 'test' } }); + }); + }, + }); + + spyOn(Auth, 'findUsersWithAuthData').and.resolveTo([true, true]); + + const user = new Parse.User(); + await expectAsync( + user.save({ authData: { baseAdapter: { id: 'another' } } }) + ).toBeRejectedWithError('this auth is already used'); + }); + + it('should pass authData, options, request, config to validateAuthData', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ auth: { baseAdapter } }); + const user = new Parse.User(); + const payload = { someData: true }; + + await user.save({ + username: 'test', + password: 'password', + authData: { baseAdapter: payload }, + }); + + expect(user.getSessionToken()).toBeDefined(); + const firstCall = baseAdapter.validateAuthData.calls.argsFor(0); + expect(firstCall[0]).toEqual(payload); + expect(firstCall[1]).toEqual(baseAdapter); + expect(firstCall[2].object).toBeDefined(); + expect(firstCall[2].original).toBeUndefined(); + expect(firstCall[2].user).toBeUndefined(); + expect(firstCall[2].isChallenge).toBeUndefined(); + expect(firstCall[3] instanceof Config).toBeTruthy(); + + await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'test', + password: 'password', + authData: { baseAdapter: payload }, + }), + }); + const secondCall = baseAdapter.validateAuthData.calls.argsFor(1); + expect(secondCall[0]).toEqual(payload); + expect(secondCall[1]).toEqual(baseAdapter); + expect(secondCall[2].original).toBeDefined(); + expect(secondCall[2].original instanceof Parse.User).toBeTruthy(); + expect(secondCall[2].original.id).toEqual(user.id); + expect(secondCall[2].object).toBeDefined(); + expect(secondCall[2].object instanceof Parse.User).toBeTruthy(); + expect(secondCall[2].object.id).toEqual(user.id); + expect(secondCall[2].isChallenge).toBeUndefined(); + expect(secondCall[2].user).toBeUndefined(); + expect(secondCall[3] instanceof Config).toBeTruthy(); + }); + + it('should trigger correctly validateSetUp', async () => { + spyOn(modernAdapter, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter, 'validateLogin').and.resolveTo({}); + spyOn(modernAdapter2, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter2, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter2, 'validateLogin').and.resolveTo({}); + + await reconfigureServer({ auth: { modernAdapter, modernAdapter2 } }); + const user = new Parse.User(); + + await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + const call = modernAdapter.validateSetUp.calls.argsFor(0); + expect(call[0]).toEqual({ id: 'modernAdapter' }); + expect(call[1]).toEqual(modernAdapter); + expect(call[2].isChallenge).toBeUndefined(); + expect(call[2].master).toBeDefined(); + expect(call[2].object instanceof Parse.User).toBeTruthy(); + expect(call[2].user).toBeUndefined(); + expect(call[2].original).toBeUndefined(); + expect(call[3] instanceof Config).toBeTruthy(); + expect(user.getSessionToken()).toBeDefined(); + + await user.save( + { authData: { modernAdapter2: { id: 'modernAdapter2' } } }, + { sessionToken: user.getSessionToken() } + ); + + expect(modernAdapter2.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter2.validateLogin).toHaveBeenCalledTimes(0); + expect(modernAdapter2.validateSetUp).toHaveBeenCalledTimes(1); + const call2 = modernAdapter2.validateSetUp.calls.argsFor(0); + expect(call2[0]).toEqual({ id: 'modernAdapter2' }); + expect(call2[1]).toEqual(modernAdapter2); + expect(call2[2].isChallenge).toBeUndefined(); + expect(call2[2].master).toBeDefined(); + expect(call2[2].object instanceof Parse.User).toBeTruthy(); + expect(call2[2].original instanceof Parse.User).toBeTruthy(); + expect(call2[2].user instanceof Parse.User).toBeTruthy(); + expect(call2[2].original.id).toEqual(call2[2].object.id); + expect(call2[2].user.id).toEqual(call2[2].object.id); + expect(call2[2].object.id).toEqual(user.id); + expect(call2[3] instanceof Config).toBeTruthy(); + + const user2 = new Parse.User(); + user2.id = user.id; + await user2.fetch({ useMasterKey: true }); + expect(user2.get('authData')).toEqual({ + modernAdapter: { id: 'modernAdapter' }, + modernAdapter2: { id: 'modernAdapter2' }, + }); + }); + + it('should trigger correctly validateLogin', async () => { + spyOn(modernAdapter, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter, 'validateLogin').and.resolveTo({}); + + await reconfigureServer({ auth: { modernAdapter }, allowExpiredAuthDataToken: false }); + const user = new Parse.User(); + + // Signup + await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + // Login + const user2 = new Parse.User(); + await user2.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(1); + const call = modernAdapter.validateLogin.calls.argsFor(0); + expect(call[0]).toEqual({ id: 'modernAdapter' }); + expect(call[1]).toEqual(modernAdapter); + expect(call[2].object instanceof Parse.User).toBeTruthy(); + expect(call[2].original instanceof Parse.User).toBeTruthy(); + expect(call[2].isChallenge).toBeUndefined(); + expect(call[2].master).toBeDefined(); + expect(call[2].user).toBeUndefined(); + expect(call[2].original.id).toEqual(user2.id); + expect(call[2].object.id).toEqual(user2.id); + expect(call[2].object.id).toEqual(user.id); + expect(call[3] instanceof Config).toBeTruthy(); + expect(user2.getSessionToken()).toBeDefined(); + }); + + it('should trigger correctly validateUpdate', async () => { + spyOn(modernAdapter, 'validateSetUp').and.resolveTo({}); + spyOn(modernAdapter, 'validateUpdate').and.resolveTo({}); + spyOn(modernAdapter, 'validateLogin').and.resolveTo({}); + + await reconfigureServer({ auth: { modernAdapter } }); + const user = new Parse.User(); + + // Signup + await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + + // Save same data + await user.save( + { authData: { modernAdapter: { id: 'modernAdapter' } } }, + { sessionToken: user.getSessionToken() } + ); + + // Save same data with master key + await user.save( + { authData: { modernAdapter: { id: 'modernAdapter' } } }, + { useMasterKey: true } + ); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(0); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(0); + + // Change authData + await user.save( + { authData: { modernAdapter: { id: 'modernAdapter2' } } }, + { sessionToken: user.getSessionToken() } + ); + + expect(modernAdapter.validateUpdate).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); + expect(modernAdapter.validateLogin).toHaveBeenCalledTimes(0); + const call = modernAdapter.validateUpdate.calls.argsFor(0); + expect(call[0]).toEqual({ id: 'modernAdapter2' }); + expect(call[1]).toEqual(modernAdapter); + expect(call[2].isChallenge).toBeUndefined(); + expect(call[2].master).toBeDefined(); + expect(call[2].object instanceof Parse.User).toBeTruthy(); + expect(call[2].user instanceof Parse.User).toBeTruthy(); + expect(call[2].original instanceof Parse.User).toBeTruthy(); + expect(call[2].object.id).toEqual(user.id); + expect(call[2].original.id).toEqual(user.id); + expect(call[2].user.id).toEqual(user.id); + expect(call[3] instanceof Config).toBeTruthy(); + expect(user.getSessionToken()).toBeDefined(); + }); + + it('should throw if no triggers found', async () => { + await reconfigureServer({ auth: { wrongAdapter } }); + const user = new Parse.User(); + await expectAsync( + user.save({ authData: { wrongAdapter: { id: 'wrongAdapter' } } }) + ).toBeRejectedWithError( + 'Adapter not ready, need to implement validateAuthData or (validateSetUp, validateLogin, validateUpdate)' + ); + }); + + it('should throw if no triggers found', async () => { + await reconfigureServer({ auth: { wrongAdapter } }); + const user = new Parse.User(); + await expectAsync( + user.save({ authData: { wrongAdapter: { id: 'wrongAdapter' } } }) + ).toBeRejectedWithError( + 'Adapter not ready, need to implement validateAuthData or (validateSetUp, validateLogin, validateUpdate)' + ); + }); + + it('should not update authData if provider return doNotSave', async () => { + spyOn(doNotSaveAdapter, 'validateAuthData').and.resolveTo({ doNotSave: true }); + await reconfigureServer({ + auth: { doNotSaveAdapter, baseAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { baseAdapter: { id: 'baseAdapter' }, doNotSaveAdapter: { token: true } }, + }); + + await user.fetch({ useMasterKey: true }); + + expect(user.get('authData')).toEqual({ baseAdapter: { id: 'baseAdapter' } }); + }); + + it('should perform authData validation only when its required', async () => { + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({}); + spyOn(baseAdapter2, 'validateAppId').and.resolveTo({}); + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ + auth: { baseAdapter2, baseAdapter }, + allowExpiredAuthDataToken: false, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { token: true }, + }, + }); + + expect(baseAdapter2.validateAuthData).toHaveBeenCalledTimes(1); + expect(baseAdapter2.validateAppId).toHaveBeenCalledTimes(1); + + const user2 = new Parse.User(); + await user2.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + }, + }); + + expect(baseAdapter2.validateAuthData).toHaveBeenCalledTimes(1); + + const user3 = new Parse.User(); + await user3.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { token: true }, + }, + }); + + expect(baseAdapter2.validateAuthData).toHaveBeenCalledTimes(2); + }); + + it('should require additional provider if configured', async () => { + await reconfigureServer({ + auth: { baseAdapter, additionalAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + additionalAdapter: { token: true }, + }, + }); + + const user2 = new Parse.User(); + await expectAsync( + user2.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + }, + }) + ).toBeRejectedWithError('Missing additional authData additionalAdapter'); + expect(user2.getSessionToken()).toBeUndefined(); + + await user2.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + additionalAdapter: { token: true }, + }, + }); + + expect(user2.getSessionToken()).toBeDefined(); + }); + + it('should skip additional provider if used provider is solo', async () => { + await reconfigureServer({ + auth: { soloAdapter, additionalAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + soloAdapter: { id: 'soloAdapter' }, + additionalAdapter: { token: true }, + }, + }); + + const user2 = new Parse.User(); + await user2.save({ + authData: { + soloAdapter: { id: 'soloAdapter' }, + }, + }); + expect(user2.getSessionToken()).toBeDefined(); + }); + + it('should return authData response and save some info on non username login', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({ + response: { someData: true }, + }); + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({ + response: { someData2: true }, + save: { otherData: true }, + }); + await reconfigureServer({ + auth: { baseAdapter, baseAdapter2 }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + + expect(user.get('authDataResponse')).toEqual({ + baseAdapter: { someData: true }, + baseAdapter2: { someData2: true }, + }); + + const user2 = new Parse.User(); + user2.id = user.id; + await user2.save( + { + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + expect(user2.get('authDataResponse')).toEqual({ baseAdapter2: { someData2: true } }); + + const user3 = new Parse.User(); + await user3.save({ + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + + // On logIn all authData are revalidated + expect(user3.get('authDataResponse')).toEqual({ + baseAdapter: { someData: true }, + baseAdapter2: { someData2: true }, + }); + + const userViaMasterKey = new Parse.User(); + userViaMasterKey.id = user2.id; + await userViaMasterKey.fetch({ useMasterKey: true }); + expect(userViaMasterKey.get('authData')).toEqual({ + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { otherData: true }, + }); + }); + + it('should return authData response and save some info on username login', async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({ + response: { someData: true }, + }); + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({ + response: { someData2: true }, + save: { otherData: true }, + }); + await reconfigureServer({ + auth: { baseAdapter, baseAdapter2 }, + }); + + const user = new Parse.User(); + + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + + expect(user.get('authDataResponse')).toEqual({ + baseAdapter: { someData: true }, + baseAdapter2: { someData2: true }, + }); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/login', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter' }, + }, + }), + }); + const result = res.data; + expect(result.authDataResponse).toEqual({ + baseAdapter2: { someData2: true }, + baseAdapter: { someData: true }, + }); + + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { otherData: true }, + }); + }); + + describe('should allow update of authData', () => { + beforeEach(async () => { + spyOn(baseAdapter, 'validateAuthData').and.resolveTo({ + response: { someData: true }, + }); + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({ + response: { someData2: true }, + save: { otherData: true }, + }); + await reconfigureServer({ + auth: { baseAdapter, baseAdapter2 }, + }); + }); + + it('should not re validate the baseAdapter when user is already logged in and authData not changed', async () => { + const user = new Parse.User(); + + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1); + + expect(user.id).toBeDefined(); + expect(user.getSessionToken()).toBeDefined(); + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter' }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1); + }); + + it('should not re-validate the baseAdapter when master key is used and authData has not changed', async () => { + const user = new Parse.User(); + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter' }, + }, + }, + { useMasterKey: true } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1); + }); + + it('should allow user to change authData', async () => { + const user = new Parse.User(); + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter2' }, + }, + }, + { sessionToken: user.getSessionToken() } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(2); + }); + + it('should allow master key to change authData', async () => { + const user = new Parse.User(); + await user.save({ + username: 'username', + password: 'password', + authData: { + baseAdapter: { id: 'baseAdapter' }, + baseAdapter2: { test: true }, + }, + }); + await user.save( + { + authData: { + baseAdapter2: { test: true }, + baseAdapter: { id: 'baseAdapter3' }, + }, + }, + { useMasterKey: true } + ); + + expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(2); + + await user.fetch({ useMasterKey: true }); + expect(user.get('authData')).toEqual({ + baseAdapter: { id: 'baseAdapter3' }, + baseAdapter2: { otherData: true }, + }); + }); + }); + + it('should pass user to auth adapter on update by matching session', async () => { + spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({}); + await reconfigureServer({ auth: { baseAdapter2 } }); + + const user = new Parse.User(); + + const payload = { someData: true }; + + await user.save({ + username: 'test', + password: 'password', + }); + + expect(user.getSessionToken()).toBeDefined(); + + await user.save( + { authData: { baseAdapter2: payload } }, + { sessionToken: user.getSessionToken() } + ); + + const firstCall = baseAdapter2.validateAuthData.calls.argsFor(0); + expect(firstCall[0]).toEqual(payload); + expect(firstCall[1]).toEqual(baseAdapter2); + expect(firstCall[2].isChallenge).toBeUndefined(); + expect(firstCall[2].master).toBeDefined(); + expect(firstCall[2].object instanceof Parse.User).toBeTruthy(); + expect(firstCall[2].user instanceof Parse.User).toBeTruthy(); + expect(firstCall[2].original instanceof Parse.User).toBeTruthy(); + expect(firstCall[2].object.id).toEqual(user.id); + expect(firstCall[2].original.id).toEqual(user.id); + expect(firstCall[2].user.id).toEqual(user.id); + expect(firstCall[3] instanceof Config).toBeTruthy(); + + await user.save({ authData: { baseAdapter2: payload } }, { useMasterKey: true }); + + const secondCall = baseAdapter2.validateAuthData.calls.argsFor(1); + expect(secondCall[0]).toEqual(payload); + expect(secondCall[1]).toEqual(baseAdapter2); + expect(secondCall[2].isChallenge).toBeUndefined(); + expect(secondCall[2].master).toEqual(true); + expect(secondCall[2].user).toBeUndefined(); + expect(secondCall[2].object instanceof Parse.User).toBeTruthy(); + expect(secondCall[2].original instanceof Parse.User).toBeTruthy(); + expect(secondCall[2].object.id).toEqual(user.id); + expect(secondCall[2].original.id).toEqual(user.id); + expect(secondCall[3] instanceof Config).toBeTruthy(); + }); + + it('should return challenge with no logged user', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter }, + }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: {}, + }) + ).toBeRejectedWithError('Nothing to challenge.'); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: { challengeData: true }, + }) + ).toBeRejectedWithError('challengeData should be an object.'); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: { challengeData: { data: true }, authData: true }, + }) + ).toBeRejectedWithError('authData should be an object.'); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }); + + expect(res.data).toEqual({ + challengeData: { + challengeAdapter: { + token: 'test', + }, + }, + }); + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someData: true }); + expect(challengeCall[1]).toBeUndefined(); + expect(challengeCall[2]).toEqual(challengeAdapter); + expect(challengeCall[3].master).toBeDefined(); + expect(challengeCall[3].headers).toBeDefined(); + expect(challengeCall[3].object).toBeUndefined(); + expect(challengeCall[3].original).toBeUndefined(); + expect(challengeCall[3].user).toBeUndefined(); + expect(challengeCall[3].isChallenge).toBeTruthy(); + expect(challengeCall[4] instanceof Config).toBeTruthy(); + }); + + it('should return empty challenge data response if challenged provider does not exists', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter }, + }); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + nonExistingProvider: { someData: true }, + }, + }), + }); + + expect(res.data).toEqual({ challengeData: {} }); + }); + it('should return challenge with username created user', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter }, + }); + + const user = new Parse.User(); + await user.save({ username: 'username', password: 'password' }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + username: 'username', + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }) + ).toBeRejectedWithError('You provided username or email, you need to also provide password.'); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + username: 'username', + password: 'password', + authData: { data: true }, + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }) + ).toBeRejectedWithError( + 'You cant provide username/email and authData, only use one identification method.' + ); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + username: 'username', + password: 'password', + challengeData: { + challengeAdapter: { someData: true }, + }, + }), + }); + + expect(res.data).toEqual({ + challengeData: { + challengeAdapter: { + token: 'test', + }, + }, + }); + + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someData: true }); + expect(challengeCall[1]).toEqual(undefined); + expect(challengeCall[2]).toEqual(challengeAdapter); + expect(challengeCall[3].master).toBeDefined(); + expect(challengeCall[3].isChallenge).toBeTruthy(); + expect(challengeCall[3].user).toBeUndefined(); + expect(challengeCall[3].object instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].original instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].object.id).toEqual(user.id); + expect(challengeCall[3].original.id).toEqual(user.id); + expect(challengeCall[4] instanceof Config).toBeTruthy(); + }); + + it('should return challenge with authData created user', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + spyOn(challengeAdapter, 'validateAuthData').and.callThrough(); + + await reconfigureServer({ + auth: { challengeAdapter, soloAdapter }, + }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }) + ).toBeRejectedWithError('User not found.'); + + const user = new Parse.User(); + await user.save({ authData: { challengeAdapter: { id: 'challengeAdapter' } } }); + + const user2 = new Parse.User(); + await user2.save({ authData: { soloAdapter: { id: 'soloAdapter' } } }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + soloAdapter: { id: 'soloAdapter' }, + }, + }), + }) + ).toBeRejectedWithError('You cant provide more than one authData provider with an id.'); + + const res = await request({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }); + + expect(res.data).toEqual({ + challengeData: { + challengeAdapter: { + token: 'test', + }, + }, + }); + + const validateCall = challengeAdapter.validateAuthData.calls.argsFor(1); + expect(validateCall[2].isChallenge).toBeTruthy(); + + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someData: true }); + expect(challengeCall[1]).toEqual({ id: 'challengeAdapter' }); + expect(challengeCall[2]).toEqual(challengeAdapter); + expect(challengeCall[3].master).toBeDefined(); + expect(challengeCall[3].isChallenge).toBeTruthy(); + expect(challengeCall[3].object instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].original instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].object.id).toEqual(user.id); + expect(challengeCall[3].original.id).toEqual(user.id); + expect(challengeCall[4] instanceof Config).toBeTruthy(); + }); + + it('should validate provided authData and prevent guess id attack', async () => { + spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' }); + + await reconfigureServer({ + auth: { challengeAdapter, soloAdapter }, + }); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }) + ).toBeRejectedWithError('User not found.'); + + const user = new Parse.User(); + await user.save({ authData: { challengeAdapter: { id: 'challengeAdapter' } } }); + + spyOn(challengeAdapter, 'validateAuthData').and.rejectWith({}); + + await expectAsync( + requestWithExpectedError({ + headers: headers, + method: 'POST', + url: 'http://localhost:8378/1/challenge', + body: JSON.stringify({ + challengeData: { + challengeAdapter: { someData: true }, + }, + authData: { + challengeAdapter: { id: 'challengeAdapter' }, + }, + }), + }) + ).toBeRejectedWithError('User not found.'); + + const validateCall = challengeAdapter.validateAuthData.calls.argsFor(0); + expect(challengeAdapter.validateAuthData).toHaveBeenCalledTimes(1); + expect(validateCall[0]).toEqual({ id: 'challengeAdapter' }); + expect(validateCall[1]).toEqual(challengeAdapter); + expect(validateCall[2].isChallenge).toBeTruthy(); + expect(validateCall[2].master).toBeDefined(); + expect(validateCall[2].object instanceof Parse.User).toBeTruthy(); + expect(validateCall[2].original instanceof Parse.User).toBeTruthy(); + expect(validateCall[2].object.id).toEqual(user.id); + expect(validateCall[2].original.id).toEqual(user.id); + expect(validateCall[3] instanceof Config).toBeTruthy(); + }); +}); diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js index 0abe07e637..e3a38388b4 100644 --- a/spec/ParseGraphQLServer.spec.js +++ b/spec/ParseGraphQLServer.spec.js @@ -1,5 +1,6 @@ const http = require('http'); const express = require('express'); +const Config = require('../lib/Config'); const req = require('../lib/request'); const fetch = (...args) => import('node-fetch').then(({ default: fetch }) => fetch(...args)); const FormData = require('form-data'); @@ -908,8 +909,7 @@ describe('ParseGraphQLServer', () => { ).data['__type'].inputFields .map(field => field.name) .sort(); - - expect(inputFields).toEqual(['clientMutationId', 'password', 'username']); + expect(inputFields).toEqual(['authData', 'clientMutationId', 'password', 'username']); }); it('should have clientMutationId in log in mutation payload', async () => { @@ -6993,7 +6993,61 @@ describe('ParseGraphQLServer', () => { }); describe('Users Mutations', () => { + const challengeAdapter = { + validateAuthData: () => Promise.resolve({ response: { someData: true } }), + validateAppId: () => Promise.resolve(), + challenge: () => Promise.resolve({ someData: true }), + options: { anOption: true }, + }; + + it('should create user and return authData response', async () => { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); + const clientMutationId = uuidv4(); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation createUser($input: CreateUserInput!) { + createUser(input: $input) { + clientMutationId + user { + id + authDataResponse + } + } + } + `, + variables: { + input: { + clientMutationId, + fields: { + authData: { + challengeAdapter: { + id: 'challengeAdapter', + }, + }, + }, + }, + }, + }); + + expect(result.data.createUser.clientMutationId).toEqual(clientMutationId); + expect(result.data.createUser.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); + }); + it('should sign user up', async () => { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); const clientMutationId = uuidv4(); const userSchema = new Parse.Schema('_User'); userSchema.addString('someField'); @@ -7010,6 +7064,7 @@ describe('ParseGraphQLServer', () => { sessionToken user { someField + authDataResponse aPointer { id username @@ -7025,6 +7080,11 @@ describe('ParseGraphQLServer', () => { fields: { username: 'user1', password: 'user1', + authData: { + challengeAdapter: { + id: 'challengeAdapter', + }, + }, aPointer: { createAndLink: { username: 'user2', @@ -7044,6 +7104,9 @@ describe('ParseGraphQLServer', () => { expect(result.data.signUp.viewer.user.aPointer.id).toBeDefined(); expect(result.data.signUp.viewer.user.aPointer.username).toEqual('user2'); expect(typeof result.data.signUp.viewer.sessionToken).toBe('string'); + expect(result.data.signUp.viewer.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); }); it('should login with user', async () => { @@ -7052,6 +7115,7 @@ describe('ParseGraphQLServer', () => { parseServer = await global.reconfigureServer({ publicServerURL: 'http://localhost:13377/parse', auth: { + challengeAdapter, myAuth: { module: global.mockCustomAuthenticator('parse', 'graphql'), }, @@ -7071,6 +7135,7 @@ describe('ParseGraphQLServer', () => { sessionToken user { someField + authDataResponse aPointer { id username @@ -7084,6 +7149,7 @@ describe('ParseGraphQLServer', () => { input: { clientMutationId, authData: { + challengeAdapter: { id: 'challengeAdapter' }, myAuth: { id: 'parse', password: 'graphql', @@ -7109,9 +7175,93 @@ describe('ParseGraphQLServer', () => { expect(typeof result.data.logInWith.viewer.sessionToken).toBe('string'); expect(result.data.logInWith.viewer.user.aPointer.id).toBeDefined(); expect(result.data.logInWith.viewer.user.aPointer.username).toEqual('user2'); + expect(result.data.logInWith.viewer.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); + }); + + it('should handle challenge', async () => { + const clientMutationId = uuidv4(); + + spyOn(challengeAdapter, 'challenge').and.callThrough(); + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); + + const user = new Parse.User(); + await user.save({ username: 'username', password: 'password' }); + + const result = await apolloClient.mutate({ + mutation: gql` + mutation Challenge($input: ChallengeInput!) { + challenge(input: $input) { + clientMutationId + challengeData + } + } + `, + variables: { + input: { + clientMutationId, + username: 'username', + password: 'password', + challengeData: { + challengeAdapter: { someChallengeData: true }, + }, + }, + }, + }); + + const challengeCall = challengeAdapter.challenge.calls.argsFor(0); + expect(challengeAdapter.challenge).toHaveBeenCalledTimes(1); + expect(challengeCall[0]).toEqual({ someChallengeData: true }); + expect(challengeCall[1]).toEqual(undefined); + expect(challengeCall[2]).toEqual(challengeAdapter); + expect(challengeCall[3].object instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].original instanceof Parse.User).toBeTruthy(); + expect(challengeCall[3].isChallenge).toBeTruthy(); + expect(challengeCall[3].object.id).toEqual(user.id); + expect(challengeCall[3].original.id).toEqual(user.id); + expect(challengeCall[4] instanceof Config).toBeTruthy(); + expect(result.data.challenge.clientMutationId).toEqual(clientMutationId); + expect(result.data.challenge.challengeData).toEqual({ + challengeAdapter: { someData: true }, + }); + + await expectAsync( + apolloClient.mutate({ + mutation: gql` + mutation Challenge($input: ChallengeInput!) { + challenge(input: $input) { + clientMutationId + challengeData + } + } + `, + variables: { + input: { + clientMutationId, + username: 'username', + password: 'wrongPassword', + challengeData: { + challengeAdapter: { someChallengeData: true }, + }, + }, + }, + }) + ).toBeRejected(); }); it('should log the user in', async () => { + parseServer = await global.reconfigureServer({ + publicServerURL: 'http://localhost:13377/parse', + auth: { + challengeAdapter, + }, + }); const clientMutationId = uuidv4(); const user = new Parse.User(); user.setUsername('user1'); @@ -7128,6 +7278,7 @@ describe('ParseGraphQLServer', () => { viewer { sessionToken user { + authDataResponse someField } } @@ -7139,6 +7290,7 @@ describe('ParseGraphQLServer', () => { clientMutationId, username: 'user1', password: 'user1', + authData: { challengeAdapter: { token: true } }, }, }, }); @@ -7147,6 +7299,9 @@ describe('ParseGraphQLServer', () => { expect(result.data.logIn.viewer.sessionToken).toBeDefined(); expect(result.data.logIn.viewer.user.someField).toEqual('someValue'); expect(typeof result.data.logIn.viewer.sessionToken).toBe('string'); + expect(result.data.logIn.viewer.user.authDataResponse).toEqual({ + challengeAdapter: { someData: true }, + }); }); it('should log the user out', async () => { diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 92301316e4..b491097fda 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -13,6 +13,63 @@ const passwordCrypto = require('../lib/password'); const Config = require('../lib/Config'); const cryptoUtils = require('../lib/cryptoUtils'); +describe('allowExpiredAuthDataToken option', () => { + it('should accept true value', async () => { + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn').and.callFake(() => {}); + await reconfigureServer({ allowExpiredAuthDataToken: true }); + expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(true); + expect( + logSpy.calls + .all() + .filter( + log => + log.args[0] === + `DeprecationWarning: The Parse Server option 'allowExpiredAuthDataToken' default will change to 'false' in a future version.` + ).length + ).toEqual(0); + }); + + it('should accept false value', async () => { + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn').and.callFake(() => {}); + await reconfigureServer({ allowExpiredAuthDataToken: false }); + expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(false); + expect( + logSpy.calls + .all() + .filter( + log => + log.args[0] === + `DeprecationWarning: The Parse Server option 'allowExpiredAuthDataToken' default will change to 'false' in a future version.` + ).length + ).toEqual(0); + }); + + it('should default true', async () => { + const logger = require('../lib/logger').logger; + const logSpy = spyOn(logger, 'warn').and.callFake(() => {}); + await reconfigureServer({}); + expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(true); + expect( + logSpy.calls + .all() + .filter( + log => + log.args[0] === + `DeprecationWarning: The Parse Server option 'allowExpiredAuthDataToken' default will change to 'false' in a future version.` + ).length + ).toEqual(1); + }); + + it('should enforce boolean values', async () => { + const options = [[], 'a', '', 0, 1, {}, 'true', 'false']; + for (const option of options) { + await expectAsync(reconfigureServer({ allowExpiredAuthDataToken: option })).toBeRejected(); + } + }); +}); + describe('Parse.User testing', () => { it('user sign up class method', async done => { const user = await Parse.User.signUp('asdf', 'zxcv'); @@ -1129,7 +1186,7 @@ describe('Parse.User testing', () => { this.synchronizedExpiration = authData.expiration_date; return true; }, - getAuthType: function () { + getAuthType() { return 'facebook'; }, deauthenticate: function () { @@ -1158,7 +1215,7 @@ describe('Parse.User testing', () => { synchronizedAuthToken: null, synchronizedExpiration: null, - authenticate: function (options) { + authenticate(options) { if (this.shouldError) { options.error(this, 'An error occurred'); } else if (this.shouldCancel) { @@ -1167,7 +1224,7 @@ describe('Parse.User testing', () => { options.success(this, this.authData); } }, - restoreAuthentication: function (authData) { + restoreAuthentication(authData) { if (!authData) { this.synchronizedUserId = null; this.synchronizedAuthToken = null; @@ -1179,10 +1236,10 @@ describe('Parse.User testing', () => { this.synchronizedExpiration = authData.expiration_date; return true; }, - getAuthType: function () { + getAuthType() { return 'myoauth'; }, - deauthenticate: function () { + deauthenticate() { this.loggedOut = true; this.restoreAuthentication(null); }, @@ -1792,7 +1849,7 @@ describe('Parse.User testing', () => { }); }); - it('should allow login with old authData token', done => { + it('should allow login with expired authData token by default', async () => { const provider = { authData: { id: '12345', @@ -1813,22 +1870,42 @@ describe('Parse.User testing', () => { }; defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token'); Parse.User._registerAuthenticationProvider(provider); - Parse.User._logInWith('shortLivedAuth', {}) - .then(() => { - // Simulate a remotely expired token (like a short lived one) - // In this case, we want success as it was valid once. - // If the client needs an updated one, do lock the user out - defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); - return Parse.User._logInWith('shortLivedAuth', {}); - }) - .then( - () => { - done(); - }, - err => { - done.fail(err); - } - ); + await Parse.User._logInWith('shortLivedAuth', {}); + // Simulate a remotely expired token (like a short lived one) + // In this case, we want success as it was valid once. + // If the client needs an updated one, do lock the user out + defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); + await Parse.User._logInWith('shortLivedAuth', {}); + }); + + it('should not allow login with expired authData token when allowExpiredAuthDataToken is set to false', async () => { + await reconfigureServer({ allowExpiredAuthDataToken: false }); + const provider = { + authData: { + id: '12345', + access_token: 'token', + }, + restoreAuthentication: function () { + return true; + }, + deauthenticate: function () { + provider.authData = {}; + }, + authenticate: function (options) { + options.success(this, provider.authData); + }, + getAuthType: function () { + return 'shortLivedAuth'; + }, + }; + defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token'); + Parse.User._registerAuthenticationProvider(provider); + await Parse.User._logInWith('shortLivedAuth', {}); + // Simulate a remotely expired token (like a short lived one) + // In this case, we want success as it was valid once. + // If the client needs an updated one, do lock the user out + defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); + expectAsync(Parse.User._logInWith('shortLivedAuth', {})).toBeRejected(); }); it('should allow PUT request with stale auth Data', done => { @@ -2260,37 +2337,14 @@ describe('Parse.User testing', () => { }); describe('anonymous users', () => { - beforeEach(() => { - const insensitiveCollisions = [ - 'abcdefghijklmnop', - 'Abcdefghijklmnop', - 'ABcdefghijklmnop', - 'ABCdefghijklmnop', - 'ABCDefghijklmnop', - 'ABCDEfghijklmnop', - 'ABCDEFghijklmnop', - 'ABCDEFGhijklmnop', - 'ABCDEFGHijklmnop', - 'ABCDEFGHIjklmnop', - 'ABCDEFGHIJklmnop', - 'ABCDEFGHIJKlmnop', - 'ABCDEFGHIJKLmnop', - 'ABCDEFGHIJKLMnop', - 'ABCDEFGHIJKLMnop', - 'ABCDEFGHIJKLMNop', - 'ABCDEFGHIJKLMNOp', - 'ABCDEFGHIJKLMNOP', - ]; - - // need a bunch of spare random strings per api request - spyOn(cryptoUtils, 'randomString').and.returnValues(...insensitiveCollisions); - }); - it('should not fail on case insensitive matches', async () => { - const user1 = await Parse.AnonymousUtils.logIn(); + spyOn(cryptoUtils, 'randomString').and.returnValue('abcdefghijklmnop'); + const logIn = id => Parse.User.logInWith('anonymous', { authData: { id } }); + const user1 = await logIn('test1'); const username1 = user1.get('username'); - const user2 = await Parse.AnonymousUtils.logIn(); + cryptoUtils.randomString.and.returnValue('ABCDEFGHIJKLMNOp'); + const user2 = await logIn('test2'); const username2 = user2.get('username'); expect(username1).not.toBeUndefined(); diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index 9af6d5e449..c9f9e37782 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -1,20 +1,102 @@ /*eslint no-unused-vars: "off"*/ + +/** + * @interface ParseAuthResponse + * @property {Boolean} [doNotSave] If true, parse server will do not save provided authData. + * @property {Object} [response] If set, parse server will send the provided response to the client under authDataResponse + * @property {Object} [save] If set, parse server will save the object provided into this key, instead of client provided authData + */ + +/** + * AuthPolicy + * default: can be combined with ONE additional auth provider if additional configured on user + * additional: could be only used with a default policy auth provider + * solo: Will ignore ALL additional providers if additional configured on user + * @typedef {"default" | "additional" | "solo"} AuthPolicy + */ + export class AuthAdapter { - /* - @param appIds: the specified app ids in the configuration - @param authData: the client provided authData - @param options: additional options - @returns a promise that resolves if the applicationId is valid + constructor() { + /** + * Usage policy + * @type {AuthPolicy} + */ + this.policy = 'default'; + } + /** + * @param appIds The specified app ids in the configuration + * @param {Object} authData The client provided authData + * @param {Object} options Additional options + * @param {Parse.Cloud.TriggerRequest} request + * @param {Object} config + * @returns {(Promise|void|undefined)} resolves or returns if the applicationId is valid + */ + validateAppId(appIds, authData, options, request) { + return Promise.resolve({}); + } + + /** + * Legacy usage, if provided it will be triggered when authData related to this provider is touched (signup/update/login) + * otherwise you should implement validateSetup, validateLogin and validateUpdate + * @param {Object} authData The client provided authData + * @param {Object} options Additional options + * @param {Parse.Cloud.TriggerRequest} request + * @param {Object} config + * @returns {Promise} + */ + validateAuthData(authData, options, request, config) { + return Promise.resolve({}); + } + + /** + * Triggered when user provide for the first time this auth provider + * could be a register or the user adding a new auth service + * @param {Object} authData The client provided authData + * @param {Object} options Additional options + * @param {Parse.Cloud.TriggerRequest} request + * @param {Object} config + * @returns {Promise} + */ + validateSetUp(authData, options, req, user) { + return Promise.resolve({}); + } + + /** + * Triggered when user provide authData related to this provider + * he is not logged in and has already set this provider before + * @param {Object} authData The client provided authData + * @param {Object} options Additional options + * @param {Parse.Cloud.TriggerRequest} request + * @param {Object} config + * @returns {Promise} + */ + validateLogin(authData, options, req, user) { + return Promise.resolve({}); + } + + /** + * Triggered when user provide authData related to this provider + * he is logged in and has already set this provider before + * @param {Object} authData The client provided authData + * @param {Object} options Additional options + * @param {Parse.Cloud.TriggerRequest} request + * @param {Object} config + * @returns {Promise} */ - validateAppId(appIds, authData, options) { + validateUpdate(authData, options, req, user) { return Promise.resolve({}); } - /* - @param authData: the client provided authData - @param options: additional options + /** + * Triggered in pre authentication process if needed (like webauthn, SMS OTP) + * @param {Object} challengeData Data provided by the client + * @param {(Object|undefined)} authData Auth data provided by the client, can be used for validation + * @param {Object} options Additional options + * @param {Parse.Cloud.TriggerRequest} request + * @param {Object} config + * @returns {Promise} A promise that resolves, resolved value will be added to challenge response under challenge key */ - validateAuthData(authData, options) { + challenge(challengeData, authData, options, req, user) { return Promise.resolve({}); } } diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 00637d1131..162adcc60d 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -1,4 +1,5 @@ import loadAdapter from '../AdapterLoader'; +import Parse from 'parse/node'; const apple = require('./apple'); const gcenter = require('./gcenter'); @@ -24,6 +25,7 @@ const phantauth = require('./phantauth'); const microsoft = require('./microsoft'); const keycloak = require('./keycloak'); const ldap = require('./ldap'); +const webauthn = require('./webauthn'); const anonymous = { validateAuthData: () => { @@ -59,21 +61,62 @@ const providers = { microsoft, keycloak, ldap, + webauthn, }; -function authDataValidator(adapter, appIds, options) { - return function (authData) { - return adapter.validateAuthData(authData, options).then(() => { - if (appIds) { - return adapter.validateAppId(appIds, authData, options); +function authDataValidator(provider, adapter, appIds, options) { + return async function (authData, req, user, requestObject) { + if (appIds && typeof adapter.validateAppId === 'function') { + await Promise.resolve( + adapter.validateAppId(appIds, authData, options, requestObject, req.config) + ); + } + if (typeof adapter.validateAuthData === 'function') { + return adapter.validateAuthData(authData, options, requestObject, req.config); + } else if ( + typeof adapter.validateSetUp === 'function' && + typeof adapter.validateLogin === 'function' && + typeof adapter.validateUpdate === 'function' + ) { + // When masterKey is detected, we should trigger a logged in user + const isLoggedIn = + (req.auth.user && user && req.auth.user.id === user.id) || (user && req.auth.isMaster); + let hasAuthDataConfigured = false; + + if (user && user.get('authData') && user.get('authData')[provider]) { + hasAuthDataConfigured = true; + } + + if (isLoggedIn) { + // User is updating their authData + if (hasAuthDataConfigured) { + return adapter.validateUpdate(authData, options, requestObject, req.config); + } + // Let's setup if the user does not have the provider configured + return adapter.validateSetUp(authData, options, requestObject, req.config); + } + + // Not logged in and authData is configured on the user + if (hasAuthDataConfigured) { + return adapter.validateLogin(authData, options, requestObject, req.config); } - return Promise.resolve(); - }); + + // User not logged in and the provider is not set up, for example when a new user + // signs up or an existing user uses a new auth provider + return adapter.validateSetUp(authData, options, requestObject, req.config); + } + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'Adapter not ready, need to implement validateAuthData or (validateSetUp, validateLogin, validateUpdate)' + ); }; } function loadAuthAdapter(provider, authOptions) { + // providers are auth providers implemented by default let defaultAdapter = providers[provider]; + // authOptions can contain complete custom auth adapters or + // a default auth adapter like Facebook const providerOptions = authOptions[provider]; if ( providerOptions && @@ -83,6 +126,7 @@ function loadAuthAdapter(provider, authOptions) { defaultAdapter = oauth2; } + // Default provider not found and a custom auth provider was not provided if (!defaultAdapter && !providerOptions) { return; } @@ -94,7 +138,15 @@ function loadAuthAdapter(provider, authOptions) { if (providerOptions) { const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions); if (optionalAdapter) { - ['validateAuthData', 'validateAppId'].forEach(key => { + [ + 'validateAuthData', + 'validateAppId', + 'validateSetUp', + 'validateLogin', + 'validateUpdate', + 'challenge', + 'policy', + ].forEach(key => { if (optionalAdapter[key]) { adapter[key] = optionalAdapter[key]; } @@ -102,14 +154,6 @@ function loadAuthAdapter(provider, authOptions) { } } - // TODO: create a new module from validateAdapter() in - // src/Controllers/AdaptableController.js so we can use it here for adapter - // validation based on the src/Adapters/Auth/AuthAdapter.js expected class - // signature. - if (!adapter.validateAuthData || !adapter.validateAppId) { - return; - } - return { adapter, appIds, providerOptions }; } @@ -121,12 +165,12 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) { // To handle the test cases on configuration const getValidatorForProvider = function (provider) { if (provider === 'anonymous' && !_enableAnonymousUsers) { - return; + return { validator: undefined }; } - - const { adapter, appIds, providerOptions } = loadAuthAdapter(provider, authOptions); - - return authDataValidator(adapter, appIds, providerOptions); + const authAdapter = loadAuthAdapter(provider, authOptions); + if (!authAdapter) return; + const { adapter, appIds, providerOptions } = authAdapter; + return { validator: authDataValidator(provider, adapter, appIds, providerOptions), adapter }; }; return Object.freeze({ diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 764cfe2d78..9cb2594371 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -273,7 +273,6 @@ const buildWhereClause = ({ schema, query, index, caseInsensitive }): WhereClaus continue; } } - const authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/); if (authDataMatch) { // TODO: Handle querying by _auth_data_provider, authData is stored in authData field @@ -1308,15 +1307,21 @@ export class PostgresStorageAdapter implements StorageAdapter { return; } var authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/); + const authDataAlreadyExists = !!object['authData']; if (authDataMatch) { var provider = authDataMatch[1]; object['authData'] = object['authData'] || {}; object['authData'][provider] = object[fieldName]; delete object[fieldName]; fieldName = 'authData'; + // Avoid adding authData multiple times to the query + if (authDataAlreadyExists) { + return; + } } columnsArray.push(fieldName); + if (!schema.fields[fieldName] && className === '_User') { if ( fieldName === '_email_verify_token' || @@ -1793,7 +1798,6 @@ export class PostgresStorageAdapter implements StorageAdapter { caseInsensitive, }); values.push(...where.values); - const wherePattern = where.pattern.length > 0 ? `WHERE ${where.pattern}` : ''; const limitPattern = hasLimit ? `LIMIT $${values.length + 1}` : ''; if (hasLimit) { diff --git a/src/Auth.js b/src/Auth.js index e3196105c7..75a1e2d7ce 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -1,5 +1,14 @@ -const RestQuery = require('./RestQuery'); const Parse = require('parse/node'); +import { isDeepStrictEqual } from 'util'; +import { getRequestObject } from './triggers'; + +const reducePromise = async (arr, fn, acc, index = 0) => { + if (arr[index]) { + const newAcc = await Promise.resolve(fn(acc, arr[index])); + return reducePromise(arr, fn, newAcc, index + 1); + } + return acc; +}; // An Auth object tells you who is requesting something and whether // the master key was used. @@ -83,7 +92,7 @@ const getAuthForSessionToken = async function ({ limit: 1, include: 'user', }; - + const RestQuery = require('./RestQuery'); const query = new RestQuery(config, master(config), '_Session', { sessionToken }, restOptions); results = (await query.execute()).results; } else { @@ -125,6 +134,7 @@ var getAuthForLegacySessionToken = function ({ config, sessionToken, installatio var restOptions = { limit: 1, }; + const RestQuery = require('./RestQuery'); var query = new RestQuery(config, master(config), '_User', { sessionToken }, restOptions); return query.execute().then(response => { var results = response.results; @@ -169,6 +179,7 @@ Auth.prototype.getRolesForUser = async function () { objectId: this.user.id, }, }; + const RestQuery = require('./RestQuery'); await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result => results.push(result) ); @@ -253,6 +264,7 @@ Auth.prototype.getRolesByIds = async function (ins) { }; }); const restWhere = { roles: { $in: roles } }; + const RestQuery = require('./RestQuery'); await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result => results.push(result) ); @@ -298,6 +310,149 @@ Auth.prototype._getAllRolesNamesForRoleIds = function (roleIDs, names = [], quer }); }; +const findUsersWithAuthData = (config, authData) => { + const providers = Object.keys(authData); + const query = providers + .reduce((memo, provider) => { + if (!authData[provider] || (authData && !authData[provider].id)) { + return memo; + } + const queryKey = `authData.${provider}.id`; + const query = {}; + query[queryKey] = authData[provider].id; + memo.push(query); + return memo; + }, []) + .filter(q => { + return typeof q !== 'undefined'; + }); + + return query.length > 0 + ? config.database.find('_User', { $or: query }, { limit: 2 }) + : Promise.resolve([]); +}; + +const hasMutatedAuthData = (authData, userAuthData) => { + if (!userAuthData) return { hasMutatedAuthData: true, mutatedAuthData: authData }; + const mutatedAuthData = {}; + Object.keys(authData).forEach(provider => { + // Anonymous provider is not handled this way + if (provider === 'anonymous') return; + const providerData = authData[provider]; + const userProviderAuthData = userAuthData[provider]; + if (!isDeepStrictEqual(providerData, userProviderAuthData)) { + mutatedAuthData[provider] = providerData; + } + }); + const hasMutatedAuthData = Object.keys(mutatedAuthData).length !== 0; + return { hasMutatedAuthData, mutatedAuthData }; +}; + +const checkIfUserHasProvidedConfiguredProvidersForLogin = ( + authData = {}, + userAuthData = {}, + config +) => { + const savedUserProviders = Object.keys(userAuthData).map(provider => ({ + name: provider, + adapter: config.authDataManager.getValidatorForProvider(provider).adapter, + })); + + const hasProvidedASoloProvider = savedUserProviders.some( + provider => + provider && provider.adapter && provider.adapter.policy === 'solo' && authData[provider.name] + ); + + // Solo providers can be considered as safe, so we do not have to check if the user needs + // to provide an additional provider to login. An auth adapter with "solo" (like webauthn) means + // no "additional" auth needs to be provided to login (like OTP, MFA) + if (hasProvidedASoloProvider) { + return; + } + + const additionProvidersNotFound = []; + const hasProvidedAtLeastOneAdditionalProvider = savedUserProviders.some(provider => { + if (provider && provider.adapter && provider.adapter.policy === 'additional') { + if (authData[provider.name]) { + return true; + } else { + // Push missing provider for error message + additionProvidersNotFound.push(provider.name); + } + } + }); + if (hasProvidedAtLeastOneAdditionalProvider || !additionProvidersNotFound.length) { + return; + } + + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + `Missing additional authData ${additionProvidersNotFound.join(',')}` + ); +}; + +// Validate each authData step-by-step and return the provider responses +const handleAuthDataValidation = async (authData, req, foundUser) => { + let user; + if (foundUser) { + user = Parse.User.fromJSON({ className: '_User', ...foundUser }); + // Find user by session and current objectId; only pass user if it's the current user or master key is provided + } else if ( + (req.auth && + req.auth.user && + typeof req.getUserId === 'function' && + req.getUserId() === req.auth.user.id) || + (req.auth && req.auth.isMaster && typeof req.getUserId === 'function' && req.getUserId()) + ) { + user = new Parse.User(); + user.id = req.auth.isMaster ? req.getUserId() : req.auth.user.id; + await user.fetch({ useMasterKey: true }); + } + + const { originalObject, updatedObject } = req.buildParseObjects(); + const requestObject = getRequestObject( + undefined, + req.auth, + updatedObject, + originalObject || user, + req.config + ); + // Perform validation as step-by-step pipeline for better error consistency + // and also to avoid to trigger a provider (like OTP SMS) if another one fails + return reducePromise( + // apply sort to run the pipeline each time in the same order + Object.keys(authData).sort(), + async (acc, provider) => { + if (authData[provider] === null) { + acc.authData[provider] = null; + return acc; + } + const { validator } = req.config.authDataManager.getValidatorForProvider(provider); + if (!validator) { + throw new Parse.Error( + Parse.Error.UNSUPPORTED_SERVICE, + 'This authentication method is unsupported.' + ); + } + const validationResult = await validator(authData[provider], req, user, requestObject); + if (validationResult) { + if (!Object.keys(validationResult).length) acc.authData[provider] = authData[provider]; + + if (validationResult.response) acc.authDataResponse[provider] = validationResult.response; + // Some auth providers after initialization will avoid to replace authData already stored + if (!validationResult.doNotSave) { + acc.authData[provider] = validationResult.save || authData[provider]; + } + } else { + // Support current authData behavior no result store the new AuthData + acc.authData[provider] = authData[provider]; + } + return acc; + }, + { authData: {}, authDataResponse: {} } + ); +}; + module.exports = { Auth, master, @@ -305,4 +460,9 @@ module.exports = { readOnly, getAuthForSessionToken, getAuthForLegacySessionToken, + findUsersWithAuthData, + hasMutatedAuthData, + checkIfUserHasProvidedConfiguredProvidersForLogin, + reducePromise, + handleAuthDataValidation, }; diff --git a/src/Config.js b/src/Config.js index 04834d3291..8c1beb37c7 100644 --- a/src/Config.js +++ b/src/Config.js @@ -79,6 +79,7 @@ export class Config { enforcePrivateUsers, schema, requestKeywordDenylist, + allowExpiredAuthDataToken, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -117,6 +118,7 @@ export class Config { this.validateSecurityOptions(security); this.validateSchemaOptions(schema); this.validateEnforcePrivateUsers(enforcePrivateUsers); + this.validateallowExpiredAuthDataToken(allowExpiredAuthDataToken); this.validateRequestKeywordDenylist(requestKeywordDenylist); } @@ -134,6 +136,12 @@ export class Config { } } + static validateallowExpiredAuthDataToken(allowExpiredAuthDataToken) { + if (typeof allowExpiredAuthDataToken !== 'boolean') { + throw 'Parse Server option allowExpiredAuthDataToken must be a boolean.'; + } + } + static validateSecurityOptions(security) { if (Object.prototype.toString.call(security) !== '[object Object]') { throw 'Parse Server option security must be an object.'; diff --git a/src/Deprecator/Deprecations.js b/src/Deprecator/Deprecations.js index 1c3d336be9..e8d1529433 100644 --- a/src/Deprecator/Deprecations.js +++ b/src/Deprecator/Deprecations.js @@ -24,4 +24,5 @@ module.exports = [ }, { optionKey: 'enforcePrivateUsers', changeNewDefault: 'true' }, { optionKey: 'allowClientClassCreation', changeNewDefault: 'false' }, + { optionKey: 'allowExpiredAuthDataToken', changeNewDefault: 'false' }, ]; diff --git a/src/GraphQL/loaders/parseClassTypes.js b/src/GraphQL/loaders/parseClassTypes.js index df4ed791ea..4f54fe2b2e 100644 --- a/src/GraphQL/loaders/parseClassTypes.js +++ b/src/GraphQL/loaders/parseClassTypes.js @@ -1,3 +1,4 @@ +/* eslint-disable indent */ import { GraphQLID, GraphQLObjectType, @@ -140,11 +141,7 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla ...fields, [field]: { description: `This is the object ${field}.`, - type: - (className === '_User' && (field === 'username' || field === 'password')) || - parseClass.fields[field].required - ? new GraphQLNonNull(type) - : type, + type: parseClass.fields[field].required ? new GraphQLNonNull(type) : type, }, }; } else { @@ -352,6 +349,14 @@ const load = (parseGraphQLSchema, parseClass, parseClassConfig: ?ParseGraphQLCla const parseObjectFields = { id: globalIdField(className, obj => obj.objectId), ...defaultGraphQLTypes.PARSE_OBJECT_FIELDS, + ...(className === '_User' + ? { + authDataResponse: { + description: `auth provider response when triggered on signUp/logIn.`, + type: defaultGraphQLTypes.OBJECT, + }, + } + : {}), }; const outputFields = () => { return classOutputFields.reduce((fields, field) => { diff --git a/src/GraphQL/loaders/usersMutations.js b/src/GraphQL/loaders/usersMutations.js index c38905cd90..183268a191 100644 --- a/src/GraphQL/loaders/usersMutations.js +++ b/src/GraphQL/loaders/usersMutations.js @@ -41,7 +41,7 @@ const load = parseGraphQLSchema => { req: { config, auth, info }, }); - const { sessionToken, objectId } = await objectsMutations.createObject( + const { sessionToken, objectId, authDataResponse } = await objectsMutations.createObject( '_User', parseFields, config, @@ -50,9 +50,15 @@ const load = parseGraphQLSchema => { ); context.info.sessionToken = sessionToken; - + const viewer = await getUserFromSessionToken( + context, + mutationInfo, + 'viewer.user.', + objectId + ); + if (authDataResponse && viewer.user) viewer.user.authDataResponse = authDataResponse; return { - viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId), + viewer, }; } catch (e) { parseGraphQLSchema.handleError(e); @@ -111,7 +117,7 @@ const load = parseGraphQLSchema => { req: { config, auth, info }, }); - const { sessionToken, objectId } = await objectsMutations.createObject( + const { sessionToken, objectId, authDataResponse } = await objectsMutations.createObject( '_User', { ...parseFields, authData }, config, @@ -120,9 +126,15 @@ const load = parseGraphQLSchema => { ); context.info.sessionToken = sessionToken; - + const viewer = await getUserFromSessionToken( + context, + mutationInfo, + 'viewer.user.', + objectId + ); + if (authDataResponse && viewer.user) viewer.user.authDataResponse = authDataResponse; return { - viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId), + viewer, }; } catch (e) { parseGraphQLSchema.handleError(e); @@ -146,6 +158,10 @@ const load = parseGraphQLSchema => { description: 'This is the password used to log in the user.', type: new GraphQLNonNull(GraphQLString), }, + authData: { + description: 'Auth data payload, needed if some required auth adapters are configured.', + type: OBJECT, + }, }, outputFields: { viewer: { @@ -155,14 +171,15 @@ const load = parseGraphQLSchema => { }, mutateAndGetPayload: async (args, context, mutationInfo) => { try { - const { username, password } = deepcopy(args); + const { username, password, authData } = deepcopy(args); const { config, auth, info } = context; - const { sessionToken, objectId } = ( + const { sessionToken, objectId, authDataResponse } = ( await usersRouter.handleLogIn({ body: { username, password, + authData, }, query: {}, config, @@ -173,8 +190,15 @@ const load = parseGraphQLSchema => { context.info.sessionToken = sessionToken; + const viewer = await getUserFromSessionToken( + context, + mutationInfo, + 'viewer.user.', + objectId + ); + if (authDataResponse && viewer.user) viewer.user.authDataResponse = authDataResponse; return { - viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId), + viewer, }; } catch (e) { parseGraphQLSchema.handleError(e); @@ -355,6 +379,57 @@ const load = parseGraphQLSchema => { true, true ); + + const challengeMutation = mutationWithClientMutationId({ + name: 'Challenge', + description: + 'The challenge mutation can be used to initiate an authentication challenge when an auth adapter needs it.', + inputFields: { + username: { + description: 'This is the username used to log in the user.', + type: GraphQLString, + }, + password: { + description: 'This is the password used to log in the user.', + type: GraphQLString, + }, + authData: { + description: + 'Auth data allow to preidentify the user if the auth adapter needs preidentification.', + type: OBJECT, + }, + challengeData: { + description: + 'Challenge data payload, can be used to post data to auth providers to auth providers if they need data for the response.', + type: OBJECT, + }, + }, + outputFields: { + challengeData: { + description: 'Challenge response from configured auth adapters.', + type: OBJECT, + }, + }, + mutateAndGetPayload: async (input, context) => { + try { + const { config, auth, info } = context; + + const { response } = await usersRouter.handleChallenge({ + body: input, + config, + auth, + info, + }); + return response; + } catch (e) { + parseGraphQLSchema.handleError(e); + } + }, + }); + + parseGraphQLSchema.addGraphQLType(challengeMutation.args.input.type.ofType, true, true); + parseGraphQLSchema.addGraphQLType(challengeMutation.type, true, true); + parseGraphQLSchema.addGraphQLMutation('challenge', challengeMutation, true, true); }; export { load }; diff --git a/src/LiveQuery/ParseLiveQueryServer.js b/src/LiveQuery/ParseLiveQueryServer.js index fa05f23711..c126421a0a 100644 --- a/src/LiveQuery/ParseLiveQueryServer.js +++ b/src/LiveQuery/ParseLiveQueryServer.js @@ -10,7 +10,13 @@ import { ParsePubSub } from './ParsePubSub'; import SchemaController from '../Controllers/SchemaController'; import _ from 'lodash'; import { v4 as uuidv4 } from 'uuid'; -import { runLiveQueryEventHandlers, getTrigger, runTrigger, resolveError, toJSONwithObjects } from '../triggers'; +import { + runLiveQueryEventHandlers, + getTrigger, + runTrigger, + resolveError, + toJSONwithObjects, +} from '../triggers'; import { getAuthForSessionToken, Auth } from '../Auth'; import { getCacheController } from '../Controllers'; import LRU from 'lru-cache'; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index f8b8eab633..efa8912942 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -68,6 +68,12 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: false, }, + allowExpiredAuthDataToken: { + env: 'PARSE_SERVER_ALLOW_EXPIRED_AUTH_DATA_TOKEN', + help: 'Allow login with expired authData token.', + action: parsers.booleanParser, + default: true, + }, allowHeaders: { env: 'PARSE_SERVER_ALLOW_HEADERS', help: 'Add headers to Access-Control-Allow-Headers', diff --git a/src/Options/docs.js b/src/Options/docs.js index 24b60c46a9..179b58d9f4 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -14,6 +14,7 @@ * @property {AccountLockoutOptions} accountLockout The account lockout policy for failed login attempts. * @property {Boolean} allowClientClassCreation Enable (or disable) client class creation, defaults to true * @property {Boolean} allowCustomObjectId Enable (or disable) custom objectId + * @property {Boolean} allowExpiredAuthDataToken Allow login with expired authData token. * @property {String[]} allowHeaders Add headers to Access-Control-Allow-Headers * @property {String} allowOrigin Sets the origin to Access-Control-Allow-Origin * @property {Adapter} analyticsAdapter Adapter module for the analytics diff --git a/src/Options/index.js b/src/Options/index.js index 8124446f99..bb39b96130 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -279,6 +279,9 @@ export interface ParseServerOptions { /* Set to true if new users should be created without public read and write access. :DEFAULT: false */ enforcePrivateUsers: ?boolean; + /* Allow login with expired authData token. + :DEFAULT: true */ + allowExpiredAuthDataToken: ?boolean; /* An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. :DEFAULT: [{"key":"_bsontype","value":"Code"},{"key":"constructor"},{"key":"__proto__"}] */ requestKeywordDenylist: ?(RequestKeywordDenylist[]); diff --git a/src/RestQuery.js b/src/RestQuery.js index be96683451..1ea92e9fa7 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -590,7 +590,7 @@ RestQuery.prototype.replaceDontSelect = function () { }); }; -const cleanResultAuthData = function (result) { +RestQuery.prototype.cleanResultAuthData = function (result) { delete result.password; if (result.authData) { Object.keys(result.authData).forEach(provider => { @@ -659,7 +659,7 @@ RestQuery.prototype.runFind = function (options = {}) { .then(results => { if (this.className === '_User' && !findOptions.explain) { for (var result of results) { - cleanResultAuthData(result); + this.cleanResultAuthData(result, this.auth, this.config); } } diff --git a/src/RestWrite.js b/src/RestWrite.js index 3e20328a9a..6347a06ea4 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -122,6 +122,9 @@ RestWrite.prototype.execute = function () { .then(() => { return this.runBeforeSaveTrigger(); }) + .then(() => { + return this.ensureUniqueAuthDataId(); + }) .then(() => { return this.deleteEmailResetTokenIfNeeded(); }) @@ -157,6 +160,12 @@ RestWrite.prototype.execute = function () { return this.cleanUserAuthData(); }) .then(() => { + // Append the authDataResponse if exists + if (this.authDataResponse) { + if (this.response && this.response.response) { + this.response.response.authDataResponse = this.authDataResponse; + } + } return this.response; }); }; @@ -382,7 +391,11 @@ RestWrite.prototype.validateAuthData = function () { return; } - if (!this.query && !this.data.authData) { + const authData = this.data.authData; + const hasUsernameAndPassword = + typeof this.data.username === 'string' && typeof this.data.password === 'string'; + + if (!this.query && !authData) { if (typeof this.data.username !== 'string' || _.isEmpty(this.data.username)) { throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'bad or missing username'); } @@ -392,10 +405,10 @@ RestWrite.prototype.validateAuthData = function () { } if ( - (this.data.authData && !Object.keys(this.data.authData).length) || + (authData && !Object.keys(authData).length) || !Object.prototype.hasOwnProperty.call(this.data, 'authData') ) { - // Handle saving authData to {} or if authData doesn't exist + // Nothing to validate here return; } else if (Object.prototype.hasOwnProperty.call(this.data, 'authData') && !this.data.authData) { // Handle saving authData to null @@ -405,15 +418,14 @@ RestWrite.prototype.validateAuthData = function () { ); } - var authData = this.data.authData; var providers = Object.keys(authData); if (providers.length > 0) { - const canHandleAuthData = providers.reduce((canHandle, provider) => { + const canHandleAuthData = providers.some(provider => { var providerAuthData = authData[provider]; var hasToken = providerAuthData && providerAuthData.id; - return canHandle && (hasToken || providerAuthData == null); - }, true); - if (canHandleAuthData) { + return hasToken || providerAuthData === null; + }); + if (canHandleAuthData || hasUsernameAndPassword || this.auth.isMaster || this.getUserId()) { return this.handleAuthData(authData); } } @@ -423,48 +435,6 @@ RestWrite.prototype.validateAuthData = function () { ); }; -RestWrite.prototype.handleAuthDataValidation = function (authData) { - const validations = Object.keys(authData).map(provider => { - if (authData[provider] === null) { - return Promise.resolve(); - } - const validateAuthData = this.config.authDataManager.getValidatorForProvider(provider); - if (!validateAuthData) { - throw new Parse.Error( - Parse.Error.UNSUPPORTED_SERVICE, - 'This authentication method is unsupported.' - ); - } - return validateAuthData(authData[provider]); - }); - return Promise.all(validations); -}; - -RestWrite.prototype.findUsersWithAuthData = function (authData) { - const providers = Object.keys(authData); - const query = providers - .reduce((memo, provider) => { - if (!authData[provider]) { - return memo; - } - const queryKey = `authData.${provider}.id`; - const query = {}; - query[queryKey] = authData[provider].id; - memo.push(query); - return memo; - }, []) - .filter(q => { - return typeof q !== 'undefined'; - }); - - let findPromise = Promise.resolve([]); - if (query.length > 0) { - findPromise = this.config.database.find(this.className, { $or: query }, {}); - } - - return findPromise; -}; - RestWrite.prototype.filteredObjectsByACL = function (objects) { if (this.auth.isMaster) { return objects; @@ -478,106 +448,158 @@ RestWrite.prototype.filteredObjectsByACL = function (objects) { }); }; -RestWrite.prototype.handleAuthData = function (authData) { - let results; - return this.findUsersWithAuthData(authData).then(async r => { - results = this.filteredObjectsByACL(r); - - if (results.length == 1) { - this.storage['authProvider'] = Object.keys(authData).join(','); - - const userResult = results[0]; - const mutatedAuthData = {}; - Object.keys(authData).forEach(provider => { - const providerData = authData[provider]; - const userAuthData = userResult.authData[provider]; - if (!_.isEqual(providerData, userAuthData)) { - mutatedAuthData[provider] = providerData; - } - }); - const hasMutatedAuthData = Object.keys(mutatedAuthData).length !== 0; - let userId; - if (this.query && this.query.objectId) { - userId = this.query.objectId; - } else if (this.auth && this.auth.user && this.auth.user.id) { - userId = this.auth.user.id; +RestWrite.prototype.getUserId = function () { + if (this.query && this.query.objectId && this.className === '_User') { + return this.query.objectId; + } else if (this.auth && this.auth.user && this.auth.user.id) { + return this.auth.user.id; + } +}; + +// 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 +RestWrite.prototype.ensureUniqueAuthDataId = async function () { + if (this.className !== '_User' || !this.data.authData) { + return; + } + + const hasAuthDataId = Object.keys(this.data.authData).some( + key => this.data.authData[key] && this.data.authData[key].id + ); + + if (!hasAuthDataId) return; + + const r = await Auth.findUsersWithAuthData(this.config, this.data.authData); + const results = this.filteredObjectsByACL(r); + if (results.length > 1) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } + // use data.objectId in case of login time and found user during handle validateAuthData + const userId = this.getUserId() || this.data.objectId; + if (results.length === 1 && userId !== results[0].objectId) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } +}; + +RestWrite.prototype.handleAuthData = async function (authData) { + const r = await Auth.findUsersWithAuthData(this.config, authData); + const results = this.filteredObjectsByACL(r); + + if (results.length > 1) { + // To avoid https://github.com/parse-community/parse-server/security/advisories/GHSA-8w3j-g983-8jh5 + // Let's run some validation before throwing + await Auth.handleAuthDataValidation(authData, this, results[0]); + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } + + // No user found with provided authData we need to validate + if (!results.length) { + const { authData: validatedAuthData, authDataResponse } = await Auth.handleAuthDataValidation( + authData, + this + ); + this.authDataResponse = authDataResponse; + // Replace current authData by the new validated one + this.data.authData = validatedAuthData; + return; + } + + // User found with provided authData + if (results.length === 1) { + const userId = this.getUserId(); + const userResult = results[0]; + // Prevent duplicate authData id + if (userId && userId !== userResult.objectId) { + throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); + } + + this.storage.authProvider = Object.keys(authData).join(','); + + const { hasMutatedAuthData, mutatedAuthData } = Auth.hasMutatedAuthData( + authData, + userResult.authData + ); + + const isCurrentUserLoggedOrMaster = + (this.auth && this.auth.user && this.auth.user.id === userResult.objectId) || + this.auth.isMaster; + + const isLogin = !userId; + + if (isLogin || isCurrentUserLoggedOrMaster) { + // no user making the call + // OR the user making the call is the right one + // Login with auth data + delete results[0].password; + + // need to set the objectId first otherwise location has trailing undefined + this.data.objectId = userResult.objectId; + + if (!this.query || !this.query.objectId) { + this.response = { + response: userResult, + location: this.location(), + }; + // Run beforeLogin hook before storing any updates + // to authData on the db; changes to userResult + // will be ignored. + await this.runBeforeLoginTrigger(deepcopy(userResult)); + + // If we are in login operation via authData + // we need to be sure that the user has provided + // required authData + Auth.checkIfUserHasProvidedConfiguredProvidersForLogin( + authData, + userResult.authData, + this.config + ); } - if (!userId || userId === userResult.objectId) { - // no user making the call - // OR the user making the call is the right one - // Login with auth data - delete results[0].password; - - // need to set the objectId first otherwise location has trailing undefined - this.data.objectId = userResult.objectId; - - if (!this.query || !this.query.objectId) { - // this a login call, no userId passed - this.response = { - response: userResult, - location: this.location(), - }; - // Run beforeLogin hook before storing any updates - // to authData on the db; changes to userResult - // will be ignored. - await this.runBeforeLoginTrigger(deepcopy(userResult)); - } - // If we didn't change the auth data, just keep going - if (!hasMutatedAuthData) { - return; - } - // We have authData that is updated on login - // that can happen when token are refreshed, - // We should update the token and let the user in - // We should only check the mutated keys - return this.handleAuthDataValidation(mutatedAuthData).then(async () => { - // IF we have a response, we'll skip the database operation / beforeSave / afterSave etc... - // we need to set it up there. - // We are supposed to have a response only on LOGIN with authData, so we skip those - // If we're not logging in, but just updating the current user, we can safely skip that part - if (this.response) { - // Assign the new authData in the response - Object.keys(mutatedAuthData).forEach(provider => { - this.response.response.authData[provider] = mutatedAuthData[provider]; - }); + // Prevent validating if no mutated data detected on update + if (!hasMutatedAuthData && isCurrentUserLoggedOrMaster) { + return; + } - // Run the DB update directly, as 'master' - // Just update the authData part - // Then we're good for the user, early exit of sorts - return this.config.database.update( - this.className, - { objectId: this.data.objectId }, - { authData: mutatedAuthData }, - {} - ); - } + // Force to validate all provided authData on login + // on update only validate mutated ones + if (hasMutatedAuthData || !this.config.allowExpiredAuthDataToken) { + const res = await Auth.handleAuthDataValidation( + isLogin ? authData : mutatedAuthData, + this, + userResult + ); + this.data.authData = res.authData; + this.authDataResponse = res.authDataResponse; + } + + // IF we are in login we'll skip the database operation / beforeSave / afterSave etc... + // we need to set it up there. + // We are supposed to have a response only on LOGIN with authData, so we skip those + // If we're not logging in, but just updating the current user, we can safely skip that part + if (this.response) { + // Assign the new authData in the response + Object.keys(mutatedAuthData).forEach(provider => { + this.response.response.authData[provider] = mutatedAuthData[provider]; }); - } else if (userId) { - // Trying to update auth data but users - // are different - if (userResult.objectId !== userId) { - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); - } - // No auth data was mutated, just keep going - if (!hasMutatedAuthData) { - return; - } + + // Run the DB update directly, as 'master' + // Just update the authData part + // Then we're good for the user, early exit of sorts + await this.config.database.update( + this.className, + { objectId: this.data.objectId }, + { authData: this.data.authData }, + {} + ); } } - return this.handleAuthDataValidation(authData).then(() => { - if (results.length > 1) { - // More than 1 user with the passed id's - throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); - } - }); - }); + } }; // The non-third-party parts of User transformation RestWrite.prototype.transformUser = function () { var promise = Promise.resolve(); - if (this.className !== '_User') { return promise; } @@ -848,7 +870,7 @@ RestWrite.prototype.createSessionTokenIfNeeded = function () { return; } if ( - !this.storage['authProvider'] && // signup call, with + !this.storage.authProvider && // signup call, with this.config.preventLoginWithUnverifiedEmail && // no login without verification this.config.verifyUserEmails ) { @@ -865,15 +887,15 @@ RestWrite.prototype.createSessionToken = async function () { return; } - if (this.storage['authProvider'] == null && this.data.authData) { - this.storage['authProvider'] = Object.keys(this.data.authData).join(','); + if (this.storage.authProvider == null && this.data.authData) { + this.storage.authProvider = Object.keys(this.data.authData).join(','); } const { sessionData, createSession } = RestWrite.createSession(this.config, { userId: this.objectId(), createdWith: { - action: this.storage['authProvider'] ? 'login' : 'signup', - authProvider: this.storage['authProvider'] || 'password', + action: this.storage.authProvider ? 'login' : 'signup', + authProvider: this.storage.authProvider || 'password', }, installationId: this.auth.installationId, }); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index cdce6a1348..05822bf59c 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -7,9 +7,10 @@ import ClassesRouter from './ClassesRouter'; import rest from '../rest'; import Auth from '../Auth'; import passwordCrypto from '../password'; -import { maybeRunTrigger, Types as TriggerTypes } from '../triggers'; +import { maybeRunTrigger, Types as TriggerTypes, getRequestObject } from '../triggers'; import { promiseEnsureIdempotency } from '../middlewares'; import RestWrite from '../RestWrite'; +import { logger } from '../../lib/Adapters/Logger/WinstonLogger'; export class UsersRouter extends ClassesRouter { className() { @@ -174,7 +175,6 @@ export class UsersRouter extends ClassesRouter { // Remove hidden properties. UsersRouter.removeHiddenProperties(user); - return { response: user }; } }); @@ -182,6 +182,30 @@ export class UsersRouter extends ClassesRouter { async handleLogIn(req) { const user = await this._authenticateUserFromRequest(req); + const authData = req.body && req.body.authData; + // Check if user has provided their required auth providers + Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(authData, user.authData, req.config); + + let authDataResponse; + let validatedAuthData; + if (authData) { + const res = await Auth.handleAuthDataValidation( + authData, + new RestWrite( + req.config, + req.auth, + '_User', + { objectId: user.objectId }, + req.body, + user, + req.info.clientSDK, + req.info.context + ), + user + ); + authDataResponse = res.authDataResponse; + validatedAuthData = res.authData; + } // handle password expiry policy if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) { @@ -228,6 +252,16 @@ export class UsersRouter extends ClassesRouter { req.config ); + // If we have some new validated authData update directly + if (validatedAuthData && Object.keys(validatedAuthData).length) { + await req.config.database.update( + '_User', + { objectId: user.objectId }, + { authData: validatedAuthData }, + {} + ); + } + const { sessionData, createSession } = RestWrite.createSession(req.config, { userId: user.objectId, createdWith: { @@ -250,6 +284,10 @@ export class UsersRouter extends ClassesRouter { req.config ); + if (authDataResponse) { + user.authDataResponse = authDataResponse; + } + return { response: user }; } @@ -453,6 +491,106 @@ export class UsersRouter extends ClassesRouter { }); } + async handleChallenge(req) { + const { username, email, password, authData, challengeData } = req.body; + + // if username or email provided with password try to authenticate the user by username + let user; + if (username || email) { + if (!password) + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'You provided username or email, you need to also provide password.' + ); + user = await this._authenticateUserFromRequest(req); + } + + if (!challengeData) throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'Nothing to challenge.'); + + if (typeof challengeData !== 'object') + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'challengeData should be an object.'); + + let request; + let parseUser; + + // Try to find user by authData + if (authData) { + if (typeof authData !== 'object') { + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'authData should be an object.'); + } + if (user) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'You cant provide username/email and authData, only use one identification method.' + ); + } + + if (Object.keys(authData).filter(key => authData[key].id).length > 1) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'You cant provide more than one authData provider with an id.' + ); + } + + const results = await Auth.findUsersWithAuthData(req.config, authData); + + try { + if (!results[0] || results.length > 1) + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'User not found.'); + + // Find the provider used to find the user + const provider = Object.keys(authData).find(key => authData[key].id); + + parseUser = Parse.User.fromJSON({ className: '_User', ...results[0] }); + request = getRequestObject(undefined, req.auth, parseUser, parseUser, req.config); + request.isChallenge = true; + // Validate authData used to identify the user to avoid brute-force attack on `id` + const { validator } = req.config.authDataManager.getValidatorForProvider(provider); + await validator(authData[provider], req, parseUser, request); + } catch (e) { + // Rewrite the error to avoid guess id attack + logger.error(e); + throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'User not found.'); + } + } + + if (!parseUser) { + parseUser = user ? Parse.User.fromJSON({ className: '_User', ...user }) : undefined; + } + + if (!request) { + request = getRequestObject(undefined, req.auth, parseUser, parseUser, req.config); + request.isChallenge = true; + } + + // Execute challenge step-by-step with consistent order for better error feedback + // and to avoid to trigger others challenges if one of them fails + const challenge = await Auth.reducePromise( + Object.keys(challengeData).sort(), + async (acc, provider) => { + const authAdapter = req.config.authDataManager.getValidatorForProvider(provider); + if (!authAdapter) return acc; + const { + adapter: { challenge }, + } = authAdapter; + if (typeof challenge === 'function') { + const providerChallengeResponse = await challenge( + challengeData[provider], + authData && authData[provider], + req.config.auth[provider], + request, + req.config + ); + acc[provider] = providerChallengeResponse || true; + return acc; + } + }, + {} + ); + + return { response: { challengeData: challenge } }; + } + mountRoutes() { this.route('GET', '/users', req => { return this.handleFind(req); @@ -493,6 +631,9 @@ export class UsersRouter extends ClassesRouter { this.route('GET', '/verifyPassword', req => { return this.handleVerifyPassword(req); }); + this.route('POST', '/challenge', req => { + return this.handleChallenge(req); + }); } } From cf4a52747d2de65e260b428e4333fd85a0296565 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Mon, 6 Jun 2022 14:38:23 +0200 Subject: [PATCH 02/18] fix: feedbacks --- spec/AuthenticationAdapters.spec.js | 4 ++-- src/Adapters/Auth/index.js | 2 +- src/Config.js | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 8e4a475d95..a89085cac0 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -2567,7 +2567,7 @@ describe('Auth Adapter features', () => { await expectAsync( user.save({ authData: { wrongAdapter: { id: 'wrongAdapter' } } }) ).toBeRejectedWithError( - 'Adapter not ready, need to implement validateAuthData or (validateSetUp, validateLogin, validateUpdate)' + 'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate' ); }); @@ -2577,7 +2577,7 @@ describe('Auth Adapter features', () => { await expectAsync( user.save({ authData: { wrongAdapter: { id: 'wrongAdapter' } } }) ).toBeRejectedWithError( - 'Adapter not ready, need to implement validateAuthData or (validateSetUp, validateLogin, validateUpdate)' + 'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate' ); }); diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 31819c2357..4e2ba68642 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -105,7 +105,7 @@ function authDataValidator(provider, adapter, appIds, options) { } throw new Parse.Error( Parse.Error.OTHER_CAUSE, - 'Adapter not ready, need to implement validateAuthData or (validateSetUp, validateLogin, validateUpdate)' + 'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate' ); }; } diff --git a/src/Config.js b/src/Config.js index 8c1beb37c7..da1a744c58 100644 --- a/src/Config.js +++ b/src/Config.js @@ -118,7 +118,7 @@ export class Config { this.validateSecurityOptions(security); this.validateSchemaOptions(schema); this.validateEnforcePrivateUsers(enforcePrivateUsers); - this.validateallowExpiredAuthDataToken(allowExpiredAuthDataToken); + this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken); this.validateRequestKeywordDenylist(requestKeywordDenylist); } @@ -136,7 +136,7 @@ export class Config { } } - static validateallowExpiredAuthDataToken(allowExpiredAuthDataToken) { + static validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken) { if (typeof allowExpiredAuthDataToken !== 'boolean') { throw 'Parse Server option allowExpiredAuthDataToken must be a boolean.'; } From 5dbee87b12b8b0b724c84c1bf09adf125579e1e9 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Mon, 6 Jun 2022 14:44:22 +0200 Subject: [PATCH 03/18] fix: restore lock --- package-lock.json | 262 +++++++++++++++++++++++----------------------- 1 file changed, 133 insertions(+), 129 deletions(-) diff --git a/package-lock.json b/package-lock.json index 30cc9829b0..b0280691ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,9 +41,9 @@ } }, "ts-invariant": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.3.tgz", - "integrity": "sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.1.tgz", + "integrity": "sha512-dOmY3naALBtNyK+nrVGzD8DVxSJ9OIHragItZ3XvxGORNAZL6uszgQYaD3PW+TPh2NWNsOpuQUxznuvTkmcdqw==", "dev": true, "requires": { "tslib": "^2.1.0" @@ -54,15 +54,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", "dev": true - }, - "zen-observable-ts": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz", - "integrity": "sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==", - "dev": true, - "requires": { - "zen-observable": "0.8.15" - } } } }, @@ -1140,30 +1131,30 @@ } }, "@envelop/core": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/@envelop/core/-/core-2.3.3.tgz", - "integrity": "sha512-ZWlBXTd35Uwp4cKRsU36NqgpSwXAIe34tXHWUjI7n/TDgOZ0hcgvhQ+nF1dGLsJted0gqnfVX8ZceX30lTGgDg==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@envelop/core/-/core-2.3.2.tgz", + "integrity": "sha512-NTVY7bajznZyE0S/yzwIu5aimcM8JLqdeWkWUmwmrAHZdE6zjrvOuVCwq1kMIHzgV3bmLdPg2F7534Nxad/HLg==", "requires": { - "@envelop/types": "2.2.1" + "@envelop/types": "2.2.0" } }, "@envelop/parser-cache": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@envelop/parser-cache/-/parser-cache-4.3.3.tgz", - "integrity": "sha512-JfgKynw/OtY+xxzGvQuaiojPYqpxmS6fh4CWc0G/SBjb4keeZ/9yCUANRMT7xN2R9lb2dKwkKS+VGd8Zp7tMWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@envelop/parser-cache/-/parser-cache-4.3.2.tgz", + "integrity": "sha512-gdi814GdJ8PazYBGtWcpKdqtbBbxhbdXBKhSpTjt1kNmgzBsKV1VV2J674/9UqwFjhpuk2POXouIwF0t3z6mXg==", "requires": { "tiny-lru": "7.0.6" } }, "@envelop/types": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@envelop/types/-/types-2.2.1.tgz", - "integrity": "sha512-TONrQ8a2/y0emVhdIRhAJzqCXWtaTBLv+JnYVmihR9Iw8ETyVbZOuReB9EuSGZHciKtpkQXTjD/gq5wVMIa47g==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@envelop/types/-/types-2.2.0.tgz", + "integrity": "sha512-Lghvfs0kh53G5mUKpCMlB/FhHh3O8SSR4hewB7JyE9hOEu/9h/6u+GHH/OEgdaRHky1Sae5Jf4grO+h21ka4ig==" }, "@envelop/validation-cache": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@envelop/validation-cache/-/validation-cache-4.3.3.tgz", - "integrity": "sha512-NchRKNGA1gegzFoxi3/PLe3qQThq5DezP+RKalCiTuFwEd89TQo3rugHJXZX6tZNmw6jJ3eTnE9ouOMKvwVo+Q==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@envelop/validation-cache/-/validation-cache-4.3.2.tgz", + "integrity": "sha512-dihBgqbwm52u69QQeyOZQyDeIz/GIlLuSmgVJcsrKbbevX6pqiuZAs7Vbaj8EraIkuhAqPInzKrtWqYRgePIOA==", "requires": { "tiny-lru": "7.0.6" } @@ -1201,6 +1192,25 @@ "tslib": "~2.3.0" }, "dependencies": { + "@graphql-tools/merge": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.0.1.tgz", + "integrity": "sha512-YAozogbjC2Oun+UcwG0LZFumhlCiHBmqe68OIf7bqtBdp4pbPAiVuK/J9oJqRVJmzvUqugo6RD9zz1qDTKZaiQ==", + "requires": { + "@graphql-tools/utils": "8.1.1", + "tslib": "~2.3.0" + }, + "dependencies": { + "@graphql-tools/utils": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-8.1.1.tgz", + "integrity": "sha512-QbFNoBmBiZ+ej4y6mOv8Ba4lNhcrTEKXAhZ0f74AhdEXi7b9xbGUH/slO5JaSyp85sGQYIPmxjRPpXBjLklbmw==", + "requires": { + "tslib": "~2.3.0" + } + } + } + }, "@graphql-tools/schema": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-8.1.1.tgz", @@ -1333,29 +1343,29 @@ }, "dependencies": { "@graphql-tools/merge": { - "version": "8.2.13", - "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.2.13.tgz", - "integrity": "sha512-lhzjCa6wCthOYl7B6UzER3SGjU2WjSGnW0WGr8giMYsrtf6G3vIRotMcSVMlhDzyyMIOn7uPULOUt3/kaJ/rIA==", + "version": "8.2.11", + "resolved": "https://registry.npmjs.org/@graphql-tools/merge/-/merge-8.2.11.tgz", + "integrity": "sha512-fsjJVdsk9GV1jj1Ed2AKLlHYlsf0ZadTK8X5KxFRE1ZSnKqh56BLVX93JrtOIAnsiHkwOK2TC43HGhApF1swpQ==", "requires": { - "@graphql-tools/utils": "8.6.12", + "@graphql-tools/utils": "8.6.10", "tslib": "~2.4.0" } }, "@graphql-tools/schema": { - "version": "8.3.13", - "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-8.3.13.tgz", - "integrity": "sha512-e+bx1VHj1i5v4HmhCYCar0lqdoLmkRi/CfV07rTqHR6CRDbIb/S/qDCajHLt7FCovQ5ozlI5sRVbBhzfq5H0PQ==", + "version": "8.3.11", + "resolved": "https://registry.npmjs.org/@graphql-tools/schema/-/schema-8.3.11.tgz", + "integrity": "sha512-esMEnbyXbp8B5VEI4o395+x0G7Qmz3JSX5onFBF8HeLYcqWJasY5vBuWkO18VxrZpEnvnryodP6Y00bVag9O3Q==", "requires": { - "@graphql-tools/merge": "8.2.13", - "@graphql-tools/utils": "8.6.12", + "@graphql-tools/merge": "8.2.11", + "@graphql-tools/utils": "8.6.10", "tslib": "~2.4.0", "value-or-promise": "1.0.11" } }, "@graphql-tools/utils": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-8.6.12.tgz", - "integrity": "sha512-WQ91O40RC+UJgZ9K+IzevSf8oolR1QE+WQ21Oyc2fgDYYiqT0eSf+HVyhZr/8x9rVjn3N9HeqCsywbdmbljg0w==", + "version": "8.6.10", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-8.6.10.tgz", + "integrity": "sha512-bJH9qwuyM3BP0PTU6/lvBDkk6jdEIOn+dbyk4pHMVNnvbJ1gZQwo62To8SHxxaUTus8OMhhVPSh9ApWXREURcg==", "requires": { "tslib": "~2.4.0" } @@ -1386,9 +1396,9 @@ }, "dependencies": { "@graphql-tools/utils": { - "version": "8.6.12", - "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-8.6.12.tgz", - "integrity": "sha512-WQ91O40RC+UJgZ9K+IzevSf8oolR1QE+WQ21Oyc2fgDYYiqT0eSf+HVyhZr/8x9rVjn3N9HeqCsywbdmbljg0w==", + "version": "8.6.10", + "resolved": "https://registry.npmjs.org/@graphql-tools/utils/-/utils-8.6.10.tgz", + "integrity": "sha512-bJH9qwuyM3BP0PTU6/lvBDkk6jdEIOn+dbyk4pHMVNnvbJ1gZQwo62To8SHxxaUTus8OMhhVPSh9ApWXREURcg==", "requires": { "tslib": "~2.4.0" } @@ -2692,6 +2702,30 @@ "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==" }, + "accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "requires": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "dependencies": { + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + } + } + }, "acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", @@ -3467,6 +3501,14 @@ "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==" }, + "busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "requires": { + "streamsearch": "^1.1.0" + } + }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -4467,9 +4509,9 @@ } }, "cross-undici-fetch": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/cross-undici-fetch/-/cross-undici-fetch-0.4.5.tgz", - "integrity": "sha512-3u5LFSPiD5frvhBmU2bH7kv7pa8/WSh3gfwyLsx84oP5mSGttd8eNXU7UofketwKCnCb2gjhCGnVpoUCb1RxDQ==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/cross-undici-fetch/-/cross-undici-fetch-0.4.3.tgz", + "integrity": "sha512-mv1jusEQsFnBHEBkpFaYROKAzAWyuW8ZyN48NcyqkjLGRrscMKuFRmUigUrkE/pdprQZjNTQQ/aWJKe6F4tzTA==", "requires": { "abort-controller": "^3.0.0", "busboy": "^1.6.0", @@ -4480,14 +4522,6 @@ "web-streams-polyfill": "^3.2.0" }, "dependencies": { - "busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "requires": { - "streamsearch": "^1.1.0" - } - }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -4495,11 +4529,6 @@ "requires": { "whatwg-url": "^5.0.0" } - }, - "streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" } } }, @@ -6031,15 +6060,6 @@ "vary": "~1.1.2" }, "dependencies": { - "accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "requires": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - } - }, "content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -6056,36 +6076,6 @@ "ms": "2.0.0" } }, - "http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "requires": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - } - }, - "mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" - }, - "mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "requires": { - "mime-db": "1.52.0" - } - }, - "negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" - }, "qs": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", @@ -6093,11 +6083,6 @@ "requires": { "side-channel": "^1.0.4" } - }, - "toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" } } }, @@ -7358,6 +7343,18 @@ "integrity": "sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==", "dev": true }, + "http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "requires": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + } + }, "http-proxy-agent": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", @@ -10027,7 +10024,7 @@ "mkdirp": "^0.5.1", "mongodb": "^3.4.0", "mongodb-dbpath": "^0.0.1", - "mongodb-tools": "mongodb-tools@github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", + "mongodb-tools": "github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", "mongodb-version-manager": "^1.4.3", "untildify": "^4.0.0", "which": "^2.0.1" @@ -10065,8 +10062,8 @@ } }, "mongodb-tools": { - "version": "git+ssh://git@github.com/mongodb-js/mongodb-tools.git#0d1a90f49796c41f6d47c7c7999fe384014a16a0", - "from": "mongodb-tools@github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", + "version": "github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", + "from": "github:mongodb-js/mongodb-tools#0d1a90f49796c41f6d47c7c7999fe384014a16a0", "dev": true, "requires": { "debug": "^2.2.0", @@ -10255,6 +10252,11 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==" + }, "neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -12640,9 +12642,9 @@ "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==" }, "object-inspect": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.11.0.tgz", - "integrity": "sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==" + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", + "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==" }, "object-keys": { "version": "1.1.1", @@ -13059,9 +13061,9 @@ } }, "core-js-pure": { - "version": "3.22.8", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.8.tgz", - "integrity": "sha512-bOxbZIy9S5n4OVH63XaLVXZ49QKicjowDx/UELyJ68vxfCRpYsbyh/WNZNfEfAk+ekA8vSjt+gCDpvh672bc3w==" + "version": "3.22.7", + "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.7.tgz", + "integrity": "sha512-wTriFxiZI+C8msGeh7fJcbC/a0V8fdInN1oS2eK79DMBGs8iIJiXhtFJCiT3rBa8w6zroHWW3p8ArlujZ/Mz+w==" }, "idb-keyval": { "version": "6.0.3", @@ -14633,18 +14635,6 @@ } } }, - "http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "requires": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - } - }, "mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -14654,11 +14644,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" } } }, @@ -15188,6 +15173,11 @@ "readable-stream": "^2.0.2" } }, + "streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==" + }, "string-argv": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.1.tgz", @@ -15690,6 +15680,11 @@ "repeat-string": "^1.6.1" } }, + "toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" + }, "tough-cookie": { "version": "2.4.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", @@ -15873,9 +15868,9 @@ "dev": true }, "undici": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.4.0.tgz", - "integrity": "sha512-A1SRXysDg7J+mVP46jF+9cKANw0kptqSFZ8tGyL+HBiv0K1spjxPX8Z4EGu+Eu6pjClJUBdnUPlxrOafR668/g==" + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.2.0.tgz", + "integrity": "sha512-XY6+NS3WH9b3TKOHeNz2CjR+qrVz/k4fO9g3etPpLozRvULoQmZ1+dk9JbIz40ehn27xzFk4jYVU2MU3Nle62A==" }, "unicode-canonical-property-names-ecmascript": { "version": "1.0.4", @@ -16481,6 +16476,15 @@ "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==", "dev": true + }, + "zen-observable-ts": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.2.3.tgz", + "integrity": "sha512-hc/TGiPkAWpByykMwDcem3SdUgA4We+0Qb36bItSuJC9xD0XVBZoFHYoadAomDSNf64CG8Ydj0Qb8Od8BUWz5g==", + "dev": true, + "requires": { + "zen-observable": "0.8.15" + } } } } From 28636ce2de7448a4ef278d673341cf0c95a3f72c Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Wed, 8 Jun 2022 13:41:39 +0200 Subject: [PATCH 04/18] Apply suggestions from code review Co-authored-by: Rhuan Barreto --- DEPRECATIONS.md | 1 + spec/AuthenticationAdapters.spec.js | 11 +++-------- spec/ParseUser.spec.js | 4 ++-- src/Adapters/Auth/AuthAdapter.js | 2 +- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index fd0f96388d..51f921c979 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -12,6 +12,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h | DEPPS6 | Auth providers disabled by default | [#7953](https://github.com/parse-community/parse-server/pull/7953) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - | | DEPPS7 | Remove file trigger syntax `Parse.Cloud.beforeSaveFile((request) => {})` | [#7966](https://github.com/parse-community/parse-server/pull/7966) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - | | DEPPS8 | Allow login with expired authData token | [#7079](https://github.com/parse-community/parse-server/pull/7079) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - | + [i_deprecation]: ## "The version and date of the deprecation." [i_removal]: ## "The version and date of the planned removal." [i_status]: ## "The current status of the deprecation: deprecated (the feature is deprecated and still available), removed (the deprecated feature has been removed and is unavailable), retracted (the deprecation has been retracted and the feature will not be removed." diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index a89085cac0..e815167178 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -20,8 +20,6 @@ const responses = { microsoft: { id: 'userId', mail: 'userMail' }, }; -// A simple wrapper to allow usage of -// expectAsync().toBeRejectedWithError(errorMessage) const requestWithExpectedError = async params => { try { return await request(params); @@ -2342,10 +2340,10 @@ describe('Auth Adapter features', () => { return originalFn(...params); } // Second call is triggered after beforeSave. A developer can modify authData during beforeSave. - // To perform a determinist login, parse need to ensure uniqueness of the authData.id into the database. + // To perform a determinist login, the uniqueness of `auth.id` needs to be ensured. // A developer with a direct access to the database could break something and duplicate authData.id. - // In this case, if parse detect 2 matching users for a singe authData.id; login/register will be canceled. - // Promise.resolve([true, true]) simulate this case with 2 matching users. + // In this case, if 2 matching users are detected for a single authData.id, then the login/register will be canceled. + // Promise.resolve([true, true]) simulates this case with 2 matching users. return Promise.resolve([true, true]); }); const user2 = new Parse.User(); @@ -2483,11 +2481,9 @@ describe('Auth Adapter features', () => { await reconfigureServer({ auth: { modernAdapter }, allowExpiredAuthDataToken: false }); const user = new Parse.User(); - // Signup await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); - // Login const user2 = new Parse.User(); await user2.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); @@ -2517,7 +2513,6 @@ describe('Auth Adapter features', () => { await reconfigureServer({ auth: { modernAdapter } }); const user = new Parse.User(); - // Signup await user.save({ authData: { modernAdapter: { id: 'modernAdapter' } } }); expect(modernAdapter.validateSetUp).toHaveBeenCalledTimes(1); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index b491097fda..3ce85ed38f 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -1873,7 +1873,7 @@ describe('Parse.User testing', () => { await Parse.User._logInWith('shortLivedAuth', {}); // Simulate a remotely expired token (like a short lived one) // In this case, we want success as it was valid once. - // If the client needs an updated one, do lock the user out + // If the client needs an updated token, do lock the user out defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); await Parse.User._logInWith('shortLivedAuth', {}); }); @@ -1903,7 +1903,7 @@ describe('Parse.User testing', () => { await Parse.User._logInWith('shortLivedAuth', {}); // Simulate a remotely expired token (like a short lived one) // In this case, we want success as it was valid once. - // If the client needs an updated one, do lock the user out + // If the client needs an updated token, do lock the user out defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken'); expectAsync(Parse.User._logInWith('shortLivedAuth', {})).toBeRejected(); }); diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index c9f9e37782..33e50f1529 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -2,7 +2,7 @@ /** * @interface ParseAuthResponse - * @property {Boolean} [doNotSave] If true, parse server will do not save provided authData. + * @property {Boolean} [doNotSave] If true, Parse Server will do not save provided authData. * @property {Object} [response] If set, parse server will send the provided response to the client under authDataResponse * @property {Object} [save] If set, parse server will save the object provided into this key, instead of client provided authData */ From ec1d61e7ca85d71453bc83ede4f5adf769ae081a Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Wed, 8 Jun 2022 23:07:56 +0200 Subject: [PATCH 05/18] Update src/Adapters/Auth/index.js --- src/Adapters/Auth/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 4e2ba68642..679e42fc39 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -90,7 +90,7 @@ function authDataValidator(provider, adapter, appIds, options) { if (hasAuthDataConfigured) { return adapter.validateUpdate(authData, options, requestObject, req.config); } - // Let's setup if the user does not have the provider configured + // Set up if the user does not have the provider configured return adapter.validateSetUp(authData, options, requestObject, req.config); } From adfacc2de90b5b146a59b98545adf48c49e96ad5 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Fri, 10 Jun 2022 09:23:06 +0200 Subject: [PATCH 06/18] Apply suggestions from code review Co-authored-by: dblythy --- spec/ParseUser.spec.js | 8 ++++---- src/Adapters/Auth/AuthAdapter.js | 6 +++--- src/Adapters/Storage/Postgres/PostgresStorageAdapter.js | 3 +-- src/Routers/UsersRouter.js | 4 ++-- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 3ce85ed38f..1dcb457859 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -1885,16 +1885,16 @@ describe('Parse.User testing', () => { id: '12345', access_token: 'token', }, - restoreAuthentication: function () { + restoreAuthentication() { return true; }, - deauthenticate: function () { + deauthenticate() { provider.authData = {}; }, - authenticate: function (options) { + authenticate(options) { options.success(this, provider.authData); }, - getAuthType: function () { + getAuthType() { return 'shortLivedAuth'; }, }; diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index 33e50f1529..a35de17212 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -3,8 +3,8 @@ /** * @interface ParseAuthResponse * @property {Boolean} [doNotSave] If true, Parse Server will do not save provided authData. - * @property {Object} [response] If set, parse server will send the provided response to the client under authDataResponse - * @property {Object} [save] If set, parse server will save the object provided into this key, instead of client provided authData + * @property {Object} [response] If set, Parse Server will send the provided response to the client under authDataResponse + * @property {Object} [save] If set, Parse Server will save the object provided into this key, instead of client provided authData */ /** @@ -63,7 +63,7 @@ export class AuthAdapter { /** * Triggered when user provide authData related to this provider - * he is not logged in and has already set this provider before + * The user is not logged in and has already set this provider before * @param {Object} authData The client provided authData * @param {Object} options Additional options * @param {Parse.Cloud.TriggerRequest} request diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js index 9cb2594371..a9350d1801 100644 --- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js +++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js @@ -1307,7 +1307,7 @@ export class PostgresStorageAdapter implements StorageAdapter { return; } var authDataMatch = fieldName.match(/^_auth_data_([a-zA-Z0-9_]+)$/); - const authDataAlreadyExists = !!object['authData']; + const authDataAlreadyExists = !!object.authData; if (authDataMatch) { var provider = authDataMatch[1]; object['authData'] = object['authData'] || {}; @@ -1321,7 +1321,6 @@ export class PostgresStorageAdapter implements StorageAdapter { } columnsArray.push(fieldName); - if (!schema.fields[fieldName] && className === '_User') { if ( fieldName === '_email_verify_token' || diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 05822bf59c..934afc0d34 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -535,9 +535,9 @@ export class UsersRouter extends ClassesRouter { const results = await Auth.findUsersWithAuthData(req.config, authData); try { - if (!results[0] || results.length > 1) + if (!results[0] || results.length > 1) { throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'User not found.'); - +} // Find the provider used to find the user const provider = Object.keys(authData).find(key => authData[key].id); From ca926505f272d3c218a0b96a51a5f76a99c3c287 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Fri, 10 Jun 2022 09:46:02 +0200 Subject: [PATCH 07/18] Update src/Adapters/Auth/AuthAdapter.js Co-authored-by: Manuel <5673677+mtrezza@users.noreply.github.com> --- src/Adapters/Auth/AuthAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index a35de17212..de48077867 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -24,7 +24,7 @@ export class AuthAdapter { this.policy = 'default'; } /** - * @param appIds The specified app ids in the configuration + * @param appIds The specified app IDs in the configuration * @param {Object} authData The client provided authData * @param {Object} options Additional options * @param {Parse.Cloud.TriggerRequest} request From 7cafead0874c8250e4baa6ac344f37239231ee8b Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Fri, 10 Jun 2022 09:46:52 +0200 Subject: [PATCH 08/18] Update src/Adapters/Auth/AuthAdapter.js Co-authored-by: Manuel <5673677+mtrezza@users.noreply.github.com> --- src/Adapters/Auth/AuthAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index de48077867..8e18f9cfc3 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -93,7 +93,7 @@ export class AuthAdapter { * @param {(Object|undefined)} authData Auth data provided by the client, can be used for validation * @param {Object} options Additional options * @param {Parse.Cloud.TriggerRequest} request - * @param {Object} config + * @param {Object} config Parse Server config object * @returns {Promise} A promise that resolves, resolved value will be added to challenge response under challenge key */ challenge(challengeData, authData, options, req, user) { From 3131e65f8351ac4f2276d8050d617476253bd56f Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Fri, 10 Jun 2022 09:47:03 +0200 Subject: [PATCH 09/18] Update src/Adapters/Auth/AuthAdapter.js Co-authored-by: Manuel <5673677+mtrezza@users.noreply.github.com> --- src/Adapters/Auth/AuthAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index 8e18f9cfc3..c0b749c430 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -76,7 +76,7 @@ export class AuthAdapter { /** * Triggered when user provide authData related to this provider - * he is logged in and has already set this provider before + * the user is logged in and has already set this provider before * @param {Object} authData The client provided authData * @param {Object} options Additional options * @param {Parse.Cloud.TriggerRequest} request From 71d2374a6c3e1b98db33b655a43644772cee2bb8 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Fri, 10 Jun 2022 09:47:12 +0200 Subject: [PATCH 10/18] Update src/Adapters/Auth/AuthAdapter.js Co-authored-by: Manuel <5673677+mtrezza@users.noreply.github.com> --- src/Adapters/Auth/AuthAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index c0b749c430..6e8ab14022 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -54,7 +54,7 @@ export class AuthAdapter { * @param {Object} authData The client provided authData * @param {Object} options Additional options * @param {Parse.Cloud.TriggerRequest} request - * @param {Object} config + * @param {Object} config Parse Server config object * @returns {Promise} */ validateSetUp(authData, options, req, user) { From fd5a7fda36f69b682f9e46b5ab19c59dbd4bc864 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Fri, 10 Jun 2022 09:47:22 +0200 Subject: [PATCH 11/18] Update src/Adapters/Auth/AuthAdapter.js Co-authored-by: Manuel <5673677+mtrezza@users.noreply.github.com> --- src/Adapters/Auth/AuthAdapter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js index 6e8ab14022..8fc687e175 100644 --- a/src/Adapters/Auth/AuthAdapter.js +++ b/src/Adapters/Auth/AuthAdapter.js @@ -41,7 +41,7 @@ export class AuthAdapter { * @param {Object} authData The client provided authData * @param {Object} options Additional options * @param {Parse.Cloud.TriggerRequest} request - * @param {Object} config + * @param {Object} config Parse Server config object * @returns {Promise} */ validateAuthData(authData, options, request, config) { From 1beebdcf67d1bde794bc55c78e2a1f646b561b82 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Fri, 10 Jun 2022 09:53:16 +0200 Subject: [PATCH 12/18] fix: bracket --- src/Routers/UsersRouter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 934afc0d34..537a138b5d 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -537,7 +537,7 @@ export class UsersRouter extends ClassesRouter { try { if (!results[0] || results.length > 1) { throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'User not found.'); -} + } // Find the provider used to find the user const provider = Object.keys(authData).find(key => authData[key].id); From 4e20959a76a817c8f1f53210eb92651116246025 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Tue, 14 Jun 2022 17:35:17 +0200 Subject: [PATCH 13/18] fix: js doc on isChallenge --- src/cloud-code/Parse.Cloud.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 1ee02fdb60..b90ffe70f6 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -728,6 +728,7 @@ module.exports = ParseCloud; * @interface Parse.Cloud.TriggerRequest * @property {String} installationId If set, the installationId triggering the request. * @property {Boolean} master If true, means the master key was used. + * @property {Boolean} isChallenge If true, means the current request is originally triggered by an auth challenge. * @property {Parse.User} user If set, the user that made the request. * @property {Parse.Object} object The object triggering the hook. * @property {String} ip The IP address of the client making the request. From 9900c64ec50eb398d83ab2d64c36a47edc96075f Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Tue, 21 Jun 2022 14:33:36 +0200 Subject: [PATCH 14/18] fix: login with mutated authData and doNotSave true --- spec/AuthenticationAdapters.spec.js | 25 +++++++++++++++++++++++++ src/RestWrite.js | 19 +++++++++++-------- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 344614bea4..d71184d9b8 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -2723,6 +2723,31 @@ describe('Auth Adapter features', () => { expect(user.get('authData')).toEqual({ baseAdapter: { id: 'baseAdapter' } }); }); + it('should loginWith user with auth Adapter with do not save option, mutated authData and no additional auth adapter', async () => { + const spy = spyOn(doNotSaveAdapter, 'validateAuthData').and.resolveTo({ doNotSave: false }); + await reconfigureServer({ + auth: { doNotSaveAdapter, baseAdapter }, + }); + + const user = new Parse.User(); + + await user.save({ + authData: { doNotSaveAdapter: { id: 'doNotSaveAdapter' } }, + }); + + await user.fetch({ useMasterKey: true }); + + expect(user.get('authData')).toEqual({ doNotSaveAdapter: { id: 'doNotSaveAdapter' } }); + + spy.and.resolveTo({ doNotSave: true }); + + const user2 = await Parse.User.logInWith('doNotSaveAdapter', { + authData: { id: 'doNotSaveAdapter', example: 'example' }, + }); + expect(user2.getSessionToken()).toBeDefined(); + expect(user2.id).toEqual(user.id); + }); + it('should perform authData validation only when its required', async () => { spyOn(baseAdapter2, 'validateAuthData').and.resolveTo({}); spyOn(baseAdapter2, 'validateAppId').and.resolveTo({}); diff --git a/src/RestWrite.js b/src/RestWrite.js index d65aad9216..54407804f1 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -584,15 +584,18 @@ RestWrite.prototype.handleAuthData = async function (authData) { this.response.response.authData[provider] = mutatedAuthData[provider]; }); - // Run the DB update directly, as 'master' - // Just update the authData part + // Run the DB update directly, as 'master' only if authData contains some keys + // authData could not contains keys after validation if the authAdapter + // uses the `doNotSave` option. Just update the authData part // Then we're good for the user, early exit of sorts - await this.config.database.update( - this.className, - { objectId: this.data.objectId }, - { authData: this.data.authData }, - {} - ); + if (Object.keys(this.data.authData).length) { + await this.config.database.update( + this.className, + { objectId: this.data.objectId }, + { authData: this.data.authData }, + {} + ); + } } } } From f6bf6a1f9b9b687302196e5e802483a138da4a28 Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 3 Jul 2022 00:26:33 +0200 Subject: [PATCH 15/18] Update src/Routers/UsersRouter.js Co-authored-by: dblythy --- src/Routers/UsersRouter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 537a138b5d..69fb553776 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -528,7 +528,7 @@ export class UsersRouter extends ClassesRouter { if (Object.keys(authData).filter(key => authData[key].id).length > 1) { throw new Parse.Error( Parse.Error.OTHER_CAUSE, - 'You cant provide more than one authData provider with an id.' + 'You cannot provide more than one authData provider with an id.' ); } From 0cace743f01a09ecc54ef7c2d84ba73ef0d7e94f Mon Sep 17 00:00:00 2001 From: Manuel <5673677+mtrezza@users.noreply.github.com> Date: Sun, 3 Jul 2022 00:26:42 +0200 Subject: [PATCH 16/18] Update src/Routers/UsersRouter.js Co-authored-by: dblythy --- src/Routers/UsersRouter.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 69fb553776..55bcb37e7f 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -521,7 +521,7 @@ export class UsersRouter extends ClassesRouter { if (user) { throw new Parse.Error( Parse.Error.OTHER_CAUSE, - 'You cant provide username/email and authData, only use one identification method.' + 'You cannot provide username/email and authData, only use one identification method.' ); } From 57026b21f98413ee8598cffe51ea2beb8c2c3369 Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Sun, 3 Jul 2022 11:55:38 +0200 Subject: [PATCH 17/18] fix: test --- spec/AuthenticationAdapters.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index d71184d9b8..540d4633c7 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -3254,7 +3254,7 @@ describe('Auth Adapter features', () => { }), }) ).toBeRejectedWithError( - 'You cant provide username/email and authData, only use one identification method.' + 'You cannot provide username/email and authData, only use one identification method.' ); const res = await request({ @@ -3338,7 +3338,7 @@ describe('Auth Adapter features', () => { }, }), }) - ).toBeRejectedWithError('You cant provide more than one authData provider with an id.'); + ).toBeRejectedWithError('You cannot provide more than one authData provider with an id.'); const res = await request({ headers: headers, From 7353012fc30682fb9a5ec241c3279006ab1bff5e Mon Sep 17 00:00:00 2001 From: Antoine Cormouls Date: Sun, 3 Jul 2022 12:17:00 +0200 Subject: [PATCH 18/18] feat: add validation on policy --- spec/AuthenticationAdapters.spec.js | 15 +++++++++++++++ src/Adapters/Auth/index.js | 13 +++++++++++++ 2 files changed, 28 insertions(+) diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js index 540d4633c7..f576a17a5c 100644 --- a/spec/AuthenticationAdapters.spec.js +++ b/spec/AuthenticationAdapters.spec.js @@ -2696,6 +2696,21 @@ describe('Auth Adapter features', () => { ); }); + it('should throw if policy does not match one of default/solo/additional', async () => { + const adapterWithBadPolicy = { + validateAppId: () => Promise.resolve(), + validateAuthData: () => Promise.resolve(), + policy: 'bad', + }; + await reconfigureServer({ auth: { adapterWithBadPolicy } }); + const user = new Parse.User(); + await expectAsync( + user.save({ authData: { adapterWithBadPolicy: { id: 'adapterWithBadPolicy' } } }) + ).toBeRejectedWithError( + 'AuthAdapter policy is not configured correctly. The value must be either "solo", "additional", "default" or undefined (will be handled as "default")' + ); + }); + it('should throw if no triggers found', async () => { await reconfigureServer({ auth: { wrongAdapter } }); const user = new Parse.User(); diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 679e42fc39..0d676f4f2b 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -62,6 +62,13 @@ const providers = { ldap, }; +// Indexed auth policies +const authAdapterPolicies = { + default: true, + solo: true, + additional: true, +}; + function authDataValidator(provider, adapter, appIds, options) { return async function (authData, req, user, requestObject) { if (appIds && typeof adapter.validateAppId === 'function') { @@ -69,6 +76,12 @@ function authDataValidator(provider, adapter, appIds, options) { adapter.validateAppId(appIds, authData, options, requestObject, req.config) ); } + if (adapter.policy && !authAdapterPolicies[adapter.policy]) { + throw new Parse.Error( + Parse.Error.OTHER_CAUSE, + 'AuthAdapter policy is not configured correctly. The value must be either "solo", "additional", "default" or undefined (will be handled as "default")' + ); + } if (typeof adapter.validateAuthData === 'function') { return adapter.validateAuthData(authData, options, requestObject, req.config); } else if (