Files
Nearfield_Friends/Proximity/Services/Auth/AppleAuthProvider.swift

129 lines
4.4 KiB
Swift
Raw Normal View History

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<AuthResult, Error>?
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()
}
}