775 lines
30 KiB
Swift

import AuthenticationServices
import BitwardenKit
import BitwardenResources
import BitwardenSdk
import Combine
import Foundation
import UIKit
// MARK: - AppLinksError
/// The errors thrown from a `AppProcessor`.
///
enum AppProcessorError: Error {
/// The received URL from AppLinks is malformed.
case appLinksInvalidURL
/// The received URL from AppLinks does not have the correct parameters.
case appLinksInvalidParametersForPath
/// The received URL from AppLinks does not have a valid path.
case appLinksInvalidPath
/// The operation to execute is invalid.
case invalidOperation
}
/// The `AppProcessor` processes actions received at the application level and contains the logic
/// to control the top-level flow through the app.
///
@MainActor
public class AppProcessor {
// MARK: Properties
/// A delegate used to communicate with the app extension.
private(set) weak var appExtensionDelegate: AppExtensionDelegate?
/// The mediator to handle `AppIntent` actions.
private let appIntentMediator: AppIntentMediator?
/// The root module to use to create sub-coordinators.
let appModule: AppModule
/// The background task ID for the background process to send events on backgrounding.
var backgroundTaskId: UIBackgroundTaskIdentifier?
/// The root coordinator of the app.
var coordinator: AnyCoordinator<AppRoute, AppEvent>?
/// A timer to send any accumulated events every five minutes.
private(set) var sendEventTimer: Timer?
/// The services used by the app.
let services: ServiceContainer
// MARK: Initialization
/// Initializes an `AppProcessor`.
///
/// - Parameters:
/// - appExtensionDelegate: A delegate used to communicate with the app extension.
/// - appIntentMediator: The mediator to handle `AppIntent` actions.
/// - appModule: The root module to use to create sub-coordinators.
/// - debugDidEnterBackground: A closure that is called in debug builds for testing after the
/// processor finishes its work when the app enters the background.
/// - debugWillEnterForeground: A closure that is called in debug builds for testing after the
/// processor finishes its work when the app enters the foreground.
/// - services: The services used by the app.
///
public init(
appExtensionDelegate: AppExtensionDelegate? = nil,
appIntentMediator: AppIntentMediator? = nil,
appModule: AppModule,
debugDidEnterBackground: (() -> Void)? = nil,
debugWillEnterForeground: (() -> Void)? = nil,
services: ServiceContainer,
) {
self.appExtensionDelegate = appExtensionDelegate
self.appIntentMediator = appIntentMediator
self.appModule = appModule
self.services = services
self.services.notificationService.setDelegate(self)
self.services.pendingAppIntentActionMediator.setDelegate(self)
self.services.syncService.delegate = self
Task {
await services.apiService.setAccountTokenProviderDelegate(delegate: self)
}
startEventTimer()
UI.initialLanguageCode = services.appSettingsStore.appLocale ?? Bundle.main.preferredLocalizations.first
UI.applyDefaultAppearances()
Task {
for await _ in services.notificationCenterService.willEnterForegroundPublisher() {
startEventTimer()
await checkIfExtensionSwitchedAccounts()
await services.authRepository.checkSessionTimeouts { [weak self] activeUserId in
// Allow the AuthCoordinator to handle the timeout for the active user
// so any necessary routing can occur.
await self?.coordinator?.handleEvent(.didTimeout(userId: activeUserId))
}
await handleNeverTimeOutAccountBecameActive()
await completeAutofillAccountSetupIfEnabled()
#if DEBUG
debugWillEnterForeground?()
#endif
}
}
Task {
for await _ in services.notificationCenterService.didEnterBackgroundPublisher() {
stopEventTimer()
do {
let userId = try await self.services.stateService.getActiveAccountId()
try await services.vaultTimeoutService.setLastActiveTime(userId: userId)
} catch StateServiceError.noActiveAccount {
// No-op: nothing to do if there's no active account.
} catch {
services.errorReporter.log(error: error)
}
#if DEBUG
debugDidEnterBackground?()
#endif
}
}
// PM-19400: We need to listen to the changes on pending app intent actions
// so they get executed and update the navigation/UI accordingly.
Task {
for await _ in await services.stateService.pendingAppIntentActionsPublisher().values {
await services.pendingAppIntentActionMediator.executePendingAppIntentActions()
}
}
}
// MARK: Methods
/// Handles receiving a deep link URL and routing to the appropriate place in the app.
///
/// - Parameter url: The deep link URL to handle.
///
public func openUrl(_ url: URL) async {
var route = await getBitwardenUrlRoute(url: url)
if route == nil {
route = await getOtpAuthUrlRoute(url: url)
}
guard let route else { return }
await checkIfLockedAndPerformNavigation(route: route)
}
/// Starts the application flow by navigating the user to the first flow.
///
/// - Parameters:
/// - appContext: The context that the app is running within.
/// - initialRoute: The initial route to navigate to. If `nil` this, will navigate to the
/// unlock or landing auth route based on if there's an active account. Defaults to `nil`.
/// - navigator: The object that will be used to navigate between routes.
/// - splashWindow: The splash window to use to set the app's theme.
/// - window: The window to use to set the app's theme.
///
public func start(
appContext: AppContext,
initialRoute: AppRoute? = nil,
navigator: RootNavigator,
splashWindow: UIWindow? = nil,
window: UIWindow?,
) async {
let coordinator = appModule.makeAppCoordinator(appContext: appContext, navigator: navigator)
coordinator.start()
self.coordinator = coordinator
Task {
for await appTheme in await services.stateService.appThemePublisher().values {
navigator.appTheme = appTheme
splashWindow?.overrideUserInterfaceStyle = appTheme.userInterfaceStyle
window?.overrideUserInterfaceStyle = appTheme.userInterfaceStyle
}
}
await services.flightRecorder.log(
"App launched, context: \(appContext), version: \(Bundle.main.appVersion) (\(Bundle.main.buildNumber))",
)
await services.migrationService.performMigrations()
await prepareEnvironmentConfig()
await completeAutofillAccountSetupIfEnabled()
if let initialRoute {
coordinator.navigate(to: initialRoute)
} else {
await coordinator.handleEvent(.didStart)
}
}
/// Handle incoming URL from iOS AppLinks and redirect it to the correct navigation within the App
///
/// - Parameter incomingURL: The URL handled from AppLinks.
///
public func handleAppLinks(incomingURL: URL) {
guard let sanitizedUrl = URL(
string: incomingURL.absoluteString.replacingOccurrences(of: "/redirect-connector.html#", with: "/"),
),
let components = URLComponents(url: sanitizedUrl, resolvingAgainstBaseURL: true) else {
return
}
// Check for specific URL components that you need.
guard let params = components.queryItems,
components.host != nil else {
services.errorReporter.log(error: AppProcessorError.appLinksInvalidURL)
return
}
guard components.path == "/finish-signup" else {
services.errorReporter.log(error: AppProcessorError.appLinksInvalidPath)
return
}
guard let email = params.first(where: { $0.name == "email" })?.value,
let verificationToken = params.first(where: { $0.name == "token" })?.value,
let fromEmail = params.first(where: { $0.name == "fromEmail" })?.value
else {
services.errorReporter.log(error: AppProcessorError.appLinksInvalidParametersForPath)
return
}
coordinator?.navigate(to: AppRoute.auth(
AuthRoute.completeRegistrationFromAppLink(
emailVerificationToken: verificationToken,
userEmail: email,
fromEmail: Bool(fromEmail) ?? true,
)))
}
/// Handles importing credentials using Credential Exchange Protocol.
/// - Parameter credentialImportToken: The credentials import token to user with the `ASCredentialImportManager`.
@available(iOSApplicationExtension 26.0, *)
public func handleImportCredentials(credentialImportToken: UUID) async {
let route = AppRoute.tab(.vault(.importCXF(
.importCredentials(credentialImportToken: credentialImportToken),
)))
await checkIfLockedAndPerformNavigation(route: route)
}
/// Perpares the current environment configuration by loading the URLs for the active account
/// and getting the current server config.
public func prepareEnvironmentConfig() async {
await services.environmentService.loadURLsForActiveAccount()
_ = await services.configService.getConfig()
}
// MARK: Autofill Methods
/// Returns a `ASPasswordCredential` that matches the user-requested credential which can be
/// used for autofill.
///
/// - Parameters:
/// - id: The identifier of the user-requested credential to return.
/// - repromptPasswordValidated: `true` if master password reprompt was required for the
/// cipher and the user's master password was validated.
/// - Returns: A `ASPasswordCredential` that matches the user-requested credential which can be
/// used for autofill.
///
public func provideCredential(
for id: String,
repromptPasswordValidated: Bool = false,
) async throws -> ASPasswordCredential {
try await services.autofillCredentialService.provideCredential(
for: id,
autofillCredentialServiceDelegate: self,
repromptPasswordValidated: repromptPasswordValidated,
)
}
/// Provides an OTP credential for the identity
/// - Parameters:
/// - id: The identifier of the user-requested credential to return
/// - repromptPasswordValidated: true` if master password reprompt was required for the
/// cipher and the user's master password was validated.
/// - Returns: An `ASOneTimeCodeCredential` that matches the user-requested credential which can be
/// used for autofill..
@available(iOSApplicationExtension 18.0, *)
public func provideOTPCredential(
for id: String,
repromptPasswordValidated: Bool = false,
) async throws -> ASOneTimeCodeCredential {
try await services.autofillCredentialService.provideOTPCredential(
for: id,
autofillCredentialServiceDelegate: self,
repromptPasswordValidated: repromptPasswordValidated,
)
}
/// Reprompts the user for their master password if the cipher for the user-requested credential
/// requires reprompt. Once reprompt has been completed (or when it's not required), the
/// `completion` closure is called notifying the caller if the master password was validated
/// successfully for reprompt.
///
/// - Parameters:
/// - id: The identifier of the user-requested credential to return.
/// - completion: A closure that is called containing a bool that identifies if the user's
/// master password was validated successfully. This will be `false` if reprompt wasn't
/// required or if it is required and the master password was incorrect.
///
public func repromptForCredentialIfNecessary(
for id: String,
completion: @escaping (Bool) async -> Void,
) async throws {
guard try await services.vaultRepository.repromptRequiredForCipher(id: id) else {
await completion(false)
return
}
let alert = Alert.masterPasswordPrompt { password in
do {
let isValid = try await self.services.authRepository.validatePassword(password)
guard isValid else {
self.coordinator?.showAlert(.defaultAlert(title: Localizations.invalidMasterPassword)) {
Task {
await completion(false)
}
}
return
}
await completion(true)
} catch {
self.services.errorReporter.log(error: error)
await completion(false)
}
}
coordinator?.showAlert(alert)
}
/// Show the debug menu.
public func showDebugMenu() {
coordinator?.navigate(to: .debugMenu)
}
// MARK: Notification Methods
/// Called when the app has registered for push notifications.
///
/// - Parameter tokenData: The device token for push notifications.
///
public func didRegister(withToken tokenData: Data) {
Task {
await services.notificationService.didRegister(withToken: tokenData)
}
}
/// Called when the app failed to register for push notifications.
///
/// - Parameter error: The error received.
///
public func failedToRegister(_ error: Error) {
services.errorReporter.log(error: error)
}
/// Called when the app has received data from a push notification.
///
/// - Parameters:
/// - message: The content of the push notification.
/// - notificationDismissed: `true` if a notification banner has been dismissed.
/// - notificationTapped: `true` if a notification banner has been tapped.
///
public func messageReceived(
_ message: [AnyHashable: Any],
notificationDismissed: Bool? = nil,
notificationTapped: Bool? = nil,
) async {
await services.notificationService.messageReceived(
message,
notificationDismissed: notificationDismissed,
notificationTapped: notificationTapped,
)
}
}
extension AppProcessor {
// MARK: Private Methods
/// Whether there are pending `AppIntent` lock actions.
private func hasLockPendingAppIntentAction() async -> Bool {
guard let actions = await services.stateService.getPendingAppIntentActions(),
!actions.isEmpty else {
return false
}
return actions.contains(where: { $0 == .lockAll })
}
/// Handles unlocking the vault for a manually locked account that uses never lock
/// and was previously unlocked in an extension.
///
private func handleNeverTimeOutAccountBecameActive() async {
guard
appExtensionDelegate?.isInAppExtension != true,
await (try? services.authRepository.isLocked()) == true,
await (try? services.authRepository.sessionTimeoutValue()) == .never,
await (try? services.stateService.getManuallyLockedAccount(userId: nil)) == false,
await !hasLockPendingAppIntentAction(),
let account = try? await services.stateService.getActiveAccount()
else { return }
await coordinator?.handleEvent(
.accountBecameActive(
account,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false,
),
)
}
/// Checks if the active account was switched while in the extension. If this occurs, the app
/// needs to also switch to the updated active account.
///
private func checkIfExtensionSwitchedAccounts() async {
guard appExtensionDelegate?.isInAppExtension != true else { return }
do {
guard try await services.stateService.didAccountSwitchInExtension() == true else { return }
let userId = try await services.stateService.getActiveAccountId()
await coordinator?.handleEvent(.switchAccounts(userId: userId, isAutomatic: false))
} catch StateServiceError.noActiveAccount {
await coordinator?.handleEvent(.didStart)
} catch {
services.errorReporter.log(error: error)
}
}
/// Checks if the vault is locked and performs the navigation to the `AppRoute`
/// or sets it as the auth completion route.
/// - Parameter route: The `AppRoute` to go to.
private func checkIfLockedAndPerformNavigation(route: AppRoute) async {
if let userId = try? await services.stateService.getActiveAccountId(),
!services.vaultTimeoutService.isLocked(userId: userId),
await (try? services.vaultTimeoutService.hasPassedSessionTimeout(userId: userId)) == false {
coordinator?.navigate(to: route)
} else {
await coordinator?.handleEvent(.setAuthCompletionRoute(route))
}
}
/// If the native create account feature flag and the autofill extension are enabled, this marks
/// any user's autofill account setup completed. This should be called on app startup.
///
private func completeAutofillAccountSetupIfEnabled() async {
// Don't mark the user's progress as complete in the extension, otherwise the app may not
// see that the user's progress needs to be updated to publish new values to subscribers.
guard appExtensionDelegate?.isInAppExtension != true,
await services.autofillCredentialService.isAutofillCredentialsEnabled()
else { return }
do {
let accounts = try await services.stateService.getAccounts()
for account in accounts {
let userId = account.profile.userId
guard let progress = await services.stateService.getAccountSetupAutofill(userId: userId),
progress != .complete
else { continue }
try await services.stateService.setAccountSetupAutofill(.complete, userId: userId)
}
} catch StateServiceError.noAccounts {
// No-op: nothing to do if there's no accounts.
} catch {
services.errorReporter.log(error: error)
}
}
/// Attempt to create an `AppRoute` from an "bitwarden://" url.
///
/// - Parameter url: The Bitwarden URL received by the app.
/// - Returns: an `AppRoute` if one was successfully built from the URL passed in, `nil` if not.
///
private func getBitwardenUrlRoute(url: URL) async -> AppRoute? {
guard let scheme = url.scheme, scheme.isBitwardenAppScheme else { return nil }
switch url.absoluteString {
case BitwardenDeepLinkConstants.accountSecurity:
return AppRoute.tab(.settings(.accountSecurity))
case BitwardenDeepLinkConstants.authenticatorNewItem:
guard let item = await services.authenticatorSyncService?.getTemporaryTotpItem(),
let totpKey = item.totpKey else {
coordinator?.showAlert(.defaultAlert(title: Localizations.somethingWentWrong,
message: Localizations.unableToMoveTheSelectedItemPleaseTryAgain))
return nil
}
let totpKeyModel = TOTPKeyModel(authenticatorKey: totpKey)
return AppRoute.tab(.vault(.vaultItemSelection(totpKeyModel)))
default:
return nil
}
}
/// Attempt to create an `AppRoute` from an "otpauth://" url.
///
/// - Parameter url: The OTPAuth URL received by the app.
/// - Returns: an `AppRoute` if one was successfully built from the URL passed in, `nil` if not.
///
private func getOtpAuthUrlRoute(url: URL) async -> AppRoute? {
guard let scheme = url.scheme, scheme.isOtpAuthScheme else { return nil }
let totpKeyModel = TOTPKeyModel(authenticatorKey: url.absoluteString)
guard case .otpAuthUri = totpKeyModel.totpKey else {
coordinator?.showAlert(.defaultAlert(title: Localizations.anErrorHasOccurred))
return nil
}
return AppRoute.tab(.vault(.vaultItemSelection(totpKeyModel)))
}
/// Logs out the user automatically, if `nil` is passed as `userId` then it will act on the current user.
/// - Parameter userId: The ID of the user to logout, current if `nil`.
private func logOutAutomatically(userId: String? = nil) async {
coordinator?.hideLoadingOverlay()
do {
try await services.authRepository.logout(userId: userId, userInitiated: false)
} catch {
services.errorReporter.log(error: error)
}
await coordinator?.handleEvent(.didLogout(userId: userId, userInitiated: false))
}
/// Starts timer to send organization events regularly
private func startEventTimer() {
sendEventTimer = Timer.scheduledTimer(withTimeInterval: 5 * 60, repeats: true) { _ in
Task { [weak self] in
await self?.uploadEvents()
}
}
sendEventTimer?.tolerance = 10
}
/// Stops the timer for organization events
private func stopEventTimer() {
sendEventTimer?.fire()
sendEventTimer?.invalidate()
}
/// Sends organization events to the server. Also sets up that regular upload
/// as a Background Task so that it won't be canceled when the app is going
/// to the background. Per https://forums.developer.apple.com/forums/thread/85066
/// calling this for every upload (not just ones where we're backgrounding)
/// is fine.
private func uploadEvents() async {
if let taskId = backgroundTaskId {
services.application?.endBackgroundTask(taskId)
backgroundTaskId = nil
}
backgroundTaskId = services.application?.startBackgroundTask(
withName: "SendEventBackgroundTask",
expirationHandler: { [weak self] in
if let backgroundTaskId = self?.backgroundTaskId {
self?.services.application?.endBackgroundTask(backgroundTaskId)
self?.backgroundTaskId = nil
}
},
)
await services.eventService.upload()
if let taskId = backgroundTaskId {
services.application?.endBackgroundTask(taskId)
backgroundTaskId = nil
}
}
}
// MARK: - NotificationServiceDelegate
extension AppProcessor: NotificationServiceDelegate {
/// Users are logged out, route to landing page.
///
func routeToLanding() async {
coordinator?.navigate(to: .auth(.landing))
}
/// Show the login request.
///
/// - Parameter loginRequest: The login request.
///
func showLoginRequest(_ loginRequest: LoginRequest) {
coordinator?.navigate(to: .loginRequest(loginRequest))
}
/// Switch the active account in order to show the login request, prompting the user if necessary.
///
/// - Parameters:
/// - account: The account associated with the login request.
/// - showAlert: Whether to show the alert or simply switch the account.
///
func switchAccountsForLoginRequest(to account: Account, showAlert: Bool) async {
if showAlert {
coordinator?.showAlert(.confirmation(
title: Localizations.logInRequested,
message: Localizations.loginAttemptFromXDoYouWantToSwitchToThisAccount(account.profile.email),
) {
await self.switchAccountsForLoginRequest(to: account.profile.userId)
})
} else {
await switchAccountsForLoginRequest(to: account.profile.userId)
}
}
/// Switch to the specified account so they can see the login request.
///
/// - Parameter userId: The user ID of the account to switch to.
///
private func switchAccountsForLoginRequest(to userId: String) async {
// Switch to the account, the login request will be shown when their vault loads (either
// immediately or after vault unlock).
await coordinator?.handleEvent(.switchAccounts(userId: userId, isAutomatic: false))
}
}
// MARK: - SyncServiceDelegate
extension AppProcessor: SyncServiceDelegate {
func onFetchSyncSucceeded(userId: String) async {
do {
let hasPerformedSyncAfterLogin = try await services.stateService.getHasPerformedSyncAfterLogin(
userId: userId,
)
// Check so the next gets executed only once after login.
guard !hasPerformedSyncAfterLogin else {
return
}
try await services.stateService.setHasPerformedSyncAfterLogin(true, userId: userId)
if await services.policyService.policyAppliesToUser(.removeUnlockWithPin) {
try await services.stateService.clearPins()
}
} catch {
services.errorReporter.log(error: error)
}
}
func removeMasterPassword(organizationName: String, organizationId: String, keyConnectorUrl: String) {
// Don't show the remove master password screen if running in an app extension.
guard appExtensionDelegate?.isInAppExtension != true else { return }
coordinator?.hideLoadingOverlay()
coordinator?.navigate(to: .auth(.removeMasterPassword(
organizationName: organizationName,
organizationId: organizationId,
keyConnectorUrl: keyConnectorUrl,
)))
}
func securityStampChanged(userId: String) async {
// Log the user out if their security stamp changes.
await logOutAutomatically(userId: userId)
}
func setMasterPassword(orgIdentifier: String) async {
DispatchQueue.main.async { [self] in
coordinator?.navigate(to: .auth(.setMasterPassword(organizationIdentifier: orgIdentifier)))
}
}
}
// MARK: - Fido2 credentials
public extension AppProcessor {
/// Provides a Fido2 credential for a passkey request
/// - Parameters:
/// - passkeyRequest: Request to get the credential.
/// - Returns: The passkey credential for assertion.
@available(iOS 17.0, *)
func provideFido2Credential(
for passkeyRequest: ASPasskeyCredentialRequest,
) async throws -> ASPasskeyAssertionCredential {
try await services.autofillCredentialService.provideFido2Credential(
for: passkeyRequest,
autofillCredentialServiceDelegate: self,
fido2UserInterfaceHelperDelegate: self,
)
}
}
// MARK: - AccountTokenProviderDelegate
extension AppProcessor: AccountTokenProviderDelegate {
func onRefreshTokenError(error: any Error) async throws {
if case IdentityTokenRefreshRequestError.invalidGrant = error {
await logOutAutomatically()
} else if let error = error as? ResponseValidationError, [401, 403].contains(error.response.statusCode) {
await logOutAutomatically()
}
}
}
// MARK: - AutofillCredentialServiceDelegate
extension AppProcessor: AutofillCredentialServiceDelegate {
func unlockVaultWithNeverlockKey() async throws {
try await services.authRepository.unlockVaultWithNeverlockKey()
}
}
// MARK: - Fido2UserVerificationMediatorDelegate
extension AppProcessor: Fido2UserInterfaceHelperDelegate {
// MARK: Properties
var isAutofillingFromList: Bool {
guard let autofillAppExtensionDelegate = appExtensionDelegate as? AutofillAppExtensionDelegate,
autofillAppExtensionDelegate.isAutofillingFido2CredentialFromList else {
return false
}
return true
}
// MARK: Methods
func informExcludedCredentialFound(cipherView: BitwardenSdk.CipherView) async {
// No-op
}
func onNeedsUserInteraction() async throws {
guard let autofillAppExtensionDelegate = appExtensionDelegate as? AutofillAppExtensionDelegate else {
return
}
if !autofillAppExtensionDelegate.flowWithUserInteraction {
autofillAppExtensionDelegate.setUserInteractionRequired()
throw Fido2Error.userInteractionRequired
}
// WORKAROUND: We need to wait until the view controller appears in order to perform any
// action that needs user interaction or it might not show the prompt to the user.
// E.g. without this there are certain devices that don't show the FaceID prompt
// and the user only sees the screen dimming a bit and failing the flow.
for await didAppear in autofillAppExtensionDelegate.getDidAppearPublisher() {
guard didAppear else { continue }
return
}
}
func showAlert(_ alert: Alert) {
coordinator?.showAlert(alert)
}
func showAlert(_ alert: Alert, onDismissed: (() -> Void)?) {
coordinator?.showAlert(alert, onDismissed: onDismissed)
}
}
// MARK: - PendingAppIntentActionMediatorDelegate
extension AppProcessor: PendingAppIntentActionMediatorDelegate {
func onPendingAppIntentActionSuccess(
_ pendingAppIntentAction: PendingAppIntentAction,
data: Any?,
) async {
switch pendingAppIntentAction {
case .lockAll:
guard let account = data as? Account else {
return
}
await coordinator?.handleEvent(
.accountBecameActive(
account,
attemptAutomaticBiometricUnlock: true,
didSwitchAccountAutomatically: false,
),
)
case .logOutAll:
await coordinator?.handleEvent(.didLogout(userId: nil, userInitiated: true))
case .openGenerator:
await checkIfLockedAndPerformNavigation(route: .tab(.generator(.generator())))
}
}
}
// swiftlint:disable:this file_length