DEV Community

William Kennedy
William Kennedy

Posted on • Originally published at williamkennedy.ninja on

Turbo Native Authentication Part 2 - IOS Client

The source code can be found here.

Now that we have our Rails backend, we can start working on our Turbo Native apps. First up is iOS. This post will touch on different parts of building a typical iOS app. We will use an established iOS design pattern called the Coordinator pattern to navigate between screens. First, we’ll get our App up and running, then implement native authentication, and finally, we’ll wrap up with some bonus content.

Intro

We are going to set up our App using the Coordinator pattern. This pattern encapsulates our navigation behaviour. It works by keeping a stack of child coordinators, and depending on the logic in the App, we push or pop a coordinator.

It won’t be my favourite pattern, but doing everything using UIViewControllers quickly became a mess, and this is recommended way in iOS. I’ve written about setting coordinators up previously in this blog post about rendering a native screen with turbo-ios, but it would be good to run through everything here as well.

Set up packages

Open Xcode and create a new app.

For the template, choose App; for the Interface, select Storyboard.

Ensure you have the turbo-ios and KeychainAccess packages.

These can be added via File > Add Package > Enter Package name.

Keychain access makes encrypting sensitive information on the user’s keychain easier.

Set up our Parent Coordinator

Create a new group called Coordinators, then create a new file named Coordinator.swift.

import UIKit

class Coordinator: NSObject, UINavigationControllerDelegate {

  var didFinish: ((Coordinator) -> Void)?

  var childCoordinators: [Coordinator] = []

  // MARK: - Methods

  func start() {}

  func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {}

  func pushCoordinator(_ coordinator: Coordinator) {
    // Install Handler
    coordinator.didFinish = { [weak self] (Coordinator) in
      self?.popCoordinator(coordinator)
    }

    // Start Coordinator
    coordinator.start()

    // Append to Child Coordinators
    childCoordinators.append(coordinator)
  }

  func popCoordinator(_ coordinator: Coordinator) {
    // Remove Coordinator From Child Coordinators
    if let index = childCoordinators.firstIndex(where: { $0 === coordinator }) {
      childCoordinators.remove(at: index)
    }
  }

  func isPresentingModal(viewController: UIViewController) -> Bool {
      return viewController.presentedViewController != nil
  }

}

Enter fullscreen mode Exit fullscreen mode

This is the parent Coordinator, and every subsequent Coordinator inherits from this.

Set up our Turbo Coordinator

Next, create a coordinator to handle our Sessions object. Session are how turbo-ios handles navigation within the webview.

If you are copying/pasting, note that my baseURL is localhost:3005, not 3000.

import Foundation
import Turbo
import UIKit
import SafariServices
import WebKit

class TurboSessionCoordinator: Coordinator {
    var didAuthenticate: (() -> Void)?
    let baseURL = URL(string: "http://localhost:3005/")!
    var rootViewController: UIViewController {
        return navigationController
    }

    var resetApp: (() -> Void)?

    override func start() {
        visit(url: baseURL)
    }

    private let navigationController = UINavigationController()
    private lazy var session = makeSession()
    private lazy var modalSession = makeSession()

    private func makeSession() -> Session {
        let session = Session()
        session.webView.customUserAgent = "My App (Turbo Native) / 1.0"
        session.delegate = self
        let pathConfiguration = PathConfiguration(sources: [
            .file(Bundle.main.url(forResource: "path_configuration", withExtension: "json")!),
            .server(baseURL.appending(path: "/turbo/ios/path_configuration"))
        ])
        session.pathConfiguration = pathConfiguration
        return session
    }

    private func visit(url: URL, action: VisitAction = .advance, properties: PathProperties = [:]) {
        let viewController = makeViewController(for: url, from: properties)
        let modal = properties["presentation"] as? String == "modal"
        let action: VisitAction = url == session.topmostVisitable?.visitableURL ? .replace : action
        navigate(to: viewController, via: action, asModal: modal)
        visit(viewController, as: modal)
    }

    private func makeViewController(for url: URL, from properties: PathProperties) -> UIViewController {
        return VisitableViewController(url: url)
    }

    private func navigate(to viewController: UIViewController, via action: VisitAction, asModal modal: Bool) {
        if modal {
            navigationController.present(viewController, animated: true)
        } else if action == .advance {
            navigationController.pushViewController(viewController, animated: true)
        } else if action == .replace {
            navigationController.dismiss(animated: true)
            navigationController.viewControllers = Array(navigationController.viewControllers.dropLast()) + [viewController]
        } else {
            navigationController.viewControllers = Array(navigationController.viewControllers.dropLast()) + [viewController]
        }
    }

    private func visit(_ viewController: UIViewController, as modal: Bool) {
        guard let visitable = viewController as? Visitable else { return }
        let session = modal ? modalSession : self.session
        session.visit(visitable)
    }
}

extension TurboSessionCoordinator: SessionDelegate {
    func session(_ session: Session, didProposeVisit proposal: VisitProposal) {
        visit(url: proposal.url, action: proposal.options.action, properties: proposal.properties)
    }

    func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {
    // tackle this later
    }

    func sessionWebViewProcessDidTerminate(_ session: Session) {
        session.reload()
    }
}

Enter fullscreen mode Exit fullscreen mode

Set up our Application Coordinator

This is the most essential part of our application because it starts all the child coordinators.

import Foundation
import UIKit

class ApplicationCoordinator: Coordinator {

    var rootViewController: UIViewController {
        return turboSessionCoordinatior.rootViewController
    }

    let turboSessionCoordinatior = TurboSessionCoordinator()

    override init() {
        super.init()

        childCoordinators.append(turboSessionCoordinatior)

    }

    override func start() {
        childCoordinators.forEach { (childCoordinator) in
            childCoordinator.start()
        }
    }

}

Enter fullscreen mode Exit fullscreen mode

Next, in our SceneDelegate, we need to start our ApplicationCoordinator.

class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?
    private let coordinator = ApplicationCoordinator()

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to configure and attach the UIWindow `window` to the provided UIWindowScene `scene` optionally.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
        guard let windowScene = (scene as? UIWindowScene) else { return }
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = coordinator.rootViewController
        window.makeKeyAndVisible()
        self.window = window
        coordinator.start()
    }
 ....

Enter fullscreen mode Exit fullscreen mode

Before we can press the run button, we need to set up a local path_configuration.json file, which can exist in the root of your iOS project. It can be the same as the one we created in the previous blog post.

{"rules":[
  {"patterns":["/new$","/edit$"],
  "properties": 
  {"presentation":"modal"}}
]}

Enter fullscreen mode Exit fullscreen mode

If you press the run button right now, you will see that the App runs like that, and we have our beautiful Rails app inside.

It’s that magic that gets me every time.

A brief tour

Now, everything is up and running. However, let’s try and access a logged-in page.

alt text

Our server is returning a 401 unauthorised response. When navigating, our TurboSessionCoordinator is triggering the didFailRequestForVisitable.

Now we can work around this in several ways. For example, we can log in using a web form. However, we still get that white screen when we navigate to posts/new as a guest user.

The UX is less than ideal, and we can do much better by reacting to the 401 status code and pushing a new coordinator onto the stack. As a bonus, we can also retrieve an API token which will make it easier to render native views later on.

Setting up Native Authentication

Another coordinator handles native authentication. We plan to react to the 401 status and push the login view onto the stack.

Let’s start by creating our AuthCoordinator.

import Foundation
import UIKit
import SwiftUI

class AuthCoordinator: Coordinator {
    var didAuthenticate: (() -> Void)?

    private let navigationController: UINavigationController
    private let authViewController: UIHostingController<SignInView>
    private let viewModel: SignInViewModel

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
        self.viewModel = SignInViewModel()
        let newView = SignInView(viewModel: viewModel)
        self.authViewController = UIHostingController(rootView: newView)

    }

    override func start() {
        viewModel.didFinish = authenticate
        // Dismiss the current modal view controller

        if isPresentingModal(viewController: navigationController) {
            navigationController.dismiss(animated: false) {
                // Present the new view controller
                self.navigationController.present(self.authViewController, animated: true, completion: nil)
            }
        } else {
            navigationController.present(authViewController, animated: true)
        }

    }

    func authenticate() {
        didAuthenticate?()
        navigationController.dismiss(animated: true)
    }
}

Enter fullscreen mode Exit fullscreen mode

Let’s run through this code a bit because it might be new if you are coming from a Ruby background.

var didAuthenticate: (() -> Void)?

Enter fullscreen mode Exit fullscreen mode

The didAuthenticate closure or callback allows other parts of the code to be notified or informed when authentication has been completed. This closure can be assigned a value or function executed when authentication occurs. Invoking this closure can trigger any desired actions or code execution in response to the authentication event.

private let navigationController: UINavigationController
private let authViewController: UIHostingController<SignInView>

Enter fullscreen mode Exit fullscreen mode

Our navigation controller is our main controller that controls what views or logic are rendered on the screen in UIKit. Our TurboSession coordinator’s root view controller is a navigation controller(you may have noticed already).

Next, we are diving into a UIHostingController<SignInView>, a simple way to allow us to drop into SwiftUI.

Finally, you’ll notice that we check for a modal that is being presented and dismiss any existing modal if they’re. This is because posts/new happens to be presented as a modal as configured by our PathConfiguration. UIKit does not allow multiple modals in this scenario.

Next, we must create a SignInView and a SignInViewModel to handle our auth flow.

Let’s create our SignInViewModel first.

import SwiftUI
import Foundation
import WebKit
import KeychainAccess

class SignInViewModel: ObservableObject {
    @Published var email: String = ""
    @Published var password: String = ""

    var didFinish: (() -> Void)?

    @MainActor
    func signIn() async {
        let url = URL(string: "http://localhost:3005/api/v1/sessions.json")!
        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let parameters = ["email": email, "password": password]
        request.httpBody = try? JSONSerialization.data(withJSONObject: parameters)

        let session = URLSession.shared
        let task = session.dataTask(with: request) { data, response, error in
            if let error = error {
                print("Error: \(error)")
                return
            }

            if let httpResponse = response as? HTTPURLResponse {
                if let cookies = HTTPCookieStorage.shared.cookies(for: url) {
                    HTTPCookieStorage.shared.setCookies(cookies, for: url, mainDocumentURL: nil)
                    self.saveCookies(cookies)
                }
                if let authToken = httpResponse.allHeaderFields["X-Session-Token"] as? String {
                    if let bundleIdentifier = Bundle.main.bundleIdentifier {
                        print("Bundle Identifier: \(bundleIdentifier)")
                        let keychain = Keychain(service: "\(bundleIdentifier).keychain")
                        keychain[string: "token"] = authToken
                        print("Token Headers: \(authToken)")
                    }
                    // Save token to keychain
                }
            }
        }
        didFinish?()
        task.resume()
    }

    private

    func saveCookies(_ cookies: [HTTPCookie]) {
        cookies.forEach { cookie in
            DispatchQueue.main.async {
                WKWebsiteDataStore.default().httpCookieStore.setCookie(cookie)
            }
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

The signIn method sends a web request and if successful, does two things. It saves cookies, and it saves an auth token to the keychain.

Finally, we’ll create our SignInView.

import SwiftUI

struct SignInView: View {
    @ObservedObject var viewModel: SignInViewModel

    var body: some View {
        Form {
            TextField("name@example.com", text: $viewModel.email)
                .textContentType(.username)
                .keyboardType(.emailAddress)
                .autocapitalization(.none)

            SecureField("password", text: $viewModel.password)
                .textContentType(.password)

            Button("Sign in") {
                Task {
                    await viewModel.signIn()
                }
            }
        }
    }
}

struct SignInView_Previews: PreviewProvider {
    static var previews: some View {
        SignInView(viewModel: SignInViewModel())
    }
}

Enter fullscreen mode Exit fullscreen mode

Connect it to our TurboSessionCoordinator

Now that we have the AuthCoordinator setup let’s handle the earlier error we encountered.

In our didFailReuestForVisitable method found in the TurboSessionCoordinator extension, we add the following:

extension TurboSessionCoordinator: SessionDelegate {
    func session(_ session: Session, didProposeVisit proposal: VisitProposal) {
        visit(url: proposal.url, action: proposal.options.action, properties: proposal.properties)
    }

    func session(_ session: Session, didFailRequestForVisitable visitable: Visitable, error: Error) {
        if let turboError = error as? TurboError, case let .http(statusCode) = turboError, statusCode == 401 {

            let authCoordinator = AuthCoordinator(navigationController: navigationController)
            authCoordinator.didAuthenticate = didAuthenticate
            pushCoordinator(authCoordinator)
        } else {
            let alert = UIAlertController(title: "Visit failed!", message: error.localizedDescription, preferredStyle: .alert)
            alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
            navigationController.present(alert, animated: true)
        }
    }

    func sessionWebViewProcessDidTerminate(_ session: Session) {
        session.reload()
    }
}

Enter fullscreen mode Exit fullscreen mode

When you rerun the App, you’ll see that before you visit /posts/new, you’ll be intercepted and have the chance to log in with the native login functionality.

alt text

There is still one last problem when we click the sign-in button; we display the web login and not the native login. Let’s change that quickly by first updating our path configuration file.

{
  "rules": [
    {
      "patterns": [
        "/new$",
        "/edit$"
      ],
      "properties": {
        "presentation": "modal"
      }
    },
    {
      "patterns": [
        "/sign_in$"
      ],
      "properties": {
        "presentation": "authentication"
      }
    }
  ]
}

Enter fullscreen mode Exit fullscreen mode

Next, let’s capture this flow in our TurboCoordinator session. We update our visit function.

private func visit(url: URL, action: VisitAction = .advance, properties: PathProperties = [:]) {
        let viewController = makeViewController(for: url, from: properties)
        let modal = properties["presentation"] as? String == "modal"
        let action: VisitAction = url == session.topmostVisitable?.visitableURL ? .replace : action
        let auth = properties["presentation"] as? String == "authentication"
        if auth {
            let authCoordinator = AuthCoordinator(navigationController: navigationController)
            authCoordinator.didAuthenticate = didAuthenticate
            pushCoordinator(authCoordinator)
        } else {
            navigate(to: viewController, via: action, asModal: modal)
            visit(viewController, as: modal)
        }
    }

Enter fullscreen mode Exit fullscreen mode

Conclusion

So there you have it. An approach to native authentication with Turbo IOS using the Coordinator pattern. There are other approaches, but I find something like this works for my needs.

The next blog post will tackle native authentication with Turbo Android.

Bonus - API endpoint

To access our API endpoint and render native screens, we can copy what we have done with our AuthCoordinator. We update our PathConfiguration to check for a ‘native’ flow and then push the new Coordinator onto the stack.

import UIKit
import SwiftUI

class PostCoordinator: Coordinator {
    var didAuthenticate: (() -> Void)?

    private let navigationController: UINavigationController
    private let postViewController: UIHostingController<PostView>
    private let viewModel: PostViewModel

    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
        self.viewModel = PostViewModel()
        let view = PostView(viewModel: viewModel)
        self.postViewController = UIHostingController(rootView: view)
    }

    override func start() {
        navigationController.pushViewController(postViewController, animated: true)
    }

}


import Foundation
import SwiftUI
import Combine
import KeychainAccess

class PostViewModel: ObservableObject {
    @Published var isUpdating = false
    @Published var posts: [Post] = []

    var didFinish: (() -> Void)?

    @MainActor
    func fetchPosts() async {
        isUpdating = true
        var token = ""
        let url = URL(string: "http://localhost:3005/posts.json")!
        if let bundleIdentifier = Bundle.main.bundleIdentifier {
            print("Bundle Identifier: \(bundleIdentifier)")
            let keychain = Keychain(service: "\(bundleIdentifier).keychain")
            token = keychain[string: "token"] ?? ""
        } else {
            return
        }
        let config = URLSessionConfiguration.default
        config.waitsForConnectivity = true
        config.timeoutIntervalForResource = 60
        config.httpAdditionalHeaders = [
            "Authorization": "Bearer \(token)"
        ]
        print(token)

        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        request.addValue("application/json", forHTTPHeaderField: "Accept")

        URLSession(configuration: config).dataTask(with: request) { data, response, error in
            if let error = error {
                print(error.localizedDescription)
            }

            if let data = data {
                let decoder = JSONDecoder()
                decoder.keyDecodingStrategy = .convertFromSnakeCase

                do {
                    let posts = try decoder.decode([Post].self, from: data)
                    DispatchQueue.main.async {
                        self.posts = posts
                        print(self.posts)
                        self.objectWillChange.send()
                    }
                } catch {
                    print(error)
                }
            }
        }.resume()

        isUpdating = false
    }

}


import SwiftUI

struct PostView: View {
    @ObservedObject var viewModel: PostViewModel

    var body: some View {
        NavigationStack {
            List {
                ForEach(viewModel.posts, id: \.id) { post in
                    Text(post.title)
                }
            }
        }
        .task {
                await viewModel.fetchPosts()
        }
    }

}

struct PostView_Previews: PreviewProvider {
    static var previews: some View {
        PostView(viewModel: PostViewModel())
    }
}

Enter fullscreen mode Exit fullscreen mode

There are two more steps to implement the native screen, but I’ll leave that as an exercise for the reader ;).

Top comments (0)