Parse-server: Logged-in user linked with Facebook loses Facebook link when migrating to Parse Server

Created on 30 Dec 2016  Â·  22Comments  Â·  Source: parse-community/parse-server

Issue Description

On iOS client (iOS Parse v1.14.2, ParseFacebookUtilsV4 - 1.11.1), user with existing and valid session created through Parse.com will lose Facebook authData when a specific CloudCode is invoked.

Steps to reproduce

On server, when initiating Parse Server:

var api = new ParseServer({
   auth: {facebook: {appIds : [FACEBOOK_APP_ID]}},
   .......
});

On iOS client, a user with valid session created by log in on Parse.com. Call the following CloudCode:

Parse.Cloud.define('functionA', function(request, response) {
    var currentUser = request.user;
    var token = currentUser ? currentUser.getSessionToken() : null;

    if (!currentUser || !token) {
        response.error(JSON.stringify({
            code: kcError.ErrorCodes.ERROR_UNAUTHENTICATED,
            message: 'Unauthenticated user.'
        }));
        return;
    }

    if (!Parse.FacebookUtils.isLinked(currentUser)) {
        response.error(JSON.stringify({
            code: kcError.ErrorCodes.ERROR_FB_NOT_LINKED,
            message: 'Current user is not linked with Facebook account.'
        }));
        return;
    }

    if (currentUser.get('email') && currentUser.get('facebookId')) {
        response.success();
    }

    Parse.Cloud.httpRequest({
        url:'https://graph.facebook.com/me?fields=email,id&access_token=' + currentUser.get('authData').facebook.access_token
    }).then(function(httpResponse) {
        if (_.isString(httpResponse.data.email) && httpResponse.data.email.length) {
            if (!currentUser.has('email') || !currentUser.get('email').length) {
                currentUser.set('email', httpResponse.data.email);
            }
        }
        currentUser.set('facebookId', httpResponse.data.id);
        currentUser.save(null, {useMasterKey: true}).then(function(savedUser) {
            response.success(savedUser);
        }, function(error) {
            if (error.code === Parse.Error.EMAIL_TAKEN) {
                currentUser.unset('email');
                currentUser.save(null, {useMasterKey: true}).then(response.success, response.error);
            }
            else {
                response.error(error);
            }
        });
    }, function(httpResponse) {
        response.error(JSON.stringify({
            code: kcError.ErrorCodes.ERROR_FB_GRAPH_HTTP_REQUEST_ERROR,
            message: 'Error interacting with FB API.'
        }));
        return;
    });
});

Expected Results

A successful CloudCode response, with authData of current user unchanged.

Actual Outcome

User's authData is set to null by iOS client.

