iOS Authentication Guide

Phase: 5 Status: Complete PRs: #338 (Dark Mode), #339 (Authentication)


Overview

Phase 5 implements iOS authentication with Firebase, including Google Sign-In OAuth and anonymous (guest) authentication. The implementation uses Swift-native Firebase SDK directly to avoid KMP ObjCExportCoroutines issues.

Key Features:

  • Google Sign-In OAuth flow
  • Anonymous (guest) authentication
  • Account upgrade from guest to Google
  • Real-time auth state observation
  • Dark mode toggle with @AppStorage persistence
  • Full TDD implementation (14 auth tests + 14 theme tests)

Architecture

Component Structure

SignInView
├── SignInViewModel (@MainActor)
│   └── AuthRepository (protocol)
│       ├── FirebaseAuthRepositoryBridge (production)
│       │   ├── GoogleSignInProviderImpl
│       │   └── Firebase Auth SDK
│       └── MockAuthRepository (testing)
├── Auth State Observer (Firebase listener)
└── DependencyContainer (singletons)

Data Flow

User taps "Sign in with Google"
         ↓
SignInViewModel.signInWithGoogle()
         ↓
FirebaseAuthRepositoryBridge.signInWithGoogle()
         ↓
GoogleSignInProviderImpl.signIn() → Get tokens
         ↓
Firebase Auth SDK → Exchange for Firebase credential
         ↓
Auth State Listener → UI updates automatically

Key Files

ComponentFileLinesDescription
Auth BridgeAuth/FirebaseAuthRepositoryBridge.swift193Swift-native Firebase auth
Google ProviderAuth/GoogleSignInProviderImpl.swift80OAuth token provider
Sign-In ViewScreens/SignInView.swift267UI + ViewModel
Auth TestsAuth/SignInViewModelTests.swift20714 TDD tests
Mock RepositoryAuth/MockAuthRepository.swift179Testing support
Theme ManagerThemeManager.swift71Dark mode
Theme TestsThemeManagerTests.swift16114 TDD tests

FirebaseAuthRepositoryBridge

Swift-Native Implementation

Uses Firebase Auth SDK directly rather than KMP bridge to avoid ObjCExportCoroutines issues:

class FirebaseAuthRepositoryBridge: AuthRepository {
 
    private let googleSignInProvider: GoogleSignInProviderImpl
 
    init(googleSignInProvider: GoogleSignInProviderImpl = GoogleSignInProviderImpl()) {
        self.googleSignInProvider = googleSignInProvider
    }
 
    func signInWithGoogle() async throws -> AuthResult<AuthUser> {
        // Step 1: Get tokens from Google Sign-In
        let tokens = try await googleSignInProvider.signIn()
 
        // Step 2: Create Firebase credential
        let credential = GoogleAuthProvider.credential(
            withIDToken: tokens.idToken,
            accessToken: tokens.accessToken
        )
 
        // Step 3: Sign in to Firebase
        let authResult = try await Auth.auth().signIn(with: credential)
        let authUser = mapToAuthUser(authResult.user)
 
        return AuthResultSuccess(data: authUser)
    }
 
    func signInAnonymously() async throws -> AuthResult<AuthUser> {
        let authResult = try await Auth.auth().signInAnonymously()
        let authUser = mapToAuthUser(authResult.user)
        return AuthResultSuccess(data: authUser)
    }
}

KMP AuthResult Swift Limitation

Swift doesn’t support KMP’s covariant sealed class generics properly. The solution is to throw Swift-native errors:

/// Swift-native error types for auth operations.
/// These are thrown from bridge methods and caught by ViewModels.
enum AuthBridgeError: LocalizedError {
    case noUserSignedIn
    case notImplemented(String)
    case networkError(String)
    case invalidCredentials(String)
    case userNotFound(String)
    case accountDisabled(String)
    case providerError(provider: AuthProvider, message: String)
    case unknown(String)
 
    var errorDescription: String? {
        switch self {
        case .noUserSignedIn:
            return "No user is currently signed in"
        case .notImplemented(let feature):
            return "\(feature) is not yet implemented"
        // ... other cases
        }
    }
}

Native Auth State Listener

KMP Flow doesn’t work well with Swift async/await. Use native Firebase listener instead:

