Skip to content

Commit 8eccee5

Browse files
authored
[PM-8828] Fido2 autofill without user interaction (#744)
1 parent 81d62a3 commit 8eccee5

15 files changed

+596
-22
lines changed

BitwardenAutoFillExtension/CredentialProviderViewController.swift

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import AuthenticationServices
2+
import BitwardenSdk
23
import BitwardenShared
34
import OSLog
45

@@ -61,6 +62,22 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
6162
provideCredential(for: recordIdentifier)
6263
}
6364

65+
@available(iOSApplicationExtension 17.0, *)
66+
override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) {
67+
switch credentialRequest {
68+
case let passwordRequest as ASPasswordCredentialRequest:
69+
provideCredentialWithoutUserInteraction(for: passwordRequest)
70+
case let passkeyRequest as ASPasskeyCredentialRequest:
71+
initializeApp(
72+
with: DefaultCredentialProviderContext(.autofillFido2Credential(passkeyRequest)),
73+
userInteraction: false
74+
)
75+
provideFido2Credential(for: passkeyRequest)
76+
default:
77+
break
78+
}
79+
}
80+
6481
// MARK: Private
6582

6683
/// Cancels the extension request and dismisses the extension's view controller.
@@ -135,6 +152,28 @@ class CredentialProviderViewController: ASCredentialProviderViewController {
135152
}
136153
}
137154
}
155+
156+
/// Provides a Fido2 credential for a passkey request.
157+
/// - Parameter passkeyRequest: Request to get the credential.
158+
@available(iOSApplicationExtension 17.0, *)
159+
private func provideFido2Credential(for passkeyRequest: ASPasskeyCredentialRequest) {
160+
guard let appProcessor else {
161+
cancel(error: ASExtensionError(.failed))
162+
return
163+
}
164+
165+
Task {
166+
do {
167+
let credential = try await appProcessor.provideFido2Credential(
168+
for: passkeyRequest
169+
)
170+
await extensionContext.completeAssertionRequest(using: credential)
171+
} catch {
172+
Logger.appExtension.error("Error providing credential without user interaction: \(error)")
173+
cancel(error: error)
174+
}
175+
}
176+
}
138177
}
139178

140179
// MARK: - AppExtensionDelegate

BitwardenShared/Core/Auth/Services/TestHelpers/MockClientFido2Authenticator.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ import BitwardenSdk
44

