mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-11 04:34:55 -06:00
[PM-8828] Fido2 autofill without user interaction (#744)
This commit is contained in:
parent
81d62a3655
commit
8eccee5020
@ -1,4 +1,5 @@
|
||||
import AuthenticationServices
|
||||
import BitwardenSdk
|
||||
import BitwardenShared
|
||||
import OSLog
|
||||
|
||||
@ -61,6 +62,22 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
provideCredential(for: recordIdentifier)
|
||||
}
|
||||
|
||||
@available(iOSApplicationExtension 17.0, *)
|
||||
override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) {
|
||||
switch credentialRequest {
|
||||
case let passwordRequest as ASPasswordCredentialRequest:
|
||||
provideCredentialWithoutUserInteraction(for: passwordRequest)
|
||||
case let passkeyRequest as ASPasskeyCredentialRequest:
|
||||
initializeApp(
|
||||
with: DefaultCredentialProviderContext(.autofillFido2Credential(passkeyRequest)),
|
||||
userInteraction: false
|
||||
)
|
||||
provideFido2Credential(for: passkeyRequest)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
/// Cancels the extension request and dismisses the extension's view controller.
|
||||
@ -135,6 +152,28 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provides a Fido2 credential for a passkey request.
|
||||
/// - Parameter passkeyRequest: Request to get the credential.
|
||||
@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 {
|
||||
Logger.appExtension.error("Error providing credential without user interaction: \(error)")
|
||||
cancel(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AppExtensionDelegate
|
||||
|
||||
@ -4,9 +4,8 @@ import BitwardenSdk
|
||||
|
||||
class MockClientFido2Authenticator: ClientFido2AuthenticatorProtocol {
|
||||
var credentialsForAutofillResult: Result<[Fido2CredentialAutofillView], Error> = .success([])
|
||||
var getAssertionResult: Result<BitwardenSdk.GetAssertionResult, Error> = .success(
|
||||
BitwardenSdk.GetAssertionResult.fixture()
|
||||
)
|
||||
var getAssertionMocker = InvocationMockerWithThrowingResult<GetAssertionRequest, GetAssertionResult>()
|
||||
.withResult(.fixture())
|
||||
var makeCredentialMocker = InvocationMockerWithThrowingResult<MakeCredentialRequest, MakeCredentialResult>()
|
||||
.withResult(.fixture())
|
||||
var silentlyDiscoverCredentialsResult: Result<[Fido2CredentialAutofillView], Error> = .success([])
|
||||
@ -16,7 +15,7 @@ class MockClientFido2Authenticator: ClientFido2AuthenticatorProtocol {
|
||||
}
|
||||
|
||||
func getAssertion(request: BitwardenSdk.GetAssertionRequest) async throws -> BitwardenSdk.GetAssertionResult {
|
||||
try getAssertionResult.get()
|
||||
try getAssertionMocker.invoke(param: request)
|
||||
}
|
||||
|
||||
func makeCredential(request: BitwardenSdk.MakeCredentialRequest) async throws -> BitwardenSdk.MakeCredentialResult {
|
||||
|
||||
@ -0,0 +1,17 @@
|
||||
import AuthenticationServices
|
||||
import BitwardenSdk
|
||||
|
||||
@available(iOSApplicationExtension 17.0, *)
|
||||
extension Fido2CredentialAutofillView {
|
||||
/// Converts this credential view into an `ASPasskeyCredentialIdentity`.
|
||||
/// - Returns: A `ASPasskeyCredentialIdentity` from the values of this object.
|
||||
func toFido2CredentialIdentity() -> ASPasskeyCredentialIdentity {
|
||||
ASPasskeyCredentialIdentity(
|
||||
relyingPartyIdentifier: rpId,
|
||||
userName: safeUsernameForUi,
|
||||
credentialID: credentialId,
|
||||
userHandle: userHandle,
|
||||
recordIdentifier: cipherId
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
import BitwardenSdk
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
class Fido2CredentialAutofillViewExtensionsTests: BitwardenTestCase { // swiftlint:disable:this type_name
|
||||
// MARK: Tests
|
||||
|
||||
/// `toFido2CredentialIdentity()` returns the converted `ASPasskeyCredentialIdentity`.
|
||||
func test_toFido2CredentialIdentity() throws {
|
||||
let subject = Fido2CredentialAutofillView(
|
||||
credentialId: Data(repeating: 1, count: 16),
|
||||
cipherId: "1",
|
||||
rpId: "myApp.com",
|
||||
userNameForUi: "username",
|
||||
userHandle: Data(repeating: 1, count: 16)
|
||||
)
|
||||
let identity = subject.toFido2CredentialIdentity()
|
||||
XCTAssertTrue(
|
||||
identity.relyingPartyIdentifier == subject.rpId
|
||||
&& identity.userName == subject.userNameForUi
|
||||
&& identity.credentialID == subject.credentialId
|
||||
&& identity.userHandle == subject.userHandle
|
||||
&& identity.recordIdentifier == subject.cipherId
|
||||
)
|
||||
}
|
||||
|
||||
/// `toFido2CredentialIdentity()` returns the converted `ASPasskeyCredentialIdentity`
|
||||
/// when `userNameForUI` is `nil`.
|
||||
func test_toFido2CredentialIdentity_userNameForUINil() throws {
|
||||
let subject = Fido2CredentialAutofillView(
|
||||
credentialId: Data(repeating: 1, count: 16),
|
||||
cipherId: "1",
|
||||
rpId: "myApp.com",
|
||||
userNameForUi: nil,
|
||||
userHandle: Data(repeating: 1, count: 16)
|
||||
)
|
||||
let identity = subject.toFido2CredentialIdentity()
|
||||
XCTAssertTrue(
|
||||
identity.relyingPartyIdentifier == subject.rpId
|
||||
&& identity.userName == Localizations.unknownAccount
|
||||
&& identity.credentialID == subject.credentialId
|
||||
&& identity.userHandle == subject.userHandle
|
||||
&& identity.recordIdentifier == subject.cipherId
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -35,6 +35,13 @@ class DefaultAutofillCredentialService {
|
||||
/// The service to manage events.
|
||||
private let eventService: EventService
|
||||
|
||||
/// A store to be used on Fido2 flows to get/save credentials.
|
||||
let fido2CredentialStore: Fido2CredentialStore
|
||||
|
||||
/// A helper to be used on Fido2 flows that requires user interaction and extends the capabilities
|
||||
/// of the `Fido2UserInterface` from the SDK.
|
||||
let fido2UserInterfaceHelper: Fido2UserInterfaceHelper
|
||||
|
||||
/// The service used to manage the credentials available for AutoFill suggestions.
|
||||
private let identityStore: CredentialIdentityStore
|
||||
|
||||
@ -60,6 +67,9 @@ class DefaultAutofillCredentialService {
|
||||
/// - clientService: The service that handles common client functionality such as encryption and decryption.
|
||||
/// - errorReporter: The service used by the application to report non-fatal errors.
|
||||
/// - eventService: The service to manage events.
|
||||
/// - fido2UserInterfaceHelper: A helper to be used on Fido2 flows that requires user interaction
|
||||
/// and extends the capabilities of the `Fido2UserInterface` from the SDK.
|
||||
/// - fido2CredentialStore: A store to be used on Fido2 flows to get/save credentials.
|
||||
/// - identityStore: The service used to manage the credentials available for AutoFill suggestions.
|
||||
/// - pasteboardService: The service used to manage copy/pasting from the device's clipboard.
|
||||
/// - stateService: The service used by the application to manage account state.
|
||||
@ -70,6 +80,8 @@ class DefaultAutofillCredentialService {
|
||||
clientService: ClientService,
|
||||
errorReporter: ErrorReporter,
|
||||
eventService: EventService,
|
||||
fido2CredentialStore: Fido2CredentialStore,
|
||||
fido2UserInterfaceHelper: Fido2UserInterfaceHelper,
|
||||
identityStore: CredentialIdentityStore = ASCredentialIdentityStore.shared,
|
||||
pasteboardService: PasteboardService,
|
||||
stateService: StateService,
|
||||
@ -79,6 +91,8 @@ class DefaultAutofillCredentialService {
|
||||
self.clientService = clientService
|
||||
self.errorReporter = errorReporter
|
||||
self.eventService = eventService
|
||||
self.fido2CredentialStore = fido2CredentialStore
|
||||
self.fido2UserInterfaceHelper = fido2UserInterfaceHelper
|
||||
self.identityStore = identityStore
|
||||
self.pasteboardService = pasteboardService
|
||||
self.stateService = stateService
|
||||
@ -151,7 +165,15 @@ class DefaultAutofillCredentialService {
|
||||
|
||||
if #available(iOS 17, *) {
|
||||
let identities = decryptedCiphers.compactMap(\.credentialIdentity)
|
||||
try await identityStore.replaceCredentialIdentities(identities)
|
||||
let fido2Identities = try await clientService.platform().fido2()
|
||||
.authenticator(
|
||||
userInterface: fido2UserInterfaceHelper,
|
||||
credentialStore: fido2CredentialStore
|
||||
)
|
||||
.credentialsForAutofill()
|
||||
.compactMap { $0.toFido2CredentialIdentity() }
|
||||
|
||||
try await identityStore.replaceCredentialIdentities(identities + fido2Identities)
|
||||
Logger.application.info("AutofillCredentialService: replaced \(identities.count) credential identities")
|
||||
} else {
|
||||
let identities = decryptedCiphers.compactMap(\.passwordCredentialIdentity)
|
||||
@ -210,7 +232,10 @@ extension DefaultAutofillCredentialService: AutofillCredentialService {
|
||||
private extension CipherView {
|
||||
@available(iOS 17, *)
|
||||
var credentialIdentity: (any ASCredentialIdentity)? {
|
||||
passwordCredentialIdentity
|
||||
guard shouldGetPasswordCredentialIdentity else {
|
||||
return nil
|
||||
}
|
||||
return passwordCredentialIdentity
|
||||
}
|
||||
|
||||
var passwordCredentialIdentity: ASPasswordCredentialIdentity? {
|
||||
@ -228,6 +253,12 @@ private extension CipherView {
|
||||
recordIdentifier: id
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether the `ASPasswordCredentialIdentity` should be gotten.
|
||||
/// Otherwise a passkey identity will be provided.
|
||||
var shouldGetPasswordCredentialIdentity: Bool {
|
||||
!hasFido2Credentials || login?.password != nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CredentialIdentityStore
|
||||
|
||||
@ -10,6 +10,8 @@ class AutofillCredentialServiceTests: BitwardenTestCase {
|
||||
var clientService: MockClientService!
|
||||
var errorReporter: MockErrorReporter!
|
||||
var eventService: MockEventService!
|
||||
var fido2CredentialStore: MockFido2CredentialStore!
|
||||
var fido2UserInterfaceHelper: MockFido2UserInterfaceHelper!
|
||||
var identityStore: MockCredentialIdentityStore!
|
||||
var pasteboardService: MockPasteboardService!
|
||||
var stateService: MockStateService!
|
||||
@ -25,6 +27,8 @@ class AutofillCredentialServiceTests: BitwardenTestCase {
|
||||
clientService = MockClientService()
|
||||
errorReporter = MockErrorReporter()
|
||||
eventService = MockEventService()
|
||||
fido2CredentialStore = MockFido2CredentialStore()
|
||||
fido2UserInterfaceHelper = MockFido2UserInterfaceHelper()
|
||||
identityStore = MockCredentialIdentityStore()
|
||||
pasteboardService = MockPasteboardService()
|
||||
stateService = MockStateService()
|
||||
@ -35,6 +39,8 @@ class AutofillCredentialServiceTests: BitwardenTestCase {
|
||||
clientService: clientService,
|
||||
errorReporter: errorReporter,
|
||||
eventService: eventService,
|
||||
fido2CredentialStore: fido2CredentialStore,
|
||||
fido2UserInterfaceHelper: fido2UserInterfaceHelper,
|
||||
identityStore: identityStore,
|
||||
pasteboardService: pasteboardService,
|
||||
stateService: stateService,
|
||||
@ -49,6 +55,8 @@ class AutofillCredentialServiceTests: BitwardenTestCase {
|
||||
clientService = nil
|
||||
errorReporter = nil
|
||||
eventService = nil
|
||||
fido2CredentialStore = nil
|
||||
fido2UserInterfaceHelper = nil
|
||||
identityStore = nil
|
||||
pasteboardService = nil
|
||||
stateService = nil
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
#if DEBUG
|
||||
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
/// Report with traceability about Fido2 flows.
|
||||
public struct Fido2DebuggingReport {
|
||||
var allCredentialsResult: Result<[BitwardenSdk.CipherView], Error>?
|
||||
var findCredentialsResult: Result<[BitwardenSdk.CipherView], Error>?
|
||||
var getAssertionRequest: GetAssertionRequest?
|
||||
var getAssertionResult: Result<GetAssertionResult, Error>?
|
||||
var saveCredentialCipher: Result<BitwardenSdk.Cipher, Error>?
|
||||
}
|
||||
|
||||
/// Fido2 builder for debugging report.
|
||||
public struct Fido2DebuggingReportBuilder {
|
||||
/// Builder for Fido2 debugging report.
|
||||
public static var builder = Fido2DebuggingReportBuilder()
|
||||
|
||||
var report = Fido2DebuggingReport()
|
||||
|
||||
/// Gets the report for Fido2 debugging.
|
||||
/// - Returns: Fido2 report.
|
||||
public func getReport() -> Fido2DebuggingReport? {
|
||||
report
|
||||
}
|
||||
|
||||
mutating func withAllCredentialsResult(_ result: Result<[BitwardenSdk.CipherView], Error>) {
|
||||
report.allCredentialsResult = result
|
||||
}
|
||||
|
||||
mutating func withFindCredentialsResult(_ result: Result<[BitwardenSdk.CipherView], Error>) {
|
||||
report.findCredentialsResult = result
|
||||
}
|
||||
|
||||
mutating func withGetAssertionRequest(_ request: GetAssertionRequest) {
|
||||
report.getAssertionRequest = request
|
||||
}
|
||||
|
||||
mutating func withGetAssertionResult(_ result: Result<GetAssertionResult, Error>) {
|
||||
report.getAssertionResult = result
|
||||
}
|
||||
|
||||
mutating func withSaveCredentialCipher(_ credential: Result<BitwardenSdk.Cipher, Error>) {
|
||||
report.saveCredentialCipher = credential
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@ -456,16 +456,6 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
trustDeviceService: trustDeviceService
|
||||
)
|
||||
|
||||
let autofillCredentialService = DefaultAutofillCredentialService(
|
||||
cipherService: cipherService,
|
||||
clientService: clientService,
|
||||
errorReporter: errorReporter,
|
||||
eventService: eventService,
|
||||
pasteboardService: pasteboardService,
|
||||
stateService: stateService,
|
||||
vaultTimeoutService: vaultTimeoutService
|
||||
)
|
||||
|
||||
let authRepository = DefaultAuthRepository(
|
||||
accountAPIService: apiService,
|
||||
authService: authService,
|
||||
@ -552,9 +542,34 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
)
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
let fido2CredentialStore = DebuggingFido2CredentialStoreService(
|
||||
fido2CredentialStore: Fido2CredentialStoreService(
|
||||
cipherService: cipherService,
|
||||
clientService: clientService,
|
||||
errorReporter: errorReporter,
|
||||
syncService: syncService
|
||||
)
|
||||
)
|
||||
#else
|
||||
let fido2CredentialStore = Fido2CredentialStoreService(
|
||||
cipherService: cipherService,
|
||||
clientService: clientService
|
||||
clientService: clientService,
|
||||
errorReporter: errorReporter,
|
||||
syncService: syncService
|
||||
)
|
||||
#endif
|
||||
|
||||
let autofillCredentialService = DefaultAutofillCredentialService(
|
||||
cipherService: cipherService,
|
||||
clientService: clientService,
|
||||
errorReporter: errorReporter,
|
||||
eventService: eventService,
|
||||
fido2CredentialStore: fido2CredentialStore,
|
||||
fido2UserInterfaceHelper: fido2UserInterfaceHelper,
|
||||
pasteboardService: pasteboardService,
|
||||
stateService: stateService,
|
||||
vaultTimeoutService: vaultTimeoutService
|
||||
)
|
||||
|
||||
self.init(
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
|
||||
/// The Fido2 credential store implementation that the SDK needs
|
||||
/// which handles getting/saving credentials for Fido2 flows.
|
||||
class Fido2CredentialStoreService: Fido2CredentialStore {
|
||||
// MARK: Properties
|
||||
|
||||
@ -10,21 +12,42 @@ class Fido2CredentialStoreService: Fido2CredentialStore {
|
||||
/// The service that handles common client functionality such as encryption and decryption.
|
||||
private let clientService: ClientService
|
||||
|
||||
/// The service used by the application to report non-fatal errors.
|
||||
private let errorReporter: ErrorReporter
|
||||
|
||||
/// The service used to handle syncing vault data with the API
|
||||
private let syncService: SyncService
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initializes a `Fido2CredentialStoreService`
|
||||
/// - Parameters:
|
||||
/// - cipherService: The service used to manage syncing and updates to the user's ciphers.
|
||||
/// - clientService: The service that handles common client functionality such as encryption and decryption.
|
||||
init(cipherService: CipherService, clientService: ClientService) {
|
||||
/// - errorReporter: The service used by the application to report non-fatal errors.
|
||||
/// - syncService: The service used to handle syncing vault data with the API.
|
||||
init(
|
||||
cipherService: CipherService,
|
||||
clientService: ClientService,
|
||||
errorReporter: ErrorReporter,
|
||||
syncService: SyncService
|
||||
) {
|
||||
self.cipherService = cipherService
|
||||
self.clientService = clientService
|
||||
self.errorReporter = errorReporter
|
||||
self.syncService = syncService
|
||||
}
|
||||
|
||||
/// Gets all the active login ciphers that have Fido2 credentials.
|
||||
/// - Returns: Array of active login ciphers that have Fido2 credentials.
|
||||
func allCredentials() async throws -> [BitwardenSdk.CipherView] {
|
||||
try await cipherService.fetchAllCiphers()
|
||||
do {
|
||||
try await syncService.fetchSync(forceSync: false)
|
||||
} catch {
|
||||
errorReporter.log(error: error)
|
||||
}
|
||||
|
||||
return try await cipherService.fetchAllCiphers()
|
||||
.filter(\.isActiveWithFido2Credentials)
|
||||
.asyncMap { cipher in
|
||||
try await self.clientService.vault().ciphers().decrypt(cipher: cipher)
|
||||
@ -81,3 +104,48 @@ private extension Cipher {
|
||||
&& login?.fido2Credentials?.isEmpty == false
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
/// A wrapper of a `Fido2CredentialStore` which adds debugging info for the `Fido2DebugginReportBuilder`.
|
||||
class DebuggingFido2CredentialStoreService: Fido2CredentialStore {
|
||||
let fido2CredentialStore: Fido2CredentialStore
|
||||
|
||||
init(fido2CredentialStore: Fido2CredentialStore) {
|
||||
self.fido2CredentialStore = fido2CredentialStore
|
||||
}
|
||||
|
||||
func findCredentials(ids: [Data]?, ripId: String) async throws -> [BitwardenSdk.CipherView] {
|
||||
do {
|
||||
let result = try await fido2CredentialStore.findCredentials(ids: ids, ripId: ripId)
|
||||
Fido2DebuggingReportBuilder.builder.withFindCredentialsResult(.success(result))
|
||||
return result
|
||||
} catch {
|
||||
Fido2DebuggingReportBuilder.builder.withFindCredentialsResult(.failure(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func allCredentials() async throws -> [BitwardenSdk.CipherView] {
|
||||
do {
|
||||
let result = try await fido2CredentialStore.allCredentials()
|
||||
Fido2DebuggingReportBuilder.builder.withAllCredentialsResult(.success(result))
|
||||
return result
|
||||
} catch {
|
||||
Fido2DebuggingReportBuilder.builder.withFindCredentialsResult(.failure(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func saveCredential(cred: BitwardenSdk.Cipher) async throws {
|
||||
do {
|
||||
try await fido2CredentialStore.saveCredential(cred: cred)
|
||||
Fido2DebuggingReportBuilder.builder.withSaveCredentialCipher(.success(cred))
|
||||
} catch {
|
||||
Fido2DebuggingReportBuilder.builder.withFindCredentialsResult(.failure(error))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
@ -1,14 +1,18 @@
|
||||
import BitwardenSdk
|
||||
import XCTest
|
||||
|
||||
// swiftlint:disable file_length
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
class Fido2CredentialStoreServiceTests: BitwardenTestCase {
|
||||
class Fido2CredentialStoreServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
|
||||
// MARK: Properties
|
||||
|
||||
var cipherService: MockCipherService!
|
||||
var clientService: MockClientService!
|
||||
var errorReporter: MockErrorReporter!
|
||||
var subject: Fido2CredentialStoreService!
|
||||
var syncService: MockSyncService!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
@ -17,10 +21,14 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase {
|
||||
|
||||
cipherService = MockCipherService()
|
||||
clientService = MockClientService()
|
||||
errorReporter = MockErrorReporter()
|
||||
syncService = MockSyncService()
|
||||
|
||||
subject = Fido2CredentialStoreService(
|
||||
cipherService: cipherService,
|
||||
clientService: clientService
|
||||
clientService: clientService,
|
||||
errorReporter: errorReporter,
|
||||
syncService: syncService
|
||||
)
|
||||
}
|
||||
|
||||
@ -29,7 +37,9 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase {
|
||||
|
||||
cipherService = nil
|
||||
clientService = nil
|
||||
errorReporter = nil
|
||||
subject = nil
|
||||
syncService = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
@ -56,6 +66,7 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase {
|
||||
|
||||
let result = try await subject.allCredentials()
|
||||
|
||||
XCTAssertTrue(syncService.didFetchSync)
|
||||
XCTAssertTrue(result.count == 1)
|
||||
XCTAssertTrue(result[0].id == "5")
|
||||
}
|
||||
@ -91,6 +102,16 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
/// `.allCredentials()` throws when syncing.
|
||||
func test_allCredentials_throwsSync() async throws {
|
||||
syncService.fetchSyncResult = .failure(BitwardenTestError.example)
|
||||
|
||||
_ = try await subject.allCredentials()
|
||||
|
||||
XCTAssertFalse(errorReporter.errors.isEmpty)
|
||||
XCTAssertTrue(cipherService.fetchAllCiphersCalled)
|
||||
}
|
||||
|
||||
/// `.findCredentials(ids:ripId:)` returns the login ciphers that are active, have Fido2 credentials
|
||||
/// and match the `ripId` and the credential `ids` if any.
|
||||
func test_findCredentials() async throws {
|
||||
@ -128,6 +149,7 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase {
|
||||
|
||||
let result = try await subject.findCredentials(ids: credentialIds, ripId: expectedRpId)
|
||||
|
||||
XCTAssertTrue(syncService.didFetchSync)
|
||||
XCTAssertTrue(result.count == 1)
|
||||
XCTAssertTrue(result[0].id == expectedCipherId)
|
||||
}
|
||||
@ -307,3 +329,96 @@ class Fido2CredentialStoreServiceTests: BitwardenTestCase {
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
class DebuggingFido2CredentialStoreServiceTests: BitwardenTestCase { // swiftlint:disable:this type_name
|
||||
// MARK: Properties
|
||||
|
||||
var fido2CredentialStore: MockFido2CredentialStore!
|
||||
var subject: DebuggingFido2CredentialStoreService!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
fido2CredentialStore = MockFido2CredentialStore()
|
||||
|
||||
subject = DebuggingFido2CredentialStoreService(
|
||||
fido2CredentialStore: fido2CredentialStore
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
fido2CredentialStore = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `.allCredentials()` returns all credentials and reports it.
|
||||
func test_allCredentials() async throws {
|
||||
fido2CredentialStore.allCredentialsResult = .success([.fixture()])
|
||||
let result = try await subject.allCredentials()
|
||||
XCTAssert(result.count == 1)
|
||||
XCTAssertFalse(
|
||||
(try? Fido2DebuggingReportBuilder.builder
|
||||
.getReport()?.allCredentialsResult?.get().isEmpty) ?? true
|
||||
)
|
||||
}
|
||||
|
||||
/// `.allCredentials()` throws and reports it.
|
||||
func test_allCredentials_throws() async throws {
|
||||
fido2CredentialStore.allCredentialsResult = .failure(BitwardenTestError.example)
|
||||
await assertAsyncThrows(error: BitwardenTestError.example) {
|
||||
_ = try await subject.allCredentials()
|
||||
}
|
||||
XCTAssertNil(try? Fido2DebuggingReportBuilder.builder.getReport()?
|
||||
.allCredentialsResult?.get()
|
||||
)
|
||||
}
|
||||
|
||||
/// `.findCredentials(ids:ripId:)` returns found credentials and reports it.
|
||||
func test_findCredentials() async throws {
|
||||
fido2CredentialStore.findCredentialsResult = .success([.fixture()])
|
||||
let result = try await subject.findCredentials(ids: nil, ripId: "something")
|
||||
XCTAssert(result.count == 1)
|
||||
XCTAssertFalse(
|
||||
(try? Fido2DebuggingReportBuilder.builder
|
||||
.getReport()?.findCredentialsResult?.get().isEmpty) ?? true
|
||||
)
|
||||
}
|
||||
|
||||
/// `.findCredentials(ids:ripId:)` throws and reports it.
|
||||
func test_findCredentialsthrows() async throws {
|
||||
fido2CredentialStore.findCredentialsResult = .failure(BitwardenTestError.example)
|
||||
await assertAsyncThrows(error: BitwardenTestError.example) {
|
||||
_ = try await subject.findCredentials(ids: nil, ripId: "something")
|
||||
}
|
||||
XCTAssertNil(try? Fido2DebuggingReportBuilder.builder.getReport()?
|
||||
.findCredentialsResult?.get()
|
||||
)
|
||||
}
|
||||
|
||||
/// `.saveCredential(cred:)` saves credentials and adds it to the report.
|
||||
func test_saveCredential() async throws {
|
||||
try await subject.saveCredential(cred: .fixture(id: "1"))
|
||||
XCTAssertTrue(fido2CredentialStore.saveCredentialCalled)
|
||||
XCTAssertTrue(
|
||||
(try? Fido2DebuggingReportBuilder.builder
|
||||
.getReport()?.saveCredentialCipher?.get().id) == "1"
|
||||
)
|
||||
}
|
||||
|
||||
/// `.saveCredential(cred:)` throws and reports it.
|
||||
func test_saveCredential_throws() async throws {
|
||||
fido2CredentialStore.saveCredentialError = BitwardenTestError.example
|
||||
await assertAsyncThrows(error: BitwardenTestError.example) {
|
||||
try await subject.saveCredential(cred: .fixture(id: "1"))
|
||||
}
|
||||
XCTAssertNil(try? Fido2DebuggingReportBuilder.builder.getReport()?
|
||||
.saveCredentialCipher?.get()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ class MockCipherService: CipherService {
|
||||
var fetchCipherId: String?
|
||||
var fetchCipherResult: Result<Cipher?, Error> = .success(nil)
|
||||
|
||||
var fetchAllCiphersCalled = false
|
||||
var fetchAllCiphersResult: Result<[Cipher], Error> = .success([])
|
||||
|
||||
var deleteCipherId: String?
|
||||
@ -86,7 +87,8 @@ class MockCipherService: CipherService {
|
||||
}
|
||||
|
||||
func fetchAllCiphers() async throws -> [Cipher] {
|
||||
try fetchAllCiphersResult.get()
|
||||
fetchAllCiphersCalled = true
|
||||
return try fetchAllCiphersResult.get()
|
||||
}
|
||||
|
||||
func fetchCipher(withId id: String) async throws -> Cipher? {
|
||||
|
||||
@ -7,6 +7,7 @@ class MockFido2CredentialStore: Fido2CredentialStore {
|
||||
var findCredentialsResult: Result<[BitwardenSdk.CipherView], Error> = .success([])
|
||||
var allCredentialsResult: Result<[BitwardenSdk.CipherView], Error> = .success([])
|
||||
var saveCredentialCalled = false
|
||||
var saveCredentialError: (any Error)?
|
||||
|
||||
func findCredentials(ids: [Data]?, ripId: String) async throws -> [BitwardenSdk.CipherView] {
|
||||
try findCredentialsResult.get()
|
||||
@ -18,5 +19,8 @@ class MockFido2CredentialStore: Fido2CredentialStore {
|
||||
|
||||
func saveCredential(cred: BitwardenSdk.Cipher) async throws {
|
||||
saveCredentialCalled = true
|
||||
if let saveCredentialError {
|
||||
throw saveCredentialError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,6 +94,9 @@ class DefaultFido2UserInterfaceHelper: Fido2UserInterfaceHelper {
|
||||
func pickCredentialForAuthentication(
|
||||
availableCredentials: [BitwardenSdk.CipherView]
|
||||
) async throws -> BitwardenSdk.CipherViewWrapper {
|
||||
if availableCredentials.count == 1 {
|
||||
return CipherViewWrapper(cipher: availableCredentials[0])
|
||||
}
|
||||
// TODO: PM-8829 implement pick credential for auth
|
||||
throw Fido2Error.invalidOperationError
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import AuthenticationServices
|
||||
import BitwardenSdk
|
||||
import Combine
|
||||
import Foundation
|
||||
import UIKit
|
||||
@ -323,3 +324,88 @@ extension AppProcessor: SyncServiceDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fido2 credentials
|
||||
|
||||
public extension AppProcessor {
|
||||
/// Provides a Fido2 credential for a passkey request.
|
||||
/// - Parameter passkeyRequest: Request to get the credential.
|
||||
@available(iOSApplicationExtension 17.0, *)
|
||||
func provideFido2Credential( // swiftlint:disable:this function_body_length
|
||||
for passkeyRequest: ASPasskeyCredentialRequest
|
||||
) async throws -> ASPasskeyAssertionCredential {
|
||||
guard let credentialIdentiy = passkeyRequest.credentialIdentity as? ASPasskeyCredentialIdentity else {
|
||||
throw AppProcessorError.invalidOperation
|
||||
}
|
||||
|
||||
let isLocked = try? await services.authRepository.isLocked()
|
||||
let vaultTimeout = try? await services.vaultTimeoutService.sessionTimeoutValue(userId: nil)
|
||||
|
||||
switch (vaultTimeout, isLocked) {
|
||||
case (.never, true):
|
||||
// If the user has enabled Never Lock, but the vault is locked,
|
||||
// unlock the vault before continuing.
|
||||
try await services.authRepository.unlockVaultWithNeverlockKey()
|
||||
case (_, false):
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
let request = GetAssertionRequest(
|
||||
rpId: credentialIdentiy.relyingPartyIdentifier,
|
||||
clientDataHash: passkeyRequest.clientDataHash,
|
||||
allowList: [
|
||||
PublicKeyCredentialDescriptor(
|
||||
ty: "public-key",
|
||||
id: credentialIdentiy.credentialID,
|
||||
transports: nil
|
||||
),
|
||||
],
|
||||
options: Options(
|
||||
rk: false,
|
||||
uv: BitwardenSdk.Uv(preference: passkeyRequest.userVerificationPreference)
|
||||
),
|
||||
extensions: nil
|
||||
)
|
||||
|
||||
#if DEBUG
|
||||
Fido2DebuggingReportBuilder.builder.withGetAssertionRequest(request)
|
||||
#endif
|
||||
|
||||
do {
|
||||
let assertionResult = try await services.clientService.platform().fido2()
|
||||
.authenticator(
|
||||
userInterface: services.fido2UserInterfaceHelper,
|
||||
credentialStore: services.fido2CredentialStore
|
||||
)
|
||||
.getAssertion(request: request)
|
||||
|
||||
#if DEBUG
|
||||
Fido2DebuggingReportBuilder.builder.withGetAssertionResult(.success(assertionResult))
|
||||
#endif
|
||||
|
||||
return ASPasskeyAssertionCredential(
|
||||
userHandle: assertionResult.userHandle,
|
||||
relyingParty: credentialIdentiy.relyingPartyIdentifier,
|
||||
signature: assertionResult.signature,
|
||||
clientDataHash: passkeyRequest.clientDataHash,
|
||||
authenticatorData: assertionResult.authenticatorData,
|
||||
credentialID: assertionResult.credentialId
|
||||
)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
Fido2DebuggingReportBuilder.builder.withGetAssertionResult(.failure(error))
|
||||
#endif
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AppProcessorError
|
||||
|
||||
/// Errors that can happen inside the `AppProcessor`.
|
||||
enum AppProcessorError: Error {
|
||||
/// The operation to execute is invalid.
|
||||
case invalidOperation
|
||||
} // swiftlint:disable:this file_length
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import AuthenticationServices
|
||||
import BitwardenSdk
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
@ -200,6 +201,96 @@ class AppProcessorTests: BitwardenTestCase { // swiftlint:disable:this type_body
|
||||
XCTAssertIdentical(syncService.delegate, subject)
|
||||
}
|
||||
|
||||
/// `provideFido2Credential(for:)` succeeds
|
||||
@available(iOS 17.0, *)
|
||||
func test_provideFido2Credential_succeeds() async throws {
|
||||
authRepository.isLockedResult = .success(false)
|
||||
let passkeyIdentity = ASPasskeyCredentialIdentity.fixture()
|
||||
let passkeyRequest = ASPasskeyCredentialRequest.fixture(credentialIdentity: passkeyIdentity)
|
||||
let expectedAssertionResult = GetAssertionResult.fixture()
|
||||
|
||||
clientService.mockPlatform.fido2Mock
|
||||
.clientFido2AuthenticatorMock
|
||||
.getAssertionMocker
|
||||
.withVerification { request in
|
||||
request.rpId == passkeyIdentity.relyingPartyIdentifier
|
||||
&& request.clientDataHash == passkeyRequest.clientDataHash
|
||||
&& request.allowList?[0].id == passkeyIdentity.credentialID
|
||||
&& request.allowList?[0].ty == "public-key"
|
||||
&& request.allowList?[0].transports == nil
|
||||
&& !request.options.rk
|
||||
&& request.options.uv == .discouraged
|
||||
&& request.extensions == nil
|
||||
}
|
||||
.withResult(expectedAssertionResult)
|
||||
|
||||
let result = try await subject.provideFido2Credential(for: passkeyRequest)
|
||||
|
||||
XCTAssertFalse(authRepository.unlockVaultWithNeverlockKeyCalled)
|
||||
|
||||
XCTAssertEqual(result.userHandle, expectedAssertionResult.userHandle)
|
||||
XCTAssertEqual(result.relyingParty, passkeyIdentity.relyingPartyIdentifier)
|
||||
XCTAssertEqual(result.signature, expectedAssertionResult.signature)
|
||||
XCTAssertEqual(result.clientDataHash, passkeyRequest.clientDataHash)
|
||||
XCTAssertEqual(result.authenticatorData, expectedAssertionResult.authenticatorData)
|
||||
XCTAssertEqual(result.credentialID, expectedAssertionResult.credentialId)
|
||||
}
|
||||
|
||||
/// `provideFido2Credential(for:)` succeeds when unlocking with never key.
|
||||
@available(iOS 17.0, *)
|
||||
func test_provideFido2Credential_succeedsWithUnlockingNeverKey() async throws {
|
||||
authRepository.isLockedResult = .success(true)
|
||||
vaultTimeoutService.vaultTimeout["1"] = .never
|
||||
|
||||
let passkeyIdentity = ASPasskeyCredentialIdentity.fixture()
|
||||
let passkeyRequest = ASPasskeyCredentialRequest.fixture(credentialIdentity: passkeyIdentity)
|
||||
let expectedAssertionResult = GetAssertionResult.fixture()
|
||||
|
||||
clientService.mockPlatform.fido2Mock
|
||||
.clientFido2AuthenticatorMock
|
||||
.getAssertionMocker
|
||||
.withVerification { request in
|
||||
request.rpId == passkeyIdentity.relyingPartyIdentifier
|
||||
&& request.clientDataHash == passkeyRequest.clientDataHash
|
||||
&& request.allowList?[0].id == passkeyIdentity.credentialID
|
||||
&& request.allowList?[0].ty == "public-key"
|
||||
&& request.allowList?[0].transports == nil
|
||||
&& !request.options.rk
|
||||
&& request.options.uv == .discouraged
|
||||
&& request.extensions == nil
|
||||
}
|
||||
.withResult(expectedAssertionResult)
|
||||
|
||||
let result = try await subject.provideFido2Credential(for: passkeyRequest)
|
||||
|
||||
XCTAssertTrue(authRepository.unlockVaultWithNeverlockKeyCalled)
|
||||
|
||||
XCTAssertEqual(result.userHandle, expectedAssertionResult.userHandle)
|
||||
XCTAssertEqual(result.relyingParty, passkeyIdentity.relyingPartyIdentifier)
|
||||
XCTAssertEqual(result.signature, expectedAssertionResult.signature)
|
||||
XCTAssertEqual(result.clientDataHash, passkeyRequest.clientDataHash)
|
||||
XCTAssertEqual(result.authenticatorData, expectedAssertionResult.authenticatorData)
|
||||
XCTAssertEqual(result.credentialID, expectedAssertionResult.credentialId)
|
||||
}
|
||||
|
||||
/// `provideFido2Credential(for:)` throws when getting assertion.
|
||||
@available(iOS 17.0, *)
|
||||
func test_provideFido2Credential_throws() async throws {
|
||||
authRepository.isLockedResult = .success(false)
|
||||
|
||||
let passkeyIdentity = ASPasskeyCredentialIdentity.fixture()
|
||||
let passkeyRequest = ASPasskeyCredentialRequest.fixture(credentialIdentity: passkeyIdentity)
|
||||
|
||||
clientService.mockPlatform.fido2Mock
|
||||
.clientFido2AuthenticatorMock
|
||||
.getAssertionMocker
|
||||
.throwing(BitwardenTestError.example)
|
||||
|
||||
await assertAsyncThrows(error: BitwardenTestError.example) {
|
||||
_ = try await subject.provideFido2Credential(for: passkeyRequest)
|
||||
}
|
||||
}
|
||||
|
||||
/// `messageReceived(_:notificationDismissed:notificationTapped)` passes the data to the notification service.
|
||||
func test_messageReceived() async {
|
||||
let message: [AnyHashable: Any] = ["knock knock": "who's there?"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user