Tutorial: Signin SwiftUI

Cover Page

DUE Wed, 11/19, 2 pm

This tutorial may be completed individually or in teams of at most 2. You can partner differently for each tutorial.

You’ll need access to a physical device with biometric sensor (fingerprint or facial recognition) to complete this tutorial.

Preparing your GitHub repo

:point_right: Go to the GitHub website to confirm that your folders follow this structure outline:

  reactive
    |-- chatter.zip
    |-- chatterd
    |-- chatterd.crt
    |-- llmprompt.zip
    |-- signin
        |-- swiftUIChatter
            |-- swiftUIChatter.xcodeproj
            |-- swiftUIChatter
    # and other files or folders

If the folders in your GitHub repo does not have the above structure, we will not be able to grade your assignment and you will get a ZERO.

Creating Google OAuth Client

The following instructions are largely based on Get started with Google Sign-In for iOS and macOS and Integrating Google Sign-In into your iOS and macOS app, simplified and elaborated upon.

Install the SDK

We will be using Apple’s Swift Package Manager (SPM) to install the Google SignIn SDK for iOS:

  1. In Xcode, select File > Add Packages Dependencies....
  2. In the search box at the upper left of the Apple Swift Packages window, enter the URL: https://github.com/google/GoogleSignIn-iOS and
  3. click the Add Package button (screenshot).
  4. On the Choose Package Products for GoogleSignIn-iOS panel, add both the GoogleSignIn and the GoogleSignInSwift libraries.

You’ve added the Google SignIn SDK to your project!

Creating an OAuth client ID

On the Get started with Google Sign-In for iOS and macOS page, scroll down till you see the big blue Create an OAuth client ID button. Click on the button. If you’re not signed in to Google, you’ll be prompted to sign in. You will need a gmail account, not your umich email address, to create an OAuth client ID. Once you’ve signed in, click the Create an OAuth client ID button again.

At the linked page,

  1. Click the big blue Create an OAuth client ID button.
  2. Enter your project name, in this case we will use swiftUIChatter.
  3. Click Next and enter your project name yet again.
  4. The next step is “Where are you calling from?”. Choose iOS.
  5. This will prompt for a Bundle ID. For this we need to navigate to the Xcode project and get the Bundle Identifier under the Project Information (first top icon on the Xcode left pane). Click on TARGETS > swiftUIChatter, on the tab menu, click on Signing & Capabilities. Copy and paste the bundle identifier (screenshot). The Bundle ID of your Xcode project must match EXACTLY the Bundle ID used for the OAuth client.
  6. Click Create.
  7. Click Download Client Configuration. This will download a credentials.plist file to your Mac (most likely into your Downloads folder).

Next we add your CLIENT_ID and a URL Scheme to Xcode, as required by Google Sign-in:

  1. Click on the downloaded credentials.plist file.
  2. Copy the value of CLIENT_ID to your clipboard.
  3. In Xcode select the top swiftUIChatter top open up the project properties, make sure swiftUIChatter is selected under TARGETS, then navigate to Info in the navigation bar (screenshot).
  4. Click the + button on as you hover over the last entry of the Info or right click and select Add Row.
  5. In the new row, under Key enter GIDClientID and paste your CLIENT_ID from the clipboard to the corresponding Value field and hit return.
  6. Go back to your credentials.plist file and copy the value of REVERSED_CLIENT_ID to your clipboard.
  7. Back on the Info tab, scroll down and expand URL Types, then click the + button in the URL Types section and paste the content of your clipboard into the URL Schemes field, leaving all of the other fields empty/unchanged (screenshot).
  8. On Xcode navigator/left pane, right-click the Info entry (usually located in the lower half of the list of file names) and select Open As > Property List and verify that you’ve correctly set your info properties: GIDClientID should hold your CLIENT_ID and URL Types > Item 0 > URL Schemes > Item 0 should have your REVERSED_CLIENT_ID (screenshot).

And that’s all you need to obtain a Google OAuth client ID.

Obtaining chatterID

We’ll tackle integrating Google Sign-In as three tasks:

  1. to use Google Sign-In to obtain a chatterID,
  2. to use iOS Keychain to store chatterID, and
  3. to use biometric check to control access to iOS Keychain.

In this section we work on obtaining chatterID. For the tutorial, we will re-use the files Chatt.swift and ChattScrollView.swift from the Chatter tutorial unmodified.

ChatterID

