import AuthenticationServices import BitwardenKit import BitwardenSdk import BitwardenShared import Combine import OSLog /// An `ASCredentialProviderViewController` that implements credential autofill. /// class CredentialProviderViewController: ASCredentialProviderViewController { // MARK: Properties /// The app's theme. var appTheme: AppTheme = .default /// A subject containing whether the controller did appear. private var didAppearSubject = CurrentValueSubject(false) /// The processor that manages application level logic. private var appProcessor: AppProcessor? /// The context of the credential provider to see how the extension is being used. private var context: CredentialProviderContext? override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) didAppearSubject.send(true) } // MARK: ASCredentialProviderViewController override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) { initializeApp(with: DefaultCredentialProviderContext(.autofillVaultList(serviceIdentifiers))) } @available(iOSApplicationExtension 17.0, *) override func prepareCredentialList( for serviceIdentifiers: [ASCredentialServiceIdentifier], requestParameters: ASPasskeyCredentialRequestParameters ) { initializeApp(with: DefaultCredentialProviderContext( .autofillFido2VaultList(serviceIdentifiers, requestParameters) )) } override func prepareInterfaceForExtensionConfiguration() { initializeApp(with: DefaultCredentialProviderContext(.configureAutofill)) } @available(iOSApplicationExtension 17.0, *) override func prepareInterface(forPasskeyRegistration registrationRequest: any ASCredentialRequest) { guard let fido2RegistrationRequest = registrationRequest as? ASPasskeyCredentialRequest else { return } initializeApp(with: DefaultCredentialProviderContext(.registerFido2Credential(fido2RegistrationRequest))) } override func prepareInterfaceToProvideCredential(for credentialIdentity: ASPasswordCredentialIdentity) { initializeApp(with: DefaultCredentialProviderContext( .autofillCredential(credentialIdentity, userInteraction: true) )) } override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) { guard let recordIdentifier = credentialIdentity.recordIdentifier else { cancel(error: ASExtensionError(.credentialIdentityNotFound)) return } initializeApp( with: DefaultCredentialProviderContext(.autofillCredential(credentialIdentity, userInteraction: false)) ) provideCredential(for: recordIdentifier) } @available(iOSApplicationExtension 17.0, *) override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) { switch credentialRequest { case let passwordRequest as ASPasswordCredentialRequest: if let passwordIdentity = passwordRequest.credentialIdentity as? ASPasswordCredentialIdentity { provideCredentialWithoutUserInteraction(for: passwordIdentity) } case let passkeyRequest as ASPasskeyCredentialRequest: initializeApp( with: DefaultCredentialProviderContext( .autofillFido2Credential(passkeyRequest, userInteraction: false) ) ) provideFido2Credential(for: passkeyRequest) default: if #available(iOSApplicationExtension 18.0, *), let otpRequest = credentialRequest as? ASOneTimeCodeCredentialRequest, let otpIdentity = otpRequest.credentialIdentity as? ASOneTimeCodeCredentialIdentity { provideOTPCredentialWithoutUserInteraction(for: otpIdentity) } } } @available(iOSApplicationExtension 17.0, *) override func prepareInterfaceToProvideCredential(for credentialRequest: any ASCredentialRequest) { switch credentialRequest { case let passwordRequest as ASPasswordCredentialRequest: if let passwordIdentity = passwordRequest.credentialIdentity as? ASPasswordCredentialIdentity { prepareInterfaceToProvideCredential(for: passwordIdentity) } case let passkeyRequest as ASPasskeyCredentialRequest: initializeApp( with: DefaultCredentialProviderContext( .autofillFido2Credential(passkeyRequest, userInteraction: true) ) ) default: if #available(iOSApplicationExtension 18.0, *), let otpRequest = credentialRequest as? ASOneTimeCodeCredentialRequest, let otpIdentity = otpRequest.credentialIdentity as? ASOneTimeCodeCredentialIdentity { initializeApp(with: DefaultCredentialProviderContext( .autofillOTPCredential(otpIdentity, userInteraction: true) )) } } } // MARK: Private /// Cancels the extension request and dismisses the extension's view controller. /// /// - Parameter error: An optional error describing why the request failed. /// private func cancel(error: Error? = nil) { if let context, context.configuring { extensionContext.completeExtensionConfigurationRequest() } else if let error { extensionContext.cancelRequest(withError: error) } else { extensionContext.cancelRequest( withError: NSError( domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue ) ) } } /// Sets up and initializes the app and UI. /// /// - Parameters: /// - with: The context that describes how the extension is being used. /// private func initializeApp(with context: CredentialProviderContext) { self.context = context let errorReporter = OSLogErrorReporter() let services = ServiceContainer(errorReporter: errorReporter) let appModule = DefaultAppModule(appExtensionDelegate: self, services: services) let appProcessor = AppProcessor(appExtensionDelegate: self, appModule: appModule, services: services) self.appProcessor = appProcessor if context.flowWithUserInteraction { Task { await appProcessor.start(appContext: .appExtension, navigator: self, window: nil) } } } /// Attempts to provide the credential with the specified ID to the extension context to handle /// 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. /// private func provideCredential( for id: String, repromptPasswordValidated: Bool = false ) { guard let appProcessor else { cancel(error: ASExtensionError(.failed)) return } Task { do { let credential = try await appProcessor.provideCredential( for: id, repromptPasswordValidated: repromptPasswordValidated ) extensionContext.completeRequest(withSelectedCredential: credential) } catch { Logger.appExtension.error("Error providing credential without user interaction: \(error)") cancel(error: error) } } } /// Provides a Fido2 credential for a passkey request /// - Parameters: /// - passkeyRequest: Request to get the credential /// - withUserInteraction: Whether this is called in a flow with user interaction. @available(iOSApplicationExtension 17.0, *) private func provideFido2Credential( for passkeyRequest: ASPasskeyCredentialRequest ) { guard let appProcessor else { cancel(error: ASExtensionError(.failed)) return } Task { do { let credential = try await appProcessor.provideFido2Credential( for: passkeyRequest ) await extensionContext.completeAssertionRequest(using: credential) } catch Fido2Error.userInteractionRequired { cancel(error: ASExtensionError(.userInteractionRequired)) } catch { if let context, context.flowFailedBecauseUserInteractionRequired { return } Logger.appExtension.error("Error providing credential without user interaction: \(error)") cancel(error: error) } } } /// Attempts to provide the OTP credential with the specified ID to the extension context to handle /// 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. /// @available(iOSApplicationExtension 18.0, *) private func provideOTPCredential( for id: String, repromptPasswordValidated: Bool = false ) { guard let appProcessor else { cancel(error: ASExtensionError(.failed)) return } Task { do { let credential = try await appProcessor.provideOTPCredential( for: id, repromptPasswordValidated: repromptPasswordValidated ) await extensionContext.completeOneTimeCodeRequest(using: credential) } catch { Logger.appExtension.error("Error providing OTP credential without user interaction: \(error)") cancel(error: error) } } } @available(iOSApplicationExtension 18.0, *) private func provideOTPCredentialWithoutUserInteraction(for otpIdentity: ASOneTimeCodeCredentialIdentity) { guard let recordIdentifier = otpIdentity.recordIdentifier else { cancel(error: ASExtensionError(.credentialIdentityNotFound)) return } initializeApp( with: DefaultCredentialProviderContext(.autofillOTPCredential(otpIdentity, userInteraction: false)) ) provideOTPCredential(for: recordIdentifier) } } // MARK: - iOS 18 extension CredentialProviderViewController { @available(iOSApplicationExtension 18.0, *) override func prepareInterfaceForUserChoosingTextToInsert() { initializeApp(with: DefaultCredentialProviderContext(.autofillText)) } @available(iOSApplicationExtension 18.0, *) override func prepareOneTimeCodeCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) { initializeApp(with: DefaultCredentialProviderContext(.autofillOTP(serviceIdentifiers))) } } // MARK: - AppExtensionDelegate extension CredentialProviderViewController: AppExtensionDelegate { var authCompletionRoute: AppRoute? { context?.authCompletionRoute } var canAutofill: Bool { true } var isAutofillingOTP: Bool { guard case .autofillOTP = context?.extensionMode else { return false } return true } var isInAppExtension: Bool { true } var uri: String? { guard let serviceIdentifiers = context?.serviceIdentifiers, let serviceIdentifier = serviceIdentifiers.first else { return nil } return switch serviceIdentifier.type { case .domain: "https://" + serviceIdentifier.identifier case .URL: serviceIdentifier.identifier @unknown default: serviceIdentifier.identifier } } func completeAutofillRequest(username: String, password: String, fields: [(String, String)]?) { let passwordCredential = ASPasswordCredential(user: username, password: password) extensionContext.completeRequest(withSelectedCredential: passwordCredential) } func didCancel() { cancel() } func didCompleteAuth() { guard let context else { return } switch context.extensionMode { case .autofillCredential: provideCredentialWithUserInteraction() case let .autofillFido2Credential(passkeyRequest, _): guard #available(iOSApplicationExtension 17.0, *), let asPasskeyRequest = passkeyRequest as? ASPasskeyCredentialRequest else { cancel(error: ASExtensionError(.failed)) return } provideFido2Credential(for: asPasskeyRequest) case let .autofillOTPCredential(otpIdentity, _): guard #available(iOSApplicationExtension 18.0, *), let asOneTimeCodeIdentity = otpIdentity as? ASOneTimeCodeCredentialIdentity else { cancel(error: ASExtensionError(.failed)) return } provideOTPCredentialWithUserInteraction(for: asOneTimeCodeIdentity) default: return } } func provideCredentialWithUserInteraction() { guard let credential = context?.passwordCredentialIdentity else { return } guard let appProcessor, let recordIdentifier = credential.recordIdentifier else { cancel(error: ASExtensionError(.failed)) return } Task { do { try await appProcessor.repromptForCredentialIfNecessary( for: recordIdentifier ) { repromptPasswordValidated in self.provideCredential( for: recordIdentifier, repromptPasswordValidated: repromptPasswordValidated ) } } catch { Logger.appExtension.error("Error providing credential: \(error)") cancel(error: error) } } } /// Provides an OTP credential with user interaction given an `ASOneTimeCodeCredentialIdentity`. /// - Parameter otpIdentity: `ASOneTimeCodeCredentialIdentity` to provide the credential for. @available(iOSApplicationExtension 18.0, *) func provideOTPCredentialWithUserInteraction(for otpIdentity: ASOneTimeCodeCredentialIdentity) { guard let appProcessor, let recordIdentifier = otpIdentity.recordIdentifier else { cancel(error: ASExtensionError(.failed)) return } Task { do { try await appProcessor.repromptForCredentialIfNecessary( for: recordIdentifier ) { repromptPasswordValidated in self.provideOTPCredential( for: recordIdentifier, repromptPasswordValidated: repromptPasswordValidated ) } } catch { Logger.appExtension.error("Error providing OTP credential: \(error)") cancel(error: error) } } } } // MARK: - AutofillAppExtensionDelegate extension CredentialProviderViewController: AutofillAppExtensionDelegate { /// The mode in which the autofill extension is running. var extensionMode: AutofillExtensionMode { context?.extensionMode ?? .configureAutofill } var flowWithUserInteraction: Bool { context?.flowWithUserInteraction ?? false } @available(iOSApplicationExtension 17.0, *) func completeAssertionRequest(assertionCredential: ASPasskeyAssertionCredential) { extensionContext.completeAssertionRequest(using: assertionCredential) } @available(iOSApplicationExtension 18.0, *) func completeOTPRequest(code: String) { extensionContext.completeOneTimeCodeRequest(using: ASOneTimeCodeCredential(code: code)) } @available(iOSApplicationExtension 17.0, *) func completeRegistrationRequest(asPasskeyRegistrationCredential: ASPasskeyRegistrationCredential) { extensionContext.completeRegistrationRequest(using: asPasskeyRegistrationCredential) } @available(iOSApplicationExtension 18.0, *) func completeTextRequest(text: String) { extensionContext.completeRequest(withTextToInsert: text) } func getDidAppearPublisher() -> AsyncPublisher> { didAppearSubject .eraseToAnyPublisher() .values } func setUserInteractionRequired() { context?.flowFailedBecauseUserInteractionRequired = true cancel(error: ASExtensionError(.userInteractionRequired)) } } // MARK: - RootNavigator extension CredentialProviderViewController: RootNavigator { var rootViewController: UIViewController? { self } func show(child: Navigator) { if let fromViewController = children.first { fromViewController.willMove(toParent: nil) fromViewController.view.removeFromSuperview() fromViewController.removeFromParent() } if let toViewController = child.rootViewController { addChild(toViewController) view.addConstrained(subview: toViewController.view) toViewController.didMove(toParent: self) } } } // swiftlint:disable:this file_length