[PM-11137] Implement iOS 18 Totp autofill from list (#884)

This commit is contained in:
Federico Maccaroni 2024-11-13 14:18:56 -03:00 committed by GitHub
parent 7544e23546
commit e0b3956ef6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2038 additions and 273 deletions

View File

@ -4,8 +4,8 @@
<dict>
<key>BitwardenAppIdentifier</key>
<string>$(BASE_BUNDLE_ID)</string>
<key>BitwardenKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)$(BASE_BUNDLE_ID)</string>
<key>BitwardenKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)$(BASE_BUNDLE_ID)</string>
<key>CFBundleDisplayName</key>
<string>Bitwarden</string>
<key>CFBundleName</key>
@ -89,6 +89,8 @@
<true/>
<key>ASCredentialProviderExtensionCapabilities</key>
<dict>
<key>ProvidesOneTimeCodes</key>
<true/>
<key>ProvidesPasskeys</key>
<true/>
<key>ProvidesPasswords</key>

View File

@ -215,6 +215,15 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
}
}
// MARK: - iOS 18
extension CredentialProviderViewController {
@available(iOSApplicationExtension 18.0, *)
override func prepareOneTimeCodeCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) {
initializeApp(with: DefaultCredentialProviderContext(.autofillOTP(serviceIdentifiers)))
}
}
// MARK: - AppExtensionDelegate
extension CredentialProviderViewController: AppExtensionDelegate {
@ -224,6 +233,13 @@ extension CredentialProviderViewController: AppExtensionDelegate {
var canAutofill: Bool { true }
var isAutofillingOTP: Bool {
guard case .autofillOTP = context?.extensionMode else {
return false
}
return true
}
var isInAppExtension: Bool { true }
var uri: String? {
@ -246,6 +262,11 @@ extension CredentialProviderViewController: AppExtensionDelegate {
extensionContext.completeRequest(withSelectedCredential: passwordCredential)
}
@available(iOSApplicationExtension 18.0, *)
func completeOTPRequest(code: String) {
extensionContext.completeOneTimeCodeRequest(using: ASOneTimeCodeCredential(code: code))
}
func didCancel() {
cancel()
}
@ -295,9 +316,9 @@ extension CredentialProviderViewController: AppExtensionDelegate {
}
}
// MARK: - Fido2AppExtensionDelegate
// MARK: - AutofillAppExtensionDelegate
extension CredentialProviderViewController: Fido2AppExtensionDelegate {
extension CredentialProviderViewController: AutofillAppExtensionDelegate {
/// The mode in which the autofill extension is running.
var extensionMode: AutofillExtensionMode {
context?.extensionMode ?? .configureAutofill

View File

@ -70,6 +70,9 @@ class DefaultAutofillCredentialService {
/// The service that handles common client functionality such as encryption and decryption.
private let clientService: ClientService
/// The factory to create credential identities.
private let credentialIdentityFactory: CredentialIdentityFactory
/// The service used by the application to report non-fatal errors.
private let errorReporter: ErrorReporter
@ -112,6 +115,7 @@ class DefaultAutofillCredentialService {
/// - 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.
/// - credentialIdentityFactory: The factory to create credential identities.
/// - 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
@ -126,6 +130,7 @@ class DefaultAutofillCredentialService {
init(
cipherService: CipherService,
clientService: ClientService,
credentialIdentityFactory: CredentialIdentityFactory,
errorReporter: ErrorReporter,
eventService: EventService,
fido2CredentialStore: Fido2CredentialStore,
@ -138,6 +143,7 @@ class DefaultAutofillCredentialService {
) {
self.cipherService = cipherService
self.clientService = clientService
self.credentialIdentityFactory = credentialIdentityFactory
self.errorReporter = errorReporter
self.eventService = eventService
self.fido2CredentialStore = fido2CredentialStore
@ -215,7 +221,12 @@ class DefaultAutofillCredentialService {
}
if #available(iOS 17, *) {
let identities = decryptedCiphers.compactMap(\.credentialIdentity)
var identities = [ASCredentialIdentity]()
for cipher in decryptedCiphers {
let newIdentities = await credentialIdentityFactory.createCredentialIdentities(from: cipher)
identities.append(contentsOf: newIdentities)
}
let fido2Identities = try await clientService.platform().fido2()
.authenticator(
userInterface: fido2UserInterfaceHelper,
@ -223,11 +234,14 @@ class DefaultAutofillCredentialService {
)
.credentialsForAutofill()
.compactMap { $0.toFido2CredentialIdentity() }
identities.append(contentsOf: fido2Identities)
try await identityStore.replaceCredentialIdentities(identities + fido2Identities)
try await identityStore.replaceCredentialIdentities(identities)
Logger.application.info("AutofillCredentialService: replaced \(identities.count) credential identities")
} else {
let identities = decryptedCiphers.compactMap(\.passwordCredentialIdentity)
let identities = decryptedCiphers.compactMap { cipher in
credentialIdentityFactory.tryCreatePasswordCredentialIdentity(from: cipher)
}
try await identityStore.replaceCredentialIdentities(with: identities)
Logger.application.info("AutofillCredentialService: replaced \(identities.count) credential identities")
}
@ -417,40 +431,6 @@ extension DefaultAutofillCredentialService: AutofillCredentialService {
}
}
// MARK: - CipherView
private extension CipherView {
@available(iOS 17, *)
var credentialIdentity: (any ASCredentialIdentity)? {
guard shouldGetPasswordCredentialIdentity else {
return nil
}
return passwordCredentialIdentity
}
var passwordCredentialIdentity: ASPasswordCredentialIdentity? {
let uris = login?.uris?.filter { $0.match != .never && $0.uri.isEmptyOrNil == false }
guard let uri = uris?.first?.uri,
let username = login?.username, !username.isEmpty
else {
return nil
}
let serviceIdentifier = ASCredentialServiceIdentifier(identifier: uri, type: .URL)
return ASPasswordCredentialIdentity(
serviceIdentifier: serviceIdentifier,
user: username,
recordIdentifier: id
)
}
/// Whether the `ASPasswordCredentialIdentity` should be gotten.
/// Otherwise a passkey identity will be provided.
var shouldGetPasswordCredentialIdentity: Bool {
!hasFido2Credentials || login?.password != nil
}
}
// MARK: - CredentialIdentityStore
/// A protocol for a store which makes credential identities available via the AutoFill suggestions.

View File

@ -10,6 +10,7 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t
var autofillCredentialServiceDelegate: MockAutofillCredentialServiceDelegate!
var cipherService: MockCipherService!
var clientService: MockClientService!
var credentialIdentityFactory: MockCredentialIdentityFactory!
var errorReporter: MockErrorReporter!
var eventService: MockEventService!
var fido2UserInterfaceHelperDelegate: MockFido2UserInterfaceHelperDelegate!
@ -30,6 +31,7 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t
autofillCredentialServiceDelegate = MockAutofillCredentialServiceDelegate()
cipherService = MockCipherService()
clientService = MockClientService()
credentialIdentityFactory = MockCredentialIdentityFactory()
errorReporter = MockErrorReporter()
eventService = MockEventService()
fido2UserInterfaceHelperDelegate = MockFido2UserInterfaceHelperDelegate()
@ -44,6 +46,7 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t
subject = DefaultAutofillCredentialService(
cipherService: cipherService,
clientService: clientService,
credentialIdentityFactory: credentialIdentityFactory,
errorReporter: errorReporter,
eventService: eventService,
fido2CredentialStore: fido2CredentialStore,
@ -68,6 +71,7 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t
autofillCredentialServiceDelegate = nil
cipherService = nil
clientService = nil
credentialIdentityFactory = nil
errorReporter = nil
eventService = nil
fido2UserInterfaceHelperDelegate = nil
@ -657,7 +661,7 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t
/// `syncIdentities(vaultLockStatus:)` updates the credential identity store with the identities
/// from the user's vault.
func test_syncIdentities() {
func test_syncIdentities() { // swiftlint:disable:this function_body_length
cipherService.fetchAllCiphersResult = .success([
.fixture(
id: "1",
@ -678,6 +682,32 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t
),
.fixture(deletedDate: .now, id: "4", type: .login),
])
credentialIdentityFactory.createCredentialIdentitiesMocker
.withResult { cipher in
if cipher.id == "1" {
return [
.password(
PasswordCredentialIdentity(
id: "1",
uri: "bitwarden.com",
username: "user@bitwarden.com"
)
),
]
} else if cipher.id == "3" {
return [
.password(
PasswordCredentialIdentity(
id: "3",
uri: "example.com",
username: "user@example.com"
)
),
]
} else {
return []
}
}
vaultTimeoutService.vaultLockStatusSubject.send(VaultLockStatus(isVaultLocked: false, userId: "1"))
waitFor(identityStore.replaceCredentialIdentitiesIdentities != nil)
@ -691,6 +721,186 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t
)
}
/// `syncIdentities(vaultLockStatus:)` updates the credential identity store with the identities
/// from the user's vault when there are passwords and Fido2 credentials
func test_syncIdentities_passwordsAndFido2Credentials() { // swiftlint:disable:this function_body_length
cipherService.fetchAllCiphersResult = .success([
.fixture(
id: "1",
login: .fixture(
password: "password123",
uris: [.fixture(uri: "bitwarden.com")],
username: "user@bitwarden.com"
)
),
.fixture(id: "2", type: .identity),
.fixture(
id: "3",
login: .fixture(
fido2Credentials: [
.fixture(),
],
uris: [.fixture(uri: "example.com")],
username: "user@example.com"
)
),
.fixture(deletedDate: .now, id: "4", type: .login),
])
credentialIdentityFactory.createCredentialIdentitiesMocker
.withResult { cipher in
guard cipher.id == "1" else {
return []
}
return [
.password(
PasswordCredentialIdentity(
id: "1",
uri: "bitwarden.com",
username: "user@bitwarden.com"
)
),
]
}
clientService.mockPlatform.fido2Mock
.clientFido2AuthenticatorMock
.credentialsForAutofillResult = .success(
[
Fido2CredentialAutofillView(
credentialId: Data(repeating: 2, count: 32),
cipherId: "3",
rpId: "myApp.com",
userNameForUi: "MyUser",
userHandle: Data(repeating: 3, count: 45)
),
]
)
vaultTimeoutService.vaultLockStatusSubject.send(VaultLockStatus(isVaultLocked: false, userId: "1"))
waitFor(identityStore.replaceCredentialIdentitiesIdentities != nil)
XCTAssertEqual(
identityStore.replaceCredentialIdentitiesIdentities,
[
.password(
PasswordCredentialIdentity(
id: "1",
uri: "bitwarden.com",
username: "user@bitwarden.com"
)
),
.passkey(
PasskeyCredentialIdentity(
credentialID: Data(repeating: 2, count: 32),
recordIdentifier: "3",
relyingPartyIdentifier: "myApp.com",
userHandle: Data(repeating: 3, count: 45),
userName: "MyUser"
)
),
]
)
}
/// `syncIdentities(vaultLockStatus:)` updates the credential identity store with the identities
/// from the user's vault when there are passwords, Fido2 credentials and one time codes.
func test_syncIdentities_passwordsFido2CredentialsAndOTP() throws { // swiftlint:disable:this function_body_length
guard #available(iOS 18, *) else {
throw XCTSkip("One time code credentials are only available on iOS 18+")
}
cipherService.fetchAllCiphersResult = .success([
.fixture(
id: "1",
login: .fixture(
password: "password123",
uris: [.fixture(uri: "bitwarden.com")],
username: "user@bitwarden.com",
totp: "something"
),
name: "MyCipher"
),
.fixture(id: "2", type: .identity),
.fixture(
id: "3",
login: .fixture(
fido2Credentials: [
.fixture(),
],
uris: [.fixture(uri: "example.com")],
username: "user@example.com"
)
),
.fixture(deletedDate: .now, id: "4", type: .login),
])
credentialIdentityFactory.createCredentialIdentitiesMocker
.withResult { cipher in
guard cipher.id == "1" else {
return []
}
return [
.password(
PasswordCredentialIdentity(
id: "1",
uri: "bitwarden.com",
username: "user@bitwarden.com"
)
),
.oneTimeCode(
OneTimeCodeCredentialIdentity(
label: "MyCipher",
recordIdentifier: "1",
serviceIdentifier: "bitwarden.com"
)
),
]
}
clientService.mockPlatform.fido2Mock
.clientFido2AuthenticatorMock
.credentialsForAutofillResult = .success(
[
Fido2CredentialAutofillView(
credentialId: Data(repeating: 2, count: 32),
cipherId: "3",
rpId: "myApp.com",
userNameForUi: "MyUser",
userHandle: Data(repeating: 3, count: 45)
),
]
)
vaultTimeoutService.vaultLockStatusSubject.send(VaultLockStatus(isVaultLocked: false, userId: "1"))
waitFor(identityStore.replaceCredentialIdentitiesIdentities != nil)
XCTAssertEqual(
identityStore.replaceCredentialIdentitiesIdentities,
[
.password(
PasswordCredentialIdentity(
id: "1",
uri: "bitwarden.com",
username: "user@bitwarden.com"
)
),
.oneTimeCode(
OneTimeCodeCredentialIdentity(
label: "MyCipher",
recordIdentifier: "1",
serviceIdentifier: "bitwarden.com"
)
),
.passkey(
PasskeyCredentialIdentity(
credentialID: Data(repeating: 2, count: 32),
recordIdentifier: "3",
relyingPartyIdentifier: "myApp.com",
userHandle: Data(repeating: 3, count: 45),
userName: "MyUser"
)
),
]
)
}
/// `syncIdentities(vaultLockStatus:)` doesn't remove identities if the store's state is disabled.
func test_syncIdentities_removeDisabled() async throws {
identityStore.state.mockIsEnabled = false
@ -727,6 +937,16 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t
)
),
])
credentialIdentityFactory.createCredentialIdentitiesMocker
.withResult([
.password(
PasswordCredentialIdentity(
id: "1",
uri: "bitwarden.com",
username: "user@bitwarden.com"
)
),
])
vaultTimeoutService.vaultLockStatusSubject.send(VaultLockStatus(isVaultLocked: false, userId: "1"))
try await waitForAsync {
@ -754,6 +974,16 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t
)
),
])
credentialIdentityFactory.createCredentialIdentitiesMocker
.withResult([
.password(
PasswordCredentialIdentity(
id: "1",
uri: "bitwarden.com",
username: "user@bitwarden.com"
)
),
])
vaultTimeoutService.vaultLockStatusSubject.send(VaultLockStatus(isVaultLocked: false, userId: "1"))
try await waitForAsync {
@ -784,6 +1014,16 @@ class AutofillCredentialServiceTests: BitwardenTestCase { // swiftlint:disable:t
)
),
])
credentialIdentityFactory.createCredentialIdentitiesMocker
.withResult([
.password(
PasswordCredentialIdentity(
id: "1",
uri: "bitwarden.com",
username: "user@bitwarden.com"
)
),
])
vaultTimeoutService.vaultLockStatusSubject.send(VaultLockStatus(isVaultLocked: false, userId: "1"))
waitFor(identityStore.replaceCredentialIdentitiesIdentities != nil)

View File

@ -0,0 +1,86 @@
import AuthenticationServices
import BitwardenSdk
/// Protocol of the factory to create credential identities.
protocol CredentialIdentityFactory {
/// Creates the `ASCredentialIdentity` array from a `CipherView` (it may return empty).
/// - Parameter cipher: The cipher to get the identities from.
/// - Returns: An array of `ASCredentialIdenitty` (password or one time code)
@available(iOS 17.0, *)
func createCredentialIdentities(from cipher: CipherView) async -> [ASCredentialIdentity]
/// Tries to create a `ASPasswordCredentialIdentity` from the given `cipher`
/// - Parameter cipher: CIpher to create the password identity.
/// - Returns: The password credential identity or `nil` if it can't be created.
func tryCreatePasswordCredentialIdentity(from cipher: CipherView) -> ASPasswordCredentialIdentity?
}
/// Default implemenation of `CredentialIdentityFactory` to create credential identities.
struct DefaultCredentialIdentityFactory: CredentialIdentityFactory {
@available(iOS 17.0, *)
func createCredentialIdentities(from cipher: CipherView) async -> [ASCredentialIdentity] {
var identities = [ASCredentialIdentity]()
if let oneTimeCodeIdentity = tryCreateOneTimeCodeIdentity(from: cipher) {
identities.append(oneTimeCodeIdentity)
}
guard !cipher.hasFido2Credentials || cipher.login?.password != nil else {
// if this is the case then a passkey credential identity needs to be provided
// but that's handled differently to improve performance from the SDK.
return identities
}
if let passwordIdentity = tryCreatePasswordCredentialIdentity(from: cipher) {
identities.append(passwordIdentity)
}
return identities
}
func tryCreatePasswordCredentialIdentity(from cipher: BitwardenSdk.CipherView) -> ASPasswordCredentialIdentity? {
guard let serviceIdentifier = createServiceIdentifierFromFirstLoginUri(of: cipher),
let username = cipher.login?.username, !username.isEmpty
else {
return nil
}
return ASPasswordCredentialIdentity(
serviceIdentifier: serviceIdentifier,
user: username,
recordIdentifier: cipher.id
)
}
// MARK: Private
/// Gets the service identifier based on the first login uri, if there's one.
private func createServiceIdentifierFromFirstLoginUri(of cipher: CipherView) -> ASCredentialServiceIdentifier? {
let uris = cipher.login?.uris?.filter { $0.match != .never && $0.uri.isEmptyOrNil == false }
guard let uri = uris?.first?.uri else {
return nil
}
return ASCredentialServiceIdentifier(identifier: uri, type: .URL)
}
/// Tries to create a one time code credential identity if possible from the `cipher`.
/// - Parameter cipher: The cipher to get the one time code identity.
/// - Returns: An `ASOneTimeCodeCredentialIdentity` if possible, `nil` otherwise.
@available(iOS 17.0, *)
private func tryCreateOneTimeCodeIdentity(from cipher: CipherView) -> ASCredentialIdentity? {
guard #available(iOSApplicationExtension 18.0, *) else {
return nil
}
guard let serviceIdentifier = createServiceIdentifierFromFirstLoginUri(of: cipher),
cipher.login?.totp != nil else {
return nil
}
return ASOneTimeCodeCredentialIdentity(
serviceIdentifier: serviceIdentifier,
label: cipher.name,
recordIdentifier: cipher.id
)
}
}