Create a new Swift source file called ChatterID.swift. We store the chatterID obtained from the chatterd backend in a singleton called ChatterID:

import SwiftUI

final class ChatterID: @unchecked Sendable {
    static let shared = ChatterID() 
    private init() {}
                                    
    var creator = ""
    var expiration = Date(timeIntervalSince1970: 0.0)
    private var _id: String?
    var id: String? {
        get { Date() >= expiration ? nil : _id }
        set(newValue) { _id = newValue }
    }
}

ChatterID.shared.id is nil when either the user has not obtained a chatterID from chatterd or the ID has expired.

SubmitButton

We start with the UI to initiate Google Sign-In so that you can obtain an ID Token to use with and test the backend.

First change the username property in your ChattViewModel in swiftUIChatterApp.swift file to a var. Then add a new property to ChattViewModel:

    var showOk = false

When the user clicks on the SubmitButton, we first check if we have a valid chatterID. If we don’t, we try to obtain one before posting the chatt. To your SubmitButton struct in ContentView.swift, add the following properties:

    var focus: FocusState<Bool>.Binding
    @Binding var getSignedin: Bool
    @Binding var completion: (() async -> Void)?

Replace the whole Task {} block of the Button’s action with:

                if (ChatterID.shared.id == nil) {
                    completion = submit
                    getSignedin = true
                } else {
                    await submit()
                }

The function submit() called above contains, more or less, the original code in the Task {} block. Add signin() as a method of your SubmitButton:

    private func submit() async {
        await ChattStore.shared.postChatt(Chatt(username: vm.username, message: vm.message), errMsg: Bindable(vm).errMsg)
        if vm.showOk || vm.errMsg.isEmpty {
            await ChattStore.shared.getChatts(errMsg: Bindable(vm).errMsg)
        }
        vm.message = ""
        isSending = false
        vm.showError = !vm.showOk && !vm.errMsg.isEmpty
        Task (priority: .userInitiated) {
            withAnimation {
                scrollProxy?.scrollTo(ChattStore.shared.chatts.last?.id, anchor: .bottom)
                focus.wrappedValue = false
            }
        }
    }

One modification from the original code is that we now also check for vm.showOk where we used to check only vm.errMsg. We now use vm.errMsg to carry both error and confirmation messages. We show different alert diaglog boxes depending on the kind of message stored in vm.errMsg, as indicated by vm.showOk.

GoogleSignInButton is given us to as a SwiftUI View, which cannot be called asynchronously. Instead, we have to provide a completion or callback function to be run after completion of signing in. This is why we pull the original code in Task {} into a separate function, submit(). We give submit() to SigninView() as the function to call after completion of signing in. If we do have a valid chatterID and do not need to sign in the user, we call submit directly in SubmitButton’s action.

To our ContentView, we also add a couple of new properties:

    @State private var getSignedin = false
    @State var signinCompletion: (() async -> Void)?

which we pass to SubmitButton, along with the existing focus property. Update your call to SubmitButton with:

                SubmitButton(scrollProxy: $scrollProxy, focus: $messageInFocus, getSignedin: $getSignedin, completion: $signinCompletion)

Then we add two modifiers: one to show an alert dialog box when vm.showOk is true and one to show SigninView when getSignedin is true. After the existing .alert modifier of the VStack of ContentView, add this new one:

        .alert("Biometric Authenticated", isPresented: Bindable(vm).showOk) {
            Button("OK") {
                vm.errMsg = ""
                vm.showOk = false
            }
        } message: {
            Text(vm.errMsg)
        }

Strictly speaking this alert is not needed—arguably even undesirable, we add it for better transparency into the workings of the tutorial, to provide feedback when chatterID is retrieved or restored.

The modifier to initiate sign in is as follows:

        .sheet(isPresented: $getSignedin) {
            SigninView(isPresenting: $getSignedin, completion: $signinCompletion)
                .presentationDetents([.fraction(0.25)])
                .presentationDragIndicator(.hidden)
                .interactiveDismissDisabled()
        }

You add it as modifier to the VStack of ContentView alongside the other modifiers. That’s all the changes we’ll make to ContentView.swift.

SigninView

So that we can present it in a sheet, we encapsulate the Google Sign-in button in its own View. Create a new Swift file, call it SigninView and declare the following View in it.

import SwiftUI
import GoogleSignIn
import GoogleSignInSwift

