mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-15 14:00:25 -06:00
[PM-11137] Implement iOS 18 Totp autofill from list (#884)
This commit is contained in:
parent
7544e23546
commit
e0b3956ef6
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
108
BitwardenShared/UI/Vault/Utilities/TOTPExpirationManager.swift
Normal file
108
BitwardenShared/UI/Vault/Utilities/TOTPExpirationManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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]
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user