Skip to content

feat: Improve authentication adapter interface #8156

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 50 commits into from
Nov 10, 2022
Merged
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
57ac3fd
feat: new auth adapter interface
Moumouls Jun 6, 2022
5b2e0b3
Merge branch 'community-alpha' into moumouls/webauthn-adapter
Moumouls Jun 6, 2022
cf4a527
fix: feedbacks
Moumouls Jun 6, 2022
5dbee87
fix: restore lock
Moumouls Jun 6, 2022
28636ce
Apply suggestions from code review
Moumouls Jun 8, 2022
ec1d61e
Update src/Adapters/Auth/index.js
mtrezza Jun 8, 2022
adfacc2
Apply suggestions from code review
Moumouls Jun 10, 2022
ca92650
Update src/Adapters/Auth/AuthAdapter.js
Moumouls Jun 10, 2022
7cafead
Update src/Adapters/Auth/AuthAdapter.js
Moumouls Jun 10, 2022
3131e65
Update src/Adapters/Auth/AuthAdapter.js
Moumouls Jun 10, 2022
71d2374
Update src/Adapters/Auth/AuthAdapter.js
Moumouls Jun 10, 2022
fd5a7fd
Update src/Adapters/Auth/AuthAdapter.js
Moumouls Jun 10, 2022
1beebdc
fix: bracket
Moumouls Jun 10, 2022
d12811d
Merge branch 'alpha' into moumouls/webauthn-adapter
Moumouls Jun 12, 2022
4e20959
fix: js doc on isChallenge
Moumouls Jun 14, 2022
80652f5
Merge branch 'alpha' into moumouls/webauthn-adapter
Moumouls Jun 14, 2022
731486d
Merge branch 'alpha' into moumouls/webauthn-adapter
Moumouls Jun 20, 2022
9900c64
fix: login with mutated authData and doNotSave true
Moumouls Jun 21, 2022
3b473f8
Merge branch 'alpha' into moumouls/webauthn-adapter
mtrezza Jun 24, 2022
814ba6c
Merge branch 'alpha' into moumouls/webauthn-adapter
mtrezza Jul 2, 2022
f6bf6a1
Update src/Routers/UsersRouter.js
mtrezza Jul 2, 2022
0cace74
Update src/Routers/UsersRouter.js
mtrezza Jul 2, 2022
b8a6fc9
Merge branch 'alpha' into moumouls/webauthn-adapter
Moumouls Jul 3, 2022
57026b2
fix: test
Moumouls Jul 3, 2022
7353012
feat: add validation on policy
Moumouls Jul 3, 2022
7dd7d2f
Merge branch 'alpha' into moumouls/webauthn-adapter
Moumouls Jul 3, 2022
1d8a4d8
Merge branch 'alpha' into moumouls/webauthn-adapter
mtrezza Jul 20, 2022
de2e08a
Merge branch 'alpha' into moumouls/webauthn-adapter
mtrezza Jul 28, 2022
713aa5d
Merge branch 'alpha' into moumouls/webauthn-adapter
mtrezza Aug 16, 2022
bc0e38c
Merge branch 'alpha' into moumouls/webauthn-adapter
mtrezza Aug 31, 2022
b3bb21f
feat: new auth adapter (continuation)
dblythy Sep 8, 2022
6d1126a
Update AuthenticationAdaptersV2.spec.js
dblythy Sep 8, 2022
68c22ae
Merge branch 'alpha' into new-auth-adapter
dblythy Sep 8, 2022
63e00d5
fix tests
dblythy Sep 8, 2022
6eba4ff
Merge branch 'alpha' into new-auth-adapter
mtrezza Sep 8, 2022
d152eee
Merge branch 'alpha' into new-auth-adapter
mtrezza Sep 12, 2022
3b20b19
Merge branch 'alpha' into new-auth-adapter
mtrezza Sep 20, 2022
2a1b0a6
Merge branch 'alpha' into new-auth-adapter
mtrezza Sep 21, 2022
4d1f1fe
Merge branch 'alpha' into new-auth-adapter
dblythy Oct 15, 2022
3fd6732
Merge branch 'alpha' into new-auth-adapter
dblythy Nov 1, 2022
2534870
revert options
dblythy Nov 1, 2022
11b1454
fix null bug
dblythy Nov 1, 2022
f9b78ab
Merge branch 'new-auth-adapter' of https://github.com/dblythy/parse-s…
dblythy Nov 1, 2022
cc81c1d
Merge branch 'alpha' into new-auth-adapter
mtrezza Nov 1, 2022
741e406
Update ParseGraphQLServer.spec.js
dblythy Nov 2, 2022
d76c2af
Merge branch 'alpha' into new-auth-adapter
mtrezza Nov 3, 2022
98000c7
Update src/Options/index.js
dblythy Nov 3, 2022
b58d6a0
definitions
dblythy Nov 3, 2022
571efb3
fix deprecation description
mtrezza Nov 3, 2022
838cf17
Merge branch 'alpha' into new-auth-adapter
mtrezza Nov 10, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions DEPRECATIONS.md
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@ The following is a list of deprecations, according to the [Deprecation Policy](h
| DEPPS5 | Config option `allowClientClassCreation` defaults to `false` | [#7925](https://github.com/parse-community/parse-server/pull/7925) | 5.3.0 (2022) | 7.0.0 (2024) | deprecated | - |
| 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 | Login with expired 3rd party authentication token defaults to `false` | [#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."
73 changes: 62 additions & 11 deletions spec/AuthenticationAdapters.spec.js
Original file line number Diff line number Diff line change
@@ -256,6 +256,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 +382,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 +405,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 +433,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();
},
@@ -541,6 +591,7 @@ describe('AuthenticationProviders', function () {
});

it('can depreciate', async () => {
await reconfigureServer();
const Deprecator = require('../lib/Deprecator/Deprecator');
const spy = spyOn(Deprecator, 'logRuntimeDeprecation').and.callFake(() => {});
const provider = getMockMyOauthProvider();
1,251 changes: 1,251 additions & 0 deletions spec/AuthenticationAdaptersV2.spec.js

Large diffs are not rendered by default.

157 changes: 155 additions & 2 deletions spec/ParseGraphQLServer.spec.js
Original file line number Diff line number Diff line change
@@ -942,8 +942,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 () => {
@@ -7027,7 +7026,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');
@@ -7044,6 +7097,7 @@ describe('ParseGraphQLServer', () => {
sessionToken
user {
someField
authDataResponse
aPointer {
id
username
@@ -7059,6 +7113,11 @@ describe('ParseGraphQLServer', () => {
fields: {
username: 'user1',
password: 'user1',
authData: {
challengeAdapter: {
id: 'challengeAdapter',
},
},
aPointer: {
createAndLink: {
username: 'user2',
@@ -7078,6 +7137,9 @@ describe('ParseGraphQLServer', () => {
expect(result.data.#.viewer.user.aPointer.id).toBeDefined();
expect(result.data.#.viewer.user.aPointer.username).toEqual('user2');
expect(typeof result.data.#.viewer.sessionToken).toBe('string');
expect(result.data.#.viewer.user.authDataResponse).toEqual({
challengeAdapter: { someData: true },
});
});

it('should login with user', async () => {
@@ -7086,6 +7148,7 @@ describe('ParseGraphQLServer', () => {
parseServer = await global.reconfigureServer({
publicServerURL: 'http://localhost:13377/parse',
auth: {
challengeAdapter,
myAuth: {
module: global.mockCustomAuthenticator('parse', 'graphql'),
},
@@ -7105,6 +7168,7 @@ describe('ParseGraphQLServer', () => {
sessionToken
user {
someField
authDataResponse
aPointer {
id
username
@@ -7118,6 +7182,7 @@ describe('ParseGraphQLServer', () => {
input: {
clientMutationId,
authData: {
challengeAdapter: { id: 'challengeAdapter' },
myAuth: {
id: 'parse',
password: 'graphql',
@@ -7143,9 +7208,92 @@ 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(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');
@@ -7162,6 +7310,7 @@ describe('ParseGraphQLServer', () => {
viewer {
sessionToken
user {
authDataResponse
someField
}
}
@@ -7173,6 +7322,7 @@ describe('ParseGraphQLServer', () => {
clientMutationId,
username: 'user1',
password: 'user1',
authData: { challengeAdapter: { token: true } },
},
},
});
@@ -7181,6 +7331,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 () => {
154 changes: 104 additions & 50 deletions spec/ParseUser.spec.js
Original file line number Diff line number Diff line change
@@ -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 # class method', async done => {
const user = await Parse.User.#('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 token, 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() {
return true;
},
deauthenticate() {
provider.authData = {};
},
authenticate(options) {
options.success(this, provider.authData);
},
getAuthType() {
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 token, 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();
96 changes: 86 additions & 10 deletions src/Adapters/Auth/AuthAdapter.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,96 @@
/*eslint no-unused-vars: "off"*/

/**
* @interface ParseAuthResponse
* @property {Boolean} [doNotSave] If true, Parse Server will 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 adapter options
* @param {Parse.Cloud.TriggerRequest} request
* @returns {(Promise<undefined|void>|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 (#/update/#)
* otherwise you should implement validateSetup, validateLogin and validateUpdate
* @param {Object} authData The client provided authData
* @param {Parse.Cloud.TriggerRequest} request
* @param {Object} options additional adapter options
* @returns {Promise<ParseAuthResponse|void|undefined>}
*/
validateAuthData(authData, request, options) {
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 {Parse.Cloud.TriggerRequest} request
* @param {Object} options additional adapter options
* @returns {Promise<ParseAuthResponse|void|undefined>}
*/
validateSetUp(authData, req, options) {
return Promise.resolve({});
}

/**
* Triggered when user provide authData related to this provider
* The user is not logged in and has already set this provider before
* @param {Object} authData The client provided authData
* @param {Parse.Cloud.TriggerRequest} request
* @param {Object} options additional adapter options
* @returns {Promise<ParseAuthResponse|void|undefined>}
*/
validateLogin(authData, req, options) {
return Promise.resolve({});
}

/**
* Triggered when user provide authData related to this provider
* the user is logged in and has already set this provider before
* @param {Object} authData The client provided authData
* @param {Object} options additional adapter options
* @param {Parse.Cloud.TriggerRequest} request
* @returns {Promise<ParseAuthResponse|void|undefined>}
*/
validateAppId(appIds, authData, options) {
validateUpdate(authData, req, options) {
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 adapter options
* @param {Parse.Cloud.TriggerRequest} request
* @returns {Promise<Object>} A promise that resolves, resolved value will be added to challenge response under challenge key
*/
validateAuthData(authData, options) {
challenge(challengeData, authData, options, request) {
return Promise.resolve({});
}
}
108 changes: 87 additions & 21 deletions src/Adapters/Auth/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import loadAdapter from '../AdapterLoader';
import Parse from 'parse/node';

const apple = require('./apple');
const gcenter = require('./gcenter');
@@ -61,19 +62,83 @@ 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);
// 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') {
await Promise.resolve(adapter.validateAppId(appIds, authData, options, requestObject));
}
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);
}
if (
typeof adapter.validateSetUp !== 'function' ||
typeof adapter.validateLogin !== 'function' ||
typeof adapter.validateUpdate !== 'function'
) {
throw new Parse.Error(
Parse.Error.OTHER_CAUSE,
'Adapter is not configured. Implement either validateAuthData or all of the following: validateSetUp, validateLogin and validateUpdate'
);
}
// 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 {
method: 'validateUpdate',
validator: () => adapter.validateUpdate(authData, options, requestObject),
};
}
return Promise.resolve();
});
// Set up if the user does not have the provider configured
return {
method: 'validateSetUp',
validator: () => adapter.validateSetUp(authData, options, requestObject),
};
}

// Not logged in and authData is configured on the user
if (hasAuthDataConfigured) {
return {
method: 'validateLogin',
validator: () => adapter.validateLogin(authData, options, requestObject),
};
}

// 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 {
method: 'validateSetUp',
validator: () => adapter.validateSetUp(authData, options, requestObject),
};
};
}

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 +148,7 @@ function loadAuthAdapter(provider, authOptions) {
defaultAdapter = oauth2;
}

// Default provider not found and a custom auth provider was not provided
if (!defaultAdapter && !providerOptions) {
return;
}
@@ -94,22 +160,22 @@ 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];
}
});
}
}

// 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 +187,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({
7 changes: 5 additions & 2 deletions src/Adapters/Storage/Postgres/PostgresStorageAdapter.js
Original file line number Diff line number Diff line change
@@ -289,7 +289,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
@@ -1322,12 +1321,17 @@ 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);
@@ -1807,7 +1811,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) {
191 changes: 189 additions & 2 deletions src/Auth.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
const RestQuery = require('./RestQuery');
const Parse = require('parse/node');
import { isDeepStrictEqual } from 'util';
import { getRequestObject, resolveError } from './triggers';
import Deprecator from './Deprecator/Deprecator';
import { logger } from './logger';

// An Auth object tells you who is requesting something and whether
// the master key was used.
@@ -83,7 +86,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 +128,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 +173,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)
);
@@ -262,6 +267,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)
);
@@ -307,11 +313,192 @@ 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
const acc = { authData: {}, authDataResponse: {} };
const authKeys = Object.keys(authData).sort();
for (const provider of authKeys) {
let method = '';
try {
if (authData[provider] === null) {
acc.authData[provider] = null;
continue;
}
const { validator } = req.config.authDataManager.getValidatorForProvider(provider);
const authProvider = (req.config.auth || {})[provider] || {};
if (authProvider.enabled == null) {
Deprecator.logRuntimeDeprecation({
usage: `auth.${provider}`,
solution: `auth.${provider}.enabled: true`,
});
}
if (!validator || authProvider.enabled === false) {
throw new Parse.Error(
Parse.Error.UNSUPPORTED_SERVICE,
'This authentication method is unsupported.'
);
}
let validationResult = await validator(authData[provider], req, user, requestObject);
method = validationResult && validationResult.method;
requestObject.triggerName = method;
if (validationResult && validationResult.validator) {
validationResult = await validationResult.validator();
}
if (!validationResult) {
acc.authData[provider] = authData[provider];
continue;
}
if (!Object.keys(validationResult).length) {
acc.authData[provider] = authData[provider];
continue;
}

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];
}
} catch (err) {
const e = resolveError(err, {
code: Parse.Error.SCRIPT_FAILED,
message: 'Auth failed. Unknown error.',
});
const userString =
req.auth && req.auth.user ? req.auth.user.id : req.data.objectId || undefined;
logger.error(
`Failed running auth step ${method} for ${provider} for user ${userString} with Error: ` +
JSON.stringify(e),
{
authenticationStep: method,
error: e,
user: userString,
provider,
}
);
throw e;
}
}
return acc;
};

module.exports = {
Auth,
master,
nobody,
readOnly,
getAuthForSessionToken,
getAuthForLegacySessionToken,
findUsersWithAuthData,
hasMutatedAuthData,
checkIfUserHasProvidedConfiguredProvidersForLogin,
handleAuthDataValidation,
};
8 changes: 8 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
@@ -81,6 +81,7 @@ export class Config {
enforcePrivateUsers,
schema,
requestKeywordDenylist,
allowExpiredAuthDataToken,
}) {
if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different');
@@ -120,6 +121,7 @@ export class Config {
this.validateSecurityOptions(security);
this.validateSchemaOptions(schema);
this.validateEnforcePrivateUsers(enforcePrivateUsers);
this.validateAllowExpiredAuthDataToken(allowExpiredAuthDataToken);
this.validateRequestKeywordDenylist(requestKeywordDenylist);
}

@@ -137,6 +139,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.';
1 change: 1 addition & 0 deletions src/Deprecator/Deprecations.js
Original file line number Diff line number Diff line change
@@ -24,4 +24,5 @@ module.exports = [
},
{ optionKey: 'enforcePrivateUsers', changeNewDefault: 'true' },
{ optionKey: 'allowClientClassCreation', changeNewDefault: 'false' },
{ optionKey: 'allowExpiredAuthDataToken', changeNewDefault: 'false' },
];
15 changes: 10 additions & 5 deletions src/GraphQL/loaders/parseClassTypes.js
Original file line number Diff line number Diff line change
@@ -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 #/#.`,
type: defaultGraphQLTypes.OBJECT,
},
}
: {}),
};
const outputFields = () => {
return classOutputFields.reduce((fields, field) => {
93 changes: 84 additions & 9 deletions src/GraphQL/loaders/usersMutations.js
Original file line number Diff line number Diff line change
@@ -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 };
7 changes: 7 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
@@ -68,6 +68,13 @@ module.exports.ParseServerOptions = {
action: parsers.booleanParser,
default: false,
},
allowExpiredAuthDataToken: {
env: 'PARSE_SERVER_ALLOW_EXPIRED_AUTH_DATA_TOKEN',
help:
'Allow a user to log in even if the 3rd party authentication token that was used to # to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `true`.',
action: parsers.booleanParser,
default: true,
},
allowHeaders: {
env: 'PARSE_SERVER_ALLOW_HEADERS',
help: 'Add headers to Access-Control-Allow-Headers',
1 change: 1 addition & 0 deletions src/Options/docs.js
Original file line number Diff line number Diff line change
@@ -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 a user to log in even if the 3rd party authentication token that was used to # to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `true`.
* @property {String[]} allowHeaders Add headers to Access-Control-Allow-Headers
* @property {String} allowOrigin Sets the origin to Access-Control-Allow-Origin
* @property {Adapter<AnalyticsAdapter>} analyticsAdapter Adapter module for the analytics
3 changes: 3 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
@@ -282,6 +282,9 @@ export interface ParseServerOptions {
/* Set to true if new users should be created without public read and write access.
:DEFAULT: false */
enforcePrivateUsers: ?boolean;
/* Allow a user to log in even if the 3rd party authentication token that was used to # to their account has expired. If this is set to `false`, then the token will be validated every time the user signs in to their account. This refers to the token that is stored in the `_User.authData` field. Defaults to `true`.
: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[]);
4 changes: 2 additions & 2 deletions src/RestQuery.js
Original file line number Diff line number Diff line change
@@ -593,7 +593,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 => {
@@ -662,7 +662,7 @@ RestQuery.prototype.runFind = function (options = {}) {
.then(results => {
if (this.className === '_User' && !findOptions.explain) {
for (var result of results) {
cleanResultAuthData(result);
this.cleanResultAuthData(result);
}
}

319 changes: 168 additions & 151 deletions src/RestWrite.js

Large diffs are not rendered by default.

171 changes: 169 additions & 2 deletions src/Routers/UsersRouter.js
Original file line number Diff line number Diff line change
@@ -7,9 +7,15 @@ 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,
resolveError,
} from '../triggers';
import { promiseEnsureIdempotency } from '../middlewares';
import RestWrite from '../RestWrite';
import { logger } from '../logger';

export class UsersRouter extends ClassesRouter {
className() {
@@ -174,14 +180,37 @@ export class UsersRouter extends ClassesRouter {

// Remove hidden properties.
UsersRouter.removeHiddenProperties(user);

return { response: user };
}
});
}

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 +257,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 +289,10 @@ export class UsersRouter extends ClassesRouter {
req.config
);

if (authDataResponse) {
user.authDataResponse = authDataResponse;
}

return { response: user };
}

@@ -453,6 +496,127 @@ 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 cannot 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 cannot 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.OBJECT_NOT_FOUND, '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);
const validatorResponse = await validator(authData[provider], req, parseUser, request);
if (validatorResponse && validatorResponse.validator) {
await validatorResponse.validator();
}
} catch (e) {
// Rewrite the error to avoid guess id attack
logger.error(e);
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, '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;
}
const acc = {};
// Execute challenge step-by-step with consistent order for better error feedback
// and to avoid to trigger others challenges if one of them fails
for (const provider of Object.keys(challengeData).sort()) {
try {
const authAdapter = req.config.authDataManager.getValidatorForProvider(provider);
if (!authAdapter) {
continue;
}
const {
adapter: { challenge },
} = authAdapter;
if (typeof challenge === 'function') {
const providerChallengeResponse = await challenge(
challengeData[provider],
authData && authData[provider],
req.config.auth[provider],
request
);
acc[provider] = providerChallengeResponse || true;
}
} catch (err) {
const e = resolveError(err, {
code: Parse.Error.SCRIPT_FAILED,
message: 'Challenge failed. Unknown error.',
});
const userString = req.auth && req.auth.user ? req.auth.user.id : undefined;
logger.error(
`Failed running auth step challenge for ${provider} for user ${userString} with Error: ` +
JSON.stringify(e),
{
authenticationStep: 'challenge',
error: e,
user: userString,
provider,
}
);
throw e;
}
}
return { response: { challengeData: acc } };
}

mountRoutes() {
this.route('GET', '/users', req => {
return this.handleFind(req);
@@ -493,6 +657,9 @@ export class UsersRouter extends ClassesRouter {
this.route('GET', '/verifyPassword', req => {
return this.handleVerifyPassword(req);
});
this.route('POST', '/challenge', req => {
return this.handleChallenge(req);
});
}
}

1 change: 1 addition & 0 deletions src/cloud-code/Parse.Cloud.js
Original file line number Diff line number Diff line change
@@ -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.