EZClientAuth Part I - Core
February 06, 2020
Special Thanks: Jacob Pricket, Eugene Pavlov, Shivum Bharill, Waseem Hijazi, Waleed Johnson
"All models are flawed. Some are useful." George E.P. Box
Today, we’ll lay the foundation of EZClientAuth: A simple, provider-agnostic and domain driven approach to clientside authentication.
EZClientAuth gives your app three new superpowers:
- Synchronization: Synchronize authentication state between your remote auth provider, on-device cache, and the runtime of your app
- Flexibility: Easily swap out auth providers in one line of code
- Testability: Straightforward mocking of authentication state for unit testing
EZClientAuth gains this flexibility by adhering to the principles of Domain Driven Design (DDD) and Protocol Oriented Programming (POP). More on these codebase-saving paradigms later.
Today we’ll be hacking with Swift. However, I promise that the domain driven core of EZClientAuth makes it conceptually portable to any language with interfaces (i.e. most).
Here’s what EZClientAuth is capable of:
EZClientAuth Capabilities
- Authentication CRUD Operations: Sign-in, Sign-out, Sign-up, determining current authentication state
- Peristing authentication state between application launches
- Synchronizing authentication state across the entire application: Because no one wants a stale cache or a stale token attached to HTTP calls
- Switch Auth Providers in One Line of Code: Prevents vendor lock-in
We’ll build out these behaviors using five primary building blocks:
EZClientAuth Components
AuthSession
An object encapsulating authentication state, e.g. the authentication token
RemoteAuthProvider
Any remote service capable of creating, deleting and refreshing AuthSessions using some form of credential
Examples: Firebase, Keycloak, a Custom Auth Service
AuthDataStore
An on-device cache for persisting an AuthSession
between application launches
Examples: Browser local storage, Keychain on iOS
AuthManager
A manager responsible for orchestrating interactions between the RemoteAuthProvider
, AuthDataStore
, and the runtime of your application
EZAuth
The client’s sole entrypoint to EZClientAuth
Domain Driven Design
The goal of domain-driven design is to create better software by focusing on a model of the domain rather than the technology. - Eric Evans
Domain Driven Design (DDD) is a term coined by Eric Evans in his 2004 book Domain Driven Design: Tackling Complexity at the Heart of Software Development.
DDD strips away the accidental complexities of implementations and provides a modelling framework for development teams to focus on the inherent complexities of their domain without getting bogged down in technical detail.
DDD encourages developers to name by function, not implementation. This means there is no “the”. There is only “a”. The client calls “a RemoteAuthProvider”, not “the FirebaseAuthProvider”.
Here are some side-by-sides of Non-DDD names and their DDD equivalents:
Non-DDD: ❌ FirebaseAuthProviderDDD: ✅ RemoteAuthProvider
Non-DDD: ❌ KeychainDataStoreDDD: ✅ AuthDataStore
Non-DDD: ❌ MongoDBClientDDD: ✅ UserRepository
Genericism endows your code with flexibility.
In Swift practice, DDD is manifested through Protocol Oriented Programming (POP).
The Three Steps of POP:
- Create an intention-revealing protocol with generic, high level method names (e.g.
signIn
,createUser
, etc.) - Create implementations that adopt the protocol
- Write your code to the generic protocol methods, NEVER directly to the implementation.
Codebases built atop proper DDD patterns enjoy a number of benefits:
Prevent vendor lock-in: If you can easily change your implementations, then changing providers is less of a lift.
Testability: Because you’ve written to interfaces rather than implementations, you can always create mocks that implement that interface.
Shared Domain-Driven Language: Teams that adhere to DDD develop a shared domain vocabulary that spans technicals chasms starting in the codebase and extending all the way up into business meetings.
Today, we’ll focus on developing a domain-driven sign-in process. Let’s get started by creating the nucleus of authentication: the AuthSession
.
AuthSession
I want some immutable object that encapsulates my entire authentication state.
This object is called the AuthSession
.
struct AuthSession: Codable {
let token: String
init(token: String) {
self.token = token
}
}
As your authentication needs grow more complex, you will want space to stretch your legs and add new properties, like a refresh token. The AuthSession
class becomes what Martin Fowler describes as “a home that can attract useful behavior”. A primitive String token would provide no room for future expansion.
Where does the AuthSession
come from? It comes from the RemoteAuthProvider
.
RemoteAuthProvider
I want a service that takes credentials (e.g. email and password) and returns an AuthSession
if they are valid, or a helpful error message if the credentials are invalid or some error occurs.
This is the responsibility of the RemoteAuthProvider
.
protocol RemoteAuthProvider {}
Notice that RemoteAuthProvider
is a protocol, i.e. an interface. RemoteAuthProvider
describes the the properties and method signatures that an object aspiring to be a RemoteAuthProvider
must provide. It does not implement these methods itself.
For example, any RemoteAuthProvider
must have a sign-in method accepting these parameters:
protocol RemoteAuthProvider {
func signIn(email: String?,
password: String?,
_ completion: @escaping (AuthSession?, AuthError?) -> Void)
}
Let’s have a closer look at this method signature, as it will appear as an idiom throughout EZClientAuth.
First off, all the parameters are Optionals. This means they’re either the specified type, or nil.
// The '?' makes this an Optional parameter
// It's either a String or nil
email: String?
EZClientAuth is built with flexibility at its core, and that’s why these parameters are optional. Business may want to use anything from biometric login to a phone number for authentication. EZClientAuth is unopinionated in the matter of credentials. More optional parameters can always be added if the use case arises.
Let’s take a closer look at the final parameter: the completion closure.
_ completion: @escaping (AuthSession?, AuthError?) -> Void)
_
: Swift let’s developers choose whether or not they want to expose named parameters in their method signatures. The underscore _
just means this is not a named parameter, so the consuming code need not include a parameter label.
completion
: The name of the closure parameter used in this method body
@escaping
: Ignoring this @escaping
keyword is ay-okay. It is not important here.
(AuthSession?, AuthError?) -> Void
: This is the completion closure (aka lambda or anonymous function) passed by the calling code. Our RemoteAuthProvider
will call this completion callback once it has either authenticated successfully or encountered an error.
There are 2 scenarios for what is passed to the (AuthSession?, AuthError?) -> Void
closure upon authentication completion:
completion(authSession, nil)
We call the completion with the returned AuthSession
and nil errors
completion(nil, AuthError.*the specific error*)
We call the completion with a nil AuthSession
and the particular AuthError
that occurred.
If you’re curious about the details of the custom AuthError
, you can check out this article on responsible error handling.
Awesome! Let’s persist the AuthSession
between application launches using a generic AuthDataStore
.
AuthDataStore
I want an on-device storage to persist my AuthSession
so that users do not have to re-authenticate between each app launch.
This is the responsibility of our AuthDataStore
.
public protocol AuthDataStore {}
The AuthDataStore
is the only component in our authentication domain with the ability to save, read, and delete the AuthSession
to or from the on-device storage.
AuthDataStore
defines three CRUD methods to interact with the cache:
public protocol AuthDataStore {
func readAuthSession(_ completion: @escaping (AuthSession?, AuthError?) -> Void)
// save also overwrites, so it double duties as an update
func save(
authSession: AuthSession,
_ completion: @escaping (AuthError?) -> Void)
func delete(_ completion: @escaping (AuthError?) -> Void)
}
Once we get our AuthSession
from RemoteAuthProvider
, we can persist it locally by passing it to AuthDataStore.save(_:_:)
.
If caching is successful, AuthDataStore
completes with a nil
error.
If a serialization error occurs, AuthDataStore
completes with an AuthError
.
Great! We can get an AuthSession
by sending credentials to RemoteAuthProvider.signIn(_:_:_:)
and persists the returned AuthSession
by passing it to AuthDataStore.save(_:_:)
.
How do we keep these two AuthSession
s in sync? How will the client actually use this AuthSession
at runtime to make authenticated calls to our backend?
Keep going to learn how to achieve simple synchronization in EZClientAuth Part II - Synchronization.