Environment Setup

  • Server

    • parse-server version (Be specific! Don't say 'latest'.) : 2.3.1
    • Localhost or remote server? (AWS, Heroku, Azure, Digital Ocean, etc): Heroku
  • Database

    • MongoDB version: 3.0.11
    • Storage engine: MMAPv1
    • Localhost or remote server? (AWS, mLab, ObjectRocket, Digital Ocean, etc): Remote on mLab, shared plan

-Facebook Develpor Console

  • Client OAuth Login: true

-iOS Client

  • Parse, 1.14.2
  • ParseFacebookUtilsV4, 1.11.1
  • FBSDKCoreKit, 4.18.0
  • FBSDKLoginKit, 4.18.0
  • iOS 10.2 (14C92)

Logs/Trace

On Server, with VERBOSE=1 ("*" is data removed):

165 <190>1 2016-12-29T22:11:06.716108+00:00 app web.1 - - verbose: REQUEST for [PUT] /parse/classes/_User/nTvnxNY75y: {
109 <190>1 2016-12-29T22:11:06.716123+00:00 app web.1 - - "authData": {
114 <190>1 2016-12-29T22:11:06.716124+00:00 app web.1 - - "facebook": null
97 <190>1 2016-12-29T22:11:06.716124+00:00 app web.1 - - }
988 <190>1 2016-12-29T22:11:06.716126+00:00 app web.1 - - } method=PUT, url=/parse/classes/_User/nTvnxNY75y, host=.herokuapp.com, connection=close, x-parse-client-version=i1.14.2, accept=/, x-parse-session-token=r:, x-parse-application-id=, x-parse-client-key=, x-parse-installation-id=b349253d-56ba-4773-ad5e-cfd3e0e1bf8c, x-parse-os-version=10.2 (14C92), accept-language=en-ca, accept-encoding=gzip, deflate, content-type=application/json; charset=utf-8, user-agent=/103 CFNetwork/808.2.16 Darwin/16.3.0, x-parse-app-build-version=103, x-parse-app-display-version=0.12.1.4.sandbox, x-request-id=4dce259f-28d4-4857-a8e1-cfd6863947e6, x-forwarded-for=, x-forwarded-proto=https, x-forwarded-port=443, via=1.1 vegur, connect-time=1, x-request-start=1483049466637, total-route-time=0, content-length=30, facebook=null
161 <190>1 2016-12-29T22:11:06.746435+00:00 app web.1 - - info: beforeSave triggered for _User for user nTvnxNY75y:
915 <190>1 2016-12-29T22:11:06.746439+00:00 app web.1 - - Input: {"updatedAt":"2016-12-29T22:11:06.457Z","_perishable_token":"","createdAt":"2016-12-29T20:55:57.527Z","authData":{"facebook":null}}
233 <190>1 2016-12-29T22:11:06.746440+00:00 app web.1 - - Result: {"object":{"authData":{"facebook":null}}} className=_User, triggerType=beforeSave, user=nTvnxNY75y
160 <190>1 2016-12-29T22:11:06.774477+00:00 app web.1 - - info: afterSave triggered for _User for user nTvnxNY75y:
940 <190>1 2016-12-29T22:11:06.774481+00:00 app web.1 - - Input: {"updatedAt":"2016-12-29T22:11:06.732Z","_perishable_token":"
","createdAt":"2016-12-29T20:55:57.527Z","authData":{"facebook":null},"objectId":"nTvnxNY75y"} className=_User, triggerType=afterSave, user=nTvnxNY75y
167 <190>1 2016-12-29T22:11:06.775020+00:00 app web.1 - - verbose: RESPONSE from [PUT] /parse/classes/_User/nTvnxNY75y: {
109 <190>1 2016-12-29T22:11:06.775022+00:00 app web.1 - - "response": {
137 <190>1 2016-12-29T22:11:06.775023+00:00 app web.1 - - "updatedAt": "2016-12-29T22:11:06.732Z"
97 <190>1 2016-12-29T22:11:06.775024+00:00 app web.1 - - }
130 <190>1 2016-12-29T22:11:06.775024+00:00 app web.1 - - } updatedAt=2016-12-29T22:11:06.732Z

On iOS client,
-[__NSDictionaryM length]: unrecognized selector sent to instance 0x17025df10

On catching the above exception, it was discovered that line 53 of PFFacebookPrivateUtilities.m causes the exception (see below). expirationDataString is null, because in authData "expiration_date" has a value of empty dictionary (0 key/value pairs). See a screenshot of the stack trace here: http://imgur.com/a/6iV8r.

The method is called several times when the app is starting. Only when called with/before/after the Facebook Graph Request will encounter such problem.

+ (nullable FBSDKAccessToken *)facebookAccessTokenFromUserAuthenticationData:(nullable NSDictionary<NSString *, NSString *> *)authData {
    NSString *accessToken = authData[@"access_token"];
    NSString *expirationDateString = authData[@"expiration_date"];
    if (!accessToken || !expirationDateString) {
        return nil;
    }

    NSDate *expirationDate = [[NSDateFormatter pffb_preciseDateFormatter] dateFromString:expirationDateString]; // <- This is line 53.
    FBSDKAccessToken *token = [[FBSDKAccessToken alloc] initWithTokenString:accessToken
                                                                permissions:nil
                                                        declinedPermissions:nil
                                                                      appID:[FBSDKSettings appID]
                                                                     userID:authData[@"id"]
                                                             expirationDate:expirationDate
                                                                refreshDate:nil];
    return token;
}

This is a rather serious issue for my app as it could lead to user losing access to their account completely and have no way to retrieve it back. Please let me know what more I can provide or do to help solve this. I will see if I can reproduce this with Android client.

stale

Most helpful comment

The expiration_date storage format may have change in between parse.com and parse-server by mistake.

It seems that the SDK's only understand the string version of the date, and that we don't enforce that format upon pulling the object from the DB. I'll try to have a look.

A simple fix would be to run a mongodb commande to update all those fields.

All 22 comments

Can't seem to reproduce it on Android client.

By looking at data in mLab directly, I found a slight discrepancy for authData created by Parse.Com and Parse Server:

Created by Parse.Com
"_auth_data_facebook": {
"access_token": “ACCESS-TOKEN”,
"expiration_date": {
"$date": "2017-02-27T21:51:13.134Z"
},
"id": "USER-FACEBOOK-ID"
}

Created by Parse Server
"_auth_data_facebook": {
"id": “USER-FACEBOOK-ID”,
"access_token": "ACCESS-TOKEN",
"expiration_date": "2017-02-27T21:51:13.680Z"
}

Could this have led to the problem?

Upon further investigation, it's confirmed that the Graph Requests is NOT what caused the problem. In the call back of the Graph Request, a CloudCode was called and it's this CloudCode that triggered the problem:

Parse.Cloud.define('functionA', function(request, response) {
    var currentUser = request.user;
    var token = currentUser ? currentUser.getSessionToken() : null;

    if (!currentUser || !token) {
        response.error(JSON.stringify({
            code: kcError.ErrorCodes.ERROR_UNAUTHENTICATED,
            message: 'Unauthenticated user.'
        }));
        return;
    }

    if (!Parse.FacebookUtils.isLinked(currentUser)) {
        response.error(JSON.stringify({
            code: kcError.ErrorCodes.ERROR_FB_NOT_LINKED,
            message: 'Current user is not linked with Facebook account.'
        }));
        return;
    }

    if (currentUser.get('email') && currentUser.get('facebookId')) {
        response.success();
    }

    Parse.Cloud.httpRequest({
        url:'https://graph.facebook.com/me?fields=email,id&access_token=' + currentUser.get('authData').facebook.access_token
    }).then(function(httpResponse) {
        if (_.isString(httpResponse.data.email) && httpResponse.data.email.length) {
            if (!currentUser.has('email') || !currentUser.get('email').length) {
                currentUser.set('email', httpResponse.data.email);
            }
        }
        currentUser.set('facebookId', httpResponse.data.id);
        currentUser.save(null, {useMasterKey: true}).then(function(savedUser) {
            response.success(savedUser);
        }, function(error) {
            if (error.code === Parse.Error.EMAIL_TAKEN) {
                currentUser.unset('email');
                currentUser.save(null, {useMasterKey: true}).then(response.success, response.error);
            }
            else {
                response.error(error);
            }
        });
    }, function(httpResponse) {
        response.error(JSON.stringify({
            code: kcError.ErrorCodes.ERROR_FB_GRAPH_HTTP_REQUEST_ERROR,
            message: 'Error interacting with FB API.'
        }));
        return;
    });
});

So the problem is further reduced to calling any CloudCode that modified the currentUser and return it in the result:

Parse.Cloud.define('saveUser', function(request, response) {
    var currentUser = request.user;
    var token = currentUser ? currentUser.getSessionToken() : null;
    currentUser.set('displayName', 'Test It');
    currentUser.save(null, {sessionToken:token}).then(response.success, response.error);
});

Saving use both masterKey and sessionToken have the same problem.

Confirmed that if I change the "expiration_date" field in mongoDB directly, from:

"expiration_date": {"$date": "2017-02-27T21:51:13.134Z"}
to
"expiration_date": "2017-02-27T21:51:13.680Z"

the problem is gone. Hope this help with narrowing down where the root cause is.

@flovilmart , wondering if you could take a look at this? I tried to find the problem myself but wasn't successful.

Confirmed, just ran into the same problem. Two users, both originally created on hosted Parse.com (in a development environment). One has been successfully jumping back and forth between Parse.com and parse-server. The other just tried to login in to parse-server for the first time after months of inactivity and gets stuck in a failed Facebook Oauth loop.

User that can login and out:

"_auth_data_facebook": {
        "access_token": "EAA...",
        "expiration_date": {
            "$date": "2016-11-14T21:47:16.063Z"
        },
        "id": "10...."
},

User that fails login:

"_auth_data_facebook": {
        "access_token": "EAA..."
        "expiration_date": "2017-03-11T01:58:52.842Z",
        "id": "10...",
},

Both users were originally created in the DB over a year ago.

I queried my production database and about 100 users out of many many thousands have this different date format where it is a string (JSON/ISO-8601 format) instead of a BSON date type. Is it possible that hosted Parse or the iOS SDK had a bug or changed formats at some point and the hosted server was updated to handle this case? I am trying to determine if parse-server needs a patch or if I should just migrate the data of the affected users, so I want to know if this might happen again in the future on some random client.

@flovilmart Quick ping again. Would really appreciate if you could take a look at this. IMHO, this probably is a rather serious bug, as it could result in some users losing access or link to their account permantently.

The expiration_date storage format may have change in between parse.com and parse-server by mistake.

It seems that the SDK's only understand the string version of the date, and that we don't enforce that format upon pulling the object from the DB. I'll try to have a look.

A simple fix would be to run a mongodb commande to update all those fields.

@flovilmart Thanks for the confirmation! Now let's see if I could get that mongodb command working. Will share it here if I could get it working.

@promisenxu @flovilmart I just want to confirm which is the correct format. It seems the mongodb BSON date objects work for me and the ISO-8601 date strings do not work. It looks like @promisenxu had the same experience. It is clear that the client is expecting a string when reading authData but I am assuming the date gets serialized to a string before it is returned as JSON over the wire. Writing a migration is pretty easy but I want to make sure the date is in the correct format so it's still important to pinpoint the exact failure point.

So the REST format (over the wire) should be a string, internally, it doesn't really matter, but what's the format you have on migrated objects? This way we keep everything consistant

@emkman Hmm on reading your previous post again, it seems that we are having the exact opposite problem? To clarify:

"expiration_date": {"$date": "2017-02-27T21:51:13.134Z"} does not work for me. This is also what the data currently looks like, if the Facebook authentication is created with Parse.com.
"expiration_date": "2017-02-27T21:51:13.680Z" works for me.

Interesting. Most of my users have it stored in this format:

"_auth_data_facebook": {
        "access_token": "EAA...",
        "expiration_date": {
            "$date": "2016-11-14T21:47:16.063Z"
        },
        "id": "10...."
},

A small percentage (0.11%) of users have it stored in this format:

"_auth_data_facebook": {
        "access_token": "EAA..."
        "expiration_date": "2017-03-11T01:58:52.842Z",
        "id": "10...",
},

and they have issues logging in via parse-server. There are users going back with _updated_at as old as 4/15/16 with this format, well befor parse-server.

@emkman what format work?

@flovilmart I just wrote scripts to switch the data between both formats and they both seem to work as the date ends up as a string when the iOS SDK reads it. This is what the client expects. @promisenxu had the expiration_date key in authData coming back as an empty dictionary causing the crash.

I actually can't reproduce the issue any more. One guess is it may only happen when the user has an expired token but I am really not sure. And further I still have no idea what lead to the two different formats in the DB.

If anyone needs any help in writing the script to change the expiration_date format from:
"expiration_date": {"$date": "2017-02-27T21:51:13.134Z"}
to:
"expiration_date": "2017-02-27T21:51:13.680Z"

Save the following code as a mongo_script.js file locally:

db.getCollection("_User").find(
    {"_auth_data_facebook" : {$exists:true},
    "_auth_data_facebook" :{$ne: null},
    "_auth_data_facebook.expiration_date" : {$type:9}})
.snapshot().forEach(
    function (e) {
        var currentDate = e["_auth_data_facebook"]["expiration_date"];
        e["_auth_data_facebook"]["expiration_date"] = currentDate.toISOString();
        db.getCollection("_tempUser").save(e);
    }
);

Run the above script in your terminal with:
mongo [***Link to your MongoDB database***] -u [A username for your database] -p [its password] mongo_script.js

Now go into your mongoDB management console. Look for a collection called "_tempUser". Verify that the changes are correct (you will only see users that need to be changed in this collection).

If everything works as planned, change the last line of the above script to:
db.getCollection("_User").save(e);
and saved. Run the script again. And we are done!

Oh, and don't forget to delete the collection "_tempUser" when you are done.

Wow. Thanks for that script!

Not sure what we should do on this side as I still not sure what format was ok and which one isn't :)

@flovilmart

On my side,
This is the format that will result in user losing their Facebook authentication:
"expiration_date": {"$date": "2017-02-27T21:51:13.134Z"}

This is the format that has no problem, and it's also the format that the Facebook authentication will be in, if created through Parse Server:
"expiration_date": "2017-02-27T21:51:13.680Z"

and if you do need to go back to the other version you can just replace

var currentDate = e["_auth_data_facebook"]["expiration_date"];
e["_auth_data_facebook"]["expiration_date"] = currentDate.toISOString();

with

var currentDate = e["_auth_data_facebook"]["expiration_date"];
e["_auth_data_facebook"]["expiration_date"] = new Date(currentDate);

Thanks for the script @promisenxu

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

omyen picture omyen  Â·  3Comments

jaydeep82 picture jaydeep82  Â·  4Comments

dpaid picture dpaid  Â·  3Comments

kilabyte picture kilabyte  Â·  4Comments

lorki picture lorki  Â·  3Comments