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
| Component | File | Lines | Description |
|---|---|---|---|
| Provider | Auth/AppleSignInProviderImpl.swift | 227 | Core implementation |
| Bridge | Auth/FirebaseAuthRepositoryBridge.swift | +23 | Firebase integration |
| Tests | Auth/SignInViewModelTests.swift | +75 | 6 Apple tests |
| Container | DI/DependencyContainer.swift | +15 | Singleton 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:
- Must resume continuation exactly once (success OR failure)
- Set continuation to nil after resuming
- 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:
SignInWithAppleButtonperforms 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 NavigationViewAnalyticsTabView.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
- Select target in Xcode
- Signing & Capabilities tab
- Add “Sign in with Apple” capability
Apple Developer Portal
- Certificates, Identifiers & Profiles
- Identifiers → Select App ID
- Enable “Sign in with Apple”
- Configure as needed
Test Coverage
SignInViewModelTests (6 new tests)
| Test | Description |
|---|---|
testSignInWithApple_success_callsRepository | Verifies repository called |
testSignInWithApple_success_clearsError | Clears previous error |
testSignInWithApple_success_stopsLoading | Loading state reset |
testSignInWithApple_failure_setsErrorMessage | Error displayed |
testSignInWithApple_failure_stopsLoading | Loading state reset |
testSignInWithApple_cancelled_doesNotShowError | Cancel != error |
Total Auth Tests: 20 (14 existing + 6 Apple)
Code Review Fixes
Issues caught during Copilot review:
| Commit | Issue | Fix |
|---|---|---|
| 2ba395d1 | Missing ‘W’ in nonce charset | Added ‘W’ to charset |
| aeb294a0 | Double sign-in with native button | Custom button |
| a9cb7fcd | Race condition | @MainActor + isSignInInProgress guard |
Related Documentation
- Phase 5 Authentication Guide - Google/Guest sign-in
- iOS Troubleshooting - Common issues
- KMP iOS Patterns - Bridge patterns
Last Updated: 2025-12-02 Status: Phase 5d complete