struct SigninView: View {
    @Environment(ChattViewModel.self) private var vm
    @Binding var isPresenting: Bool
    @Binding var completion: (() async -> Void)?
    private let signinClient = GIDSignIn.sharedInstance
}

We now call GoogleSignInButton() to create a sign-in button and initiate the sign-in process. To sign in the user, Google Sign-In will present a UIKit-based view for user to enter their account name, another for their password or biometric check, and perhaps other user-confirmation views. To presentat these UIKIt views, Google Sign-In requires a UIKit ViewController. This being an iPhone SwiftUI app, we have only one ViewController: the rootViewController of the application’s only scene. In the following code, we first obtain our only ViewController and pass it to Google’s sign-in flow. The Google Sign-in library is already asynchronous, albeit not suspending. Instead, it relies on a callback or completion function, which we provide here. The frame() modifier sizes the sign-in button and centers it. Put the following code in your SigninView struct:

    var body: some View {
        if let rootVC = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.keyWindow?.rootViewController {
            VStack {
                Spacer()
                Text("Click once. Signing in with UIKit. Be patient.")
                Spacer()
                GoogleSignInButton {
                    signinClient.signIn(withPresenting: rootVC){ result, error in
                        if let token = result?.user.idToken?.tokenString {
                            getChatterID(token)
                        } else {
                            vm.errMsg = "Failed Google Sign-In error: \(error == nil ? String(describing: error?.localizedDescription) : "unknown"). Please try again."
                        }
                        isPresenting.toggle()
                    }
                }
                .frame(width:100, height:50, alignment: Alignment.center)
                // refresh or restore

                Spacer()
            }
        }
    }                

To reduce the frequency we prompt the user to sign in, and the accompanying laggy trip to UIKit land, we perform two checks prior to showing the sign-in button:

  1. if the user is already signed in (signinClient.currentUser is not nil), we “silently” check the freshness of the user’s ID Token, and obtain a new one if needed, without asking the user to sign in again,
  2. if the user is not currently signed in, but their previous sign-in has not expired at Google, we simply restore the previous sign-in and grab a new ID Token.

We perform the above two checks when the sign-in button is displayed by putting them in the .onAppear modifier of GoogleSignInButton so that these side-effect functions are not called upon every re-rendering. Replace // refresh or restore comment above with:

            .onAppear {
                    if let token = signinClient.currentUser?.idToken?.tokenString {
                        getChatterID(token)
                        isPresenting.toggle()
                    } else {
                        signinClient.restorePreviousSignIn { user, error in
                            if error == nil, let token = user?.idToken?.tokenString {
                                getChatterID(token)
                                isPresenting.toggle()
                            }
                            // else show GoogleSignInButton and let user sign in
                        }
                    }
            }

If we’re able to obtain a chatterID or if Google Sign-In returns an error, we dismiss SigninView(), otherwise, we present the GoogleSignInButton().

In all three success cases: whether we’re refreshing the token of an already signed in user, or we’re restoring the user’s previous sign-in, or the user is newly signed in, we always call getChatterID(_:) to obtain a chatterID. Add getChatterID(_:) as a method of your SigninView struct:

    private func getChatterID(_ token: String) {
        Task (priority: .background) {
            if let _ = await ChattStore.shared.addUser(token, errMsg: Bindable(vm).errMsg) {

                // save chatterID

                await completion?()  // call the completion passed to SigninView()
            } else {
                vm.errMsg = "Chatter backend cannot add user. Please try again."
            }
        }
    }

The function getChatterID(_:) calls addUser(_:errMsg:) to asynchronously add the user to the chatterd backend and obtain a chatterID. Later when we save the chatterID to Keychain, we will add a call to the save() method here, replacing the // save chatterID comment.

That’s all the code we need for the SigninView() View. Now we work on the networking code.

The networking

In addition to the getchatts API, we add a new API, adduser. We also modify the postchatt API, which must now carry an authenticated credential to post a chatt. Due to this change, we rename the postchatt API, postauth.

addUser(_:errMsg:)

Add the following addUser(_:errMsg:) function to your ChattStore class. The method addUser(_:errMsg:) creates a JSON object containing (1) the app’s OAuth 2.0 Client ID you previously created with Google Sign-In and (2) the idToken user obtained when signing in. It then asynchronously sends the JSON object to the chatterd backend with an HTTP POST request.