/// Native Swift auth state listener using Firebase SDK.
/// Use this instead of observeAuthState() for real-time updates.
func addAuthStateListener(_ callback: @escaping (User?) -> Void) -> AuthStateDidChangeListenerHandle {
    return Auth.auth().addStateDidChangeListener { _, user in
        callback(user)
    }
}
 
func removeAuthStateListener(_ handle: AuthStateDidChangeListenerHandle) {
    Auth.auth().removeStateDidChangeListener(handle)
}

GoogleSignInProviderImpl

Token Flow Optimization

Returns both idToken and accessToken from single signIn() call to avoid redundant token refresh:

struct GoogleSignInTokens {
    let idToken: String
    let accessToken: String
}
 
class GoogleSignInProviderImpl {
 
    func signIn() async throws -> GoogleSignInTokens {
        // Configure with CLIENT_ID from Firebase plist
        guard let clientID = FirebaseApp.app()?.options.clientID else {
            throw GoogleSignInError.missingClientID
        }
 
        let config = GIDConfiguration(clientID: clientID)
        GIDSignIn.sharedInstance.configuration = config
 
        // Get root view controller for OAuth UI
        guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
              let rootVC = windowScene.windows.first?.rootViewController else {
            throw GoogleSignInError.noViewController
        }
 
        // Perform sign-in
        let result = try await GIDSignIn.sharedInstance.signIn(withPresenting: rootVC)
 
        guard let idToken = result.user.idToken?.tokenString else {
            throw GoogleSignInError.missingIdToken
        }
 
        return GoogleSignInTokens(
            idToken: idToken,
            accessToken: result.user.accessToken.tokenString
        )
    }
}

URL Scheme Configuration

Required in Info.plist for OAuth callback:

<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>com.googleusercontent.apps.YOUR_CLIENT_ID</string>
        </array>
    </dict>
</array>

Handle callback in app entry point:

@main
struct ArcheryApprenticeApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .onOpenURL { url in
                    GIDSignIn.sharedInstance.handle(url)
                }
        }
    }
}

SignInView

UI States

struct SignInView: View {
    @StateObject private var viewModel: SignInViewModel
    @State private var currentUser: FirebaseAuth.User?
 
    private var isSignedInAsGuest: Bool {
        currentUser?.isAnonymous == true
    }
 
    var body: some View {
        VStack {
            // Google Sign-In Button
            Button(action: { Task { await viewModel.signInWithGoogle() }}) {
                Text(isSignedInAsGuest ? "Upgrade to Google Account" : "Sign in with Google")
            }
            .disabled(viewModel.isLoading)
 
            // Guest Sign-In Button or Signed-In State
            if isSignedInAsGuest {
                HStack {
                    Image(systemName: "checkmark.circle.fill")
                    Text("Signed in as Guest")
                }
                .foregroundColor(.green)
            } else {
                Button(action: { Task { await viewModel.signInAnonymously() }}) {
                    Text("Continue as guest")
                }
                .disabled(viewModel.isLoading)
            }
 
            // Error Message
            if let error = viewModel.errorMessage {
                Text(error).foregroundColor(.red)
            }
 
            // Loading Indicator
            if viewModel.isLoading {
                ProgressView()
            }
        }
    }
}

Auth State Observation

.onAppear {
    currentUser = Auth.auth().currentUser
    authListenerHandle = Auth.auth().addStateDidChangeListener { _, user in
        currentUser = user
    }
}
.onDisappear {
    if let handle = authListenerHandle {
        Auth.auth().removeStateDidChangeListener(handle)
    }
}

SignInViewModel

TDD Implementation

@MainActor
class SignInViewModel: ObservableObject {
    @Published var isLoading = false
    @Published var errorMessage: String?
 
    private let authRepository: AuthRepository
 
    func signInWithGoogle() async {
        isLoading = true
        errorMessage = nil
 
        do {
            _ = try await authRepository.signInWithGoogle()
            // Success: auth state listener handles UI update
        } catch {
            errorMessage = "Google Sign-In failed: \(error.localizedDescription)"
        }
 
        isLoading = false
    }
 
    func signInAnonymously() async {
        isLoading = true
        errorMessage = nil
 
        do {
            _ = try await authRepository.signInAnonymously()
            // Success: auth state listener handles UI update
        } catch {
            errorMessage = "Guest sign-in failed: \(error.localizedDescription)"
        }
 
        isLoading = false
    }
}

ThemeManager

@AppStorage + ObservableObject Pattern

@MainActor
class ThemeManager: ObservableObject {
 
