Building an Okta Clone (Part 5): Authenticating with Face ID
The Okta Clone is coming along nicely, but we still have a problem. Right now, if I snatch your unlocked phone I can open your app and steal your passcode. This would be a failure of what’s called the "Possession Factor." To make this actually secure, we need to bind the Possession Factor (The Key) to the Inherence Factor (Your Face).
We are going to upgrade the KeychainManager to use Biometric Access Control. This tells the Secure Enclave to not release these bytes to the CPU unless the user passes a FaceID (or passcode) check.
First thing we need to do is to make sure we add this permission in the info.plist. Open the info.plist (Project Settings -> Info) and add a new row “Privacy - Face ID”. Now we can add that to the save and load functions. In the save function, we need to make sure that when we save it, it’s being saved as data that requires real access control. In this case, we are specifying what access control is associated with the data.
static func save(key: Data) throws {
// 1. Create the Access Control Object
// Basically telling the system: "Require Biometrics (FaceID/TouchID) OR the Device Passcode to read this."
guard let accessControl = SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly,
.userPresence, // This flags requires FaceID or Passcode
nil
) else {
throw KeychainError.unknown(errSecParam)
}
// 2. Construct the Query, using the access control object
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: account,
kSecValueData as String: key,
kSecAttrAccessControl as String: accessControl // <--- Put the object here
]
...
}
Now we are going to add the prompt for Face ID in the load function.
A quick note here, DispatchQueue.global is being used for tutorial purposes, however this is not ideal for production. Swift Concurrency or using a dedicated serial queue would be a much better option for production use.
func loadSecret() {
// We perform this on a background thread so we don't block the UI while FaceID is thinking
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
do {
// This line will PAUSE execution and show the FaceID prompt
let data = try KeychainManager.read()
DispatchQueue.main.async {
self?.secret = data
self?.hasToken = true
self?.startTimer()
print("🔓 FaceID Success: Token Loaded")
}
} catch {
DispatchQueue.main.async {
// Check if it was a "No Token" error or a "FaceID Failed" error
// For simplicity in this tutorial, we treat all errors as "Locked" or "Empty"
print("🔒 Access Denied or Empty: \(error)")
self?.hasToken = false
}
}
}
}
Now with those changes lets do a fresh install.
1. Uninstall the app
2. Run the app with the changes
3. You should see a prompt for Face ID
4. Accept it and you should should see a new code
This is functional, but there is certainly more to do if you would like to take it further. Here are some ideas:
- Make a better UI (Codes, progress indicators, color scheme, etc)
- Add multiple codes
- Use better concurrency
- Improve the architecture
- Turn this into an SDK