Building an Okta Clone (Part 4): Reading from QR Codes
The functionality for our app is essentially there, however the code that we get needs to be externally generated so we can initiate the two factor authentication. The way this works is that the server or external app will generate a QR code that includes not only our secret, but our information as well. This allows us to have multiple accounts. For the sake of this tutorial though we will stick to one account. When we scan a QR code, we are really reading a URL that looks something like this:
otpauth://totp/Amazon:ian@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Amazon
There are a few components here but for the sake of the tutorial we are just looking for these:
secret=JBSWY3DPEHPK3PXP
issuer=Amazon
Here is the flow:
1. Scan the QR Code (AVFoundation).
2. Parse the URL to extract the label (Amazon) and the secret (JBSWY...).
3. Decode the Secret.
There is a hang up though. That secret string (JBSWY...) is Base32 Encoded, not standard UTF8. If we try to just turn it into Data directly, it won't work. We’ll need to write a function to decode this. Lets start with parsing a url. After we know we can get what we need from the URL, we will implement the camera to tie it all together.
## Creating the OTP Parser
Create a new file called OTPParser.swift. In here we will have our OTPToken and our OTPParser:
struct OTPToken {
let label: String
let issuer: String
let secret: Data
}
struct OTPParser {
…
}
We are going to make two functions, one to parse, and one to decode from Base32. Just to get it our of the way, here is the decode function. You could actually put this outside of the parser if you would like:
private func base32Decode(_ string: String) -> Data? {
let alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
var bits = 0
var value = 0
var data = Data()
for char in string.uppercased() {
guard let index = alphabet.firstIndex(of: char) else { continue }
let val = alphabet.distance(from: alphabet.startIndex, to: index)
value = (value << 5) | val
bits += 5
if bits >= 8 {
let byte = UInt8((value >> (bits - 8)) & 0xFF)
data.append(byte)
bits -= 8
}
}
return data
}
Now the actual parsing function will do 5 things:
1. Extract the Label
2. Extract the Parameters
3. Find the Secret String
4. Decode from Base32
5. Find the Issuer (If not we’ll default to the label)
Here is the function:
struct OTPParser {
static func parse(uri: String) -> OTPToken? {
guard let url = URL(string: uri),
// this is the standard scheme
url.scheme == "otpauth",
url.host == "totp" else {
print("❌ Invalid Scheme or Type. Only 'otpauth://totp' is supported.")
return nil
}
// 1. Extract Label
let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let label = path
// 2. Extract Parameters
// We need 'secret' and optional 'issuer'
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = components.queryItems else {
return nil
}
// 3. Find the Secret String
guard let secretString = queryItems.first(where: { $0.name == "secret" })?.value else {
print("❌ Missing Secret Parameter")
return nil
}
// 4. Decode Base32 (The Hard Part)
guard let secretData = base32Decode(secretString) else {
print("❌ Failed to decode Base32 secret")
return nil
}
// 5. Find Issuer (Optional, default to label)
let issuer = queryItems.first(where: { $0.name == "issuer" })?.value ?? label
return OTPToken(label: label, issuer: issuer, secret: secretData)
}
}
Ok lets go back to the view model to test this. Inside of the initializer, we can mock a URL:
init() {
// TEST CASE: Use a real Google Authenticator URI
// Secret "JBSWY3DPEHPK3PXP" decodes to "Hello!Hello!" (ASCII)
let testURI = "otpauth://totp/Example:Alice?secret=JBSWY3DPEHPK3PXP&issuer=Example"
if let token = OTPParser.parse(uri: testURI) {
print("✅ Parsed Token!")
print("Label: \(token.label)")
print("Secret Bytes: \(token.secret as NSData)")
// Save this real parsed secret to keychain
try? KeychainManager.save(key: token.secret)
}
loadSecret()
startTimer()
}
Now when you run this you should see ✅ Parsed Token! And a new token generated from that.
## Creating the UI
Now that we know our logic is working, we need to remove the test token from our initializer, and create the QRScannerView.swift file. Here is the full code for making a scanner view that we can implement in our swiftui file:
import SwiftUI
import AVFoundation
struct QRScannerView: UIViewRepresentable {
// This closure will be called when a code is found
var didFindCode: (String) -> Void
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
func makeUIView(context: Context) -> UIView {
let view = UIView()
// Setup the Capture Session
let captureSession = AVCaptureSession()
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { return view }
let videoInput: AVCaptureDeviceInput
do {
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
} catch { return view }
if (captureSession.canAddInput(videoInput)) { captureSession.addInput(videoInput) }
let metadataOutput = AVCaptureMetadataOutput()
if (captureSession.canAddOutput(metadataOutput)) {
captureSession.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(context.coordinator, queue: DispatchQueue.main)
metadataOutput.metadataObjectTypes = [.qr]
}
let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
previewLayer.videoGravity = .resizeAspectFill
view.layer.addSublayer(previewLayer)
// Start running
DispatchQueue.global(qos: .userInitiated).async {
captureSession.startRunning()
}
return view
}
func updateUIView(_ uiView: UIView, context: Context) {
// This is called whenever the SwiftUI frame changes
if let layer = uiView.layer.sublayers?.first as? AVCaptureVideoPreviewLayer {
layer.frame = uiView.bounds
}
}
// Our Bridge between UIKit Delegate and SwiftUI Closure
class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
var parent: QRScannerView
init(parent: QRScannerView) {
self.parent = parent
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
if let metadataObject = metadataObjects.first {
guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return }
guard let stringValue = readableObject.stringValue else { return }
// We found a code! Add some haptic feedback so we can tell
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
// Send it back to SwiftUI
parent.didFindCode(stringValue)
}
}
}
}
Now all we really need to do is add this as a sheet. We also included the state variable to tell whether or not to display the scanner:
struct ContentView: View {
@StateObject private var vm = TokenViewModel()
@State private var isScanning = false
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("\(vm.secondsRemaining)")
Text("\(vm.code)")
Text("\(vm.progress)")
Button(action: { isScanning = true }) {
Image(systemName: "qrcode.viewfinder")
.font(.title2)
.foregroundColor(.blue)
}
}
.padding()
.sheet(isPresented: $isScanning) {
QRScannerView { code in
// When code is found:
vm.handleScan(code: code)
isScanning = false // Close sheet
}
}
}
}
We are going to add the handleScan function soon so ignore the error for now. Also this won’t work if we don’t add the camera permission to our Info.plist. If you are cloning the project you shouldn’t have to do this, but if you are following along in your own project you will. Go to the project file in the project tree on the left in XCode. The select Targets (in this case mine is called OktaClone). Then you should see “Custom … Target Properties”. Hover over any of them, select the plus button and add “Privacy - Camera Usage Description”. I just added “We need to use the camera for scanning QR codes”.
If you try to run it right now, nothing will happen because our view model isn’t ready. First, lets make sure we have a proper empty state so that we aren’t getting confused with stale codes. We are going to go into our view model and make a reset function and call it in our initializer:
init() {
// remember to comment/uncomment for testing
reset()
loadSecret()
startTimer()
}
func reset() {
try? KeychainManager.delete()
self.secret = nil
self.hasToken = false
self.timer?.cancel()
self.code = "--- ---"
}
Now lets wrap this up with our handleScan function:
func handleScan(code: String) {
// Use your Parser!
guard let token = OTPParser.parse(uri: code) else {
print("❌ Invalid QR Code")
return
}
// Save the real secret
print("✅ Scanned: \(token.label)")
try? KeychainManager.save(key: token.secret)
// Reload
self.secret = token.secret
update()
}
Now lets load it correctly. We need to include a Published variable here:
@Published var hasToken: Bool = false
Then we are going to update the loadSecret function to make sure that if there is no code, we wait for the user:
func loadSecret() {
do {
// Try to read from Keychain
self.secret = try KeychainManager.read()
self.hasToken = true
print("Success: Loaded secret from Keychain")
} catch {
print("Keychain Empty or Error: \(error)")
print("No secret found. Waiting for user to scan.")
self.hasToken = false
}
}
Now we need go to this URL to generate a QR Code:
https://generate-random.org/qr-codes
Past this into the data textfield:
otpauth://totp/TestAccount:Ian?secret=JBSWY3DPEHPK3PXP&issuer=TestAccount
Once we have the QR Code, run the app and the screen should have an empty code. Select the QR Code button and it should open the camera view. Hover over the QR code that you generated and it should buzz, close the view, and generate a code. Now its real! This should be the full lifecycle of 2FA.
Next, we are going to protect the keys with FaceID