View File

@ -0,0 +1,378 @@
import AuthenticationServices
import BitwardenSdk
import XCTest
@testable import BitwardenShared
class CredentialIdentityFactoryTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Properties
var subject: DefaultCredentialIdentityFactory!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
subject = DefaultCredentialIdentityFactory()
}
override func tearDown() {
super.tearDown()
subject = nil
}
// MARK: Tests
/// `createCredentialIdentities(from:)` creates the credential identities (one time code and password)
/// from the given cipher view.
func test_createCredentialIdentities_allIdentities() async throws {
guard #available(iOS 18.0, *) else {
throw XCTSkip("iOS 18.0 is required to run this test.")
}
let expectedName = "CipherName"
let expectedUri = "https://example.com"
let expectedUsername = "test"
let cipher = CipherView.fixture(
login: .fixture(
password: "1234",
uris: [
.fixture(uri: expectedUri, match: .domain),
],
username: expectedUsername,
totp: "1234"
),
name: expectedName
)
let identities = await subject.createCredentialIdentities(from: cipher)
XCTAssertEqual(identities.count, 2)
let oneTimeCodeIdentity = try XCTUnwrap(identities[0] as? ASOneTimeCodeCredentialIdentity)
let passwordIdentity = try XCTUnwrap(identities[1] as? ASPasswordCredentialIdentity)
XCTAssertEqual(oneTimeCodeIdentity.label, expectedName)
XCTAssertEqual(oneTimeCodeIdentity.serviceIdentifier.identifier, expectedUri)
XCTAssertEqual(
oneTimeCodeIdentity.serviceIdentifier.type,
ASCredentialServiceIdentifier.IdentifierType.URL
)
XCTAssertEqual(oneTimeCodeIdentity.recordIdentifier, cipher.id)
XCTAssertEqual(passwordIdentity.serviceIdentifier.identifier, expectedUri)
XCTAssertEqual(
passwordIdentity.serviceIdentifier.type,
ASCredentialServiceIdentifier.IdentifierType.URL
)
XCTAssertEqual(passwordIdentity.user, expectedUsername)
XCTAssertEqual(passwordIdentity.recordIdentifier, cipher.id)
}
/// `createCredentialIdentities(from:)` creates the credential identities (one time code and password)
/// from the given cipher view when some of the uris are nil, empty or have match `.never`.
func test_createCredentialIdentities_allIdentitiesWithSomeUnmatchingUris() async throws {
guard #available(iOS 18.0, *) else {
throw XCTSkip("iOS 18.0 is required to run this test.")
}
let expectedName = "CipherName"
let expectedUri = "https://example.com"
let expectedUsername = "test"
let cipher = CipherView.fixture(
login: .fixture(
password: "1234",
uris: [
.fixture(uri: nil, match: .domain),
.fixture(uri: "", match: .domain),
.fixture(uri: expectedUri, match: .domain),
.fixture(uri: nil, match: .never),
.fixture(uri: "https://example2.com", match: .never),
],
username: expectedUsername,
totp: "1234"
),
name: expectedName
)
let identities = await subject.createCredentialIdentities(from: cipher)
XCTAssertEqual(identities.count, 2)
let oneTimeCodeIdentity = try XCTUnwrap(identities[0] as? ASOneTimeCodeCredentialIdentity)
let passwordIdentity = try XCTUnwrap(identities[1] as? ASPasswordCredentialIdentity)
XCTAssertEqual(oneTimeCodeIdentity.label, expectedName)
XCTAssertEqual(oneTimeCodeIdentity.serviceIdentifier.identifier, expectedUri)
XCTAssertEqual(
oneTimeCodeIdentity.serviceIdentifier.type,
ASCredentialServiceIdentifier.IdentifierType.URL
)
XCTAssertEqual(oneTimeCodeIdentity.recordIdentifier, cipher.id)
XCTAssertEqual(passwordIdentity.serviceIdentifier.identifier, expectedUri)
XCTAssertEqual(
passwordIdentity.serviceIdentifier.type,
ASCredentialServiceIdentifier.IdentifierType.URL
)
XCTAssertEqual(passwordIdentity.user, expectedUsername)
XCTAssertEqual(passwordIdentity.recordIdentifier, cipher.id)
}
/// `createCredentialIdentities(from:)` creates only OTC credential identity
/// from the given cipher view when the cipher doens't have username nor password..
func test_createCredentialIdentities_otcIdentityWhenNoUsernameNorPassword() async throws {
guard #available(iOS 18.0, *) else {
throw XCTSkip("iOS 18.0 is required to run this test.")
}
let expectedName = "CipherName"
let expectedUri = "https://example.com"
let cipher = CipherView.fixture(
login: .fixture(
uris: [
.fixture(uri: expectedUri, match: .domain),
],
totp: "1234"
),
name: expectedName
)
let identities = await subject.createCredentialIdentities(from: cipher)
XCTAssertEqual(identities.count, 1)
let oneTimeCodeIdentity = try XCTUnwrap(identities[0] as? ASOneTimeCodeCredentialIdentity)
XCTAssertEqual(oneTimeCodeIdentity.label, expectedName)
XCTAssertEqual(oneTimeCodeIdentity.serviceIdentifier.identifier, expectedUri)
XCTAssertEqual(
oneTimeCodeIdentity.serviceIdentifier.type,
ASCredentialServiceIdentifier.IdentifierType.URL
)
XCTAssertEqual(oneTimeCodeIdentity.recordIdentifier, cipher.id)
}
/// `createCredentialIdentities(from:)` creates only password credential identity
/// from the given cipher view when there is no totp.
func test_createCredentialIdentities_passwordOnly() async throws {
guard #available(iOS 17.0, *) else {
throw XCTSkip("iOS 17.0 is required to run this test.")
}
let expectedUri = "https://example.com"
let expectedUsername = "test"
let cipher = CipherView.fixture(
login: .fixture(
password: "1234",
uris: [
.fixture(uri: expectedUri, match: .domain),
],
username: expectedUsername
)
)
let identities = await subject.createCredentialIdentities(from: cipher)
XCTAssertEqual(identities.count, 1)
let passwordIdentity = try XCTUnwrap(identities[0] as? ASPasswordCredentialIdentity)
XCTAssertEqual(passwordIdentity.serviceIdentifier.identifier, expectedUri)
XCTAssertEqual(
passwordIdentity.serviceIdentifier.type,
ASCredentialServiceIdentifier.IdentifierType.URL
)
XCTAssertEqual(passwordIdentity.user, expectedUsername)
XCTAssertEqual(passwordIdentity.recordIdentifier, cipher.id)
}
/// `createCredentialIdentities(from:)` returns no credentials if the cipher view uris
/// has match `.never`.
func test_createCredentialIdentities_noCredentialsOnMatchNever() async throws {
guard #available(iOS 17.0, *) else {
throw XCTSkip("iOS 17.0 is required to run this test.")
}
let cipher = CipherView.fixture(
login: .fixture(
password: "1234",
uris: [
.fixture(uri: "https://example.com", match: .never),
],
username: "test",
totp: "1234"
)
)
let identities = await subject.createCredentialIdentities(from: cipher)
XCTAssertTrue(identities.isEmpty)
}
/// `createCredentialIdentities(from:)` returns no credentials if the cipher view has no uris
func test_createCredentialIdentities_noUris() async throws {
guard #available(iOS 17.0, *) else {
throw XCTSkip("iOS 17.0 is required to run this test.")
}
let cipher = CipherView.fixture(
login: .fixture(
password: "1234",
username: "test",
totp: "1234"
)
)
let identities = await subject.createCredentialIdentities(from: cipher)
XCTAssertTrue(identities.isEmpty)
}
/// `createCredentialIdentities(from:)` returns no credentials if the cipher view uris
/// are empty.
func test_createCredentialIdentities_uriEmpty() async throws {
guard #available(iOS 17.0, *) else {
throw XCTSkip("iOS 17.0 is required to run this test.")
}
let cipher = CipherView.fixture(
login: .fixture(
password: "1234",
uris: [
.fixture(uri: "", match: .domain),
],
username: "test",
totp: "1234"
)
)
let identities = await subject.createCredentialIdentities(from: cipher)
XCTAssertTrue(identities.isEmpty)
}
/// `createCredentialIdentities(from:)` returns no credentials if the cipher view uris
/// are `nil`.
func test_createCredentialIdentities_uriNil() async throws {
guard #available(iOS 17.0, *) else {
throw XCTSkip("iOS 17.0 is required to run this test.")
}
let cipher = CipherView.fixture(
login: .fixture(
password: "1234",
uris: [
.fixture(uri: nil, match: .domain),
],
username: "test",
totp: "1234"
)
)
let identities = await subject.createCredentialIdentities(from: cipher)
XCTAssertTrue(identities.isEmpty)
}
/// `createCredentialIdentities(from:)` returns no credentials if the cipher view is not login.
func test_createCredentialIdentities_notLogin() async throws {
guard #available(iOS 17.0, *) else {
throw XCTSkip("iOS 17.0 is required to run this test.")
}
let cipher = CipherView.fixture(
card: .fixture()
)
let identities = await subject.createCredentialIdentities(from: cipher)
XCTAssertTrue(identities.isEmpty)
}
/// `tryCreatePasswordCredentialIdentity(from:)` returns the password credential from the cipher.
func test_tryCreatePasswordCredentialIdentity_success() throws {
let expectedUri = "https://example.com"
let expectedUsername = "test"
let cipher = CipherView.fixture(
login: .fixture(
password: "1234",
uris: [
.fixture(uri: expectedUri, match: .domain),
],
username: expectedUsername
)
)
let passwordIdentity = try XCTUnwrap(
subject.tryCreatePasswordCredentialIdentity(from: cipher)
)
XCTAssertEqual(passwordIdentity.serviceIdentifier.identifier, expectedUri)
XCTAssertEqual(passwordIdentity.serviceIdentifier.type, ASCredentialServiceIdentifier.IdentifierType.URL)
XCTAssertEqual(passwordIdentity.user, expectedUsername)
XCTAssertEqual(passwordIdentity.recordIdentifier, cipher.id)
}
/// `tryCreatePasswordCredentialIdentity(from:)` returns `nil` when cipher doesn't have login.
func test_tryCreatePasswordCredentialIdentity_noLogin() throws {
let cipher = CipherView.fixture(
login: nil
)
let passwordIdentity = subject.tryCreatePasswordCredentialIdentity(from: cipher)
XCTAssertNil(passwordIdentity)
}
/// `tryCreatePasswordCredentialIdentity(from:)` returns `nil` when the login Uri has match `never`.
func test_tryCreatePasswordCredentialIdentity_loginUriNever() throws {
let cipher = CipherView.fixture(
login: .fixture(
password: "1234",
uris: [
.fixture(uri: "https://example.com", match: .never),
],
username: "expectedUsername"
)
)
let passwordIdentity = subject.tryCreatePasswordCredentialIdentity(from: cipher)
XCTAssertNil(passwordIdentity)
}
/// `tryCreatePasswordCredentialIdentity(from:)` returns `nil` when the login Uri is empty.
func test_tryCreatePasswordCredentialIdentity_loginUriEmpty() throws {
let cipher = CipherView.fixture(
login: .fixture(
password: "1234",
uris: [
.fixture(uri: "", match: .domain),
],
username: "expectedUsername"
)
)
let passwordIdentity = subject.tryCreatePasswordCredentialIdentity(from: cipher)
XCTAssertNil(passwordIdentity)
}
/// `tryCreatePasswordCredentialIdentity(from:)` returns `nil` when there are 0 login uris.
func test_tryCreatePasswordCredentialIdentity_loginUrisEmpty() throws {
let cipher = CipherView.fixture(
login: .fixture(
password: "1234",
uris: [],
username: "expectedUsername"
)
)
let passwordIdentity = subject.tryCreatePasswordCredentialIdentity(from: cipher)
XCTAssertNil(passwordIdentity)
}
/// `tryCreatePasswordCredentialIdentity(from:)` returns `nil` when login username is `nil`.
func test_tryCreatePasswordCredentialIdentity_usernameNil() throws {
let expectedUri = "https://example.com"
let cipher = CipherView.fixture(
login: .fixture(
password: "1234",
uris: [
.fixture(uri: expectedUri, match: .domain),
],
username: nil
)
)
let passwordIdentity = subject.tryCreatePasswordCredentialIdentity(from: cipher)
XCTAssertNil(passwordIdentity)
}
/// `tryCreatePasswordCredentialIdentity(from:)` returns `nil` when login username is empty.
func test_tryCreatePasswordCredentialIdentity_usernameEmpty() throws {
let expectedUri = "https://example.com"
let cipher = CipherView.fixture(
login: .fixture(
password: "1234",
uris: [
.fixture(uri: expectedUri, match: .domain),
],
username: ""
)
)
let passwordIdentity = subject.tryCreatePasswordCredentialIdentity(from: cipher)
XCTAssertNil(passwordIdentity)
}
}

