HTTP in Swift, Part 15: OAuth

Part 15 in a series on building a Swift HTTP framework:

  1. HTTP in Swift, Part 1: An Intro to HTTP
  2. HTTP in Swift, Part 2: Basic Structures
  3. HTTP in Swift, Part 3: Request Bodies
  4. HTTP in Swift, Part 4: Loading Requests
  5. HTTP in Swift, Part 5: Testing and Mocking
  6. HTTP in Swift, Part 6: Chaining Loaders
  7. HTTP in Swift, Part 7: Dynamically Modifying Requests
  8. HTTP in Swift, Part 8: Request Options
  9. HTTP in Swift, Part 9: Resetting
  10. HTTP in Swift, Part 10: Cancellation
  11. HTTP in Swift, Part 11: Throttling
  12. HTTP in Swift, Part 12: Retrying
  13. HTTP in Swift, Part 13: Basic Authentication
  14. HTTP in Swift, Part 14: OAuth Setup
  15. HTTP in Swift, Part 15: OAuth
  16. HTTP in Swift, Part 16: Composite Loaders
  17. HTTP in Swift, Part 17: Brain Dump
  18. HTTP in Swift, Part 18: Wrapping Up

The last post covered the basics of an OAuth flow: how we check to see if tokens, how we ask the user to log in, how we refresh the tokens, and so on. In this post we’re going to take that state machine and integrate it into an HTTPLoader subclass.

The Loader

We’ve defined the state machine for the authorization flow, but we need another simple state machine to describe how the loader will interact with it. Let’s consider the various “states” our loader can be in when it is asked to load a task:

  • idle (nothing has happened yet, or the state machine failed) → we should spin up the state machine and start running through the authorization flow
  • authorizing (state machine is running) → we should make the task wait for the state machine to finish
  • authorized (we have valid credentials) → load the task
  • authorized (we have expired credentials) → we need to refresh the tokens

If we take a closer look at this, we’ll see that the “idle” state is really the same thing as the “authorized + expired tokens” state: in either case, we’ll need to spin up the state machine so that we can get new tokens (Recall that the state machine has logic already to refresh expired tokens). With this in mind, let’s stub out our loader:

public class OAuth: HTTPLoader {

    private var stateMachine: OAuthStateMachine?
    private var credentials: OAuthCredentials?
    private var pendingTasks = Array<HTTPTask>()

    public override func load(task: HTTPTask) {
        // TODO: make everything threadsafe

        if stateMachine != nil {
            // "AUTHORIZING" state
            // we are running the state machine; load this task later
            self.enqueueTask(task)

        } else if let tokens = credentials {
            // we are not running the state machine
            // we have tokens, but they might be expired
            if tokens.expired == true {
                // "AUTHORIZED+EXPIRED" state
                // we need new tokens
                self.enqueueTask(task)
                self.runStateMachine()
            } else {
                // "AUTHORIZED+VALID" state
                // we have valid tokens!
                self.authorizeTask(task, with: tokens)
                super.load(task: task)
            }

        } else {
            // "IDLE" state
            // we are not running the state machine, but we also do not have tokens
            self.enqueueTask(task)
            self.runStateMachine()
        }
    }

}

We can see the four possible states encoded in the if statement. We’re missing some pieces, so let’s take a look at those:

public class OAuth: HTTPLoader {
    ... // the stuff above

    private func authorizeTask(_ task: HTTPTask, with credentials: OAuthCredentials) {
        // TODO: create the "Authorization" header value
        // TODO: set the header value on the task
    }

    private func enqueueTask(_ task: HTTPTask) {
        self.pendingTasks.append(task)
        // TODO: how should we react if the task is cancelled while it's pending?
    }

    private func runStateMachine() {
        self.stateMachine = OAuthStateMachine(...)
        self.stateMachine?.delegate = self
        self.stateMachine?.run()
    }
}

extension OAuth: OAuthStateMachineDelegate {

    // TODO: the OAuth loader itself needs a delegate for some of these to work

    func stateMachine(_ machine: OAuthStateMachine, wantsPersistedCredentials: @escaping (OAuthCredentials?) -> Void) {
        // The state machine is asking if we have any credentials
        // TODO: if self.credentials != nil, use those
        // TODO: if self.credentials == nil, ask a delegate
    }

    func stateMachine(_ machine: OAuthStateMachine, persistCredentials: OAuthCredentials?) {
        // The state machine has found new tokens for us to save (nil = delete tokens)
        // TODO: save them to self.credentials
        // TODO: also pass them on to our delegate
    }

    func stateMachine(_ machine: OAuthStateMachine, displayLoginURL: URL, completion: @escaping (URL?) -> Void) {
        // The state machine needs us to display a login UI
        // TODO: pass this on to our delegate
    }

    func stateMachine(_ machine: OAuthStateMachine, displayLogoutURL: URL, completion: @escaping () -> Void) {
        // The state machine needs us to display a logout UI
        // This happens when the loader is reset. Some OAuth flows need to display a webpage to clear cookies from the browser session
        // However, this is not always necessary. For example, an ephemeral ASWebAuthenticationSession does not need this
        // TODO: pass this on to our delegate
    }

    func stateMachine(_ machine: OAuthStateMachine, didFinishWithResult result: Result<OAuthCredentials, Error>) {
        // The state machine has finished its authorization flow

        // TODO: if the result is a success
        //       - save the credentials to self.credentials (we should already have gotten the "persistCredentials" callback)
        //       - apply these credentials to everything in self.pendingTasks
        // 
        // TODO: if the result is a failure
        //       - fail all the pending tasks as "cannot authenticate" and use the error as the "underlyingError"

        self.stateMachine = nil
    }

}

Most of the reactions to the state machine involve forwarding on the information to yet-another delegate. This is because our loader (correctly!) doesn’t know how to display a login/logout UI, nor does our loader have any idea how or where credentials are persisted. This is as it should be. Displaying UI and persisting information are unrelated to the our loader’s task of “authenticating a request”.

Resetting

Other than the TODO: items scattered around our code, the last major piece of the puzzle we’re missing is the “reset” logic. At first glance, we might think it to be this:

public func reset(with group: DispatchGroup) {
    self.stateMachine?.reset(with: group)
    super.reset(with: group)
}

As discussed in the previous post, every state in the state machine can be interrupted by a reset() call, and this is how that would happen. Thus, if our machine is currently running, this is how we can interrupt it.

… But what if it’s not running? What if we have already authenticated and have valid tokens, and then we get the call to reset()? (This would actually be the common scenario, since “reset” is largely analogous to “log out”, which typically only happens if authentication has succeeded)

In this case, we need to modify our state machine. Recall that last time we described this OAuth flow:

A basic OAuth flow

There’s nothing in this flow that handles the “log out” scenario. We need to modify this slightly so that we also have a way to invalidate tokens. This LogOut state was listed in the “caveats” section before. With it included, the state flow diagram now looks approximately like this:

OAuth with reset

Two things to note about this are:

  1. The dashed lines from all the previous states to the new “Log Out” state represent the “interruption” of that state by calling reset() while the state machine is running
  2. The new “Log Out” state is a possible entry point to the state machine. In other words, we can start the machine in this state.

I’ll leave the implementation of the “Log Out” state to you, but it needs to do a handful of things:

  1. It needs to construct the URL to display the “log out” page to show to the user (the one mentioned before to clear cookies from the browser session)
  2. It needs to contact the server and tell them that the credentials have been revoked
  3. It needs to notify its delegate to clear any persisted credentials

With this in place, our OAuth loader should be completely functional:

public func reset(with group: DispatchGroup) {
    if let currentMachine = self.stateMachine {
       // we are currently authorizing; interrupt the flow
       currentMachine.reset(with: group)
    } else {
        // TODO: you'll want to pass the "group" into the machine here
        self.stateMachine = OAuthStateMachine(...)
        self.stateMachine?.delegate = self

        // "running" the state machine after we gave it the DispatchGroup should start it in the LogOut state
        self.stateMachine?.run()
    }
    super.reset(with: group)
}

Food for thought: There’s technically another possible situation here. What if we reset the loader before we’ve loaded any tasks, and thus do not know if we’re currently authorized? Do we still need to run the state machine? Why or why not? How would the state flow diagram change, if at all?

Conclusion

I hope that these two posts illustrate that OAuth doesn’t have to be this big scary thing. We’ve got our state machine to authorize (or un-authorize) the user, and it has six possible states. That’s not very many, and we can keep that in our heads. Similarly, the loader itself only has a handful of possible states, depending on what’s going on with the state machine. By encapsulating the respective logic in different layers of abstraction, we’re able to keep overall complexity fairly low. Each individual State subclass of our machine is straight-forward; our StateMachine class has almost no code in it; and even our OAuth loader is barely a couple dozen lines.

But from it, we’ve ended up with a full-featured OAuth flow:

  • we guarantee we only run a single OAuth authorization UI at a time
  • we allow clients to display the OAuth UI that they want
  • we allow clients to persist tokens how they want
  • we allow for interruption of authorization
  • we allow for un-authorizing by resetting

That’s pretty awesome!


In the next post, we’ll be combining the BasicAuth and OAuth loaders into a single composite Authentication loader.


Related️️ Posts️

HTTP in Swift, Part 18: Wrapping Up
HTTP in Swift, Part 17: Brain Dump
HTTP in Swift, Part 16: Composite Loaders
HTTP in Swift, Part 14: OAuth Setup
HTTP in Swift, Part 13: Basic Authentication
HTTP in Swift, Part 12: Retrying
HTTP in Swift, Part 11: Throttling
HTTP in Swift, Part 10: Cancellation
HTTP in Swift, Part 9: Resetting
HTTP in Swift, Part 8: Request Options
HTTP in Swift, Part 7: Dynamically Modifying Requests
HTTP in Swift, Part 6: Chaining Loaders
HTTP in Swift, Part 5: Testing and Mocking
HTTP in Swift, Part 4: Loading Requests
HTTP in Swift, Part 3: Request Bodies
HTTP in Swift, Part 2: Basic Structures
HTTP in Swift, Part 1: An Intro to HTTP