initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project
This commit is contained in:
128
Proximity/Services/Auth/AppleAuthProvider.swift
Normal file
128
Proximity/Services/Auth/AppleAuthProvider.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user