feat: Improve authentication adapter interface to support multi-factor authentication (MFA), authentication challenges, and provide a more powerful interface for writing custom authentication adapters (#8156)

This commit is contained in:
dblythy
2022-11-11 03:35:39 +11:00
committed by GitHub
parent 4eb5f28b04
commit 5bbf9cade9
20 changed files with 2391 additions and 264 deletions

View File

@@ -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();

File diff suppressed because it is too large Load Diff

View File

@@ -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.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 () => {
@@ -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 () => {

View File

@@ -13,6 +13,63 @@ const passwordCrypto = require('../lib/password');
const Config = require('../lib/Config');
const cryptoUtils = require('../lib/cryptoUtils');
describe('allowExpiredAuthDataToken option', () => {
it('should accept true value', async () => {
const logger = require('../lib/logger').logger;
const logSpy = spyOn(logger, 'warn').and.callFake(() => {});
await reconfigureServer({ allowExpiredAuthDataToken: true });
expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(true);
expect(
logSpy.calls
.all()
.filter(
log =>
log.args[0] ===
`DeprecationWarning: The Parse Server option 'allowExpiredAuthDataToken' default will change to 'false' in a future version.`
).length
).toEqual(0);
});
it('should accept false value', async () => {
const logger = require('../lib/logger').logger;
const logSpy = spyOn(logger, 'warn').and.callFake(() => {});
await reconfigureServer({ allowExpiredAuthDataToken: false });
expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(false);
expect(
logSpy.calls
.all()
.filter(
log =>
log.args[0] ===
`DeprecationWarning: The Parse Server option 'allowExpiredAuthDataToken' default will change to 'false' in a future version.`
).length
).toEqual(0);
});
it('should default true', async () => {
const logger = require('../lib/logger').logger;
const logSpy = spyOn(logger, 'warn').and.callFake(() => {});
await reconfigureServer({});
expect(Config.get(Parse.applicationId).allowExpiredAuthDataToken).toBe(true);
expect(
logSpy.calls
.all()
.filter(
log =>
log.args[0] ===
`DeprecationWarning: The Parse Server option 'allowExpiredAuthDataToken' default will change to 'false' in a future version.`
).length
).toEqual(1);
});
it('should enforce boolean values', async () => {
const options = [[], 'a', '', 0, 1, {}, 'true', 'false'];
for (const option of options) {
await expectAsync(reconfigureServer({ allowExpiredAuthDataToken: option })).toBeRejected();
}
});
});
describe('Parse.User testing', () => {
it('user sign up class method', async done => {
const user = await Parse.User.signUp('asdf', 'zxcv');
@@ -1129,7 +1186,7 @@ describe('Parse.User testing', () => {
this.synchronizedExpiration = authData.expiration_date;
return true;
},
getAuthType: function () {
getAuthType() {
return 'facebook';
},
deauthenticate: function () {
@@ -1158,7 +1215,7 @@ describe('Parse.User testing', () => {
synchronizedAuthToken: null,
synchronizedExpiration: null,
authenticate: function (options) {
authenticate(options) {
if (this.shouldError) {
options.error(this, 'An error occurred');
} else if (this.shouldCancel) {
@@ -1167,7 +1224,7 @@ describe('Parse.User testing', () => {
options.success(this, this.authData);
}
},
restoreAuthentication: function (authData) {
restoreAuthentication(authData) {
if (!authData) {
this.synchronizedUserId = null;
this.synchronizedAuthToken = null;
@@ -1179,10 +1236,10 @@ describe('Parse.User testing', () => {
this.synchronizedExpiration = authData.expiration_date;
return true;
},
getAuthType: function () {
getAuthType() {
return 'myoauth';
},
deauthenticate: function () {
deauthenticate() {
this.loggedOut = true;
this.restoreAuthentication(null);
},
@@ -1792,7 +1849,7 @@ describe('Parse.User testing', () => {
});
});
it('should allow login with old authData token', done => {
it('should allow login with expired authData token by default', async () => {
const provider = {
authData: {
id: '12345',
@@ -1813,22 +1870,42 @@ describe('Parse.User testing', () => {
};
defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('token');
Parse.User._registerAuthenticationProvider(provider);
Parse.User._logInWith('shortLivedAuth', {})
.then(() => {
// Simulate a remotely expired token (like a short lived one)
// In this case, we want success as it was valid once.
// If the client needs an updated one, do lock the user out
defaultConfiguration.auth.shortLivedAuth.setValidAccessToken('otherToken');
return Parse.User._logInWith('shortLivedAuth', {});
})
.then(
() => {
done();
},
err => {
done.fail(err);
}
);
await Parse.User._logInWith('shortLivedAuth', {});
// Simulate a remotely expired token (like a short lived one)
// In this case, we want success as it was valid once.
// If the client needs an updated 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();