Skip to content

Instantly share code, notes, and snippets.

@ernie58
Last active May 7, 2018 08:12
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save ernie58/09cd5c065ae44e1651be08179cce49cf to your computer and use it in GitHub Desktop.
Save ernie58/09cd5c065ae44e1651be08179cce49cf to your computer and use it in GitHub Desktop.
Loopback Passport Component: keep UserIdentity in sync with UserCredentials
module.exports = function (PassportUserCredential) {
/*
* Check if credentials already exist for a given provider and external id
* Enable this hook if credentials can be linked only once
*
* @param Loopback context object
* @param next middleware function
* */
PassportUserCredential.observe('before save', function checkPassportUserCredentials(ctx, next){
//new insert - see if it is used else where
if(ctx.isNewInstance === true && ctx.instance){ //indicates a new insert
var filter = {where: { provider: ctx.instance.provider, externalId: ctx.instance.externalId }};
PassportUserCredential.findOne(filter, function(err, userCredential){
if(err) return next(err);
if(userCredential){
err = new Error('Credentials already linked');
err.code = 'Validation Error';
err.statusCode = 422;
return next(err);
} else {
//allow proceed
return next();
}
});
} else {
// don't allow updates on provider and external ID
if(ctx.instance){
delete ctx.instance.externalId;
delete ctx.instance.provider;
} else if(ctx.data){
delete ctx.data.externalId;
delete ctx.data.provider;
}
next();
}
});
/*
* Keep user identities in sync after saving a user-credential
* It checks if a UserIdentityModel with the same provider and external ID exists
* It assumes that the providername of userIdentity has suffix `-login`and of userCredentials has suffix `-link`
*
* @param Loopback context object
* @param next middleware function
* */
PassportUserCredential.observe('after save', function checkPassportUserIdentities(ctx, next){
var data = JSON.parse(JSON.stringify(ctx.instance));
data.provider = data.provider.replace('-link', '-login');
delete data.id; // has to be auto-increment
var PassportUserIdentity = PassportUserCredential.app.models.PassportUserIdentity;
var filter = {where: { provider: data.provider, externalId: data.externalId }};
PassportUserIdentity.findOrCreate(filter, data, next);
});
};
module.exports = function (PassportUserIdentity) {
/*
* Keep user credentials in sync after saving a user-identity
* It checks if a UserCredentialModel with the same provider and external ID exists for that user
* It assumes that the providername of userIdentity has suffix `-login`and of userCredentials has suffix `-link`
*
* @param Loopback context object
* @param next middleware function
* */
PassportUserIdentity.observe('after save', function checkPassportUserCredentials(ctx, next){
var data = JSON.parse(JSON.stringify(ctx.instance));
data.provider = data.provider.replace('-login', '-link');
delete data.id; // has to be auto-increment
var PassportUserCredential = PassportUserIdentity.app.models.PassportUserCredential;
var filter = {where: { userId: data.userId, provider: data.provider, externalId: data.externalId }};
PassportUserCredential.findOrCreate(filter, data, next);
});
};
var app = require('../../server');
var assert = require('assert');
var loopback = require('loopback');
before(function changeDataSourceToMemory(done) {
var db = app.dataSources.db;
loopback.configureModel(loopback.getModel('PassportUserCredential'), {dataSource: db});
loopback.configureModel(loopback.getModel('PassportUserIdentity'), {dataSource: db});
loopback.configureModel(loopback.getModel('Person'), {dataSource: db});
if (db.connected) {
db.automigrate(addUser);
} else {
db.once('connected', function () {
db.automigrate(addUser);
});
}
function addUser() {
app.models.Person.create({
'id': 1,
'name': 'John',
'firstName': 'Doe',
'created': new Date(),
'gender': 'M',
'type': 'teacher',
'username': 'john-doe',
'password': 'testme',
'email': 'john-doe@mailinator.com'
}, done);
}
});
describe('Sync credentials when adding identities', function () {
var user;
var loginProvider = 'google-login';
var linkProvider = 'google-link';
var dummyLoginData = {
'provider': loginProvider,
'authScheme': 'oAuth 2.0',
'externalId': '1081943683967445545454654646465411',
'profile': {'info': 'some-provider-info'},
'credentials': {'accessToken': 'secret-token-from-google'}
};
var dummyLinkData = {
'provider': linkProvider,
'authScheme': 'oAuth 2.0',
'externalId': '1081943683967445545454654646465411',
'profile': {'info': 'some-provider-info'},
'credentials': {'accessToken': 'secret-token-from-google'}
};
before(function (done) {
app.models.Person.findOne({id: 1}, function (err, u) {
if (err) return done(err);
user = u;
dummyLoginData.userId = user.id;
done();
});
});
describe('IDENTITIES', function(){
describe('add new identity', function () {
it('should add the identity and credentials', function (done) {
addDataAndExpectOneIdentityAndCredential(dummyLoginData, app.models.PassportUserIdentity, done);
});
});
//https://github.com/strongloop/loopback-component-passport/issues/131
describe.skip('add existing identity', function () {
it('should do nothing on both tables', function (done) {
addDataAndExpectOneIdentityAndCredential(dummyLoginData, app.models.PassportUserIdentity, done);
});
});
//https://github.com/strongloop/loopback-component-passport/issues/131
describe.skip('add existing identity with no credentials yet', function () {
it('should not add identity but add credentials', function (done) {
app.models.PassportUserCredential.destroyAll({'provider': linkProvider}, function (err) {
if (err) return done(err);
addDataAndExpectOneIdentityAndCredential(dummyLoginData, app.models.PassportUserIdentity, done);
});
});
});
describe('add new identity with already existing credentials', function () {
it('should add identity but not add credentials', function (done) {
app.models.PassportUserIdentity.destroyAll({'provider': loginProvider}, function (err) {
if (err) return done(err);
addDataAndExpectOneIdentityAndCredential(dummyLoginData, app.models.PassportUserIdentity, done);
});
});
});
});
describe('CREDENTIALS', function(){
//reset tables
before(function(done){
app.models.PassportUserIdentity.destroyAll({'provider': loginProvider}, function (err) {
if (err) return done(err);
app.models.PassportUserCredential.destroyAll({'provider': linkProvider}, done);
});
});
describe('add new credential', function () {
it('should add the credential and identity', function (done) {
addDataAndExpectOneIdentityAndCredential(dummyLinkData, app.models.PassportUserCredential, done);
});
});
describe('add existing credential', function () {
it('should fail and return a Validation error', function (done) {
app.models.PassportUserCredential.create(dummyLinkData, function (err) {
if (!err) return done('it should fail');
assert.equal(err.code, 'Validation Error');
assert.equal(err.message, 'Credentials already linked');
app.models.PassportUserIdentity.find(
{externalId: dummyLinkData.externalId, provider: loginProvider},
function (err, identities) {
if (err) return done(err);
assert.equal(identities.length, 1);
//get credentials for this provider and externalId
app.models.PassportUserCredential.find(
{externalId: dummyLinkData.externalId, provider: linkProvider},
function (err, creds) {
if (err) return done(err);
assert.equal(creds.length, 1);
done();
});
});
});
});
});
describe('add new credential with existing identity', function () {
it('should fail and return a Validation error', function (done) {
app.models.PassportUserCredential.destroyAll({'provider': linkProvider}, function (err) {
if (err) return done(err);
addDataAndExpectOneIdentityAndCredential(dummyLinkData, app.models.PassportUserCredential, done);
});
});
});
});
function addDataAndExpectOneIdentityAndCredential(data, model, done) {
model.create(data, function (err, inst) {
if (err) return done(err);
assert.equal(inst.provider, data.provider);
assert.equal(inst.externalId, data.externalId);
app.models.PassportUserIdentity.find(
{externalId: data.externalId, provider: loginProvider},
function (err, identities) {
if (err) return done(err);
assert.equal(identities.length, 1);
//get credentials for this provider and externalId
app.models.PassportUserCredential.find(
{externalId: data.externalId, provider: linkProvider},
function (err, creds) {
if (err) return done(err);
assert.equal(creds.length, 1);
done();
});
});
});
}
});
@ernie58
Copy link
Author

