Lab 4.3 — Form with Keychain

Goal: Build a login form that validates email/password fields live, handles keyboard avoidance with keyboardLayoutGuide, simulates an auth API call, and persists the returned auth token in the Keychain. On relaunch, the app reads the token and skips the login screen.

Time: ~90 minutes Phase prerequisites: Chapters 4.3, 4.6, 4.7

What you’ll build

Two screens:

  • LoginVC — email field, password field (secure), “Sign in” button (disabled until valid), live validation messages, loading spinner, error banner. Keyboard never covers the active field.
  • HomeVC — placeholder “Welcome <email>” + “Sign out” button. Pushed automatically when a valid token exists; presented after successful sign-in.

The token is stored in Keychain via the Security framework (no third-party deps). Sign-out deletes the token.

Setup

  1. New Xcode project: KeychainForm, UIKit, Swift, no Storyboard.
  2. Configure SceneDelegate to install a root VC chosen by token presence.

Step 1 — Keychain helper

Reuse the pattern from chapter 4.7:

// Keychain.swift
import Foundation
import Security

enum KeychainError: Error {
    case status(OSStatus)
    case dataConversion
}

enum Keychain {
    private static let service = "dev.10x.KeychainForm"

    static func set(_ string: String, for account: String) throws {
        guard let data = string.data(using: .utf8) else { throw KeychainError.dataConversion }
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
        ]
        let attributes: [String: Any] = [
            kSecValueData as String: data,
            kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
        ]
        let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
        if updateStatus == errSecItemNotFound {
            var addQuery = query
            addQuery.merge(attributes) { _, new in new }
            let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
            guard addStatus == errSecSuccess else { throw KeychainError.status(addStatus) }
        } else if updateStatus != errSecSuccess {
            throw KeychainError.status(updateStatus)
        }
    }

    static func get(_ account: String) throws -> String? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
            kSecReturnData as String: true,
            kSecMatchLimit as String: kSecMatchLimitOne,
        ]
        var item: CFTypeRef?
        let status = SecItemCopyMatching(query as CFDictionary, &item)
        if status == errSecItemNotFound { return nil }
        guard status == errSecSuccess, let data = item as? Data, let s = String(data: data, encoding: .utf8) else {
            throw KeychainError.status(status)
        }
        return s
    }

    static func delete(_ account: String) throws {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: account,
        ]
        let status = SecItemDelete(query as CFDictionary)
        guard status == errSecSuccess || status == errSecItemNotFound else {
            throw KeychainError.status(status)
        }
    }
}

enum KeychainKeys {
    static let token = "auth-token"
    static let email = "auth-email"
}

Step 2 — Validators

// Validation.swift
import Foundation

enum FieldValidation {
    case valid
    case invalid(String)

    var isValid: Bool { if case .valid = self { return true } else { return false } }
    var message: String? { if case .invalid(let m) = self { return m } else { return nil } }
}

enum Validators {
    static func email(_ raw: String) -> FieldValidation {
        if raw.isEmpty { return .invalid("Email is required.") }
        // Simple, good-enough regex; for production use NSDataDetector + RFC 5322
        let pattern = #"^[A-Z0-9a-z._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}$"#
        if raw.range(of: pattern, options: .regularExpression) == nil {
            return .invalid("Enter a valid email address.")
        }
        return .valid
    }

    static func password(_ raw: String) -> FieldValidation {
        if raw.count < 8 { return .invalid("Password must be at least 8 characters.") }
        if raw.rangeOfCharacter(from: .decimalDigits) == nil { return .invalid("Include at least one number.") }
        return .valid
    }
}

Step 3 — Simulated auth service

// AuthService.swift
import Foundation

enum AuthError: Error, LocalizedError {
    case invalidCredentials
    case network

    var errorDescription: String? {
        switch self {
        case .invalidCredentials: return "Wrong email or password."
        case .network: return "Couldn't reach the server. Try again."
        }
    }
}

final class AuthService {
    func signIn(email: String, password: String) async throws -> String {
        try await Task.sleep(nanoseconds: 800_000_000)   // simulate latency
        if email == "demo@10x.dev" && password == "password1" {
            return UUID().uuidString
        }
        throw AuthError.invalidCredentials
    }
}

Step 4 — LoginVC

// LoginVC.swift
import UIKit

final class LoginVC: UIViewController {
    private let scrollView = UIScrollView()
    private let contentStack = UIStackView()
    private let titleLabel = UILabel()
    private let emailField = UITextField()
    private let emailError = UILabel()
    private let passwordField = UITextField()
    private let passwordError = UILabel()
    private let signInButton = UIButton(configuration: .filled())
    private let bannerLabel = UILabel()
    private let spinner = UIActivityIndicatorView(style: .medium)