View File

@ -0,0 +1,25 @@
import AuthenticationServices
import BitwardenSdk
@testable import BitwardenShared
class MockCredentialIdentityFactory: CredentialIdentityFactory {
var createCredentialIdentitiesMocker = InvocationMockerWithThrowingResult<CipherView, [CredentialIdentity]>()
.throwing(BitwardenTestError.example)
// swiftlint:disable:next identifier_name
var tryCreatePasswordCredentialIdentityResult: ASPasswordCredentialIdentity?
@available(iOS 17.0, *)
func createCredentialIdentities(from cipher: BitwardenSdk.CipherView) async -> [any ASCredentialIdentity] {
do {
return try createCredentialIdentitiesMocker.invoke(param: cipher)
.compactMap(\.asCredentialIdentity)
} catch {
return []
}
}
func tryCreatePasswordCredentialIdentity(from cipher: BitwardenSdk.CipherView) -> ASPasswordCredentialIdentity? {
tryCreatePasswordCredentialIdentityResult
}
}

View File

@ -51,6 +51,44 @@ class MockCredentialIdentityStoreState: ASCredentialIdentityStoreState {
enum CredentialIdentity: Equatable {
case password(PasswordCredentialIdentity)
case passkey(PasskeyCredentialIdentity)
case oneTimeCode(OneTimeCodeCredentialIdentity)
@available(iOS 17.0, *)
var asCredentialIdentity: ASCredentialIdentity? {
switch self {
case let .password(passwordIdentity):
return ASPasswordCredentialIdentity(
serviceIdentifier: ASCredentialServiceIdentifier(
identifier: passwordIdentity.uri,
type: .URL
),
user: passwordIdentity.username,
recordIdentifier: passwordIdentity.id
)
case let .passkey(passkeyIdentity):
return ASPasskeyCredentialIdentity(
relyingPartyIdentifier: passkeyIdentity.relyingPartyIdentifier,
userName: passkeyIdentity.userName,
credentialID: passkeyIdentity.credentialID,
userHandle: passkeyIdentity.userHandle,
recordIdentifier: passkeyIdentity.recordIdentifier
)
default:
if #available(iOS 18, *), case let .oneTimeCode(oneTimeCodeIdentity) = self {
return ASOneTimeCodeCredentialIdentity(
serviceIdentifier: ASCredentialServiceIdentifier(
identifier: oneTimeCodeIdentity.serviceIdentifier,
type: .URL
),
label: oneTimeCodeIdentity.label,
recordIdentifier: oneTimeCodeIdentity.recordIdentifier
)
} else {
return nil
}
}
}
init(identity: ASPasswordCredentialIdentity) {
self = .password(PasswordCredentialIdentity(identity))
@ -61,8 +99,14 @@ enum CredentialIdentity: Equatable {
switch identity {
case let identity as ASPasswordCredentialIdentity:
self = .password(PasswordCredentialIdentity(identity))
case let passkeyIdentity as ASPasskeyCredentialIdentity:
self = .passkey(PasskeyCredentialIdentity(passkeyIdentity))
default:
return nil
if #available(iOS 18, *), let oneTimeCodeIdentity = identity as? ASOneTimeCodeCredentialIdentity {
self = .oneTimeCode(OneTimeCodeCredentialIdentity(oneTimeCodeIdentity))
} else {
return nil
}
}
}
}
@ -82,3 +126,41 @@ extension PasswordCredentialIdentity {
username = identity.user
}
}
// MARK: - PasskeyCredentialIdentity
struct PasskeyCredentialIdentity: Equatable {
let credentialID: Data
let recordIdentifier: String?
let relyingPartyIdentifier: String
let userHandle: Data
let userName: String
}
extension PasskeyCredentialIdentity {
@available(iOS 17.0, *)
init(_ identity: ASPasskeyCredentialIdentity) {
credentialID = identity.credentialID
recordIdentifier = identity.recordIdentifier
relyingPartyIdentifier = identity.relyingPartyIdentifier
userHandle = identity.userHandle
userName = identity.userName
}
}
// MARK: - OneTimeCodeCredentialIdentity
struct OneTimeCodeCredentialIdentity: Equatable {
let label: String
let recordIdentifier: String?
let serviceIdentifier: String
}
extension OneTimeCodeCredentialIdentity {
@available(iOS 18.0, *)
init(_ identity: ASOneTimeCodeCredentialIdentity) {
label = identity.label
recordIdentifier = identity.recordIdentifier
serviceIdentifier = identity.serviceIdentifier.identifier
}
}

View File

@ -6,4 +6,6 @@ public enum AutofillListMode {
case combinedSingleSection
/// The autofill list shows both passwords and Fido2 items grouped per section.
case combinedMultipleSections
/// The autofill list only shows ciphers with totp.
case totp
}

View File

