Simplicity is the ultimate sophistication

Social Login With Cocoa and Cocoa Touch

| Comments

Why “Social Login”

In the evolution of social networks, from stand-alone websites to services to be integrated in other apps, we saw an evolution from simple “share my achievement” buttons (you want to share your high-score in a game with your friends or followers) to full login experiences where the social network is used as a gateway for user authentication before using any kind of service.
There are multiple reasons for this: the first one is that you want to leverage the existence of a sort of user identity verification done by these social networks, thus reducing the need to put in place your private account creation system (with all the risks related to privacy, data protection and in many cases a resistance from the user to give to an unknown developer her/his email address), but also because you can immediately take advantage of the friends and followers network to spread your service knowledge (“invite your friends” or “tweet about this app” features).

The Accounts and Social frameworks

Since iOS5 and OSX 10.8 (Mountain Lion) Apple introduced a couple of frameworks dedicated to account management and social network interactions. The Accounts Framework is the API that Apple introduced to provide a single sign-on model for certain user accounts: in this way users can simply input their account credentials once (typically in the Settings or Preferences panel of their device) and then grant access to the applications that require access to these accounts.
Accounts is usually paired with its Social Framework sibling that provides a simple interface to interact with the framework API: in most of the cases these APIs require the user authentication and that’s why this framework cannot be used without its Accounts counterpart. The great advantage of the Social framework is that the complex methodology the social networks use to build their authenticated requests is completely hidden to the developer which has access to a really simple and straightforward API (this API is a base-level API: this means that it doesn’t provide high-level access to the complex API provided by the single networks: you, as a developer, must take care of this).

A simple Social Login service

In this article I write about a simple Social Login Service I built on top of the Accounts framework to facilitate social login. Even if the Accounts API is quite simple and straight-forward some details need to be considered for a fully working social login experience.
My building block is called LoginService: note that this is neither a library nor a framework but just a simple dedicated application of a framework. I call it a “service” because it has been designed to be added ad a module in your application just in case you need to provide the specific functionality of a “social login” service. My example code will provide a simple iOS user interface to interact with this service. It also provides, for completeness, a minimum Social framework usage to validate the account creation: note that the Social framework calls are not contained in the LoginService which is based on the Accounts framework only. Also note that even if the example is for iOS8 the SocialLogin class can be reused without any change for Mac OSX.

A look at the Accounts framework

Internally the Accounts framework manages its internal data structure using a Core Data model kept persistent via a SQLite database. However the developer has no access to this object network and can see only a high-level API to retrieve a small subset of the stored data: this is required because the role of the Accounts framework is to shield the private data provided to the system by the user (login credentials) from the specific application.
I did some reverse engineering by looking at the Core Data mom file contained in the Accounts.framework bundle and decoding it using the Core Data Managed Object Model Decompiler (a.k.a. “momdec”). I’m not going to post the results of this activity, I’m not allowed to do it, but the Accounts framework provided classes mirror very well the corresponding entities in the model.

The starting point is the ACAccountStore class which provides the interface to the accounts database: basically this high-level API works as an interface to the internal managed object context used to manipulate the object graph. Once you have created your ACAccountStore instance you can define an AccountType you want to work with, using the ACAccountType object. Note that the choice of possible account types is currently hard-coded, and is limited to Facebook, Twitter, Sina Weibo and Tencent Weibo. However the Core Data structure is generic enough in my opinion that in the future it will be easy for Apple to support new services or to give developers the possibility to extend the system with their own account systems; e.g. the header files in the framework already contain references to the LinkedIn network even if at the moment LinkedIn is still not officially supported.
Once you have defined your account type then you can retrieve the list of Account elements, represented by the ACAccount class: this provides user the user “screen name” but it doesn’t provide the user identifier which is stored in the CoreData model and could be retrieved from the ACAccount using a specific key-path: however these key-paths (and many others) are not documented so their usage is not recommended as your application will become too dependent on internal representations and it could easily break in the future. Just as en example, you can retrieve the Facebook user id (which is application dependent), using this code (note the try-catch to eventually trap the exception):

1
2
3
4
5
6
@try {
   userIdentifier  = [(NSNumber *)[account valueForKeyPath:@"properties.uid"] stringValue];
}
@catch (NSException *ex) {
   userIdentifier = account.identifier;
}

