mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-11 13:54:06 -06:00
471 lines
17 KiB
Swift
471 lines
17 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?
|
|
|
|
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<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) {
|
|
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
|