Getting users registered in your app is key to building engagement. Supporting social login gives a huge boost to conversion rates! Using Swift and some nifty software design patterns, you can structure multiple login flows in a clean, testable way that is easy to change later on.
Your registration screen is really important. You want to get as many users to register as possible, so the whole experience has to be simple and easy. This is why social login is cool: It’s a one- or two-click way to get users registered inside your app.
Supporting social login is a huge boost to conversion rates. Supporting both Facebook and Google login, alongside a regular registration flow, is even better. Managing multiple registration flows in code, however, can be pretty painful.
No matter which way your users try to login or register, there are often actions you want to perform after a successful login. You want to cache the user, save some credentials, perform a request to your backend etc.
This can very quickly lead to spaghetti code, where different login flows are coupled together and completely untestable. Especially if you didn’t plan to add social login from the start, it can quickly become a huge mess.
With the power of Swift and its protocol-oriented programming, we can think of a nice way to solve this problem. Let’s think about what our login flow actually is.
We want to grab the users info, send it to our backend for storage or validation, and receive some sort of authorization token. This is all that a login is.
The user’s info is different in each case: Facebook and Google return different authorization tokens, and regular registration grabs the user’s info in the form of an email and password.
When you think about it, the job of Facebook, Google and a regular registration form is to obtain an authorization token from your server. They all have the same output. This means they are nice candidates for an abstraction via a protocol.
protocol LoginProvider: AutoMockable { func login(completion: (LoginResult)-> Void) }
The login result is an enum that can either be successful or an error.
enum LoginResult { case success((User, LoginToken)) case failure(LoginError) } enum LoginError { case emailOrPasswordInvalid case noInternet case serverError case userCancelled case emailNotVerified }
The primary job of a LoginProvider
is to request a token from the specific service it uses, and to return it. In the case of an error, it needs to present the error in a way the rest of the application will understand.
The rest of the application doesn’t care how a user had registered or logged in, all it cares about is that there is an authorization token it can use.
Now that we have this abstraction, we can define our post-login or post-registration actions in a single place. We cache the user the same way no matter how he had logged in.
What’s good about this approach is that the rest of the app doesn’t know about the way a user logged in. This means we can easily change login providers and add new ones, with only additive changes to the code-base. In other words, we don’t need to make changes to the existing code to add new functionality.
This is often called the strategy pattern.
We’ll have three different providers: one for each login platform we are supporting. Google, Facebook and a native login flow.
Let’s start with our regular provider first.
A regular provider needs the user’s email and password to work. So we’ll instantiate it with those properties.
class AppLoginProvider: LoginProvider { private let email: String private let password: String private let dataService: DataServiceProtocol init(email: String, password: String, dataService: DataServiceProtocol) { self.email = email self.password = password self.dataService = dataService }
We’re also giving it a DataService
so it can make calls to our backend.
This depends heavily on your API, but in general a register/login method looks something like this.
class AppLoginProvier: LoginProvider { //... func login(completion: (LoginResult) -> Void) { guard password.characters.count >= 6 && email.isValidEmail else { completion(.failure(.emailOrPasswordInvalid)) return } dataService.performRequest(.login(email: email, password: password)) { result in case .success(let user, let token): completion(.success(user, token)) case .failure(let error): completion(.failure(.serverError)) } } }
We take the user’s input, and if it’s valid, we perform a network request. Once we get the user entity and the token, we’ll call completion and let our caller take care of the rest.
Speaking of the strategy pattern, one of the ways it’s often used is for validation. What we can do is to separate our string validation logic, like the email and password logic, into separate Validator structs.
A Validator is simply something that takes a type (it can be any type) and returns a Bool, depending on whether the type is valid or not.
struct Validator<T> { let validate: (T)-> Bool }
Since functions are first class citizens in Swift, we can have the validation be a function property.
We can then declare some validators we will use throughout our app. For instance, a validator that will make sure a string isn’t empty.
struct Validators { static var nonEmpty: Validator<String> { return Validator<String> { text in return !text.isEmpty } } }
Or, in the case of login validation, a validator that will make sure the email is in the correct format.
struct Validators { static var email: Validator<String> { return Validator<String> { text in let emailRegEx = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}" let emailTest = NSPredicate(format:"SELF MATCHES %@", emailRegEx) return emailTest.evaluate(with: text) } } }
This lets us reuse the validation logic in our app, thus preventing code duplication. It’s also a very simple struct with a single pure function, which means we can test it really easily. We just input a valid string and check that it returns true, and vice-versa.
With our validators in place, we can use them on the password and email in our login method.
class AppLoginProvier: LoginProvider { //... func login(completion: (LoginResult) -> Void) { guard Validators.password.validate(password) && Validators.email.validate(email) else { completion(.failure(.usernameOrPasswordInvalid)) return } //...
Facebook and Google login are a little bit more complex. Let’s start with Facebook.
In the case of Facebook, we first need to obtain a user token from Facebook.
For this, we’ll need a couple of things. First, a LoginManager
instance from the Facebook login kit. We’ll also need a UIViewController
on which to present the login screen.
class FacebookLoginProvider: LoginProvider { private let loginManager: LoginManager = LoginManager() private let viewController: UIViewController private let dataService: DataServiceProtocol init(_ viewController: UIViewController, dataService: DataServiceProtocol) { self.viewController = viewController self.dataService = dataService }
Once we have that, we can start the login flow, and ask for a token upon completion.
private func fetchToken( from viewController: UIViewController, onSuccess: (String)-> Void, onError: (LoginError)-> Void) { guard token == nil else { onSuccess(token!.authenticationToken) return } loginManager.logIn([.publicProfile, .email], viewController: viewController) { result in switch result { case .cancelled: onError(.userCancelled)) case .failed(let error): onError(.other(error))) case .success(_, _, let token): onSuccess(token.authenticationToken)) } } }
We’ll first check if a login token already exists. (i.e. the user is already logged in) In that case, we don’t really need to fetch a new one, we can just return the one we already have.
We’ll call the logIn
method on the LoginManager
, and handle the result.
We’ll then send that token to our backend, which will hopefully return us a user.
func login(completion: (LoginResult)-> Void) { guard dataService.isConnectedToInternet else { completion(.failure(.noInternet)) return } fetchToken(from: viewController) { result in switch result { case .success(let token): dataService.fetch(.login(facebookToken: token)) { [weak self] result in switch result { case .success(let user, let token): completion(.success(user, token)) case .failure(let error): completion(.failure(.serverError)) } } } case .failure(let error): completion(.failure(error)) } } }
Google follows a similar pattern. It also needs a UIViewController
to work.
class GoogleLoginProvider: NSObject, LoginProvider { fileprivate let presentingViewController: UIViewController private let dataService: DataServiceProtocol init(_ viewController: UIViewController, _ dataService: DataServiceProtocol) { self.presentingViewController = viewController self.dataService = dataService super.init() GIDSignIn.sharedInstance().delegate = self } }
However, Google’s API is a bit more unwieldy. It doesn’t offer a nice swifty API with blocks. It uses the delegate pattern instead. This means we can’t call the completion block inside our login method, so we’ll need to store it and call it later.
fileprivate var loginCompletionCallback: ((LoginResult)-> Void)? func login(completion: @escaping (LoginResult)-> Void) { self.loginCompletionCallback = completion GIDSignIn.sharedInstance().signIn() }
Here we are storing the login completion block, and starting the login process. In order to get notified about the result of the sign in process, we need to conform to GIDSignInDelegate
.
extension GoogleLoginProvider: GIDSignInDelegate { func sign(_ signIn: GIDSignIn!, didSignInFor user: GIDGoogleUser!, withError error: Error!) { guard error == nil else { let errorCode = (error as NSError).code if let signInError = GIDSignInErrorCode(rawValue: errorCode) { if case .canceled = signInError { loginCompletionCallback?(.failure(.userCancelled)) return } } loginCompletionCallback?(.failure(.serverError)) return } initiateLogin(withToken: user.authentication.idToken) } }
We are doing a bit of casting and dancing around with the error to check if the user has cancelled the login process, or there was an actual error. This is important because we don’t want to display an error to the user if they have decided to cancel the login process.
On the other hand, if we do manage to get the token, we will initiate a request to our backend, and call the stored completion block if it returns successfully.
fileprivate func initiateLogin(withToken token: String) { dataService.fetch(.login(googleToken: token)) { [weak self] result in switch result { case .success(let user, let token): loginCompletionCallback?(.success(user, token)) case .failure(let error): loginCompletionCallback?(.failure(.serverError)) } } }
Okay, we have our providers, but we still have no way of hooking them up.
This is where the magic happens: We can create a single LoginService
that will work with every provider. All we need is a login
method that will take a provider as a parameter. That method will call on the provider, and perform post-login actions.
struct LoginService { func login( with provider: LoginProvider, onError: (LoginError)-> Void, onSuccess: ()-> Void) { provider.login { [weak self] result in switch result { case .success(let user, let token): //post-login actions, like caching the token and user onSuccess() case .failure(let error): onError(error) } } } }
If we need to add another provider we can very easily do that.
1. Separating domains
Fetching Facebook tokens and sending them to the web server is the login feature’s domain. Saving a User and their token is in the application domain. The whole app relies on having the token. However, the app can work fine without a Facebook login. This should be reflected in code.
If the rest of the app doesn’t depend on a component, we should be able to delete that component without breaking the rest of the app. In this case, we can delete a LoginProvider with minimal impact to the codebase.
2. Testability
Each provider can be tested (or not) separately. The logic of what happens after the provider is done is tested separately and only once. It’s also very easy to mock providers and see if our LoginService behaves correctly.
3. De-coupling
Facebook releases an update that breaks everyone’s code? No worries, it’s a 5 minute fix because we only need to edit a single file in our application. It’s always a good idea to keep dependencies in a sort of quarantine, away from your actual code. Whether that’s trough making wrappers, dependency-inversion or something else.
4. Easier on the eyes
As developers, we have to keep a lot of stuff in our head. Holding a large project with intertwined dependencies can be very difficult. This is why it’s good to separate the project into smaller components. Not only does it make thinking about the project easier, debugging is easier since we can take a look at each component separately and isolate the problem.
Caching the user is a very good idea. It means you don’t need to make calls to the backend to get some information about the current user. That reduces your server load and makes the application feel more responsive.
A note on caching data though, when it comes to authentications tokens, always store them in the Keychain. UserDefaults can be easily accessed if somebody has a jailbroken device! Keychain, on the other hand, is fully encrypted and can’t be read. It’s not too much more work, and it will make your users much more secure.
We hope this article will help you in your future projects. Happy coding!