So we have an AccountType object, represented by the corresponding ACAccount class: the critical point in this is that in order to get access to this piece of information the user must explicitly grant it to the application. This means that if you try to retrieve the list of registered accounts for a certain account type you will not be able to do it (a “nil” result will be returned) if you don’t explicitly ask for permission: as soon as you do it the user will be prompted to grant access to the specific account type and this grant will be limited to your application only and can be revoked from the Privacy settings at any time. The authorization status is stored in the Authorization entity of the Accounts database (together with the Bundle ID of the application and the list of granted permissions): note that this entity has not a counterpart in the public Accounts header.

The access permission flow (and its variants)

The idea of our Social Login service is to provide three basic functionalities:

  • Login: you type the “Login” button and the service will provide the access for you
  • Persistence: the service should keep track of the login status (“in” or “out”) so that every time you restart your app you don’t need to redo the login step again
  • Logout: you type the “Logout” button and the service will forget the login status, so the app can return to the login page.

These functionalities are respectively represented by the three main public functions in the login service API:

1
2
3
- (void)loginWithService:(LoginServiceType)serviceType completion:(LoginServiceAnswerBlock)completionBlock;
- (BOOL)isLoggedIn;
- (void)logout;

The most critical one is the login one as this requires the interaction with the access permission flow. It is completely block-based due to the fact that the authorization process is asynchronous. What the service does is to wrap the completion blocks required by the Accounts framework in a high-level completion block that tries to differentiate the most critical cases. So the service login API is defined in this way:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef NS_ENUM(NSInteger, LoginServiceType) {
	LoginServiceTypeTwitter = 0,
	LoginServiceTypeFacebook = 1
};

typedef NS_ENUM(NSInteger, LoginServiceAnswer) {
	LoginServiceAnswerUndefined = -1,
	LoginServiceAnswerAccessNotGranted = 0,
	LoginServiceAnswerNoAccounts = 1,
	LoginServiceAnswerInvalidAccount = 2,
	LoginServiceAnswerValid = 3,
	LoginServiceAnswerOtherError = 4,
	LoginServiceAnswerMultipleAccounts = 5,

};

typedef void(^LoginServiceAnswerBlock)(LoginServiceAnswer answer,NSError *error);

+ (instancetype)service;

- (void)loginWithService:(LoginServiceType)serviceType completion:(LoginServiceAnswerBlock)completionBlock;

The service is instantiated as a singleton and accessible using the class method +service and the login gateway is provided by the -loginWithService:completion: method which accepts as input the service type (in my analysis I worked with the “western” services, that is Twitter and Facebook; I think Chinese developers can extend it for the Weibo networks) and provide their output as a callback which accepts two arguments: a LoginServiceAnswer value and an error object. The interesting part in this code is the service answer which is different from the simple “granted/non-granted” boolean result returned by the Accounts framework. What I do instead in my code is to differentiate the most interesting cases: access not granted (the user explicitly rejected access or just switched off your app from the Privacy settings), no accounts defined, invalid account, multiple accounts and finally valid login (which is the only “success” value). I defined these possible cases because they are relevant for the user interface which will react differently based on the return output. Specifically, if the access has not been granted you might provide a nice UI to the user explaining why the access is needed and how to restore it (“go to Privacy settings”) or if there are multiple accounts (this applies to Twitter) than you must provide a UI to allow the user to pick the account she/he wants to login with, and so on.

