diff --git a/TestHarnessShared/Core/Autofill/Passkey/Fixtures/GetAssertionResult+Fixtures.swift b/TestHarnessShared/Core/Autofill/Passkey/Fixtures/GetAssertionResult+Fixtures.swift new file mode 100644 index 0000000000..a5e70ae543 --- /dev/null +++ b/TestHarnessShared/Core/Autofill/Passkey/Fixtures/GetAssertionResult+Fixtures.swift @@ -0,0 +1,18 @@ +import BitwardenSdk +import Foundation + +extension GetAssertionResult { + static func fixture( + credentialId: Data = Data([0x03]), + selectedCredential: SelectedCredential = SelectedCredential(cipher: .fixture(), credential: .fixture()), + ) -> GetAssertionResult { + GetAssertionResult( + credentialId: credentialId, + authenticatorData: Data([0x02]), + signature: Data([0x04]), + userHandle: Data([0x05]), + selectedCredential: selectedCredential, + extensions: GetAssertionExtensionsOutput(prf: nil), + ) + } +} diff --git a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyAction.swift b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyAction.swift new file mode 100644 index 0000000000..a233da45a3 --- /dev/null +++ b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyAction.swift @@ -0,0 +1,6 @@ +// MARK: - UsePasskeyAction + +/// Actions that can be processed by a `UsePasskeyProcessor`. This screen has no synchronous +/// user interactions, so this has no cases. +/// +enum UsePasskeyAction: Equatable {} diff --git a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyEffect.swift b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyEffect.swift new file mode 100644 index 0000000000..77874ce83d --- /dev/null +++ b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyEffect.swift @@ -0,0 +1,13 @@ +import BitwardenSdk + +// MARK: - UsePasskeyEffect + +/// Effects that can be processed by a `UsePasskeyProcessor`. +/// +enum UsePasskeyEffect: Equatable { + /// The view appeared, and should load the list of registered credentials. + case loadRegisteredCredentials + + /// The user selected a credential from the registered credentials list. + case selectCredential(Fido2CredentialAutofillView) +} diff --git a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessor.swift b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessor.swift new file mode 100644 index 0000000000..076b45f93d --- /dev/null +++ b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessor.swift @@ -0,0 +1,77 @@ +import BitwardenKit +import BitwardenSdk +import Foundation + +// MARK: - UsePasskeyProcessor + +/// The processor for the use passkey test screen. +/// +final class UsePasskeyProcessor: StateProcessor< + UsePasskeyState, + UsePasskeyAction, + UsePasskeyEffect, +> { + // MARK: Private Properties + + /// The coordinator that handles navigation. + private let coordinator: AnyCoordinator + + /// The service used to perform passkey assertion through the Bitwarden SDK. + private let passkeyService: PasskeyService + + // MARK: Initialization + + /// Initializes a `UsePasskeyProcessor`. + /// + /// - Parameters: + /// - coordinator: The coordinator that handles navigation. + /// - passkeyService: The service used to perform passkey assertion through the + /// Bitwarden SDK. + /// + init( + coordinator: AnyCoordinator, + passkeyService: PasskeyService, + ) { + self.coordinator = coordinator + self.passkeyService = passkeyService + super.init(state: UsePasskeyState()) + } + + // MARK: Methods + + override func perform(_ effect: UsePasskeyEffect) async { + switch effect { + case .loadRegisteredCredentials: + await loadRegisteredCredentials() + case let .selectCredential(credential): + await assertPasskey(credentialId: credential.credentialId, rpId: credential.rpId) + } + } + + // MARK: Private + + /// Orchestrates state transitions and calls `passkeyService.assertPasskey`. + private func assertPasskey(credentialId: Data?, rpId: String) async { + state.status = .inProgress + do { + let result = try await passkeyService.assertPasskey(credentialId: credentialId, rpId: rpId) + state.status = .success( + credentialId: result.credentialId.base64EncodedString(), + rpId: rpId, + userName: result.selectedCredential.credential.userName, + ) + } catch { + state.status = .failure(error.localizedDescription) + } + } + + /// Loads the list of credentials registered so far, across app launches. + private func loadRegisteredCredentials() async { + defer { state.isLoadingCredentials = false } + do { + state.registeredCredentials = try await passkeyService.registeredCredentials() + } catch { + state.status = .failure(error.localizedDescription) + } + } +} diff --git a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessorTests.swift b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessorTests.swift new file mode 100644 index 0000000000..2a0544f9a7 --- /dev/null +++ b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessorTests.swift @@ -0,0 +1,117 @@ +import BitwardenKit +import BitwardenKitMocks +import BitwardenSdk +import TestHelpers +import XCTest + +@testable import TestHarnessShared + +// MARK: - UsePasskeyProcessorTests + +/// Tests for `UsePasskeyProcessor`. +/// +class UsePasskeyProcessorTests: BitwardenTestCase { + // MARK: Properties + + var coordinator: MockCoordinator! + var passkeyService: MockPasskeyService! + var subject: UsePasskeyProcessor! + + // MARK: Setup & Teardown + + @MainActor + override func setUp() { + super.setUp() + coordinator = MockCoordinator() + passkeyService = MockPasskeyService() + subject = UsePasskeyProcessor( + coordinator: coordinator.asAnyCoordinator(), + passkeyService: passkeyService, + ) + } + + override func tearDown() { + super.tearDown() + coordinator = nil + passkeyService = nil + subject = nil + } + + // MARK: Effect Tests + + /// The initial state marks credentials as still loading. + @MainActor + func test_state_isLoadingCredentials_initiallyTrue() { + XCTAssertTrue(subject.state.isLoadingCredentials) + } + + /// `perform(.loadRegisteredCredentials)` populates the registered credentials list and clears + /// the loading flag. + @MainActor + func test_perform_loadRegisteredCredentials_success() async { + passkeyService.registeredCredentialsReturnValue = [.fixture(rpId: "bitwarden.com")] + + await subject.perform(.loadRegisteredCredentials) + + XCTAssertEqual(subject.state.registeredCredentials, [.fixture(rpId: "bitwarden.com")]) + XCTAssertFalse(subject.state.isLoadingCredentials) + } + + /// `perform(.loadRegisteredCredentials)` sets status to `.failure` when loading throws, and + /// clears the loading flag. + @MainActor + func test_perform_loadRegisteredCredentials_failure() async { + passkeyService.registeredCredentialsThrowableError = BitwardenTestError.example + + await subject.perform(.loadRegisteredCredentials) + + XCTAssertEqual(subject.state.status, .failure(BitwardenTestError.example.localizedDescription)) + XCTAssertFalse(subject.state.isLoadingCredentials) + } + + /// `perform(.selectCredential)` asserts using the selected credential's specific credential + /// ID and RP ID. + @MainActor + func test_perform_selectCredential_passesCredentialIdAndRpId() async { + let credential = Fido2CredentialAutofillView.fixture(credentialId: Data([0x09]), rpId: "example.com") + passkeyService.assertPasskeyReturnValue = .fixture() + + await subject.perform(.selectCredential(credential)) + + XCTAssertEqual(passkeyService.assertPasskeyReceivedArguments?.credentialId, Data([0x09])) + XCTAssertEqual(passkeyService.assertPasskeyReceivedArguments?.rpId, "example.com") + } + + /// `perform(.selectCredential)` sets status to `.success` with the matched credential's RP ID + /// and username when assertion succeeds. + @MainActor + func test_perform_selectCredential_success() async { + let credential = Fido2CredentialAutofillView.fixture(credentialId: Data([0x09]), rpId: "example.com") + passkeyService.assertPasskeyReturnValue = .fixture( + credentialId: Data([0x01, 0x02, 0x03]), + selectedCredential: SelectedCredential(cipher: .fixture(), credential: .fixture(userName: "alice")), + ) + + await subject.perform(.selectCredential(credential)) + + XCTAssertEqual( + subject.state.status, + .success( + credentialId: Data([0x01, 0x02, 0x03]).base64EncodedString(), + rpId: "example.com", + userName: "alice", + ), + ) + } + + /// `perform(.selectCredential)` sets status to `.failure` when assertion throws. + @MainActor + func test_perform_selectCredential_failure() async { + let credential = Fido2CredentialAutofillView.fixture(credentialId: Data([0x09]), rpId: "example.com") + passkeyService.assertPasskeyThrowableError = BitwardenTestError.example + + await subject.perform(.selectCredential(credential)) + + XCTAssertEqual(subject.state.status, .failure(BitwardenTestError.example.localizedDescription)) + } +} diff --git a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyState.swift b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyState.swift new file mode 100644 index 0000000000..b64c7b56e2 --- /dev/null +++ b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyState.swift @@ -0,0 +1,39 @@ +import BitwardenSdk +import Foundation + +// MARK: - UsePasskeyState + +/// The state for the use passkey test screen. +/// +struct UsePasskeyState: Equatable { + // MARK: Types + + /// The current status of a passkey assertion attempt. + enum AssertionStatus: Equatable { + /// Assertion failed with the associated error description. + case failure(String) + + /// No assertion attempt has been made. + case idle + + /// An assertion request is in progress. + case inProgress + + /// Assertion completed successfully for the associated credential. + case success(credentialId: String, rpId: String, userName: String?) + } + + // MARK: Properties + + /// Whether the registered credentials are still being loaded. + var isLoadingCredentials = true + + /// The credentials registered so far, across app launches. + var registeredCredentials: [Fido2CredentialAutofillView] = [] + + /// The current assertion status. + var status: AssertionStatus = .idle + + /// The title of the screen. + var title: String = Localizations.usePasskey +} diff --git a/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyView.swift b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyView.swift new file mode 100644 index 0000000000..b973719ee5 --- /dev/null +++ b/TestHarnessShared/UI/Autofill/Passkey/UsePasskeyView.swift @@ -0,0 +1,177 @@ +import BitwardenKit +import BitwardenSdk +import SwiftUI + +// MARK: - UsePasskeyView + +/// A view that allows asserting a passkey directly through the Bitwarden SDK, the same way the +/// main Bitwarden app and its AutoFill extension do. +/// +struct UsePasskeyView: View { + // MARK: Properties + + /// The store used to render the view. + @ObservedObject var store: Store + + // MARK: View + + var body: some View { + content + .navigationTitle(store.state.title) + .navigationBarTitleDisplayMode(.large) + .task { + await store.perform(.loadRegisteredCredentials) + } + } + + // MARK: Private Views + + private var content: some View { + Form { + registeredCredentialsSection + statusSection + } + } + + /// The section listing the credentials registered so far, across app launches, or an + /// empty-state message when none have been registered yet. + private var registeredCredentialsSection: some View { + Section { + if store.state.isLoadingCredentials { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + } else if store.state.registeredCredentials.isEmpty { + Text(Localizations.noRegisteredCredentials) + .foregroundStyle(.secondary) + } else { + ForEach(store.state.registeredCredentials, id: \.credentialId) { credential in + Button { + Task { await store.perform(.selectCredential(credential)) } + } label: { + VStack(alignment: .leading) { + Text(credential.rpId) + if let userName = credential.userNameForUi { + Text(userName) + .font(.footnote) + .foregroundStyle(.secondary) + } + Text(credential.credentialId.prefix(4).asHexString()) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .accessibilityIdentifier( + "RegisteredCredentialRow_\(credential.rpId)_\(credential.credentialId.asHexString())", + ) + .disabled(store.state.status == .inProgress) + } + } + } header: { + Text(Localizations.registeredCredentials) + } footer: { + Text(Localizations.registeredCredentialsFooterDescriptionLong) + } + } + + /// The section displaying the result of the most recent assertion attempt, if any. + @ViewBuilder private var statusSection: some View { + switch store.state.status { + case .idle, .inProgress: + EmptyView() + case let .success(credentialId, rpId, userName): + Section { + Label(Localizations.passkeyAssertedSuccessfully, systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .accessibilityIdentifier("AssertionSuccessLabel") + Text(Localizations.xColonY(Localizations.relyingPartyId, rpId)) + .font(.footnote) + if let userName { + Text(Localizations.xColonY(Localizations.username, userName)) + .font(.footnote) + } + Text(Localizations.xColonY(Localizations.credentialId, credentialId)) + .font(.footnote) + } header: { + Text(Localizations.assertionResult) + } + case let .failure(message): + Section { + Label(message, systemImage: "xmark.circle.fill") + .foregroundStyle(.red) + .accessibilityIdentifier("AssertionFailureLabel") + } header: { + Text(Localizations.assertionResult) + } + } + } +} + +// MARK: - Previews + +#if DEBUG +#Preview("Loading") { + NavigationView { + UsePasskeyView(store: Store(processor: StateProcessor(state: UsePasskeyState()))) + } +} + +#Preview("No Credentials") { + NavigationView { + UsePasskeyView( + store: Store(processor: StateProcessor(state: { + var state = UsePasskeyState() + state.isLoadingCredentials = false + return state + }())), + ) + } +} + +#Preview("Success") { + NavigationView { + UsePasskeyView( + store: Store(processor: StateProcessor(state: { + var state = UsePasskeyState() + state.isLoadingCredentials = false + state.status = .success(credentialId: "AQIDBA==", rpId: "bitwarden.com", userName: "user") + return state + }())), + ) + } +} + +#Preview("Failure") { + NavigationView { + UsePasskeyView( + store: Store(processor: StateProcessor(state: { + var state = UsePasskeyState() + state.isLoadingCredentials = false + state.status = .failure("No stored credential matches this relying party ID.") + return state + }())), + ) + } +} + +#Preview("Registered Credentials") { + NavigationView { + UsePasskeyView( + store: Store(processor: StateProcessor(state: { + var state = UsePasskeyState() + state.isLoadingCredentials = false + state.registeredCredentials = [ + Fido2CredentialAutofillView( + credentialId: Data([0x01]), + cipherId: "cipher-id", + rpId: "bitwarden.com", + userNameForUi: "user@example.com", + userHandle: Data([0x02]), + hasCounter: false, + ), + ] + return state + }())), + ) + } +} +#endif diff --git a/TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings b/TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings index 5091643ff7..64cd1280b4 100644 --- a/TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings +++ b/TestHarnessShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings @@ -1,6 +1,7 @@ "AccountCreatedSuccessfully" = "Account created successfully"; "AccountDetails" = "Account Details"; "AmbiguousCredentialReceived" = "Multiple stored credentials match this relying party ID; picking between them is not yet supported."; +"AssertionResult" = "Assertion Result"; "CardAutofillForm" = "Card Autofill Form"; "CardAutofillFormDescriptionLong" = "Long press a field, then select Autofill and Password to autofill with Bitwarden. Requires Bitwarden PM as AutoFill provider and a saved Card cipher."; "CardDetails" = "Card Details"; @@ -22,11 +23,15 @@ "FileShare" = "File Share"; "FormValues" = "Form Values"; "NoMatchingCredentialReceived" = "No stored credential matches this relying party ID."; +"NoRegisteredCredentials" = "No credentials registered yet. Use Register Passkey to create one."; +"PasskeyAssertedSuccessfully" = "Passkey sign-in succeeded via the Bitwarden SDK."; "PasskeyRegisteredSuccessfully" = "Passkey registered successfully via the Bitwarden SDK."; "Password" = "Password"; "PasswordsDoNotMatch" = "Passwords do not match"; "RegisterPasskey" = "Register Passkey"; "RegisterPasskeyFormDescriptionLong" = "Fill in the fields above, then tap Register Passkey. The credential is created directly through the Bitwarden SDK, with no OS passkey sheet or separate Bitwarden app involved."; +"RegisteredCredentials" = "Registered Credentials"; +"RegisteredCredentialsFooterDescriptionLong" = "Tap a credential to sign in as that specific passkey through the Bitwarden SDK."; "RegistrationResult" = "Registration Result"; "RelyingPartyId" = "Relying Party ID"; "Result" = "Result"; @@ -44,6 +49,7 @@ "TapTheTOTPCodeFieldAndSelectDescriptionLong" = "Tap the TOTP Code field and select a code from Bitwarden. Requires Bitwarden PM as AutoFill provider and a saved Login with a TOTP seed."; "TOTPCode" = "TOTP Code"; "Username" = "Username"; +"UsePasskey" = "Use Passkey"; "UseThisLoginFormToTestAutofillFunctionality" = "Use this login form to test autofill functionality."; "XColonY" = "%@: %@"; "DateFieldPicker" = "Date Field Picker"; diff --git a/TestHarnessShared/UI/Platform/Root/RootCoordinator.swift b/TestHarnessShared/UI/Platform/Root/RootCoordinator.swift index f64f6db646..cbc1428874 100644 --- a/TestHarnessShared/UI/Platform/Root/RootCoordinator.swift +++ b/TestHarnessShared/UI/Platform/Root/RootCoordinator.swift @@ -44,14 +44,16 @@ class RootCoordinator: Coordinator, HasStackNavigator { showDateFieldPickerShowcase() case .fileShare: showFileShare() - case .scenarioPicker: - showScenarioPicker() case .registerPasskey: showRegisterPasskey() + case .scenarioPicker: + showScenarioPicker() case .simpleLoginForm: showSimpleLoginForm() case .totpAutofillForm: showTOTPAutofillForm() + case .usePasskey: + showUsePasskey() } } @@ -99,14 +101,6 @@ class RootCoordinator: Coordinator, HasStackNavigator { stackNavigator?.push(viewController) } - /// Shows the scenario picker screen. - /// - private func showScenarioPicker() { - let processor = ScenarioPickerProcessor(coordinator: asAnyCoordinator()) - let view = ScenarioPickerView(store: Store(processor: processor)) - stackNavigator?.replace(view) - } - /// Shows the register passkey test screen. /// private func showRegisterPasskey() { @@ -119,6 +113,14 @@ class RootCoordinator: Coordinator, HasStackNavigator { stackNavigator?.push(viewController) } + /// Shows the scenario picker screen. + /// + private func showScenarioPicker() { + let processor = ScenarioPickerProcessor(coordinator: asAnyCoordinator()) + let view = ScenarioPickerView(store: Store(processor: processor)) + stackNavigator?.replace(view) + } + /// Shows the simple login form test screen. /// private func showSimpleLoginForm() { @@ -136,6 +138,18 @@ class RootCoordinator: Coordinator, HasStackNavigator { let viewController = UIHostingController(rootView: view) stackNavigator?.push(viewController) } + + /// Shows the use passkey test screen. + /// + private func showUsePasskey() { + let processor = UsePasskeyProcessor( + coordinator: asAnyCoordinator(), + passkeyService: services.passkeyService, + ) + let view = UsePasskeyView(store: Store(processor: processor)) + let viewController = UIHostingController(rootView: view) + stackNavigator?.push(viewController) + } } // MARK: - HasErrorAlertServices diff --git a/TestHarnessShared/UI/Platform/Root/RootRoute.swift b/TestHarnessShared/UI/Platform/Root/RootRoute.swift index a7f2133c97..510f3f2b63 100644 --- a/TestHarnessShared/UI/Platform/Root/RootRoute.swift +++ b/TestHarnessShared/UI/Platform/Root/RootRoute.swift @@ -15,15 +15,18 @@ public enum RootRoute { /// A route to the file share test screen. case fileShare - /// A route to the scenario picker home screen. - case scenarioPicker - /// A route to the register passkey test screen. case registerPasskey + /// A route to the scenario picker home screen. + case scenarioPicker + /// A route to the simple login form test screen. case simpleLoginForm /// A route to the TOTP autofill form test screen. case totpAutofillForm + + /// A route to the use passkey test screen. + case usePasskey } diff --git a/TestHarnessShared/UI/Platform/Root/ScenarioPicker/ScenarioPickerState.swift b/TestHarnessShared/UI/Platform/Root/ScenarioPicker/ScenarioPickerState.swift index 4f7d003158..f4588efe32 100644 --- a/TestHarnessShared/UI/Platform/Root/ScenarioPicker/ScenarioPickerState.swift +++ b/TestHarnessShared/UI/Platform/Root/ScenarioPicker/ScenarioPickerState.swift @@ -35,6 +35,7 @@ struct ScenarioPickerState: Equatable { ScenarioItem(id: "totpAutofillForm", title: Localizations.totpAutofillForm, route: .totpAutofillForm), ScenarioItem(id: "dateFieldPicker", title: Localizations.dateFieldPicker, route: .dateFieldPickerShowcase), ScenarioItem(id: "registerPasskey", title: Localizations.registerPasskey, route: .registerPasskey), + ScenarioItem(id: "usePasskey", title: Localizations.usePasskey, route: .usePasskey), ] if #available(iOS 17, *) { items.append(