Add Sign in with Apple for iOS

Kotlin Shared Layer:
- Add AppleSignInRequest and AppleSignInResponse models
- Add appleSignIn method to AuthApi and APILayer
- Add appleSignInState and appleSignIn() to AuthViewModel

iOS App:
- Create AppleSignInManager for AuthenticationServices integration
- Create AppleSignInViewModel to coordinate Apple auth flow
- Update LoginView with "Sign in with Apple" button
- Add Sign in with Apple entitlement
- Add accessibility identifier for UI testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-11-29 01:17:38 -06:00
parent 4b905ad5fe
commit 5a1a87fe8d
10 changed files with 529 additions and 0 deletions

View File

@@ -12,6 +12,7 @@ struct AccessibilityIdentifiers {
static let signUpButton = "Login.SignUpButton"
static let forgotPasswordButton = "Login.ForgotPasswordButton"
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
static let appleSignInButton = "Login.AppleSignInButton"
// Registration
static let registerUsernameField = "Register.UsernameField"

View File

@@ -0,0 +1,176 @@
import Foundation
import AuthenticationServices
/// Handles Sign in with Apple authentication flow
class AppleSignInManager: NSObject, ObservableObject {
// MARK: - Published Properties
@Published var isProcessing: Bool = false
@Published var error: Error?
// MARK: - Completion Handler
private var completionHandler: ((Result<AppleSignInCredential, Error>) -> Void)?
// MARK: - Public Methods
/// Initiates the Sign in with Apple flow
/// - Parameter completion: Callback with either credentials or error
func signIn(completion: @escaping (Result<AppleSignInCredential, Error>) -> Void) {
self.completionHandler = completion
self.error = nil
self.isProcessing = true
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.fullName, .email]
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()
}
}
// MARK: - ASAuthorizationControllerDelegate
extension AppleSignInManager: ASAuthorizationControllerDelegate {
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
isProcessing = false
guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else {
let error = AppleSignInError.invalidCredential
self.error = error
completionHandler?(.failure(error))
return
}
// Get the identity token as a string
guard let identityTokenData = appleIDCredential.identityToken,
let identityToken = String(data: identityTokenData, encoding: .utf8) else {
let error = AppleSignInError.missingIdentityToken
self.error = error
completionHandler?(.failure(error))
return
}
// Extract user info (only available on first sign in)
let email = appleIDCredential.email
let firstName = appleIDCredential.fullName?.givenName
let lastName = appleIDCredential.fullName?.familyName
let userIdentifier = appleIDCredential.user
let credential = AppleSignInCredential(
identityToken: identityToken,
userIdentifier: userIdentifier,
email: email,
firstName: firstName,
lastName: lastName
)
completionHandler?(.success(credential))
}
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
isProcessing = false
// Check if user cancelled
if let authError = error as? ASAuthorizationError {
switch authError.code {
case .canceled:
// User cancelled, don't treat as error
self.error = AppleSignInError.userCancelled
completionHandler?(.failure(AppleSignInError.userCancelled))
return
case .failed:
self.error = AppleSignInError.authorizationFailed
completionHandler?(.failure(AppleSignInError.authorizationFailed))
return
case .invalidResponse:
self.error = AppleSignInError.invalidResponse
completionHandler?(.failure(AppleSignInError.invalidResponse))
return
case .notHandled:
self.error = AppleSignInError.notHandled
completionHandler?(.failure(AppleSignInError.notHandled))
return
case .notInteractive:
self.error = AppleSignInError.notInteractive
completionHandler?(.failure(AppleSignInError.notInteractive))
return
case .unknown:
break // Fall through to generic error
@unknown default:
break
}
}
self.error = error
completionHandler?(.failure(error))
}
}
// MARK: - ASAuthorizationControllerPresentationContextProviding
extension AppleSignInManager: ASAuthorizationControllerPresentationContextProviding {
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
// Get the key window for presentation
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.windows.first(where: { $0.isKeyWindow }) else {
// Fallback to first window
return UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first ?? ASPresentationAnchor()
}
return window
}
}
// MARK: - Supporting Types
/// Credentials returned from successful Apple Sign In
struct AppleSignInCredential {
let identityToken: String
let userIdentifier: String
let email: String?
let firstName: String?
let lastName: String?
}
/// Custom errors for Apple Sign In
enum AppleSignInError: LocalizedError {
case invalidCredential
case missingIdentityToken
case userCancelled
case authorizationFailed
case invalidResponse
case notHandled
case notInteractive
case serverError(String)
var errorDescription: String? {
switch self {
case .invalidCredential:
return "Invalid Apple ID credential"
case .missingIdentityToken:
return "Missing identity token from Apple"
case .userCancelled:
return nil // Don't show error for user cancellation
case .authorizationFailed:
return "Apple Sign In authorization failed"
case .invalidResponse:
return "Invalid response from Apple"
case .notHandled:
return "Apple Sign In request was not handled"
case .notInteractive:
return "Apple Sign In requires user interaction"
case .serverError(let message):
return message
}
}
var isCancellation: Bool {
if case .userCancelled = self {
return true
}
return false
}
}

