Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/SignalServiceKit/LocalDeviceAuth/LocalDeviceAuthentication.swift
1 views
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LocalAuthentication

public struct LocalDeviceAuthentication {
    public enum AuthError: Error {
        case notRequired
        case canceled
        case genericError(localizedErrorMessage: String)
    }

    /// An opaque object representing successful authentication.
    public struct AuthSuccess {}

    public struct AttemptToken {}

    private let context: LAContext

    public init() {
        context = DeviceOwnerAuthenticationType.localAuthenticationContext()
    }

    // MARK: -

    public func performBiometricAuth() async -> AuthSuccess? {
        let localDeviceAuthAttemptToken: AttemptToken

        switch self.checkCanAttempt() {
        case .success(let attemptToken): localDeviceAuthAttemptToken = attemptToken
        case .failure(.notRequired): return AuthSuccess()
        case .failure(.canceled), .failure(.genericError): return nil
        }

        switch await self.attempt(token: localDeviceAuthAttemptToken) {
        case .success, .failure(.notRequired): return AuthSuccess()
        case .failure(.canceled), .failure(.genericError): return nil
        }
    }

    // MARK: -

    /// Returns whether checking local device auth is possible. Must be called
    /// prior to calling ``attempt(token:)``.
    public func checkCanAttempt() -> Result<AttemptToken, AuthError> {
        var error: NSError?
        let canEvaluatePolicy = context.canEvaluatePolicy(.deviceOwnerAuthentication, error: &error)

        guard canEvaluatePolicy, error == nil else {
            return .failure(parseAuthError(error))
        }

        return .success(AttemptToken())
    }

    /// Returns the result of performing local device authentication. Must be
    /// called after calling ``checkCanAttempt()``.
    public func attempt(token: AttemptToken) async -> Result<AuthSuccess, AuthError> {
        do {
            try await context.evaluatePolicy(
                .deviceOwnerAuthentication,
                localizedReason: OWSLocalizedString(
                    "LINK_NEW_DEVICE_AUTHENTICATION_REASON",
                    comment: "Description of how and why Signal iOS uses Touch ID/Face ID/Phone Passcode to unlock device linking.",
                ),
            )

            return .success(AuthSuccess())
        } catch {
            return .failure(parseAuthError(error))
        }
    }

    private func parseAuthError(_ error: Error?) -> AuthError {
        guard
            let error,
            let laError = error as? LAError
        else {
            owsFailDebug("Unexpected or missing auth error: \(error as Optional)")
            return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.unknownError)
        }

        switch laError.code {
        case
            .biometryNotAvailable,
            .biometryNotEnrolled,
            .passcodeNotSet,
            .touchIDNotAvailable,
            .touchIDNotEnrolled:
            return .notRequired
        case
            .userCancel,
            .userFallback,
            .systemCancel,
            .appCancel:
            return .canceled
        case .biometryLockout, .touchIDLockout:
            return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.lockout)
        case .authenticationFailed:
            return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.authenticationFailed)
        case .invalidContext:
            owsFailDebug("Context not valid.")
            return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.unknownError)
        case .notInteractive:
            owsFailDebug("Context not interactive!")
            return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.unknownError)
        case .companionNotAvailable:
            owsFailDebug("Companion device not available.")
            return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.unknownError)
        @unknown default:
            owsFailDebug("Unexpected LAContext error code: \(laError.code)")
            return .genericError(localizedErrorMessage: DeviceAuthenticationErrorMessage.unknownError)
        }
    }
}