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