    /// Persisted in UserDefaults via @AppStorage
    @AppStorage("themeMode") private var themeModeRaw: String = ThemeMode.system.rawValue
 
    /// Computed property with objectWillChange.send() for SwiftUI observation
    var themeMode: ThemeMode {
        get { ThemeMode(rawValue: themeModeRaw) ?? .system }
        set {
            objectWillChange.send()  // Critical: notify observers before change
            themeModeRaw = newValue.rawValue
        }
    }
 
    /// ColorScheme for .preferredColorScheme() modifier
    var preferredColorScheme: ColorScheme? {
        themeMode.colorScheme
    }
}

ThemeMode Enum

enum ThemeMode: String, CaseIterable {
    case system = "System"
    case light = "Light"
    case dark = "Dark"
 
    var colorScheme: ColorScheme? {
        switch self {
        case .system: return nil  // Follow system
        case .light: return .light
        case .dark: return .dark
        }
    }
 
    var icon: String {
        switch self {
        case .system: return "gear"
        case .light: return "sun.max"
        case .dark: return "moon"
        }
    }
}

App-Level Theme Application

@main
struct ArcheryApprenticeApp: App {
    @StateObject private var themeManager = ThemeManager()
 
    var body: some Scene {
        WindowGroup {
            MainTabView()
                .environmentObject(themeManager)
                .preferredColorScheme(themeManager.preferredColorScheme)
        }
    }
}

Dependency Injection

DependencyContainer

class DependencyContainer {
    static let shared = DependencyContainer()
 
    // Singleton instances
    lazy var googleSignInProvider = GoogleSignInProviderImpl()
 
    lazy var authRepositoryBridge: FirebaseAuthRepositoryBridge = {
        FirebaseAuthRepositoryBridge(googleSignInProvider: googleSignInProvider)
    }()
}

Usage in Views

SignInView(
    authRepository: DependencyContainer.shared.authRepositoryBridge,
    googleSignInProvider: DependencyContainer.shared.googleSignInProvider
)

Test Coverage

SignInViewModelTests (14 tests)

CategoryTestsDescription
Initial State2Not loading, no error
Anonymous Sign-In5Success/failure, loading states
Google Sign-In5Success/failure, loading states
Error Handling2Message display, clearing

ThemeManagerTests (14 tests)

CategoryTestsDescription
Initialization2Default values
ColorScheme Mapping3System/Light/Dark
setTheme()3Persistence, all modes
toggleDarkMode()2Light↔Dark toggle
Cross-Instance4UserDefaults persistence

MockAuthRepository

class MockAuthRepository: AuthRepository {
    var signInAnonymouslyCallCount = 0
    var signInWithGoogleCallCount = 0
    var signInAnonymouslyResult: Result<AuthUser, Error> = .failure(MockAuthError.notConfigured)
    var signInWithGoogleResult: Result<AuthUser, Error> = .failure(MockAuthError.notConfigured)
 
    func signInAnonymously() async throws -> AuthResult<AuthUser> {
        signInAnonymouslyCallCount += 1
        switch signInAnonymouslyResult {
        case .success(let user):
            return AuthResultSuccess(data: user)
        case .failure(let error):
            throw error
        }
    }
 
    static func createTestUser(isAnonymous: Bool = false) -> AuthUser {
        AuthUser(
            uid: "test-uid",
            email: isAnonymous ? nil : "test@example.com",
            displayName: isAnonymous ? nil : "Test User",
            photoUrl: nil,
            isAnonymous: isAnonymous,
            isEmailVerified: !isAnonymous,
            provider: isAnonymous ? .anonymous : .google,
            metadata: UserMetadata(creationTimestamp: 0, lastSignInTimestamp: 0)
        )
    }
}

Important Concepts

Firebase Anonymous vs GUEST Participants

These are completely different concepts:

ConceptFirebase AnonymousGUEST Participant
WhatTemporary Firebase accountFamily member in tournament
Created byUser choosing “Continue as Guest”Primary user adding family
Can upgradeYes → Google accountNo (just a data record)
Has Firebase UIDYes (anonymous UID)No (just participantId)
Data locationFirebase AuthTournament.participants

Do not confuse these. Firebase anonymous users can upgrade to Google accounts. GUEST participants are simply non-account holders added to tournaments by the primary user.



Last Updated: 2025-12-02 Status: Phase 5 Authentication complete