ernie58 commented Apr 4, 2016

It assumes my models are called PassportUserCredential and PassportUserIdentity

passportConfigurator.setupModels({
    userModel: app.models.Person,
    userIdentityModel: app.models.PassportUserIdentity,
    userCredentialModel: app.models.PassportUserCredential
})

@lotas
Copy link

lotas commented Apr 12, 2016

@ernie58 Seems like loopback enters an endless loop here:
UC:after save -> save UI -> UI: after save -> save UC -> UC: after save ..

Can be fixed by adding extra option {skipAfterSave: true} to both models:

    UserIdentity.observe('after save', function checkuserCredentials(ctx, next) {
        if (ctx.options && ctx.options.skipAfterSave) return next();

        var data = JSON.parse(JSON.stringify(ctx.instance));

        data.provider = data.provider.replace('-login', '-link');
        delete data.id; // has to be auto-increment

        var userCredential = UserIdentity.app.models.userCredential;
        var filter = {where: {userId: data.userId, provider: data.provider, externalId: data.externalId}};
        userCredential.findOrCreate(filter, data, {skipAfterSave: true}, next);
    });

@ernie58
Copy link
Author

ernie58 commented Apr 13, 2016

Nice , I'll remember that trick.

It's strange however that you have an endless loop!
I purposely used findOrCreate, so the loop should stop once a model is found, because nothing gets saved then anymore

@michaelfreund
Copy link

@ernie58 @lotas I can confirm the loop. skipAfterSave solves it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment