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
}
}
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()
}
}
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()
}
}
}
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()
}
....
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"}}
]}
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.
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)
}
}
Let’s run through this code a bit because it might be new if you are coming from a Ruby background.
var didAuthenticate: (() -> Void)?
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>
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)
}
}
}
}
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())
}
}
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()
}
}
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.
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"
}
}
]
}
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)
}
}
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())
}
}
There are two more steps to implement the native screen, but I’ll leave that as an exercise for the reader ;).