import Foundation import AuthenticationServices import CryptoKit /// Apple "Sign in with Apple" — the primary, privacy-respecting auth path. /// /// Sign in with Apple is ideal for Proximity because it is designed around /// privacy: users can hide their real email (relay), and we get a stable /// opaque user identifier without a social graph. This keeps *account* /// identity cleanly separated from any *revealed* social identity. final class AppleAuthProvider: NSObject, AuthProvider, ASAuthorizationControllerDelegate { var providerID: AuthProviderID { .apple } private var continuation: CheckedContinuation? private var currentNonce: String? // MARK: - AuthProvider func signIn() async throws -> AuthResult { let nonce = randomNonceString() currentNonce = nonce let request = ASAuthorizationAppleIDProvider().createRequest() request.requestedScopes = [.fullName, .email] request.nonce = sha256(nonce) let controller = ASAuthorizationController(authorizationRequests: [request]) controller.delegate = self controller.presentationContextProvider = self controller.performRequests() return try await withCheckedThrowingContinuation { continuation in self.continuation = continuation } } func signOut() async throws { // Sign in with Apple has no server-side session to revoke here; // our backend session is revoked separately. } // MARK: - ASAuthorizationControllerDelegate func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, let idTokenData = credential.identityToken, let idToken = String(data: idTokenData, encoding: .utf8) else { continuation?.resume(throwing: AuthError.invalidCredential) continuation = nil return } let profile = ProviderProfile( name: credential.fullName?.formatted(), email: credential.email, givenName: credential.fullName?.givenName, familyName: credential.fullName?.familyName ) continuation?.resume(returning: AuthResult( providerID: .apple, idToken: idToken, nonce: currentNonce, profile: profile )) continuation = nil } func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { continuation?.resume(throwing: error) continuation = nil } // MARK: - Nonce helpers private func randomNonceString(length: Int = 32) -> String { precondition(length > 0) let charset: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._") var result = "" var remaining = length while remaining > 0 { let randoms: [UInt8] = (0..<16).map { _ in var r: UInt8 = 0 SecRandomCopyBytes(kSecRandomDefault, 1, &r) return r } randoms.forEach { random in if remaining == 0 { return } if random < charset.count { result.append(charset[Int(random)]) remaining -= 1 } } } return result } private func sha256(_ input: String) -> String { let hashed = SHA256.hash(data: Data(input.utf8)) return hashed.map { String(format: "%02x", $0) }.joined() } } /// Errors thrown by the auth layer. enum AuthError: Error, LocalizedError { case invalidCredential case providerUnavailable var errorDescription: String? { switch self { case .invalidCredential: return "The sign-in credential was invalid." case .providerUnavailable: return "This sign-in option is unavailable." } } } // MARK: - Presentation context provider extension AppleAuthProvider: ASAuthorizationControllerPresentationContextProviding { func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { // In production, return the active window scene. ASPresentationAnchor() } }