Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,16 @@ actor DefaultFido2CredentialStore: Fido2CredentialStore {
try await vaultClientService.ciphers().decryptList(ciphers: ciphers)
}

/// Deletes the credential backed by the cipher with the given ID, if one exists, and persists
/// the updated list via the injected `CipherStorageService`.
///
/// - Parameter cipherId: The ID of the cipher to delete.
///
func deleteCredential(cipherId: String) {
ciphers.removeAll { $0.id == cipherId }
cipherStorageService.save(ciphers: ciphers)
}

func findCredentials(ids: [Data]?, ripId: String, userHandle: Data?) async throws -> [CipherView] {
var matches: [CipherView] = []
for cipher in ciphers {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,33 @@ class DefaultFido2CredentialStoreTests: BitwardenTestCase {
XCTAssertTrue(result.isEmpty)
}

/// `deleteCredential(cipherId:)` removes the matching cipher and persists the updated list via
/// the injected `CipherStorageService`.
func test_deleteCredential_removesMatchingCipher() async throws {
let first = Cipher(cipherView: .fixture(id: "cipher-1", name: "First"))
let second = Cipher(cipherView: .fixture(id: "cipher-2", name: "Second"))
try await subject.saveCredential(cred: EncryptionContext(encryptedFor: "1", cipher: first))
try await subject.saveCredential(cred: EncryptionContext(encryptedFor: "1", cipher: second))

await subject.deleteCredential(cipherId: "cipher-1")

let result = try await subject.allCredentials()
XCTAssertEqual(result.map(\.name), ["Second"])
XCTAssertEqual(cipherStorageService.saveReceivedCiphers, [second])
}

/// `deleteCredential(cipherId:)` leaves the list unchanged when no cipher matches the given
/// ID.
func test_deleteCredential_noMatch_leavesListUnchanged() async throws {
let cipher = Cipher(cipherView: .fixture(id: "cipher-1", name: "Only"))
try await subject.saveCredential(cred: EncryptionContext(encryptedFor: "1", cipher: cipher))

await subject.deleteCredential(cipherId: "nonexistent")

let result = try await subject.allCredentials()
XCTAssertEqual(result.map(\.name), ["Only"])
}

/// `findCredentials(ids:ripId:userHandle:)` excludes ciphers whose Fido2 credentials don't
/// match the requested relying party.
func test_findCredentials_noMatch_returnsEmpty() async throws {
Expand Down
12 changes: 12 additions & 0 deletions TestHarnessShared/Core/Autofill/Passkey/PasskeyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ public protocol PasskeyService: AnyObject {
///
func assertPasskey(credentialId: Data?, rpId: String) async throws -> GetAssertionResult

/// Deletes a previously registered credential.
///
/// - Parameter cipherId: The ID of the cipher β€” from `Fido2CredentialAutofillView.cipherId` β€”
/// backing the credential to delete.
///
func deleteCredential(cipherId: String) async throws

/// Lists the credentials registered so far, across app launches.
///
/// - Returns: The registered credentials' autofill-ready metadata.
Expand Down Expand Up @@ -129,6 +136,11 @@ actor DefaultPasskeyService: PasskeyService {
.getAssertion(request: request)
}

func deleteCredential(cipherId: String) async throws {
let (_, credentialStore) = try await session()
await credentialStore.deleteCredential(cipherId: cipherId)
}

func registeredCredentials() async throws -> [Fido2CredentialAutofillView] {
let (client, credentialStore) = try await session()
return try await client.platform().fido2()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,22 @@ class PasskeyServiceTests: BitwardenTestCase {
XCTAssertEqual(assertion.selectedCredential.credential.userName, "user2@example.com")
}

/// `deleteCredential(cipherId:)` removes a registered credential so it's no longer listed.
func test_deleteCredential_removesRegisteredCredential() async throws {
_ = try await subject.registerPasskey(
rpId: "bitwarden.com",
userName: "user@example.com",
displayName: "User",
)
let registered = try await subject.registeredCredentials()
let cipherId = try XCTUnwrap(registered.first?.cipherId)

try await subject.deleteCredential(cipherId: cipherId)

let credentials = try await subject.registeredCredentials()
XCTAssertTrue(credentials.isEmpty)
}

/// `registerPasskey(rpId:userName:displayName:)` returns a non-empty credential ID and
/// attestation object.
func test_registerPasskey_returnsCredential() async throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import BitwardenSdk
/// Effects that can be processed by a `UsePasskeyProcessor`.
///
enum UsePasskeyEffect: Equatable {
/// The user requested deletion of a registered credential.
case deleteCredential(Fido2CredentialAutofillView)

/// The view appeared, and should load the list of registered credentials.
case loadRegisteredCredentials

Expand Down
12 changes: 12 additions & 0 deletions TestHarnessShared/UI/Autofill/Passkey/UsePasskeyProcessor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ final class UsePasskeyProcessor: StateProcessor<

override func perform(_ effect: UsePasskeyEffect) async {
switch effect {
case let .deleteCredential(credential):
await deleteCredential(credential)
case .loadRegisteredCredentials:
await loadRegisteredCredentials()
case let .selectCredential(credential):
Expand All @@ -65,6 +67,16 @@ final class UsePasskeyProcessor: StateProcessor<
}
}

/// Deletes the given credential, then reloads the registered credentials list.
private func deleteCredential(_ credential: Fido2CredentialAutofillView) async {
do {
try await passkeyService.deleteCredential(cipherId: credential.cipherId)
await loadRegisteredCredentials()
} 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 }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,32 @@ class UsePasskeyProcessorTests: BitwardenTestCase {
XCTAssertTrue(subject.state.isLoadingCredentials)
}

/// `perform(.deleteCredential)` deletes the credential's cipher and reloads the registered
/// credentials list.
@MainActor
func test_perform_deleteCredential_success() async {
let credential = Fido2CredentialAutofillView.fixture(cipherId: "cipher-1")
subject.state.registeredCredentials = [credential]
passkeyService.registeredCredentialsReturnValue = []

await subject.perform(.deleteCredential(credential))

XCTAssertEqual(passkeyService.deleteCredentialReceivedCipherId, "cipher-1")
XCTAssertTrue(passkeyService.registeredCredentialsCalled)
XCTAssertEqual(subject.state.registeredCredentials, [])
Comment thread
morganzellers-bw marked this conversation as resolved.
}

/// `perform(.deleteCredential)` sets status to `.failure` when deletion throws.
@MainActor
func test_perform_deleteCredential_failure() async {
let credential = Fido2CredentialAutofillView.fixture(cipherId: "cipher-1")
passkeyService.deleteCredentialThrowableError = BitwardenTestError.example

await subject.perform(.deleteCredential(credential))

XCTAssertEqual(subject.state.status, .failure(BitwardenTestError.example.localizedDescription))
}

/// `perform(.loadRegisteredCredentials)` populates the registered credentials list and clears
/// the loading flag.
@MainActor
Expand Down
30 changes: 28 additions & 2 deletions TestHarnessShared/UI/Autofill/Passkey/UsePasskeyView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,48 @@ struct UsePasskeyView: View {
Button {
Task { await store.perform(.selectCredential(credential)) }
} label: {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: 2) {
Text(credential.rpId)
if let userName = credential.userNameForUi {
Text(userName)
.font(.footnote)
.foregroundStyle(.secondary)
}
Text(credential.credentialId.prefix(4).asHexString())
Text(
Localizations.xColonY(
Localizations.credentialId,
credential.credentialId.asHexString(),
),
)
.font(.caption2)
.foregroundStyle(.secondary)
Text(Localizations.xColonY(Localizations.cipherId, credential.cipherId))
.font(.caption2)
.foregroundStyle(.secondary)
Text(Localizations.xColonY(Localizations.userHandle, credential.userHandle.asHexString()))
.font(.caption2)
.foregroundStyle(.secondary)
if credential.hasCounter {
Text(Localizations.usesSignatureCounter)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.accessibilityIdentifier(
"RegisteredCredentialRow_\(credential.rpId)_\(credential.credentialId.asHexString())",
)
.disabled(store.state.status == .inProgress)
.swipeActions {
Button(role: .destructive) {
Task { await store.perform(.deleteCredential(credential)) }
} label: {
Label(Localizations.delete, systemImage: "trash")
}
.accessibilityIdentifier(
"DeleteCredentialButton_\(credential.rpId)_\(credential.credentialId.asHexString())",
)
}
}
}
} header: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
"CardDetails" = "Card Details";
"CardholderName" = "Cardholder Name";
"CardNumber" = "Card Number";
"CipherId" = "Cipher ID";
"ConfirmPassword" = "Confirm Password";
"CreateAccount" = "Create Account";
"CreateAccountForm" = "Create Account Form";
"CreateAccountFormDescriptionLong" = "Fill in the form and tap Create Account. On supported devices, iOS will prompt to save the credential via the active password provider.";
"CreatePasskey" = "Create Passkey";
"CredentialId" = "Credential ID";
"Credentials" = "Credentials";
"Delete" = "Delete";
"DisplayName" = "Display Name";
"EnterCardDetailsAbove" = "Enter card details above";
"EnterCredentialsAbove" = "Enter credentials above";
Expand All @@ -31,7 +33,7 @@
"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.";
"RegisteredCredentialsFooterDescriptionLong" = "Tap a credential to sign in as that specific passkey through the Bitwarden SDK. Swipe to delete a passkey.";
"RegistrationResult" = "Registration Result";
"RelyingPartyId" = "Relying Party ID";
"Result" = "Result";
Expand All @@ -48,9 +50,11 @@
"TOTPAutofillForm" = "TOTP Autofill Form";
"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";
"UserHandle" = "User Handle";
"Username" = "Username";
"UsePasskey" = "Use Passkey";
"UseThisLoginFormToTestAutofillFunctionality" = "Use this login form to test autofill functionality.";
"UsesSignatureCounter" = "Uses signature counter";
"XColonY" = "%@: %@";
"DateFieldPicker" = "Date Field Picker";
"DateFieldPickerDescription" = "Tap the field to expand the inline calendar and select a date.";
Expand Down
Loading