    private let auth = AuthService()
    private var signInTask: Task<Void, Never>?

    var onSignedIn: ((_ email: String, _ token: String) -> Void)?

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        setupViews()
        setupConstraints()
        wireActions()
        updateValidation()
    }

    private func setupViews() {
        scrollView.translatesAutoresizingMaskIntoConstraints = false
        scrollView.alwaysBounceVertical = true
        view.addSubview(scrollView)
        scrollView.addSubview(contentStack)
        contentStack.axis = .vertical
        contentStack.spacing = 12
        contentStack.translatesAutoresizingMaskIntoConstraints = false

        titleLabel.text = "Sign in"
        titleLabel.font = .preferredFont(forTextStyle: .largeTitle).bold()
        titleLabel.adjustsFontForContentSizeCategory = true

        emailField.placeholder = "Email"
        emailField.borderStyle = .roundedRect
        emailField.keyboardType = .emailAddress
        emailField.textContentType = .emailAddress
        emailField.autocapitalizationType = .none
        emailField.autocorrectionType = .no
        emailField.returnKeyType = .next

        emailError.font = .preferredFont(forTextStyle: .caption1)
        emailError.textColor = .systemRed
        emailError.numberOfLines = 0

        passwordField.placeholder = "Password"
        passwordField.borderStyle = .roundedRect
        passwordField.isSecureTextEntry = true
        passwordField.textContentType = .password
        passwordField.returnKeyType = .go

        passwordError.font = .preferredFont(forTextStyle: .caption1)
        passwordError.textColor = .systemRed
        passwordError.numberOfLines = 0

        var buttonConfig = UIButton.Configuration.filled()
        buttonConfig.title = "Sign in"
        signInButton.configuration = buttonConfig

        bannerLabel.font = .preferredFont(forTextStyle: .footnote)
        bannerLabel.textColor = .systemRed
        bannerLabel.numberOfLines = 0
        bannerLabel.isHidden = true

        spinner.hidesWhenStopped = true

        let hint = UILabel()
        hint.text = "Use demo@10x.dev / password1"
        hint.font = .preferredFont(forTextStyle: .footnote)
        hint.textColor = .secondaryLabel

        [titleLabel, emailField, emailError, passwordField, passwordError, signInButton, bannerLabel, spinner, hint].forEach {
            contentStack.addArrangedSubview($0)
        }
    }

    private func setupConstraints() {
        let frame = scrollView.frameLayoutGuide
        let content = scrollView.contentLayoutGuide
        NSLayoutConstraint.activate([
            scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            scrollView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor),

            contentStack.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
            contentStack.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -24),
            contentStack.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20),
            contentStack.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20),
            contentStack.widthAnchor.constraint(equalTo: frame.widthAnchor, constant: -40),
        ])
    }

    private func wireActions() {
        emailField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
        passwordField.addTarget(self, action: #selector(textChanged), for: .editingChanged)
        emailField.addTarget(self, action: #selector(emailReturn), for: .editingDidEndOnExit)
        passwordField.addTarget(self, action: #selector(submit), for: .editingDidEndOnExit)
        signInButton.addAction(UIAction { [weak self] _ in self?.submit() }, for: .touchUpInside)
    }

    @objc private func textChanged() { updateValidation() }
    @objc private func emailReturn() { passwordField.becomeFirstResponder() }

    private func updateValidation() {
        let emailResult = Validators.email(emailField.text ?? "")
        let passwordResult = Validators.password(passwordField.text ?? "")
        emailError.text = emailField.hasText ? emailResult.message : nil
        emailError.isHidden = (emailError.text ?? "").isEmpty
        passwordError.text = passwordField.hasText ? passwordResult.message : nil
        passwordError.isHidden = (passwordError.text ?? "").isEmpty
        signInButton.isEnabled = emailResult.isValid && passwordResult.isValid
        bannerLabel.isHidden = true
    }

    @objc private func submit() {
        view.endEditing(true)
        guard signInButton.isEnabled else { return }
        let email = emailField.text ?? ""
        let password = passwordField.text ?? ""
        bannerLabel.isHidden = true
        spinner.startAnimating()
        signInButton.isEnabled = false

        signInTask?.cancel()
        signInTask = Task { [weak self] in
            guard let self else { return }
            do {
                let token = try await auth.signIn(email: email, password: password)
                try Task.checkCancellation()
                try Keychain.set(token, for: KeychainKeys.token)
                try Keychain.set(email, for: KeychainKeys.email)
                await MainActor.run {
                    self.spinner.stopAnimating()
                    self.onSignedIn?(email, token)
                }
            } catch is CancellationError {
                return
            } catch {
                await MainActor.run {
                    self.spinner.stopAnimating()
                    self.signInButton.isEnabled = true
                    self.bannerLabel.text = error.localizedDescription
                    self.bannerLabel.isHidden = false
                }
            }
        }
    }
}

Step 5 — HomeVC

// HomeVC.swift
import UIKit

final class HomeVC: UIViewController {
    private let email: String
    var onSignedOut: (() -> Void)?

    init(email: String) {
        self.email = email
        super.init(nibName: nil, bundle: nil)
    }
    required init?(coder: NSCoder) { fatalError() }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .systemBackground
        title = "Home"

        let greeting = UILabel()
        greeting.text = "Welcome, \(email)"
        greeting.font = .preferredFont(forTextStyle: .title2)
        greeting.numberOfLines = 0

        let signOut = UIButton(configuration: .borderedProminent(), primaryAction: UIAction(title: "Sign out") { [weak self] _ in
            try? Keychain.delete(KeychainKeys.token)
            try? Keychain.delete(KeychainKeys.email)
            self?.onSignedOut?()
        })

        let stack = UIStackView(arrangedSubviews: [greeting, signOut])
        stack.axis = .vertical
        stack.spacing = 20
        stack.alignment = .leading
        stack.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(stack)
        NSLayoutConstraint.activate([
            stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
            stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
            stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
        ])
    }
}

Step 6 — Wire it up in SceneDelegate

// SceneDelegate.swift
import UIKit

final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
        guard let windowScene = scene as? UIWindowScene else { return }
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = makeRoot()
        window.makeKeyAndVisible()
        self.window = window
    }

    private func makeRoot() -> UIViewController {
        if let token = try? Keychain.get(KeychainKeys.token), token != nil,
           let email = try? Keychain.get(KeychainKeys.email), let email {
            return UINavigationController(rootViewController: makeHome(email: email))
        }
        return UINavigationController(rootViewController: makeLogin())
    }

    private func makeLogin() -> LoginVC {
        let login = LoginVC()
        login.onSignedIn = { [weak self] email, _ in
            self?.window?.rootViewController = UINavigationController(rootViewController: self!.makeHome(email: email))
        }
        return login
    }

    private func makeHome(email: String) -> HomeVC {
        let home = HomeVC(email: email)
        home.onSignedOut = { [weak self] in
            self?.window?.rootViewController = UINavigationController(rootViewController: self!.makeLogin())
        }
        return home
    }
}

