Files
ios/BitwardenAutoFillExtension/CredentialProviderViewController.swift

596 lines
22 KiB
Swift

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<Bool, Never>(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?
/// Zero-frame text field that holds first responder in `autofillText` mode to keep
/// InputUI's keyboard session alive across view-controller transitions and search dismissals.
private var keyboardAnchor: KeyboardAnchorTextField?
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
}
Task {
await initializeAppWithoutUserInteraction(
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:
Task {
await initializeAppWithoutUserInteraction(
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(appContext: .appExtension, 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)
}
}
}
/// Sets up and initializes the app without user interaction.
/// - Parameter context: The context that describes how the extension is being used.
private func initializeAppWithoutUserInteraction(
with context: CredentialProviderContext,
) async {
initializeApp(with: context)
await appProcessor?.prepareEnvironmentConfig()
}
/// 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
}
Task {
await initializeAppWithoutUserInteraction(
with: DefaultCredentialProviderContext(.autofillOTPCredential(otpIdentity, userInteraction: false)),
)
provideOTPCredential(for: recordIdentifier)
}
}
}
// MARK: - iOS 18
extension CredentialProviderViewController {
@available(iOSApplicationExtension 18.0, *)
override func prepareInterfaceForUserChoosingTextToInsert() {
// Anchor here rather than inside initializeApp: initializeApp launches an async Task,
// so by the time it runs something else may have stolen focus. At this call site the view
// is already in the window with no child VCs, giving becomeFirstResponder a clean shot.
let anchor = KeyboardAnchorTextField(frame: .zero)
view.addSubview(anchor)
keyboardAnchor = anchor
anchor.becomeFirstResponder()
initializeApp(with: DefaultCredentialProviderContext(.autofillText))
}
@available(iOSApplicationExtension 18.0, *)
override func prepareOneTimeCodeCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) {
initializeApp(with: DefaultCredentialProviderContext(.autofillOTP(serviceIdentifiers)))
}
}
// MARK: - iOS 26
extension CredentialProviderViewController {
@available(iOSApplicationExtension 26.2, *)
override func prepareInterface(for savePasswordRequest: ASSavePasswordRequest) {
initializeApp(with: DefaultCredentialProviderContext(
.savePasswordCredential(savePasswordRequest, userInteraction: true),
))
}
@available(iOSApplicationExtension 26.2, *)
override func performWithoutUserInteractionIfPossible(savePasswordRequest: ASSavePasswordRequest) {
Task {
await initializeAppWithoutUserInteraction(
with: DefaultCredentialProviderContext(
.savePasswordCredential(savePasswordRequest, userInteraction: false),
),
)
await savePassword(savePasswordRequest: savePasswordRequest)
}
}
/// Handles a save-password request from the AutoFill extension, persisting the credential to the vault.
///
/// - Parameter savePasswordRequest: The `ASSavePasswordRequest` containing the credential and service info to save.
@available(iOSApplicationExtension 26.2, *)
private func savePassword(savePasswordRequest: ASSavePasswordRequest) async {
guard let appProcessor else {
cancel(error: ASExtensionError(.failed))
return
}
do {
try await appProcessor.savePasswordCredential(
username: savePasswordRequest.credential.user,
password: savePasswordRequest.credential.password,
uri: savePasswordRequest.serviceIdentifier.normalizedURI,
name: savePasswordRequest.title,
)
extensionContext.completeSavePasswordRequest(completionHandler: nil)
} catch {
Logger.appExtension.error("Error saving password credential without user interaction: \(error)")
cancel(error: ASExtensionError(.userInteractionRequired))
}
}
}
// 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? {
context?.uri
}
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: - CredentialProviderExtensionDelegate
extension CredentialProviderViewController: CredentialProviderExtensionDelegate {
/// The mode in which the autofill extension is running.
var extensionMode: CredentialProviderMode {
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))
}
func completeSavePasswordRequest() {
guard #available(iOSApplicationExtension 26.2, *) else { return }
extensionContext.completeSavePasswordRequest(completionHandler: nil)
}
@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<AnyPublisher<Bool, Never>> {
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) {
// In autofillText mode, reclaim the anchor before the transition to keep the keyboard
// session alive. In other modes keyboardAnchor is nil, so this is a no-op.
keyboardAnchor?.becomeFirstResponder()
removeChildViewController()
if let toViewController = child.rootViewController {
addChild(toViewController)
view.addConstrained(subview: toViewController.view)
toViewController.didMove(toParent: self)
}
}
// MARK: Private methods
/// Removes the first child view controller.
func removeChildViewController() {
guard let fromViewController = children.first else { return }
fromViewController.willMove(toParent: nil)
fromViewController.view.removeFromSuperview()
fromViewController.removeFromParent()
}
}
// MARK: - KeyboardAnchorTextField
/// A zero-frame text field used in `autofillText` mode to hold first responder and keep
/// the system keyboard session alive until the SwiftUI view hierarchy is ready.
///
/// `inputView` must remain `nil` (the default); a custom input view suppresses the system
/// keyboard session, causing InputUI to end it after ~5 seconds. Once the SwiftUI view
/// appears it takes over first responder via `@FocusState`.
///
/// Registers a `keyboardWillHide` safety net on init to reclaim first responder if something
/// unexpectedly dismisses the keyboard mid-flow.
private final class KeyboardAnchorTextField: UITextField {
@available(*, unavailable)
required init?(coder: NSCoder) { nil }
override init(frame: CGRect) {
super.init(frame: frame)
NotificationCenter.default.addObserver(
self,
selector: #selector(keyboardWillHide),
name: UIResponder.keyboardWillHideNotification,
object: nil,
)
}
@objc
private func keyboardWillHide() {
guard !isFirstResponder else { return }
Logger.appExtension.debug("KeyboardAnchorTextField: keyboard will hide without anchor as FR — reclaiming")
becomeFirstResponder()
}
@discardableResult
override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
Logger.appExtension.debug("KeyboardAnchorTextField: becomeFirstResponder → \(result)")
return result
}
@discardableResult
override func resignFirstResponder() -> Bool {
let result = super.resignFirstResponder()
Logger.appExtension.debug("KeyboardAnchorTextField: resignFirstResponder → \(result)")
return result
}
} // swiftlint:disable:this file_length