Apple Sign-In Implementation Guide

Phase: 5d Status: Complete PR: #340


Overview

Phase 5d implements Apple Sign-In using the native AuthenticationServices framework, required for App Store compliance when offering third-party sign-in options (like Google). The implementation includes cryptographic nonce security, delegate-to-async bridging, and Firebase credential exchange.

Key Features:

  • Native AuthenticationServices framework integration
  • Cryptographic nonce generation (SecRandomCopyBytes + SHA256)
  • Continuation-based delegate → async/await bridging
  • Firebase OAuthProvider credential exchange
  • Race condition prevention
  • 6 TDD tests including cancel handling

Architecture

Component Structure

SignInView
├── SignInViewModel
│   └── FirebaseAuthRepositoryBridge
│       └── AppleSignInProviderImpl
│           ├── ASAuthorizationController (Apple SDK)
│           ├── Nonce Generation (SecRandomCopyBytes)
│           └── SHA256 Hashing (CryptoKit)

Security Flow

1. Generate random nonce → Store raw locally
2. SHA256(nonce) → Send hash to Apple
3. Apple embeds hash in identity token
4. Send raw nonce + identity token to Firebase
5. Firebase verifies: SHA256(raw nonce) == embedded hash

Key Files

ComponentFileLinesDescription
ProviderAuth/AppleSignInProviderImpl.swift227Core implementation
BridgeAuth/FirebaseAuthRepositoryBridge.swift+23Firebase integration
TestsAuth/SignInViewModelTests.swift+756 Apple tests
ContainerDI/DependencyContainer.swift+15Singleton provider

AppleSignInProviderImpl

Class Structure

@MainActor
class AppleSignInProviderImpl: NSObject {
 
    private var continuation: CheckedContinuation<AppleSignInCredentials, Error>?
    private var currentNonce: String?
    private var isSignInInProgress: Bool = false
 
    func signIn() async throws -> AppleSignInCredentials {
        // Prevent concurrent sign-in attempts
        guard !isSignInInProgress else {
            throw AppleSignInError.signInAlreadyInProgress
        }
        isSignInInProgress = true
        defer { isSignInInProgress = false }
 
        // Generate nonce for security
        let nonce = randomNonceString()
        currentNonce = nonce
 
        // Create Apple ID request
        let appleIDProvider = ASAuthorizationAppleIDProvider()
        let request = appleIDProvider.createRequest()
        request.requestedScopes = [.fullName, .email]
        request.nonce = sha256(nonce)  // Send HASH to Apple
 
        // Use continuation to bridge delegate to async/await
        return try await withCheckedThrowingContinuation { continuation in
            self.continuation = continuation
            authorizationController.performRequests()
        }
    }
}

Nonce Security Implementation

Why Nonces: Prevents replay attacks where an attacker could reuse a captured identity token.

Generation (SecRandomCopyBytes):

private func randomNonceString(length: Int = 32) -> String {
    precondition(length > 0)
    var randomBytes = [UInt8](repeating: 0, count: length)
    let errorCode = SecRandomCopyBytes(kSecRandomDefault, randomBytes.count, &randomBytes)
    if errorCode != errSecSuccess {
        fatalError("Unable to generate nonce. SecRandomCopyBytes failed with OSStatus \(errorCode)")
    }
 
    let charset: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._")
    let nonce = randomBytes.map { byte in
        charset[Int(byte) % charset.count]
    }
    return String(nonce)
}

SHA256 Hashing (CryptoKit):

private func sha256(_ input: String) -> String {
    let inputData = Data(input.utf8)
    let hashedData = SHA256.hash(data: inputData)
    let hashString = hashedData.compactMap {
        String(format: "%02x", $0)
    }.joined()
    return hashString
}

Continuation-Based Async Bridging

Bridges Apple’s delegate pattern to Swift async/await:

// In signIn() method
return try await withCheckedThrowingContinuation { continuation in
    self.continuation = continuation
    authorizationController.performRequests()
}
 
// In delegate callback (success)
func authorizationController(controller: ASAuthorizationController,
                             didCompleteWithAuthorization authorization: ASAuthorization) {
    // ... extract credentials
 
    continuation?.resume(returning: credentials)
    continuation = nil  // Clean up - must resume exactly once
    currentNonce = nil
}
 
// In delegate callback (failure)
func authorizationController(controller: ASAuthorizationController,
                             didCompleteWithError error: Error) {
    if let authError = error as? ASAuthorizationError, authError.code == .canceled {
        continuation?.resume(throwing: AppleSignInError.cancelled)
    } else {
        continuation?.resume(throwing: AppleSignInError.authorizationFailed(error.localizedDescription))
    }
    continuation = nil
    currentNonce = nil
}

Critical Rules:

  1. Must resume continuation exactly once (success OR failure)
  2. Set continuation to nil after resuming
  3. Clean up nonce state after completion

AppleSignInCredentials

struct AppleSignInCredentials {
    let identityToken: String      // JWT for Firebase
    let authorizationCode: String  // For server-side verification (future)
    let nonce: String              // Raw nonce for Firebase verification
    let fullName: PersonNameComponents?  // Only on FIRST sign-in
    let email: String?             // Only on FIRST sign-in
}

