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
- New Xcode project:
KeychainForm, UIKit, Swift, no Storyboard. - Configure
SceneDelegateto 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.topAnchorso the field stays visible.
Stretch goals
- Biometric unlock: after first successful sign-in, store the token with
kSecAttrAccessControlrequiring.biometryCurrentSet. On launch, attempt to read; the system will prompt Face ID / Touch ID. - Password strength meter that updates a
UIProgressViewas the user types. - Show/hide password button — a
UIButtonset aspasswordField.rightViewtogglingisSecureTextEntry. - Form submit on Cmd+Return (iPad keyboard) via
UIKeyCommandon the VC. - Combine version — bind both fields’ text into a
CombineLatestpipeline that drivessignInButton.isEnabled(per chapter 4.10). - Snapshot test the validation states (loading, error, valid) using a third-party snapshot testing library.
Notes & troubleshooting
- Keychain returns
errSecMissingEntitlementon 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. UIScrollViewdoesn’t scroll up when keyboard appears: ensure the scroll view’sbottomAnchoris constrained toview.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 flagkSecAttrAccessibleAfterFirstUnlockThisDeviceOnlyensures the value never syncs. - Form fields recreate every keystroke: don’t rebuild the view hierarchy on
textChanged— only update labels andisEnabled. - 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.