The central part of the login code is this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
- (void)loginWithService:(LoginServiceType)serviceType completion:(LoginServiceAnswerBlock)completionBlock {

	// retrieve the account type
	// ...
	
	// create granted block
	void(^grantedBlock)() = ^() {
		NSArray *accounts = [self.accountStore accountsWithAccountType:accountType];
		if(accounts.count==0) {
			completionBlock(LoginServiceAnswerNoAccounts,nil);
		} else if(accounts.count==1) {
			self.currentAccount = accounts.lastObject;
			completionBlock(LoginServiceAnswerValid,nil);
		} else {
			NSMutableArray *tmpAccounts = [NSMutableArray new];
			for(ACAccount *account in accounts) {
				[tmpAccounts addObject:account.username];
			}
			self.multipleAccounts = [NSArray arrayWithArray:tmpAccounts];
			completionBlock(LoginServiceAnswerMultipleAccounts,nil);
		}

	};



	// ask for permission
	if(!accountType.accessGranted) {

		NSDictionary *options = nil;

		if([[accountType identifier] isEqualToString:ACAccountTypeIdentifierFacebook]) {
			NSArray *permissions = self.facebookBug?@[@"email",@"public_profile",@"user_friends"]:@[@"email"];
			options = @{
						ACFacebookAppIdKey:self.facebookAppId,
						ACFacebookPermissionsKey:permissions,
						ACFacebookAudienceKey:ACFacebookAudienceFriends
						};
		}

		[[self accountStore] requestAccessToAccountsWithType:accountType
													 options:options
												  completion:^(BOOL granted, NSError *error) {

								  if(granted) {

									  dispatch_async(dispatch_get_main_queue(),^{
										  grantedBlock();
									  });

								   } else {
									   LoginServiceAnswer answerCode;

									   if(error.code==ACErrorAccountNotFound) {
										   answerCode = LoginServiceAnswerNoAccounts; // this is the answer returned when there are no Facebook accounts configured
									   } else {
										   answerCode = LoginServiceAnswerAccessNotGranted;
									   }

									   dispatch_async(dispatch_get_main_queue(), ^{
										   completionBlock(answerCode,error);
									   });

								  }
							  }];

	} else {

		dispatch_async(dispatch_get_main_queue(),^{
			grantedBlock();
		});
	}

}

As you can see, the call to the ACAccountStore’s -requestAccessToAccountsWithType:options:completion: requires a completion block where the returned YES/NO status and the error blocks need to be carefully evaluated in order to return the proper answer. During my analysis I have found a couple of discpepancies and openend the corresponding radars with Apple: the first discrepancy (radar #17900485) is related to the fact that if you try to ask the “public_profile”, “user_friends” and “email” Facebook permissions, which are the permissions officially stated by Facebook to be provided by default (so no further authorization is required), the Accounts framework will still return a non-granted answer: in order to have it working you must specify the “email” permission only and this is misleading. The second bug (#17900553) is related to the fact that if you have no Twitter accounts then the system will still ask for access and, if granted, it will return 0 accounts, while in Facebook if no account is defined then the “non-granted” answer will be returned without explicit request to the user, with error code 7 (“no account defined”); the good thing is that the non-granted answer will not be permanently associated to your Bundle ID so as soon as the user creates the account the access request will be displayed: however I have asked to Apple to fix the API and provide a consistent behaviour between the two cases. In any case don’t worry: my service takes care of this. For further reference, you may look at the ACError.h file in the Accounts framework with a list of the possible errors that could be returned by the framework.

Persistence, logout and notifications

In order to provide the isLoggedIn functionality, the LoginService class persists the logged-in account (you can optionally select if you want it to be saved in the user defaults or in a dedicated file; you can extend it by saving this data in iCloud if you want to provide a complete single sign-on experience across all devices!). A particular attention should be done in such case: the ACAccount class provides an identifier property which allows to retrieve the ACAccount object associated to it using the ACAccountStore’s -accountWithIdentifier: method. I discourage to store this identifier and use it to restore the ACAccount: it couldn’t work as the identifier can change between multiple sing-in/sign-out of the same account from the system settings. So the best solution I found is to save the user name and the account type identifier and then check their validity when the -isLoggedIn method is called; this will protect you agains most cases apart one: when the user changes the username.
So the -isLoggedIn method will retrieve the saved data, will check your application still has access to the account type and finally will retrieve the saved account object: if these checks are all passed, then the function will return with a success. I think that this behaviour is the good way to operate: your app has to be polite towards the user and if the user revokes permission or sign-out from an account, your app should invalidate the login and then logout.

The -logout method is simple: it just delete the persistent account info and nil the current account information: the user will be forced to login again.

Finally a quick note on notifications: there should be at least a part of your application that should respond to the UIApplicationDidBecomeActiveNotification notification or just implement in your application delegate the -applicationDidBecomeActive: method: in this method you must check the logged-in status and if it the user didn’t login immediately redirect your application flow to the login page: as I stated in a previous paragraph, an application should operate politely with user accounts and if - for any reason - the user did sign-out you must at least inform the user or, if sign-in is strictly required for the app to operate correctly, present the login page again. You can do this in the example: login, then switch to the settings, delete the account and finally reenter in the app: you will see the “post” page will close and the app will return to the login page, as expected.

References

Comments