diff --git a/BitwardenShared/Core/Auth/Services/AuthService.swift b/BitwardenShared/Core/Auth/Services/AuthService.swift index 1b1a1e073..c0d5c2f71 100644 --- a/BitwardenShared/Core/Auth/Services/AuthService.swift +++ b/BitwardenShared/Core/Auth/Services/AuthService.swift @@ -1,3 +1,4 @@ +import AuthenticationServices import BitwardenSdk import CryptoKit import Foundation @@ -198,6 +199,14 @@ protocol AuthService { /// - userId: The user ID associated with the pending admin login request. /// func setPendingAdminLoginRequest(_ adminLoginRequest: PendingAdminLoginRequest?, userId: String?) async throws + + /// Provides a web authentication session. In practice this is a passthrough + /// for `ASWebAuthenticationSession.init`. + /// + func webAuthenticationSession( + url: URL, + completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler + ) -> ASWebAuthenticationSession } extension AuthService { @@ -682,6 +691,17 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng } } + func webAuthenticationSession( + url: URL, + completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler + ) -> ASWebAuthenticationSession { + ASWebAuthenticationSession( + url: url, + callbackURLScheme: callbackUrlScheme, + completionHandler: completionHandler + ) + } + // MARK: Private Methods /// Get the fingerprint phrase from the public key of a login request. diff --git a/BitwardenShared/Core/Auth/Services/TestHelpers/MockAuthService.swift b/BitwardenShared/Core/Auth/Services/TestHelpers/MockAuthService.swift index fccf7680d..ef758508f 100644 --- a/BitwardenShared/Core/Auth/Services/TestHelpers/MockAuthService.swift +++ b/BitwardenShared/Core/Auth/Services/TestHelpers/MockAuthService.swift @@ -1,3 +1,4 @@ +import AuthenticationServices import BitwardenSdk import Foundation @@ -63,6 +64,8 @@ class MockAuthService: AuthService { var setPendingAdminLoginRequest: PendingAdminLoginRequest? var setPendingAdminLoginRequestResult: Result = .success(()) + var webAuthenticationSession: ASWebAuthenticationSession? + func answerLoginRequest(_ request: LoginRequest, approve: Bool) async throws { answerLoginRequestRequest = request answerLoginRequestApprove = approve @@ -166,4 +169,44 @@ class MockAuthService: AuthService { setPendingAdminLoginRequest = adminLoginRequest try setPendingAdminLoginRequestResult.get() } + + func webAuthenticationSession( + url: URL, + completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler + ) -> ASWebAuthenticationSession { + let mockSession = MockWebAuthenticationSession( + url: url, + callbackURLScheme: callbackUrlScheme, + completionHandler: completionHandler + ) + webAuthenticationSession = mockSession + return mockSession + } +} + +// MARK: - MockWebAuthenticationSession + +class MockWebAuthenticationSession: ASWebAuthenticationSession { + var startCalled = false + var startReturn = true + + var initUrl: URL + var initCallbackURLScheme: String? + var initCompletionHandler: ASWebAuthenticationSession.CompletionHandler + + override init( + url URL: URL, + callbackURLScheme: String?, + completionHandler: @escaping ASWebAuthenticationSession.CompletionHandler + ) { + initUrl = URL + initCallbackURLScheme = callbackURLScheme + initCompletionHandler = completionHandler + super.init(url: URL, callbackURLScheme: callbackURLScheme, completionHandler: completionHandler) + } + + override func start() -> Bool { + startCalled = true + return startReturn + } } diff --git a/BitwardenShared/Core/Platform/Services/EnvironmentService.swift b/BitwardenShared/Core/Platform/Services/EnvironmentService.swift index e0a18c00f..a11bb5d90 100644 --- a/BitwardenShared/Core/Platform/Services/EnvironmentService.swift +++ b/BitwardenShared/Core/Platform/Services/EnvironmentService.swift @@ -24,6 +24,9 @@ protocol EnvironmentService { /// The URL for the recovery code help page. var recoveryCodeURL: URL { get } + /// The region of the current environment. + var region: RegionType { get } + /// The URL for sharing a send. var sendShareURL: URL { get } @@ -118,6 +121,16 @@ extension DefaultEnvironmentService { environmentUrls.recoveryCodeURL } + var region: RegionType { + if environmentUrls.baseURL == EnvironmentUrlData.defaultUS.base { + return .unitedStates + } else if environmentUrls.baseURL == EnvironmentUrlData.defaultEU.base { + return .europe + } else { + return .selfHosted + } + } + var sendShareURL: URL { environmentUrls.sendShareURL } diff --git a/BitwardenShared/Core/Platform/Services/EnvironmentServiceTests.swift b/BitwardenShared/Core/Platform/Services/EnvironmentServiceTests.swift index 882cce305..d0a989bc2 100644 --- a/BitwardenShared/Core/Platform/Services/EnvironmentServiceTests.swift +++ b/BitwardenShared/Core/Platform/Services/EnvironmentServiceTests.swift @@ -34,6 +34,7 @@ class EnvironmentServiceTests: XCTestCase { XCTAssertEqual(subject.iconsURL, URL(string: "https://vault.bitwarden.com/icons")) XCTAssertEqual(subject.identityURL, URL(string: "https://vault.bitwarden.com/identity")) XCTAssertEqual(subject.importItemsURL, URL(string: "https://vault.bitwarden.com/#/tools/import")) + XCTAssertEqual(subject.region, .unitedStates) XCTAssertEqual(subject.sendShareURL, URL(string: "https://vault.bitwarden.com/#/send")) XCTAssertEqual(subject.settingsURL, URL(string: "https://vault.bitwarden.com/#/settings")) XCTAssertEqual(subject.webVaultURL, URL(string: "https://vault.bitwarden.com")) @@ -53,11 +54,32 @@ class EnvironmentServiceTests: XCTestCase { XCTAssertEqual(subject.iconsURL, URL(string: "https://example.com/icons")) XCTAssertEqual(subject.identityURL, URL(string: "https://example.com/identity")) XCTAssertEqual(subject.importItemsURL, URL(string: "https://example.com/#/tools/import")) + XCTAssertEqual(subject.region, .selfHosted) XCTAssertEqual(subject.sendShareURL, URL(string: "https://example.com/#/send")) XCTAssertEqual(subject.settingsURL, URL(string: "https://example.com/#/settings")) XCTAssertEqual(subject.webVaultURL, URL(string: "https://example.com")) } + /// `loadURLsForActiveAccount()` handles EU URLs + func test_loadURLsForActiveAccount_europe() async { + let urls = EnvironmentUrlData.defaultEU + let account = Account.fixture(settings: .fixture(environmentUrls: urls)) + stateService.activeAccount = account + stateService.environmentUrls = [account.profile.userId: urls] + + await subject.loadURLsForActiveAccount() + + XCTAssertEqual(subject.apiURL, URL(string: "https://vault.bitwarden.eu/api")) + XCTAssertEqual(subject.eventsURL, URL(string: "https://vault.bitwarden.eu/events")) + XCTAssertEqual(subject.iconsURL, URL(string: "https://vault.bitwarden.eu/icons")) + XCTAssertEqual(subject.identityURL, URL(string: "https://vault.bitwarden.eu/identity")) + XCTAssertEqual(subject.importItemsURL, URL(string: "https://vault.bitwarden.eu/#/tools/import")) + XCTAssertEqual(subject.region, .europe) + XCTAssertEqual(subject.sendShareURL, URL(string: "https://vault.bitwarden.eu/#/send")) + XCTAssertEqual(subject.settingsURL, URL(string: "https://vault.bitwarden.eu/#/settings")) + XCTAssertEqual(subject.webVaultURL, URL(string: "https://vault.bitwarden.eu")) + } + /// `loadURLsForActiveAccount()` loads the default URLs if there's no active account. func test_loadURLsForActiveAccount_noAccount() async { await subject.loadURLsForActiveAccount() @@ -67,6 +89,7 @@ class EnvironmentServiceTests: XCTestCase { XCTAssertEqual(subject.iconsURL, URL(string: "https://vault.bitwarden.com/icons")) XCTAssertEqual(subject.identityURL, URL(string: "https://vault.bitwarden.com/identity")) XCTAssertEqual(subject.importItemsURL, URL(string: "https://vault.bitwarden.com/#/tools/import")) + XCTAssertEqual(subject.region, .unitedStates) XCTAssertEqual(subject.sendShareURL, URL(string: "https://vault.bitwarden.com/#/send")) XCTAssertEqual(subject.settingsURL, URL(string: "https://vault.bitwarden.com/#/settings")) XCTAssertEqual(subject.webVaultURL, URL(string: "https://vault.bitwarden.com")) @@ -83,6 +106,7 @@ class EnvironmentServiceTests: XCTestCase { XCTAssertEqual(subject.iconsURL, URL(string: "https://example.com/icons")) XCTAssertEqual(subject.identityURL, URL(string: "https://example.com/identity")) XCTAssertEqual(subject.importItemsURL, URL(string: "https://example.com/#/tools/import")) + XCTAssertEqual(subject.region, .selfHosted) XCTAssertEqual(subject.sendShareURL, URL(string: "https://example.com/#/send")) XCTAssertEqual(subject.settingsURL, URL(string: "https://example.com/#/settings")) XCTAssertEqual(subject.webVaultURL, URL(string: "https://example.com")) diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/MockEnvironmentService.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/MockEnvironmentService.swift index a7de9dbe0..c3e09627e 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/MockEnvironmentService.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/MockEnvironmentService.swift @@ -13,6 +13,7 @@ class MockEnvironmentService: EnvironmentService { var identityURL = URL(string: "https://example.com/identity")! var importItemsURL = URL(string: "https://example.com/#/tools/import")! var recoveryCodeURL = URL(string: "https://example.com/#/recover-2fa")! + var region = RegionType.selfHosted var sendShareURL = URL(string: "https://example.com/#/send")! var settingsURL = URL(string: "https://example.com/#/settings")! var webVaultURL = URL(string: "https://example.com")! diff --git a/BitwardenShared/UI/Auth/AuthCoordinator.swift b/BitwardenShared/UI/Auth/AuthCoordinator.swift index 32e9eb5ab..f8a2e0c14 100644 --- a/BitwardenShared/UI/Auth/AuthCoordinator.swift +++ b/BitwardenShared/UI/Auth/AuthCoordinator.swift @@ -166,6 +166,8 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt credentialIds: allowCredentialIds, userVerificationPreference: userVerificationPreference ) + case let .webAuthnSelfHosted(url): + showWebAuthnSelfHosted(authURL: url, delegate: context as? WebAuthnFlowDelegate) case let .vaultUnlock( account, animated, @@ -567,6 +569,34 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt stackNavigator?.present(navigationController) } + /// Shows the vault unlock view. + /// + /// - Parameters: + /// - account: The active account. + /// - animated: Whether to animate the transition. + /// - attemptAutmaticBiometricUnlock: Whether to the processor should attempt a biometric unlock on appear. + /// - didSwitchAccountAutomatically: A flag indicating if the active account was switched automatically. + /// + private func showVaultUnlock( + account: Account, + animated: Bool, + attemptAutmaticBiometricUnlock: Bool, + didSwitchAccountAutomatically: Bool + ) { + let processor = VaultUnlockProcessor( + appExtensionDelegate: appExtensionDelegate, + coordinator: asAnyCoordinator(), + services: services, + state: VaultUnlockState(account: account) + ) + processor.shouldAttemptAutomaticBiometricUnlock = attemptAutmaticBiometricUnlock + let view = VaultUnlockView(store: Store(processor: processor)) + stackNavigator?.replace(view, animated: animated) + if didSwitchAccountAutomatically { + processor.state.toast = Toast(text: Localizations.accountSwitchedAutomatically) + } + } + /// Show the WebAuthn two factor authentication view. /// /// - Parameters: @@ -604,32 +634,43 @@ final class AuthCoordinator: NSObject, // swiftlint:disable:this type_body_lengt authController.performRequests() } - /// Shows the vault unlock view. + /// Show the WebAuthn connector web page for self-hosted vaults. /// /// - Parameters: - /// - account: The active account. - /// - animated: Whether to animate the transition. - /// - attemptAutmaticBiometricUnlock: Whether to the processor should attempt a biometric unlock on appear. - /// - didSwitchAccountAutomatically: A flag indicating if the active account was switched automatically. + /// - url: The URL for the single sign on web auth session. + /// - delegate: A `WebAuthnFlowDelegate` object that is notified when the WebAuthn flow succeeds or fails. /// - private func showVaultUnlock( - account: Account, - animated: Bool, - attemptAutmaticBiometricUnlock: Bool, - didSwitchAccountAutomatically: Bool + private func showWebAuthnSelfHosted( + authURL url: URL, + delegate: WebAuthnFlowDelegate? ) { - let processor = VaultUnlockProcessor( - appExtensionDelegate: appExtensionDelegate, - coordinator: asAnyCoordinator(), - services: services, - state: VaultUnlockState(account: account) - ) - processor.shouldAttemptAutomaticBiometricUnlock = attemptAutmaticBiometricUnlock - let view = VaultUnlockView(store: Store(processor: processor)) - stackNavigator?.replace(view, animated: animated) - if didSwitchAccountAutomatically { - processor.state.toast = Toast(text: Localizations.accountSwitchedAutomatically) + guard let delegate else { return } + let session = services.authService.webAuthenticationSession( + url: url + ) { callbackURL, error in + if let error { + delegate.webAuthnErrored(error: error) + return + } + guard let callbackURL, + let components = URLComponents( + url: callbackURL, + resolvingAgainstBaseURL: false + ), + let queryItems = components.queryItems, + let token = queryItems.first(where: { component in + component.name == "data" + })?.value else { + delegate.webAuthnErrored(error: WebAuthnError.unableToDecodeCredential) + return + } + + delegate.webAuthnCompleted(token: token) } + + session.prefersEphemeralWebBrowserSession = false + session.presentationContextProvider = self + session.start() } } diff --git a/BitwardenShared/UI/Auth/AuthCoordinatorTests.swift b/BitwardenShared/UI/Auth/AuthCoordinatorTests.swift index c7587d9fe..986ce83e6 100644 --- a/BitwardenShared/UI/Auth/AuthCoordinatorTests.swift +++ b/BitwardenShared/UI/Auth/AuthCoordinatorTests.swift @@ -1,6 +1,9 @@ +import AuthenticationServices import SwiftUI import XCTest +// swiftlint:disable file_length + @testable import BitwardenShared // MARK: - AuthCoordinatorTests @@ -11,6 +14,7 @@ class AuthCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_b var appSettingsStore: MockAppSettingsStore! var authDelegate: MockAuthDelegate! var authRepository: MockAuthRepository! + var authService: MockAuthService! var authRouter: AuthRouter! var errorReporter: MockErrorReporter! var rootNavigator: MockRootNavigator! @@ -26,6 +30,7 @@ class AuthCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_b appSettingsStore = MockAppSettingsStore() authDelegate = MockAuthDelegate() authRepository = MockAuthRepository() + authService = MockAuthService() errorReporter = MockErrorReporter() rootNavigator = MockRootNavigator() stackNavigator = MockStackNavigator() @@ -34,6 +39,7 @@ class AuthCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_b let services = ServiceContainer.withMocks( appSettingsStore: appSettingsStore, authRepository: authRepository, + authService: authService, errorReporter: errorReporter, stateService: stateService, vaultTimeoutService: vaultTimeoutService @@ -54,6 +60,7 @@ class AuthCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_b appSettingsStore = nil authDelegate = nil authRepository = nil + authService = nil errorReporter = nil rootNavigator = nil stackNavigator = nil @@ -323,6 +330,79 @@ class AuthCoordinatorTests: BitwardenTestCase { // swiftlint:disable:this type_b XCTAssertEqual(state.orgIdentifier, "Bitwarden") } + /// `navigate(to:)` with `.webAuthnSelfHosted` opens the WebAuthn connector web page. + func test_navigate_webAuthnSelfHosted() throws { + let delegate = MockWebAuthnFlowDelegate() + + subject.navigate(to: .webAuthnSelfHosted(URL.example), context: delegate) + + guard let mockSession = authService.webAuthenticationSession as? MockWebAuthenticationSession else { + XCTFail("Did not initialize web authentication session") + return + } + + let expectedToken = "token" + let callbackUrl = URL(string: "https://www.example.com/?data=\(expectedToken)") + + XCTAssertTrue(mockSession.startCalled) + + XCTAssertEqual(mockSession.initUrl, URL.example) + XCTAssertEqual(mockSession.initCallbackURLScheme, authService.callbackUrlScheme) + + mockSession.initCompletionHandler(callbackUrl, nil) + + XCTAssertEqual(delegate.completedToken, expectedToken) + } + + /// `navigate(to:)` with `.webAuthnSelfHosted` handles errors. + func test_navigate_webAuthnSelfHosted_error() throws { + let delegate = MockWebAuthnFlowDelegate() + + subject.navigate(to: .webAuthnSelfHosted(URL.example), context: delegate) + + guard let mockSession = authService.webAuthenticationSession as? MockWebAuthenticationSession else { + XCTFail("Did not initialize web authentication session") + return + } + + XCTAssertTrue(mockSession.startCalled) + + XCTAssertEqual(mockSession.initUrl, URL.example) + XCTAssertEqual(mockSession.initCallbackURLScheme, authService.callbackUrlScheme) + + mockSession.initCompletionHandler(nil, BitwardenTestError.example) + + XCTAssertEqual(delegate.erroredError as? BitwardenTestError, BitwardenTestError.example) + } + + /// `navigate(to:)` with `.webAuthnSelfHosted` handles when the server sends unparseable credentials + func test_navigate_webAuthnSelfHosted_unableToDecode() throws { + let delegate = MockWebAuthnFlowDelegate() + + subject.navigate(to: .webAuthnSelfHosted(URL.example), context: delegate) + + guard let mockSession = authService.webAuthenticationSession as? MockWebAuthenticationSession else { + XCTFail("Did not initialize web authentication session") + return + } + + XCTAssertTrue(mockSession.startCalled) + + XCTAssertEqual(mockSession.initUrl, URL.example) + XCTAssertEqual(mockSession.initCallbackURLScheme, authService.callbackUrlScheme) + + mockSession.initCompletionHandler(nil, nil) + XCTAssertEqual(delegate.erroredError as? WebAuthnError, WebAuthnError.unableToDecodeCredential) + delegate.erroredError = nil + + mockSession.initCompletionHandler(URL(string: "https://www.example.com/junk"), nil) + XCTAssertEqual(delegate.erroredError as? WebAuthnError, WebAuthnError.unableToDecodeCredential) + delegate.erroredError = nil + + mockSession.initCompletionHandler(URL(string: "https://www.example.com/?junk=token"), nil) + XCTAssertEqual(delegate.erroredError as? WebAuthnError, WebAuthnError.unableToDecodeCredential) + } + /// `rootNavigator` uses a weak reference and does not retain a value once the root navigator has been erased. func test_rootNavigator_resetWeakReference() { var rootNavigator: MockRootNavigator? = MockRootNavigator() diff --git a/BitwardenShared/UI/Auth/AuthRoute.swift b/BitwardenShared/UI/Auth/AuthRoute.swift index 36c4ac6b0..48aea720a 100644 --- a/BitwardenShared/UI/Auth/AuthRoute.swift +++ b/BitwardenShared/UI/Auth/AuthRoute.swift @@ -123,4 +123,11 @@ public enum AuthRoute: Equatable { allowCredentialIDs: [Data], userVerificationPreference: String ) + + /// A route to the WebAuthn two-factor authentication webpage for self-hosted users. + /// Requires that any `context` provided to the coordinator conform to `WebAuthnFlowDelegate`. + /// + /// - Parameters: + /// - authUrl: The URL of the WebAuthn Connector to open. + case webAuthnSelfHosted(_ authUrl: URL) } diff --git a/BitwardenShared/UI/Auth/Login/TwoFactorAuth/TwoFactorAuthProcessor.swift b/BitwardenShared/UI/Auth/Login/TwoFactorAuth/TwoFactorAuthProcessor.swift index 8c7cb487a..1609a1e0d 100644 --- a/BitwardenShared/UI/Auth/Login/TwoFactorAuth/TwoFactorAuthProcessor.swift +++ b/BitwardenShared/UI/Auth/Login/TwoFactorAuth/TwoFactorAuthProcessor.swift @@ -414,12 +414,21 @@ protocol WebAuthnFlowDelegate: AnyObject { } public enum WebAuthnError: Error { - case unableToDecodeCredential - case unableToCreateAttestationVerification case requiredParametersMissing + case unableToCreateAttestationVerification + case unableToDecodeCredential + case unableToGenerateUrl } extension TwoFactorAuthProcessor: WebAuthnFlowDelegate { + struct WebAuthnConnectorData: Codable, Equatable { + let btnReturnText: String + let btnText: String + let callbackUri: URL + let data: String + let headerText: String + } + func webAuthnCompleted(token: String) { Task { state.verificationCode = token @@ -447,20 +456,35 @@ extension TwoFactorAuthProcessor: WebAuthnFlowDelegate { let challengeUrlDecode = try? challenge.urlDecoded(), let challengeData = Data(base64Encoded: challengeUrlDecode), let allowCredentials = webAuthnProvider.allowCredentials { - try coordinator.navigate( - to: .webAuthn(rpid: rpID, - challenge: challengeData, - allowCredentialIDs: allowCredentials.map { credential in - guard let id = credential.id, - let idUrlDecoded = try? id.urlDecoded(), - let idData = Data(base64Encoded: idUrlDecoded) else { - throw WebAuthnError.unableToDecodeCredential - } - return idData - }, - userVerificationPreference: userVerificationPreference), - context: self - ) + if services.environmentService.region == .selfHosted { + try coordinator.navigate( + to: .webAuthnSelfHosted( + webAuthnUrl( + baseUrl: services.environmentService.webVaultURL, + data: webAuthnProvider, + headerText: Localizations.fido2Title, + buttonText: Localizations.fido2AuthenticateWebAuthn, + returnButtonText: Localizations.fido2ReturnToApp + ) + ), + context: self + ) + } else { + try coordinator.navigate( + to: .webAuthn(rpid: rpID, + challenge: challengeData, + allowCredentialIDs: allowCredentials.map { credential in + guard let id = credential.id, + let idUrlDecoded = try? id.urlDecoded(), + let idData = Data(base64Encoded: idUrlDecoded) else { + throw WebAuthnError.unableToDecodeCredential + } + return idData + }, + userVerificationPreference: userVerificationPreference), + context: self + ) + } } else { throw WebAuthnError.requiredParametersMissing } @@ -472,4 +496,39 @@ extension TwoFactorAuthProcessor: WebAuthnFlowDelegate { services.errorReporter.log(error: error) } } + + /// Generates a URL to display a WebAuthn challenge for Self-Hosted vault authentication. + /// + private func webAuthnUrl( + baseUrl: URL, + data: WebAuthn, + headerText: String, + buttonText: String, + returnButtonText: String + ) throws -> URL { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys // for consistency + let callbackUrlString = "bitwarden://webauthn-callback" + let encodedCallback = callbackUrlString.urlEncoded() + let connectorData = try WebAuthnConnectorData( + btnReturnText: returnButtonText, + btnText: buttonText, + callbackUri: URL(string: callbackUrlString)!, + data: String(data: encoder.encode(data), encoding: .utf8)!, + headerText: headerText + ) + let jsonData = try encoder.encode(connectorData) + let base64string = jsonData.base64EncodedString() + + guard let url = baseUrl + .appendingPathComponent("/webauthn-mobile-connector.html") + .appending(queryItems: [ + URLQueryItem(name: "data", value: base64string), + URLQueryItem(name: "parent", value: encodedCallback), + URLQueryItem(name: "v", value: "2"), + ]) else { + throw WebAuthnError.unableToGenerateUrl + } + return url + } } // swiftlint:disable:this file_length diff --git a/BitwardenShared/UI/Auth/Login/TwoFactorAuth/TwoFactorAuthProcessorTests.swift b/BitwardenShared/UI/Auth/Login/TwoFactorAuth/TwoFactorAuthProcessorTests.swift index c060cffea..8f433b19b 100644 --- a/BitwardenShared/UI/Auth/Login/TwoFactorAuth/TwoFactorAuthProcessorTests.swift +++ b/BitwardenShared/UI/Auth/Login/TwoFactorAuth/TwoFactorAuthProcessorTests.swift @@ -221,8 +221,54 @@ class TwoFactorAuthProcessorTests: BitwardenTestCase { // swiftlint:disable:this XCTAssertEqual(errorReporter.errors.last as? WebAuthnError, .unableToDecodeCredential) } + /// `perform(_:)` with `.beginWebAuthn` initates the WebAuthn Connector flow on self-hosted vaults. + @available(iOS 16.0, *) + func test_perform_beginWebAuthn_selfHosted() async throws { + environmentService.region = .selfHosted + let testData = AuthMethodsData.fixtureWebAuthn() + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let connectorData = try TwoFactorAuthProcessor.WebAuthnConnectorData( + btnReturnText: Localizations.fido2ReturnToApp, + btnText: Localizations.fido2AuthenticateWebAuthn, + callbackUri: URL(string: "bitwarden://webauthn-callback")!, + data: String(data: encoder.encode(testData.webAuthn), encoding: .utf8)!, + headerText: Localizations.fido2Title + ) + let expectedUrl = try URL(string: "https://example.com")! + .appending(path: "/webauthn-mobile-connector.html") + .appending(queryItems: [ + URLQueryItem(name: "data", value: encoder.encode(connectorData).base64EncodedString()), + URLQueryItem(name: "parent", value: "bitwarden:__webauthn-callback"), + URLQueryItem(name: "v", value: "2"), + ])! + + subject.state.authMethod = .webAuthn + subject.state.authMethodsData = testData + await subject.perform(.beginWebAuthn) + + XCTAssertEqual( + coordinator.routes.last, + .webAuthnSelfHosted(expectedUrl) + ) + + let decoder = JSONDecoder() + guard case let .webAuthnSelfHosted(actualUrl) = coordinator.routes.last, + let actualComponents = URLComponents(url: actualUrl, resolvingAgainstBaseURL: true), + let actualbase64String = actualComponents.queryItems?.first(where: { $0.name == "data" })?.value, + let actualData = Data(base64Encoded: actualbase64String) + else { XCTFail("Unable to parse WebAuthn Connector Data"); return } + + let actualConnectorData = try decoder.decode( + TwoFactorAuthProcessor.WebAuthnConnectorData.self, + from: actualData + ) + XCTAssertEqual(actualConnectorData, connectorData) + } + /// `perform(_:)` with `.beginWebAuthn` initates the WebAuthn auth flow. func test_perform_beginWebAuthn_success() async throws { + environmentService.region = .unitedStates let testData = AuthMethodsData.fixtureWebAuthn() let rpIdExpected = try XCTUnwrap(testData.webAuthn?.rpId) let userVerificationPreferenceExpected = try XCTUnwrap(testData.webAuthn?.userVerification) @@ -572,3 +618,23 @@ class TwoFactorAuthProcessorTests: BitwardenTestCase { // swiftlint:disable:this XCTAssertTrue(subject.state.continueEnabled) } } + +// MARK: - MockWebAuthnFlowDelegate + +class MockWebAuthnFlowDelegate: WebAuthnFlowDelegate { + var completedCalled = false + var completedToken: String? + + var erroredCalled = false + var erroredError: Error? + + func webAuthnCompleted(token: String) { + completedCalled = true + completedToken = token + } + + func webAuthnErrored(error: Error) { + erroredCalled = true + erroredError = error + } +}