129 lines
4.4 KiB
Swift
129 lines
4.4 KiB
Swift
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()
|
|
}
|
|
}
|