View File

@@ -0,0 +1,171 @@
import Foundation
import ComposeApp
import Combine
/// ViewModel for handling Apple Sign In flow
/// Coordinates between AppleSignInManager (iOS) and AuthViewModel (Kotlin)
@MainActor
class AppleSignInViewModel: ObservableObject {
// MARK: - Published Properties
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var isNewUser: Bool = false
// MARK: - Private Properties
private let appleSignInManager = AppleSignInManager()
private let sharedViewModel: ComposeApp.AuthViewModel
private let tokenStorage: TokenStorageProtocol
// MARK: - Callbacks
var onSignInSuccess: ((Bool) -> Void)? // Bool indicates if user is verified
// MARK: - Initialization
init(
sharedViewModel: ComposeApp.AuthViewModel? = nil,
tokenStorage: TokenStorageProtocol? = nil
) {
self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel()
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
}
// MARK: - Public Methods
/// Initiates the Apple Sign In flow
func signInWithApple() {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
appleSignInManager.signIn { [weak self] result in
Task { @MainActor in
switch result {
case .success(let credential):
self?.sendCredentialToBackend(credential)
case .failure(let error):
self?.handleAppleError(error)
}
}
}
}
/// Resets the error state
func clearError() {
errorMessage = nil
}
// MARK: - Private Methods
/// Sends Apple credential to backend for verification/authentication
private func sendCredentialToBackend(_ credential: AppleSignInCredential) {
sharedViewModel.appleSignIn(
idToken: credential.identityToken,
userId: credential.userIdentifier,
email: credential.email,
firstName: credential.firstName,
lastName: credential.lastName
)
// Observe the result
Task {
for await state in sharedViewModel.appleSignInState {
if state is ApiResultLoading {
await MainActor.run {
self.isLoading = true
}
} else if let success = state as? ApiResultSuccess<AppleSignInResponse> {
await MainActor.run {
self.handleSuccess(success.data)
}
break
} else if let error = state as? ApiResultError {
await MainActor.run {
self.handleBackendError(error)
}
break
}
}
}
}
/// Handles successful authentication
private func handleSuccess(_ response: AppleSignInResponse?) {
isLoading = false
guard let response = response,
let token = response.token as String? else {
errorMessage = "Invalid response from server"
return
}
let user = response.user
// Store the token
tokenStorage.saveToken(token: token)
// Track if this is a new user
isNewUser = response.isNewUser
// Initialize lookups
Task {
_ = try? await APILayer.shared.initializeLookups()
}
// Prefetch data
Task {
do {
print("Starting data prefetch after Apple Sign In...")
let prefetchManager = DataPrefetchManager.Companion().getInstance()
_ = try await prefetchManager.prefetchAllData()
print("Data prefetch completed successfully")
} catch {
print("Data prefetch failed: \(error.localizedDescription)")
}
}
print("Apple Sign In successful! User: \(user.username), New user: \(isNewUser)")
// Call success callback with verification status
onSignInSuccess?(user.verified)
}
/// Handles Apple Sign In errors
private func handleAppleError(_ error: Error) {
isLoading = false
if let appleError = error as? AppleSignInError {
// Don't show error for user cancellation
if appleError.isCancellation {
return
}
errorMessage = appleError.errorDescription
} else {
errorMessage = error.localizedDescription
}
}
/// Handles backend API errors
private func handleBackendError(_ error: ApiResultError) {
isLoading = false
sharedViewModel.resetAppleSignInState()
if let code = error.code?.intValue {
switch code {
case 400:
errorMessage = "Invalid Apple Sign In token"
case 401:
errorMessage = "Authentication failed. Please try again."
case 403:
errorMessage = "Access denied"
case 500...599:
errorMessage = "Server error. Please try again later."
default:
errorMessage = ErrorMessageParser.parse(error.message)
}
} else {
errorMessage = ErrorMessageParser.parse(error.message)
}
print("Apple Sign In backend error: \(error.message)")
}
}

