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. canSubmit checks 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 AuthRepository protocol + API implementation — the app's small, testable seam over the auth
  • A TokenStore protocol (in-memory for now) so token persistence is a swappable boundary.
  • A SessionManager that 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 SessionManager owns "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.