Please enable JavaScript to view the{" "} comments powered by Disqus.

just keep clicking

EZClientAuth Part II - Synchronization

This is Part II of the three part EZClientAuth series:
EZClientAuth Part I - Core
EZClientAuth Part II - Synchronization
EZClientAuth Part III - Integration

Solving The Problem of Synchronizing Remote, Cached, and Runtime AuthSessions

Raise your hand if this has ever happened to you while implementing authentication on a client:

You login
You kill the app
You re-open the app
You’re still presented with login page

or

You login
You attempt an authenticated network call
You still receive a 401-Unauthorized network error

This class of error stems from failure to synchronize the AuthSession across the 3 areas of memory where AuthSession lives:

  • 1. Remote: The most recent AuthSession returned from your remote auth provider
  • 2. Cached: The AuthSession stored in cache
  • 3. Runtime: The AuthSession object used in your application’s runtime to make authenticated calls

In its remote form, the AuthSession is stored in some serialized form on a remote server.
In its cached form, the AuthSession is serialized JSON.
In its runtime form, the AuthSession is a Swift struct.

But as far as our domain is concerned, these are identical AuthSessions!

They are domain-identical but memory-distinct.

Your control over the synchronized CRUD of these three AuthSessions will determine whether you succeed or fail in clientside authentication.

EZClientAuth uses unidirectional data flow to ensure synchronization of these three AuthSessions.

Unidirectional data flow for authentication means that authentication state MUST flow from the RemoteAuthProvider into the AuthDataStore and finally into the single AuthSession used by your application.

The class responsible for coordinating unidirectional authentication flow is called the AuthManager.


AuthManager

I want a manager that synchronizes the AuthSession between the RemoteAuthProvider and the AuthDataStore. This manager should also act as a custodian of the most recent AuthSession.

This is the responsibility of the AuthManager:

public protocol AuthManager {
    var authSession: AuthSession? { get }
    var authDataStore: AuthDataStore { get }

    // We'll explain what this is in just a second 😉
    var authProviderConfiguration: AuthProviderConfiguration { get }
}

An AuthManager has an AuthSession, an AuthDataStore, and an AuthProviderConfiguration (more on this in a moment).

Although we will only implement one AuthManager, we make it a protocol for a very important reason: in our application, we may want to replace this with a MockAuthManager for unit testing and manipulating authentication state.

Here’s what our AuthManager implementation looks like. Let’s call it EZAuthManager:

public class EZAuthManager: AuthManager {
    public var authSession: AuthSession? { get }
    private var authDataStore: AuthDataStore { get }

    // We'll explain what this is in just a second
    private var authProviderConfiguration: AuthProviderConfiguration { get }
    private var remoteAuthProvider: RemoteAuthProvider = {
        return authProviderConfiguration.remoteAuthProvider
    }
}

There’s one thing here we haven’t seen yet: AuthProviderConfiguration. Let’s justify its existence as a simple enum that greatly reduces the API surface area that EZClientAuth exposes to the client.

AuthProviderConfiguration

I want some simple enum that allows me to hide the remote auth provider implementation from the client

Why abstract away the RemoteAuthProvider from the client? A major goal of the EZClientAuth framework is to genericize the specific authentication provider behind a shared interface of common authentication methods.

With this as the goal, it makes little sense to expose the RemoteAuthProvider to the client.

So we make AuthProviderConfiguration public instead:

public enum AuthProviderConfiguration {
    // Your remote auth provider options
    case firebase
    case keycloak

    // Implementations you've written for remote auth providers
    var remoteAuthProvider: RemoteAuthProvider {
        switch self {
        case .firebase:
          return FirebaseRemoteAuthProvider()
        case .keycloak:
          return KeycloakRemoteAuthProvider()
        }
    }
}

The AuthProviderConfiguration is an enum exposed to the client for choosing a RemoteAuthProvider implementation.

The case is switched over in the remoteAuthProvider computed property to determine which RemoteAuthProvider implementation to return to the AuthManager.

This private remoteAuthProvider property on EZAuthManger:

var authProviderConfiguration: AuthProviderConfiguration { get }
private var remoteAuthProvider: RemoteAuthProvider = {
    return authProviderChoice.remoteAuthProvider
}

is just a convenience getter for the remoteAuthProvider stored as an associated value on the chosen AuthProviderConfiguration enum.

Congrats on making it this far! It’s finally time to sign in!

AuthManager Sign In Flow

Let’s perform a fully synchronized sign-in on the AuthManager in 3 steps.

Here’s the sign-in method signature:

public protocol AuthManager {
    func signIn(
        email: String?,
        password: String?,
        _ completion: @escaping (AuthSession?, AuthError?) -> Void
        )
}

It takes the same arguments as RemoteAuthProvider.signIn(_:_:_:). That’s because Step 1 to sign in is passing these credentials to a RemoteAuthProvider’s sign-in method, like so:

Step 1: Call RemoteAuthProvider.signIn with the provided credentials.

You will receive an AuthSession if successful, or an AuthError if unsuccessful.

// 1: Sign in with your remote
remoteAuthProvider.signIn(
    email: email,
    password: password
    ) { (authSession, error) in
    //2. Check for errors
    guard error == nil else {
        return completion(
            nil,
            .failedToSignInWithRemote(
                "\(error!.localizedDescription)"))
    }

    //3. Check for auth session
    guard let authSession = authSession else {
        return completion(
            nil,
            failedToRetrieveAuthSession(
                "No AuthSession, but also no errors?"))
    }

If you’re not familiar with the concept of a guard, I suggest you give this article on guards and null-safe languages a quick read and then come back. If you are familiar with guards, onwards!

If remote sign-in completes error-free, AuthManager proceeds to Step 2.

Step 2: cache the AuthSession

Attempt to cache the AuthSession returned from RemoteAuthProvider into the AuthDataStore.

// 1. Cache the AuthSession
self.dataStore.save(authSession: authSession, { (error) in

    // 2. Check for caching errors
    guard error == nil else {
        return completion(
            nil,
            .failedToPersistUserSessionData(
                error!.localizedDescription
                ))
    }
})

If save completes error-free, then we know the AuthSession was cached successfully. AuthManager moves on to Step 3.

Step 3: Set or update the AuthSession stored on the AuthManager

Set the AuthSession stored on the AuthManager to the AuthSession returned from the RemoteAuthProvider.

// Set the AuthSession on the AuthManager
self!.authSession = authSession

Then complete!

// Complete sign-in on the AuthManager by calling
// the completion handler with the freshly synchronized AuthSession
completion(authSession, nil)

That’s it for synchronizing sign-in! All together now:

// 1: Sign in with your remote
remoteAuthProvider.signIn(
    email: email,
    password: password,
    phoneNumber: phoneNumber) { [weak self] (authSession, error) in

    //2. Check for errors
    guard error == nil else {
        return completion(
            nil,
            AuthError.failedToSignInWithRemote(
                "Email: \(email ?? 'no email'),
                Password: \(password ?? 'no password')
                \(error!.localizedDescription)"))
    }

    //3. Check for AuthSession
    guard let authSession = authSession else {
        return completion(
            nil,
            AuthError
            .failedToRetrieveAuthSession("No AuthSession, but also no errors?"))
    }

    // 4. Cache the AuthSession
    self?.authDataStore.save(authSession: authSession, { (error) in

        // 5. Check for caching errors
        guard error == nil else {
            return completion(nil,
            AuthError.failedToPersistAuthSessionData(error!.localizedDescription))
        }

        // 6. Set the AuthSession on the AuthManager
        self!.authSession = authSession

        // 7. Complete sign-in on the AuthManager by calling the
        // completion handler with the synchronized AuthSession
        completion(authSession, nil)
    })
}

We’re ONE STEP away from securely integrating EZClientAuth into an application.

All that remains is a strategy for:

A) protecting the internals of EZClientAuth from being directly accessed by the client

and

B) ensuring that only a single instance of AuthManager exists in the application.

That’s the topic of the final step - EZClientAuth Part III - Integration.