apple_kit

Apple-native services for Radroots iOS and macOS apps
git clone https://radroots.dev/git/apple_kit.git
Log | Files | Refs | README

RadrootsAppleUserPresence.swift (8276B)


      1 import Foundation
      2 
      3 #if canImport(LocalAuthentication)
      4 @preconcurrency import LocalAuthentication
      5 #endif
      6 
      7 public struct RadrootsAppleUserPresenceAdapters: Sendable {
      8     public let currentStatus: @Sendable () async throws -> RadrootsUserPresenceStatus
      9     public let verify: @Sendable (RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult
     10 
     11     public init(
     12         currentStatus: @escaping @Sendable () async throws -> RadrootsUserPresenceStatus,
     13         verify: @escaping @Sendable (RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult
     14     ) {
     15         self.currentStatus = currentStatus
     16         self.verify = verify
     17     }
     18 
     19     public static func live(callbackTimeout: TimeInterval = 30) -> Self {
     20         #if canImport(LocalAuthentication)
     21         Self(
     22             currentStatus: {
     23                 Self.status(for: LAContext())
     24             },
     25             verify: { request in
     26                 let context = LAContext()
     27                 return try await Self.verify(
     28                     request,
     29                     context: context,
     30                     callbackTimeout: callbackTimeout
     31                 )
     32             }
     33         )
     34         #else
     35         Self(
     36             currentStatus: {
     37                 throw RadrootsUserPresenceError.unavailable("user presence is unavailable")
     38             },
     39             verify: { _ in
     40                 throw RadrootsUserPresenceError.unavailable("user presence is unavailable")
     41             }
     42         )
     43         #endif
     44     }
     45 }
     46 
     47 public final class RadrootsAppleUserPresence: RadrootsUserPresence, Sendable {
     48     private let adapters: RadrootsAppleUserPresenceAdapters
     49 
     50     public init(adapters: RadrootsAppleUserPresenceAdapters = RadrootsAppleUserPresenceAdapters.live()) {
     51         self.adapters = adapters
     52     }
     53 
     54     public func currentStatus() async throws -> RadrootsUserPresenceStatus {
     55         try await adapters.currentStatus()
     56     }
     57 
     58     public func verify(_ request: RadrootsUserPresenceRequest) async throws -> RadrootsUserPresenceResult {
     59         try await adapters.verify(request)
     60     }
     61 }
     62 
     63 #if canImport(LocalAuthentication)
     64 extension RadrootsAppleUserPresenceAdapters {
     65     static func platformPolicy(_ policy: RadrootsUserPresencePolicy) -> LAPolicy {
     66         switch policy {
     67         case .deviceOwnerAuthentication:
     68             .deviceOwnerAuthentication
     69         case .deviceOwnerAuthenticationWithBiometrics:
     70             .deviceOwnerAuthenticationWithBiometrics
     71         }
     72     }
     73 
     74     static func status(for context: LAContext) -> RadrootsUserPresenceStatus {
     75         var biometricsError: NSError?
     76         let canEvaluateBiometrics = context.canEvaluatePolicy(
     77             .deviceOwnerAuthenticationWithBiometrics,
     78             error: &biometricsError
     79         )
     80 
     81         var deviceCredentialError: NSError?
     82         let canEvaluateDeviceCredential = context.canEvaluatePolicy(
     83             .deviceOwnerAuthentication,
     84             error: &deviceCredentialError
     85         )
     86 
     87         let support: RadrootsUserPresenceSupport
     88         if canEvaluateBiometrics {
     89             support = .biometricsOrDeviceCredential
     90         } else if canEvaluateDeviceCredential {
     91             support = .deviceCredential
     92         } else {
     93             support = .none
     94         }
     95 
     96         return RadrootsUserPresenceStatus(
     97             support: support,
     98             biometryKind: biometryKind(context.biometryType),
     99             canEvaluateDeviceCredential: canEvaluateDeviceCredential,
    100             canEvaluateBiometrics: canEvaluateBiometrics
    101         )
    102     }
    103 
    104     static func biometryKind(_ biometryType: LABiometryType) -> RadrootsBiometryKind {
    105         switch biometryType {
    106         case .none:
    107             .none
    108         case .touchID:
    109             .touchID
    110         case .faceID:
    111             .faceID
    112         case .opticID:
    113             .opticID
    114         @unknown default:
    115             .unknown
    116         }
    117     }
    118 
    119     static func verify(
    120         _ request: RadrootsUserPresenceRequest,
    121         context: LAContext,
    122         callbackTimeout: TimeInterval
    123     ) async throws -> RadrootsUserPresenceResult {
    124         try await RadrootsAppleUserPresenceAsyncSupport.awaitCallback(
    125             timeout: callbackTimeout,
    126             timeoutMessage: "timed out while completing user presence verification"
    127         ) { completion in
    128             context.evaluatePolicy(
    129                 platformPolicy(request.policy),
    130                 localizedReason: request.reason
    131             ) { success, error in
    132                 if let error {
    133                     completion(.failure(adapt(error: error)))
    134                 } else {
    135                     completion(.success(RadrootsUserPresenceResult(policy: request.policy, verified: success)))
    136                 }
    137             }
    138         }
    139     }
    140 
    141     static func adapt(error: Error) -> RadrootsUserPresenceError {
    142         if let error = error as? RadrootsUserPresenceError {
    143             return error
    144         }
    145 
    146         if let error = error as? LAError {
    147             switch error.code {
    148             case .userCancel, .userFallback:
    149                 return .userCancelled(error.localizedDescription)
    150             case .appCancel, .systemCancel, .notInteractive:
    151                 return .transientFailure(error.localizedDescription)
    152             case .biometryNotAvailable, .biometryNotEnrolled, .passcodeNotSet:
    153                 return .unavailable(error.localizedDescription)
    154             case .authenticationFailed:
    155                 return .permissionDenied(error.localizedDescription)
    156             default:
    157                 return .permanentFailure(error.localizedDescription)
    158             }
    159         }
    160 
    161         return .permanentFailure(error.localizedDescription)
    162     }
    163 }
    164 #endif
    165 
    166 enum RadrootsAppleUserPresenceAsyncSupport {
    167     static func awaitCallback<Value: Sendable>(
    168         timeout: TimeInterval,
    169         timeoutMessage: String,
    170         _ body: (@escaping @Sendable (Result<Value, RadrootsUserPresenceError>) -> Void) -> Void
    171     ) async throws -> Value {
    172         let state = RadrootsAppleUserPresenceAsyncCallbackState<Value>()
    173         return try await withTaskCancellationHandler {
    174             try await withCheckedThrowingContinuation { continuation in
    175                 state.install(continuation)
    176                 body { result in
    177                     state.resume(result)
    178                 }
    179                 Task {
    180                     try? await Task.sleep(nanoseconds: try Self.timeoutNanoseconds(timeout))
    181                     state.resume(.failure(.timeout(timeoutMessage)))
    182                 }
    183             }
    184         } onCancel: {
    185             state.resume(.failure(.userCancelled("user presence verification was cancelled")))
    186         }
    187     }
    188 
    189     private static func timeoutNanoseconds(_ timeout: TimeInterval) throws -> UInt64 {
    190         guard timeout.isFinite, timeout > 0 else {
    191             throw RadrootsUserPresenceError.invalidRequest("user presence timeout must be finite and greater than zero")
    192         }
    193         let nanoseconds = timeout * 1_000_000_000
    194         guard nanoseconds <= Double(UInt64.max) else {
    195             throw RadrootsUserPresenceError.invalidRequest("user presence timeout is too large")
    196         }
    197         return UInt64(nanoseconds)
    198     }
    199 }
    200 
    201 private final class RadrootsAppleUserPresenceAsyncCallbackState<Value: Sendable>: @unchecked Sendable {
    202     private let lock = NSLock()
    203     private var continuation: CheckedContinuation<Value, any Error>?
    204     private var didResolve = false
    205 
    206     func install(_ continuation: CheckedContinuation<Value, any Error>) {
    207         lock.lock()
    208         defer { lock.unlock() }
    209         guard !didResolve else {
    210             continuation.resume(throwing: RadrootsUserPresenceError.transientFailure("user presence verification already resolved"))
    211             return
    212         }
    213         self.continuation = continuation
    214     }
    215 
    216     func resume(_ result: Result<Value, RadrootsUserPresenceError>) {
    217         let pending: CheckedContinuation<Value, any Error>?
    218         lock.lock()
    219         if didResolve {
    220             lock.unlock()
    221             return
    222         }
    223         didResolve = true
    224         pending = continuation
    225         continuation = nil
    226         lock.unlock()
    227 
    228         switch result {
    229         case .success(let value):
    230             pending?.resume(returning: value)
    231         case .failure(let error):
    232             pending?.resume(throwing: error)
    233         }
    234     }
    235 }