Important: Apple only provides fullName and email on the user’s FIRST sign-in. Subsequent sign-ins only return the user identifier. Store this info on first sign-in.


AppleSignInError

enum AppleSignInError: LocalizedError {
    case noWindow                    // No window for presentation
    case invalidCredential           // Wrong credential type returned
    case missingIdentityToken        // Token not in response
    case missingAuthorizationCode    // Code not in response
    case missingNonce                // Nonce lost before completion
    case cancelled                   // User cancelled (NOT an error to display)
    case authorizationFailed(String) // Apple returned an error
    case signInAlreadyInProgress     // Race condition prevention
 
    var errorDescription: String? {
        switch self {
        case .noWindow:
            return "No window available for presenting Sign in with Apple"
        case .cancelled:
            return "Sign in with Apple was cancelled"
        case .signInAlreadyInProgress:
            return "Sign in with Apple is already in progress"
        // ... other cases
        }
    }
}

Firebase Integration

FirebaseAuthRepositoryBridge

func signInWithApple() async throws -> AuthResult<AuthUser> {
    // Step 1: Get credentials from Apple Sign-In
    let appleCredentials = try await appleSignInProvider.signIn()
 
    // Step 2: Create Firebase OAuth credential
    // Note: Use RAW nonce (not hashed) - Firebase does the verification
    let credential = OAuthProvider.appleCredential(
        withIDToken: appleCredentials.identityToken,
        rawNonce: appleCredentials.nonce,  // RAW, not hashed
        fullName: appleCredentials.fullName
    )
 
    // Step 3: Sign in to Firebase
    let authResult = try await Auth.auth().signIn(with: credential)
    let authUser = mapToAuthUser(authResult.user)
 
    return AuthResultSuccess(data: authUser)
}

SignInView Integration

Custom Apple Button (Not SignInWithAppleButton)

// ❌ WRONG - Native button does own auth before calling onCompletion
SignInWithAppleButton(.signIn) { request in
    // Configure request
} onCompletion: { result in
    // This fires AFTER Apple already did auth
    // Results in DOUBLE authentication!
}
 
// ✅ CORRECT - Custom button only triggers our provider once
Button(action: {
    Task { await viewModel.signInWithApple() }
}) {
    HStack {
        Image(systemName: "apple.logo")
        Text(isSignedInAsGuest ? "Upgrade with Apple" : "Sign in with Apple")
    }
    .frame(maxWidth: .infinity)
    .padding(.vertical, 14)
    .background(Color.black)
    .foregroundColor(.white)
    .cornerRadius(8)
}

Why Custom Button:

  • SignInWithAppleButton performs its own auth flow
  • Our provider also does auth → double sign-in
  • Custom button maintains same visual appearance
  • Only triggers provider once

Cancel Handling

func signInWithApple() async {
    isLoading = true
    errorMessage = nil
 
    do {
        _ = try await authRepository.signInWithApple()
        // Success: auth state listener handles UI update
    } catch let error as AppleSignInError where error == .cancelled {
        // User cancelled - don't show error
        // This is intentional user action, not an error
    } catch {
        errorMessage = "Apple Sign-In failed: \(error.localizedDescription)"
    }
 
    isLoading = false
}

iPad Navigation Fixes

Problem

NavigationView defaults to split view on iPad, causing layout issues.

Solution

Use NavigationStack (iOS 16+):

// ❌ WRONG - Creates split view on iPad
NavigationView {
    List { ... }
}
 
// ✅ CORRECT - Single column on all devices
NavigationStack {
    List { ... }
}

Files Fixed:

  • RoundListView.swift - Removed nested NavigationView
  • AnalyticsTabView.swift - Changed NavigationView → NavigationStack

Xcode Configuration

Entitlements

ArcheryApprentice.entitlements:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "...">
<plist version="1.0">
<dict>
    <key>com.apple.developer.applesignin</key>
    <array>
        <string>Default</string>
    </array>
</dict>
</plist>

Capabilities

  1. Select target in Xcode
  2. Signing & Capabilities tab
  3. Add “Sign in with Apple” capability

Apple Developer Portal

  1. Certificates, Identifiers & Profiles
  2. Identifiers → Select App ID
  3. Enable “Sign in with Apple”
  4. Configure as needed

Test Coverage

SignInViewModelTests (6 new tests)

TestDescription
testSignInWithApple_success_callsRepositoryVerifies repository called
testSignInWithApple_success_clearsErrorClears previous error
testSignInWithApple_success_stopsLoadingLoading state reset
testSignInWithApple_failure_setsErrorMessageError displayed
testSignInWithApple_failure_stopsLoadingLoading state reset
testSignInWithApple_cancelled_doesNotShowErrorCancel != error

Total Auth Tests: 20 (14 existing + 6 Apple)


Code Review Fixes

Issues caught during Copilot review:

CommitIssueFix
2ba395d1Missing ‘W’ in nonce charsetAdded ‘W’ to charset
aeb294a0Double sign-in with native buttonCustom button
a9cb7fcdRace condition@MainActor + isSignInInProgress guard


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