Skip to content
Open
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 @@ -4,7 +4,7 @@ import Foundation
// MARK: - CipherStorageService

// sourcery: AutoMockable
/// A service that locally persists the SDK-encrypted `Cipher`s created by the SDK-backed passkey
/// A service that locally persists the SDK-encrypted `Cipher`s created by the passkey
/// scenarios, so they survive app relaunches.
///
protocol CipherStorageService: AnyObject {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import BitwardenSdk

// MARK: - ClientManagedTokensProvider

/// A `ClientManagedTokens` implementation for the SDK-backed passkey scenarios, which never
/// A `ClientManagedTokens` implementation for the passkey scenarios, which never
/// make network requests and so never have an access token to provide.
///
final class ClientManagedTokensProvider: ClientManagedTokens {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import Foundation

// MARK: - DefaultFido2CredentialStore

/// A `Fido2CredentialStore` for the SDK-backed passkey scenarios, backed by an injected
/// A `Fido2CredentialStore` for the passkey scenarios, backed by an injected
/// `CipherStorageService` so credentials survive app relaunches as long as the same synthetic
/// identity β€” and therefore the same crypto keys β€” is reconstructed alongside it.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation

// MARK: - DefaultFido2UserInterface

/// A minimal `Fido2UserInterface` implementation for the SDK-backed passkey scenarios. There's
/// A minimal `Fido2UserInterface` implementation for the passkey scenarios. There's
/// no vault, biometrics, or master password reprompt to mediate here, so this always approves
/// user checks and synthesizes a single throwaway login item for new credentials.
///
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import BitwardenSdk
import Foundation

extension MakeCredentialResult {
static func fixture(
attestationObject: Data = Data([0x01]),
authenticatorData: Data = Data([0x02]),
credentialId: Data = Data([0x03]),
) -> MakeCredentialResult {
MakeCredentialResult(
authenticatorData: authenticatorData,
attestationObject: attestationObject,
credentialId: credentialId,
extensions: MakeCredentialExtensionsOutput(prf: nil),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation

// MARK: - PasskeyError

/// Errors produced by the SDK-backed passkey scenarios.
/// Errors produced by the passkey scenarios.
///
enum PasskeyError: Equatable, Error, LocalizedError {
/// More than one stored credential matched the requested relying party; picking between
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation

// MARK: - PasskeyKeychainItem

/// The keychain items used by the SDK-backed passkey scenarios.
/// The keychain items used by the passkey scenarios.
///
enum PasskeyKeychainItem: Equatable, KeychainItem {
/// The keychain item for the synthetic identity used to bootstrap the SDK client, so the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import Foundation

// sourcery: AutoMockable
/// A service that performs passkey registration and authentication directly through
/// `BitwardenSdk`'s Fido2 client β€” the same way the main Bitwarden app and its AutoFill
/// extension do β€” without going through the OS's passkey UI or a real vault.
/// `BitwardenSdk`'s Fido2 client.
///
public protocol PasskeyService: AnyObject {
/// Asserts a passkey for `rpId` against previously registered credentials.
Expand Down Expand Up @@ -42,7 +41,7 @@ public protocol PasskeyService: AnyObject {

// MARK: - HasPasskeyService

/// A protocol for an object that provides an `PasskeyService`.
/// A protocol for an object that provides a `PasskeyService`.
protocol HasPasskeyService {
/// The service used to perform passkey registration and authentication through the
/// Bitwarden SDK.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation

// MARK: - StoredCipher

/// A `Codable` mirror of the handful of `Cipher`/`Fido2Credential` fields the SDK-backed passkey
/// A `Codable` mirror of the handful of `Cipher`/`Fido2Credential` fields the passkey
/// scenarios actually populate, since `BitwardenSdk.Cipher` doesn't itself conform to `Codable`.
/// Every cipher these scenarios create is a login-type cipher with exactly one Fido2 credential,
/// so the remaining `Cipher` fields are reconstructed with the same fixed defaults used when the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import Foundation

// MARK: - SyntheticIdentity

/// The synthetic, throwaway identity used to bootstrap the SDK-backed passkey scenarios' ephemeral
/// The synthetic, throwaway identity used to bootstrap the passkey scenarios' ephemeral
/// `BitwardenSdk.Client`. Persisted in the keychain so the same crypto keys can be reconstructed
/// across app launches β€” without it, a freshly generated identity on the next launch couldn't
/// decrypt any previously-registered credential.
/// across app launches.
///
struct SyntheticIdentity: Codable, Equatable {
/// The synthetic account's email address.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// MARK: - RegisterPasskeyAction

/// Actions that can be processed by an `RegisterPasskeyProcessor`.
///
enum RegisterPasskeyAction: Equatable {
/// The display name text field changed.
case displayNameChanged(String)

/// The relying party ID text field changed.
case rpIdChanged(String)

/// The username text field changed.
case userNameChanged(String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// MARK: - RegisterPasskeyEffect

/// Effects that can be processed by an `RegisterPasskeyProcessor`.
///
enum RegisterPasskeyEffect: Equatable {
/// The user tapped the register passkey button.
case registerPasskey
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import BitwardenKit

// MARK: - RegisterPasskeyProcessor

/// The processor for the register passkey test screen.
///
final class RegisterPasskeyProcessor: StateProcessor<
RegisterPasskeyState,
RegisterPasskeyAction,
RegisterPasskeyEffect,
> {
// MARK: Private Properties

/// The coordinator that handles navigation.
private let coordinator: AnyCoordinator<RootRoute, Void>

/// The service used to perform passkey registration through the Bitwarden SDK.
private let passkeyService: PasskeyService

// MARK: Initialization

/// Initializes an `RegisterPasskeyProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator that handles navigation.
/// - passkeyService: The service used to perform passkey registration through the
/// Bitwarden SDK.
///
init(
coordinator: AnyCoordinator<RootRoute, Void>,
passkeyService: PasskeyService,
) {
self.coordinator = coordinator
self.passkeyService = passkeyService
super.init(state: RegisterPasskeyState())
}

// MARK: Methods

override func perform(_ effect: RegisterPasskeyEffect) async {
switch effect {
case .registerPasskey:
await registerPasskey()
}
}

override func receive(_ action: RegisterPasskeyAction) {
switch action {
case let .displayNameChanged(newValue):
state.displayName = newValue
state.status = .idle
case let .rpIdChanged(newValue):
state.rpId = newValue
state.status = .idle
case let .userNameChanged(newValue):
state.userName = newValue
state.status = .idle
}
}
Comment on lines +47 to +59

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 SUGGESTED: Editing a field while a registration is in flight resets status to .idle, re-enabling the register button.

Details and fix

The text fields stay editable during registration (only the button is .disabled), so typing mid-flight sets status back to .idle. That hides the ProgressView and re-enables the Register button (RegisterPasskeyView.swift:83), which lets a second concurrent registerPasskey run and register a duplicate credential for the same RP β€” later ambiguous for assertPasskey(credentialId: nil, rpId:).

Guarding the reset keeps the in-progress state authoritative:

Suggested change
override func receive(_ action: RegisterPasskeyAction) {
switch action {
case let .displayNameChanged(newValue):
state.displayName = newValue
state.status = .idle
case let .rpIdChanged(newValue):
state.rpId = newValue
state.status = .idle
case let .userNameChanged(newValue):
state.userName = newValue
state.status = .idle
}
}
override func receive(_ action: RegisterPasskeyAction) {
switch action {
case let .displayNameChanged(newValue):
state.displayName = newValue
case let .rpIdChanged(newValue):
state.rpId = newValue
case let .userNameChanged(newValue):
state.userName = newValue
}
if state.status != .inProgress {
state.status = .idle
}
}


// MARK: Private

/// Orchestrates state transitions and calls `passkeyService.registerPasskey`.
private func registerPasskey() async {
state.status = .inProgress
do {
let result = try await passkeyService.registerPasskey(
rpId: state.rpId,
userName: state.userName,
displayName: state.displayName,
)
state.status = .success(credentialId: result.credentialId.base64EncodedString())
} catch {
state.status = .failure(error.localizedDescription)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import BitwardenKit
import BitwardenKitMocks
import BitwardenSdk
import TestHelpers
import XCTest

@testable import TestHarnessShared

// MARK: - RegisterPasskeyProcessorTests

/// Tests for `RegisterPasskeyProcessor`.
///
class RegisterPasskeyProcessorTests: BitwardenTestCase {
// MARK: Properties

var coordinator: MockCoordinator<RootRoute, Void>!
var passkeyService: MockPasskeyService!
var subject: RegisterPasskeyProcessor!

// MARK: Setup & Teardown

@MainActor
override func setUp() {
super.setUp()
coordinator = MockCoordinator()
passkeyService = MockPasskeyService()
subject = RegisterPasskeyProcessor(
coordinator: coordinator.asAnyCoordinator(),
passkeyService: passkeyService,
)
}

override func tearDown() {
super.tearDown()
coordinator = nil
passkeyService = nil
subject = nil
}

// MARK: Action Tests

/// `receive(.displayNameChanged)` updates the display name in state.
@MainActor
func test_receive_displayNameChanged() {
subject.receive(.displayNameChanged("Test User"))
XCTAssertEqual(subject.state.displayName, "Test User")
}

/// `receive(.rpIdChanged)` updates the RP ID in state.
@MainActor
func test_receive_rpIdChanged() {
subject.receive(.rpIdChanged("bitwarden.com"))
XCTAssertEqual(subject.state.rpId, "bitwarden.com")
}

/// `receive(.userNameChanged)` updates the username in state.
@MainActor
func test_receive_userNameChanged() {
subject.receive(.userNameChanged("testuser"))
XCTAssertEqual(subject.state.userName, "testuser")
}

// MARK: Effect Tests

/// `perform(.registerPasskey)` passes the current state values to the SDK passkey service.
@MainActor
func test_perform_registerPasskey_passesStateValues() async {
subject.receive(.rpIdChanged("example.com"))
subject.receive(.userNameChanged("alice"))
subject.receive(.displayNameChanged("Alice Smith"))
passkeyService.registerPasskeyReturnValue = .fixture()

await subject.perform(.registerPasskey)

XCTAssertEqual(
passkeyService.registerPasskeyReceivedArguments?.rpId,
"example.com",
)
XCTAssertEqual(
passkeyService.registerPasskeyReceivedArguments?.userName,
"alice",
)
XCTAssertEqual(
passkeyService.registerPasskeyReceivedArguments?.displayName,
"Alice Smith",
)
}

/// `perform(.registerPasskey)` sets status to `.failure` when registration throws.
@MainActor
func test_perform_registerPasskey_failure() async {
passkeyService.registerPasskeyThrowableError = BitwardenTestError.example

await subject.perform(.registerPasskey)

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

/// `perform(.registerPasskey)` sets status to `.success` with the resulting credential ID
/// when registration succeeds.
@MainActor
func test_perform_registerPasskey_success() async {
passkeyService.registerPasskeyReturnValue = .fixture(credentialId: Data([0x01, 0x02, 0x03]))

await subject.perform(.registerPasskey)

XCTAssertEqual(subject.state.status, .success(credentialId: Data([0x01, 0x02, 0x03]).base64EncodedString()))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation

// MARK: - RegisterPasskeyState

/// The state for the register passkey test screen.
///
struct RegisterPasskeyState: Equatable {
// MARK: Types

/// The current status of an passkey registration attempt.
enum RegistrationStatus: Equatable {
/// Registration failed with the associated error description.
case failure(String)

/// No registration attempt has been made.
case idle

/// A registration request is in progress.
case inProgress

/// Registration completed successfully, producing the credential ID below.
case success(credentialId: String)
}

// MARK: Properties

/// The display name for the passkey credential.
var displayName: String = ""

/// The relying party identifier (RP ID) for passkey registration.
var rpId: String = "bitwarden.pw"

/// The current registration status.
var status: RegistrationStatus = .idle

/// The title of the screen.
var title: String = Localizations.registerPasskey

/// The username for the passkey credential.
var userName: String = ""
}
Loading
Loading