@ -5,6 +5,9 @@ public enum AutofillExtensionMode {
/// The extension is autofilling a specific password credential.
case autofillCredential(ASPasswordCredentialIdentity, userInteraction: Bool)
/// The extension is displaying a list of OTP items in the vault that match a service identifier.
case autofillOTP([ASCredentialServiceIdentifier])
/// The extension is displaying a list of password items in the vault that match a service identifier.
case autofillVaultList([ASCredentialServiceIdentifier])

View File

@ -26,6 +26,8 @@ public struct DefaultCredentialProviderContext: CredentialProviderContext {
switch extensionMode {
case .autofillCredential:
return nil
case .autofillOTP:
return AppRoute.vault(.autofillList)
case .autofillVaultList:
return AppRoute.vault(.autofillList)
case .autofillFido2Credential:
@ -69,14 +71,16 @@ public struct DefaultCredentialProviderContext: CredentialProviderContext {
}
public var serviceIdentifiers: [ASCredentialServiceIdentifier] {
if case let .autofillVaultList(serviceIdentifiers) = extensionMode {
return serviceIdentifiers
return switch extensionMode {
case let .autofillOTP(serviceIdentifiers):
serviceIdentifiers
case let .autofillVaultList(serviceIdentifiers):
serviceIdentifiers
case let .autofillFido2VaultList(serviceIdentifiers, _):
serviceIdentifiers
default:
[]
}
if case let .autofillFido2VaultList(serviceIdentifiers, _) = extensionMode {
return serviceIdentifiers
}
return []
}
/// Initializes the context.

View File

@ -133,6 +133,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// The service used by the application to manage account access tokens.
let tokenService: TokenService
/// The factory to create TOTP expiration managers.
let totpExpirationManagerFactory: TOTPExpirationManagerFactory
/// The service used by the application to validate TOTP keys and produce TOTP values.
let totpService: TOTPService
@ -197,6 +200,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// - systemDevice: The object used by the application to retrieve information about this device.
/// - timeProvider: Provides the present time for TOTP Code Calculation.
/// - tokenService: The service used by the application to manage account access tokens.
/// - totpExpirationManagerFactory: The factory to create TOTP expiration managers.
/// - totpService: The service used by the application to validate TOTP keys and produce TOTP values.
/// - trustDeviceService: The service used to handle device trust.
/// - twoStepLoginService: The service used by the application to generate a two step login URL.
@ -243,6 +247,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
systemDevice: SystemDevice,
timeProvider: TimeProvider,
tokenService: TokenService,
totpExpirationManagerFactory: TOTPExpirationManagerFactory,
totpService: TOTPService,
trustDeviceService: TrustDeviceService,
twoStepLoginService: TwoStepLoginService,
@ -288,6 +293,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
self.systemDevice = systemDevice
self.timeProvider = timeProvider
self.tokenService = tokenService
self.totpExpirationManagerFactory = totpExpirationManagerFactory
self.totpService = totpService
self.trustDeviceService = trustDeviceService
self.twoStepLoginService = twoStepLoginService
@ -323,6 +329,8 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
)
let timeProvider = CurrentTime()
let totpExpirationManagerFactory = DefaultTOTPExpirationManagerFactory(timeProvider: timeProvider)
let stateService = DefaultStateService(
appSettingsStore: appSettingsStore,
dataStore: dataStore,
@ -608,9 +616,11 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
)
#endif
let credentialIdentityFactory = DefaultCredentialIdentityFactory()
let autofillCredentialService = DefaultAutofillCredentialService(
cipherService: cipherService,
clientService: clientService,
credentialIdentityFactory: credentialIdentityFactory,
errorReporter: errorReporter,
eventService: eventService,
fido2CredentialStore: fido2CredentialStore,
@ -695,6 +705,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
systemDevice: UIDevice.current,
timeProvider: timeProvider,
tokenService: tokenService,
totpExpirationManagerFactory: totpExpirationManagerFactory,
totpService: totpService,
trustDeviceService: trustDeviceService,
twoStepLoginService: twoStepLoginService,

View File

@ -36,6 +36,7 @@ typealias Services = HasAPIService
& HasSettingsRepository
& HasStateService
& HasSystemDevice
& HasTOTPExpirationManagerFactory
& HasTOTPService
& HasTimeProvider
& HasTrustDeviceService
@ -296,6 +297,13 @@ protocol HasTimeProvider {
var timeProvider: TimeProvider { get }
}
/// Protocol for an object that provides a `TOTPExpirationManagerFactory`.
///
protocol HasTOTPExpirationManagerFactory {
/// Factory to create TOTP expiration managers.
var totpExpirationManagerFactory: TOTPExpirationManagerFactory { get }
}
/// Protocol for an object that provides a `TOTPService`.
///
protocol HasTOTPService {

View File

@ -43,6 +43,7 @@ extension ServiceContainer {
timeProvider: TimeProvider = MockTimeProvider(.currentTime),
trustDeviceService: TrustDeviceService = MockTrustDeviceService(),
tokenService: TokenService = MockTokenService(),
totpExpirationManagerFactory: TOTPExpirationManagerFactory = MockTOTPExpirationManagerFactory(),
totpService: TOTPService = MockTOTPService(),
twoStepLoginService: TwoStepLoginService = MockTwoStepLoginService(),
vaultRepository: VaultRepository = MockVaultRepository(),
@ -91,6 +92,7 @@ extension ServiceContainer {
systemDevice: systemDevice,
timeProvider: timeProvider,
tokenService: tokenService,
totpExpirationManagerFactory: totpExpirationManagerFactory,
totpService: totpService,
trustDeviceService: trustDeviceService,
twoStepLoginService: twoStepLoginService,

View File

@ -60,6 +60,7 @@ class MockVaultRepository: VaultRepository {
var organizationsPublisherError: Error?
var organizationsSubject = CurrentValueSubject<[Organization], Error>([])
var refreshTOTPCodesCalled = false
var refreshTOTPCodesResult: Result<[VaultListItem], Error> = .success([])
var refreshedTOTPTime: Date?
var refreshedTOTPCodes: [VaultListItem] = []
@ -216,6 +217,7 @@ class MockVaultRepository: VaultRepository {
}
func refreshTOTPCodes(for items: [BitwardenShared.VaultListItem]) async throws -> [BitwardenShared.VaultListItem] {
refreshTOTPCodesCalled = true
refreshedTOTPTime = timeProvider.presentTime
refreshedTOTPCodes = items
return try refreshTOTPCodesResult.get()

View File

@ -1223,7 +1223,11 @@ extension DefaultVaultRepository: VaultRepository {
rpID: String?,
uri: String?
) async throws -> AsyncThrowingPublisher<AnyPublisher<[VaultListSection], Error>> {
try await Publishers.CombineLatest(
if mode == .totp {
return try await totpCiphersAutofillPublisher()
}
return try await Publishers.CombineLatest(
cipherService.ciphersPublisher(),
availableFido2CredentialsPublisher
)
@ -1384,43 +1388,57 @@ extension DefaultVaultRepository: VaultRepository {
rpID: String?,
searchText: String?
) async throws -> [VaultListSection] {
guard mode != .combinedSingleSection else {
switch mode {
case .combinedMultipleSections, .passwords:
var sections = [VaultListSection]()
if #available(iOSApplicationExtension 17.0, *),
let fido2Section = try await loadAutofillFido2Section(
availableFido2Credentials: availableFido2Credentials,
mode: mode,
rpID: rpID,
searchText: searchText,
searchResults: searchText != nil ? ciphers : nil
) {
sections.append(fido2Section)
} else if ciphers.isEmpty {
return []
}
let sectionName = getAutofillPasswordsSectionName(
mode: mode,
rpID: rpID,
searchText: searchText
)
sections.append(
VaultListSection(
id: sectionName,
items: ciphers.compactMap { .init(cipherView: $0) },
name: sectionName
)
)
return sections
case .combinedSingleSection:
guard !ciphers.isEmpty else {
return []
}
let section = try await createAutofillListCombinedSingleSection(from: ciphers)
return [section]
case .totp:
let totpVaultListItems = try await totpListItems(from: ciphers, filter: .allVaults)
guard !totpVaultListItems.isEmpty else {
return []
}
return [
VaultListSection(
id: "",
items: totpVaultListItems,
name: ""
),
]
}
var sections = [VaultListSection]()
if #available(iOSApplicationExtension 17.0, *),
let fido2Section = try await loadAutofillFido2Section(
availableFido2Credentials: availableFido2Credentials,
mode: mode,
rpID: rpID,
searchText: searchText,
searchResults: searchText != nil ? ciphers : nil
) {
sections.append(fido2Section)
} else if ciphers.isEmpty {
return []
}
let sectionName = getAutofillPasswordsSectionName(
mode: mode,
rpID: rpID,
searchText: searchText
)
sections.append(
VaultListSection(
id: sectionName,
items: ciphers.compactMap { .init(cipherView: $0) },
name: sectionName
)
)
return sections
}
/// Creates the single vault list section for passwords + Fido2 credentials.
@ -1536,4 +1554,34 @@ extension DefaultVaultRepository: VaultRepository {
name: Localizations.passkeysForX(searchText ?? rpID)
)
}
/// Gets a publisher with Totp cipher items in a single section.
/// - Returns: The publisher with the vault list section with the totp items.
private func totpCiphersAutofillPublisher(
) async throws -> AsyncThrowingPublisher<AnyPublisher<[VaultListSection], Error>> {
try await cipherService.ciphersPublisher()
.asyncTryMap { ciphers in
try await ciphers.filter { cipher in
cipher.deletedDate == nil && cipher.login?.totp != nil
}
.asyncMap { cipher in
try await self.clientService.vault().ciphers().decrypt(cipher: cipher)
}
}
.asyncTryMap { cipherViews in
let totpVaultListItems = try await self.totpListItems(from: cipherViews, filter: nil)
guard !totpVaultListItems.isEmpty else {
return []
}
return [
VaultListSection(
id: "",
items: totpVaultListItems,
name: ""
),
]
}
.eraseToAnyPublisher()
.values
}
} // swiftlint:disable:this file_length

View File

@ -512,6 +512,97 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b
}
}
/// `ciphersAutofillPublisher(availableFido2CredentialsPublisher:mode:rpID:uri:)`
/// returns a publisher for the list of a user's ciphers matching a URI in `.totp` mode.
func test_ciphersAutofillPublisher_mode_totp() async throws {
let ciphers: [Cipher] = [
.fixture(
id: "1",
login: .fixture(
uris: [
.fixture(
uri: "https://bitwarden.com",
match: .exact
),
]
),
name: "Bitwarden"
),
.fixture(
creationDate: Date(year: 2024, month: 1, day: 1),
id: "2",
login: .fixture(
uris: [
.fixture(
uri: "https://example.com",
match: .exact
),
],
totp: "123"
),
name: "Example",
revisionDate: Date(year: 2024, month: 1, day: 1)
),
]
cipherService.ciphersSubject.value = ciphers
var iterator = try await subject.ciphersAutofillPublisher(
availableFido2CredentialsPublisher: MockFido2UserInterfaceHelper()
.availableCredentialsForAuthenticationPublisher(),
mode: .totp,
rpID: nil,
uri: "https://example.com"
).makeAsyncIterator()
let publishedSections = try await iterator.next()
try assertInlineSnapshot(of: dumpVaultListSections(XCTUnwrap(publishedSections)), as: .lines) {
"""
Section:
- TOTP: 2 Example 123 456
"""
}
}
/// `ciphersAutofillPublisher(availableFido2CredentialsPublisher:mode:rpID:uri:)`
/// doesn't return the item on `.totp` mode because of Totp generation throwing.
func test_ciphersAutofillPublisher_mode_totpThrowsOnGeneration() async throws {
let ciphers: [Cipher] = [
.fixture(
creationDate: Date(year: 2024, month: 1, day: 1),
id: "2",
login: .fixture(
uris: [
.fixture(
uri: "https://example.com",
match: .exact
),
],
totp: "123"
),
name: "Example",
revisionDate: Date(year: 2024, month: 1, day: 1)
),
]
cipherService.ciphersSubject.value = ciphers
clientService.mockVault.generateTOTPCodeResult = .failure(BitwardenTestError.example)
var iterator = try await subject.ciphersAutofillPublisher(
availableFido2CredentialsPublisher: MockFido2UserInterfaceHelper()
.availableCredentialsForAuthenticationPublisher(),
mode: .totp,
rpID: nil,
uri: "https://example.com"
).makeAsyncIterator()
let publishedSections = try await iterator.next()
let sections = try XCTUnwrap(publishedSections)
XCTAssertTrue(sections.isEmpty)
XCTAssertEqual(
errorReporter.errors as? [TOTPServiceError],
[.unableToGenerateCode("Unable to create TOTP code for key 123 for cipher id 2")]
)
}
/// `deleteCipher()` throws on id errors.
func test_deleteCipher_idError_nil() async throws {
cipherService.deleteCipherWithServerResult = .failure(CipherAPIServiceError.updateMissingId)
@ -1337,6 +1428,86 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b
XCTAssertEqual(sections, [VaultListSection(id: "", items: [VaultListItem(cipherView: cipherView)!], name: "")])
}
/// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)`
/// returns search matching cipher name in `.totp` mode.
func test_searchCipherAutofillPublisher_mode_totp() async throws {
stateService.activeAccount = .fixtureAccountLogin()
let ciphers = [
Cipher.fixture(id: "1", name: "dabcd", type: .login),
Cipher.fixture(id: "2", name: "qwe", type: .login),
Cipher.fixture(id: "3", name: "Café", type: .login),
Cipher.fixture(
id: "4",
login: .fixture(
totp: "123"
),
name: "Cafffffffe",
type: .login
),
]
cipherService.ciphersSubject.value = ciphers
var iterator = try await subject
.searchCipherAutofillPublisher(
availableFido2CredentialsPublisher: fido2UserInterfaceHelper
.availableCredentialsForAuthenticationPublisher(),
mode: .totp,
filterType: .allVaults,
rpID: nil,
searchText: "caf"
)
.makeAsyncIterator()
let sectionsResult = try await iterator.next()
let sections = try XCTUnwrap(sectionsResult)
assertInlineSnapshot(of: dumpVaultListSections(sections), as: .lines) {
"""
Section:
- TOTP: 4 Cafffffffe 123 456
"""
}
}
/// `searchCipherAutofillPublisher(availableFido2CredentialsPublisher:mode:filterType:rpID:searchText:)`
/// returns empty items in `.totp` mode when totp generation throws.
func test_searchCipherAutofillPublisher_mode_totpGenerationThrows() async throws {
stateService.activeAccount = .fixtureAccountLogin()
let ciphers = [
Cipher.fixture(id: "1", name: "dabcd", type: .login),
Cipher.fixture(id: "2", name: "qwe", type: .login),
Cipher.fixture(id: "3", name: "Café", type: .login),
Cipher.fixture(
id: "4",
login: .fixture(
totp: "123"
),
name: "Cafffffffe",
type: .login
),
]
cipherService.ciphersSubject.value = ciphers
clientService.mockVault.generateTOTPCodeResult = .failure(BitwardenTestError.example)
var iterator = try await subject
.searchCipherAutofillPublisher(
availableFido2CredentialsPublisher: fido2UserInterfaceHelper
.availableCredentialsForAuthenticationPublisher(),
mode: .totp,
filterType: .allVaults,
rpID: nil,
searchText: "caf"
)
.makeAsyncIterator()
let sectionsResult = try await iterator.next()
let sections = try XCTUnwrap(sectionsResult)
XCTAssertTrue(sections.isEmpty)
XCTAssertEqual(
errorReporter.errors as? [TOTPServiceError],
[.unableToGenerateCode("Unable to create TOTP code for key 123 for cipher id 4")]
)
}
/// `searchVaultListPublisher(searchText:, filterType:)` returns search matching cipher name.
func test_searchVaultListPublisher_searchText_name() async throws {
stateService.activeAccount = .fixtureAccountLogin()

View File

@ -2,9 +2,9 @@ import AuthenticationServices
import Combine
/// A delegate that is used to handle actions and retrieve information from within an Autofill extension
/// on Fido2 flows.
/// on credential provider flows.
@MainActor
public protocol Fido2AppExtensionDelegate: AppExtensionDelegate {
public protocol AutofillAppExtensionDelegate: AppExtensionDelegate {
/// The mode in which the autofill extension is running.
var extensionMode: AutofillExtensionMode { get }
@ -16,6 +16,11 @@ public protocol Fido2AppExtensionDelegate: AppExtensionDelegate {
@available(iOSApplicationExtension 17.0, *)
func completeAssertionRequest(assertionCredential: ASPasskeyAssertionCredential)
/// Completes the autofill OTP request with the specified code.
/// - Parameter code: The code to autofill.
@available(iOSApplicationExtension 18.0, *)
func completeOTPRequest(code: String)
/// Completes the registration request with a Fido2 credential
/// - Parameter asPasskeyRegistrationCredential: The passkey credential to be used to complete the registration.
@available(iOSApplicationExtension 17.0, *)
@ -28,12 +33,14 @@ public protocol Fido2AppExtensionDelegate: AppExtensionDelegate {
func setUserInteractionRequired()
}
extension Fido2AppExtensionDelegate {
extension AutofillAppExtensionDelegate {
/// Gets the mode in which the autofill list should run.
var autofillListMode: AutofillListMode {
switch extensionMode {
case .autofillFido2VaultList:
.combinedMultipleSections
case .autofillOTP:
.totp
case .registerFido2Credential:
.combinedSingleSection
default:

View File

@ -4,17 +4,17 @@ import XCTest
@testable import BitwardenShared
@available(iOS 17.0, *)
class Fido2AppExtensionDelegateTests: BitwardenTestCase {
class AutofillAppExtensionDelegateTests: BitwardenTestCase {
// MARK: Properties
var subject: MockFido2AppExtensionDelegate!
var subject: MockAutofillAppExtensionDelegate!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
subject = MockFido2AppExtensionDelegate()
subject = MockAutofillAppExtensionDelegate()
}
override func tearDown() {
@ -31,6 +31,9 @@ class Fido2AppExtensionDelegateTests: BitwardenTestCase {
subject.extensionMode = .autofillFido2VaultList([], MockPasskeyCredentialRequestParameters())
XCTAssertEqual(subject.autofillListMode, .combinedMultipleSections)
subject.extensionMode = .autofillOTP([])
XCTAssertEqual(subject.autofillListMode, .totp)
subject.extensionMode = .registerFido2Credential(ASPasskeyCredentialRequest.fixture())
XCTAssertEqual(subject.autofillListMode, .combinedSingleSection)

View File

@ -5,8 +5,9 @@ import Foundation
@testable import BitwardenShared
@available(iOS 17.0, *)
class MockFido2AppExtensionDelegate: MockAppExtensionDelegate, Fido2AppExtensionDelegate {
class MockAutofillAppExtensionDelegate: MockAppExtensionDelegate, AutofillAppExtensionDelegate {
var completeAssertionRequestMocker = InvocationMocker<ASPasskeyAssertionCredential>()
var completeOTPRequestCodeCalled: String?
var completeRegistrationRequestMocker = InvocationMocker<ASPasskeyRegistrationCredential>()
var extensionMode: AutofillExtensionMode = .configureAutofill
var didAppearPublisher = CurrentValueSubject<Bool, Never>(false)
@ -18,6 +19,10 @@ class MockFido2AppExtensionDelegate: MockAppExtensionDelegate, Fido2AppExtension
completeAssertionRequestMocker.invoke(param: assertionCredential)
}
func completeOTPRequest(code: String) {
completeOTPRequestCodeCalled = code
}
func completeRegistrationRequest(asPasskeyRegistrationCredential: ASPasskeyRegistrationCredential) {
completeRegistrationRequestMocker.invoke(param: asPasskeyRegistrationCredential)
}

View File

@ -10,7 +10,7 @@ import XCTest
class AppCoordinatorFido2Tests: BitwardenTestCase {
// MARK: Properties
var appExtensionDelegate: MockFido2AppExtensionDelegate!
var appExtensionDelegate: MockAutofillAppExtensionDelegate!
var module: MockAppModule!
var rootNavigator: MockRootNavigator!
var router: MockRouter<AuthEvent, AuthRoute>!
@ -22,7 +22,7 @@ class AppCoordinatorFido2Tests: BitwardenTestCase {
override func setUp() {
super.setUp()
appExtensionDelegate = MockFido2AppExtensionDelegate()
appExtensionDelegate = MockAutofillAppExtensionDelegate()
router = MockRouter(routeForEvent: { _ in .landing })
module = MockAppModule()
module.authRouter = router

View File

@ -137,8 +137,8 @@ class AppCoordinator: Coordinator, HasRootNavigator {
// To fix this we show a transparent navigation controller which makes the
// biometric prompt work again.
if route == .completeWithNeverUnlockKey,
let fido2AppExtensionDelegate = appExtensionDelegate as? Fido2AppExtensionDelegate,
case .autofillFido2Credential = fido2AppExtensionDelegate.extensionMode {
let autofillAppExtensionDelegate = appExtensionDelegate as? AutofillAppExtensionDelegate,
case .autofillFido2Credential = autofillAppExtensionDelegate.extensionMode {
showTransparentController()
didCompleteAuth(rehydratableTarget: nil)
return

View File

@ -11,7 +11,7 @@ import XCTest
class AppProcessorFido2Tests: BitwardenTestCase {
// MARK: Properties
var appExtensionDelegate: MockFido2AppExtensionDelegate!
var appExtensionDelegate: MockAutofillAppExtensionDelegate!
var appModule: MockAppModule!
var authRepository: MockAuthRepository!
var autofillCredentialService: MockAutofillCredentialService!
@ -36,7 +36,7 @@ class AppProcessorFido2Tests: BitwardenTestCase {
super.setUp()
router = MockRouter(routeForEvent: { _ in .landing })
appExtensionDelegate = MockFido2AppExtensionDelegate()
appExtensionDelegate = MockAutofillAppExtensionDelegate()
appModule = MockAppModule()
authRepository = MockAuthRepository()
autofillCredentialService = MockAutofillCredentialService()

View File

@ -586,20 +586,20 @@ extension AppProcessor: AutofillCredentialServiceDelegate {
extension AppProcessor: Fido2UserInterfaceHelperDelegate {
var isAutofillingFromList: Bool {
guard let fido2AppExtensionDelegate = appExtensionDelegate as? Fido2AppExtensionDelegate,
fido2AppExtensionDelegate.isAutofillingFido2CredentialFromList else {
guard let autofillAppExtensionDelegate = appExtensionDelegate as? AutofillAppExtensionDelegate,
autofillAppExtensionDelegate.isAutofillingFido2CredentialFromList else {
return false
}
return true
}
func onNeedsUserInteraction() async throws {
guard let fido2AppExtensionDelegate = appExtensionDelegate as? Fido2AppExtensionDelegate else {
guard let autofillAppExtensionDelegate = appExtensionDelegate as? AutofillAppExtensionDelegate else {
return
}
if !fido2AppExtensionDelegate.flowWithUserInteraction {
fido2AppExtensionDelegate.setUserInteractionRequired()
if !autofillAppExtensionDelegate.flowWithUserInteraction {
autofillAppExtensionDelegate.setUserInteractionRequired()
throw Fido2Error.userInteractionRequired
}
@ -607,7 +607,7 @@ extension AppProcessor: Fido2UserInterfaceHelperDelegate {
// action that needs user interaction or it might not show the prompt to the user.
// E.g. without this there are certain devices that don't show the FaceID prompt
// and the user only sees the screen dimming a bit and failing the flow.
for await didAppear in fido2AppExtensionDelegate.getDidAppearPublisher() {
for await didAppear in autofillAppExtensionDelegate.getDidAppearPublisher() {
guard didAppear else { continue }
return
}

View File

@ -241,6 +241,18 @@ extension CipherView {
revisionDate: revisionDate
)
}
static func totpFixture(
id: String = "8675",
name: String = "Bitwarden",
totp: String = "1234"
) -> CipherView {
.loginFixture(
id: id,
login: .fixture(totp: totp),
name: name
)
}
}
extension Collection {

View File

@ -0,0 +1,27 @@
/// A protocol to work with processors that have TOTP sections.
protocol HasTOTPCodesSections {
/// The repository used by the application to manage vault data for the UI layer.
var vaultRepository: VaultRepository { get }
/// Refreshes the TOTP Codes from items in sections using the corresponding manager.
func refreshTOTPCodes(
for items: [VaultListItem],
in sections: [VaultListSection],
using manager: TOTPExpirationManager?
) async throws -> [VaultListSection]
}
/// Extension of the `HasTOTPCodesSections` protocol for some common behavior.
extension HasTOTPCodesSections {
func refreshTOTPCodes(
for items: [VaultListItem],
in sections: [VaultListSection],
using manager: TOTPExpirationManager?
) async throws -> [VaultListSection] {
let refreshedItems = try await vaultRepository.refreshTOTPCodes(for: items)
let updatedSections = sections.updated(with: refreshedItems)
let allItems = updatedSections.flatMap(\.items)
manager?.configureTOTPRefreshScheduling(for: allItems)
return updatedSections
}
}

View File

@ -0,0 +1,108 @@
import Foundation
/// A protocol to manage TOTP code expirations for `VaultListItem`s and batch refresh calls.
///
protocol TOTPExpirationManager {
// MARK: Properties
/// A closure to call on expiration
///
var onExpiration: (([VaultListItem]) -> Void)? { get }
// MARK: Methods
/// Removes any outstanding timers
///
func cleanup()
/// Configures TOTP code refresh scheduling
///
/// - Parameter items: The vault list items that may require code expiration tracking.
///
func configureTOTPRefreshScheduling(for items: [VaultListItem])
}
/// A class to manage TOTP code expirations for `VaultListItem`s and batch refresh calls.
///
class DefaultTOTPExpirationManager: TOTPExpirationManager {
// MARK: Properties
/// A closure to call on expiration
///
var onExpiration: (([VaultListItem]) -> Void)?
// MARK: Private Properties
/// All items managed by the object, grouped by TOTP period.
///
private(set) var itemsByInterval = [UInt32: [VaultListItem]]()
/// A model to provide time to calculate the countdown.
///
private var timeProvider: any TimeProvider
/// A timer that triggers `checkForExpirations` to manage code expirations.
///
private var updateTimer: Timer?
/// Initializes a new countdown timer
///
/// - Parameters
/// - timeProvider: A protocol providing the present time as a `Date`.
/// Used to calculate time remaining for a present TOTP code.
/// - onExpiration: A closure to call on code expiration for a list of vault items.
///
init(
timeProvider: any TimeProvider,
onExpiration: (([VaultListItem]) -> Void)?
) {
self.timeProvider = timeProvider
self.onExpiration = onExpiration
updateTimer = Timer.scheduledTimer(
withTimeInterval: 0.25,
repeats: true,
block: { _ in
self.checkForExpirations()
}
)
}
/// Clear out any timers tracking TOTP code expiration
deinit {
cleanup()
}
// MARK: Methods
func configureTOTPRefreshScheduling(for items: [VaultListItem]) {
var newItemsByInterval = [UInt32: [VaultListItem]]()
items.forEach { item in
guard case let .totp(_, model) = item.itemType else { return }
newItemsByInterval[model.totpCode.period, default: []].append(item)
}
itemsByInterval = newItemsByInterval
}
func cleanup() {
updateTimer?.invalidate()
updateTimer = nil
}
// MARK: Private
private func checkForExpirations() {
var expired = [VaultListItem]()
var notExpired = [UInt32: [VaultListItem]]()
itemsByInterval.forEach { period, items in
let sortedItems: [Bool: [VaultListItem]] = TOTPExpirationCalculator.listItemsByExpiration(
items,
timeProvider: timeProvider
)
expired.append(contentsOf: sortedItems[true] ?? [])
notExpired[period] = sortedItems[false]
}
itemsByInterval = notExpired
guard !expired.isEmpty else { return }
onExpiration?(expired)
}
}

View File

@ -0,0 +1,23 @@
/// Protocol to create `TOTPExpirationManager`.
protocol TOTPExpirationManagerFactory {
/// Creates a `TOTPExpirationManager` passing the `onExpiration` closure.
/// - Parameter onExpiration: Closure to execute on expiration.
/// - Returns: A `TOTPExpirationManager` configured with the given closure.
func create(onExpiration: (([VaultListItem]) -> Void)?) -> TOTPExpirationManager
}
/// The default implementation of `TOTPExpirationManagerFactory`.
class DefaultTOTPExpirationManagerFactory: TOTPExpirationManagerFactory {
/// The service used to get the present time.
var timeProvider: TimeProvider
/// Initializes a `DefaultTOTPExpirationManagerFactory`.
/// - Parameter timeProvider: The service used to get the present time.
init(timeProvider: TimeProvider) {
self.timeProvider = timeProvider
}
func create(onExpiration: (([VaultListItem]) -> Void)?) -> TOTPExpirationManager {
DefaultTOTPExpirationManager(timeProvider: timeProvider, onExpiration: onExpiration)
}
}

View File

@ -0,0 +1,45 @@
import XCTest
@testable import BitwardenShared
class TOTPExpirationManagerFactoryTests: BitwardenTestCase {
// MARK: Properties
var timeProvider: MockTimeProvider!
var subject: DefaultTOTPExpirationManagerFactory!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
timeProvider = MockTimeProvider(.currentTime)
subject = DefaultTOTPExpirationManagerFactory(
timeProvider: timeProvider
)
}
override func tearDown() {
super.tearDown()
timeProvider = nil
subject = nil
}
// MARK: Tests
/// `create(onExpiration:)` creates a `DefaultTOTPExpirationManager` with the
/// given expiration closure.
func test_create() {
var called = false
let expirationClosure: ([VaultListItem]) -> Void = { _ in
called = true
}
let result = subject.create(onExpiration: expirationClosure)
XCTAssertNotNil(result as? DefaultTOTPExpirationManager)
if let onExpiration = result.onExpiration {
onExpiration([])
}
XCTAssertTrue(called)
}
}

View File

@ -0,0 +1,15 @@
@testable import BitwardenShared
class MockTOTPExpirationManager: TOTPExpirationManager {
var cleanupCalled = false
var configuredTOTPRefreshSchedulingItems: [VaultListItem]?
var onExpiration: (([VaultListItem]) -> Void)?
func cleanup() {
cleanupCalled = true
}
func configureTOTPRefreshScheduling(for items: [VaultListItem]) {
configuredTOTPRefreshSchedulingItems = items
}
}

View File

@ -0,0 +1,13 @@
@testable import BitwardenShared
class MockTOTPExpirationManagerFactory: TOTPExpirationManagerFactory {
var createTimesCalled: Int = 0
var createResults: [TOTPExpirationManager] = []
var onExpirationClosures: [(([VaultListItem]) -> Void)?] = []
func create(onExpiration: (([VaultListItem]) -> Void)?) -> TOTPExpirationManager {
defer { createTimesCalled += 1 }
onExpirationClosures.append(onExpiration)
return createResults[createTimesCalled]
}
}

View File

@ -12,7 +12,7 @@ import XCTest
class VaultAutofillListProcessorFido2Tests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Properties
var appExtensionDelegate: MockFido2AppExtensionDelegate!
var appExtensionDelegate: MockAutofillAppExtensionDelegate!
var authRepository: MockAuthRepository!
var autofillCredentialService: MockAutofillCredentialService!
var clientService: MockClientService!
@ -29,7 +29,7 @@ class VaultAutofillListProcessorFido2Tests: BitwardenTestCase { // swiftlint:dis
override func setUp() {
super.setUp()
appExtensionDelegate = MockFido2AppExtensionDelegate()
appExtensionDelegate = MockAutofillAppExtensionDelegate()
authRepository = MockAuthRepository()
autofillCredentialService = MockAutofillCredentialService()
clientService = MockClientService()

View File

@ -0,0 +1,398 @@
// swiftlint:disable:this file_name
import BitwardenSdk
import XCTest
@testable import BitwardenShared
@available(iOS 17.0, *)
class VaultAutofillListProcessorTotpTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Properties
var appExtensionDelegate: MockAutofillAppExtensionDelegate!
var authRepository: MockAuthRepository!
var clientService: MockClientService!
var coordinator: MockCoordinator<VaultRoute, AuthAction>!
var errorReporter: MockErrorReporter!
var fido2CredentialStore: MockFido2CredentialStore!
var fido2UserInterfaceHelper: MockFido2UserInterfaceHelper!
var stateService: MockStateService!
var subject: VaultAutofillListProcessor!
var totpExpirationManagerForItems: MockTOTPExpirationManager!
var totpExpirationManagerForSearchItems: MockTOTPExpirationManager!
var totpExpirationManagerFactory: MockTOTPExpirationManagerFactory!
var vaultRepository: MockVaultRepository!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
appExtensionDelegate = MockAutofillAppExtensionDelegate()
appExtensionDelegate.extensionMode = .autofillOTP([
.fixture(),
])
authRepository = MockAuthRepository()
clientService = MockClientService()
coordinator = MockCoordinator()
errorReporter = MockErrorReporter()
fido2CredentialStore = MockFido2CredentialStore()
fido2UserInterfaceHelper = MockFido2UserInterfaceHelper()
stateService = MockStateService()
totpExpirationManagerForItems = MockTOTPExpirationManager()
totpExpirationManagerForSearchItems = MockTOTPExpirationManager()
totpExpirationManagerFactory = MockTOTPExpirationManagerFactory()
totpExpirationManagerFactory.createResults = [
totpExpirationManagerForItems,
totpExpirationManagerForSearchItems,
]
vaultRepository = MockVaultRepository()
subject = VaultAutofillListProcessor(
appExtensionDelegate: appExtensionDelegate,
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
authRepository: authRepository,
clientService: clientService,
errorReporter: errorReporter,
fido2CredentialStore: fido2CredentialStore,
fido2UserInterfaceHelper: fido2UserInterfaceHelper,
stateService: stateService,
totpExpirationManagerFactory: totpExpirationManagerFactory,
vaultRepository: vaultRepository
),
state: VaultAutofillListState()
)
}
override func tearDown() {
super.tearDown()
appExtensionDelegate = nil
authRepository = nil
clientService = nil
coordinator = nil
errorReporter = nil
fido2CredentialStore = nil
fido2UserInterfaceHelper = nil
stateService = nil
subject = nil
totpExpirationManagerFactory = nil
vaultRepository = nil
}
// MARK: Tests
/// `init(appExtensionDelegate:coordinator:services:state:)` initializes
/// the state with totp.
@MainActor
func test_init() {
XCTAssertTrue(subject.state.isAutofillingTotpList)
XCTAssertEqual(totpExpirationManagerFactory.createTimesCalled, 2)
}
/// `perform(_:)` with `.search()` performs a cipher search and updates the state with the results.
/// Also it configures TOTP refresh scheduling.
@MainActor
func test_perform_searchWithTOTPRefreshScheduling() {
let items = [
VaultListItem(
id: "1",
itemType: .totp(name: "test1", totpModel: VaultListTOTP.fixture(id: "1"))
),
VaultListItem(
id: "2",
itemType: .totp(name: "test2", totpModel: VaultListTOTP.fixture(id: "2"))
),
]
let expectedSection = VaultListSection(
id: "",
items: items,
name: ""
)
vaultRepository.searchCipherAutofillSubject.value = [expectedSection]
let task = Task {
await subject.perform(.search("Bit"))
}
waitFor(!subject.state.ciphersForSearch.isEmpty)
task.cancel()
XCTAssertEqual(subject.state.ciphersForSearch, [expectedSection])
XCTAssertFalse(subject.state.showNoResults)
XCTAssertEqual(totpExpirationManagerForSearchItems.configuredTOTPRefreshSchedulingItems?.count, 2)
}
/// `perform(_:)` with `.streamAutofillItems` streams the list of autofill ciphers and configures
/// TOTP refresh scheduling.
@MainActor
func test_perform_streamAutofillItemsWithTOTPRefreshScheduling() {
let items = [
VaultListItem(
id: "1",
itemType: .totp(name: "test1", totpModel: VaultListTOTP.fixture(id: "1"))
),
VaultListItem(
id: "2",
itemType: .totp(name: "test2", totpModel: VaultListTOTP.fixture(id: "2"))
),
]
let expectedSection = VaultListSection(
id: "",
items: items,
name: ""
)
vaultRepository.ciphersAutofillSubject.value = [expectedSection]
let task = Task {
await subject.perform(.streamAutofillItems)
}
waitFor(!subject.state.vaultListSections.isEmpty)
task.cancel()
XCTAssertEqual(subject.state.vaultListSections, [expectedSection])
XCTAssertEqual(totpExpirationManagerForItems.configuredTOTPRefreshSchedulingItems?.count, 2)
}
/// `refreshTOTPCodes(for:)` is called from the TOTP expiration manager expiration closure
/// and refreshes the vault list sections.
@MainActor
func test_refreshTOTPCodes_forItems() { // swiftlint:disable:this function_body_length
let items = [
VaultListItem(
id: "1",
itemType: .totp(name: "test1", totpModel: VaultListTOTP.fixture(id: "1"))
),
VaultListItem(
id: "2",
itemType: .totp(name: "test2", totpModel: VaultListTOTP.fixture(id: "2"))
),
]
subject.state.vaultListSections = [
VaultListSection(
id: "",
items: items,
name: ""
),
]
let refreshedItems = [
VaultListItem(
id: "2",
itemType: .totp(name: "test2", totpModel: VaultListTOTP.fixture(
id: "2",
totpCode: .init(
code: "456789",
codeGenerationDate: Date(),
period: 30
)
))
),
]
vaultRepository.refreshTOTPCodesResult = .success(refreshedItems)
guard let onExpiration = totpExpirationManagerFactory.onExpirationClosures[0] else {
XCTFail("There is no onExpiration closure for the first item in the factory")
return
}
onExpiration(items.filter { $0.id == "2" })
waitFor(totpExpirationManagerForItems.configuredTOTPRefreshSchedulingItems?.count == 2)
XCTAssertEqual(subject.state.vaultListSections.count, 1)
XCTAssertEqual(subject.state.vaultListSections[0].items.count, 2)
let totpItem0 = subject.state.vaultListSections[0].items[0]
guard case let .totp(name0, totpModel0) = totpItem0.itemType else {
XCTFail("There is no TOTP item in the first section first item.")
return
}
XCTAssertEqual(name0, "test1")
XCTAssertEqual(totpModel0.totpCode.code, "123456")
let totpItem1 = subject.state.vaultListSections[0].items[1]
guard case let .totp(name1, totpModel1) = totpItem1.itemType else {
XCTFail("There is no TOTP item in first section second item.")
return
}
XCTAssertEqual(name1, "test2")
XCTAssertEqual(totpModel1.totpCode.code, "456789")
}
/// `refreshTOTPCodes(for:)` does nothing if vault list sections are empty..
@MainActor
func test_refreshTOTPCodes_forItemsEmpty() {
subject.state.vaultListSections = []
guard let onExpiration = totpExpirationManagerFactory.onExpirationClosures[0] else {
XCTFail("There is no onExpiration closure for the first item in the factory")
return
}
onExpiration([])
XCTAssertFalse(vaultRepository.refreshTOTPCodesCalled)
}
/// `refreshTOTPCodes(for:)` logs when refreshing throws.
@MainActor
func test_refreshTOTPCodes_forItemsThrows() {
let items = [
VaultListItem(
id: "1",
itemType: .totp(name: "test1", totpModel: VaultListTOTP.fixture(id: "1"))
),
VaultListItem(
id: "2",
itemType: .totp(name: "test2", totpModel: VaultListTOTP.fixture(id: "2"))
),
]
subject.state.vaultListSections = [
VaultListSection(
id: "",
items: items,
name: ""
),
]
vaultRepository.refreshTOTPCodesResult = .failure(BitwardenTestError.example)
guard let onExpiration = totpExpirationManagerFactory.onExpirationClosures[0] else {
XCTFail("There is no onExpiration closure for the first item in the factory")
return
}
onExpiration(items.filter { $0.id == "2" })
waitFor(errorReporter.errors.last as? BitwardenTestError == BitwardenTestError.example)
}
/// `refreshTOTPCodes(searchItems:)` is called from the TOTP expiration manager expiration closure
/// and refreshes the search list sections.
@MainActor
func test_refreshTOTPCodes_searchItems() { // swiftlint:disable:this function_body_length
let items = [
VaultListItem(
id: "1",
itemType: .totp(name: "test1", totpModel: VaultListTOTP.fixture(id: "1"))
),
VaultListItem(
id: "2",
itemType: .totp(name: "test2", totpModel: VaultListTOTP.fixture(id: "2"))
),
]
subject.state.ciphersForSearch = [
VaultListSection(
id: "",
items: items,
name: ""
),
]
let refreshedItems = [
VaultListItem(
id: "2",
itemType: .totp(name: "test2", totpModel: VaultListTOTP.fixture(
id: "2",
totpCode: .init(
code: "456789",
codeGenerationDate: Date(),
period: 30
)
))
),
]
vaultRepository.refreshTOTPCodesResult = .success(refreshedItems)
guard let onExpiration = totpExpirationManagerFactory.onExpirationClosures[1] else {
XCTFail("There is no onExpiration closure for the second item in the factory")
return
}
onExpiration(items.filter { $0.id == "2" })
waitFor(totpExpirationManagerForSearchItems.configuredTOTPRefreshSchedulingItems?.count == 2)
XCTAssertEqual(subject.state.ciphersForSearch.count, 1)
XCTAssertEqual(subject.state.ciphersForSearch[0].items.count, 2)
let totpItem0 = subject.state.ciphersForSearch[0].items[0]
guard case let .totp(name0, totpModel0) = totpItem0.itemType else {
XCTFail("There is no TOTP item in the first section first item.")
return
}
XCTAssertEqual(name0, "test1")
XCTAssertEqual(totpModel0.totpCode.code, "123456")
let totpItem1 = subject.state.ciphersForSearch[0].items[1]
guard case let .totp(name1, totpModel1) = totpItem1.itemType else {
XCTFail("There is no TOTP item in first section second item.")
return
}
XCTAssertEqual(name1, "test2")
XCTAssertEqual(totpModel1.totpCode.code, "456789")
}
/// `refreshTOTPCodes(searchItems:)` does nothing if vault list sections are empty..
@MainActor
func test_refreshTOTPCodes_searchItemsEmpty() {
let items = [
VaultListItem(
id: "2",
itemType: .totp(name: "test2", totpModel: VaultListTOTP.fixture(id: "2"))
),
]
subject.state.ciphersForSearch = []
let refreshedItems = [
VaultListItem(
id: "2",
itemType: .totp(name: "test2", totpModel: VaultListTOTP.fixture(
id: "2",
totpCode: .init(
code: "456789",
codeGenerationDate: Date(),
period: 30
)
))
),
]
vaultRepository.refreshTOTPCodesResult = .success(refreshedItems)
guard let onExpiration = totpExpirationManagerFactory.onExpirationClosures[1] else {
XCTFail("There is no onExpiration closure for the second item in the factory")
return
}
onExpiration(items)
waitFor(totpExpirationManagerForSearchItems.configuredTOTPRefreshSchedulingItems?.isEmpty == true)
XCTAssertEqual(subject.state.ciphersForSearch.count, 1)
XCTAssertEqual(subject.state.ciphersForSearch[0].items.count, 0)
}
/// `refreshTOTPCodes(searchItems:)` logs when refreshing throws.
@MainActor
func test_refreshTOTPCodes_searchItemsThrows() {
let items = [
VaultListItem(
id: "1",
itemType: .totp(name: "test1", totpModel: VaultListTOTP.fixture(id: "1"))
),
VaultListItem(
id: "2",
itemType: .totp(name: "test2", totpModel: VaultListTOTP.fixture(id: "2"))
),
]
subject.state.ciphersForSearch = [
VaultListSection(
id: "",
items: items,
name: ""
),
]
vaultRepository.refreshTOTPCodesResult = .failure(BitwardenTestError.example)
guard let onExpiration = totpExpirationManagerFactory.onExpirationClosures[1] else {
XCTFail("There is no onExpiration closure for the second item in the factory")
return
}
onExpiration(items.filter { $0.id == "2" })
waitFor(errorReporter.errors.last as? BitwardenTestError == BitwardenTestError.example)
}
}

View File

@ -5,11 +5,11 @@ import AuthenticationServices
/// The processor used to manage state and handle actions for the autofill list screen.
///
class VaultAutofillListProcessor: StateProcessor<
class VaultAutofillListProcessor: StateProcessor<// swiftlint:disable:this type_body_length
VaultAutofillListState,
VaultAutofillListAction,
VaultAutofillListEffect
> {
>, HasTOTPCodesSections {
// MARK: Types
typealias Services = HasAuthRepository
@ -21,6 +21,7 @@ class VaultAutofillListProcessor: StateProcessor<
& HasFido2UserInterfaceHelper
& HasPasteboardService
& HasStateService
& HasTOTPExpirationManagerFactory
& HasTimeProvider
& HasVaultRepository
@ -35,6 +36,12 @@ class VaultAutofillListProcessor: StateProcessor<
/// The `Coordinator` that handles navigation.
private var coordinator: AnyCoordinator<VaultRoute, AuthAction>
/// An object to manage TOTP code expirations and batch refresh calls for the vault list items.
private var vaultItemsTotpExpirationManager: TOTPExpirationManager?
/// An object to manage TOTP code expirations and batch refresh calls for search results.
private var searchTotpExpirationManager: TOTPExpirationManager?
/// The services used by this processor.
private var services: Services
@ -42,13 +49,17 @@ class VaultAutofillListProcessor: StateProcessor<
/// Gets the mode in which this autofill list should run.
private var autofillListMode: AutofillListMode {
fido2AppExtensionDelegate?.autofillListMode ?? .passwords
autofillAppExtensionDelegate?.autofillListMode ?? .passwords
}
/// A delegate that is used to handle actions and retrieve information from within an Autofill extension
/// on Fido2 flows.
private var fido2AppExtensionDelegate: Fido2AppExtensionDelegate? {
appExtensionDelegate as? Fido2AppExtensionDelegate
private var autofillAppExtensionDelegate: AutofillAppExtensionDelegate? {
appExtensionDelegate as? AutofillAppExtensionDelegate
}
var vaultRepository: VaultRepository {
services.vaultRepository
}
// MARK: Initialization
@ -76,6 +87,19 @@ class VaultAutofillListProcessor: StateProcessor<
self.coordinator = coordinator
self.services = services
super.init(state: state)
if autofillListMode == .totp {
self.state.isAutofillingTotpList = true
initTotpExpirationManagers()
}
}
deinit {
vaultItemsTotpExpirationManager?.cleanup()
vaultItemsTotpExpirationManager = nil
searchTotpExpirationManager?.cleanup()
searchTotpExpirationManager = nil
}
// MARK: Methods
@ -86,8 +110,8 @@ class VaultAutofillListProcessor: StateProcessor<
switch vaultItem.itemType {
case let .cipher(cipher, fido2CredentialAutofillView):
if #available(iOSApplicationExtension 17.0, *),
let fido2AppExtensionDelegate,
fido2CredentialAutofillView != nil || fido2AppExtensionDelegate.isCreatingFido2Credential {
let autofillAppExtensionDelegate,
fido2CredentialAutofillView != nil || autofillAppExtensionDelegate.isCreatingFido2Credential {
await onCipherForFido2CredentialPicked(cipher: cipher)
} else {
await autofillHelper.handleCipherForAutofill(cipherView: cipher) { [weak self] toastText in
@ -96,7 +120,10 @@ class VaultAutofillListProcessor: StateProcessor<
}
case .group:
return
case .totp:
case let .totp(_, totpModel):
if #available(iOSApplicationExtension 18.0, *) {
autofillAppExtensionDelegate?.completeOTPRequest(code: totpModel.totpCode.code)
}
return
}
case .initFido2:
@ -125,8 +152,8 @@ class VaultAutofillListProcessor: StateProcessor<
guard #available(iOSApplicationExtension 17.0, *),
!fromToolbar,
let fido2AppExtensionDelegate,
fido2AppExtensionDelegate.isCreatingFido2Credential else {
let autofillAppExtensionDelegate,
autofillAppExtensionDelegate.isCreatingFido2Credential else {
coordinator.navigate(
to: .addItem(
allowTypeSelection: false,
@ -161,8 +188,8 @@ class VaultAutofillListProcessor: StateProcessor<
/// Creates a `NewCipherOptions` based on the context flow.
func createNewCipherOptions() -> NewCipherOptions {
if let fido2AppExtensionDelegate,
fido2AppExtensionDelegate.isCreatingFido2Credential,
if let autofillAppExtensionDelegate,
autofillAppExtensionDelegate.isCreatingFido2Credential,
let fido2CredentialNewView = services.fido2UserInterfaceHelper.fido2CredentialNewView {
return NewCipherOptions(
name: fido2CredentialNewView.rpName,
@ -209,6 +236,61 @@ class VaultAutofillListProcessor: StateProcessor<
}
}
/// Initilaizes the TOTP expiration managers so the TOTP codes are refreshed automatically.
func initTotpExpirationManagers() {
vaultItemsTotpExpirationManager = services.totpExpirationManagerFactory.create(
onExpiration: { [weak self] expiredItems in
guard let self else { return }
Task {
await self.refreshTOTPCodes(for: expiredItems)
}
}
)
searchTotpExpirationManager = services.totpExpirationManagerFactory.create(
onExpiration: { [weak self] expiredSearchItems in
guard let self else { return }
Task {
await self.refreshTOTPCodes(searchItems: expiredSearchItems)
}
}
)
}
/// Refreshes the vault group's TOTP Codes.
///
private func refreshTOTPCodes(for items: [VaultListItem]) async {
guard !state.vaultListSections.isEmpty else {
return
}
do {
state.vaultListSections = try await refreshTOTPCodes(
for: items,
in: state.vaultListSections,
using: vaultItemsTotpExpirationManager
)
} catch {
services.errorReporter.log(error: error)
}
}
/// Refreshes TOTP Codes for the search results.
///
private func refreshTOTPCodes(searchItems: [VaultListItem]) async {
let currentSearchResults = state.ciphersForSearch.first?.items ?? []
do {
state.ciphersForSearch = try await refreshTOTPCodes(
for: searchItems,
in: [
VaultListSection(id: "", items: currentSearchResults, name: ""),
],
using: searchTotpExpirationManager
)
} catch {
services.errorReporter.log(error: error)
}
}
/// Searches the list of ciphers for those matching the search term.
///
private func searchVault(for searchText: String) async {
@ -224,12 +306,15 @@ class VaultAutofillListProcessor: StateProcessor<
.availableCredentialsForAuthenticationPublisher(),
mode: autofillListMode,
filterType: .allVaults,
rpID: fido2AppExtensionDelegate?.rpID,
rpID: autofillAppExtensionDelegate?.rpID,
searchText: searchText
)
for try await sections in searchResult {
state.ciphersForSearch = sections
state.showNoResults = sections.isEmpty
if let section = sections.first, !section.items.isEmpty {
searchTotpExpirationManager?.configureTOTPRefreshScheduling(for: section.items)
}
}
} catch {
state.ciphersForSearch = []
@ -243,9 +328,9 @@ class VaultAutofillListProcessor: StateProcessor<
private func streamAutofillItems() async {
do {
var uri = appExtensionDelegate?.uri
if let fido2AppExtensionDelegate,
fido2AppExtensionDelegate.isCreatingFido2Credential,
let rpID = fido2AppExtensionDelegate.rpID {
if let autofillAppExtensionDelegate,
autofillAppExtensionDelegate.isCreatingFido2Credential,
let rpID = autofillAppExtensionDelegate.rpID {
uri = "https://\(rpID)"
}
@ -254,9 +339,12 @@ class VaultAutofillListProcessor: StateProcessor<
.fido2UserInterfaceHelper
.availableCredentialsForAuthenticationPublisher(),
mode: autofillListMode,
rpID: fido2AppExtensionDelegate?.rpID,
rpID: autofillAppExtensionDelegate?.rpID,
uri: uri
) {
if autofillListMode == .totp, !sections.isEmpty {
vaultItemsTotpExpirationManager?.configureTOTPRefreshScheduling(for: sections.flatMap(\.items))
}
state.vaultListSections = sections
}
} catch {
@ -317,7 +405,7 @@ extension VaultAutofillListProcessor: ProfileSwitcherHandler {
extension VaultAutofillListProcessor: Fido2UserInterfaceHelperDelegate {
var isAutofillingFromList: Bool {
fido2AppExtensionDelegate?.isAutofillingFido2CredentialFromList == true
autofillAppExtensionDelegate?.isAutofillingFido2CredentialFromList == true
}
func onNeedsUserInteraction() async throws {
@ -337,11 +425,11 @@ extension VaultAutofillListProcessor {
/// Initializes Fido2 state and flows if needed.
private func initFido2State() async {
guard let fido2AppExtensionDelegate else {
guard let autofillAppExtensionDelegate else {
return
}
switch fido2AppExtensionDelegate.extensionMode {
switch autofillAppExtensionDelegate.extensionMode {
case let .registerFido2Credential(request):
if let request = request as? ASPasskeyCredentialRequest,
let credentialIdentity = request.credentialIdentity as? ASPasskeyCredentialIdentity {
@ -351,7 +439,7 @@ extension VaultAutofillListProcessor {
services.fido2UserInterfaceHelper.setupDelegate(fido2UserInterfaceHelperDelegate: self)
await handleFido2CredentialCreation(
fido2appExtensionDelegate: fido2AppExtensionDelegate,
autofillAppExtensionDelegate: autofillAppExtensionDelegate,
request: request,
credentialIdentity: credentialIdentity
)
@ -360,7 +448,7 @@ extension VaultAutofillListProcessor {
state.isAutofillingFido2List = true
await handleFido2CredentialAutofill(
fido2appExtensionDelegate: fido2AppExtensionDelegate,
autofillAppExtensionDelegate: autofillAppExtensionDelegate,
serviceIdentifiers: serviceIdentifiers,
fido2RequestParameters: fido2RequestParameters
)
@ -371,11 +459,11 @@ extension VaultAutofillListProcessor {
/// Handles Fido2 credential creation flow starting a request and completing the registration.
/// - Parameters:
/// - fido2appExtensionDelegate: The app extension delegate from the Autofill extension.
/// - autofillAppExtensionDelegate: The app extension delegate from the Autofill extension.
/// - request: The passkey credential request to create the Fido2 credential.
/// - credentialIdentity: The passkey credential identity from the request to create the Fido2 credential.
func handleFido2CredentialAutofill(
fido2appExtensionDelegate: Fido2AppExtensionDelegate,
autofillAppExtensionDelegate: AutofillAppExtensionDelegate,
serviceIdentifiers: [ASCredentialServiceIdentifier],
fido2RequestParameters: PasskeyCredentialRequestParameters
) async {
@ -385,7 +473,7 @@ extension VaultAutofillListProcessor {
fido2UserInterfaceHelperDelegate: self
)
fido2appExtensionDelegate.completeAssertionRequest(assertionCredential: assertionCredential)
autofillAppExtensionDelegate.completeAssertionRequest(assertionCredential: assertionCredential)
} catch {
services.fido2UserInterfaceHelper.pickedCredentialForAuthentication(result: .failure(error))
services.errorReporter.log(error: error)
@ -394,11 +482,11 @@ extension VaultAutofillListProcessor {
/// Handles Fido2 credential creation flow starting a request and completing the registration.
/// - Parameters:
/// - fido2appExtensionDelegate: The app extension delegate from the Autofill extension.
/// - autofillAppExtensionDelegate: The app extension delegate from the Autofill extension.
/// - request: The passkey credential request to create the Fido2 credential.
/// - credentialIdentity: The passkey credential identity from the request to create the Fido2 credential.
func handleFido2CredentialCreation(
fido2appExtensionDelegate: Fido2AppExtensionDelegate,
autofillAppExtensionDelegate: AutofillAppExtensionDelegate,
request: ASPasskeyCredentialRequest,
credentialIdentity: ASPasskeyCredentialIdentity
) async {
@ -433,7 +521,7 @@ extension VaultAutofillListProcessor {
)
.makeCredential(request: request)
fido2appExtensionDelegate.completeRegistrationRequest(
autofillAppExtensionDelegate.completeRegistrationRequest(
asPasskeyRegistrationCredential: ASPasskeyRegistrationCredential(
relyingParty: credentialIdentity.relyingPartyIdentifier,
clientDataHash: request.clientDataHash,
@ -450,11 +538,11 @@ extension VaultAutofillListProcessor {
/// Picks a cipher to use for the Fido2 process
/// - Parameter cipher: Cipher to use.
func onCipherForFido2CredentialPicked(cipher: CipherView) async {
guard let fido2AppExtensionDelegate else {
guard let autofillAppExtensionDelegate else {
return
}
if fido2AppExtensionDelegate.isCreatingFido2Credential {
if autofillAppExtensionDelegate.isCreatingFido2Credential {
guard let fido2CreationOptions = services.fido2UserInterfaceHelper.fido2CreationOptions else {
coordinator.showAlert(.defaultAlert(title: Localizations.anErrorHasOccurred))
return
@ -474,7 +562,7 @@ extension VaultAutofillListProcessor {
}
await checkUserAndDoPickedCredentialForCreation(for: cipher, fido2CreationOptions: fido2CreationOptions)
} else if fido2AppExtensionDelegate.isAutofillingFido2CredentialFromList {
} else if autofillAppExtensionDelegate.isAutofillingFido2CredentialFromList {
services.fido2UserInterfaceHelper.pickedCredentialForAuthentication(
result: .success(cipher)
)

View File

@ -23,6 +23,9 @@ struct VaultAutofillListState: Equatable, Sendable {
/// Whether the extension mode is preparing for autofill from Fido2 list.
var isAutofillingFido2List: Bool = false
/// Whether the extension mode is preparing for autofill from Totp items.
var isAutofillingTotpList: Bool = false
/// Whether the extension mode is creating a Fido2 credential.
var isCreatingFido2Credential: Bool = false
@ -43,4 +46,9 @@ struct VaultAutofillListState: Equatable, Sendable {
/// The list of sections to display for matching vault items.
var vaultListSections = [VaultListSection]()
/// Whether to show the add item button.
var showAddItemButton: Bool {
!isAutofillingTotpList
}
}

View File

@ -11,11 +11,14 @@ struct VaultAutofillListView: View {
/// The `Store` for this view.
@ObservedObject var store: Store<VaultAutofillListState, VaultAutofillListAction, VaultAutofillListEffect>
/// The `TimeProvider` used to calculate TOTP expiration.
var timeProvider: any TimeProvider
// MARK: View
var body: some View {
ZStack {
VaultAutofillListSearchableView(store: store)
VaultAutofillListSearchableView(store: store, timeProvider: timeProvider)
profileSwitcher
}
@ -43,7 +46,7 @@ struct VaultAutofillListView: View {
)
}
addToolbarItem {
addToolbarItem(hidden: !store.state.showAddItemButton) {
store.send(.addTapped(fromToolbar: true))
}
}
@ -78,6 +81,9 @@ private struct VaultAutofillListSearchableView: View {
/// The `Store` for this view.
@ObservedObject var store: Store<VaultAutofillListState, VaultAutofillListAction, VaultAutofillListEffect>
/// The `TimeProvider` used to calculate TOTP expiration.
var timeProvider: any TimeProvider
// MARK: View
var body: some View {
@ -174,16 +180,17 @@ private struct VaultAutofillListSearchableView: View {
state: { state in
VaultListItemRowState(
iconBaseURL: state.iconBaseURL,
isFromExtension: true,
item: item,
hasDivider: !isLastInSection,
showWebIcons: state.showWebIcons,
isFromExtension: true
showTotpCopyButton: false,
showWebIcons: state.showWebIcons
)
},
mapAction: nil,
mapEffect: nil
),
timeProvider: nil
timeProvider: timeProvider
)
.accessibilityIdentifier("CipherCell")
}
@ -202,17 +209,21 @@ private struct VaultAutofillListSearchableView: View {
image: Asset.Images.Illustrations.items.swiftUIImage,
text: store.state.emptyViewMessage
) {
Button {
store.send(.addTapped(fromToolbar: false))
} label: {
Label {
Text(store.state.emptyViewButtonText)
} icon: {
Asset.Images.plus16.swiftUIImage
.imageStyle(.accessoryIcon(
color: Asset.Colors.buttonFilledForeground.swiftUIColor,
scaleWithFont: true
))
if store.state.isAutofillingTotpList {
EmptyView()
} else {
Button {
store.send(.addTapped(fromToolbar: false))
} label: {
Label {
Text(store.state.emptyViewButtonText)
} icon: {
Asset.Images.plus16.swiftUIImage
.imageStyle(.accessoryIcon(
color: Asset.Colors.buttonFilledForeground.swiftUIColor,
scaleWithFont: true
))
}
}
}
}
@ -248,7 +259,14 @@ private struct VaultAutofillListSearchableView: View {
#if DEBUG
#Preview("Empty") {
NavigationView {
VaultAutofillListView(store: Store(processor: StateProcessor(state: VaultAutofillListState())))
VaultAutofillListView(
store: Store(
processor: StateProcessor(
state: VaultAutofillListState()
)
),
timeProvider: PreviewTimeProvider()
)
}
}
@ -276,7 +294,8 @@ private struct VaultAutofillListSearchableView: View {
searchText: "Test"
)
)
)
),
timeProvider: PreviewTimeProvider()
)
}
}
@ -304,7 +323,8 @@ private struct VaultAutofillListSearchableView: View {
]
)
)
)
),
timeProvider: PreviewTimeProvider()
)
}
}
@ -388,8 +408,9 @@ private struct VaultAutofillListSearchableView: View {
]
)
)
)
),
timeProvider: PreviewTimeProvider()
)
}
}
#endif
#endif // swiftlint:disable:this file_length

View File

@ -8,6 +8,7 @@ class VaultAutofillListViewTests: BitwardenTestCase { // swiftlint:disable:this
var processor: MockProcessor<VaultAutofillListState, VaultAutofillListAction, VaultAutofillListEffect>!
var subject: VaultAutofillListView!
var timeProvider: MockTimeProvider!
// MARK: Setup & Teardown
@ -16,8 +17,9 @@ class VaultAutofillListViewTests: BitwardenTestCase { // swiftlint:disable:this
processor = MockProcessor(state: VaultAutofillListState())
let store = Store(processor: processor)
timeProvider = MockTimeProvider(.currentTime)
subject = VaultAutofillListView(store: store)
subject = VaultAutofillListView(store: store, timeProvider: timeProvider)
}
override func tearDown() {
@ -25,6 +27,7 @@ class VaultAutofillListViewTests: BitwardenTestCase { // swiftlint:disable:this
processor = nil
subject = nil
timeProvider = nil
}
// MARK: Tests

View File

@ -76,6 +76,7 @@ final class VaultCoordinator: Coordinator, HasStackNavigator {
& HasNotificationService
& HasSettingsRepository
& HasStateService
& HasTOTPExpirationManagerFactory
& HasTimeProvider
& HasVaultRepository
& VaultItemCoordinator.Services
@ -215,7 +216,7 @@ final class VaultCoordinator: Coordinator, HasStackNavigator {
iconBaseURL: services.environmentService.iconsURL
)
)
let view = VaultAutofillListView(store: Store(processor: processor))
let view = VaultAutofillListView(store: Store(processor: processor), timeProvider: services.timeProvider)
stackNavigator?.replace(view)
}

View File

@ -8,7 +8,7 @@ final class VaultGroupProcessor: StateProcessor<
VaultGroupState,
VaultGroupAction,
VaultGroupEffect
> {
>, HasTOTPCodesSections {
// MARK: Types
typealias Services = HasAuthRepository
@ -39,6 +39,10 @@ final class VaultGroupProcessor: StateProcessor<
/// The helper to handle the more options menu for a vault item.
private let vaultItemMoreOptionsHelper: VaultItemMoreOptionsHelper
var vaultRepository: VaultRepository {
services.vaultRepository
}
// MARK: Initialization
/// Creates a new `VaultGroupProcessor`.
@ -60,7 +64,7 @@ final class VaultGroupProcessor: StateProcessor<
self.vaultItemMoreOptionsHelper = vaultItemMoreOptionsHelper
super.init(state: state)
groupTotpExpirationManager = .init(
groupTotpExpirationManager = DefaultTOTPExpirationManager(
timeProvider: services.timeProvider,
onExpiration: { [weak self] expiredItems in
guard let self else { return }
@ -69,7 +73,7 @@ final class VaultGroupProcessor: StateProcessor<
}
}
)
searchTotpExpirationManager = .init(
searchTotpExpirationManager = DefaultTOTPExpirationManager(
timeProvider: services.timeProvider,
onExpiration: { [weak self] expiredSearchItems in
guard let self else { return }
@ -166,10 +170,11 @@ final class VaultGroupProcessor: StateProcessor<
private func refreshTOTPCodes(for items: [VaultListItem]) async {
guard case let .data(currentSections) = state.loadingState else { return }
do {
let refreshedItems = try await services.vaultRepository.refreshTOTPCodes(for: items)
let updatedSections = currentSections.updated(with: refreshedItems)
let allItems = updatedSections.flatMap(\.items)
groupTotpExpirationManager?.configureTOTPRefreshScheduling(for: allItems)
let updatedSections = try await refreshTOTPCodes(
for: items,
in: currentSections,
using: groupTotpExpirationManager
)
state.loadingState = .data(updatedSections)
} catch {
services.errorReporter.log(error: error)
@ -181,10 +186,14 @@ final class VaultGroupProcessor: StateProcessor<
private func refreshTOTPCodes(searchItems: [VaultListItem]) async {
let currentSearchResults = state.searchResults
do {
let refreshedSearchResults = try await services.vaultRepository.refreshTOTPCodes(for: searchItems)
let allSearchResults = currentSearchResults.updated(with: refreshedSearchResults)
searchTotpExpirationManager?.configureTOTPRefreshScheduling(for: allSearchResults)
state.searchResults = allSearchResults
let updatedSections = try await refreshTOTPCodes(
for: searchItems,
in: [
VaultListSection(id: "", items: currentSearchResults, name: ""),
],
using: searchTotpExpirationManager
)
state.searchResults = updatedSections[0].items
} catch {
services.errorReporter.log(error: error)
}
@ -276,92 +285,3 @@ extension VaultGroupProcessor: CipherItemOperationDelegate {
}
}
}
/// A class to manage TOTP code expirations for the VaultGroupProcessor and batch refresh calls.
///
private class TOTPExpirationManager {
// MARK: Properties
/// A closure to call on expiration
///
var onExpiration: (([VaultListItem]) -> Void)?
// MARK: Private Properties
/// All items managed by the object, grouped by TOTP period.
///
private(set) var itemsByInterval = [UInt32: [VaultListItem]]()
/// A model to provide time to calculate the countdown.
///
private var timeProvider: any TimeProvider
/// A timer that triggers `checkForExpirations` to manage code expirations.
///
private var updateTimer: Timer?
/// Initializes a new countdown timer
///
/// - Parameters
/// - timeProvider: A protocol providing the present time as a `Date`.
/// Used to calculate time remaining for a present TOTP code.
/// - onExpiration: A closure to call on code expiration for a list of vault items.
///
init(
timeProvider: any TimeProvider,
onExpiration: (([VaultListItem]) -> Void)?
) {
self.timeProvider = timeProvider
self.onExpiration = onExpiration
updateTimer = Timer.scheduledTimer(
withTimeInterval: 0.25,
repeats: true,
block: { _ in
self.checkForExpirations()
}
)
}
/// Clear out any timers tracking TOTP code expiration
deinit {
cleanup()
}
// MARK: Methods
/// Configures TOTP code refresh scheduling
///
/// - Parameter items: The vault list items that may require code expiration tracking.
///
func configureTOTPRefreshScheduling(for items: [VaultListItem]) {
var newItemsByInterval = [UInt32: [VaultListItem]]()
items.forEach { item in
guard case let .totp(_, model) = item.itemType else { return }
newItemsByInterval[model.totpCode.period, default: []].append(item)
}
itemsByInterval = newItemsByInterval
}
/// A function to remove any outstanding timers
///
func cleanup() {
updateTimer?.invalidate()
updateTimer = nil
}
private func checkForExpirations() {
var expired = [VaultListItem]()
var notExpired = [UInt32: [VaultListItem]]()
itemsByInterval.forEach { period, items in
let sortedItems: [Bool: [VaultListItem]] = TOTPExpirationCalculator.listItemsByExpiration(
items,
timeProvider: timeProvider
)
expired.append(contentsOf: sortedItems[true] ?? [])
notExpired[period] = sortedItems[false]
}
itemsByInterval = notExpired
guard !expired.isEmpty else { return }
onExpiration?(expired)
}
}

View File

@ -244,10 +244,10 @@ private struct SearchableVaultListView: View {
state: { state in
VaultListItemRowState(
iconBaseURL: state.iconBaseURL,
isFromExtension: false,
item: item,
hasDivider: !isLastInSection,
showWebIcons: state.showWebIcons,
isFromExtension: false
showWebIcons: state.showWebIcons
)
},
mapAction: { action in

View File

@ -14,7 +14,7 @@ class AddEditItemProcessorFido2Tests: BitwardenTestCase {
// MARK: Properties
var authRepository: MockAuthRepository!
var appExtensionDelegate: MockFido2AppExtensionDelegate!
var appExtensionDelegate: MockAutofillAppExtensionDelegate!
var cameraService: MockCameraService!
var client: MockHTTPClient!
var coordinator: MockCoordinator<VaultItemRoute, VaultItemEvent>!
@ -34,7 +34,7 @@ class AddEditItemProcessorFido2Tests: BitwardenTestCase {
super.setUp()
authRepository = MockAuthRepository()
appExtensionDelegate = MockFido2AppExtensionDelegate()
appExtensionDelegate = MockAutofillAppExtensionDelegate()
cameraService = MockCameraService()
client = MockHTTPClient()
coordinator = MockCoordinator<VaultItemRoute, VaultItemEvent>()

View File

@ -607,8 +607,8 @@ final class AddEditItemProcessor: StateProcessor<// swiftlint:disable:this type_
/// Adds the item currently in `state`.
///
private func addItem(fido2UserVerified: Bool) async throws {
if let fido2AppExtensionDelegate = appExtensionDelegate as? Fido2AppExtensionDelegate,
fido2AppExtensionDelegate.isCreatingFido2Credential {
if let autofillAppExtensionDelegate = appExtensionDelegate as? AutofillAppExtensionDelegate,
autofillAppExtensionDelegate.isCreatingFido2Credential {
services.fido2UserInterfaceHelper.pickedCredentialForCreation(
result: .success(
CheckUserAndPickCredentialForCreationResult(
@ -627,8 +627,8 @@ final class AddEditItemProcessor: StateProcessor<// swiftlint:disable:this type_
/// Checks user verification if needed on Fido2 flows.
private func fido2CheckUserIfNeeded() async throws -> Bool {
guard let fido2AppExtensionDelegate = appExtensionDelegate as? Fido2AppExtensionDelegate,
fido2AppExtensionDelegate.isCreatingFido2Credential,
guard let autofillAppExtensionDelegate = appExtensionDelegate as? AutofillAppExtensionDelegate,
autofillAppExtensionDelegate.isCreatingFido2Credential,
let fido2CreationOptions = services.fido2UserInterfaceHelper.fido2CreationOptions else {
return false
}

View File

@ -9,15 +9,18 @@ struct VaultListItemRowState {
/// The base url used to fetch icons.
var iconBaseURL: URL?
/// Whether we are in an extension context.
var isFromExtension: Bool = false
/// The item displayed in this row.
var item: VaultListItem
/// A flag indicating if this row should display a divider on the bottom edge.
var hasDivider: Bool
/// Whether the copy button for Totp rows is displayed.
var showTotpCopyButton: Bool = true
/// Whether to show the special web icons.
var showWebIcons: Bool
/// Whether we are in an extension context.
var isFromExtension: Bool = false
}

View File

@ -145,15 +145,15 @@ struct VaultListItemRowView: View {
Text(model.totpCode.displayCode)
.styleGuide(.bodyMonospaced, weight: .regular, monoSpacedDigit: true)
.foregroundColor(Asset.Colors.textPrimary.swiftUIColor)
Button {
Task { @MainActor in
if store.state.showTotpCopyButton {
Button {
store.send(.copyTOTPCode(model.totpCode.code))
} label: {
Asset.Images.copy24.swiftUIImage
}
} label: {
Asset.Images.copy24.swiftUIImage
.foregroundColor(Asset.Colors.iconPrimary.swiftUIColor)
.accessibilityLabel(Localizations.copyTotp)
}
.foregroundColor(Asset.Colors.iconPrimary.swiftUIColor)
.accessibilityLabel(Localizations.copyTotp)
}
}
}