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
| Component | File | Lines | Description |
|---|---|---|---|
| Auth Bridge | Auth/FirebaseAuthRepositoryBridge.swift | 193 | Swift-native Firebase auth |
| Google Provider | Auth/GoogleSignInProviderImpl.swift | 80 | OAuth token provider |
| Sign-In View | Screens/SignInView.swift | 267 | UI + ViewModel |
| Auth Tests | Auth/SignInViewModelTests.swift | 207 | 14 TDD tests |
| Mock Repository | Auth/MockAuthRepository.swift | 179 | Testing support |
| Theme Manager | ThemeManager.swift | 71 | Dark mode |
| Theme Tests | ThemeManagerTests.swift | 161 | 14 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)
| Category | Tests | Description |
|---|---|---|
| Initial State | 2 | Not loading, no error |
| Anonymous Sign-In | 5 | Success/failure, loading states |
| Google Sign-In | 5 | Success/failure, loading states |
| Error Handling | 2 | Message display, clearing |
ThemeManagerTests (14 tests)
| Category | Tests | Description |
|---|---|---|
| Initialization | 2 | Default values |
| ColorScheme Mapping | 3 | System/Light/Dark |
| setTheme() | 3 | Persistence, all modes |
| toggleDarkMode() | 2 | Light↔Dark toggle |
| Cross-Instance | 4 | UserDefaults 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:
| Concept | Firebase Anonymous | GUEST Participant |
|---|---|---|
| What | Temporary Firebase account | Family member in tournament |
| Created by | User choosing “Continue as Guest” | Primary user adding family |
| Can upgrade | Yes → Google account | No (just a data record) |
| Has Firebase UID | Yes (anonymous UID) | No (just participantId) |
| Data location | Firebase Auth | Tournament.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.
Related Documentation
- iOS Troubleshooting - Common auth issues
- KMP iOS Patterns - Bridge patterns
- Phase 5 Auth Overview - Feature summary
Last Updated: 2025-12-02 Status: Phase 5 Authentication complete