diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 0000000000..2e1fa2d52e
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1 @@
+*.md
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 71df536115..2e513186a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
__BREAKING CHANGES:__
- NEW: Added file upload restriction. File upload is now only allowed for authenticated users by default for improved security. To allow file upload also for Anonymous Users or Public, set the `fileUpload` parameter in the [Parse Server Options](https://parseplatform.org/parse-server/api/master/ParseServerOptions.html). [#7071](https://github.com/parse-community/parse-server/pull/7071). Thanks to [dblythy](https://github.com/dblythy).
___
+- IMPROVE: Improved Auth Adapter Interface [7052](https://github.com/parse-community/parse-server/pull/7052) Thanks to [Moumouls](https://github.com/Moumouls).
- IMPROVE: Optimize queries on classes with pointer permissions. [#7061](https://github.com/parse-community/parse-server/pull/7061). Thanks to [Pedro Diaz](https://github.com/pdiaz)
- FIX: request.context for afterFind triggers. [#7078](https://github.com/parse-community/parse-server/pull/7078). Thanks to [dblythy](https://github.com/dblythy)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6c2ccd3bd8..c602426a08 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -8,12 +8,12 @@ If you are not familiar with Pull Requests and want to know more about them, you
### Recommended setup:
-* [vscode](https://code.visualstudio.com), the popular IDE.
-* [Jasmine Test Explorer](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-jasmine-test-adapter), a very practical test exploration plugin which let you run, debug and see the test results inline.
+- [vscode](https://code.visualstudio.com), the popular IDE.
+- [Jasmine Test Explorer](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-jasmine-test-adapter), a very practical test exploration plugin which let you run, debug and see the test results inline.
### Setting up you local machine:
-* [Fork](https://github.com/parse-community/parse-server) this project and clone the fork on your local machine:
+- [Fork](https://github.com/parse-community/parse-server) this project and clone the fork on your local machine:
```sh
$ git clone https://github.com/parse-community/parse-server
@@ -29,44 +29,44 @@ Once you have babel running in watch mode, you can start making changes to parse
### Good to know:
-* The `lib/` folder is not commited, so never make changes in there.
-* Always make changes to files in the `src/` folder.
-* All the tests should point to sources in the `lib/` folder.
+- The `lib/` folder is not commited, so never make changes in there.
+- Always make changes to files in the `src/` folder.
+- All the tests should point to sources in the `lib/` folder.
### Troubleshooting:
-*Question*: I modify the code in the src folder but it doesn't seem to have any effect.
-*Answer*: Check that `npm run watch` is running
+_Question_: I modify the code in the src folder but it doesn't seem to have any effect.
+_Answer_: Check that `npm run watch` is running
-*Question*: How do I use breakpoints and debug step by step?
-*Answer*: The easiest way is to install [Jasmine Test Explorer](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-test-explorer), it will let you run selectively tests and debug them.
-
-*Question*: How do I deploy my forked version on my servers?
-*Answer*: In your `package.json`, update the `parse-server` dependency to `https://github.com/MY_USERNAME/parse-server#MY_FEATURE`. Run `npm install`, commit the changes and deploy to your servers.
+_Question_: How do I use breakpoints and debug step by step?
+_Answer_: The easiest way is to install [Jasmine Test Explorer](https://marketplace.visualstudio.com/items?itemName=hbenl.vscode-test-explorer), it will let you run selectively tests and debug them.
+_Question_: How do I deploy my forked version on my servers?
+_Answer_: In your `package.json`, update the `parse-server` dependency to `https://github.com/MY_USERNAME/parse-server#MY_FEATURE`. Run `npm install`, commit the changes and deploy to your servers.
### Please Do's
-* Begin by reading the [Development Guide](http://docs.parseplatform.org/parse-server/guide/#development-guide) to learn how to get started running the parse-server.
-* Take testing seriously! Aim to increase the test coverage with every pull request. To obtain the test coverage of the project, run: `npm run coverage`
-* Run the tests for the file you are working on with the following command: `npm test spec/MyFile.spec.js`
-* Run the tests for the whole project to make sure the code passes all tests. This can be done by running the test command for a single file but removing the test file argument. The results can be seen at */coverage/lcov-report/index.html*.
-* Lint your code by running `npm run lint` to make sure the code is not going to be rejected by the CI.
-* **Do not** publish the *lib* folder.
+- Begin by reading the [Development Guide](http://docs.parseplatform.org/parse-server/guide/#development-guide) to learn how to get started running the parse-server.
+- Take testing seriously! Aim to increase the test coverage with every pull request. To obtain the test coverage of the project, run: `npm run coverage`
+- Run the tests for the file you are working on with the following command: `npm test spec/MyFile.spec.js`
+- Run the tests for the whole project to make sure the code passes all tests. This can be done by running the test command for a single file but removing the test file argument. The results can be seen at _/coverage/lcov-report/index.html_.
+- Lint your code by running `npm run lint` to make sure the code is not going to be rejected by the CI.
+- **Do not** publish the _lib_ folder.
### Run your tests against Postgres (optional)
If your pull request introduces a change that may affect the storage or retrieval of objects, you may want to make sure it plays nice with Postgres.
-* Run the tests against the postgres database with `PARSE_SERVER_TEST_DB=postgres PARSE_SERVER_TEST_DATABASE_URI=postgres://postgres:password@localhost:5432/parse_server_postgres_adapter_test_database npm run testonly`. You'll need to have postgres running on your machine and setup [appropriately](https://github.com/parse-community/parse-server/blob/master/.travis.yml#L43) or use [`Docker`](#run-a-parse-postgres-with-docker).
-* The Postgres adapter has a special debugger that traces all the sql commands. You can enable it with setting the environment variable `PARSE_SERVER_LOG_LEVEL=debug`
-* If your feature is intended to only work with MongoDB, you should disable PostgreSQL-specific tests with:
+- Run the tests against the postgres database with `PARSE_SERVER_TEST_DB=postgres PARSE_SERVER_TEST_DATABASE_URI=postgres://postgres:password@localhost:5432/parse_server_postgres_adapter_test_database npm run testonly`. You'll need to have postgres running on your machine and setup [appropriately](https://github.com/parse-community/parse-server/blob/master/.travis.yml#L43) or use [`Docker`](#run-a-parse-postgres-with-docker).
+- The Postgres adapter has a special debugger that traces all the sql commands. You can enable it with setting the environment variable `PARSE_SERVER_LOG_LEVEL=debug`
+- If your feature is intended to only work with MongoDB, you should disable PostgreSQL-specific tests with:
- `describe_only_db('mongo')` // will create a `describe` that runs only on mongoDB
- `it_only_db('mongo')` // will make a test that only runs on mongo
- `it_exclude_dbs(['postgres'])` // will make a test that runs against all DB's but postgres
-* Similarly, if your feature is intended to only work with PostgreSQL, you should disable MongoDB-specific tests with:
-
+
+- Similarly, if your feature is intended to only work with PostgreSQL, you should disable MongoDB-specific tests with:
+
- `describe_only_db('postgres')` // will create a `describe` that runs only on postgres
- `it_only_db('postgres')` // will make a test that only runs on postgres
- `it_exclude_dbs(['mongo'])` // will make a test that runs against all DB's but mongo
@@ -75,9 +75,10 @@ If your pull request introduces a change that may affect the storage or retrieva
[PostGIS images (select one with v2.2 or higher) on docker dashboard](https://hub.docker.com/r/postgis/postgis) is based off of the official [postgres](https://registry.hub.docker.com/_/postgres/) image and will work out-of-the-box (as long as you create a user with the necessary extensions for each of your Parse databases; see below). To launch the compatible Postgres instance, copy and paste the following line into your shell:
-```
+```sh
docker run -d --name parse-postgres -p 5432:5432 -e POSTGRES_PASSWORD=password --rm postgis/postgis:11-3.0-alpine && sleep 20 && docker exec -it parse-postgres psql -U postgres -c 'CREATE DATABASE parse_server_postgres_adapter_test_database;' && docker exec -it parse-postgres psql -U postgres -c 'CREATE EXTENSION postgis;' -d parse_server_postgres_adapter_test_database && docker exec -it parse-postgres psql -U postgres -c 'CREATE EXTENSION postgis_topology;' -d parse_server_postgres_adapter_test_database
```
+
To stop the Postgres instance:
```
diff --git a/package.json b/package.json
index 02f91b4e5e..f8c7cf8d26 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"@graphql-tools/links": "6.2.5",
"@graphql-tools/stitch": "6.2.4",
"@graphql-tools/utils": "6.2.4",
+ "@node-rs/bcrypt": "0.4.1",
"@parse/fs-files-adapter": "1.2.0",
"@parse/push-adapter": "3.4.0",
"@parse/s3-files-adapter": "1.6.0",
@@ -105,7 +106,7 @@
"posttest": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=4.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} MONGODB_STORAGE_ENGINE=${MONGODB_STORAGE_ENGINE:=mmapv1} mongodb-runner stop",
"coverage": "cross-env MONGODB_VERSION=${MONGODB_VERSION:=4.0.4} MONGODB_TOPOLOGY=${MONGODB_TOPOLOGY:=standalone} MONGODB_STORAGE_ENGINE=${MONGODB_STORAGE_ENGINE:=mmapv1} TESTING=1 nyc jasmine",
"start": "node ./bin/parse-server",
- "prettier": "prettier --write '{src,spec}/{**/*,*}.js'",
+ "prettier": "prettier --write '{src,spec}/{**/*,*}.js' && npm run lint-fix",
"prepare": "npm run build",
"postinstall": "node -p 'require(\"./postinstall.js\")()'"
},
diff --git a/spec/AuthenticationAdapters.spec.js b/spec/AuthenticationAdapters.spec.js
index 87679d02a4..66d169a5f6 100644
--- a/spec/AuthenticationAdapters.spec.js
+++ b/spec/AuthenticationAdapters.spec.js
@@ -1748,3 +1748,808 @@ describe('microsoft graph auth adapter', () => {
});
});
});
+
+describe('Auth Adapter features', () => {
+ const baseAdapter = {
+ validateAppId: () => Promise.resolve(),
+ validateAuthData: () => Promise.resolve(),
+ };
+ const baseAdapter2 = {
+ validateAppId: () => Promise.resolve(),
+ validateAuthData: () => Promise.resolve(),
+ 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 wrongAdapter = {
+ validateAppId: () => Promise.resolve(),
+ };
+
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'X-Parse-Application-Id': 'test',
+ 'X-Parse-REST-API-Key': 'rest',
+ };
+
+ it('should pass authData, options, req, user 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].config).toBeDefined();
+ expect(firstCall[2].config.headers).toBeDefined();
+ expect(firstCall[2].auth).toBeDefined();
+ expect(firstCall[3]).toBeUndefined();
+
+ // Use request sine JS SDK not ready
+ 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].config).toBeDefined();
+ expect(secondCall[2].auth).toBeDefined();
+ expect(secondCall[2].config.headers).toBeDefined();
+ expect(secondCall[3] instanceof Parse.User).toBeTruthy();
+ expect(secondCall[3].id).toEqual(user.id);
+ });
+
+ it('should trigger correctly validateSetUp', 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();
+
+ 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].config).toBeDefined();
+ expect(call[2].auth).toBeDefined();
+ expect(call[2].config.headers).toBeDefined();
+ expect(call[3]).toBeUndefined();
+ expect(user.getSessionToken()).toBeDefined();
+ });
+ 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 } });
+ 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].config).toBeDefined();
+ expect(call[2].auth).toBeDefined();
+ expect(call[2].config.headers).toBeDefined();
+ expect(call[3] instanceof Parse.User).toBeTruthy();
+ expect(call[3].id).toEqual(user2.id);
+ expect(call[3].id).toEqual(user.id);
+ 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].config).toBeDefined();
+ expect(call[2].auth).toBeDefined();
+ expect(call[2].config.headers).toBeDefined();
+ expect(call[3] instanceof Parse.User).toBeTruthy();
+ expect(call[3].id).toEqual(user.id);
+ expect(user.getSessionToken()).toBeDefined();
+ });
+ it('should throw if no triggers found', async () => {
+ await reconfigureServer({ auth: { wrongAdapter } });
+ const user = new Parse.User();
+ try {
+ await user.save({ authData: { wrongAdapter: { id: 'wrongAdapter' } } });
+ fail('should throw');
+ } catch (e) {
+ expect(e.message).toContain(
+ '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(baseAdapter, 'validateAuthData').and.resolveTo({});
+ await reconfigureServer({
+ auth: { baseAdapter2, baseAdapter },
+ });
+
+ const user = new Parse.User();
+
+ await user.save({
+ authData: {
+ baseAdapter: { id: 'baseAdapter' },
+ baseAdapter2: { token: true },
+ },
+ });
+
+ expect(baseAdapter2.validateAuthData).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();
+ try {
+ await user2.save({
+ authData: {
+ baseAdapter: { id: 'baseAdapter' },
+ },
+ });
+ fail('should require additional authData');
+ } catch (e) {
+ expect(e.message).toContain('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 = JSON.parse(res.text);
+ 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 },
+ });
+ });
+ it('should allow update of authData', 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 },
+ });
+ 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(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1);
+
+ expect(user.id).toBeDefined();
+ expect(user.getSessionToken()).toBeDefined();
+ // Should not re validate the baseAdapter
+ // when user is already logged in and authData not changed
+ await user.save(
+ {
+ authData: {
+ baseAdapter2: { test: true },
+ baseAdapter: { id: 'baseAdapter' },
+ },
+ },
+ { sessionToken: user.getSessionToken() }
+ );
+
+ expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1);
+
+ // Should not re validate the baseAdapter
+ // when master key used and authData are not changed
+ await user.save(
+ {
+ authData: {
+ baseAdapter2: { test: true },
+ baseAdapter: { id: 'baseAdapter' },
+ },
+ },
+ { useMasterKey: true }
+ );
+
+ expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(1);
+
+ // Should allow user to change authData
+ await user.save(
+ {
+ authData: {
+ baseAdapter2: { test: true },
+ baseAdapter: { id: 'baseAdapter2' },
+ },
+ },
+ { sessionToken: user.getSessionToken() }
+ );
+
+ expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(2);
+
+ // Should allow master key to change authData
+ await user.save(
+ {
+ authData: {
+ baseAdapter2: { test: true },
+ baseAdapter: { id: 'baseAdapter3' },
+ },
+ },
+ { useMasterKey: true }
+ );
+
+ expect(baseAdapter.validateAuthData).toHaveBeenCalledTimes(3);
+
+ 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].config).toBeDefined();
+ expect(firstCall[2].auth).toBeDefined();
+ expect(firstCall[2].config.headers).toBeDefined();
+ expect(firstCall[3] instanceof Parse.User).toBeTruthy();
+ expect(firstCall[3].id).toEqual(user.id);
+
+ 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].config).toBeDefined();
+ expect(secondCall[2].auth).toBeDefined();
+ expect(secondCall[2].config.headers).toBeDefined();
+ expect(secondCall[3] instanceof Parse.User).toBeTruthy();
+ expect(secondCall[3].id).toEqual(user.id);
+ });
+ it('should return challenge with no logged user', async () => {
+ spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' });
+
+ await reconfigureServer({
+ auth: { challengeAdapter },
+ });
+
+ try {
+ await request({
+ headers: headers,
+ method: 'POST',
+ url: 'http://localhost:8378/1/challenge',
+ body: {},
+ });
+ fail('should throw Nothing to challenge.');
+ } catch (e) {
+ expect(e.text).toContain('Nothing to challenge.');
+ }
+
+ try {
+ await request({
+ headers: headers,
+ method: 'POST',
+ url: 'http://localhost:8378/1/challenge',
+ body: { challengeData: true },
+ });
+ fail('should throw challengeData should be an object.');
+ } catch (e) {
+ expect(e.text).toContain('challengeData should be an object.');
+ }
+
+ try {
+ await request({
+ headers: headers,
+ method: 'POST',
+ url: 'http://localhost:8378/1/challenge',
+ body: { challengeData: { data: true }, authData: true },
+ });
+ fail('should throw authData should be an object.');
+ } catch (e) {
+ expect(e.text).toContain('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(JSON.parse(res.text)).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]).toBeUndefined();
+ expect(challengeCall[3].config).toBeDefined();
+ expect(challengeCall[3].auth).toBeDefined();
+ expect(challengeCall[3].config.headers).toBeDefined();
+ expect(challengeCall[4]).toEqual({ anOption: true });
+ });
+ 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' });
+
+ try {
+ await request({
+ headers: headers,
+ method: 'POST',
+ url: 'http://localhost:8378/1/challenge',
+ body: JSON.stringify({
+ username: 'username',
+ challengeData: {
+ challengeAdapter: { someData: true },
+ },
+ }),
+ });
+ fail('should throw You provided username or email, you need to also provide password.');
+ } catch (e) {
+ expect(e.text).toContain(
+ 'You provided username or email, you need to also provide password.'
+ );
+ }
+
+ try {
+ await request({
+ headers: headers,
+ method: 'POST',
+ url: 'http://localhost:8378/1/challenge',
+ body: JSON.stringify({
+ username: 'username',
+ password: 'password',
+ authData: { data: true },
+ challengeData: {
+ challengeAdapter: { someData: true },
+ },
+ }),
+ });
+ fail(
+ 'should throw You cant provide username/email and authData, only use one identification method.'
+ );
+ } catch (e) {
+ expect(e.text).toContain(
+ '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(JSON.parse(res.text)).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] instanceof Parse.User).toBeTruthy();
+ expect(challengeCall[2].id).toEqual(user.id);
+ expect(challengeCall[3].config).toBeDefined();
+ expect(challengeCall[3].auth).toBeDefined();
+ expect(challengeCall[3].config.headers).toBeDefined();
+ expect(challengeCall[4]).toEqual({ anOption: true });
+ });
+ it('should return challenge with authData created user', async () => {
+ spyOn(challengeAdapter, 'challenge').and.resolveTo({ token: 'test' });
+
+ await reconfigureServer({
+ auth: { challengeAdapter, soloAdapter },
+ });
+
+ try {
+ await request({
+ headers: headers,
+ method: 'POST',
+ url: 'http://localhost:8378/1/challenge',
+ body: JSON.stringify({
+ challengeData: {
+ challengeAdapter: { someData: true },
+ },
+ authData: {
+ challengeAdapter: { id: 'challengeAdapter' },
+ },
+ }),
+ });
+ fail('should throw User not found.');
+ } catch (e) {
+ expect(e.text).toContain('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' } } });
+
+ try {
+ await request({
+ headers: headers,
+ method: 'POST',
+ url: 'http://localhost:8378/1/challenge',
+ body: JSON.stringify({
+ challengeData: {
+ challengeAdapter: { someData: true },
+ },
+ authData: {
+ challengeAdapter: { id: 'challengeAdapter' },
+ soloAdapter: { id: 'soloAdapter' },
+ },
+ }),
+ });
+ fail('should throw You cant provide more than one authData provider with an id.');
+ } catch (e) {
+ expect(e.text).toContain('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(JSON.parse(res.text)).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({ id: 'challengeAdapter' });
+ expect(challengeCall[2] instanceof Parse.User).toBeTruthy();
+ expect(challengeCall[2].id).toEqual(user.id);
+ expect(challengeCall[3].config).toBeDefined();
+ expect(challengeCall[3].auth).toBeDefined();
+ expect(challengeCall[3].config.headers).toBeDefined();
+ expect(challengeCall[4]).toEqual({ anOption: true });
+ });
+});
diff --git a/spec/CloudCode.Validator.spec.js b/spec/CloudCode.Validator.spec.js
index d15bc2479d..c740696ea1 100644
--- a/spec/CloudCode.Validator.spec.js
+++ b/spec/CloudCode.Validator.spec.js
@@ -587,16 +587,10 @@ describe('cloud validator', () => {
expect(obj.get('foo')).toBe('bar');
const query = new Parse.Query('beforeFind');
- try {
- const first = await query.first({ useMasterKey: true });
- expect(first).toBeDefined();
- expect(first.id).toBe(obj.id);
- done();
- } catch (e) {
- console.log(e);
- console.log(e.code);
- throw e;
- }
+ const first = await query.first({ useMasterKey: true });
+ expect(first).toBeDefined();
+ expect(first.id).toBe(obj.id);
+ done();
});
it('basic beforeDelete skipWithMasterKey', async function (done) {
diff --git a/spec/ParseGraphQLServer.spec.js b/spec/ParseGraphQLServer.spec.js
index dad9bda3df..b40ccd31d7 100644
--- a/spec/ParseGraphQLServer.spec.js
+++ b/spec/ParseGraphQLServer.spec.js
@@ -903,8 +903,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 () => {
@@ -6754,7 +6753,62 @@ 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();
+
+ await parseGraphQLServer.parseGraphQLSchema.databaseController.schemaCache.clear();
+ 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');
@@ -6771,6 +6825,7 @@ describe('ParseGraphQLServer', () => {
sessionToken
user {
someField
+ authDataResponse
aPointer {
id
username
@@ -6786,6 +6841,11 @@ describe('ParseGraphQLServer', () => {
fields: {
username: 'user1',
password: 'user1',
+ authData: {
+ challengeAdapter: {
+ id: 'challengeAdapter',
+ },
+ },
aPointer: {
createAndLink: {
username: 'user2',
@@ -6805,6 +6865,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 () => {
@@ -6813,6 +6876,7 @@ describe('ParseGraphQLServer', () => {
parseServer = await global.reconfigureServer({
publicServerURL: 'http://localhost:13377/parse',
auth: {
+ challengeAdapter,
myAuth: {
module: global.mockCustomAuthenticator('parse', 'graphql'),
},
@@ -6832,6 +6896,7 @@ describe('ParseGraphQLServer', () => {
sessionToken
user {
someField
+ authDataResponse
aPointer {
id
username
@@ -6845,6 +6910,7 @@ describe('ParseGraphQLServer', () => {
input: {
clientMutationId,
authData: {
+ challengeAdapter: { id: 'challengeAdapter' },
myAuth: {
id: 'parse',
password: 'graphql',
@@ -6870,9 +6936,69 @@ 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] instanceof Parse.User).toBeTruthy();
+ expect(challengeCall[2].id).toEqual(user.id);
+ expect(challengeCall[3].config).toBeDefined();
+ expect(challengeCall[3].auth).toBeDefined();
+ expect(challengeCall[3].config.headers).toBeDefined();
+ expect(challengeCall[4]).toEqual({ anOption: true });
+ expect(result.data.challenge.clientMutationId).toEqual(clientMutationId);
+ expect(result.data.challenge.challengeData).toEqual({
+ challengeAdapter: { someData: true },
+ });
});
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');
@@ -6889,6 +7015,7 @@ describe('ParseGraphQLServer', () => {
viewer {
sessionToken
user {
+ authDataResponse
someField
}
}
@@ -6900,6 +7027,7 @@ describe('ParseGraphQLServer', () => {
clientMutationId,
username: 'user1',
password: 'user1',
+ authData: { challengeAdapter: { token: true } },
},
},
});
@@ -6908,6 +7036,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 a44926caa4..d1984333c4 100644
--- a/spec/ParseUser.spec.js
+++ b/spec/ParseUser.spec.js
@@ -1789,7 +1789,7 @@ describe('Parse.User testing', () => {
});
});
- it('should allow login with old authData token', done => {
+ it('should not allow login with old authData token', async () => {
const provider = {
authData: {
id: '12345',
@@ -1810,22 +1810,17 @@ 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');
+ try {
+ await Parse.User._logInWith('shortLivedAuth', {});
+ fail('should not authenticate');
+ } catch (e) {
+ expect(e).toBeDefined();
+ }
});
it('should allow PUT request with stale auth Data', done => {
diff --git a/src/Adapters/Auth/AuthAdapter.js b/src/Adapters/Auth/AuthAdapter.js
index 9af6d5e449..a20aa6d0c5 100644
--- a/src/Adapters/Auth/AuthAdapter.js
+++ b/src/Adapters/Auth/AuthAdapter.js
@@ -1,20 +1,101 @@
/*eslint no-unused-vars: "off"*/
export class AuthAdapter {
- /*
+ constructor() {
+ /**
+ * Usage policy
+ * 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
+ */
+ this.policy = 'default';
+ }
+ /**
@param appIds: the specified app ids in the configuration
@param authData: the client provided authData
@param options: additional options
+ @param req: RestWrite instance with config/auth/data
@returns a promise that resolves if the applicationId is valid
*/
- validateAppId(appIds, authData, options) {
+ validateAppId(appIds, authData, options, req) {
+ 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 authData: the client provided authData
+ @param options: additional options
+ @param req: RestWrite instance with config/auth/data
+ @param user: Parse.User instance if Parse.User found
+ @returns a promise that resolves, the resolved value will be handled by the server like:
+ - resolve undefined|void|{} parse server will save authData
+ - resolve { doNotSave: boolean, response: Object} parse server will do not save provided authData and send response to the client under authDataResponse
+ - resolve { response: Object } parse server will save authData and send response to the client under authDataResponse
+ - resolve { response: Object, save: Object } parse server will save the object provided into `save` key and send response to the client under authDataResponse
+ */
+ validateAuthData(authData, options, req, user) {
return Promise.resolve({});
}
- /*
+ /**
+ * Triggered when user provide for the first time this auth provider
@param authData: the client provided authData
@param options: additional options
+ @param req: RestWrite instance with config/auth/data
+ @param user: Parse.User instance if Parse.User found
+ @returns a promise that resolves, the resolved value will be handled by the server like:
+ - resolve undefined|void|{} parse server will save authData
+ - resolve { doNotSave: boolean, response: Object} parse server will do not save provided authData and send response to the client under authDataResponse
+ - resolve { response: Object } parse server will save authData and send response to the client under authDataResponse
+ - resolve { response: Object, save: Object } parse server will save the object provided into `save` key and send response to the client under authDataResponse
+ */
+ 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 authData: the client provided authData
+ @param options: additional options
+ @param req: RestWrite instance with config/auth/data
+ @param user: Parse.User instance if Parse.User found
+ @returns a promise that resolves, the resolved value will be handled by the server like:
+ - resolve undefined|void|{} parse server will save authData
+ - resolve { doNotSave: boolean, response: Object} parse server will do not save provided authData and send response to the client under authDataResponse
+ - resolve { response: Object } parse server will save authData and send response to the client under authDataResponse
+ - resolve { response: Object, save: Object } parse server will save the object provided into `save` key and send response to the client under authDataResponse
+ */
+ 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 authData: the client provided authData
+ @param options: additional options
+ @param req: RestWrite instance with config/auth/data
+ @param user: Parse.User instance if Parse.User found
+ @returns a promise that resolves, the resolved value will be handled by the server like:
+ - resolve undefined|void|{} parse server will save authData
+ - resolve { doNotSave: boolean, response: Object} parse server will do not save provided authData and send response to the client under authDataResponse
+ - resolve { response: Object } parse server will save authData and send response to the client under authDataResponse
+ - resolve { response: Object, save: Object } parse server will save the object provided into `save` key and send response to the client under authDataResponse
+ */
+ validateUpdate(authData, options, req, user) {
+ return Promise.resolve({});
+ }
+
+ /**
+ * Triggered in pre authentication process if needed (like webauthn, SMS OTP)
+ * @param challengeData: data provided by the client
+ * @param authData: auth data provided by the client, can be used for validation
+ * @param options: additional options
+ * @param req: RestWrite instance with config/auth/data
+ * @returns a promise that resolves, resolved value will be added to challenge response under challenge key
*/
- validateAuthData(authData, options) {
+ challenge(challengeData, authData, options, req) {
return Promise.resolve({});
}
}
diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js
index 00637d1131..39f8572466 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');
@@ -61,14 +62,49 @@ const providers = {
ldap,
};
-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) {
+ if (appIds && typeof adapter.validateAppId === 'function') {
+ await adapter.validateAppId(appIds, authData, options, req, user);
+ }
+ if (typeof adapter.validateAuthData === 'function') {
+ return adapter.validateAuthData(authData, options, req, user);
+ } else if (
+ typeof adapter.validateSetUp === 'function' &&
+ typeof adapter.validateLogin === 'function' &&
+ typeof adapter.validateUpdate === 'function'
+ ) {
+ // We can consider for DX purpose when masterKey is detected, we should
+ // trigger a logged in user
+ const isLoggedIn =
+ (req.auth.user && req.auth.user.id === user.id) || (user && req.auth.isMaster);
+ let isUpdate = false;
+ let hasAuthDataConfigured = false;
+
+ if (user && user.get('authData') && user.get('authData')[provider]) {
+ hasAuthDataConfigured = true;
+ }
+
+ if (isLoggedIn && hasAuthDataConfigured) {
+ isUpdate = true;
+ }
+
+ if (isUpdate) {
+ return adapter.validateUpdate(authData, options, req, user);
+ }
+
+ if (!isLoggedIn && hasAuthDataConfigured) {
+ return adapter.validateLogin(authData, options, req, user);
+ }
+
+ if (!hasAuthDataConfigured) {
+ return adapter.validateSetUp(authData, options, req, user);
}
- return Promise.resolve();
- });
+ }
+ throw new Parse.Error(
+ Parse.Error.OTHER_CAUSE,
+ 'Adapter not ready, need to implement validateAuthData or (validateSetUp, validateLogin, validateUpdate)'
+ );
};
}
@@ -94,7 +130,13 @@ function loadAuthAdapter(provider, authOptions) {
if (providerOptions) {
const optionalAdapter = loadAdapter(providerOptions, undefined, providerOptions);
if (optionalAdapter) {
- ['validateAuthData', 'validateAppId'].forEach(key => {
+ [
+ 'validateAuthData',
+ 'validateAppId',
+ 'validateSetUp',
+ 'validateLogin',
+ 'validateUpdate',
+ ].forEach(key => {
if (optionalAdapter[key]) {
adapter[key] = optionalAdapter[key];
}
@@ -102,14 +144,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 };
}
@@ -126,7 +160,7 @@ module.exports = function (authOptions = {}, enableAnonymousUsers = true) {
const { adapter, appIds, providerOptions } = loadAuthAdapter(provider, authOptions);
- return authDataValidator(adapter, appIds, providerOptions);
+ return authDataValidator(provider, adapter, appIds, providerOptions);
};
return Object.freeze({
diff --git a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
index acacbac048..9a38125779 100644
--- a/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
+++ b/src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
@@ -272,7 +272,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
@@ -1234,15 +1233,20 @@ 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 pushing authData multiple times to the
+ // postgres query
+ if (authDataAlreadyExists) return;
}
columnsArray.push(fieldName);
+
if (!schema.fields[fieldName] && className === '_User') {
if (
fieldName === '_email_verify_token' ||
@@ -1729,7 +1733,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 2d63e785be..2cc0b6c0ec 100644
--- a/src/Auth.js
+++ b/src/Auth.js
@@ -1,6 +1,14 @@
const cryptoUtils = require('./cryptoUtils');
-const RestQuery = require('./RestQuery');
const Parse = require('parse/node');
+import _ from 'lodash';
+
+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.
@@ -84,7 +92,8 @@ const getAuthForSessionToken = async function ({
limit: 1,
include: 'user',
};
-
+ // For cyclic dep
+ const RestQuery = require('./RestQuery');
const query = new RestQuery(config, master(config), '_Session', { sessionToken }, restOptions);
results = (await query.execute()).results;
} else {
@@ -126,6 +135,8 @@ var getAuthForLegacySessionToken = function ({ config, sessionToken, installatio
var restOptions = {
limit: 1,
};
+ // For cyclic dep
+ const RestQuery = require('./RestQuery');
var query = new RestQuery(config, master(config), '_User', { sessionToken }, restOptions);
return query.execute().then(response => {
var results = response.results;
@@ -170,6 +181,8 @@ Auth.prototype.getRolesForUser = async function () {
objectId: this.user.id,
},
};
+ // For cyclic dep
+ const RestQuery = require('./RestQuery');
await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result =>
results.push(result)
);
@@ -254,6 +267,8 @@ Auth.prototype.getRolesByIds = async function (ins) {
};
});
const restWhere = { roles: { $in: roles } };
+ // For cyclic dep
+ const RestQuery = require('./RestQuery');
await new RestQuery(this.config, master(this.config), '_Role', restWhere, {}).each(result =>
results.push(result)
);
@@ -332,6 +347,146 @@ const createSession = function (
};
};
+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';
+ });
+
+ let findPromise = Promise.resolve([]);
+ if (query.length > 0) {
+ findPromise = config.database.find('_User', { $or: query }, {});
+ }
+
+ return findPromise;
+};
+
+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 (!_.isEqual(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);
+
+ const hasProvidedASoloProvider = savedUserProviders.some(
+ provider =>
+ config.auth[provider] && config.auth[provider].policy === 'solo' && authData[provider]
+ );
+
+ // Solo providers can be considered as safe
+ // so we do not have to check if the user need
+ // to provide an additional provider to login
+ if (hasProvidedASoloProvider) return;
+
+ const additionProvidersNotFound = [];
+ const hasProvidedAtLeastOneAdditionalProvider = savedUserProviders.some(provider => {
+ if (config.auth[provider] && config.auth[provider].policy === 'additional') {
+ if (authData[provider]) {
+ return true;
+ } else {
+ // Push missing provider for plausible error return
+ additionProvidersNotFound.push(provider);
+ }
+ }
+ });
+ 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 the user by session and current object id
+ // Only pass user if it's the current one or master key
+ } else if (
+ (req.auth &&
+ req.auth.user &&
+ typeof req.getUserId === 'function' &&
+ req.getUserId() === req.auth.user.id) ||
+ (req.auth && req.auth.isMaster)
+ ) {
+ user = new Parse.User();
+ user.id = req.auth.isMaster ? req.getUserId() : req.auth.user.id;
+ await user.fetch({ useMasterKey: true });
+ }
+
+ // 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 fail
+ 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 validateAuthData = req.config.authDataManager.getValidatorForProvider(provider);
+ if (!validateAuthData) {
+ throw new Parse.Error(
+ Parse.Error.UNSUPPORTED_SERVICE,
+ 'This authentication method is unsupported.'
+ );
+ }
+ const validationResult = await validateAuthData(
+ authData[provider],
+ { config: req.config, auth: req.auth },
+ user
+ );
+ 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,
@@ -340,4 +495,9 @@ module.exports = {
getAuthForSessionToken,
getAuthForLegacySessionToken,
createSession,
+ findUsersWithAuthData,
+ hasMutatedAuthData,
+ checkIfUserHasProvidedConfiguredProvidersForLogin,
+ reducePromise,
+ handleAuthDataValidation,
};
diff --git a/src/GraphQL/helpers/objectsQueries.js b/src/GraphQL/helpers/objectsQueries.js
index 7b973312ce..d33c757bb2 100644
--- a/src/GraphQL/helpers/objectsQueries.js
+++ b/src/GraphQL/helpers/objectsQueries.js
@@ -58,7 +58,7 @@ const getObject = async (
options.keys = keys;
}
} catch (e) {
- console.log(e);
+ console.error(e);
}
if (include) {
options.include = include;
diff --git a/src/GraphQL/loaders/parseClassTypes.js b/src/GraphQL/loaders/parseClassTypes.js
index 22d90b52b7..133c30f17b 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 9c57c85125..f43c44d0be 100644
--- a/src/GraphQL/loaders/usersMutations.js
+++ b/src/GraphQL/loaders/usersMutations.js
@@ -39,7 +39,7 @@ const load = parseGraphQLSchema => {
req: { config, auth, info },
});
- const { sessionToken, objectId } = await objectsMutations.createObject(
+ const { sessionToken, objectId, authDataResponse } = await objectsMutations.createObject(
'_User',
parseFields,
config,
@@ -48,9 +48,15 @@ const load = parseGraphQLSchema => {
);
context.info.sessionToken = sessionToken;
-
+ const viewer = await getUserFromSessionToken(
+ context,
+ mutationInfo,
+ 'viewer.user.',
+ objectId
+ );
+ if (authDataResponse) viewer.user.authDataResponse = authDataResponse;
return {
- viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId),
+ viewer,
};
} catch (e) {
parseGraphQLSchema.handleError(e);
@@ -109,7 +115,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,
@@ -118,9 +124,15 @@ const load = parseGraphQLSchema => {
);
context.info.sessionToken = sessionToken;
-
+ const viewer = await getUserFromSessionToken(
+ context,
+ mutationInfo,
+ 'viewer.user.',
+ objectId
+ );
+ if (authDataResponse) viewer.user.authDataResponse = authDataResponse;
return {
- viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId),
+ viewer,
};
} catch (e) {
parseGraphQLSchema.handleError(e);
@@ -144,6 +156,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: {
@@ -153,14 +169,15 @@ const load = parseGraphQLSchema => {
},
mutateAndGetPayload: async (args, context, mutationInfo) => {
try {
- const { username, password } = args;
+ const { username, password, authData } = args;
const { config, auth, info } = context;
- const { sessionToken, objectId } = (
+ const { sessionToken, objectId, authDataResponse } = (
await usersRouter.handleLogIn({
body: {
username,
password,
+ authData,
},
query: {},
config,
@@ -171,8 +188,15 @@ const load = parseGraphQLSchema => {
context.info.sessionToken = sessionToken;
+ const viewer = await getUserFromSessionToken(
+ context,
+ mutationInfo,
+ 'viewer.user.',
+ objectId
+ );
+ if (authDataResponse) viewer.user.authDataResponse = authDataResponse;
return {
- viewer: await getUserFromSessionToken(context, mutationInfo, 'viewer.user.', objectId),
+ viewer,
};
} catch (e) {
parseGraphQLSchema.handleError(e);
@@ -298,6 +322,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 need 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 pre identify the user if the auth adapter need pre identification.',
+ type: OBJECT,
+ },
+ challengeData: {
+ description:
+ 'Challenge data payload, could be used to post some data 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/RestQuery.js b/src/RestQuery.js
index ef3846daec..cfea5ce1b3 100644
--- a/src/RestQuery.js
+++ b/src/RestQuery.js
@@ -577,7 +577,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 => {
@@ -646,7 +646,7 @@ RestQuery.prototype.runFind = function (options = {}) {
.then(results => {
if (this.className === '_User' && findOptions.explain !== true) {
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 38b318100c..5a972fe368 100644
--- a/src/RestWrite.js
+++ b/src/RestWrite.js
@@ -142,6 +142,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;
});
};
@@ -374,7 +380,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');
}
@@ -384,10 +394,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
@@ -397,15 +407,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);
}
}
@@ -415,48 +424,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;
@@ -470,106 +437,130 @@ 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;
- }
- 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));
- }
+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;
+ }
+};
- // 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];
- });
+RestWrite.prototype.handleAuthData = async function (authData) {
+ const r = await Auth.findUsersWithAuthData(this.config, authData);
+ const results = this.filteredObjectsByACL(r);
- // 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 },
- {}
- );
- }
+ 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;
+
+ // Prevent validating if no mutated data detected
+ if (!hasMutatedAuthData && isCurrentUserLoggedOrMaster) {
+ return;
+ }
+
+ const isLogin = !userId;
+
+ if (isLogin || hasMutatedAuthData) {
+ // 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 (isLogin) {
+ 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
+ );
+ }
+
+ // Force to validate all provided authData on login
+ // on update only validate mutated ones
+ 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 (isLogin && hasMutatedAuthData) {
+ // 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;
}
diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js
index 7843cf4674..e1bec25cb9 100644
--- a/src/Routers/UsersRouter.js
+++ b/src/Routers/UsersRouter.js
@@ -164,7 +164,6 @@ export class UsersRouter extends ClassesRouter {
// Remove hidden properties.
UsersRouter.removeHiddenProperties(user);
-
return { response: user };
}
});
@@ -172,6 +171,17 @@ 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 his required auth providers
+ Auth.checkIfUserHasProvidedConfiguredProvidersForLogin(authData, user.authData, req.config);
+
+ let authDataResponse;
+ let validatedAuthData;
+ if (authData) {
+ const res = await Auth.handleAuthDataValidation(authData, req, user);
+ authDataResponse = res.authDataResponse;
+ validatedAuthData = res.authData;
+ }
// handle password expiry policy
if (req.config.passwordPolicy && req.config.passwordPolicy.maxPasswordAge) {
@@ -218,6 +228,17 @@ 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 } = Auth.createSession(req.config, {
userId: user.objectId,
createdWith: {
@@ -240,6 +261,10 @@ export class UsersRouter extends ClassesRouter {
req.config
);
+ if (authDataResponse) {
+ user.authDataResponse = authDataResponse;
+ }
+
return { response: user };
}
@@ -392,6 +417,72 @@ 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 find the user with default
+ // system
+ 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.');
+
+ // 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.');
+ // To avoid security issue we should only support one identifying method
+ if (user)
+ throw new Parse.Error(
+ Parse.Error.OTHER_CAUSE,
+ 'You cant provide username/email and authData, only use one identification method.'
+ );
+ const results = await Auth.findUsersWithAuthData(req.config, authData);
+ if (results.length > 1) {
+ throw new Parse.Error(
+ Parse.Error.OTHER_CAUSE,
+ 'You cant provide more than one authData provider with an id.'
+ );
+ }
+ if (!results[0]) throw new Parse.Error(Parse.Error.OTHER_CAUSE, 'User not found.');
+ user = results[0];
+ }
+
+ const authAdapters = req.config.auth;
+
+ // Execute challenge step by step
+ // with consistent order
+ const challenge = await Auth.reducePromise(
+ Object.keys(challengeData).sort(),
+ async (acc, provider) => {
+ if (typeof authAdapters[provider].challenge === 'function') {
+ acc[provider] =
+ (await authAdapters[provider].challenge(
+ challengeData[provider],
+ authData && authData[provider],
+ user ? Parse.User.fromJSON({ className: '_User', ...user }) : undefined,
+ req,
+ authAdapters[provider].options
+ )) || true;
+ return acc;
+ }
+ },
+ {}
+ );
+
+ return { response: Object.keys(challenge).length ? { challengeData: challenge } : undefined };
+ }
+
mountRoutes() {
this.route('GET', '/users', req => {
return this.handleFind(req);
@@ -429,6 +520,9 @@ export class UsersRouter extends ClassesRouter {
this.route('GET', '/verifyPassword', req => {
return this.handleVerifyPassword(req);
});
+ this.route('POST', '/challenge', req => {
+ return this.handleChallenge(req);
+ });
}
}