Step 7 — Run

  • Launch — Login screen.
  • Type “junk” in email — inline error appears.
  • Type “demo@10x.dev” and “password1” — button enables.
  • Tap “Sign in” — spinner, then push to Home.
  • Force-quit the app, relaunch — opens directly to Home (token in Keychain).
  • Sign out — back to Login. Relaunch — Login again.
  • Tap password field — keyboard appears, the scroll view’s bottom is pinned to keyboardLayoutGuide.topAnchor so the field stays visible.

Stretch goals

  1. Biometric unlock: after first successful sign-in, store the token with kSecAttrAccessControl requiring .biometryCurrentSet. On launch, attempt to read; the system will prompt Face ID / Touch ID.
  2. Password strength meter that updates a UIProgressView as the user types.
  3. Show/hide password button — a UIButton set as passwordField.rightView toggling isSecureTextEntry.
  4. Form submit on Cmd+Return (iPad keyboard) via UIKeyCommand on the VC.
  5. Combine version — bind both fields’ text into a CombineLatest pipeline that drives signInButton.isEnabled (per chapter 4.10).
  6. Snapshot test the validation states (loading, error, valid) using a third-party snapshot testing library.

Notes & troubleshooting

  • Keychain returns errSecMissingEntitlement on simulator: in Xcode 11+ this requires the Keychain Sharing capability or running with a development team. The simplest fix: assign a real team in Signing & Capabilities, even for simulator runs.
  • UIScrollView doesn’t scroll up when keyboard appears: ensure the scroll view’s bottomAnchor is constrained to view.keyboardLayoutGuide.topAnchor, not the safe area or the view’s bottom.
  • Token shows up in iCloud Keychain on another device: that’s kSecAttrSynchronizable, which we did NOT set. The accessibility flag kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly ensures the value never syncs.
  • Form fields recreate every keystroke: don’t rebuild the view hierarchy on textChanged — only update labels and isEnabled.
  • Storing the password itself: don’t. Only store the token returned by the server. The password should leave the device only over HTTPS and never be persisted client-side.

Phase 4 labs complete.