55
class MockClientFido2Authenticator: ClientFido2AuthenticatorProtocol {
66
var credentialsForAutofillResult: Result<[Fido2CredentialAutofillView], Error> = .success([])
7-
var getAssertionResult: Result<BitwardenSdk.GetAssertionResult, Error> = .success(
8-
BitwardenSdk.GetAssertionResult.fixture()
9-
)
7+
var getAssertionMocker = InvocationMockerWithThrowingResult<GetAssertionRequest, GetAssertionResult>()
8+
.withResult(.fixture())
109
var makeCredentialMocker = InvocationMockerWithThrowingResult<MakeCredentialRequest, MakeCredentialResult>()
1110
.withResult(.fixture())
1211
var silentlyDiscoverCredentialsResult: Result<[Fido2CredentialAutofillView], Error> = .success([])
@@ -16,7 +15,7 @@ class MockClientFido2Authenticator: ClientFido2AuthenticatorProtocol {
1615
}
1716

1817
func getAssertion(request: BitwardenSdk.GetAssertionRequest) async throws -> BitwardenSdk.GetAssertionResult {
19-
try getAssertionResult.get()
18+
try getAssertionMocker.invoke(param: request)
2019
}
2120

2221
func makeCredential(request: BitwardenSdk.MakeCredentialRequest) async throws -> BitwardenSdk.MakeCredentialResult {
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import AuthenticationServices
2+
import BitwardenSdk
3+
4+
@available(iOSApplicationExtension 17.0, *)
5+
extension Fido2CredentialAutofillView {
6+
/// Converts this credential view into an `ASPasskeyCredentialIdentity`.
7+
/// - Returns: A `ASPasskeyCredentialIdentity` from the values of this object.
8+
func toFido2CredentialIdentity() -> ASPasskeyCredentialIdentity {
9+
ASPasskeyCredentialIdentity(
10+
relyingPartyIdentifier: rpId,
11+
userName: safeUsernameForUi,
12+
credentialID: credentialId,
13+
userHandle: userHandle,
14+
recordIdentifier: cipherId
15+
)
16+
}
17+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import BitwardenSdk
2+
import XCTest
3+
4+
@testable import BitwardenShared
5+
6+
class Fido2CredentialAutofillViewExtensionsTests: BitwardenTestCase { // swiftlint:disable:this type_name
7+
// MARK: Tests
8+
9+
/// `toFido2CredentialIdentity()` returns the converted `ASPasskeyCredentialIdentity`.
10+
func test_toFido2CredentialIdentity() throws {
11+
let subject = Fido2CredentialAutofillView(
12+
credentialId: Data(repeating: 1, count: 16),
13+
cipherId: "1",
14+
rpId: "myApp.com",
15+
userNameForUi: "username",
16+
userHandle: Data(repeating: 1, count: 16)
17+
)
18+
let identity = subject.toFido2CredentialIdentity()
19+
XCTAssertTrue(
20+
identity.relyingPartyIdentifier == subject.rpId
21+
&& identity.userName == subject.userNameForUi
22+
&& identity.credentialID == subject.credentialId
23+
&& identity.userHandle == subject.userHandle
24+
&& identity.recordIdentifier == subject.cipherId
25+
)
26+
}
27+
28+
/// `toFido2CredentialIdentity()` returns the converted `ASPasskeyCredentialIdentity`
29+
/// when `userNameForUI` is `nil`.
30+
func test_toFido2CredentialIdentity_userNameForUINil() throws {
31+
let subject = Fido2CredentialAutofillView(
32+
credentialId: Data(repeating: 1, count: 16),
33+
cipherId: "1",
34+
rpId: "myApp.com",
35+
userNameForUi: nil,
36+
userHandle: Data(repeating: 1, count: 16)
37+
)
38+
let identity = subject.toFido2CredentialIdentity()
39+
XCTAssertTrue(
40+
identity.relyingPartyIdentifier == subject.rpId
41+
&& identity.userName == Localizations.unknownAccount
42+
&& identity.credentialID == subject.credentialId
43+
&& identity.userHandle == subject.userHandle
44+
&& identity.recordIdentifier == subject.cipherId
45+
)
46+
}
47+
}

BitwardenShared/Core/Autofill/Services/AutofillCredentialService.swift

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ class DefaultAutofillCredentialService {
3535
/// The service to manage events.
3636
private let eventService: EventService
3737

38+
/// A store to be used on Fido2 flows to get/save credentials.
39+
let fido2CredentialStore: Fido2CredentialStore
40+
41+
/// A helper to be used on Fido2 flows that requires user interaction and extends the capabilities
42+
/// of the `Fido2UserInterface` from the SDK.
43+
let fido2UserInterfaceHelper: Fido2UserInterfaceHelper
44+
3845
/// The service used to manage the credentials available for AutoFill suggestions.
3946
private let identityStore: CredentialIdentityStore
4047

@@ -60,6 +67,9 @@ class DefaultAutofillCredentialService {
6067
/// - clientService: The service that handles common client functionality such as encryption and decryption.
6168
/// - errorReporter: The service used by the application to report non-fatal errors.
6269
/// - eventService: The service to manage events.
70+
/// - fido2UserInterfaceHelper: A helper to be used on Fido2 flows that requires user interaction
71+
/// and extends the capabilities of the `Fido2UserInterface` from the SDK.
72+
/// - fido2CredentialStore: A store to be used on Fido2 flows to get/save credentials.
6373
/// - identityStore: The service used to manage the credentials available for AutoFill suggestions.
6474
/// - pasteboardService: The service used to manage copy/pasting from the device's clipboard.
6575
/// - stateService: The service used by the application to manage account state.
@@ -70,6 +80,8 @@ class DefaultAutofillCredentialService {
7080
clientService: ClientService,
7181
errorReporter: ErrorReporter,
7282
eventService: EventService,
83+
fido2CredentialStore: Fido2CredentialStore,
84+
fido2UserInterfaceHelper: Fido2UserInterfaceHelper,
7385
identityStore: CredentialIdentityStore = ASCredentialIdentityStore.shared,
7486
pasteboardService: PasteboardService,
7587
stateService: StateService,
@@ -79,6 +91,8 @@ class DefaultAutofillCredentialService {
7991
self.clientService = clientService
8092
self.errorReporter = errorReporter
8193
self.eventService = eventService
94+
self.fido2CredentialStore = fido2CredentialStore
95+
self.fido2UserInterfaceHelper = fido2UserInterfaceHelper
8296
self.identityStore = identityStore
8397
self.pasteboardService = pasteboardService
8498
self.stateService = stateService
@@ -151,7 +165,15 @@ class DefaultAutofillCredentialService {
151165

152166
if #available(iOS 17, *) {
153167
let identities = decryptedCiphers.compactMap(\.credentialIdentity)
154-
try await identityStore.replaceCredentialIdentities(identities)
168+
let fido2Identities = try await clientService.platform().fido2()
169+
.authenticator(
170+
userInterface: fido2UserInterfaceHelper,
171+
credentialStore: fido2CredentialStore
172+
)
173+
.credentialsForAutofill()
174+
.compactMap { $0.toFido2CredentialIdentity() }
175+
176+
try await identityStore.replaceCredentialIdentities(identities + fido2Identities)
155177
Logger.application.info("AutofillCredentialService: replaced \(identities.count) credential identities")
156178
} else {
157179
let identities = decryptedCiphers.compactMap(\.passwordCredentialIdentity)
@@ -210,7 +232,10 @@ extension DefaultAutofillCredentialService: AutofillCredentialService {
210232
private extension CipherView {
211233
@available(iOS 17, *)
212234
var credentialIdentity: (any ASCredentialIdentity)? {
213-
passwordCredentialIdentity
235+
guard shouldGetPasswordCredentialIdentity else {
236+
return nil
237+
}
238+
return passwordCredentialIdentity
214239
}
215240

216241
var passwordCredentialIdentity: ASPasswordCredentialIdentity? {
@@ -228,6 +253,12 @@ private extension CipherView {
228253
recordIdentifier: id
229254
)
230255
}
256+
257+
/// Whether the `ASPasswordCredentialIdentity` should be gotten.
258+
/// Otherwise a passkey identity will be provided.
259+
var shouldGetPasswordCredentialIdentity: Bool {
260+
!hasFido2Credentials || login?.password != nil
261+
}
231262
}
232263

233264
// MARK: - CredentialIdentityStore

BitwardenShared/Core/Autofill/Services/AutofillCredentialServiceTests.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ class AutofillCredentialServiceTests: BitwardenTestCase {
1010
var clientService: MockClientService!
1111
var errorReporter: MockErrorReporter!
1212
var eventService: MockEventService!
13+
var fido2CredentialStore: MockFido2CredentialStore!
14+
var fido2UserInterfaceHelper: MockFido2UserInterfaceHelper!
1315
var identityStore: MockCredentialIdentityStore!
1416
var pasteboardService: MockPasteboardService!
1517
var stateService: MockStateService!
@@ -25,6 +27,8 @@ class AutofillCredentialServiceTests: BitwardenTestCase {
2527
clientService = MockClientService()
2628
errorReporter = MockErrorReporter()
2729
eventService = MockEventService()
30+
fido2CredentialStore = MockFido2CredentialStore()
31+
fido2UserInterfaceHelper = MockFido2UserInterfaceHelper()
2832
identityStore = MockCredentialIdentityStore()
2933
pasteboardService = MockPasteboardService()
3034
stateService = MockStateService()
@@ -35,6 +39,8 @@ class AutofillCredentialServiceTests: BitwardenTestCase {
3539
clientService: clientService,
3640
errorReporter: errorReporter,
3741
eventService: eventService,
42+
fido2CredentialStore: fido2CredentialStore,
43+
fido2UserInterfaceHelper: fido2UserInterfaceHelper,
3844
identityStore: identityStore,
3945
pasteboardService: pasteboardService,
4046
stateService: stateService,
@@ -49,6 +55,8 @@ class AutofillCredentialServiceTests: BitwardenTestCase {
4955
clientService = nil
5056
errorReporter = nil
5157
eventService = nil
58+
fido2CredentialStore = nil
59+
fido2UserInterfaceHelper = nil
5260
identityStore = nil
5361
pasteboardService = nil
5462
stateService = nil
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#if DEBUG
2+
3+
import BitwardenSdk
4+
import Foundation
5+
6+
/// Report with traceability about Fido2 flows.
7+
public struct Fido2DebuggingReport {
8+
var allCredentialsResult: Result<[BitwardenSdk.CipherView], Error>?
9+
var findCredentialsResult: Result<[BitwardenSdk.CipherView], Error>?
10+
var getAssertionRequest: GetAssertionRequest?
11+
var getAssertionResult: Result<GetAssertionResult, Error>?
12+
var saveCredentialCipher: Result<BitwardenSdk.Cipher, Error>?
13+
}
14+
15+
/// Fido2 builder for debugging report.
16+
public struct Fido2DebuggingReportBuilder {
17+
/// Builder for Fido2 debugging report.
18+
public static var builder = Fido2DebuggingReportBuilder()
19+
20+
var report = Fido2DebuggingReport()
21+
22+
/// Gets the report for Fido2 debugging.
23+
/// - Returns: Fido2 report.
24+
public func getReport() -> Fido2DebuggingReport? {
25+
report
26+
}
27+
28+
mutating func withAllCredentialsResult(_ result: Result<[BitwardenSdk.CipherView], Error>) {
29+
report.allCredentialsResult = result
30+
}
31+
32+
mutating func withFindCredentialsResult(_ result: Result<[BitwardenSdk.CipherView], Error>) {
33+
report.findCredentialsResult = result
34+
}
35+
36+
mutating func withGetAssertionRequest(_ request: GetAssertionRequest) {
37+
report.getAssertionRequest = request
38+
}
39+
40+
mutating func withGetAssertionResult(_ result: Result<GetAssertionResult, Error>) {
41+
report.getAssertionResult = result
42+
}
43+
44+
mutating func withSaveCredentialCipher(_ credential: Result<BitwardenSdk.Cipher, Error>) {
45+
report.saveCredentialCipher = credential
46+
}
47+
}
48+
49+
#endif

BitwardenShared/Core/Platform/Services/ServiceContainer.swift

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -456,16 +456,6 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
456456
trustDeviceService: trustDeviceService
457457
)
458458

459-
let autofillCredentialService = DefaultAutofillCredentialService(
460-
cipherService: cipherService,
461-
clientService: clientService,
462-
errorReporter: errorReporter,
463-
eventService: eventService,
464-
pasteboardService: pasteboardService,
465-
stateService: stateService,
466-
vaultTimeoutService: vaultTimeoutService
467-
)
468-
469459
let authRepository = DefaultAuthRepository(
470460
accountAPIService: apiService,
471461
authService: authService,
@@ -552,9 +542,34 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
552542
)
553543
)
554544

545+
#if DEBUG
546+
let fido2CredentialStore = DebuggingFido2CredentialStoreService(
547+
fido2CredentialStore: Fido2CredentialStoreService(
548+
cipherService: cipherService,
549+
clientService: clientService,
550+
errorReporter: errorReporter,
551+
syncService: syncService
552+
)
553+
)
554+
#else
555555
let fido2CredentialStore = Fido2CredentialStoreService(
556556
cipherService: cipherService,
557-
clientService: clientService
557+
clientService: clientService,
558+
errorReporter: errorReporter,
559+
syncService: syncService
560+
)
561+
#endif
562+
563+
let autofillCredentialService = DefaultAutofillCredentialService(
564+
cipherService: cipherService,
565+
clientService: clientService,
566+
errorReporter: errorReporter,
567+
eventService: eventService,
568+
fido2CredentialStore: fido2CredentialStore,
569+
fido2UserInterfaceHelper: fido2UserInterfaceHelper,
570+
pasteboardService: pasteboardService,
571+
stateService: stateService,
572+
vaultTimeoutService: vaultTimeoutService
558573
)
559574

560575
self.init(

0 commit comments

Comments
 (0)