feat: Sign in with Apple authentication
- AuthManager: native AuthenticationServices, session in Keychain - AuthView: branded screen with Apple Sign In button - App gates ContentView behind auth; animates on sign-in/out - Settings: Account section shows name/email + Sign Out button - Entitlements: added com.apple.developer.applesignin Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
This commit is contained in:
114
KisaniCal/Managers/AuthManager.swift
Normal file
114
KisaniCal/Managers/AuthManager.swift
Normal file
@@ -0,0 +1,114 @@
|
||||
import SwiftUI
|
||||
import AuthenticationServices
|
||||
import Security
|
||||
|
||||
// MARK: - User model
|
||||
|
||||
struct AppUser: Codable, Equatable {
|
||||
let id: String
|
||||
var email: String?
|
||||
var displayName: String?
|
||||
}
|
||||
|
||||
// MARK: - Auth Manager
|
||||
|
||||
@MainActor
|
||||
final class AuthManager: NSObject, ObservableObject {
|
||||
static let shared = AuthManager()
|
||||
|
||||
@Published var currentUser: AppUser?
|
||||
@Published var isLoading = false
|
||||
@Published var authError: String?
|
||||
|
||||
private let keychainUser = "kisani.auth.user.v1"
|
||||
private let keychainEmail = "kisani.auth.apple.email."
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
currentUser = loadUser()
|
||||
}
|
||||
|
||||
var isAuthenticated: Bool { currentUser != nil }
|
||||
|
||||
// MARK: - Sign in with Apple
|
||||
|
||||
func handleApple(_ result: Result<ASAuthorization, Error>) {
|
||||
authError = nil
|
||||
switch result {
|
||||
case .success(let auth):
|
||||
guard let cred = auth.credential as? ASAuthorizationAppleIDCredential else { return }
|
||||
|
||||
// Apple only provides email + name on the very first sign-in — cache them
|
||||
var email = cred.email ?? loadAppleEmail(for: cred.user)
|
||||
if let e = cred.email { saveAppleEmail(e, for: cred.user) }
|
||||
|
||||
let fullName = [cred.fullName?.givenName, cred.fullName?.familyName]
|
||||
.compactMap { $0 }.joined(separator: " ")
|
||||
|
||||
let user = AppUser(
|
||||
id: cred.user,
|
||||
email: email,
|
||||
displayName: fullName.isEmpty ? nil : fullName
|
||||
)
|
||||
persist(user)
|
||||
|
||||
case .failure(let err):
|
||||
if (err as NSError).code != ASAuthorizationError.canceled.rawValue {
|
||||
authError = err.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sign Out
|
||||
|
||||
func signOut() {
|
||||
currentUser = nil
|
||||
deleteKeychain(key: keychainUser)
|
||||
}
|
||||
|
||||
// MARK: - Keychain
|
||||
|
||||
private func persist(_ user: AppUser) {
|
||||
currentUser = user
|
||||
if let data = try? JSONEncoder().encode(user) {
|
||||
saveKeychain(key: keychainUser, data: data)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadUser() -> AppUser? {
|
||||
guard let data = readKeychain(key: keychainUser) else { return nil }
|
||||
return try? JSONDecoder().decode(AppUser.self, from: data)
|
||||
}
|
||||
|
||||
private func saveKeychain(key: String, data: Data) {
|
||||
let q: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: key, kSecValueData: data,
|
||||
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock]
|
||||
SecItemDelete(q as CFDictionary)
|
||||
SecItemAdd(q as CFDictionary, nil)
|
||||
}
|
||||
|
||||
private func readKeychain(key: String) -> Data? {
|
||||
let q: [CFString: Any] = [kSecClass: kSecClassGenericPassword,
|
||||
kSecAttrAccount: key,
|
||||
kSecReturnData: true,
|
||||
kSecMatchLimit: kSecMatchLimitOne]
|
||||
var ref: AnyObject?
|
||||
SecItemCopyMatching(q as CFDictionary, &ref)
|
||||
return ref as? Data
|
||||
}
|
||||
|
||||
private func deleteKeychain(key: String) {
|
||||
let q: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key]
|
||||
SecItemDelete(q as CFDictionary)
|
||||
}
|
||||
|
||||
// Apple only provides email on first sign-in — store it ourselves
|
||||
private func saveAppleEmail(_ email: String, for uid: String) {
|
||||
saveKeychain(key: keychainEmail + uid, data: Data(email.utf8))
|
||||
}
|
||||
private func loadAppleEmail(for uid: String) -> String? {
|
||||
guard let d = readKeychain(key: keychainEmail + uid) else { return nil }
|
||||
return String(data: d, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user