The back-end server verifies the validity of the presented idToken with Google. If verification is successful, the backend returns a chatterID (a String). Subsequently, the backend will identify the user by this chatterID, for the lifetime of the chatterID. If the token cannot be validated for whatever reason, addUser(_:errMsg:) returns nil. The backend also obtains the name registered with the ID Token and returns it along with the chatterID. We use it to align retrieved chatts right (belonging to the user) or left. When Google cannot return a registered name, we default to "Profile NA", which messes up our UI, but doesn’t otherwise impact the workings of the app.

Replace YOUR_APP'S_OAUTH2.0_CLIENT_ID below with your app’s OAuth 2.0 Client ID, retain the quotation marks.

You can retrieve your clientID from the credentials.plist file you downloaded from Google earlier. ```swift func addUser(_ idToken: String?, errMsg: Binding) async -> String? { guard let idToken else { return nil }

    let jsonObj = ["clientID": "YOUR_APP'S_OAUTH2.0_CLIENT_ID",
                   "idToken" : idToken]
    guard let requestBody = try? JSONSerialization.data(withJSONObject: jsonObj) else {
        errMsg.wrappedValue = "addUser: JSONSerialization error"
        return nil
    }
    
    guard let apiUrl = URL(string: "\(serverUrl)/adduser") else {
        errMsg.wrappedValue = "addUser: Bad URL"
        return nil
    }
    var request = URLRequest(url: apiUrl)
    request.httpMethod = "POST"
    request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
    request.httpBody = requestBody
    
    do {
        let (data, response) = try await URLSession.shared.data(for: request)
        
        if let http = response as? HTTPURLResponse, http.statusCode != 200 {
            errMsg.wrappedValue = "addUser: \(http.statusCode) \(HTTPURLResponse.localizedString(forStatusCode: http.statusCode))\n\(apiUrl)"
            return nil
        }
        
        // obtain username and chatterID from backend

    } catch {
        errMsg.wrappedValue = "addUser: POSTing failed \(error)"
        return nil
    }
} ``` If we did not retrieve a valid `chatterID` from the backend, the `catch` block simply  returns an error message and `nil`. Otherwise, we first deserialize the backend's response  to our POST above, and if the deserialization is successful, we set the creator of the `chatterID` in the `ChatterID` singleton to the registered name returned by Google to `chatterd`---note that the `creator` name cannot be longer than 100 elements. We also  store the `chatterdID` returned by the backend along with its computed expiration time  in the `ChatterID` singleton. In the successful case, `addUser(_:errMsg:)` returns the `chatterID`. Replace the `// obtain username and chatterID from backend` comment above with: ```swift
        guard let jsonObj = try? JSONSerialization.jsonObject(with: data) as? [String:Any] else {
            errMsg.wrappedValue = "addUser: JSON deserialization"
            return nil
        }

        if let creator = jsonObj["username"] as? String {
            if creator.count > 100 {
                errMsg.wrappedValue = "addUser: creator name (\(creator) longer than 100 characters"
                return nil
            }
            ChatterID.shared.creator = creator
        }
        ChatterID.shared.id = jsonObj["chatterID"] as? String
        ChatterID.shared.expiration = Date()+(jsonObj["lifetime"] as! TimeInterval)
        
        return ChatterID.shared.id ``` 

postChatt(_:errMsg:)

The postauth API, replacing the postchatt API, requires that chatterID be sent along with each chatt. It first verifies that the chatterID exists in the database and has not expired. If so, the new chatt, along with the user’s username (retrieved from the database) will be added to the chatts database. Otherwise, HTTP error 401 is returned to the client.

We need to make two changes to postChatt(_:errMsg:) in ChattStore:

  1. replace:
    "username": chatt.username,
    

    with:

    "chatterID": ChatterID.shared.id,
    
  2. change postchatt in the apiUrl declaration to postauth

We can use the rest of postChatt(_:errMsg:) unmodified.

That’s all we need to do to obtain and use chatterID. Next we will look at how to store the chatterID in iOS Keychain and how to use biometric check to control access to the chatterID stored in the Keychain.

At this point, if you haven’t completed your backend, we suggest you switch gear and work on your backend. You’ll need the ID Token obtained from Google Sign-In to test your backend, which is why we had you work on the front end up to this point first.

The ID Token is the first argument passed to addUser(_:errMsg:) above. Put a break point in addUser(_:errMsg:) and run your front end to this break point. In Xcode, mouse over the idToken variable inside the addUser(_:errMsg:) function, then click on the i icon on the extreme right of the box that pops up. The idToken will be displayed as plain text on yet another pop up box. Click in the box, then select all and copy (⌘-A, ⌘-C). This is your idToken to be used when testing your backend.

Keychain with biometric access control

Returning to the front end: we store the chatterID obtained from the backend encrypted in iOS’s Keychain. Keychain uses iPhone’s Secure Enclave to hold our keys, with key access protected by biometric authentication. Recall that the only purpose of the biometric check is to control access to the stored chatterID across invocations of the app. It doesn’t make the sign-in process itself any more secure.

Let’s first figure out how to securely store ChatterID in Keychain. Then we will look at how to use biometric control for Keychain access. The following instructions are heavily based on Using the iOS Keychain with Biometrics.

Keychain and Secure Enclave

Keychain is used to store small encrypted data (such as passwords and chatterID) in an SQLite database. The key that encrypts the data itself is generated and stored in iPhone’s Secure Enclave. “Secure Enclave” or “trusted execution environment” (TEE) is a piece of hardware dedicated to performing cryptographic functions and storing secret keys. It has a limited set of APIs. To use it, you ask it to create a key for you. It returns a handle/alias for the key, but not the key itself. You can give the hardware plain text to be encrypted using the key it is holding for you. Or you give it encrypted text to be decrypted using the key under your name. The key itself never leaves the hardware’s possession and you can only ask the hardware to do encryption or decryption.

Since ChatterID is not an encryption key, storing it encrypted in the Keychain fits our purpose.

Keychain storage

When the user launches ContentView, we load the previous session’s chatterID from the Keychain. In the .task modifier block of ContentView, add the following code before calling getChatts(errMsg:):

            await ChatterID.shared.open(username: Bindable(vm).username, errMsg: Bindable(vm).errMsg, showOk: Bindable(vm).showOk)

and upon returning from getChatts(errMsg:), check both vm.showOk and vm.errMsg to determine whether we need to pop up an alert box:

            vm.showError = !vm.showOk && !vm.errMsg.isEmpty

open()

We define open() as an asynchronous method of the ChatterID class. The “uninitialized expiration time” of ChatterID defaults to Unix epoch (1/1/70). The ChatterID’s expiration time being other than the default indicates that this is not the first time we call open() after the initial launch of the app. It indicates that we have already opened the Keychain and tried to load previous session’s chatterID, so we can just return. Add open() as a method of the ChatterID class:

    func open(username: Binding<String>, errMsg: Binding<String>, showOk: Binding<Bool>) async {
        if expiration != Date(timeIntervalSince1970: 0.0) {
            // not first launch
            return
        }
        
        // search for chatterID

    }

Next we search the Keychain for chatterID and specify a date format we want to use. The Keychain API on iOS dates back to its Objective-C days. Programming the Keychain has a “low-level” flavor to it. Replace // search for chatterID with:

        let searchFor: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrDescription: "ChatterID",
            kSecReturnData: true,
            kSecReturnAttributes: true,
        ]
        
        var itemRef: AnyObject?
        let searchStatus = SecItemCopyMatching(searchFor as CFDictionary, &itemRef)

        let df = DateFormatter()
        df.dateFormat="yyyy-MM-dd HH:mm:ss '+'SSSS"

        // handle search results

The code search for an item of “generic password” security class in the Keychain. The item has attribute description of "ChatterID". If the item is found, we want both the value associated with the attribute and all of its other attributes to be returned.

If the search is successful, store the found item value in the id property of ChatterID class, and store the Label attribute of the found item in the expiration property of ChatterID. Replace // handle search results with the following code:

        switch (searchStatus) {
        case errSecSuccess:      // found keychain
            if let item = itemRef as? NSDictionary,
               let data = item[kSecValueData] as? Data,
               let dateStr = item[kSecAttrLabel] as? String,
               let date = df.date(from: dateStr),
               let creator = item[kSecAttrCreator] as? String
            {
                id = String(data: data, encoding: .utf8)
                expiration = date
                if !creator.isEmpty {
                    username.wrappedValue = creator
                }
                showOk.wrappedValue = true
                errMsg.wrappedValue = "ChatterID available from last session."
            } else {
                errMsg.wrappedValue = "ChatterID.open: id found but invalid!\nDelete and reinstall Chatter."
            }
        
        // if not found, insert template

If the item is not found, we insert a template into Keychain for "ChatterID" item with unitialized (“blank”) value and expiration time set to the default “1/1/70”. We insert the “blank” template here so that when we receive a chatterID from the backend later, all we’ll need to do is to update the item. Replace // if not found, insert template comment with:

        case errSecItemNotFound: // add template
            
            // add biometric check
            
            let item: [CFString: Any] = [
                kSecClass: kSecClassGenericPassword,
                kSecAttrDescription: "ChatterID",
                kSecAttrLabel: df.string(from: expiration),
                kSecAttrCreator: creator as CFString,
                // biometric check attribute
            ]

            let addStatus = SecItemAdd(item as CFDictionary, nil)
            if (addStatus != 0) {
                errMsg.wrappedValue = "ChatterID.open add: \(String(describing: SecCopyErrorMessageString(addStatus, nil)!))"
            }

        // search error

We close off open() with the following code for the default case. Replace // search error with:

        default:
            errMsg.wrappedValue = "ChatterID.open search: \(String(describing: SecCopyErrorMessageString(searchStatus, nil)!))"
        }

Keychain update

Every time we obtain a new chatterID from the backend, we save it to the Keychain: in the getChatterID(_:) function of your SigninView, after successful return from addUser(_:errMsg:), replace the comment, // save chatterID, with the following:

                await ChatterID.shared.save(errMsg: Bindable(vm).errMsg, showOk: Bindable(vm).showOk)
                if vm.errMsg.isEmpty {
                    vm.showOk = true
                    vm.errMsg = "ChatterID refreshed."
                }

save()

Back in the ChatterID.swift file, define save() as a method of the ChatterID class:

    func save(errMsg: Binding<String>, showOk: Binding<Bool>) async {
        let df = DateFormatter()
        df.dateFormat="yyyy-MM-dd HH:mm:ss '+'SSSS"
        
        let item: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrDescription: "ChatterID",
        ]
        
        let updates: [CFString: Any] = [
            kSecValueData: id?.data(using: .utf8) as Any,
            kSecAttrLabel: df.string(from: expiration),
            kSecAttrCreator: creator as CFString
        ]
        
        let updateStatus = SecItemUpdate(item as CFDictionary, updates as CFDictionary)
        if (updateStatus != 0) {
            errMsg.wrappedValue = "\(String(describing: SecCopyErrorMessageString(updateStatus, nil)!))\nChatterID can be used to post, but you will be asked to sign in again when you restart Chatter"
        } else {
            showOk.wrappedValue = true
            errMsg.wrappedValue = "ChatterID saved"
        }
    }

The code defines two dictionary variables: the item variable describing the item we want to update (in this case an item of “generic password” security class with attribute description "ChatterID") and the updates variable containing the fields of the item we want to update (in this case, the value associated with the item and its Label and Creator attributes). Then we call the secure update method, passing it the two dictionaries, and handle the update results.

That’s all the changes we need to make to store chatterID in the Keychain! You can now test your implementation of Keychain storage by closing your Chatter app after making a post, re-launching it within your chatterID lifetime (which you set in the backend), and it should allow you to post without requiring you to sign in again.

To help you test your code, you may want to add the following method to the ChatterID class:

    func delete(_ errMsg: Binding<String>) async {
        let item: [CFString: Any] = [
            kSecClass: kSecClassGenericPassword,
            kSecAttrDescription: "ChatterID",
        ]
        
        let delStatus = SecItemDelete(item as CFDictionary)
        if (delStatus != 0) {
            errMsg.wrappedValue = "ChatterID.delete: \(String(describing: SecCopyErrorMessageString(delStatus, nil)!))"
        }
    }

and call it whenever you want to delete your Keychain entry; for example, right before you call open() in .task of ContentView. You may also want to play with shorter or longer chatterID lifetime in the backend and see whether you’re asked to sign in again when expected.

Biometric check

To add biometric check to control access to your key chain, we only need to make three small changes.

  1. Add the Privacy - Face ID Usage Description key to your Info list:
    • Click on your project name (first item in your left/navigator pane), then click on the Info tab.
    • In the Custom iOS Target Properties section, right click (or ctl-click) any row in the table,
    • choose Add Row (screenshot), and
    • in the drop down menu, select Privacy - Face ID Usage Description and, in the Value field to the right enter a descriptive usage such as, “to access chatterID in Keychain”

      the same usage description will be used for Touch ID

  2. In the open() method of the ChatterID class, for case errSecItemNotFound:, replace the // add biometric check comment with:
             // biometric check
             let accessControl = SecAccessControlCreateWithFlags(nil,
               kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
               .userPresence,
               nil)!
    
  3. Still within case errSecItemNotFound:, add access control to the item dictionary, replacing, // biometric check attribute:
                 kSecAttrAccessControl: accessControl  // biometric check
    

    The kSecAttrAccessControl: accessControl field enables access control. The .userPresence flag requires the user to be present, which translates to a FaceID check, if available, or a TouchID check, if FaceID is not available, or prompting for system passcode, as a fall-back mechanism.

Biometric prompt on simulator

We explain the use of simulated biometric prompt in Getting Started with iOS Development. To use it, first add import LocalAuthentication to the top of your ChatterID file, then add the following property to your ChatterID class:

    #if targetEnvironment(simulator)
    private let auth = LAContext()
    #endif

Finally add the following code to ChatterID’s open() and save() methods. In open(), add the code after the expiration check. In save(), add it first thing. Replace <Keychain op> in the following with open() or save() respectively.

        #if targetEnvironment(simulator)
        guard let _ = try? await auth.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "to allow simulator to save ChatterID to KeyChain") else { errMsg.wrappedValue = "<Keychain op> failed"; return }
        #endif

And with that, we’re done with this tutorial! Don’t forget to test the last version of your app with biometric authentication as you did earlier versions.

Congratulations! You’re done with the front end! (Don’t forget to work on the backend!)

Run and test to verify and debug

Once you have your backend setup, run your front end against your backend—mada.eecs.umich.edu is not available for this tutorial. You will not get full credit if your front end is not set up to work with your backend!

You may need to sign out from your Google account on your device to test Google Sign-In. On your development host (not on your device), open up your browser and go to a Google property, e.g., Gmail. At the upper right corner of your browser, click on your avatar icon and tap on Manage your Google Account button on the drop-down menu. Once in your Google Account, click on the Security menu on the left. In Security, scroll down until you see Your devices card. At the bottom of the card, click on Manage your devices. Find your device and click on the 3-vertical dot menu and select Sign out.

Front-end submission guidelines

:point_right: Unlike in previous labs, there is one CRUCIAL extra step to do before you push your lab to GitHub: ensure that the Bundle identifier under the Signing & Capabilities tab of your Project pane is the one you used to create your OAuth client ID or else we won’t be able to test your client ID.

We will only grade files committed to the main branch. If you use multiple branches, please merge them all to the main branch for submission.

Push your front-end code to the same GitHub repo you’ve submitted your back-end code:

:point_right: Go to the GitHub website to confirm that your front-end files have been uploaded to your GitHub repo and that your repo has a folder structure outline similar to the following. If your folder structure is not as outlined, our script will not pick up your submission and, further, you may have problems getting started on latter tutorials. There could be other files or folders in your local folder not listed below, don’t delete them. As long as you have installed the course .gitignore as per the instructions in Preparing GitHub for Reactive Tutorials, only files needed for grading will be pushed to GitHub.

  reactive
    |-- chatter.zip
    |-- chatterd
    |-- chatterd.crt
    |-- llmprompt.zip
    |-- signin
        |-- swiftUIChatter
            |-- swiftUIChatter.xcodeproj
            |-- swiftUIChatter
    # and other files or folders   

Verify that your Git repo is set up correctly: on your laptop, grab a new clone of your repo and build and run your submission to make sure that it works. You will get ZERO point if your tutorial doesn’t build, run, or open.

IMPORTANT: If you work in a team, put your team mate’s name and uniqname in your repo’s README.md (click the pencil icon at the upper right corner of the README.md box on your git repo) so that we’d know. Otherwise, we could mistakenly think that you were cheating and accidentally report you to the Honor Council, which would be a hassle to undo. You don’t need a README.md if you work by yourself.

Review your information on the Tutorial and Project Links sheet. If you’ve changed your teaming arrangement from previous tutorial’s, please update your entry. If you’re using a different GitHub repo from previous tutorial’s, invite eecsreactive@umich.edu to your new GitHub repo and update your entry.

References

Secure Enclave

Keychain

Biometric


Prepared by Wendan Jiang, Alexander Wu, Benjamin Brengman, Ollie Elmgren, Nowrin Mohamed, Xin Jie ‘Joyce’ Liu, Chenglin Li, Yibo Pi, and Sugih Jamin Last updated: August 19th, 2025