Chapter 5 — Building the auth screens
The repository: the app's view of "auth"
// Chirp/Features/Auth/AuthRepository.swift
import Foundation
protocol AuthRepository {
func login(email: String, password: String) async throws -> TokenResponse
func register(username: String, email: String, password: String) async throws -> PublicUser
}
struct APIAuthRepository: AuthRepository {
let client: APIClient
func login(email: String, password: String) async throws -> TokenResponse {
try await client.send(API.login(email: email, password: password))
}
func register(username: String, email: String, password: String) async throws -> PublicUser {
try await client.send(API.register(username: username, email: email, password: password))
}
}
Where do the tokens go? The storage seam
// Chirp/Networking/TokenStore.swift
protocol TokenStore: Sendable {
func save(_ tokens: TokenBundle) throws
func load() -> TokenBundle?
func clear()
}
struct TokenBundle: Codable, Sendable {
let accessToken: String
let refreshToken: String
let accessTokenExpiry: Date // when the access token stops working
}
// Temporary — replaced by a KeychainTokenStore in Chapter 6.
final class InMemoryTokenStore: TokenStore, @unchecked Sendable {
private var bundle: TokenBundle?
func save(_ tokens: TokenBundle) { bundle = tokens }
func load() -> TokenBundle? { bundle }
func clear() { bundle = nil }
}
The SessionManager: the app's logged-in / logged-out switch
// Chirp/App/SessionManager.swift
import Foundation
@MainActor
@Observable
final class SessionManager {
enum State { case signedOut, signedIn }
private(set) var state: State
private let tokenStore: TokenStore
init(tokenStore: TokenStore) {
self.tokenStore = tokenStore
// On launch, we're signed in if we already have stored tokens.
self.state = tokenStore.load() == nil ? .signedOut : .signedIn
}
func didLogin(_ tokens: TokenResponse) {
let bundle = TokenBundle(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
accessTokenExpiry: Date().addingTimeInterval(TimeInterval(tokens.expiresIn)))
try? tokenStore.save(bundle)
state = .signedIn
}
func logout() {
tokenStore.clear()
state = .signedOut
}
}
Switching the whole app on auth state
// Chirp/App/RootView.swift
import SwiftUI
struct RootView: View {
@State private var session: SessionManager
let client: APIClient
var body: some View {
switch session.state {
case .signedOut:
AuthFlowView(client: client, session: session) // login / register
case .signedIn:
MainTabView(client: client, session: session) // feed, profile, etc.
}
}
}
The login screen
// Chirp/Features/Auth/LoginViewModel.swift
@MainActor @Observable
final class LoginViewModel {
var email = ""
var password = ""
private(set) var isSubmitting = false
var errorMessage: String?
private let auth: AuthRepository
private let session: SessionManager
init(auth: AuthRepository, session: SessionManager) { self.auth = auth; self.session = session }
var canSubmit: Bool { email.contains("@") && password.count >= 8 && !isSubmitting }
func submit() async {
isSubmitting = true; errorMessage = nil
defer { isSubmitting = false }
do {
let tokens = try await auth.login(email: email, password: password)
session.didLogin(tokens) // flips the whole app to signedIn
} catch {
errorMessage = error.userMessage // e.g. "Invalid email or password."
}
}
}
// Chirp/Features/Auth/LoginView.swift
struct LoginView: View {
@State private var viewModel: LoginViewModel
var body: some View {
Form {
Section {
TextField("Email", text: $viewModel.email)
.textContentType(.emailAddress).keyboardType(.emailAddress)
.textInputAutocapitalization(.never)
SecureField("Password", text: $viewModel.password)
.textContentType(.password)
}
if let message = viewModel.errorMessage {
Text(message).foregroundStyle(.red).font(.footnote)
}
Section {
Button {
Task { await viewModel.submit() }
} label: {
if viewModel.isSubmitting { ProgressView() }
else { Text("Log In").frame(maxWidth: .infinity) }
}
.disabled(!viewModel.canSubmit)
}
}
.navigationTitle("Welcome back")
}
}
Client-side validation is a courtesy, not security.
canSubmitchecks a@and password length so the user gets instant feedback and we don't fire obviously-doomed requests. But the server is the real validator (Vapor in Depth, Chapter 8) — never trust the client. Client validation improves UX; server validation enforces rules.
The end-to-end flow
flowchart TB
Form["LoginView: email + password"] --> VM["LoginViewModel.submit()"]
VM -->|"auth.login(...)"| Repo["AuthRepository"]
Repo -->|"client.send(API.login)"| Client["APIClient → Chirp API"]
Client -->|"TokenResponse"| Repo --> VM
VM -->|"session.didLogin(tokens)"| Session["SessionManager"]
Session -->|"save tokens + state = .signedIn"| Store["TokenStore"]
Session -->|"state flips"| Root["RootView rebuilds → MainTabView"]
What we built
- An
AuthRepositoryprotocol + API implementation — the app's small, testable seam over the auth - A
TokenStoreprotocol (in-memory for now) so token persistence is a swappable boundary. - A
SessionManagerthat reads its initial state from stored tokens and flips the whole app between - Real login (and register) screens using Chapter 4's states: validated, disabled-while-submitting,
Mental model to take away
- Put a small repository protocol over each feature's endpoints; view models depend on the
- A single
SessionManagerowns "logged in?" and the root view switches the whole app on it, so - Define the token-storage seam (
TokenStore) now; the secure implementation drops in next. - Client validation is UX; the server is the authority.