View File

@@ -1,7 +1,9 @@
import SwiftUI
import AuthenticationServices
struct LoginView: View {
@StateObject private var viewModel = LoginViewModel()
@StateObject private var appleSignInViewModel = AppleSignInViewModel()
@FocusState private var focusedField: Field?
@State private var showingRegister = false
@State private var showVerification = false
@@ -202,6 +204,70 @@ struct LoginView: View {
.disabled(!isFormValid || viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton)
// Divider
HStack {
Rectangle()
.fill(Color.appTextSecondary.opacity(0.3))
.frame(height: 1)
Text("or")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
.padding(.horizontal, AppSpacing.sm)
Rectangle()
.fill(Color.appTextSecondary.opacity(0.3))
.frame(height: 1)
}
.padding(.vertical, AppSpacing.xs)
// Sign in with Apple Button
SignInWithAppleButton(
onRequest: { request in
request.requestedScopes = [.fullName, .email]
},
onCompletion: { _ in }
)
.frame(height: 56)
.cornerRadius(AppRadius.md)
.signInWithAppleButtonStyle(.black)
.disabled(appleSignInViewModel.isLoading)
.opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0)
.overlay {
// Custom tap handler to use our view model
Color.clear
.contentShape(Rectangle())
.onTapGesture {
appleSignInViewModel.signInWithApple()
}
}
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.appleSignInButton)
// Apple Sign In loading indicator
if appleSignInViewModel.isLoading {
HStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
Text("Signing in with Apple...")
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
}
.padding(.top, AppSpacing.xs)
}
// Apple Sign In Error
if let appleError = appleSignInViewModel.errorMessage {
HStack(spacing: AppSpacing.sm) {
Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError)
Text(appleError)
.font(.callout)
.foregroundColor(Color.appError)
Spacer()
}
.padding(AppSpacing.md)
.background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md)
}
// Sign Up Link
HStack(spacing: AppSpacing.xs) {
Text("Don't have an account?")
@@ -242,6 +308,20 @@ struct LoginView: View {
// since AuthenticationManager.isVerified is now false
}
}
// Set up callback for Apple Sign In success
appleSignInViewModel.onSignInSuccess = { [self] isVerified in
// Update the shared authentication manager
AuthenticationManager.shared.login(verified: isVerified)
if isVerified {
// User is verified, call the success callback
self.onLoginSuccess?()
} else {
// User needs verification - RootView will handle showing VerifyEmailView
// since AuthenticationManager.isVerified is now false
}
}
}
.fullScreenCover(isPresented: $showVerification) {
VerifyEmailView(

View File

@@ -4,6 +4,10 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.tt.mycrib.MyCribDev</string>