diff --git a/.maestro/README.md b/.maestro/README.md new file mode 100644 index 0000000000..6795cc65e1 --- /dev/null +++ b/.maestro/README.md @@ -0,0 +1,41 @@ +# Maestro smoke flows for Safari extension fixtures + +These flows are intentionally small and assume you are using the local fixture pages in `Docs/safari-extension-dev-fixtures/`. + +## Prerequisites + +1. Start the local fixture server: + +```bash +cd ~/_dev/ios +python3 -m http.server 8123 -d Docs/safari-extension-dev-fixtures +``` + +2. Ensure the iOS simulator is booted. +3. Ensure Safari's first-run onboarding has already been dismissed on that simulator. +4. Install Maestro CLI according to the official docs: + +```bash +curl -fsSL "https://get.maestro.mobile.dev" | bash +``` + +or + +```bash +brew tap mobile-dev-inc/tap +brew install mobile-dev-inc/tap/maestro +``` + +## Run a smoke flow + +```bash +maestro test .maestro/safari-signup-smoke.yaml +maestro test .maestro/safari-change-password-smoke.yaml +``` + +## Intended use + +These are not full product regressions yet. They are a lightweight starting point for: +- opening the stable local fixture page +- proving simulator + Safari + CLI wiring works +- growing toward richer Safari extension E2E checks later diff --git a/.maestro/safari-change-password-smoke.yaml b/.maestro/safari-change-password-smoke.yaml new file mode 100644 index 0000000000..08a3acfb41 --- /dev/null +++ b/.maestro/safari-change-password-smoke.yaml @@ -0,0 +1,13 @@ +appId: com.apple.mobilesafari +--- +- launchApp +- openLink: http://127.0.0.1:8123/change-password.html +- assertVisible: Update your password +- tapOn: Current password +- inputText: old-secret +- tapOn: New password +- inputText: new-secret-123 +- tapOn: Confirm new password +- inputText: new-secret-123 +- tapOn: Update password +- assertVisible: Change-password fixture intercepted submit locally. diff --git a/.maestro/safari-signup-smoke.yaml b/.maestro/safari-signup-smoke.yaml new file mode 100644 index 0000000000..0ec46be8de --- /dev/null +++ b/.maestro/safari-signup-smoke.yaml @@ -0,0 +1,13 @@ +appId: com.apple.mobilesafari +--- +- launchApp +- openLink: http://127.0.0.1:8123/signup.html +- assertVisible: Create your Bitwarden Safari test account +- tapOn: Email +- inputText: user@example.com +- tapOn: New password +- inputText: new-secret-123 +- tapOn: Confirm new password +- inputText: new-secret-123 +- tapOn: Create account +- assertVisible: Signup fixture intercepted submit locally for user@example.com. diff --git a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift index 258269b685..9aa4108376 100644 --- a/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift +++ b/AuthenticatorBridgeKit/AuthenticatorBridgeDataStore.swift @@ -42,6 +42,33 @@ public final nonisolated class AuthenticatorBridgeDataStore: @unchecked Sendable return managedObjectModel }() + static func persistedStoreURL( + fileManager: FileManager = .default, + groupIdentifier: String, + bundleIdentifier: String? = Bundle.main.bundleIdentifier, + bundlePath: String = Bundle.main.bundlePath, + containerURLProvider: (FileManager, String) -> URL? = { fileManager, groupIdentifier in + fileManager.containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier) + } + ) -> URL { + #if targetEnvironment(simulator) + if bundlePath.contains(".appex") { + let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + let directoryURL = applicationSupportURL + .appendingPathComponent(bundleIdentifier ?? "AuthenticatorBridgeExtension", isDirectory: true) + try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + return directoryURL.appendingPathComponent("\(authenticatorBridgeModelName).sqlite") + } + #endif + + if let containerURL = containerURLProvider(fileManager, groupIdentifier) { + return containerURL.appendingPathComponent("\(authenticatorBridgeModelName).sqlite") + } + + return fileManager.temporaryDirectory.appendingPathComponent("\(authenticatorBridgeModelName).sqlite") + } + // MARK: Properties /// A thread-safe lock for `backgroundContext`. Once we have a minimum of iOS 16, we can use an @@ -99,9 +126,7 @@ public final nonisolated class AuthenticatorBridgeDataStore: @unchecked Sendable case .memory: storeDescription = NSPersistentStoreDescription(url: URL(fileURLWithPath: "/dev/null")) case .persisted: - let storeURL = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier)! - .appendingPathComponent("\(authenticatorBridgeModelName).sqlite") + let storeURL = Self.persistedStoreURL(groupIdentifier: groupIdentifier) storeDescription = NSPersistentStoreDescription(url: storeURL) } persistentContainer.persistentStoreDescriptions = [storeDescription] diff --git a/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift new file mode 100644 index 0000000000..a74bb40a34 --- /dev/null +++ b/AuthenticatorBridgeKit/Tests/AuthenticatorBridgeDataStoreTests.swift @@ -0,0 +1,37 @@ +import XCTest + +@testable import AuthenticatorBridgeKit + +final class AuthenticatorBridgeDataStoreTests: XCTestCase { + func test_persistedStoreURL_prefersAppGroupContainer() { + let subject = AuthenticatorBridgeDataStore.persistedStoreURL( + groupIdentifier: "group.com.8bit.bitwarden", + bundleIdentifier: "com.8bit.bitwarden", + bundlePath: "/tmp/Bitwarden.app", + containerURLProvider: { _, _ in URL(fileURLWithPath: "/tmp/group-container", isDirectory: true) } + ) + + XCTAssertEqual(subject.path, "/tmp/group-container/Bitwarden-Authenticator.sqlite") + } + + func test_persistedStoreURL_fallsBackForSimulatorAppExtension() { + let fileManager = FileManager.default + let subject = AuthenticatorBridgeDataStore.persistedStoreURL( + fileManager: fileManager, + groupIdentifier: "group.com.8bit.bitwarden", + bundleIdentifier: "com.8bit.bitwarden.find-login-action-extension", + bundlePath: "/tmp/BitwardenActionExtension.appex", + containerURLProvider: { _, _ in nil } + ) + + let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + XCTAssertEqual( + subject.path, + applicationSupportURL + .appendingPathComponent("com.8bit.bitwarden.find-login-action-extension", isDirectory: true) + .appendingPathComponent("Bitwarden-Authenticator.sqlite") + .path + ) + } +} diff --git a/AuthenticatorShared/Core/Platform/Services/StateService.swift b/AuthenticatorShared/Core/Platform/Services/StateService.swift index bc70bd75b6..8b43adbcfc 100644 --- a/AuthenticatorShared/Core/Platform/Services/StateService.swift +++ b/AuthenticatorShared/Core/Platform/Services/StateService.swift @@ -108,7 +108,7 @@ protocol StateService: AnyObject { /// The errors thrown from a `StateService`. /// -enum StateServiceError: Error { +enum StateServiceError: Error, NonLoggableError { /// There are no known accounts. case noAccounts diff --git a/Bitwarden/Application/TestHelpers/Support/UITests-Info.plist b/Bitwarden/Application/TestHelpers/Support/UITests-Info.plist new file mode 100644 index 0000000000..6c40a6cd0c --- /dev/null +++ b/Bitwarden/Application/TestHelpers/Support/UITests-Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/Bitwarden/Application/UITests/UITestFeasibilityTests.swift b/Bitwarden/Application/UITests/UITestFeasibilityTests.swift new file mode 100644 index 0000000000..5d3479e0a0 --- /dev/null +++ b/Bitwarden/Application/UITests/UITestFeasibilityTests.swift @@ -0,0 +1,363 @@ +import XCTest + +final class UITestFeasibilityTests: XCTestCase { + @MainActor + private func dismissKeyboardIfPresent(_ app: XCUIApplication) { + if app.keyboards.element.exists { + if app.keyboards.buttons["return"].exists { + app.keyboards.buttons["return"].tap() + } else if app.keyboards.buttons["Go"].exists { + app.keyboards.buttons["Go"].tap() + } else { + app.tapCoordinate(horizontalOffset: 200, verticalOffset: 200) + } + } + } + + @MainActor + private func configureDirectSelfHosted(_ app: XCUIApplication) { + let regionSelector = app.buttons["RegionSelectorDropdown"] + XCTAssertTrue(regionSelector.waitForExistence(timeout: 8)) + regionSelector.tap() + + let selfHosted = app.buttons["Self-hosted"] + XCTAssertTrue(selfHosted.waitForExistence(timeout: 8)) + selfHosted.tap() + + let serverUrlField = app.textFields["Server URL"] + XCTAssertTrue(serverUrlField.waitForExistence(timeout: 8)) + serverUrlField.tap() + serverUrlField.typeText("password.2001y.dev") + dismissKeyboardIfPresent(app) + + let saveButton = app.buttons["SaveButton"] + XCTAssertTrue(saveButton.waitForExistence(timeout: 8)) + saveButton.tap() + } + + @MainActor + private func loginToVault(_ app: XCUIApplication) { + let logInButton = app.buttons["Log in"] + if logInButton.waitForExistence(timeout: 5) { + logInButton.tap() + } + + if app.buttons["RegionSelectorDropdown"].waitForExistence(timeout: 5) { + configureDirectSelfHosted(app) + } + + let emailField = app.textFields["LoginEmailAddressEntry"] + XCTAssertTrue(emailField.waitForExistence(timeout: 8)) + emailField.tap() + emailField.typeText("mail@tam.nz") + dismissKeyboardIfPresent(app) + + let passwordField = app.secureTextFields["LoginMasterPasswordEntry"] + if !passwordField.waitForExistence(timeout: 6) { + let continueButton = app.buttons["ContinueButton"] + XCTAssertTrue(continueButton.waitForExistence(timeout: 8)) + continueButton.tap() + XCTAssertTrue(passwordField.waitForExistence(timeout: 12)) + } + + passwordField.tap() + passwordField.typeText("Yoshiki20010920") + dismissKeyboardIfPresent(app) + + if app.buttons["OK, got it!"].waitForExistence(timeout: 3) || app.buttons["Settings"].exists { + if app.buttons["OK, got it!"].exists { + app.buttons["OK, got it!"].tap() + } + return + } + + let loginWithMasterPasswordButton = app.buttons["LogInWithMasterPasswordButton"] + if loginWithMasterPasswordButton.waitForExistence(timeout: 8) { + loginWithMasterPasswordButton.tap() + } + + let unlockPasswordField = app.secureTextFields["MasterPasswordEntry"] + if unlockPasswordField.waitForExistence(timeout: 8), app.buttons["UnlockVaultButton"].exists { + unlockPasswordField.tap() + unlockPasswordField.typeText("Yoshiki20010920") + dismissKeyboardIfPresent(app) + app.buttons["UnlockVaultButton"].tap() + } + + let okButton = app.buttons["OK, got it!"] + if okButton.waitForExistence(timeout: 8) { + okButton.tap() + } + } + + @MainActor + private func activateSafariExtension(_ app: XCUIApplication) { + let settingsTab = app.buttons["Settings"] + XCTAssertTrue(settingsTab.waitForExistence(timeout: 10)) + settingsTab.tap() + + let autofillSettings = app.buttons["AutofillSettingsButton"] + XCTAssertTrue(autofillSettings.waitForExistence(timeout: 8)) + autofillSettings.tap() + + let safariExtensionRow = app.buttons["Safari Extension"] + XCTAssertTrue(safariExtensionRow.waitForExistence(timeout: 8)) + safariExtensionRow.tap() + + let activateButton = app.buttons["Activate Safari Extension"] + if activateButton.waitForExistence(timeout: 8) { + activateButton.tap() + } + + XCTAssertTrue(app.cells["actionGroupCell"].waitForExistence(timeout: 8)) + app.buttons["BackButton"].tap() + } + + @MainActor + private func openAutofillFromSafari(_ safari: XCUIApplication) { + let moreButton = safari.buttons["MoreMenuButton"] + XCTAssertTrue(moreButton.waitForExistence(timeout: 8)) + moreButton.tap() + + let shareButton = safari.buttons["Share"] + XCTAssertTrue(shareButton.waitForExistence(timeout: 8)) + shareButton.tap() + + let reduceActionsCell = safari.cells.matching( + NSPredicate(format: "identifier == %@ AND label CONTAINS[c] %@", "actionGroupCell", "表示を減らす") + ).firstMatch + if reduceActionsCell.waitForExistence(timeout: 2) { + reduceActionsCell.tap() + } else { + let expandActionsCell = safari.cells.matching( + NSPredicate(format: "identifier == %@ AND label CONTAINS[c] %@", "actionGroupCell", "表示を増やす") + ).firstMatch + if expandActionsCell.waitForExistence(timeout: 2) { + expandActionsCell.tap() + } + } + + let bitwardenCell = safari.cells.matching( + NSPredicate(format: "label CONTAINS[c] %@", "Autofill with Bitwarden") + ).firstMatch + XCTAssertTrue(bitwardenCell.waitForExistence(timeout: 8)) + bitwardenCell.tap() + + if safari.buttons["UnlockVaultButton"].waitForExistence(timeout: 3) { + let unlockPasswordField = safari.secureTextFields["MasterPasswordEntry"] + XCTAssertTrue(unlockPasswordField.waitForExistence(timeout: 5)) + unlockPasswordField.tap() + unlockPasswordField.typeText("Yoshiki20010920") + dismissKeyboardIfPresent(safari) + + if safari.navigationBars["New login"].waitForExistence(timeout: 2) + || safari.navigationBars["Items"].exists + || safari.buttons["SaveButton"].exists { + return + } + + if safari.buttons["UnlockVaultButton"].exists { + safari.buttons["UnlockVaultButton"].tap() + } + } + } + + @MainActor + private func ensureSignupFixtureLoginExists(_ app: XCUIApplication, safari: XCUIApplication) { + openAutofillFromSafari(safari) + + let newLoginBar = safari.navigationBars["New login"] + if newLoginBar.waitForExistence(timeout: 8) { + let usernameField = safari.textFields["LoginUsernameEntry"] + XCTAssertTrue(usernameField.waitForExistence(timeout: 8)) + usernameField.tap() + usernameField.typeText("fixture-user") + + let passwordField = safari.secureTextFields["LoginPasswordEntry"] + XCTAssertTrue(passwordField.waitForExistence(timeout: 8)) + passwordField.tap() + passwordField.typeText("old-secret") + dismissKeyboardIfPresent(safari) + + let saveButton = safari.buttons["SaveButton"] + XCTAssertTrue(saveButton.waitForExistence(timeout: 8)) + saveButton.tap() + sleep(2) + return + } + + let itemsBar = safari.navigationBars["Items"] + if itemsBar.waitForExistence(timeout: 5) { + let addButton = safari.buttons["AddItemFloatingActionButton"] + if addButton.waitForExistence(timeout: 5) { + addButton.tap() + } + + let newItemButton = safari.buttons["New item"] + if newItemButton.waitForExistence(timeout: 5) { + newItemButton.tap() + } + + XCTAssertTrue(safari.navigationBars["New login"].waitForExistence(timeout: 8)) + let itemNameField = safari.textFields["ItemNameEntry"] + XCTAssertTrue(itemNameField.waitForExistence(timeout: 8)) + itemNameField.tap() + itemNameField.typeText("Bitwarden Safari Dev Fixture — Signup") + + let usernameField = safari.textFields["LoginUsernameEntry"] + usernameField.tap() + usernameField.typeText("fixture-user") + + let passwordField = safari.secureTextFields["LoginPasswordEntry"] + passwordField.tap() + passwordField.typeText("old-secret") + + let uriField = safari.textFields["LoginUriEntry"] + uriField.tap() + uriField.typeText("http://127.0.0.1:8123/login.html") + dismissKeyboardIfPresent(safari) + + let saveButton = safari.buttons["SaveButton"] + XCTAssertTrue(saveButton.waitForExistence(timeout: 8)) + saveButton.tap() + sleep(2) + } + + _ = app + } + + @MainActor + func test_directSelfHostedLoginFlow() { + let app = XCUIApplication(bundleIdentifier: "com.8bit.bitwarden") + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + + loginToVault(app) + XCTAssertTrue(app.secureTextFields["LoginMasterPasswordEntry"].waitForExistence(timeout: 1) || app.navigationBars["MainHeaderBar"].exists) + print(app.debugDescription) + } + + @MainActor + func test_signupFixtureSaveNewLogin() { + let app = XCUIApplication(bundleIdentifier: "com.8bit.bitwarden") + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + + loginToVault(app) + activateSafariExtension(app) + + safari.launch() + openAutofillFromSafari(safari) + + XCTAssertTrue(safari.navigationBars["New login"].waitForExistence(timeout: 8)) + let usernameField = safari.textFields["LoginUsernameEntry"] + XCTAssertTrue(usernameField.waitForExistence(timeout: 8)) + usernameField.tap() + usernameField.typeText("fixture-user") + + let passwordField = safari.secureTextFields["LoginPasswordEntry"] + XCTAssertTrue(passwordField.waitForExistence(timeout: 8)) + passwordField.tap() + passwordField.typeText("old-secret") + dismissKeyboardIfPresent(safari) + + let saveButton = safari.buttons["SaveButton"] + XCTAssertTrue(saveButton.waitForExistence(timeout: 8)) + saveButton.tap() + sleep(2) + print(safari.debugDescription) + } + + @MainActor + func test_loginFixtureFillMatchedCredential() { + let app = XCUIApplication(bundleIdentifier: "com.8bit.bitwarden") + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + + loginToVault(app) + activateSafariExtension(app) + + safari.launch() + ensureSignupFixtureLoginExists(app, safari: safari) + + safari.terminate() + safari.launch() + openAutofillFromSafari(safari) + + let cipherCell = safari.buttons["CipherCell"] + XCTAssertTrue(cipherCell.waitForExistence(timeout: 8)) + cipherCell.tap() + sleep(2) + + let usernameValue = safari.textFields["Email or username"].value as? String + XCTAssertEqual(usernameValue, "fixture-user") + print(safari.debugDescription) + } + + @MainActor + func test_signupFixtureGeneratePasswordFollowUp() { + let app = XCUIApplication(bundleIdentifier: "com.8bit.bitwarden") + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + + loginToVault(app) + activateSafariExtension(app) + + safari.launch() + openAutofillFromSafari(safari) + + XCTAssertTrue(safari.navigationBars["New login"].waitForExistence(timeout: 8)) + let regeneratePasswordButton = safari.buttons["RegeneratePasswordButton"] + XCTAssertTrue(regeneratePasswordButton.waitForExistence(timeout: 8)) + regeneratePasswordButton.tap() + + XCTAssertTrue(safari.navigationBars["Generator"].waitForExistence(timeout: 8)) + let selectButton = safari.buttons["SelectButton"] + XCTAssertTrue(selectButton.waitForExistence(timeout: 8)) + selectButton.tap() + + XCTAssertTrue(safari.navigationBars["New login"].waitForExistence(timeout: 8)) + XCTAssertTrue(safari.secureTextFields["LoginPasswordEntry"].waitForExistence(timeout: 8)) + print(safari.debugDescription) + } + + @MainActor + func test_changePasswordFixtureMatchedItemSelection() { + let app = XCUIApplication(bundleIdentifier: "com.8bit.bitwarden") + let safari = XCUIApplication(bundleIdentifier: "com.apple.mobilesafari") + app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + + loginToVault(app) + activateSafariExtension(app) + + safari.launch() + ensureSignupFixtureLoginExists(app, safari: safari) + + safari.terminate() + safari.launch() + openAutofillFromSafari(safari) + + let itemsBar = safari.navigationBars["Items"] + XCTAssertTrue(itemsBar.waitForExistence(timeout: 8)) + + let cipherCell = safari.buttons["CipherCell"] + XCTAssertTrue(cipherCell.waitForExistence(timeout: 8)) + cipherCell.tap() + + XCTAssertTrue(safari.buttons["Copy password"].waitForExistence(timeout: 8)) + print(safari.debugDescription) + } +} + +private extension XCUIApplication { + @MainActor + func tapCoordinate(horizontalOffset: CGFloat, verticalOffset: CGFloat) { + coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: horizontalOffset, dy: verticalOffset)) + .tap() + } +} diff --git a/BitwardenKit/Core/Platform/Extensions/Bundle+Extensions.swift b/BitwardenKit/Core/Platform/Extensions/Bundle+Extensions.swift index abcd4f66e9..0572f2dbe9 100644 --- a/BitwardenKit/Core/Platform/Extensions/Bundle+Extensions.swift +++ b/BitwardenKit/Core/Platform/Extensions/Bundle+Extensions.swift @@ -9,6 +9,9 @@ public protocol BundleProtocol { /// Return's the app's action extension identifier. var appExtensionIdentifier: String { get } + /// Return's the app's Safari web extension identifier. + var safariExtensionIdentifier: String { get } + /// Return's the app's app identifier. var appIdentifier: String { get } @@ -42,6 +45,10 @@ extension Bundle: BundleProtocol { "\(bundleIdentifier!).find-login-action-extension" } + public var safariExtensionIdentifier: String { + "\(bundleIdentifier!).safari-web-extension" + } + public var appIdentifier: String { infoDictionary?["BitwardenAppIdentifier"] as? String ?? bundleIdentifier diff --git a/BitwardenKit/Core/Platform/Extensions/BundleExtensionsTests.swift b/BitwardenKit/Core/Platform/Extensions/BundleExtensionsTests.swift new file mode 100644 index 0000000000..d68a6ab31c --- /dev/null +++ b/BitwardenKit/Core/Platform/Extensions/BundleExtensionsTests.swift @@ -0,0 +1,12 @@ +import BitwardenKitMocks +import XCTest + +@testable import BitwardenKit + +class BundleExtensionsTests: BitwardenTestCase { + func test_mockBundle_safariExtensionIdentifier() { + let subject = MockBundle() + + XCTAssertEqual(subject.safariExtensionIdentifier, "com.8bit.bitwarden.safari-web-extension") + } +} diff --git a/BitwardenKit/Core/Platform/Extensions/Error+Networking.swift b/BitwardenKit/Core/Platform/Extensions/Error+Networking.swift index 6dc3047153..ab8eb3c560 100644 --- a/BitwardenKit/Core/Platform/Extensions/Error+Networking.swift +++ b/BitwardenKit/Core/Platform/Extensions/Error+Networking.swift @@ -6,12 +6,15 @@ public extension Error { /// networking or server errors may add noise instead of being actionable errors that need to /// be fixed in the app. var isNonLoggableError: Bool { - switch self { - case is NonLoggableError, // Any error marked as `NetworkingError` - is URLError: // URLSession errors. - true - default: - false + if self is NonLoggableError || self is URLError { + return true } + + if let keychainError = self as? KeychainServiceError, + case let .osStatusError(status) = keychainError { + return status == errSecMissingEntitlement + } + + return false } } diff --git a/BitwardenKit/Core/Platform/Extensions/ErrorNetworkingTests.swift b/BitwardenKit/Core/Platform/Extensions/ErrorNetworkingTests.swift index 1d2320f660..ca6e1cab71 100644 --- a/BitwardenKit/Core/Platform/Extensions/ErrorNetworkingTests.swift +++ b/BitwardenKit/Core/Platform/Extensions/ErrorNetworkingTests.swift @@ -32,14 +32,19 @@ class ErrorNetworkingTests: BitwardenTestCase { } /// `isNetworkingError` returns `true` for `URLError`s. - func test_isNetworkingError_urlError() throws { + func test_isNonLoggableError_withURLSessionError() { XCTAssertTrue(URLError(.cancelled).isNonLoggableError) XCTAssertTrue(URLError(.networkConnectionLost).isNonLoggableError) XCTAssertTrue(URLError(.timedOut).isNonLoggableError) } - /// `isNetworkingError` returns `true` for custom `NetworkingError`. - func test_isNetworkingError_networkingError() throws { + func test_isNonLoggableError_withMissingEntitlementKeychainError() { + XCTAssertTrue(KeychainServiceError.osStatusError(errSecMissingEntitlement).isNonLoggableError) + XCTAssertFalse(KeychainServiceError.osStatusError(errSecParam).isNonLoggableError) + } + + func test_isNonLoggableError_withCustomError() { + XCTAssertTrue(TestNetworkingError.test.isNonLoggableError) } } diff --git a/BitwardenKit/Core/Platform/Extensions/Mocks/MockBundle.swift b/BitwardenKit/Core/Platform/Extensions/Mocks/MockBundle.swift index 0d23bf40c0..8892d55825 100644 --- a/BitwardenKit/Core/Platform/Extensions/Mocks/MockBundle.swift +++ b/BitwardenKit/Core/Platform/Extensions/Mocks/MockBundle.swift @@ -2,6 +2,7 @@ import BitwardenKit public class MockBundle: BundleProtocol { public var appExtensionIdentifier = "com.8bit.bitwarden.find-login-action-extension" + public var safariExtensionIdentifier = "com.8bit.bitwarden.safari-web-extension" public var appIdentifier = "com.8bit.bitwarden" public var appName = "Bitwarden" public var appVersion = "1.0" diff --git a/BitwardenKit/Core/Platform/Utilities/OSLogErrorReporter.swift b/BitwardenKit/Core/Platform/Utilities/OSLogErrorReporter.swift index 7aa20790b0..212d1c3da5 100644 --- a/BitwardenKit/Core/Platform/Utilities/OSLogErrorReporter.swift +++ b/BitwardenKit/Core/Platform/Utilities/OSLogErrorReporter.swift @@ -1,4 +1,5 @@ import OSLog +import Security /// An `ErrorReporter` that logs non-fatal errors to the console via OSLog. /// @@ -35,9 +36,26 @@ public final class OSLogErrorReporter: ErrorReporter { logger.log("Error: \(error as NSError)\n\(callStack)") } + let nsError = error as NSError + if nsError.code == Int(errSecMissingEntitlement) { + return + } + guard !error.isNonLoggableError else { return } + if let keychainError = error as? KeychainServiceError, + case let .osStatusError(status) = keychainError, + status == errSecMissingEntitlement { + return + } + #if !DISABLE_ASSERTION_FAILURE_ON_LOG_ERROR + #if targetEnvironment(simulator) + return + #endif + if Bundle.main.bundlePath.contains(".appex") { + return + } // Crash in debug builds to make the error more visible during development. assertionFailure("Unexpected error: \(error)") #endif diff --git a/BitwardenSafariWebExtension/Application/Support/BitwardenSafariWebExtension.entitlements b/BitwardenSafariWebExtension/Application/Support/BitwardenSafariWebExtension.entitlements new file mode 100644 index 0000000000..c74bd27f7c --- /dev/null +++ b/BitwardenSafariWebExtension/Application/Support/BitwardenSafariWebExtension.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + group.$(BASE_BUNDLE_ID) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BASE_BUNDLE_ID) + + + diff --git a/BitwardenSafariWebExtension/Application/Support/Info.plist b/BitwardenSafariWebExtension/Application/Support/Info.plist new file mode 100644 index 0000000000..5835734fdb --- /dev/null +++ b/BitwardenSafariWebExtension/Application/Support/Info.plist @@ -0,0 +1,39 @@ + + + + + BitwardenAppIdentifier + $(BASE_BUNDLE_ID) + BitwardenKeychainAccessGroup + $(AppIdentifierPrefix)$(BASE_BUNDLE_ID) + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Bitwarden Safari + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Bitwarden Safari Web Extension + CFBundlePackageType + XPC! + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + ITSAppUsesNonExemptEncryption + + ITSEncryptionExportComplianceCode + ecf076d3-4824-4d7b-b716-2a9a47d7d296 + NSExtension + + NSExtensionPointIdentifier + com.apple.Safari.web-extension + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).SafariWebExtensionHandler + + + diff --git a/BitwardenSafariWebExtension/Application/Support/background.js b/BitwardenSafariWebExtension/Application/Support/background.js new file mode 100644 index 0000000000..0592f9b0a4 --- /dev/null +++ b/BitwardenSafariWebExtension/Application/Support/background.js @@ -0,0 +1,99 @@ +browser.runtime.onInstalled.addListener(() => { + console.log("Bitwarden Safari Web Extension installed."); +}); + +function bitwardenParseNativeResponse(nativeResponse) { + const message = nativeResponse?.message; + if (typeof message !== "string") { + return nativeResponse; + } + + try { + return JSON.parse(message); + } catch { + return { + errorMessage: "Invalid native response payload", + id: null, + response: null, + }; + } +} + +async function bitwardenSendNativeRequest(request) { + const bridgeRequest = { + id: crypto.randomUUID(), + request, + }; + + try { + const nativeResponse = await browser.runtime.sendNativeMessage("bitwarden", { + message: JSON.stringify(bridgeRequest), + }); + + return bitwardenParseNativeResponse(nativeResponse); + } catch (error) { + return { + id: bridgeRequest.id, + response: null, + errorMessage: error && typeof error.message === 'string' && error.message.length > 0 + ? error.message + : 'Couldn’t reach the Bitwarden native host.', + }; + } +} + +function bitwardenMergeRequestContext(request, requestContext) { + if (!request || typeof request !== "object") { + return request; + } + if (!requestContext || typeof requestContext !== "object") { + return request; + } + return { + ...request, + requestContext, + }; +} + +function bitwardenMessageToRequest(message) { + if (message?.request && typeof message.request === "object") { + return bitwardenMergeRequestContext(message.request, message.requestContext); + } + + let request = null; + switch (message?.type) { + case "bitwarden:change-password": + request = { kind: "changePassword" }; + break; + case "bitwarden:fill": + request = { kind: "fill" }; + break; + case "bitwarden:generate-password": + request = { kind: "generatePassword" }; + break; + case "bitwarden:save-login": + request = { kind: "saveLogin" }; + break; + case "bitwarden:setup": + request = { kind: "setup" }; + break; + default: + request = null; + break; + } + + return bitwardenMergeRequestContext(request, message?.requestContext); +} + +browser.runtime.onMessage.addListener((message) => { + const request = bitwardenMessageToRequest(message); + if (request) { + return bitwardenSendNativeRequest(request); + } + + if (message?.type === "bitwarden:ping") { + return Promise.resolve({ type: "bitwarden:pong" }); + } + + return false; +}); diff --git a/BitwardenSafariWebExtension/Application/Support/background.node-test.js b/BitwardenSafariWebExtension/Application/Support/background.node-test.js new file mode 100644 index 0000000000..4efe4e1748 --- /dev/null +++ b/BitwardenSafariWebExtension/Application/Support/background.node-test.js @@ -0,0 +1,80 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function makeEnvironment(options = {}) { + const listeners = { onInstalled: null, onMessage: null }; + const browser = { + runtime: { + sendNativeMessage: async (name, payload) => { + if (typeof options.sendNativeMessage === 'function') { + return options.sendNativeMessage(name, payload); + } + return { message: JSON.stringify({ id: 'uuid-1', response: { submissionAction: 'none' } }) }; + }, + onInstalled: { + addListener(listener) { + listeners.onInstalled = listener; + }, + }, + onMessage: { + addListener(listener) { + listeners.onMessage = listener; + }, + }, + }, + }; + + const context = { + browser, + crypto: { randomUUID: () => 'uuid-1' }, + console, + Promise, + }; + + vm.createContext(context); + const source = fs.readFileSync('BitwardenSafariWebExtension/Application/Support/background.js', 'utf8'); + vm.runInContext(source, context); + return { context, browser, listeners }; +} + +async function testOnMessage_nativeFailure_returnsErrorEnvelope() { + const { listeners } = makeEnvironment({ + sendNativeMessage: async () => { + throw new Error('Native host missing'); + }, + }); + + const response = await listeners.onMessage({ + type: 'bitwarden:fill', + request: { kind: 'fill' }, + }); + + assert.equal(response.id, 'uuid-1'); + assert.equal(response.response, null); + assert.equal(response.errorMessage, 'Native host missing'); +} + +async function testOnMessage_invalidNativePayload_returnsParseErrorEnvelope() { + const { listeners } = makeEnvironment({ + sendNativeMessage: async () => ({ message: '{not-json' }), + }); + + const response = await listeners.onMessage({ + type: 'bitwarden:fill', + request: { kind: 'fill' }, + }); + + assert.equal(response.id, null); + assert.equal(response.response, null); + assert.equal(response.errorMessage, 'Invalid native response payload'); +} + +(async () => { + await testOnMessage_nativeFailure_returnsErrorEnvelope(); + await testOnMessage_invalidNativePayload_returnsParseErrorEnvelope(); + console.log('background node tests passed'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/BitwardenSafariWebExtension/Application/Support/content.js b/BitwardenSafariWebExtension/Application/Support/content.js new file mode 100644 index 0000000000..2fdad85123 --- /dev/null +++ b/BitwardenSafariWebExtension/Application/Support/content.js @@ -0,0 +1,1174 @@ +(() => { + function bitwardenUUID() { + return crypto.randomUUID(); + } + + function bitwardenCurrentURL() { + return window.location.href; + } + + function bitwardenTrimmedValue(value) { + if (typeof value !== "string") { + return null; + } + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + function bitwardenFieldType(element) { + const type = (element.getAttribute("type") || element.type || "text").toLowerCase(); + return type.length > 0 ? type : "text"; + } + + function bitwardenIsVisible(element) { + const style = window.getComputedStyle(element); + if (style.display === "none" || style.visibility === "hidden") { + return false; + } + + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + } + + function bitwardenLabelText(element) { + if (element.labels && element.labels.length > 0) { + return bitwardenTrimmedValue( + Array.from(element.labels) + .map((label) => label.textContent || "") + .join(" "), + ); + } + + const ariaLabel = bitwardenTrimmedValue(element.getAttribute("aria-label")); + if (ariaLabel) { + return ariaLabel; + } + + const placeholder = bitwardenTrimmedValue(element.getAttribute("placeholder")); + if (placeholder) { + return placeholder; + } + + return null; + } + + function bitwardenCollectForms(document) { + return Array.from(document.querySelectorAll("form")).reduce((forms, form, index) => { + const opid = form.dataset.bitwardenOpid || `form__${index}`; + form.dataset.bitwardenOpid = opid; + forms[opid] = { + htmlAction: form.action || bitwardenCurrentURL(), + htmlID: form.id || opid, + htmlMethod: (form.method || "get").toLowerCase(), + htmlName: form.name || form.id || opid, + opid, + }; + return forms; + }, {}); + } + + function bitwardenCollectFields(document) { + const selector = "input, select, textarea, button"; + return Array.from(document.querySelectorAll(selector)).map((element, index) => { + const opid = element.dataset.bitwardenOpid || `field__${index}`; + element.dataset.bitwardenOpid = opid; + const label = bitwardenLabelText(element); + return { + disabled: element.disabled || false, + elementNumber: index, + form: element.form?.dataset.bitwardenOpid || null, + htmlClass: bitwardenTrimmedValue(element.className), + htmlID: bitwardenTrimmedValue(element.id), + htmlName: bitwardenTrimmedValue(element.name), + "label-left": label, + "label-right": null, + "label-tag": label, + onepasswordFieldType: bitwardenFieldType(element), + opid, + placeholder: bitwardenTrimmedValue(element.getAttribute("placeholder")), + readOnly: element.readOnly || false, + text: bitwardenTrimmedValue(element.innerText || element.textContent), + type: bitwardenFieldType(element), + value: bitwardenTrimmedValue(element.value), + viewable: bitwardenIsVisible(element), + visible: bitwardenIsVisible(element), + }; + }); + } + + function bitwardenCollectPageDetails(document = window.document) { + const forms = bitwardenCollectForms(document); + const fields = bitwardenCollectFields(document); + return { + collectedTimestamp: new Date().toISOString(), + documentUUID: document.documentElement.dataset.bitwardenDocumentUUID || bitwardenUUID(), + documentUrl: document.location.href, + fields, + forms, + tabUrl: window.location.href, + title: document.title || "", + url: document.location.href, + }; + } + + function bitwardenFirstFieldValue(pageDetails, predicate) { + const field = pageDetails.fields.find(predicate); + return field?.value || null; + } + + function bitwardenPasswordFieldRole(field) { + const source = [field.htmlID, field.htmlName, field['label-tag'], field['label-left'], field.placeholder] + .filter((value) => typeof value === 'string' && value.length > 0) + .join(' ') + .toLowerCase(); + + if (/(current|old)/.test(source)) { + return 'current'; + } + if (/(confirm|verification|verify|repeat|again)/.test(source)) { + return 'confirm'; + } + if (/(new|create|choose|set)/.test(source)) { + return 'new'; + } + return 'unknown'; + } + + function bitwardenPreferredUsernameField(fields, options = {}) { + const includeHiddenEmail = options.includeHiddenEmail || false; + const preferredField = fields.find((field) => field.type === 'email' && field.viewable) + || fields.find((field) => field.type === 'text' && field.viewable) + || fields.find((field) => field.type === 'tel' && field.viewable) + || null; + + if (preferredField || !includeHiddenEmail || !bitwardenCanUseHiddenEmailUsername(fields, options.document, options.forms)) { + return preferredField; + } + + return fields.find((field) => field.type === 'email') + || null; + } + + function bitwardenCanUseHiddenEmailUsername(fields, document = window.document, forms = {}) { + const passwordFields = fields.filter((field) => field.type === 'password' && field.viewable); + if (passwordFields.some((field) => { + const role = bitwardenPasswordFieldRole(field); + return role === 'new' || role === 'confirm'; + })) { + return true; + } + + return passwordFields.length > 0 && bitwardenLooksLikeAccountSetupPage(document, forms); + } + + function bitwardenPreferredSavePasswordField(fields) { + const passwordFields = fields.filter((field) => field.type === 'password' && field.viewable); + return passwordFields.find((field) => bitwardenPasswordFieldRole(field) === 'new') + || passwordFields.find((field) => bitwardenPasswordFieldRole(field) === 'unknown') + || passwordFields.find((field) => bitwardenPasswordFieldRole(field) !== 'confirm') + || null; + } + + function bitwardenFieldText(field) { + return [field.htmlID, field.htmlName, field['label-tag'], field['label-left'], field.placeholder] + .filter((value) => typeof value === 'string' && value.length > 0) + .join(' ') + .toLowerCase(); + } + + function bitwardenSignupFieldText(field) { + const supplementalText = field.viewable && /^submit$/i.test(field.type || '') + ? [field.value, field.text] + : []; + + return [bitwardenFieldText(field), ...supplementalText] + .filter((value) => typeof value === 'string' && value.length > 0) + .join(' ') + .toLowerCase(); + } + + function bitwardenPageText(document = window.document) { + return [document?.title, document?.location?.href, window?.location?.href] + .filter((value) => typeof value === 'string' && value.length > 0) + .join(' ') + .toLowerCase(); + } + + function bitwardenFormsText(forms) { + return Object.values(forms || {}) + .flatMap((form) => [form?.htmlAction, form?.htmlName, form?.htmlID]) + .filter((value) => typeof value === 'string' && value.length > 0) + .join(' ') + .toLowerCase(); + } + + function bitwardenRelevantSignupSignals(fields, forms, candidates = []) { + const candidateFields = candidates.filter(Boolean); + const candidateFormIDs = [...new Set(candidateFields.map((field) => field?.form).filter(Boolean))]; + if (candidateFields.length > 0 && candidateFormIDs.length === 0) { + return { + fields: fields.filter((field) => !field.form), + forms: {}, + }; + } + + if (candidateFormIDs.length === 0) { + return { + fields, + forms, + }; + } + + return { + fields: fields.filter((field) => candidateFormIDs.includes(field.form)), + forms: Object.fromEntries(Object.entries(forms || {}).filter(([, form]) => candidateFormIDs.includes(form?.opid))), + }; + } + + function bitwardenLooksLikeAccountSetupPage(document = window.document, forms = {}) { + const accountSetupSource = [bitwardenFormsText(forms), bitwardenPageText(document)] + .filter((value) => typeof value === 'string' && value.length > 0) + .join(' '); + return /(accept invitation|activate( your)? account|set password|complete( your)? account)/.test(accountSetupSource); + } + + function bitwardenLooksLikePasswordResetPage(fields, document = window.document, forms = {}) { + const passwordFields = fields.filter((field) => field.type === 'password' && field.viewable); + const hasNewPassword = passwordFields.some((field) => bitwardenPasswordFieldRole(field) === 'new'); + const hasConfirmPassword = passwordFields.some((field) => bitwardenPasswordFieldRole(field) === 'confirm'); + if (!(hasNewPassword || hasConfirmPassword)) { + return false; + } + + const resetSource = [bitwardenFormsText(forms), bitwardenPageText(document)] + .filter((value) => typeof value === 'string' && value.length > 0) + .join(' '); + return /(reset|forgot|recover).{0,20}password|password.{0,20}(reset|forgot|recover)/.test(resetSource); + } + + function bitwardenLooksLikeSignupPage(fields, document = window.document, forms = {}, candidates = []) { + const signupSignals = bitwardenRelevantSignupSignals(fields, forms, candidates); + const passwordFields = signupSignals.fields.filter((field) => field.type === 'password' && field.viewable); + const hasConfirmPassword = passwordFields.some((field) => bitwardenPasswordFieldRole(field) === 'confirm'); + const hasNewPassword = passwordFields.some((field) => bitwardenPasswordFieldRole(field) === 'new'); + if (hasConfirmPassword || hasNewPassword) { + return true; + } + + if (passwordFields.length > 0 && bitwardenLooksLikeAccountSetupPage(document, signupSignals.forms)) { + return true; + } + + if (signupSignals.fields.some((field) => /(sign[ -]?up|create( your)? account|register account|join bitwarden|new account)/.test(bitwardenSignupFieldText(field)))) { + return true; + } + + if (/(sign[ -]?up|create( your)? account|register account|join bitwarden|new account)/.test(bitwardenFormsText(signupSignals.forms))) { + return true; + } + + return /(sign[ -]?up|create( your)? account|register account|join bitwarden|new account)/.test(bitwardenPageText(document)); + } + + function bitwardenSuggestPageAction(document = window.document) { + const pageDetails = bitwardenCollectPageDetails(document); + const passwordFields = pageDetails.fields.filter((field) => field.type === 'password' && field.viewable); + const hasCurrentPassword = passwordFields.some((field) => bitwardenPasswordFieldRole(field) === 'current'); + const hasNewPassword = passwordFields.some((field) => bitwardenPasswordFieldRole(field) === 'new'); + const hasConfirmPassword = passwordFields.some((field) => bitwardenPasswordFieldRole(field) === 'confirm'); + + if (hasCurrentPassword && (hasNewPassword || hasConfirmPassword)) { + return 'changePassword'; + } + + if (bitwardenLooksLikePasswordResetPage(pageDetails.fields, document, pageDetails.forms)) { + return 'changePassword'; + } + + const preferredUsernameField = bitwardenPreferredUsernameField(pageDetails.fields, { + includeHiddenEmail: true, + document, + forms: pageDetails.forms, + }); + const preferredSavePasswordField = bitwardenPreferredSavePasswordField(pageDetails.fields); + + if (bitwardenLooksLikeSignupPage( + pageDetails.fields, + document, + pageDetails.forms, + [preferredUsernameField, preferredSavePasswordField], + ) + && preferredUsernameField + && preferredSavePasswordField) { + return 'saveLogin'; + } + + return 'fill'; + } + + function bitwardenBuildRequest(kind, overrides = {}) { + return { + id: bitwardenUUID(), + request: { + kind, + ...overrides, + }, + }; + } + + function bitwardenBuildFillRequest() { + return bitwardenBuildRequest("fill", { + pageDetails: bitwardenCollectPageDetails(), + urlString: bitwardenCurrentURL(), + }); + } + + function bitwardenBuildSaveLoginRequest() { + const pageDetails = bitwardenCollectPageDetails(); + const usernameField = bitwardenPreferredUsernameField(pageDetails.fields, { + includeHiddenEmail: true, + document, + forms: pageDetails.forms, + }); + const passwordField = bitwardenPreferredSavePasswordField(pageDetails.fields); + + return bitwardenBuildRequest("saveLogin", { + loginTitle: document.title || null, + pageDetails, + password: passwordField?.value || null, + urlString: bitwardenCurrentURL(), + username: usernameField?.value || null, + }); + } + + function bitwardenBuildChangePasswordRequest() { + const pageDetails = bitwardenCollectPageDetails(); + const passwordFields = pageDetails.fields.filter((field) => field.type === "password"); + const explicitCurrentPasswordField = passwordFields.find((field) => bitwardenPasswordFieldRole(field) === 'current') || null; + const resetPasswordFlow = bitwardenLooksLikePasswordResetPage(pageDetails.fields, document, pageDetails.forms); + const currentPasswordField = explicitCurrentPasswordField || (resetPasswordFlow ? null : passwordFields.at(0) || null); + const newPasswordField = passwordFields.find((field) => bitwardenPasswordFieldRole(field) === 'new') + || passwordFields.find((field) => bitwardenPasswordFieldRole(field) === 'unknown' && field !== currentPasswordField) + || passwordFields.at(-1) + || null; + + return bitwardenBuildRequest("changePassword", { + loginTitle: document.title || null, + oldPassword: currentPasswordField?.value || null, + pageDetails, + password: newPasswordField?.value || null, + urlString: bitwardenCurrentURL(), + }); + } + + function bitwardenBuildGeneratePasswordRequest() { + return bitwardenBuildRequest("generatePassword", { + pageDetails: bitwardenCollectPageDetails(), + urlString: bitwardenCurrentURL(), + }); + } + + function bitwardenBuildSetupRequest() { + return bitwardenBuildRequest("setup", { + urlString: bitwardenCurrentURL(), + }); + } + + function bitwardenElements(document = window.document) { + return Array.from(document.querySelectorAll("input, select, textarea, button")); + } + + function bitwardenElementByOpid(opid, document = window.document) { + if (!opid) { + return null; + } + + const elements = bitwardenElements(document); + const exactMatch = elements.find((element) => element.dataset.bitwardenOpid === opid); + if (exactMatch) { + return exactMatch; + } + + const index = Number.parseInt(String(opid).split("__").at(-1), 10); + return Number.isNaN(index) ? null : elements[index] || null; + } + + function bitwardenDispatchInputEvents(element) { + if (!element || typeof element.dispatchEvent !== "function") { + return; + } + + element.dispatchEvent({ type: "input", target: element }); + element.dispatchEvent({ type: "change", target: element }); + } + + function bitwardenSetElementValue(element, value) { + if (!element || element.disabled || element.readOnly || typeof value !== "string") { + return false; + } + + if (typeof element.focus === "function") { + element.focus(); + } + element.value = value; + bitwardenDispatchInputEvents(element); + return true; + } + + function bitwardenParseFillScript(fillScriptJSON) { + if (typeof fillScriptJSON !== "string" || fillScriptJSON.length === 0) { + return null; + } + + try { + return JSON.parse(fillScriptJSON); + } catch { + return null; + } + } + + function bitwardenApplyFillScript(fillScriptJSON, document = window.document) { + const fillScript = bitwardenParseFillScript(fillScriptJSON); + if (!fillScript || !Array.isArray(fillScript.script)) { + return false; + } + + let applied = false; + for (const step of fillScript.script) { + if (!Array.isArray(step) || step.length === 0) { + continue; + } + + const [operation, opid, value] = step; + const element = bitwardenElementByOpid(opid, document); + if (!element) { + continue; + } + + switch (operation) { + case "click_on_opid": + if (typeof element.click === "function") { + element.click(); + } + break; + case "fill_by_opid": + applied = bitwardenSetElementValue(element, value) || applied; + break; + case "focus_by_opid": + if (typeof element.focus === "function") { + element.focus(); + } + break; + default: + break; + } + } + + return applied; + } + + function bitwardenApplyGeneratedPassword(generatedPassword, document = window.document) { + if (typeof generatedPassword !== "string" || generatedPassword.length === 0) { + return false; + } + + const passwordFields = bitwardenElements(document).filter( + (field) => bitwardenFieldType(field) === "password" && !field.disabled && !field.readOnly, + ); + if (passwordFields.length === 0) { + return false; + } + + let applied = false; + for (const field of passwordFields) { + applied = bitwardenSetElementValue(field, generatedPassword) || applied; + } + return applied; + } + + function bitwardenRemoveStatusBanner(document = window.document) { + const existingBanner = document.body?.querySelector?.('[data-bitwarden-status-banner]'); + if (existingBanner && typeof existingBanner.remove === "function") { + existingBanner.remove(); + } + } + + function bitwardenRemoveActionPanel(document = window.document) { + const existingPanel = document.body?.querySelector?.('[data-bitwarden-action-panel]'); + if (existingPanel && typeof existingPanel.remove === "function") { + existingPanel.remove(); + } + } + + function bitwardenNeedsActionPanel(submissionAction) { + return ["saveNewLogin", "updateExistingLogin", "updatePassword"].includes(submissionAction); + } + + function bitwardenActionPanelContent(response) { + const generatedPasswordFollowUp = response?.followUpType === 'generatedPassword'; + switch (response?.submissionAction) { + case "saveNewLogin": + return { + eyebrow: generatedPasswordFollowUp ? 'Review generated password' : 'Review before saving', + title: generatedPasswordFollowUp ? 'Save generated password' : 'Save login', + subtitle: response.userMessage || (generatedPasswordFollowUp ? 'Save this generated password to Bitwarden.' : 'Save this login to Bitwarden.'), + primaryLabel: "Save in Bitwarden", + dismissLabel: "Not now", + iconBackground: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 0.16)' : 'rgba(52, 199, 89, 0.16)', + iconColor: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 1)' : 'rgba(36, 138, 61, 1)', + eyebrowBackground: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 0.12)' : 'rgba(52, 199, 89, 0.12)', + eyebrowColor: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 1)' : 'rgba(36, 138, 61, 1)', + primaryBackground: generatedPasswordFollowUp ? 'rgba(137, 68, 171, 1)' : 'rgba(24, 122, 51, 1)', + detailBackground: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 0.08)' : 'rgba(52, 199, 89, 0.08)', + detailBorder: generatedPasswordFollowUp ? '1px solid rgba(175, 82, 222, 0.18)' : '1px solid rgba(52, 199, 89, 0.18)', + detailLabelColor: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 1)' : 'rgba(36, 138, 61, 1)', + dismissBackground: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 0.12)' : 'rgba(52, 199, 89, 0.12)', + dismissColor: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 1)' : 'rgba(36, 138, 61, 1)', + dismissBorder: generatedPasswordFollowUp ? '1px solid rgba(175, 82, 222, 0.18)' : '1px solid rgba(52, 199, 89, 0.18)', + }; + case "updateExistingLogin": + return { + eyebrow: 'Review before updating', + title: "Update login", + subtitle: response.userMessage || "Update the existing Bitwarden login with these changes.", + primaryLabel: "Update in Bitwarden", + dismissLabel: "Not now", + iconBackground: 'rgba(0, 122, 255, 0.14)', + iconColor: 'rgba(0, 122, 255, 1)', + eyebrowBackground: 'rgba(0, 122, 255, 0.12)', + eyebrowColor: 'rgba(0, 122, 255, 1)', + primaryBackground: 'rgba(0, 86, 214, 1)', + detailBackground: 'rgba(0, 122, 255, 0.08)', + detailBorder: '1px solid rgba(0, 122, 255, 0.18)', + detailLabelColor: 'rgba(0, 122, 255, 1)', + dismissBackground: 'rgba(0, 122, 255, 0.12)', + dismissColor: 'rgba(0, 122, 255, 1)', + dismissBorder: '1px solid rgba(0, 122, 255, 0.18)', + }; + case "updatePassword": + return { + eyebrow: generatedPasswordFollowUp ? 'Review generated password' : 'Review before updating', + title: generatedPasswordFollowUp ? 'Update with generated password' : 'Update password', + subtitle: response.userMessage || (generatedPasswordFollowUp ? 'Update this Bitwarden login with the generated password.' : 'Update the password for this Bitwarden login.'), + primaryLabel: "Update in Bitwarden", + dismissLabel: "Not now", + iconBackground: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 0.16)' : 'rgba(0, 122, 255, 0.14)', + iconColor: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 1)' : 'rgba(0, 122, 255, 1)', + eyebrowBackground: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 0.12)' : 'rgba(0, 122, 255, 0.12)', + eyebrowColor: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 1)' : 'rgba(0, 122, 255, 1)', + primaryBackground: generatedPasswordFollowUp ? 'rgba(137, 68, 171, 1)' : 'rgba(0, 86, 214, 1)', + detailBackground: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 0.08)' : 'rgba(0, 122, 255, 0.08)', + detailBorder: generatedPasswordFollowUp ? '1px solid rgba(175, 82, 222, 0.18)' : '1px solid rgba(0, 122, 255, 0.18)', + detailLabelColor: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 1)' : 'rgba(0, 122, 255, 1)', + dismissBackground: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 0.12)' : 'rgba(0, 122, 255, 0.12)', + dismissColor: generatedPasswordFollowUp ? 'rgba(175, 82, 222, 1)' : 'rgba(0, 122, 255, 1)', + dismissBorder: generatedPasswordFollowUp ? '1px solid rgba(175, 82, 222, 0.18)' : '1px solid rgba(0, 122, 255, 0.18)', + }; + default: + return null; + } + } + + function bitwardenActionPanelSite(response) { + const urlString = bitwardenTrimmedValue(response?.request?.urlString); + if (!urlString) { + return null; + } + + try { + const parsed = new URL(urlString); + return bitwardenTrimmedValue(parsed.host) || bitwardenTrimmedValue(parsed.hostname) || bitwardenTrimmedValue(urlString); + } catch (_error) { + const normalized = urlString + .replace(/^[a-z]+:\/\//i, '') + .split('/')[0] + .split('?')[0] + .split('#')[0] + .split('@').pop() + .trim(); + return bitwardenTrimmedValue(normalized) || bitwardenTrimmedValue(urlString); + } + } + + function bitwardenActionPanelUsername(response) { + return bitwardenTrimmedValue(response?.request?.username) + || bitwardenTrimmedValue(response?.matchedLogin?.username) + || null; + } + + function bitwardenActionPanelDetails(response) { + const details = []; + const site = bitwardenActionPanelSite(response); + const username = bitwardenActionPanelUsername(response); + const generatedPasswordFollowUp = response?.followUpType === 'generatedPassword'; + + if (site) { + details.push({ + key: 'site', + label: 'Site', + value: site, + }); + } + + if (username) { + details.push({ + key: 'username', + label: 'Username', + value: username, + }); + } + + if (generatedPasswordFollowUp) { + details.push({ + key: 'generated-password', + label: 'Password', + value: 'Generated just now', + }); + } + + return details; + } + + function bitwardenShouldPresentActionPanel(nativeResponse) { + const response = nativeResponse?.response; + const trigger = response?.request?.requestContext?.trigger; + return bitwardenNeedsActionPanel(response?.submissionAction) && trigger !== 'actionPanelPrimary'; + } + + function bitwardenActionPendingMessage(submissionAction) { + switch (submissionAction) { + case 'saveNewLogin': + return 'Saving login to Bitwarden…'; + case 'updateExistingLogin': + return 'Updating login in Bitwarden…'; + case 'updatePassword': + return 'Updating password in Bitwarden…'; + case 'fill': + return 'Filling login from Bitwarden…'; + default: + return null; + } + } + + function bitwardenStatusTone(nativeResponse) { + const response = nativeResponse?.response; + const message = response?.userMessage || nativeResponse?.errorMessage; + if ((!response || typeof response !== 'object') + && typeof nativeResponse?.errorMessage === 'string' + && nativeResponse.errorMessage.length > 0) { + return 'warning'; + } + if (typeof message === 'string' && /no matching bitwarden login found/i.test(message)) { + return 'warning'; + } + switch (response?.submissionAction) { + case 'fill': + case 'saveNewLogin': + case 'updateExistingLogin': + case 'updatePassword': + case 'generatePassword': + return 'success'; + default: + return 'info'; + } + } + + function bitwardenNativeGeneratedPasswordFollowUpResponse(response) { + if (response?.followUpType !== 'generatedPassword') { + return null; + } + + const followUpRequest = response?.followUpRequest; + const followUpSubmissionAction = response?.followUpSubmissionAction; + if (!followUpRequest || typeof followUpRequest !== 'object' || !bitwardenNeedsActionPanel(followUpSubmissionAction)) { + return null; + } + + return { + response: { + followUpType: response.followUpType, + request: followUpRequest, + submissionAction: followUpSubmissionAction, + matchedLogin: response?.matchedLogin || null, + }, + }; + } + + function bitwardenFollowUpResponseForGeneratedPassword(nativeResponse, document = window.document) { + const nativeFollowUpResponse = bitwardenNativeGeneratedPasswordFollowUpResponse(nativeResponse?.response); + if (nativeFollowUpResponse) { + return nativeFollowUpResponse; + } + + const generatedPassword = nativeResponse?.response?.generatedPassword; + if (typeof generatedPassword !== 'string' || generatedPassword.length === 0) { + return null; + } + + const pageDetails = bitwardenCollectPageDetails(document); + const suggestedAction = bitwardenSuggestPageAction(document); + switch (suggestedAction) { + case 'saveLogin': { + const usernameField = bitwardenPreferredUsernameField(pageDetails.fields, { + includeHiddenEmail: true, + document, + forms: pageDetails.forms, + }); + return { + response: { + followUpType: 'generatedPassword', + request: { + kind: 'saveLogin', + urlString: bitwardenCurrentURL(), + username: usernameField?.value || null, + }, + submissionAction: 'saveNewLogin', + userMessage: 'Save this generated password to Bitwarden.', + }, + }; + } + case 'changePassword': + return { + response: { + followUpType: 'generatedPassword', + request: { + kind: 'changePassword', + urlString: bitwardenCurrentURL(), + }, + submissionAction: 'updatePassword', + userMessage: 'Update this Bitwarden login with the generated password.', + }, + }; + default: + return null; + } + } + + function bitwardenPresentActionPanel(nativeResponse, document = window.document) { + const response = nativeResponse?.response; + if (!document?.body || !response || !bitwardenShouldPresentActionPanel(nativeResponse)) { + return null; + } + + const content = bitwardenActionPanelContent(response); + if (!content) { + return null; + } + + bitwardenRemoveActionPanel(document); + + const panel = document.createElement('div'); + panel.dataset.bitwardenActionPanel = 'true'; + panel.dataset.bitwardenActionKind = response.submissionAction; + panel.role = 'dialog'; + + const eyebrow = document.createElement('div'); + eyebrow.dataset.bitwardenActionEyebrow = 'true'; + eyebrow.textContent = content.eyebrow || ''; + + const icon = document.createElement('div'); + icon.dataset.bitwardenActionIcon = 'true'; + icon.textContent = 'B'; + + const title = document.createElement('div'); + title.dataset.bitwardenActionTitle = 'true'; + title.textContent = content.title; + + const textGroup = document.createElement('div'); + textGroup.dataset.bitwardenActionTextGroup = 'true'; + + const header = document.createElement('div'); + header.dataset.bitwardenActionHeader = 'true'; + + const subtitle = document.createElement('div'); + subtitle.dataset.bitwardenActionSubtitle = 'true'; + subtitle.textContent = content.subtitle; + + const detailRows = bitwardenActionPanelDetails(response); + const details = document.createElement('div'); + details.dataset.bitwardenActionDetails = 'true'; + details.style.display = detailRows.length > 0 ? 'grid' : 'none'; + details.style.gridTemplateColumns = 'repeat(2, minmax(0, 1fr))'; + details.style.gap = '10px'; + details.style.marginTop = detailRows.length > 0 ? '14px' : '0'; + + detailRows.forEach((detail) => { + const row = document.createElement('div'); + row.dataset.bitwardenActionDetail = detail.key; + if (detail.key === 'site') { + row.dataset.bitwardenActionDetailSite = 'true'; + } + if (detail.key === 'username') { + row.dataset.bitwardenActionDetailUsername = 'true'; + } + if (detail.key === 'generated-password') { + row.dataset.bitwardenActionDetailGeneratedPassword = 'true'; + } + row.style.padding = '12px'; + row.style.borderRadius = '14px'; + row.style.background = content.detailBackground || 'rgba(120, 120, 128, 0.08)'; + row.style.border = content.detailBorder || '1px solid rgba(60, 60, 67, 0.08)'; + + const label = document.createElement('div'); + label.textContent = detail.label; + label.style.fontSize = '12px'; + label.style.fontWeight = '600'; + label.style.color = content.detailLabelColor || 'rgba(60, 60, 67, 0.72)'; + label.style.marginBottom = '4px'; + + const value = document.createElement('div'); + value.textContent = detail.value; + value.style.fontSize = '14px'; + value.style.fontWeight = '600'; + value.style.color = '#111'; + value.style.wordBreak = 'break-word'; + + row.appendChild(label); + row.appendChild(value); + details.appendChild(row); + }); + + const buttons = document.createElement('div'); + buttons.dataset.bitwardenActionButtons = 'true'; + buttons.style.display = 'flex'; + buttons.style.gap = '10px'; + buttons.style.marginTop = '16px'; + + const primaryButton = document.createElement('button'); + primaryButton.dataset.bitwardenActionPrimary = 'true'; + primaryButton.textContent = content.primaryLabel; + primaryButton.style.background = content.primaryBackground || 'rgba(0, 122, 255, 1)'; + primaryButton.style.color = '#fff'; + primaryButton.style.border = 'none'; + primaryButton.style.borderRadius = '12px'; + primaryButton.style.padding = '12px 16px'; + primaryButton.style.fontWeight = '600'; + primaryButton.style.flex = '1'; + primaryButton.disabled = false; + primaryButton.onclick = async () => { + if (primaryButton.disabled) { + return; + } + primaryButton.disabled = true; + dismissButton.disabled = true; + const pendingMessage = bitwardenActionPendingMessage(response.submissionAction); + if (typeof pendingMessage === 'string' && pendingMessage.length > 0) { + bitwardenPresentStatusBanner(pendingMessage, document, { tone: 'progress' }); + } + try { + await bitwardenTriggerSubmissionAction(response.submissionAction); + bitwardenDispatchActionEvent({ action: response.submissionAction, confirmed: true }); + if (typeof panel.remove === 'function') { + panel.remove(); + } + } catch (error) { + const errorMessage = error && typeof error.message === 'string' && error.message.length > 0 + ? error.message + : 'Couldn’t complete the Bitwarden action.'; + bitwardenPresentStatusBanner(errorMessage, document, { tone: 'warning' }); + primaryButton.disabled = false; + dismissButton.disabled = false; + } + }; + const dismissButton = document.createElement('button'); + dismissButton.dataset.bitwardenActionDismiss = 'true'; + dismissButton.textContent = content.dismissLabel; + dismissButton.style.background = content.dismissBackground || 'rgba(120, 120, 128, 0.12)'; + dismissButton.style.color = content.dismissColor || '#111'; + dismissButton.style.border = content.dismissBorder || 'none'; + dismissButton.style.borderRadius = '12px'; + dismissButton.style.padding = '12px 16px'; + dismissButton.style.fontWeight = '500'; + dismissButton.disabled = false; + dismissButton.onclick = () => { + bitwardenDispatchActionEvent({ action: response.submissionAction, confirmed: false }); + if (typeof panel.remove === 'function') { + panel.remove(); + } + }; + if (typeof panel.appendChild === 'function') { + buttons.appendChild(primaryButton); + buttons.appendChild(dismissButton); + textGroup.appendChild(eyebrow); + textGroup.appendChild(title); + textGroup.appendChild(subtitle); + header.appendChild(icon); + header.appendChild(textGroup); + panel.appendChild(header); + panel.appendChild(details); + panel.appendChild(buttons); + } + header.style.display = 'flex'; + header.style.alignItems = 'flex-start'; + header.style.gap = '12px'; + icon.style.width = '36px'; + icon.style.height = '36px'; + icon.style.borderRadius = '999px'; + icon.style.display = 'flex'; + icon.style.alignItems = 'center'; + icon.style.justifyContent = 'center'; + icon.style.fontSize = '16px'; + icon.style.fontWeight = '700'; + icon.style.background = content.iconBackground || 'rgba(0, 122, 255, 0.14)'; + icon.style.color = content.iconColor || 'rgba(0, 122, 255, 1)'; + icon.style.flexShrink = '0'; + textGroup.style.display = 'grid'; + textGroup.style.gap = '6px'; + eyebrow.style.display = 'inline-flex'; + eyebrow.style.alignSelf = 'flex-start'; + eyebrow.style.padding = '4px 10px'; + eyebrow.style.borderRadius = '999px'; + eyebrow.style.background = content.eyebrowBackground || 'rgba(60, 60, 67, 0.08)'; + eyebrow.style.color = content.eyebrowColor || 'rgba(60, 60, 67, 0.82)'; + eyebrow.style.fontSize = '12px'; + eyebrow.style.fontWeight = '600'; + title.style.fontSize = '20px'; + title.style.fontWeight = '700'; + title.style.lineHeight = '1.2'; + subtitle.style.color = 'rgba(60, 60, 67, 0.82)'; + subtitle.style.lineHeight = '1.35'; + panel.style.position = 'fixed'; + panel.style.top = '16px'; + panel.style.left = '16px'; + panel.style.right = '16px'; + panel.style.maxWidth = '420px'; + panel.style.marginLeft = 'auto'; + panel.style.marginRight = 'auto'; + panel.style.zIndex = '2147483647'; + panel.style.padding = '16px'; + panel.style.borderRadius = '18px'; + panel.style.background = 'rgba(255, 255, 255, 0.96)'; + panel.style.color = '#111'; + panel.style.fontSize = '14px'; + panel.style.fontFamily = '-apple-system, BlinkMacSystemFont, sans-serif'; + panel.style.boxShadow = '0 16px 40px rgba(0, 0, 0, 0.16)'; + panel.style.backdropFilter = 'blur(20px)'; + panel.style.border = '1px solid rgba(60, 60, 67, 0.12)'; + document.body.appendChild(panel); + return panel; + } + + function bitwardenPresentStatusBanner(message, document = window.document, options = {}) { + if (!document?.body || typeof message !== "string" || message.length === 0) { + return null; + } + + bitwardenRemoveStatusBanner(document); + + const banner = document.createElement('div'); + banner.dataset.bitwardenStatusBanner = 'true'; + banner.dataset.bitwardenStatusTone = options.tone || 'info'; + banner.role = 'status'; + banner.ariaLive = options.tone === 'warning' ? 'assertive' : 'polite'; + banner.textContent = message; + banner.style.position = 'fixed'; + banner.style.left = '16px'; + banner.style.right = '16px'; + banner.style.bottom = '16px'; + banner.style.zIndex = '2147483647'; + banner.style.padding = '12px 16px'; + banner.style.borderRadius = '14px'; + banner.style.fontSize = '14px'; + banner.style.fontFamily = '-apple-system, BlinkMacSystemFont, sans-serif'; + banner.style.boxShadow = '0 10px 30px rgba(0, 0, 0, 0.2)'; + banner.style.border = '1px solid rgba(255, 255, 255, 0.12)'; + switch (banner.dataset.bitwardenStatusTone) { + case 'success': + banner.style.background = 'rgba(33, 118, 61, 0.96)'; + banner.style.color = '#fff'; + break; + case 'warning': + banner.style.background = 'rgba(176, 86, 0, 0.96)'; + banner.style.color = '#fff'; + break; + case 'progress': + banner.style.background = 'rgba(24, 84, 186, 0.96)'; + banner.style.color = '#fff'; + break; + default: + banner.style.background = 'rgba(28, 28, 30, 0.92)'; + banner.style.color = '#fff'; + break; + } + document.body.appendChild(banner); + setTimeout(() => { + if (typeof banner.remove === "function") { + banner.remove(); + } + }, 4000); + return banner; + } + + function bitwardenDispatchActionEvent(detail) { + const event = typeof CustomEvent === "function" + ? new CustomEvent("bitwarden:safari-extension-action", { detail }) + : { type: "bitwarden:safari-extension-action", detail }; + + if (typeof window.dispatchEvent === "function") { + window.dispatchEvent(event); + } + if (typeof document.dispatchEvent === "function") { + document.dispatchEvent(event); + } + } + + function bitwardenDispatchStatusEvent(nativeResponse) { + const detail = nativeResponse; + const event = typeof CustomEvent === "function" + ? new CustomEvent("bitwarden:safari-extension-response", { detail }) + : { type: "bitwarden:safari-extension-response", detail }; + + if (typeof window.dispatchEvent === "function") { + window.dispatchEvent(event); + } + if (typeof document.dispatchEvent === "function") { + document.dispatchEvent(event); + } + } + + async function bitwardenApplyNativeResponse(nativeResponse) { + const response = nativeResponse?.response; + const errorMessage = typeof nativeResponse?.errorMessage === 'string' ? nativeResponse.errorMessage : null; + + window.bitwardenSafariWebExtension.lastNativeResponse = nativeResponse; + + if ((!response || typeof response !== "object") && !errorMessage) { + return nativeResponse; + } + + if (errorMessage && (!response || typeof response !== "object")) { + bitwardenPresentStatusBanner(errorMessage, window.document, { tone: bitwardenStatusTone(nativeResponse) }); + bitwardenRemoveActionPanel(window.document); + bitwardenDispatchStatusEvent(nativeResponse); + return nativeResponse; + } + + if (response.submissionAction === "fill" && typeof response.fillScriptJSON === "string") { + bitwardenApplyFillScript(response.fillScriptJSON); + } + + if (typeof response.generatedPassword === "string" && response.generatedPassword.length > 0) { + bitwardenApplyGeneratedPassword(response.generatedPassword); + } + + if (typeof response.userMessage === "string" && response.userMessage.length > 0) { + bitwardenPresentStatusBanner(response.userMessage, window.document, { tone: bitwardenStatusTone(nativeResponse) }); + } + + const followUpResponse = response.submissionAction === 'generatePassword' + ? bitwardenFollowUpResponseForGeneratedPassword(nativeResponse, window.document) + : null; + + bitwardenPresentActionPanel(followUpResponse || nativeResponse); + + bitwardenDispatchStatusEvent(nativeResponse); + return nativeResponse; + } + + function bitwardenBridgeFailureResponse(error) { + return { + response: null, + errorMessage: error && typeof error.message === 'string' && error.message.length > 0 + ? error.message + : 'Couldn’t reach Bitwarden in Safari.', + }; + } + + async function bitwardenSendBuiltRequest(type, requestBuilder, requestContext = null, options = {}) { + try { + const nativeResponse = await browser.runtime.sendMessage({ + type, + request: requestBuilder().request, + requestContext, + }); + if (options.rethrowBridgeFailure + && (!nativeResponse?.response || typeof nativeResponse.response !== 'object') + && typeof nativeResponse?.errorMessage === 'string' + && nativeResponse.errorMessage.length > 0) { + throw new Error(nativeResponse.errorMessage); + } + return bitwardenApplyNativeResponse(nativeResponse); + } catch (error) { + if (options.rethrowBridgeFailure) { + throw error; + } + return bitwardenApplyNativeResponse(bitwardenBridgeFailureResponse(error)); + } + } + + async function bitwardenTriggerSuggestedAction(document = window.document) { + switch (bitwardenSuggestPageAction(document)) { + case 'changePassword': + return bitwardenSendBuiltRequest('bitwarden:change-password', bitwardenBuildChangePasswordRequest, { + trigger: 'suggestedAction', + submissionAction: 'updatePassword', + }); + case 'saveLogin': + return bitwardenSendBuiltRequest('bitwarden:save-login', bitwardenBuildSaveLoginRequest, { + trigger: 'suggestedAction', + submissionAction: 'saveNewLogin', + }); + default: + return bitwardenSendBuiltRequest('bitwarden:fill', bitwardenBuildFillRequest, { + trigger: 'suggestedAction', + submissionAction: 'fill', + }); + } + } + + async function bitwardenTriggerSubmissionAction(submissionAction) { + switch (submissionAction) { + case 'saveNewLogin': + case 'updateExistingLogin': + return bitwardenSendBuiltRequest('bitwarden:save-login', bitwardenBuildSaveLoginRequest, { + trigger: 'actionPanelPrimary', + submissionAction, + }, { + rethrowBridgeFailure: true, + }); + case 'updatePassword': + return bitwardenSendBuiltRequest('bitwarden:change-password', bitwardenBuildChangePasswordRequest, { + trigger: 'actionPanelPrimary', + submissionAction, + }, { + rethrowBridgeFailure: true, + }); + case 'fill': + return bitwardenSendBuiltRequest('bitwarden:fill', bitwardenBuildFillRequest, { + trigger: 'actionPanelPrimary', + submissionAction, + }, { + rethrowBridgeFailure: true, + }); + default: + return null; + } + } + + window.bitwardenSafariWebExtension = { + applyGeneratedPassword: bitwardenApplyGeneratedPassword, + applyFillScript: bitwardenApplyFillScript, + applyNativeResponse: bitwardenApplyNativeResponse, + presentActionPanel: bitwardenPresentActionPanel, + presentStatusBanner: bitwardenPresentStatusBanner, + shouldPresentActionPanel: bitwardenShouldPresentActionPanel, + buildRequest: bitwardenBuildRequest, + buildChangePasswordRequest: bitwardenBuildChangePasswordRequest, + buildFillRequest: bitwardenBuildFillRequest, + buildGeneratePasswordRequest: bitwardenBuildGeneratePasswordRequest, + buildSaveLoginRequest: bitwardenBuildSaveLoginRequest, + buildSetupRequest: bitwardenBuildSetupRequest, + collectPageDetails: bitwardenCollectPageDetails, + suggestPageAction: bitwardenSuggestPageAction, + triggerSuggestedAction: bitwardenTriggerSuggestedAction, + triggerSubmissionAction: bitwardenTriggerSubmissionAction, + generatePassword: () => bitwardenSendBuiltRequest("bitwarden:generate-password", bitwardenBuildGeneratePasswordRequest), + requestFill: () => bitwardenSendBuiltRequest("bitwarden:fill", bitwardenBuildFillRequest), + requestSaveLogin: () => bitwardenSendBuiltRequest("bitwarden:save-login", bitwardenBuildSaveLoginRequest), + requestChangePassword: () => bitwardenSendBuiltRequest("bitwarden:change-password", bitwardenBuildChangePasswordRequest), + requestSetup: () => bitwardenSendBuiltRequest("bitwarden:setup", bitwardenBuildSetupRequest, { + trigger: 'setupButton', + }), + }; +})(); diff --git a/BitwardenSafariWebExtension/Application/Support/content.node-test.js b/BitwardenSafariWebExtension/Application/Support/content.node-test.js new file mode 100644 index 0000000000..77cad08c6c --- /dev/null +++ b/BitwardenSafariWebExtension/Application/Support/content.node-test.js @@ -0,0 +1,1285 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); + +function createInput({ id, name, type, value = '', visible = true }) { + const element = { + id, + name, + type, + value, + disabled: false, + readOnly: false, + form: null, + labels: [], + className: '', + dataset: {}, + style: {}, + ownerDocument: null, + getAttribute(attr) { + return this[attr] ?? null; + }, + getBoundingClientRect() { + return visible ? { width: 100, height: 20 } : { width: 0, height: 0 }; + }, + focus() {}, + blur() {}, + dispatchEvent() { return true; }, + }; + return element; +} + +function createForm({ id, name, action, method = 'post' }) { + return { + id, + name, + action, + method, + dataset: {}, + }; +} + +function createButton({ id, name, type = 'submit', textContent = '', value = '', visible = true }) { + const element = createInput({ id, name, type, value, visible }); + element.textContent = textContent; + element.innerText = textContent; + return element; +} + +function makeEnvironment(elements, options = {}) { + const dispatchedEvents = []; + const title = options.title || 'Example'; + const href = options.href || 'https://example.com/login'; + const forms = options.forms || []; + const bannerContainer = { + children: [], + appendChild(node) { + this.children.push(node); + return node; + }, + querySelector(selector) { + if (selector === '[data-bitwarden-status-banner]') { + return this.children.find((child) => child.dataset?.bitwardenStatusBanner === 'true') || null; + } + if (selector === '[data-bitwarden-action-panel]') { + return this.children.find((child) => child.dataset?.bitwardenActionPanel === 'true') || null; + } + return null; + }, + }; + const document = { + title, + location: { href }, + documentElement: { dataset: {} }, + body: bannerContainer, + querySelectorAll(selector) { + if (selector === 'form') return forms; + if (selector === 'input, select, textarea, button') return elements; + if (selector === 'input[type="password"]') return elements.filter((e) => e.type === 'password'); + return []; + }, + createEvent() { + return { initEvent() {}, initKeyEvent() {} }; + }, + createElement(tagName) { + return { + tagName, + dataset: {}, + style: {}, + textContent: '', + role: null, + children: [], + appendChild(child) { + this.children.push(child); + return child; + }, + querySelector(selector) { + const findChild = (predicate) => { + for (const child of this.children) { + if (predicate(child)) { + return child; + } + if (typeof child.querySelector === 'function') { + const nested = child.querySelector(selector); + if (nested) { + return nested; + } + } + } + return null; + }; + if (selector === '[data-bitwarden-action-dismiss]') { + return findChild((child) => child.dataset?.bitwardenActionDismiss === 'true'); + } + if (selector === '[data-bitwarden-action-primary]') { + return findChild((child) => child.dataset?.bitwardenActionPrimary === 'true'); + } + if (selector === '[data-bitwarden-action-title]') { + return findChild((child) => child.dataset?.bitwardenActionTitle === 'true'); + } + if (selector === '[data-bitwarden-action-header]') { + return findChild((child) => child.dataset?.bitwardenActionHeader === 'true'); + } + if (selector === '[data-bitwarden-action-icon]') { + return findChild((child) => child.dataset?.bitwardenActionIcon === 'true'); + } + if (selector === '[data-bitwarden-action-text-group]') { + return findChild((child) => child.dataset?.bitwardenActionTextGroup === 'true'); + } + if (selector === '[data-bitwarden-action-eyebrow]') { + return findChild((child) => child.dataset?.bitwardenActionEyebrow === 'true'); + } + if (selector === '[data-bitwarden-action-subtitle]') { + return findChild((child) => child.dataset?.bitwardenActionSubtitle === 'true'); + } + if (selector === '[data-bitwarden-action-buttons]') { + return findChild((child) => child.dataset?.bitwardenActionButtons === 'true'); + } + if (selector === '[data-bitwarden-action-details]') { + return findChild((child) => child.dataset?.bitwardenActionDetails === 'true'); + } + if (selector === '[data-bitwarden-action-detail-site]') { + return findChild((child) => child.dataset?.bitwardenActionDetailSite === 'true'); + } + if (selector === '[data-bitwarden-action-detail-username]') { + return findChild((child) => child.dataset?.bitwardenActionDetailUsername === 'true'); + } + if (selector === '[data-bitwarden-action-detail-generated-password]') { + return findChild((child) => child.dataset?.bitwardenActionDetailGeneratedPassword === 'true'); + } + return null; + }, + remove() { + bannerContainer.children = bannerContainer.children.filter((child) => child !== this); + }, + }; + }, + elementFromPoint() { + return null; + }, + dispatchEvent(event) { + dispatchedEvents.push(event); + return true; + }, + }; + elements.forEach((element) => { element.ownerDocument = document; }); + const window = { + location: { href }, + document, + dispatchedEvents, + dispatchEvent(event) { + dispatchedEvents.push(event); + return true; + }, + getComputedStyle() { + return { display: 'block', visibility: 'visible' }; + }, + }; + const browser = { + runtime: { + sentMessages: [], + sendMessage: async (message) => { + browser.runtime.sentMessages.push(message); + if (typeof options.sendMessage === 'function') { + return options.sendMessage(message, browser); + } + return {}; + }, + }, + }; + const context = { + window, + document, + browser, + crypto: { randomUUID: () => 'uuid-1' }, + console, + setTimeout() { return 0; }, + clearTimeout() {}, + Event: function Event(type) { this.type = type; }, + CustomEvent: function CustomEvent(type, init = {}) { this.type = type; this.detail = init.detail; }, + }; + vm.createContext(context); + const source = fs.readFileSync('BitwardenSafariWebExtension/Application/Support/content.js', 'utf8'); + vm.runInContext(source, context); + return context; +} + +async function testApplyStatusEvent() { + const password = createInput({ id: 'password', name: 'password', type: 'password' }); + const ctx = makeEnvironment([password]); + const nativeResponse = { + response: { + submissionAction: 'updatePassword', + userMessage: 'Password updated for this login.', + }, + }; + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse(nativeResponse); + assert.equal(ctx.window.bitwardenSafariWebExtension.lastNativeResponse, nativeResponse); + const statusEvent = ctx.window.dispatchedEvents.find((event) => event.type === 'bitwarden:safari-extension-response'); + assert.ok(statusEvent); + assert.equal(statusEvent.detail.response.submissionAction, 'updatePassword'); + assert.equal(statusEvent.detail.response.userMessage, 'Password updated for this login.'); +} + +async function testApplyFillResponse_showsCompletionBannerWithoutPanel() { + const username = createInput({ id: 'username', name: 'username', type: 'text', value: '' }); + const password = createInput({ id: 'password', name: 'password', type: 'password', value: '' }); + const ctx = makeEnvironment([username, password]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'fill', + fillScriptJSON: JSON.stringify({ + script: [ + ['fill_by_opid', 'field__0', 'user@example.com'], + ['fill_by_opid', 'field__1', 'secret'], + ], + }), + userMessage: 'Filled user@example.com from Bitwarden.', + }, + }); + + const banner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(banner); + assert.equal(banner.textContent, 'Filled user@example.com from Bitwarden.'); + assert.equal(banner.dataset.bitwardenStatusTone, 'success'); + assert.equal(ctx.document.body.querySelector('[data-bitwarden-action-panel]'), null); +} + +async function testApplyFillResponse_withoutUsername_usesSiteHostCopy() { + const username = createInput({ id: 'username', name: 'username', type: 'text', value: '' }); + const password = createInput({ id: 'password', name: 'password', type: 'password', value: '' }); + const ctx = makeEnvironment([username, password]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'fill', + fillScriptJSON: JSON.stringify({ + script: [ + ['fill_by_opid', 'field__0', 'user@example.com'], + ['fill_by_opid', 'field__1', 'secret'], + ], + }), + userMessage: 'Filled login for accounts.example.com from Bitwarden.', + }, + }); + + const banner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(banner); + assert.equal(banner.textContent, 'Filled login for accounts.example.com from Bitwarden.'); + assert.equal(banner.dataset.bitwardenStatusTone, 'success'); +} + +async function testApplyNoMatchFillMessage_showsBannerWithoutPanel() { + const username = createInput({ id: 'username', name: 'username', type: 'text', value: '' }); + const password = createInput({ id: 'password', name: 'password', type: 'password', value: '' }); + const ctx = makeEnvironment([username, password]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'none', + userMessage: 'No matching Bitwarden login found for this page.', + }, + }); + + const banner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(banner); + assert.equal(banner.textContent, 'No matching Bitwarden login found for this page.'); + assert.equal(banner.dataset.bitwardenStatusTone, 'warning'); + assert.equal(ctx.document.body.querySelector('[data-bitwarden-action-panel]'), null); +} + +async function testApplySetupResponse_showsInfoBannerWithoutPanel() { + const password = createInput({ id: 'password', name: 'password', type: 'password', value: '' }); + const ctx = makeEnvironment([password]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'none', + userMessage: 'Open Bitwarden to finish Safari extension setup.', + }, + }); + + const banner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(banner); + assert.equal(banner.textContent, 'Open Bitwarden to finish Safari extension setup.'); + assert.equal(banner.dataset.bitwardenStatusTone, 'info'); + assert.equal(ctx.document.body.querySelector('[data-bitwarden-action-panel]'), null); +} + +async function testApplyNativeErrorMessage_showsWarningBannerWithoutPanel() { + const username = createInput({ id: 'username', name: 'username', type: 'text', value: '' }); + const password = createInput({ id: 'password', name: 'password', type: 'password', value: '' }); + const ctx = makeEnvironment([username, password]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + errorMessage: 'Native bridge failed.', + }); + + const banner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(banner); + assert.equal(banner.textContent, 'Native bridge failed.'); + assert.equal(banner.dataset.bitwardenStatusTone, 'warning'); + assert.equal(ctx.document.body.querySelector('[data-bitwarden-action-panel]'), null); +} + +async function testApplyNativeErrorMessage_withResponse_prefersResponseHandling() { + const username = createInput({ id: 'username', name: 'username', type: 'text', value: '' }); + const password = createInput({ id: 'password', name: 'password', type: 'password', value: '' }); + const ctx = makeEnvironment([username, password]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + errorMessage: 'Native bridge failed.', + response: { + submissionAction: 'fill', + fillScriptJSON: JSON.stringify({ + script: [ + ['fill_by_opid', 'field__0', 'user@example.com'], + ['fill_by_opid', 'field__1', 'secret'], + ], + }), + userMessage: 'Filled user@example.com from Bitwarden.', + }, + }); + + assert.equal(username.value, 'user@example.com'); + assert.equal(password.value, 'secret'); + const banner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(banner); + assert.equal(banner.textContent, 'Filled user@example.com from Bitwarden.'); + assert.equal(banner.dataset.bitwardenStatusTone, 'success'); + assert.equal(ctx.document.body.querySelector('[data-bitwarden-action-panel]'), null); +} + +async function testApplyStatusBanner() { + const password = createInput({ id: 'password', name: 'password', type: 'password' }); + const ctx = makeEnvironment([password]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + request: { + kind: 'saveLogin', + urlString: 'https://vault.example.com/login', + username: 'user@example.com', + }, + submissionAction: 'saveNewLogin', + userMessage: 'Save this login to Bitwarden.', + }, + }); + + const banner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(banner); + assert.equal(banner.textContent, 'Save this login to Bitwarden.'); + assert.equal(banner.role, 'status'); + + const actionPanel = ctx.document.body.querySelector('[data-bitwarden-action-panel]'); + assert.ok(actionPanel); + assert.equal(actionPanel.dataset.bitwardenActionKind, 'saveNewLogin'); + assert.equal(actionPanel.role, 'dialog'); + const title = actionPanel.querySelector('[data-bitwarden-action-title]'); + const header = actionPanel.querySelector('[data-bitwarden-action-header]'); + const icon = actionPanel.querySelector('[data-bitwarden-action-icon]'); + const textGroup = actionPanel.querySelector('[data-bitwarden-action-text-group]'); + const eyebrow = actionPanel.querySelector('[data-bitwarden-action-eyebrow]'); + const subtitle = actionPanel.querySelector('[data-bitwarden-action-subtitle]'); + const details = actionPanel.querySelector('[data-bitwarden-action-details]'); + const siteDetail = actionPanel.querySelector('[data-bitwarden-action-detail-site]'); + const usernameDetail = actionPanel.querySelector('[data-bitwarden-action-detail-username]'); + const buttons = actionPanel.querySelector('[data-bitwarden-action-buttons]'); + assert.ok(title); + assert.ok(header); + assert.ok(icon); + assert.ok(textGroup); + assert.ok(eyebrow); + assert.ok(subtitle); + assert.ok(details); + assert.ok(siteDetail); + assert.ok(usernameDetail); + assert.ok(buttons); + assert.equal(eyebrow.textContent, 'Review before saving'); + assert.equal(icon.textContent, 'B'); + assert.equal(icon.style.background, 'rgba(52, 199, 89, 0.16)'); + assert.equal(icon.style.color, 'rgba(36, 138, 61, 1)'); + assert.equal(title.textContent, 'Save login'); + assert.equal(subtitle.textContent, 'Save this login to Bitwarden.'); + assert.equal(siteDetail.textContent, ''); + assert.equal(siteDetail.children.length, 2); + assert.equal(siteDetail.children[0].textContent, 'Site'); + assert.equal(siteDetail.children[1].textContent, 'vault.example.com'); + assert.equal(usernameDetail.textContent, ''); + assert.equal(usernameDetail.children.length, 2); + assert.equal(usernameDetail.children[0].textContent, 'Username'); + assert.equal(usernameDetail.children[1].textContent, 'user@example.com'); + assert.equal(siteDetail.style.background, 'rgba(52, 199, 89, 0.08)'); + assert.equal(siteDetail.style.border, '1px solid rgba(52, 199, 89, 0.18)'); + assert.equal(siteDetail.children[0].style.color, 'rgba(36, 138, 61, 1)'); + const primary = buttons.querySelector('[data-bitwarden-action-primary]'); + const dismiss = buttons.querySelector('[data-bitwarden-action-dismiss]'); + assert.equal(primary.textContent, 'Save in Bitwarden'); + assert.equal(dismiss.textContent, 'Not now'); + assert.equal(buttons.style.display, 'flex'); + assert.equal(eyebrow.style.background, 'rgba(52, 199, 89, 0.12)'); + assert.equal(eyebrow.style.color, 'rgba(36, 138, 61, 1)'); + assert.equal(primary.style.background, 'rgba(24, 122, 51, 1)'); + assert.equal(actionPanel.style.maxWidth, '420px'); + assert.equal(actionPanel.style.marginLeft, 'auto'); + assert.equal(actionPanel.style.marginRight, 'auto'); + assert.equal(primary.style.color, '#fff'); + assert.equal(dismiss.style.background, 'rgba(52, 199, 89, 0.12)'); + assert.equal(dismiss.style.color, 'rgba(36, 138, 61, 1)'); + assert.equal(dismiss.style.border, '1px solid rgba(52, 199, 89, 0.18)'); +} + +async function testApplyStatusBanner_doesNotReopenPanelForConfirmedAction() { + const password = createInput({ id: 'password', name: 'password', type: 'password' }); + const ctx = makeEnvironment([password]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + request: { + kind: 'saveLogin', + requestContext: { + trigger: 'actionPanelPrimary', + submissionAction: 'saveNewLogin', + }, + }, + submissionAction: 'saveNewLogin', + userMessage: 'Save this login to Bitwarden.', + }, + }); + + const banner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(banner); + assert.equal(banner.textContent, 'Save this login to Bitwarden.'); + assert.equal(ctx.document.body.querySelector('[data-bitwarden-action-panel]'), null); +} + +async function testApplyGeneratedPassword_showsSaveLoginFollowUpPanel() { + const email = createInput({ id: 'email', name: 'email', type: 'email', value: 'user@example.com' }); + const password = createInput({ id: 'new-password', name: 'newPassword', type: 'password', value: '' }); + password.placeholder = 'New password'; + const confirmPassword = createInput({ id: 'confirm-password', name: 'confirmPassword', type: 'password', value: '' }); + confirmPassword.placeholder = 'Confirm password'; + const ctx = makeEnvironment([email, password, confirmPassword], { + href: 'https://signup.example.com/register', + }); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'generatePassword', + generatedPassword: 'generated-secret', + userMessage: 'Generated password with Bitwarden.', + }, + }); + + const banner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(banner); + assert.equal(banner.textContent, 'Generated password with Bitwarden.'); + assert.equal(banner.dataset.bitwardenStatusTone, 'success'); + + const actionPanel = ctx.document.body.querySelector('[data-bitwarden-action-panel]'); + assert.ok(actionPanel); + assert.equal(actionPanel.dataset.bitwardenActionKind, 'saveNewLogin'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-eyebrow]').textContent, 'Review generated password'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-title]').textContent, 'Save generated password'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-icon]').style.background, 'rgba(175, 82, 222, 0.16)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-icon]').style.color, 'rgba(175, 82, 222, 1)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-eyebrow]').style.background, 'rgba(175, 82, 222, 0.12)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-eyebrow]').style.color, 'rgba(175, 82, 222, 1)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-primary]').style.background, 'rgba(137, 68, 171, 1)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-subtitle]').textContent, 'Save this generated password to Bitwarden.'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-primary]').textContent, 'Save in Bitwarden'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').textContent, ''); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').children[0].textContent, 'Site'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').children[1].textContent, 'signup.example.com'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-username]').textContent, ''); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-username]').children[0].textContent, 'Username'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-username]').children[1].textContent, 'user@example.com'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-generated-password]').textContent, ''); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-generated-password]').children[0].textContent, 'Password'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-generated-password]').children[1].textContent, 'Generated just now'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').style.background, 'rgba(175, 82, 222, 0.08)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').style.border, '1px solid rgba(175, 82, 222, 0.18)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-generated-password]').children[0].style.color, 'rgba(175, 82, 222, 1)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-dismiss]').style.background, 'rgba(175, 82, 222, 0.12)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-dismiss]').style.color, 'rgba(175, 82, 222, 1)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-dismiss]').style.border, '1px solid rgba(175, 82, 222, 0.18)'); +} + +async function testApplyGeneratedPassword_showsUpdatePasswordFollowUpPanel() { + const currentPassword = createInput({ id: 'current-password', name: 'currentPassword', type: 'password', value: 'old-secret' }); + currentPassword.placeholder = 'Current password'; + const newPassword = createInput({ id: 'new-password', name: 'newPassword', type: 'password', value: '' }); + newPassword.placeholder = 'New password'; + const confirmPassword = createInput({ id: 'confirm-password', name: 'confirmPassword', type: 'password', value: '' }); + confirmPassword.placeholder = 'Confirm password'; + const ctx = makeEnvironment([currentPassword, newPassword, confirmPassword], { + href: 'https://accounts.example.com/change-password', + }); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'generatePassword', + generatedPassword: 'generated-secret', + }, + }); + + const actionPanel = ctx.document.body.querySelector('[data-bitwarden-action-panel]'); + assert.ok(actionPanel); + assert.equal(actionPanel.dataset.bitwardenActionKind, 'updatePassword'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-eyebrow]').textContent, 'Review generated password'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-title]').textContent, 'Update with generated password'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-subtitle]').textContent, 'Update this Bitwarden login with the generated password.'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-primary]').textContent, 'Update in Bitwarden'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-primary]').style.background, 'rgba(137, 68, 171, 1)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').textContent, ''); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').children[0].textContent, 'Site'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').children[1].textContent, 'accounts.example.com'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-generated-password]').textContent, ''); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-generated-password]').children[0].textContent, 'Password'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-generated-password]').children[1].textContent, 'Generated just now'); +} + +async function testApplyUpdateExistingLogin_showsStructuredPanelCopy() { + const email = createInput({ id: 'email', name: 'email', type: 'email', value: 'user@example.com' }); + const password = createInput({ id: 'password', name: 'password', type: 'password', value: 'secret' }); + const ctx = makeEnvironment([email, password]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + request: { + kind: 'saveLogin', + urlString: 'https://accounts.example.com/sign-in', + }, + matchedLogin: { + username: 'matched@example.com', + }, + submissionAction: 'updateExistingLogin', + userMessage: 'Update the existing Bitwarden login with these changes.', + }, + }); + + const actionPanel = ctx.document.body.querySelector('[data-bitwarden-action-panel]'); + assert.ok(actionPanel); + assert.equal(actionPanel.dataset.bitwardenActionKind, 'updateExistingLogin'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-eyebrow]').textContent, 'Review before updating'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-title]').textContent, 'Update login'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-subtitle]').textContent, 'Update the existing Bitwarden login with these changes.'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-icon]').style.background, 'rgba(0, 122, 255, 0.14)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-icon]').style.color, 'rgba(0, 122, 255, 1)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-eyebrow]').style.background, 'rgba(0, 122, 255, 0.12)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-eyebrow]').style.color, 'rgba(0, 122, 255, 1)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-primary]').style.background, 'rgba(0, 86, 214, 1)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').textContent, ''); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').children[0].textContent, 'Site'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').children[1].textContent, 'accounts.example.com'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-username]').textContent, ''); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-username]').children[0].textContent, 'Username'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-username]').children[1].textContent, 'matched@example.com'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').style.background, 'rgba(0, 122, 255, 0.08)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').style.border, '1px solid rgba(0, 122, 255, 0.18)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-username]').children[0].style.color, 'rgba(0, 122, 255, 1)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-dismiss]').style.background, 'rgba(0, 122, 255, 0.12)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-dismiss]').style.color, 'rgba(0, 122, 255, 1)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-dismiss]').style.border, '1px solid rgba(0, 122, 255, 0.18)'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-primary]').textContent, 'Update in Bitwarden'); +} + +async function testApplyUpdateExistingLogin_stripsSensitiveURLPartsFromSiteDetail() { + const email = createInput({ id: 'email', name: 'email', type: 'email', value: 'user@example.com' }); + const password = createInput({ id: 'password', name: 'password', type: 'password', value: 'new-secret' }); + const ctx = makeEnvironment([email, password]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + request: { + kind: 'saveLogin', + urlString: 'https://user:pass@accounts.example.com/sign-in?token=abc#frag', + }, + matchedLogin: { + username: 'matched@example.com', + }, + submissionAction: 'updateExistingLogin', + userMessage: 'Update the existing Bitwarden login with these changes.', + }, + }); + + const actionPanel = ctx.document.body.querySelector('[data-bitwarden-action-panel]'); + assert.ok(actionPanel); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').children[1].textContent, 'accounts.example.com'); +} + +async function testApplyGeneratedPassword_prefersNativeProvidedFollowUpContext() { + const currentPassword = createInput({ id: 'current-password', name: 'currentPassword', type: 'password', value: 'old-secret' }); + currentPassword.placeholder = 'Current password'; + const newPassword = createInput({ id: 'new-password', name: 'newPassword', type: 'password', value: '' }); + newPassword.placeholder = 'New password'; + const confirmPassword = createInput({ id: 'confirm-password', name: 'confirmPassword', type: 'password', value: '' }); + confirmPassword.placeholder = 'Confirm password'; + const ctx = makeEnvironment([currentPassword, newPassword, confirmPassword], { + href: 'https://accounts.example.com/change-password', + }); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'generatePassword', + generatedPassword: 'generated-secret', + followUpType: 'generatedPassword', + followUpRequest: { + kind: 'saveLogin', + urlString: 'https://signup.example.com/register', + username: 'native@example.com', + }, + followUpSubmissionAction: 'saveNewLogin', + }, + }); + + const actionPanel = ctx.document.body.querySelector('[data-bitwarden-action-panel]'); + assert.ok(actionPanel); + assert.equal(actionPanel.dataset.bitwardenActionKind, 'saveNewLogin'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-title]').textContent, 'Save generated password'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-site]').children[1].textContent, 'signup.example.com'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-detail-username]').children[1].textContent, 'native@example.com'); +} + +async function testApplyGeneratedPasswordFailure_showsErrorBannerWithoutFollowUpPanel() { + const email = createInput({ id: 'email', name: 'email', type: 'email', value: 'user@example.com' }); + const password = createInput({ id: 'new-password', name: 'newPassword', type: 'password', value: '' }); + password.placeholder = 'New password'; + const confirmPassword = createInput({ id: 'confirm-password', name: 'confirmPassword', type: 'password', value: '' }); + confirmPassword.placeholder = 'Confirm password'; + const ctx = makeEnvironment([email, password, confirmPassword]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'none', + userMessage: 'Couldn’t generate a password in Bitwarden.', + }, + }); + + const banner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(banner); + assert.equal(banner.textContent, 'Couldn’t generate a password in Bitwarden.'); + assert.equal(banner.dataset.bitwardenStatusTone, 'info'); + assert.equal(ctx.document.body.querySelector('[data-bitwarden-action-panel]'), null); +} + +async function testActionPanelPrimaryDispatchesConfirmEvent() { + const password = createInput({ id: 'password', name: 'password', type: 'password' }); + const ctx = makeEnvironment([password]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'saveNewLogin', + userMessage: 'Save this login to Bitwarden.', + }, + }); + + const actionPanel = ctx.document.body.querySelector('[data-bitwarden-action-panel]'); + assert.ok(actionPanel); + const primaryButton = actionPanel.querySelector('[data-bitwarden-action-primary]'); + assert.ok(primaryButton); + await primaryButton.onclick(); + + const confirmEvent = ctx.window.dispatchedEvents.find((event) => event.type === 'bitwarden:safari-extension-action'); + assert.ok(confirmEvent); + assert.equal(confirmEvent.detail.action, 'saveNewLogin'); + assert.equal(confirmEvent.detail.confirmed, true); + assert.equal(ctx.browser.runtime.sentMessages.at(-1).type, 'bitwarden:save-login'); + assert.equal(ctx.browser.runtime.sentMessages.at(-1).requestContext.trigger, 'actionPanelPrimary'); + assert.equal(ctx.browser.runtime.sentMessages.at(-1).requestContext.submissionAction, 'saveNewLogin'); + assert.equal(ctx.document.body.querySelector('[data-bitwarden-action-panel]'), null); +} + +async function testActionPanelPrimaryShowsPendingBannerWhileSaving() { + const password = createInput({ id: 'password', name: 'password', type: 'password', value: 'secret' }); + let resolveSendMessage; + const sendMessagePromise = new Promise((resolve) => { + resolveSendMessage = resolve; + }); + const ctx = makeEnvironment([password], { + sendMessage: () => sendMessagePromise, + }); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'saveNewLogin', + userMessage: 'Save this login to Bitwarden.', + }, + }); + + const actionPanel = ctx.document.body.querySelector('[data-bitwarden-action-panel]'); + assert.ok(actionPanel); + const primaryButton = actionPanel.querySelector('[data-bitwarden-action-primary]'); + assert.ok(primaryButton); + const dismissButton = actionPanel.querySelector('[data-bitwarden-action-dismiss]'); + assert.ok(dismissButton); + + const clickPromise = primaryButton.onclick(); + const pendingBanner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(pendingBanner); + assert.equal(pendingBanner.textContent, 'Saving login to Bitwarden…'); + assert.equal(pendingBanner.dataset.bitwardenStatusTone, 'progress'); + assert.equal(ctx.browser.runtime.sentMessages.at(-1).requestContext.submissionAction, 'saveNewLogin'); + assert.equal(primaryButton.disabled, true); + assert.equal(dismissButton.disabled, true); + + await primaryButton.onclick(); + assert.equal(ctx.browser.runtime.sentMessages.length, 1); + + resolveSendMessage({ response: { submissionAction: 'saveNewLogin', userMessage: 'Saved login to Bitwarden.' } }); + await clickPromise; + assert.equal(ctx.document.body.querySelector('[data-bitwarden-status-banner]').textContent, 'Saved login to Bitwarden.'); +} + +async function testUpdatePasswordPanelShowsSpecificTitle() { + const currentPassword = createInput({ id: 'current-password', name: 'currentPassword', type: 'password', value: 'old-secret' }); + currentPassword.placeholder = 'Current password'; + const newPassword = createInput({ id: 'new-password', name: 'newPassword', type: 'password', value: 'new-secret' }); + newPassword.placeholder = 'New password'; + const confirmPassword = createInput({ id: 'confirm-password', name: 'confirmPassword', type: 'password', value: 'new-secret' }); + confirmPassword.placeholder = 'Confirm password'; + const ctx = makeEnvironment([currentPassword, newPassword, confirmPassword]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'updatePassword', + userMessage: 'Update the password for this Bitwarden login.', + }, + }); + + const actionPanel = ctx.document.body.querySelector('[data-bitwarden-action-panel]'); + assert.ok(actionPanel); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-title]').textContent, 'Update password'); + assert.equal(actionPanel.querySelector('[data-bitwarden-action-subtitle]').textContent, 'Update the password for this Bitwarden login.'); + + const primaryButton = actionPanel.querySelector('[data-bitwarden-action-primary]'); + assert.ok(primaryButton); + await primaryButton.onclick(); + assert.equal(ctx.browser.runtime.sentMessages.at(-1).type, 'bitwarden:change-password'); + assert.equal(ctx.browser.runtime.sentMessages.at(-1).requestContext.trigger, 'actionPanelPrimary'); + assert.equal(ctx.browser.runtime.sentMessages.at(-1).requestContext.submissionAction, 'updatePassword'); +} + +async function testActionPanelDismissRemovesPanel() { + const password = createInput({ id: 'password', name: 'password', type: 'password' }); + const ctx = makeEnvironment([password]); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'saveNewLogin', + userMessage: 'Save this login to Bitwarden.', + }, + }); + + const actionPanel = ctx.document.body.querySelector('[data-bitwarden-action-panel]'); + assert.ok(actionPanel); + const dismissButton = actionPanel.querySelector('[data-bitwarden-action-dismiss]'); + assert.ok(dismissButton); + dismissButton.onclick(); + const dismissEvent = ctx.window.dispatchedEvents.find((event) => event.type === 'bitwarden:safari-extension-action' && event.detail?.confirmed === false); + assert.ok(dismissEvent); + assert.equal(dismissEvent.detail.action, 'saveNewLogin'); + assert.equal(dismissEvent.detail.confirmed, false); + assert.equal(ctx.document.body.querySelector('[data-bitwarden-action-panel]'), null); +} + +function testBuildChangePasswordRequest_usesCurrentAndNewPasswordHeuristics() { + const currentPassword = createInput({ id: 'current-password', name: 'currentPassword', type: 'password', value: 'old-secret' }); + currentPassword.placeholder = 'Current password'; + const newPassword = createInput({ id: 'new-password', name: 'newPassword', type: 'password', value: 'new-secret' }); + newPassword.placeholder = 'New password'; + const confirmPassword = createInput({ id: 'confirm-password', name: 'confirmPassword', type: 'password', value: 'confirm-secret' }); + confirmPassword.placeholder = 'Confirm password'; + + let ctx = makeEnvironment([currentPassword, newPassword, confirmPassword]); + let built = ctx.window.bitwardenSafariWebExtension.buildChangePasswordRequest(); + + assert.equal(built.request.kind, 'changePassword'); + assert.equal(built.request.oldPassword, 'old-secret'); + assert.equal(built.request.password, 'new-secret'); + + const resetEmail = createInput({ id: 'reset-email', name: 'email', type: 'email', value: 'user@example.com' }); + const resetPassword = createInput({ id: 'reset-password', name: 'newPassword', type: 'password', value: 'reset-secret' }); + resetPassword.placeholder = 'New password'; + const resetConfirm = createInput({ id: 'reset-confirm', name: 'confirmPassword', type: 'password', value: 'reset-secret' }); + resetConfirm.placeholder = 'Confirm password'; + + ctx = makeEnvironment([resetEmail, resetPassword, resetConfirm], { + title: 'Reset your password', + href: 'https://example.com/reset-password', + }); + built = ctx.window.bitwardenSafariWebExtension.buildChangePasswordRequest(); + + assert.equal(built.request.kind, 'changePassword'); + assert.equal(built.request.oldPassword, null); + assert.equal(built.request.password, 'reset-secret'); +} + +function testBuildSaveLoginRequest_prefersEmailAndIgnoresConfirmPassword() { + const username = createInput({ id: 'username', name: 'username', type: 'text', value: 'display-name' }); + username.placeholder = 'Username'; + const email = createInput({ id: 'email', name: 'email', type: 'email', value: 'user@example.com' }); + email.placeholder = 'Email address'; + const confirmPassword = createInput({ id: 'confirm-password', name: 'confirmPassword', type: 'password', value: 'confirm-secret' }); + confirmPassword.placeholder = 'Confirm password'; + const password = createInput({ id: 'password', name: 'password', type: 'password', value: 'signup-secret' }); + password.placeholder = 'Create password'; + + let ctx = makeEnvironment([username, email, confirmPassword, password]); + let built = ctx.window.bitwardenSafariWebExtension.buildSaveLoginRequest(); + + assert.equal(built.request.kind, 'saveLogin'); + assert.equal(built.request.username, 'user@example.com'); + assert.equal(built.request.password, 'signup-secret'); + + const hiddenEmail = createInput({ id: 'hidden-email', name: 'email', type: 'email', value: 'hidden-user@example.com', visible: false }); + const hiddenSignupPassword = createInput({ id: 'signup-password', name: 'password', type: 'password', value: 'hidden-signup-secret' }); + hiddenSignupPassword.placeholder = 'Create password'; + const hiddenSignupConfirm = createInput({ id: 'signup-confirm', name: 'confirmPassword', type: 'password', value: 'hidden-signup-secret' }); + hiddenSignupConfirm.placeholder = 'Confirm password'; + + ctx = makeEnvironment([hiddenEmail, hiddenSignupPassword, hiddenSignupConfirm], { + title: 'Create your account', + href: 'https://example.com/account/create/password', + }); + built = ctx.window.bitwardenSafariWebExtension.buildSaveLoginRequest(); + + assert.equal(built.request.kind, 'saveLogin'); + assert.equal(built.request.username, 'hidden-user@example.com'); + assert.equal(built.request.password, 'hidden-signup-secret'); + + const hiddenText = createInput({ id: 'hidden-referral', name: 'referralCode', type: 'text', value: 'invite-code', visible: false }); + ctx = makeEnvironment([hiddenText, hiddenSignupPassword, hiddenSignupConfirm], { + title: 'Create your account', + href: 'https://example.com/account/create/password', + }); + built = ctx.window.bitwardenSafariWebExtension.buildSaveLoginRequest(); + + assert.equal(built.request.username, null); + + const hiddenLoginEmail = createInput({ id: 'login-email-hidden', name: 'email', type: 'email', value: 'returning-user@example.com', visible: false }); + const hiddenLoginPassword = createInput({ id: 'login-password-visible', name: 'password', type: 'password', value: 'login-secret' }); + hiddenLoginPassword.placeholder = 'Password'; + ctx = makeEnvironment([hiddenLoginEmail, hiddenLoginPassword], { + title: 'Sign in', + href: 'https://example.com/login/password', + }); + built = ctx.window.bitwardenSafariWebExtension.buildSaveLoginRequest(); + + assert.equal(built.request.username, null); + + const inviteEmail = createInput({ id: 'invite-email-hidden', name: 'email', type: 'email', value: 'invited-user@example.com', visible: false }); + const invitePassword = createInput({ id: 'invite-password-visible', name: 'password', type: 'password', value: 'invite-secret' }); + invitePassword.placeholder = 'Password'; + ctx = makeEnvironment([inviteEmail, invitePassword], { + title: 'Accept invitation', + href: 'https://example.com/invite/accept', + }); + built = ctx.window.bitwardenSafariWebExtension.buildSaveLoginRequest(); + + assert.equal(built.request.username, 'invited-user@example.com'); + assert.equal(built.request.password, 'invite-secret'); + + const activationEmail = createInput({ id: 'activation-email-hidden', name: 'email', type: 'email', value: 'member@example.com', visible: false }); + const activationPassword = createInput({ id: 'activation-password-visible', name: 'password', type: 'password', value: 'activation-secret' }); + activationPassword.placeholder = 'Password'; + ctx = makeEnvironment([activationEmail, activationPassword], { + title: 'Activation required', + href: 'https://example.com/activation/check', + }); + built = ctx.window.bitwardenSafariWebExtension.buildSaveLoginRequest(); + + assert.equal(built.request.username, null); + + const activateAccountEmail = createInput({ id: 'activate-account-email-hidden', name: 'email', type: 'email', value: 'activate-user@example.com', visible: false }); + const activateAccountPassword = createInput({ id: 'activate-account-password-visible', name: 'password', type: 'password', value: 'activate-secret' }); + activateAccountPassword.placeholder = 'Password'; + ctx = makeEnvironment([activateAccountEmail, activateAccountPassword], { + title: 'Activate your account', + href: 'https://example.com/account/activate', + }); + built = ctx.window.bitwardenSafariWebExtension.buildSaveLoginRequest(); + + assert.equal(built.request.username, 'activate-user@example.com'); + assert.equal(built.request.password, 'activate-secret'); + + const completeAccountEmail = createInput({ id: 'complete-account-email-hidden', name: 'email', type: 'email', value: 'complete-user@example.com', visible: false }); + const completeAccountPassword = createInput({ id: 'complete-account-password-visible', name: 'password', type: 'password', value: 'complete-secret' }); + completeAccountPassword.placeholder = 'Password'; + ctx = makeEnvironment([completeAccountEmail, completeAccountPassword], { + title: 'Complete your account', + href: 'https://example.com/account/complete', + }); + built = ctx.window.bitwardenSafariWebExtension.buildSaveLoginRequest(); + + assert.equal(built.request.username, 'complete-user@example.com'); + assert.equal(built.request.password, 'complete-secret'); +} + +function testSuggestPageAction_detectsLoginSignupAndPasswordChange() { + const loginUsername = createInput({ id: 'login-email', name: 'email', type: 'email', value: 'user@example.com' }); + loginUsername.placeholder = 'Email'; + const loginPassword = createInput({ id: 'login-password', name: 'password', type: 'password', value: 'secret' }); + loginPassword.placeholder = 'Password'; + let ctx = makeEnvironment([loginUsername, loginPassword]); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'fill'); + + const signupEmail = createInput({ id: 'signup-email', name: 'email', type: 'email', value: 'user@example.com' }); + const signupPassword = createInput({ id: 'signup-password', name: 'password', type: 'password', value: 'secret' }); + signupPassword.placeholder = 'Create password'; + const signupConfirm = createInput({ id: 'signup-confirm', name: 'confirmPassword', type: 'password', value: 'secret' }); + signupConfirm.placeholder = 'Confirm password'; + ctx = makeEnvironment([signupEmail, signupPassword, signupConfirm]); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'saveLogin'); + + const hiddenSignupEmail = createInput({ id: 'hidden-signup-email', name: 'email', type: 'email', value: 'hidden-user@example.com', visible: false }); + const hiddenSignupPassword = createInput({ id: 'hidden-signup-password', name: 'password', type: 'password', value: 'secret' }); + hiddenSignupPassword.placeholder = 'Create password'; + const hiddenSignupConfirm = createInput({ id: 'hidden-signup-confirm', name: 'confirmPassword', type: 'password', value: 'secret' }); + hiddenSignupConfirm.placeholder = 'Confirm password'; + ctx = makeEnvironment([hiddenSignupEmail, hiddenSignupPassword, hiddenSignupConfirm], { + title: 'Create your account', + href: 'https://example.com/account/create/password', + }); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'saveLogin'); + + const hiddenLoginEmail = createInput({ id: 'hidden-login-email', name: 'email', type: 'email', value: 'returning-user@example.com', visible: false }); + const hiddenLoginPassword = createInput({ id: 'hidden-login-password', name: 'password', type: 'password', value: 'secret' }); + hiddenLoginPassword.placeholder = 'Password'; + ctx = makeEnvironment([hiddenLoginEmail, hiddenLoginPassword], { + title: 'Sign in', + href: 'https://example.com/login/password', + }); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'fill'); + + const inviteEmail = createInput({ id: 'invite-email', name: 'email', type: 'email', value: 'invited-user@example.com', visible: false }); + const invitePassword = createInput({ id: 'invite-password', name: 'password', type: 'password', value: 'secret' }); + invitePassword.placeholder = 'Password'; + ctx = makeEnvironment([inviteEmail, invitePassword], { + title: 'Accept invitation', + href: 'https://example.com/invite/accept', + }); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'saveLogin'); + + const activationEmail = createInput({ id: 'activation-email', name: 'email', type: 'email', value: 'member@example.com', visible: false }); + const activationPassword = createInput({ id: 'activation-password', name: 'password', type: 'password', value: 'secret' }); + activationPassword.placeholder = 'Password'; + ctx = makeEnvironment([activationEmail, activationPassword], { + title: 'Activation required', + href: 'https://example.com/activation/check', + }); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'fill'); + + const activateAccountEmail = createInput({ id: 'activate-account-email', name: 'email', type: 'email', value: 'activate-user@example.com', visible: false }); + const activateAccountPassword = createInput({ id: 'activate-account-password', name: 'password', type: 'password', value: 'secret' }); + activateAccountPassword.placeholder = 'Password'; + ctx = makeEnvironment([activateAccountEmail, activateAccountPassword], { + title: 'Activate your account', + href: 'https://example.com/account/activate', + }); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'saveLogin'); + + const completeAccountEmail = createInput({ id: 'complete-account-email', name: 'email', type: 'email', value: 'complete-user@example.com', visible: false }); + const completeAccountPassword = createInput({ id: 'complete-account-password', name: 'password', type: 'password', value: 'secret' }); + completeAccountPassword.placeholder = 'Password'; + ctx = makeEnvironment([completeAccountEmail, completeAccountPassword], { + title: 'Complete your account', + href: 'https://example.com/account/complete', + }); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'saveLogin'); + + ctx = makeEnvironment([loginUsername, loginPassword], { + title: 'Create your account', + href: 'https://example.com/account/create', + }); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'saveLogin'); + + const customDocument = { + ...ctx.document, + title: 'Create your account', + location: { href: 'https://example.com/account/create' }, + }; + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(customDocument), 'saveLogin'); + + ctx = makeEnvironment([loginUsername, loginPassword], { + title: 'Join meeting', + href: 'https://example.com/join', + }); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'fill'); + + ctx = makeEnvironment([loginUsername, loginPassword], { + title: 'Register device', + href: 'https://example.com/register-device', + }); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'fill'); + + const signupButton = createInput({ id: 'create-account', name: 'createAccount', type: 'submit', value: 'Create account' }); + ctx = makeEnvironment([loginUsername, loginPassword, signupButton]); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'saveLogin'); + + const signupButtonElement = createButton({ id: 'create-account-button', name: 'createAccountButton', textContent: 'Create account' }); + ctx = makeEnvironment([loginUsername, loginPassword, signupButtonElement]); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'saveLogin'); + + const accountCtaButton = createButton({ id: 'account-cta', name: 'accountCta', type: 'button', textContent: 'Create account' }); + ctx = makeEnvironment([loginUsername, loginPassword, accountCtaButton]); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'fill'); + + const hiddenSignupMarker = createInput({ id: 'flow', name: 'flow', type: 'hidden', value: 'Create account' }); + ctx = makeEnvironment([loginUsername, loginPassword, hiddenSignupMarker]); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'fill'); + + const signupForm = createForm({ + id: 'signup-form', + name: 'signup', + action: 'https://example.com/users/sign_up', + }); + loginUsername.form = signupForm; + loginPassword.form = signupForm; + ctx = makeEnvironment([loginUsername, loginPassword], { + forms: [signupForm], + }); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'saveLogin'); + loginUsername.form = null; + loginPassword.form = null; + + const loginForm = createForm({ + id: 'login-form', + name: 'login', + action: 'https://example.com/session', + }); + const separateSignupForm = createForm({ + id: 'separate-signup-form', + name: 'signup', + action: 'https://example.com/users/sign_up', + }); + loginUsername.form = loginForm; + loginPassword.form = loginForm; + signupButton.form = separateSignupForm; + ctx = makeEnvironment([loginUsername, loginPassword, signupButton], { + forms: [loginForm, separateSignupForm], + }); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'fill'); + loginUsername.form = null; + loginPassword.form = null; + signupButton.form = null; + + const orphanSignupForm = createForm({ + id: 'orphan-signup-form', + name: 'signup', + action: 'https://example.com/users/sign_up', + }); + signupButton.form = orphanSignupForm; + ctx = makeEnvironment([loginUsername, loginPassword, signupButton], { + forms: [orphanSignupForm], + }); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'fill'); + signupButton.form = null; + + const resetEmail = createInput({ id: 'reset-email', name: 'email', type: 'email', value: 'user@example.com' }); + const resetPassword = createInput({ id: 'reset-password', name: 'newPassword', type: 'password', value: 'new-secret' }); + resetPassword.placeholder = 'New password'; + const resetConfirm = createInput({ id: 'reset-confirm', name: 'confirmPassword', type: 'password', value: 'new-secret' }); + resetConfirm.placeholder = 'Confirm password'; + ctx = makeEnvironment([resetEmail, resetPassword, resetConfirm], { + title: 'Reset your password', + href: 'https://example.com/reset-password', + }); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'changePassword'); + + const currentPassword = createInput({ id: 'current-password', name: 'currentPassword', type: 'password', value: 'old-secret' }); + currentPassword.placeholder = 'Current password'; + const newPassword = createInput({ id: 'new-password', name: 'newPassword', type: 'password', value: 'new-secret' }); + newPassword.placeholder = 'New password'; + const confirmPassword = createInput({ id: 'confirm-password', name: 'confirmPassword', type: 'password', value: 'new-secret' }); + confirmPassword.placeholder = 'Confirm password'; + ctx = makeEnvironment([currentPassword, newPassword, confirmPassword]); + assert.equal(ctx.window.bitwardenSafariWebExtension.suggestPageAction(), 'changePassword'); +} + +async function testTriggerSuggestedAction_sendsActionSpecificRequest() { + const loginUsername = createInput({ id: 'login-email', name: 'email', type: 'email', value: 'user@example.com' }); + loginUsername.placeholder = 'Email'; + const loginPassword = createInput({ id: 'login-password', name: 'password', type: 'password', value: 'secret' }); + loginPassword.placeholder = 'Password'; + let ctx = makeEnvironment([loginUsername, loginPassword]); + await ctx.window.bitwardenSafariWebExtension.triggerSuggestedAction(); + assert.equal(ctx.browser.runtime.sentMessages.at(-1).type, 'bitwarden:fill'); + assert.equal(ctx.browser.runtime.sentMessages.at(-1).requestContext.trigger, 'suggestedAction'); + + const signupEmail = createInput({ id: 'signup-email', name: 'email', type: 'email', value: 'user@example.com' }); + const signupPassword = createInput({ id: 'signup-password', name: 'password', type: 'password', value: 'secret' }); + signupPassword.placeholder = 'Create password'; + const signupConfirm = createInput({ id: 'signup-confirm', name: 'confirmPassword', type: 'password', value: 'secret' }); + signupConfirm.placeholder = 'Confirm password'; + ctx = makeEnvironment([signupEmail, signupPassword, signupConfirm]); + await ctx.window.bitwardenSafariWebExtension.triggerSuggestedAction(); + assert.equal(ctx.browser.runtime.sentMessages.at(-1).type, 'bitwarden:save-login'); + + const currentPassword = createInput({ id: 'current-password', name: 'currentPassword', type: 'password', value: 'old-secret' }); + currentPassword.placeholder = 'Current password'; + const newPassword = createInput({ id: 'new-password', name: 'newPassword', type: 'password', value: 'new-secret' }); + newPassword.placeholder = 'New password'; + const confirmPassword = createInput({ id: 'confirm-password', name: 'confirmPassword', type: 'password', value: 'new-secret' }); + confirmPassword.placeholder = 'Confirm password'; + ctx = makeEnvironment([currentPassword, newPassword, confirmPassword]); + await ctx.window.bitwardenSafariWebExtension.triggerSuggestedAction(); + assert.equal(ctx.browser.runtime.sentMessages.at(-1).type, 'bitwarden:change-password'); +} + +async function testApplyGeneratedPassword() { + const password = createInput({ id: 'password', name: 'password', type: 'password' }); + const confirmPassword = createInput({ id: 'confirm-password', name: 'confirm-password', type: 'password' }); + const ctx = makeEnvironment([password, confirmPassword]); + assert.equal(typeof ctx.window.bitwardenSafariWebExtension.applyNativeResponse, 'function'); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'generatePassword', + generatedPassword: 'generated-secret', + }, + }); + assert.equal(password.value, 'generated-secret'); + assert.equal(confirmPassword.value, 'generated-secret'); +} + +async function testApplyFillScript() { + const username = createInput({ id: 'username', name: 'username', type: 'text' }); + const password = createInput({ id: 'password', name: 'password', type: 'password' }); + const ctx = makeEnvironment([username, password]); + const fillScriptJSON = JSON.stringify({ + script: [ + ['fill_by_opid', 'field__0', 'user@example.com'], + ['fill_by_opid', 'field__1', 'secret'], + ], + }); + assert.equal(typeof ctx.window.bitwardenSafariWebExtension.applyNativeResponse, 'function'); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'fill', + fillScriptJSON, + }, + }); + assert.equal(username.value, 'user@example.com'); + assert.equal(password.value, 'secret'); +} + +async function testRequestFill_sendMessageFailure_showsWarningBanner() { + const username = createInput({ id: 'username', name: 'username', type: 'text', value: '' }); + const password = createInput({ id: 'password', name: 'password', type: 'password', value: '' }); + const ctx = makeEnvironment([username, password], { + sendMessage: async () => { + throw new Error('Native bridge unavailable'); + }, + }); + + const response = await ctx.window.bitwardenSafariWebExtension.requestFill(); + + const banner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(banner); + assert.equal(banner.textContent, 'Native bridge unavailable'); + assert.equal(banner.dataset.bitwardenStatusTone, 'warning'); + assert.equal(ctx.document.body.querySelector('[data-bitwarden-action-panel]'), null); + assert.equal(response.errorMessage, 'Native bridge unavailable'); +} + +async function testRequestSetup_sendsSetupRequestContext() { + const password = createInput({ id: 'password', name: 'password', type: 'password', value: '' }); + const ctx = makeEnvironment([password], { + sendMessage: async (message) => ({ + response: { + submissionAction: 'none', + userMessage: 'Open Bitwarden to finish Safari extension setup.', + request: { + ...message.request, + requestContext: message.requestContext, + }, + }, + }), + }); + + const response = await ctx.window.bitwardenSafariWebExtension.requestSetup(); + + assert.equal(ctx.browser.runtime.sentMessages.at(-1).type, 'bitwarden:setup'); + assert.equal(ctx.browser.runtime.sentMessages.at(-1).requestContext.trigger, 'setupButton'); + assert.equal(response.response.request.requestContext.trigger, 'setupButton'); +} + +async function testActionPanelPrimaryErrorEnvelope_restoresPanelInteractivity() { + const password = createInput({ id: 'password', name: 'password', type: 'password', value: 'secret' }); + const ctx = makeEnvironment([password], { + sendMessage: async () => ({ + response: null, + errorMessage: 'Native host missing', + }), + }); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'saveNewLogin', + userMessage: 'Save this login to Bitwarden.', + }, + }); + + const actionPanel = ctx.document.body.querySelector('[data-bitwarden-action-panel]'); + assert.ok(actionPanel); + const primaryButton = actionPanel.querySelector('[data-bitwarden-action-primary]'); + const dismissButton = actionPanel.querySelector('[data-bitwarden-action-dismiss]'); + + await primaryButton.onclick(); + + const banner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(banner); + assert.equal(banner.textContent, 'Native host missing'); + assert.equal(banner.dataset.bitwardenStatusTone, 'warning'); + assert.equal(primaryButton.disabled, false); + assert.equal(dismissButton.disabled, false); + assert.ok(ctx.document.body.querySelector('[data-bitwarden-action-panel]')); +} + +async function testActionPanelPrimaryFailure_restoresPanelInteractivity() { + const password = createInput({ id: 'password', name: 'password', type: 'password', value: 'secret' }); + const ctx = makeEnvironment([password], { + sendMessage: async () => { + throw new Error('Bridge request failed'); + }, + }); + await ctx.window.bitwardenSafariWebExtension.applyNativeResponse({ + response: { + submissionAction: 'saveNewLogin', + userMessage: 'Save this login to Bitwarden.', + }, + }); + + const actionPanel = ctx.document.body.querySelector('[data-bitwarden-action-panel]'); + assert.ok(actionPanel); + const primaryButton = actionPanel.querySelector('[data-bitwarden-action-primary]'); + const dismissButton = actionPanel.querySelector('[data-bitwarden-action-dismiss]'); + + await primaryButton.onclick(); + + const banner = ctx.document.body.querySelector('[data-bitwarden-status-banner]'); + assert.ok(banner); + assert.equal(banner.textContent, 'Bridge request failed'); + assert.equal(banner.dataset.bitwardenStatusTone, 'warning'); + assert.equal(primaryButton.disabled, false); + assert.equal(dismissButton.disabled, false); + assert.ok(ctx.document.body.querySelector('[data-bitwarden-action-panel]')); +} + +(async () => { + testBuildChangePasswordRequest_usesCurrentAndNewPasswordHeuristics(); + testBuildSaveLoginRequest_prefersEmailAndIgnoresConfirmPassword(); + testSuggestPageAction_detectsLoginSignupAndPasswordChange(); + await testTriggerSuggestedAction_sendsActionSpecificRequest(); + await testApplyGeneratedPassword(); + await testApplyFillScript(); + await testRequestFill_sendMessageFailure_showsWarningBanner(); + await testRequestSetup_sendsSetupRequestContext(); + await testApplyStatusEvent(); + await testApplyFillResponse_showsCompletionBannerWithoutPanel(); + await testApplyFillResponse_withoutUsername_usesSiteHostCopy(); + await testApplyNoMatchFillMessage_showsBannerWithoutPanel(); + await testApplySetupResponse_showsInfoBannerWithoutPanel(); + await testApplyNativeErrorMessage_showsWarningBannerWithoutPanel(); + await testApplyNativeErrorMessage_withResponse_prefersResponseHandling(); + await testApplyStatusBanner(); + await testApplyStatusBanner_doesNotReopenPanelForConfirmedAction(); + await testApplyGeneratedPassword_showsSaveLoginFollowUpPanel(); + await testApplyGeneratedPassword_showsUpdatePasswordFollowUpPanel(); + await testApplyUpdateExistingLogin_showsStructuredPanelCopy(); + await testApplyUpdateExistingLogin_stripsSensitiveURLPartsFromSiteDetail(); + await testApplyGeneratedPassword_prefersNativeProvidedFollowUpContext(); + await testApplyGeneratedPasswordFailure_showsErrorBannerWithoutFollowUpPanel(); + await testActionPanelPrimaryErrorEnvelope_restoresPanelInteractivity(); + await testActionPanelPrimaryDispatchesConfirmEvent(); + await testActionPanelPrimaryShowsPendingBannerWhileSaving(); + await testActionPanelPrimaryFailure_restoresPanelInteractivity(); + await testUpdatePasswordPanelShowsSpecificTitle(); + await testActionPanelDismissRemovesPanel(); + console.log('content node tests passed'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/BitwardenSafariWebExtension/Application/Support/icon-48.png b/BitwardenSafariWebExtension/Application/Support/icon-48.png new file mode 100644 index 0000000000..24462564d4 Binary files /dev/null and b/BitwardenSafariWebExtension/Application/Support/icon-48.png differ diff --git a/BitwardenSafariWebExtension/Application/Support/icon-96.png b/BitwardenSafariWebExtension/Application/Support/icon-96.png new file mode 100644 index 0000000000..07f030adaf Binary files /dev/null and b/BitwardenSafariWebExtension/Application/Support/icon-96.png differ diff --git a/BitwardenSafariWebExtension/Application/Support/manifest.json b/BitwardenSafariWebExtension/Application/Support/manifest.json new file mode 100644 index 0000000000..0202c5c85d --- /dev/null +++ b/BitwardenSafariWebExtension/Application/Support/manifest.json @@ -0,0 +1,17 @@ +{ + "manifest_version": 3, + "name": "Bitwarden Safari", + "description": "Bitwarden Safari Web Extension for iOS and iPadOS.", + "version": "0.0.1", + "icons": { + "48": "icon-48.png", + "96": "icon-96.png" + }, + "background": { + "service_worker": "background.js" + }, + "permissions": [ + "activeTab", + "nativeMessaging" + ] +} diff --git a/BitwardenSafariWebExtension/Application/TestHelpers/Support/Info.plist b/BitwardenSafariWebExtension/Application/TestHelpers/Support/Info.plist new file mode 100644 index 0000000000..6c40a6cd0c --- /dev/null +++ b/BitwardenSafariWebExtension/Application/TestHelpers/Support/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/BitwardenSafariWebExtension/SafariWebExtensionBridge.swift b/BitwardenSafariWebExtension/SafariWebExtensionBridge.swift new file mode 100644 index 0000000000..33a362c86b --- /dev/null +++ b/BitwardenSafariWebExtension/SafariWebExtensionBridge.swift @@ -0,0 +1,61 @@ +import BitwardenShared +import Foundation +import SafariServices + +struct SafariWebExtensionBridgeRequest: Codable, Equatable { + var id: String + var request: SafariExtensionRequest +} + +struct SafariWebExtensionBridgeResponse: Codable, Equatable { + var id: String + var response: SafariExtensionResponse + var errorMessage: String? +} + +enum SafariWebExtensionBridge { + static let legacyMessageUserInfoKey = "message" + + static var messageUserInfoKey: String { + if #available(iOS 15.0, macOS 11.0, *) { + return SFExtensionMessageKey + } + return legacyMessageUserInfoKey + } + + static func decodeRequest(from userInfo: [String: Any]) -> SafariWebExtensionBridgeRequest? { + let rawMessage = userInfo[messageUserInfoKey] ?? userInfo[legacyMessageUserInfoKey] + + if let message = rawMessage as? String, + let data = message.data(using: .utf8) { + return try? JSONDecoder().decode(SafariWebExtensionBridgeRequest.self, from: data) + } + + if let message = rawMessage as? [String: Any], + let data = try? JSONSerialization.data(withJSONObject: message) { + return try? JSONDecoder().decode(SafariWebExtensionBridgeRequest.self, from: data) + } + + return nil + } + + static func makeResponseItem( + for request: SafariWebExtensionBridgeRequest, + response: SafariExtensionResponse, + errorMessage: String? = nil, + ) throws -> NSExtensionItem { + let bridgeResponse = SafariWebExtensionBridgeResponse( + id: request.id, + response: response, + errorMessage: errorMessage, + ) + let data = try JSONEncoder().encode(bridgeResponse) + guard let message = String(data: data, encoding: .utf8) else { + throw CocoaError(.coderInvalidValue) + } + + let item = NSExtensionItem() + item.userInfo = [messageUserInfoKey: message] + return item + } +} diff --git a/BitwardenSafariWebExtension/SafariWebExtensionBridgeTests.swift b/BitwardenSafariWebExtension/SafariWebExtensionBridgeTests.swift new file mode 100644 index 0000000000..99bd3d73f9 --- /dev/null +++ b/BitwardenSafariWebExtension/SafariWebExtensionBridgeTests.swift @@ -0,0 +1,53 @@ +import TestHelpers +import XCTest + +@testable import BitwardenShared + +class SafariWebExtensionBridgeTests: BitwardenTestCase { + func test_decodeRequest_fromLegacyMessageUserInfo_parsesBridgeEnvelope() throws { + let subject = try XCTUnwrap(SafariWebExtensionBridge.decodeRequest(from: [ + SafariWebExtensionBridge.legacyMessageUserInfoKey: """ + { + \"id\": \"req-bridge\", + \"request\": { + \"kind\": \"setup\" + } + } + """ + ])) + + XCTAssertEqual(subject.id, "req-bridge") + XCTAssertEqual(subject.request, SafariExtensionRequest(kind: .setup)) + } + + func test_makeResponseItem_encodesBridgeResponse_intoConfiguredMessageUserInfoKey() throws { + let request = SafariWebExtensionBridgeRequest( + id: "req-response", + request: SafariExtensionRequest(kind: .setup) + ) + let response = SafariExtensionResponse( + request: request.request, + suggestionAction: .none, + submissionAction: .none, + matchedLogin: nil, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: "Open Bitwarden to finish Safari extension setup." + ) + + let item = try SafariWebExtensionBridge.makeResponseItem( + for: request, + response: response, + errorMessage: "Setup is incomplete." + ) + let message = try XCTUnwrap(item.userInfo?[SafariWebExtensionBridge.messageUserInfoKey] as? String) + let decoded = try JSONDecoder().decode( + SafariWebExtensionBridgeResponse.self, + from: XCTUnwrap(message.data(using: String.Encoding.utf8)) + ) + + XCTAssertEqual(decoded.id, "req-response") + XCTAssertEqual(decoded.response.userMessage, "Open Bitwarden to finish Safari extension setup.") + XCTAssertEqual(decoded.errorMessage, "Setup is incomplete.") + } +} diff --git a/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift b/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift new file mode 100644 index 0000000000..b9932b5c92 --- /dev/null +++ b/BitwardenSafariWebExtension/SafariWebExtensionHandler.swift @@ -0,0 +1,99 @@ +import BitwardenKit +import BitwardenShared +import Foundation +import SafariServices + +final class SafariWebExtensionHandler: NSObject, NSExtensionRequestHandling { + typealias ResponseProvider = @MainActor (SafariExtensionRequest) async -> SafariExtensionResponse? + + private let responseProvider: ResponseProvider + private let bridgeMessageUserInfoKeyProvider: () -> String + + @MainActor + override convenience init() { + self.init( + responseProvider: { request in + let requestProcessor = SafariExtensionRequestProcessor.liveForAppExtension( + errorReporter: OSLogErrorReporter(), + ) + return await requestProcessor.makeAsyncResponse(for: request) + } + ) + } + + @MainActor + init( + responseProvider: @escaping ResponseProvider, + bridgeMessageUserInfoKeyProvider: @escaping () -> String = { + if #available(iOS 15.0, macOS 11.0, *) { + return SFExtensionMessageKey + } + return SafariWebExtensionBridge.legacyMessageUserInfoKey + } + ) { + self.responseProvider = responseProvider + self.bridgeMessageUserInfoKeyProvider = bridgeMessageUserInfoKeyProvider + super.init() + } + + func beginRequest(with context: NSExtensionContext) { + Task { @MainActor in + let inputItems = context.inputItems as? [NSExtensionItem] ?? [] + let responseItems = await inputItems.asyncCompactMap { item in + await makeResponseItem(from: item.userInfo as? [String: Any] ?? [:]) + } + context.completeRequest(returningItems: responseItems, completionHandler: nil) + } + } + + @MainActor + func makeResponseItem(from userInfo: [String: Any]) async -> NSExtensionItem? { + let rawMessage = userInfo[bridgeMessageUserInfoKey] ?? userInfo[SafariWebExtensionBridge.legacyMessageUserInfoKey] + let fallbackRequestID = (rawMessage as? [String: Any]).flatMap { $0["id"] as? String } ?? "invalid-request" + + guard let bridgeRequest = SafariExtensionBridgeCodec.decodeRequest(from: rawMessage) else { + return makeBridgeItem(from: try? SafariExtensionBridgeCodec.encodeErrorResponse( + requestID: fallbackRequestID, + errorMessage: "Invalid native request payload.", + )) + } + + guard let response = await responseProvider(bridgeRequest.request) else { + return makeBridgeItem(from: try? SafariExtensionBridgeCodec.encodeErrorResponse( + requestID: bridgeRequest.id, + errorMessage: "Couldn’t process Safari extension request.", + )) + } + + return makeBridgeItem(from: try? SafariExtensionBridgeCodec.encodeResponse( + requestID: bridgeRequest.id, + response: response, + )) + } + + private func makeBridgeItem(from message: String?) -> NSExtensionItem? { + guard let message else { + return nil + } + + let item = NSExtensionItem() + item.userInfo = [bridgeMessageUserInfoKey: message] + return item + } + + private var bridgeMessageUserInfoKey: String { + bridgeMessageUserInfoKeyProvider() + } +} + +private extension Array { + func asyncCompactMap(_ transform: (Element) async -> T?) async -> [T] { + var results: [T] = [] + for element in self { + if let value = await transform(element) { + results.append(value) + } + } + return results + } +} diff --git a/BitwardenSafariWebExtension/SafariWebExtensionHandlerTests.swift b/BitwardenSafariWebExtension/SafariWebExtensionHandlerTests.swift new file mode 100644 index 0000000000..16c123f078 --- /dev/null +++ b/BitwardenSafariWebExtension/SafariWebExtensionHandlerTests.swift @@ -0,0 +1,102 @@ +import TestHelpers +import XCTest + +@testable import BitwardenShared + +@MainActor +class SafariWebExtensionHandlerTests: BitwardenTestCase { + func test_makeResponseItem_withInvalidPayload_returnsInvalidRequestErrorEnvelope() async throws { + let subject = SafariWebExtensionHandler( + responseProvider: { _ in + XCTFail("responseProvider should not be called for invalid payloads") + return nil + }, + bridgeMessageUserInfoKeyProvider: { SafariWebExtensionBridge.legacyMessageUserInfoKey } + ) + + let maybeItem = await subject.makeResponseItem(from: [:]) + let item = try XCTUnwrap(maybeItem) + let userInfo = try XCTUnwrap(item.userInfo) + let message = try XCTUnwrap(userInfo[SafariWebExtensionBridge.legacyMessageUserInfoKey] as? String) + let decoded = try JSONDecoder().decode( + SafariExtensionBridgeResponse.self, + from: XCTUnwrap(message.data(using: String.Encoding.utf8)) + ) + + XCTAssertEqual(decoded.id, "invalid-request") + XCTAssertNil(decoded.response) + XCTAssertEqual(decoded.errorMessage, "Invalid native request payload.") + } + + func test_makeResponseItem_withValidPayload_usesInjectedResponseProvider() async throws { + var capturedRequest: SafariExtensionRequest? + let subject = SafariWebExtensionHandler( + responseProvider: { request in + capturedRequest = request + return SafariExtensionResponse( + request: request, + suggestionAction: .none, + submissionAction: .none, + matchedLogin: nil, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: "Handled in tests." + ) + }, + bridgeMessageUserInfoKeyProvider: { SafariWebExtensionBridge.legacyMessageUserInfoKey } + ) + + let maybeItem = await subject.makeResponseItem(from: [ + SafariWebExtensionBridge.legacyMessageUserInfoKey: """ + { + \"id\": \"req-handler\", + \"request\": { + \"kind\": \"setup\", + \"urlString\": \"https://example.com/setup\" + } + } + """ + ]) + let item = try XCTUnwrap(maybeItem) + let userInfo = try XCTUnwrap(item.userInfo) + let message = try XCTUnwrap(userInfo[SafariWebExtensionBridge.legacyMessageUserInfoKey] as? String) + let decoded = try JSONDecoder().decode( + SafariExtensionBridgeResponse.self, + from: XCTUnwrap(message.data(using: String.Encoding.utf8)) + ) + + XCTAssertEqual(capturedRequest, SafariExtensionRequest(kind: .setup, urlString: "https://example.com/setup")) + XCTAssertEqual(decoded.id, "req-handler") + XCTAssertEqual(decoded.response?.userMessage, "Handled in tests.") + XCTAssertNil(decoded.errorMessage) + } + + func test_makeResponseItem_whenResponseProviderReturnsNil_returnsProcessingErrorEnvelope() async throws { + let subject = SafariWebExtensionHandler( + responseProvider: { _ in nil }, + bridgeMessageUserInfoKeyProvider: { SafariWebExtensionBridge.legacyMessageUserInfoKey } + ) + + let maybeItem = await subject.makeResponseItem(from: [ + SafariWebExtensionBridge.legacyMessageUserInfoKey: """ + { + \"id\": \"req-processing-error\", + \"request\": { + \"kind\": \"setup\" + } + } + """ + ]) + let item = try XCTUnwrap(maybeItem) + let userInfo = try XCTUnwrap(item.userInfo) + let message = try XCTUnwrap(userInfo[SafariWebExtensionBridge.legacyMessageUserInfoKey] as? String) + let decoded = try JSONDecoder().decode( + SafariExtensionBridgeResponse.self, + from: XCTUnwrap(message.data(using: String.Encoding.utf8)) + ) + + XCTAssertEqual(decoded.id, "req-processing-error") + XCTAssertNil(decoded.response) + XCTAssertEqual(decoded.errorMessage, "Couldn’t process Safari extension request.") + } +} diff --git a/BitwardenShared/Core/Autofill/Utilities/ActionExtensionHelper.swift b/BitwardenShared/Core/Autofill/Utilities/ActionExtensionHelper.swift index a8a8e26fa8..63d03ed2d4 100644 --- a/BitwardenShared/Core/Autofill/Utilities/ActionExtensionHelper.swift +++ b/BitwardenShared/Core/Autofill/Utilities/ActionExtensionHelper.swift @@ -56,6 +56,11 @@ public class ActionExtensionHelper { // swiftlint:disable:this type_body_length context.providerType == Constants.UTType.appExtensionSaveLogin } + /// Whether the app extension change password provider was included or derived from the input items. + public var isProviderChangePassword: Bool { + context.providerType == Constants.UTType.appExtensionChangePasswordAction + } + /// The URL of the page or app to determine matching ciphers. public var uri: String? { context.urlString @@ -398,6 +403,7 @@ public class ActionExtensionHelper { // swiftlint:disable:this type_body_length self.context.urlString = result[Constants.appExtensionUrlStringKey] self.context.pageDetails = self.decodePageDetails(from: result) + self.deriveContextFromPageDetails() Logger.appExtension.debug( """ @@ -406,4 +412,108 @@ public class ActionExtensionHelper { // swiftlint:disable:this type_body_length ) }) } + + private func deriveContextFromPageDetails() { + guard let pageDetails = context.pageDetails else { + return + } + + let passwordFields = pageDetails.fields.filter { $0.type == "password" && $0.viewable } + let hasCurrentPassword = passwordFields.contains { passwordFieldRole(for: $0) == .current } + let hasNewPassword = passwordFields.contains { passwordFieldRole(for: $0) == .new } + let hasConfirmPassword = passwordFields.contains { passwordFieldRole(for: $0) == .confirm } + + if hasCurrentPassword && (hasNewPassword || hasConfirmPassword) { + context.providerType = Constants.UTType.appExtensionChangePasswordAction + context.loginTitle = normalizedText(pageDetails.title) + context.oldPassword = currentPasswordValue(from: pageDetails) + context.password = preferredSavePasswordValue(from: pageDetails) + context.username = preferredUsername(from: pageDetails, includeHiddenEmail: false) + return + } + + if looksLikeSignupPage(pageDetails) { + context.providerType = Constants.UTType.appExtensionSaveLogin + context.loginTitle = normalizedText(pageDetails.title) + context.password = preferredSavePasswordValue(from: pageDetails) + context.username = preferredUsername(from: pageDetails, includeHiddenEmail: true) + } + } + + private func currentPasswordValue(from pageDetails: PageDetails) -> String? { + let currentPasswordField = pageDetails.fields.first { passwordFieldRole(for: $0) == .current } + return normalizedText(currentPasswordField?.value) + } + + private func preferredSavePasswordValue(from pageDetails: PageDetails) -> String? { + let passwordFields = pageDetails.fields.filter { $0.type == "password" && $0.viewable } + let preferredField = passwordFields.first { passwordFieldRole(for: $0) == .new } + ?? passwordFields.first { passwordFieldRole(for: $0) == .unknown } + ?? passwordFields.first { passwordFieldRole(for: $0) != .confirm } + return normalizedText(preferredField?.value) + } + + private func preferredUsername(from pageDetails: PageDetails, includeHiddenEmail: Bool) -> String? { + let preferredField = pageDetails.fields.first { $0.type == "email" && $0.viewable } + ?? pageDetails.fields.first { $0.type == "text" && $0.viewable } + ?? pageDetails.fields.first { $0.type == "tel" && $0.viewable } + ?? (includeHiddenEmail ? pageDetails.fields.first { $0.type == "email" } : nil) + return normalizedText(preferredField?.value) + } + + private func normalizedText(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private func looksLikeSignupPage(_ pageDetails: PageDetails) -> Bool { + let passwordFields = pageDetails.fields.filter { $0.type == "password" && $0.viewable } + let hasConfirmPassword = passwordFields.contains { passwordFieldRole(for: $0) == .confirm } + let hasNewPassword = passwordFields.contains { passwordFieldRole(for: $0) == .new } + if hasConfirmPassword || hasNewPassword { + return true + } + + let surfaceText = ([pageDetails.title] + + pageDetails.forms.values.flatMap { [$0.htmlAction, $0.htmlName, $0.htmlId] } + + pageDetails.fields.flatMap { + [$0.htmlId, $0.htmlName, $0.labelTag, $0.labelLeft, $0.placeholder, $0.value] + }.compactMap { $0 }) + .joined(separator: " ") + + return signupSurfaceTokens.contains { token in + surfaceText.localizedCaseInsensitiveContains(token) + } + } + + private func passwordFieldRole(for field: PageDetails.Field) -> PasswordFieldRole { + let source = [field.htmlId, field.htmlName, field.labelTag, field.labelLeft, field.placeholder] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + + if source.contains("current") || source.contains("old") { + return .current + } + if source.contains("confirm") || source.contains("verification") || source.contains("verify") + || source.contains("repeat") || source.contains("again") { + return .confirm + } + if source.contains("new") || source.contains("create") || source.contains("choose") || source.contains("set") { + return .new + } + return .unknown + } + + private var signupSurfaceTokens: [String] { + ["sign up", "signup", "create account", "create your account", "register", "join"] + } + + private enum PasswordFieldRole { + case current + case new + case confirm + case unknown + } } diff --git a/BitwardenShared/Core/Autofill/Utilities/ActionExtensionHelperTests.swift b/BitwardenShared/Core/Autofill/Utilities/ActionExtensionHelperTests.swift index c8151726b8..777b608520 100644 --- a/BitwardenShared/Core/Autofill/Utilities/ActionExtensionHelperTests.swift +++ b/BitwardenShared/Core/Autofill/Utilities/ActionExtensionHelperTests.swift @@ -317,6 +317,54 @@ class ActionExtensionHelperTests: BitwardenTestCase { // swiftlint:disable:this XCTAssertEqual(itemData[Constants.appExtensionUsernameKey] as? String, "user@bitwarden.com") } + /// `processInputItems(_:)` derives a save-login provider from signup-like web page details. + func test_processInputItems_webUrlProvider_signupPageDerivesSaveLoginProvider() throws { + let pageDetails = makePageDetails( + title: "Create your Bitwarden Safari test account", + fields: [ + makeField(elementNumber: 0, htmlId: "email", htmlName: "email", labelTag: "Email", type: "email", value: "fixture-user@example.com"), + makeField(elementNumber: 1, htmlId: "new-password", htmlName: "newPassword", labelTag: "New password", type: "password", value: "FixturePass123!"), + makeField(elementNumber: 2, htmlId: "confirm-password", htmlName: "confirmPassword", labelTag: "Confirm new password", type: "password", value: "FixturePass123!"), + makeField(elementNumber: 3, htmlId: "submit", htmlName: "submit", labelTag: "Create account", type: "submit", value: "Create account"), + ] + ) + let extensionItem = try makeWebURLProviderExtensionItem(pageDetails: pageDetails, urlString: "https://example.com/signup") + + subject.processInputItems([extensionItem]) + waitFor(subject.context.didFinishLoadingItem) + + XCTAssertTrue(subject.isProviderSaveLogin) + XCTAssertFalse(subject.canAutofill) + XCTAssertEqual(subject.context.loginTitle, "Create your Bitwarden Safari test account") + XCTAssertEqual(subject.context.username, "fixture-user@example.com") + XCTAssertEqual(subject.context.password, "FixturePass123!") + XCTAssertEqual(subject.context.urlString, "https://example.com/signup") + } + + /// `processInputItems(_:)` derives a change-password provider from password update web page details. + func test_processInputItems_webUrlProvider_changePasswordPageDerivesChangePasswordProvider() throws { + let pageDetails = makePageDetails( + title: "Change password", + fields: [ + makeField(elementNumber: 0, htmlId: "current-password", htmlName: "currentPassword", labelTag: "Current password", type: "password", value: "old-secret"), + makeField(elementNumber: 1, htmlId: "new-password", htmlName: "newPassword", labelTag: "New password", type: "password", value: "new-secret"), + makeField(elementNumber: 2, htmlId: "confirm-password", htmlName: "confirmPassword", labelTag: "Confirm new password", type: "password", value: "new-secret"), + ] + ) + let extensionItem = try makeWebURLProviderExtensionItem(pageDetails: pageDetails, urlString: "https://example.com/change-password") + + subject.processInputItems([extensionItem]) + waitFor(subject.context.didFinishLoadingItem) + + XCTAssertTrue(subject.isProviderChangePassword) + XCTAssertFalse(subject.isProviderSaveLogin) + XCTAssertFalse(subject.canAutofill) + XCTAssertEqual(subject.context.loginTitle, "Change password") + XCTAssertEqual(subject.context.oldPassword, "old-secret") + XCTAssertEqual(subject.context.password, "new-secret") + XCTAssertEqual(subject.context.urlString, "https://example.com/change-password") + } + /// `processInputItems(_:)` processes the input items for a web URL provider and returns the /// data necessary to autofill the selected cipher on the web page. func test_processInputItems_webUrlProvider() throws { // swiftlint:disable:this function_body_length @@ -386,4 +434,74 @@ class ActionExtensionHelperTests: BitwardenTestCase { // swiftlint:disable:this """ } } + + private func makeWebURLProviderExtensionItem(pageDetails: PageDetails, urlString: String) throws -> NSExtensionItem { + let pageDetailsData = try JSONEncoder().encode(pageDetails) + let pageDetailsJson = try XCTUnwrap(String(data: pageDetailsData, encoding: .utf8)) + + let extensionItem = NSExtensionItem() + extensionItem.attachments = [ + NSItemProvider( + item: [ + NSExtensionJavaScriptPreprocessingResultsKey: [ + Constants.appExtensionUrlStringKey: urlString, + Constants.appExtensionWebViewPageDetails: pageDetailsJson, + ], + ] as NSSecureCoding, + typeIdentifier: UTType.propertyList.identifier, + ), + ] + return extensionItem + } + + private func makePageDetails(title: String, fields: [PageDetails.Field]) -> PageDetails { + PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 0), + documentUUID: "fixture-document", + documentUrl: "https://example.com", + fields: fields, + forms: [ + "form__0": PageDetails.Form( + htmlAction: "https://example.com/submit", + htmlId: "form-0", + htmlMethod: "post", + htmlName: "account-form", + opId: "form__0" + ), + ], + tabUrl: "https://example.com", + title: title, + url: "https://example.com" + ) + } + + private func makeField( + elementNumber: Int, + htmlId: String, + htmlName: String, + labelTag: String, + type: String, + value: String, + viewable: Bool = true + ) -> PageDetails.Field { + PageDetails.Field( + disabled: false, + elementNumber: elementNumber, + form: "form__0", + htmlClass: nil, + htmlId: htmlId, + htmlName: htmlName, + labelLeft: labelTag, + labelRight: nil, + labelTag: labelTag, + onepasswordFieldType: type, + opId: "__\(elementNumber)", + placeholder: nil, + readOnly: false, + type: type, + value: value, + viewable: viewable, + visible: viewable + ) + } } diff --git a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift index 8e8c73cca7..b753922757 100644 --- a/BitwardenShared/Core/Platform/Services/ServiceContainer.swift +++ b/BitwardenShared/Core/Platform/Services/ServiceContainer.swift @@ -75,6 +75,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le /// A helper to create cipher views with proper ownership based on policies. let cipherOwnershipHelper: CipherOwnershipHelper + /// The service used by the application to manage cipher data. + let cipherService: CipherService + /// The service used by the application to manage client certificates for mTLS authentication. let clientCertificateService: ClientCertificateService @@ -175,6 +178,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le /// The repository used by the application to manage data for the UI layer. let settingsRepository: SettingsRepository + /// The service used by the application to manage settings data. + let settingsService: SettingsService + /// The service that manages account timeout between apps. public let sharedTimeoutService: SharedTimeoutService @@ -321,6 +327,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le cameraService: CameraService, changeKdfService: ChangeKdfService, cipherOwnershipHelper: CipherOwnershipHelper, + cipherService: CipherService, clientCertificateService: ClientCertificateService, clientService: ClientService, configService: ConfigService, @@ -354,6 +361,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le serverCommunicationConfigAPIService: ServerCommunicationConfigAPIService, serverCommunicationConfigClientSingleton: ServerCommunicationConfigClientSingleton, settingsRepository: SettingsRepository, + settingsService: SettingsService, sharedTimeoutService: SharedTimeoutService, stateService: StateService, syncService: SyncService, @@ -388,6 +396,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le self.cameraService = cameraService self.changeKdfService = changeKdfService self.cipherOwnershipHelper = cipherOwnershipHelper + self.cipherService = cipherService self.clientCertificateService = clientCertificateService self.clientService = clientService self.configService = configService @@ -421,6 +430,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le self.serverCommunicationConfigAPIService = serverCommunicationConfigAPIService self.serverCommunicationConfigClientSingleton = serverCommunicationConfigClientSingleton self.settingsRepository = settingsRepository + self.settingsService = settingsService self.sharedTimeoutService = sharedTimeoutService self.stateService = stateService self.syncService = syncService @@ -1118,6 +1128,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le cameraService: DefaultCameraService(), changeKdfService: changeKdfService, cipherOwnershipHelper: cipherOwnershipHelper, + cipherService: cipherService, clientCertificateService: clientCertificateService, clientService: clientService, configService: configService, @@ -1151,6 +1162,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le serverCommunicationConfigAPIService: serverCommunicationConfigAPIService, serverCommunicationConfigClientSingleton: serverCommunicationConfigClientSingleton, settingsRepository: settingsRepository, + settingsService: settingsService, sharedTimeoutService: sharedTimeoutService, stateService: stateService, syncService: syncService, diff --git a/BitwardenShared/Core/Platform/Services/StateService.swift b/BitwardenShared/Core/Platform/Services/StateService.swift index f4ea461b54..429e2147cd 100644 --- a/BitwardenShared/Core/Platform/Services/StateService.swift +++ b/BitwardenShared/Core/Platform/Services/StateService.swift @@ -1363,7 +1363,7 @@ extension StateService { /// The errors thrown from a `StateService`. /// -enum StateServiceError: LocalizedError { +enum StateServiceError: LocalizedError, NonLoggableError { /// There are no known accounts. case noAccounts diff --git a/BitwardenShared/Core/Platform/Services/Stores/DataStore.swift b/BitwardenShared/Core/Platform/Services/Stores/DataStore.swift index 0a3e76cc4b..46f196d7e5 100644 --- a/BitwardenShared/Core/Platform/Services/Stores/DataStore.swift +++ b/BitwardenShared/Core/Platform/Services/Stores/DataStore.swift @@ -32,6 +32,33 @@ class DataStore { return managedObjectModel }() + static func persistedStoreURL( + fileManager: FileManager = .default, + groupIdentifier: String = Bundle.main.groupIdentifier, + bundleIdentifier: String? = Bundle.main.bundleIdentifier, + bundlePath: String = Bundle.main.bundlePath, + containerURLProvider: (FileManager, String) -> URL? = { fileManager, groupIdentifier in + fileManager.containerURL(forSecurityApplicationGroupIdentifier: groupIdentifier) + } + ) -> URL { + #if targetEnvironment(simulator) + if bundlePath.contains(".appex") { + let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + let directoryURL = applicationSupportURL + .appendingPathComponent(bundleIdentifier ?? "BitwardenAppExtension", isDirectory: true) + try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + return directoryURL.appendingPathComponent("Bitwarden.sqlite") + } + #endif + + if let containerURL = containerURLProvider(fileManager, groupIdentifier) { + return containerURL.appendingPathComponent("Bitwarden.sqlite") + } + + return fileManager.temporaryDirectory.appendingPathComponent("Bitwarden.sqlite") + } + // MARK: Properties /// A managed object context which executes on a background queue. @@ -67,9 +94,7 @@ class DataStore { case .memory: storeDescription = NSPersistentStoreDescription(url: URL(fileURLWithPath: "/dev/null")) case .persisted: - let storeURL = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: Bundle.main.groupIdentifier)! - .appendingPathComponent("Bitwarden.sqlite") + let storeURL = Self.persistedStoreURL() storeDescription = NSPersistentStoreDescription(url: storeURL) } persistentContainer.persistentStoreDescriptions = [storeDescription] diff --git a/BitwardenShared/Core/Platform/Services/Stores/DataStoreTests.swift b/BitwardenShared/Core/Platform/Services/Stores/DataStoreTests.swift new file mode 100644 index 0000000000..2f74e697fc --- /dev/null +++ b/BitwardenShared/Core/Platform/Services/Stores/DataStoreTests.swift @@ -0,0 +1,51 @@ +import Foundation +import XCTest + +@testable import BitwardenShared + +final class DataStoreTests: BitwardenTestCase { + func test_persistedStoreURL_prefersAppGroupContainer() { + let subject = DataStore.persistedStoreURL( + groupIdentifier: "group.com.8bit.bitwarden", + bundleIdentifier: "com.8bit.bitwarden", + bundlePath: "/tmp/Bitwarden.app", + containerURLProvider: { _, _ in URL(fileURLWithPath: "/tmp/group-container", isDirectory: true) } + ) + + XCTAssertEqual(subject.path, "/tmp/group-container/Bitwarden.sqlite") + } + + func test_persistedStoreURL_fallsBackForSimulatorAppExtension() { + let fileManager = FileManager.default + let subject = DataStore.persistedStoreURL( + fileManager: fileManager, + groupIdentifier: "group.com.8bit.bitwarden", + bundleIdentifier: "com.8bit.bitwarden.find-login-action-extension", + bundlePath: "/tmp/BitwardenActionExtension.appex", + containerURLProvider: { _, _ in nil } + ) + + let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? fileManager.temporaryDirectory + XCTAssertEqual( + subject.path, + applicationSupportURL + .appendingPathComponent("com.8bit.bitwarden.find-login-action-extension", isDirectory: true) + .appendingPathComponent("Bitwarden.sqlite") + .path + ) + } + + func test_persistedStoreURL_fallsBackToTemporaryDirectoryForNonExtension() { + let fileManager = FileManager.default + let subject = DataStore.persistedStoreURL( + fileManager: fileManager, + groupIdentifier: "group.com.8bit.bitwarden", + bundleIdentifier: "com.8bit.bitwarden", + bundlePath: "/tmp/Bitwarden.app", + containerURLProvider: { _, _ in nil } + ) + + XCTAssertEqual(subject.path, fileManager.temporaryDirectory.appendingPathComponent("Bitwarden.sqlite").path) + } +} diff --git a/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift b/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift index 42aa69bc0f..d96326fd36 100644 --- a/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift +++ b/BitwardenShared/Core/Platform/Services/TestHelpers/ServiceContainer+Mocks.swift @@ -28,6 +28,7 @@ extension ServiceContainer { cameraService: CameraService = MockCameraService(), changeKdfService: ChangeKdfService = MockChangeKdfService(), cipherOwnershipHelper: CipherOwnershipHelper = MockCipherOwnershipHelper(), + cipherService: CipherService = MockCipherService(), clientCertificateService: ClientCertificateService = MockClientCertificateService(), clientService: ClientService = MockClientService(), configService: ConfigService = MockConfigService(), @@ -64,6 +65,7 @@ extension ServiceContainer { // swiftlint:disable:next line_length serverCommunicationConfigClientSingleton: ServerCommunicationConfigClientSingleton = MockServerCommunicationConfigClientSingleton(), settingsRepository: SettingsRepository = MockSettingsRepository(), + settingsService: SettingsService = MockSettingsService(), sharedTimeoutService: SharedTimeoutService = MockSharedTimeoutService(), stateService: StateService = MockStateService(), syncService: SyncService = MockSyncService(), @@ -113,6 +115,7 @@ extension ServiceContainer { cameraService: cameraService, changeKdfService: changeKdfService, cipherOwnershipHelper: cipherOwnershipHelper, + cipherService: cipherService, clientCertificateService: clientCertificateService, clientService: clientService, configService: configService, @@ -146,6 +149,7 @@ extension ServiceContainer { serverCommunicationConfigAPIService: serverCommunicationConfigAPIService, serverCommunicationConfigClientSingleton: serverCommunicationConfigClientSingleton, settingsRepository: settingsRepository, + settingsService: settingsService, sharedTimeoutService: sharedTimeoutService, stateService: stateService, syncService: syncService, diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodec.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodec.swift new file mode 100644 index 0000000000..aca9a13814 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodec.swift @@ -0,0 +1,81 @@ +import Foundation + +// MARK: - SafariExtensionBridgeRequest + +public struct SafariExtensionBridgeRequest: Codable, Equatable { + public var id: String + public var request: SafariExtensionRequest + + public init(id: String, request: SafariExtensionRequest) { + self.id = id + self.request = request + } +} + +// MARK: - SafariExtensionBridgeResponse + +public struct SafariExtensionBridgeResponse: Codable, Equatable { + public var id: String + public var response: SafariExtensionResponse? + public var errorMessage: String? + + public init(id: String, response: SafariExtensionResponse?, errorMessage: String?) { + self.id = id + self.response = response + self.errorMessage = errorMessage + } +} + +// MARK: - SafariExtensionBridgeCodec + +public enum SafariExtensionBridgeCodec { + public static func decodeRequest(from message: Any?) -> SafariExtensionBridgeRequest? { + if let message, + JSONSerialization.isValidJSONObject(message), + let data = try? JSONSerialization.data(withJSONObject: message) { + return try? JSONDecoder().decode(SafariExtensionBridgeRequest.self, from: data) + } + + if let message = message as? String, + let data = message.data(using: .utf8) { + return try? JSONDecoder().decode(SafariExtensionBridgeRequest.self, from: data) + } + + return nil + } + + public static func encodeResponse( + requestID: String, + response: SafariExtensionResponse, + errorMessage: String? = nil, + ) throws -> String { + try encodeBridgeResponse( + SafariExtensionBridgeResponse( + id: requestID, + response: response, + errorMessage: errorMessage, + ) + ) + } + + public static func encodeErrorResponse( + requestID: String, + errorMessage: String, + ) throws -> String { + try encodeBridgeResponse( + SafariExtensionBridgeResponse( + id: requestID, + response: nil, + errorMessage: errorMessage, + ) + ) + } + + private static func encodeBridgeResponse(_ bridgeResponse: SafariExtensionBridgeResponse) throws -> String { + let data = try JSONEncoder().encode(bridgeResponse) + guard let message = String(data: data, encoding: .utf8) else { + throw CocoaError(.coderInvalidValue) + } + return message + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodecTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodecTests.swift new file mode 100644 index 0000000000..6961b1ff6f --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionBridgeCodecTests.swift @@ -0,0 +1,162 @@ +import XCTest + +@testable import BitwardenShared + +class SafariExtensionBridgeCodecTests: BitwardenTestCase { + func test_decodeRequestFromLegacyWrappedDictionary_parsesBridgeEnvelope() throws { + let message: [String: Any] = [ + "message": [ + "id": "req-legacy", + "request": [ + "kind": "setup", + ], + ], + ] + + let subject = try XCTUnwrap(SafariExtensionBridgeCodec.decodeRequest(from: message["message"])) + + XCTAssertEqual(subject.id, "req-legacy") + XCTAssertEqual(subject.request, SafariExtensionRequest(kind: .setup)) + } + + func test_decodeRequestFromJSONString_parsesBridgeEnvelope() throws { + let message = """ + { + "id": "req-1", + "request": { + "kind": "generatePassword" + } + } + """ + + let subject = try XCTUnwrap(SafariExtensionBridgeCodec.decodeRequest(from: message)) + + XCTAssertEqual(subject.id, "req-1") + XCTAssertEqual(subject.request, SafariExtensionRequest(kind: .generatePassword)) + } + + func test_decodeRequestFromDictionary_parsesBridgeEnvelope() throws { + let message: [String: Any] = [ + "id": "req-2", + "request": [ + "kind": "setup", + ], + ] + + let subject = try XCTUnwrap(SafariExtensionBridgeCodec.decodeRequest(from: message)) + + XCTAssertEqual(subject.id, "req-2") + XCTAssertEqual(subject.request, SafariExtensionRequest(kind: .setup)) + } + + func test_decodeRequestFromDictionary_withRequestContext_parsesBridgeEnvelope() throws { + let message: [String: Any] = [ + "id": "req-3", + "request": [ + "kind": "saveLogin", + "username": "user@example.com", + "password": "***", + "requestContext": [ + "trigger": "actionPanelPrimary", + "submissionAction": "saveNewLogin", + ], + ], + ] + + let subject = try XCTUnwrap(SafariExtensionBridgeCodec.decodeRequest(from: message)) + + XCTAssertEqual(subject.id, "req-3") + XCTAssertEqual(subject.request.kind, .saveLogin) + XCTAssertEqual(subject.request.requestContext?.trigger, .actionPanelPrimary) + XCTAssertEqual(subject.request.requestContext?.submissionAction, .saveNewLogin) + } + + func test_decodeRequestFromDictionary_withSetupRequestContext_parsesBridgeEnvelope() throws { + let message: [String: Any] = [ + "id": "req-setup", + "request": [ + "kind": "setup", + "urlString": "https://example.com/account", + "requestContext": [ + "trigger": "setupButton", + ], + ], + ] + + let subject = try XCTUnwrap(SafariExtensionBridgeCodec.decodeRequest(from: message)) + + XCTAssertEqual(subject.id, "req-setup") + XCTAssertEqual(subject.request.kind, .setup) + XCTAssertEqual(subject.request.requestContext?.trigger, .setupButton) + XCTAssertNil(subject.request.requestContext?.submissionAction) + } + + func test_encodeResponse_returnsJSONStringEnvelope() throws { + let response = try SafariExtensionResponse.generatedPassword( + "generated-secret", + for: SafariExtensionRequest(kind: .generatePassword), + ) + + let encoded = try SafariExtensionBridgeCodec.encodeResponse( + requestID: "req-1", + response: response, + ) + let decoded = try JSONDecoder().decode(SafariExtensionBridgeResponse.self, from: XCTUnwrap(encoded.data(using: .utf8))) + + XCTAssertEqual(decoded.id, "req-1") + XCTAssertEqual(decoded.response?.generatedPassword, "generated-secret") + XCTAssertNil(decoded.response?.followUpType) + XCTAssertNil(decoded.errorMessage) + } + + func test_encodeResponse_withFollowUpType_roundTrips() throws { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com" + ) + let followUpRequest = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/register", + username: "user@example.com" + ) + let response = SafariExtensionResponse( + request: request, + suggestionAction: .saveLogin, + submissionAction: .saveNewLogin, + matchedLogin: nil, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: "Save this generated password to Bitwarden.", + followUpType: .generatedPassword, + followUpRequest: followUpRequest, + followUpSubmissionAction: .saveNewLogin, + ) + + let encoded = try SafariExtensionBridgeCodec.encodeResponse( + requestID: "req-followup", + response: response, + ) + let decoded = try JSONDecoder().decode(SafariExtensionBridgeResponse.self, from: XCTUnwrap(encoded.data(using: .utf8))) + + XCTAssertEqual(decoded.id, "req-followup") + XCTAssertEqual(decoded.response?.followUpType, .generatedPassword) + XCTAssertEqual(decoded.response?.followUpRequest?.urlString, "https://example.com/register") + XCTAssertEqual(decoded.response?.followUpSubmissionAction, .saveNewLogin) + XCTAssertEqual(decoded.response?.submissionAction, .saveNewLogin) + } + + func test_encodeErrorResponse_returnsErrorOnlyJSONStringEnvelope() throws { + let encoded = try SafariExtensionBridgeCodec.encodeErrorResponse( + requestID: "req-error", + errorMessage: "Invalid native request payload." + ) + let decoded = try JSONDecoder().decode(SafariExtensionBridgeResponse.self, from: XCTUnwrap(encoded.data(using: .utf8))) + + XCTAssertEqual(decoded.id, "req-error") + XCTAssertNil(decoded.response) + XCTAssertEqual(decoded.errorMessage, "Invalid native request payload.") + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionCredentialStoreService.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionCredentialStoreService.swift new file mode 100644 index 0000000000..e97a4ffe63 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionCredentialStoreService.swift @@ -0,0 +1,182 @@ +import BitwardenKit +import BitwardenSdk +import Foundation + +protocol SafariExtensionCredentialStoring { + func saveCredential( + for request: SafariExtensionRequest, + matchedLogin: SafariExtensionMatchedLogin?, + submissionAction: SafariExtensionSubmissionAction + ) async throws +} + +final class SafariExtensionCredentialStoreService: SafariExtensionCredentialStoring { + private let cipherService: CipherService + private let clientService: ClientService + private let nowProvider: () -> Date + + init( + cipherService: CipherService, + clientService: ClientService, + nowProvider: @escaping () -> Date = Date.init + ) { + self.cipherService = cipherService + self.clientService = clientService + self.nowProvider = nowProvider + } + + func saveCredential( + for request: SafariExtensionRequest, + matchedLogin: SafariExtensionMatchedLogin?, + submissionAction: SafariExtensionSubmissionAction + ) async throws { + let cipherView = try await makeCipherView( + for: request, + matchedLogin: matchedLogin, + submissionAction: submissionAction + ) + let encryptionContext = try await clientService.vault().ciphers().encrypt(cipherView: cipherView) + + switch submissionAction { + case .saveNewLogin: + try await cipherService.addCipherWithServer( + encryptionContext.cipher, + encryptedFor: encryptionContext.encryptedFor + ) + case .updateExistingLogin, .updatePassword: + try await cipherService.updateCipherWithServer( + encryptionContext.cipher, + encryptedFor: encryptionContext.encryptedFor + ) + default: + break + } + } + + private func makeCipherView( + for request: SafariExtensionRequest, + matchedLogin: SafariExtensionMatchedLogin?, + submissionAction: SafariExtensionSubmissionAction + ) async throws -> CipherView { + switch submissionAction { + case .saveNewLogin: + return CipherView( + id: nil, + organizationId: nil, + folderId: nil, + collectionIds: [], + key: nil, + name: resolvedName(for: request, fallback: "Login", prefersURLFallback: true), + notes: normalized(request.notes), + type: .login, + login: BitwardenSdk.LoginView( + username: normalized(request.username), + password: normalized(request.password), + passwordRevisionDate: normalized(request.password) == nil ? nil : nowProvider(), + uris: resolvedUris(from: request.urlString), + totp: nil, + autofillOnPageLoad: nil, + fido2Credentials: nil + ), + identity: nil, + card: nil, + secureNote: nil, + sshKey: nil, + favorite: false, + reprompt: .none, + organizationUseTotp: false, + edit: true, + permissions: nil, + viewPassword: true, + localData: nil, + attachments: nil, + attachmentDecryptionFailures: nil, + fields: nil, + passwordHistory: nil, + creationDate: nowProvider(), + deletedDate: nil, + revisionDate: nowProvider(), + archivedDate: nil + ) + case .updateExistingLogin, .updatePassword: + guard let cipherID = matchedLogin?.id, + let existingCipher = try await cipherService.fetchCipher(withId: cipherID) else { + throw CocoaError(.fileNoSuchFile) + } + let existingCipherView = try await clientService.vault().ciphers().decrypt(cipher: existingCipher) + let existingLogin = existingCipherView.login + let password = normalized(request.password) ?? existingLogin?.password + let updatedLogin = BitwardenSdk.LoginView( + username: submissionAction == .updatePassword + ? existingLogin?.username + : normalized(request.username) ?? existingLogin?.username, + password: password, + passwordRevisionDate: password == existingLogin?.password + ? existingLogin?.passwordRevisionDate + : nowProvider(), + uris: resolvedUris(from: request.urlString) ?? existingLogin?.uris, + totp: existingLogin?.totp, + autofillOnPageLoad: existingLogin?.autofillOnPageLoad, + fido2Credentials: existingLogin?.fido2Credentials + ) + return CipherView( + id: existingCipherView.id, + organizationId: existingCipherView.organizationId, + folderId: existingCipherView.folderId, + collectionIds: existingCipherView.collectionIds, + key: existingCipherView.key, + name: resolvedName(for: request, fallback: existingCipherView.name, prefersURLFallback: false), + notes: normalized(request.notes) ?? existingCipherView.notes, + type: existingCipherView.type, + login: updatedLogin, + identity: existingCipherView.identity, + card: existingCipherView.card, + secureNote: existingCipherView.secureNote, + sshKey: existingCipherView.sshKey, + favorite: existingCipherView.favorite, + reprompt: existingCipherView.reprompt, + organizationUseTotp: existingCipherView.organizationUseTotp, + edit: existingCipherView.edit, + permissions: existingCipherView.permissions, + viewPassword: existingCipherView.viewPassword, + localData: existingCipherView.localData, + attachments: existingCipherView.attachments, + attachmentDecryptionFailures: existingCipherView.attachmentDecryptionFailures, + fields: existingCipherView.fields, + passwordHistory: existingCipherView.passwordHistory, + creationDate: existingCipherView.creationDate, + deletedDate: existingCipherView.deletedDate, + revisionDate: nowProvider(), + archivedDate: existingCipherView.archivedDate + ) + default: + throw CocoaError(.coderInvalidValue) + } + } + + private func normalized(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private func resolvedName(for request: SafariExtensionRequest, fallback: String, prefersURLFallback: Bool) -> String { + if let loginTitle = normalized(request.loginTitle) { + return loginTitle + } + if prefersURLFallback, + let host = normalized(request.urlString).flatMap({ URL(string: $0)?.host }) { + return host + } + return fallback + } + + private func resolvedUris(from urlString: String?) -> [LoginUriView]? { + guard let urlString = normalized(urlString) else { + return nil + } + return [LoginUriView(uri: urlString, match: nil, uriChecksum: nil)] + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionCredentialStoreServiceTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionCredentialStoreServiceTests.swift new file mode 100644 index 0000000000..1760906331 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionCredentialStoreServiceTests.swift @@ -0,0 +1,121 @@ +import BitwardenKitMocks +import BitwardenSdk +import TestHelpers +import XCTest + +@testable import BitwardenShared +@testable import BitwardenSharedMocks + +final class SafariExtensionCredentialStoreServiceTests: BitwardenTestCase { + var cipherService: MockCipherService! + var clientService: MockClientService! + var now: Date! + var subject: SafariExtensionCredentialStoreService! + + override func setUp() { + super.setUp() + cipherService = MockCipherService() + clientService = MockClientService() + now = Date(year: 2026, month: 4, day: 23, hour: 19, minute: 35) + subject = SafariExtensionCredentialStoreService( + cipherService: cipherService, + clientService: clientService, + nowProvider: { self.now } + ) + } + + override func tearDown() { + super.tearDown() + subject = nil + now = nil + clientService = nil + cipherService = nil + } + + func test_saveCredential_saveNewLogin_encryptsAndAddsCipher() async throws { + let request = SafariExtensionRequest( + kind: .saveLogin, + loginTitle: "Example", + notes: "Imported from Safari", + requestContext: SafariExtensionRequestContext( + trigger: .actionPanelPrimary, + submissionAction: .saveNewLogin + ), + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com" + ) + + try await subject.saveCredential( + for: request, + matchedLogin: nil, + submissionAction: .saveNewLogin + ) + + XCTAssertEqual(clientService.mockVault.clientCiphers.encryptedCiphers.count, 1) + let encryptedCipherView = try XCTUnwrap(clientService.mockVault.clientCiphers.encryptedCiphers.first) + XCTAssertNil(encryptedCipherView.id) + XCTAssertEqual(encryptedCipherView.name, "Example") + XCTAssertEqual(encryptedCipherView.notes, "Imported from Safari") + XCTAssertEqual(encryptedCipherView.login?.username, "user@example.com") + XCTAssertEqual(encryptedCipherView.login?.password, "secret") + XCTAssertEqual(encryptedCipherView.login?.passwordRevisionDate, now) + XCTAssertEqual(encryptedCipherView.login?.uris?.first?.uri, "https://example.com/login") + XCTAssertEqual(cipherService.addCipherWithServerCiphers.count, 1) + XCTAssertEqual(cipherService.addCipherWithServerEncryptedFor, "1") + XCTAssertTrue(cipherService.updateCipherWithServerCiphers.isEmpty) + } + + func test_saveCredential_updateExistingLogin_fetchesDecryptsEncryptsAndUpdatesCipher() async throws { + let existingCipher = Cipher.fixture( + id: "cipher-1", + login: .fixture( + password: "old-secret", + passwordRevisionDate: Date(year: 2025, month: 1, day: 1), + uris: [.fixture(uri: "https://example.com/old")], + username: "old@example.com" + ), + name: "Existing login", + notes: "Existing notes", + type: .login + ) + cipherService.fetchCipherResult = .success(existingCipher) + + let request = SafariExtensionRequest( + kind: .saveLogin, + requestContext: SafariExtensionRequestContext( + trigger: .actionPanelPrimary, + submissionAction: .updateExistingLogin + ), + password: "new-secret", + urlString: "https://example.com/login", + username: "new@example.com" + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "old@example.com", + password: "old-secret", + urlString: "https://example.com/old" + ) + + try await subject.saveCredential( + for: request, + matchedLogin: matchedLogin, + submissionAction: .updateExistingLogin + ) + + XCTAssertEqual(cipherService.fetchCipherId, "cipher-1") + XCTAssertEqual(clientService.mockVault.clientCiphers.encryptedCiphers.count, 1) + let encryptedCipherView = try XCTUnwrap(clientService.mockVault.clientCiphers.encryptedCiphers.first) + XCTAssertEqual(encryptedCipherView.id, "cipher-1") + XCTAssertEqual(encryptedCipherView.name, "Existing login") + XCTAssertEqual(encryptedCipherView.notes, "Existing notes") + XCTAssertEqual(encryptedCipherView.login?.username, "new@example.com") + XCTAssertEqual(encryptedCipherView.login?.password, "new-secret") + XCTAssertEqual(encryptedCipherView.login?.passwordRevisionDate, now) + XCTAssertEqual(encryptedCipherView.login?.uris?.first?.uri, "https://example.com/login") + XCTAssertEqual(cipherService.updateCipherWithServerCiphers.count, 1) + XCTAssertEqual(cipherService.updateCipherWithServerEncryptedFor, "1") + XCTAssertTrue(cipherService.addCipherWithServerCiphers.isEmpty) + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolver.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolver.swift new file mode 100644 index 0000000000..33f09920f7 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolver.swift @@ -0,0 +1,62 @@ +import BitwardenSdk +import Foundation + +// MARK: - SafariExtensionMatchedLoginResolver + +struct SafariExtensionMatchedLoginResolver: SafariExtensionMatchedLoginResolving { + private let cipherMatchingHelperFactory: CipherMatchingHelperFactory + private let ciphersClientWrapperService: CiphersClientWrapperService + private let cipherService: CipherService + private let stateService: StateService + + init( + cipherMatchingHelperFactory: CipherMatchingHelperFactory, + ciphersClientWrapperService: CiphersClientWrapperService, + cipherService: CipherService, + stateService: StateService, + ) { + self.cipherMatchingHelperFactory = cipherMatchingHelperFactory + self.ciphersClientWrapperService = ciphersClientWrapperService + self.cipherService = cipherService + self.stateService = stateService + } + + func resolveMatchedLogin(for request: SafariExtensionRequest) async throws -> SafariExtensionMatchedLogin? { + guard let uri = request.urlString, !uri.isEmpty else { + return nil + } + + let ciphers = try await cipherService.fetchAllCiphers() + guard !ciphers.isEmpty else { + return nil + } + + let matchingHelper = await cipherMatchingHelperFactory.make(uri: uri) + let ciphersById = Dictionary(uniqueKeysWithValues: ciphers.compactMap { cipher in + cipher.id.map { ($0, cipher) } + }) + + var matchedLogin: SafariExtensionMatchedLogin? + await ciphersClientWrapperService.decryptAndProcessCiphersInBatch( + ciphers: ciphers, + onCipher: { decryptedCipher in + guard matchedLogin == nil, + matchingHelper.doesCipherMatch(cipher: decryptedCipher, archiveVaultItemsFF: false) != .none, + let id = decryptedCipher.id, + let sourceCipher = ciphersById[id] else { + return + } + + matchedLogin = SafariExtensionMatchedLogin( + id: id, + username: decryptedCipher.type.loginListView?.username, + password: sourceCipher.login?.password, + urlString: sourceCipher.login?.uris?.first?.uri, + ) + }, + ) + + _ = stateService + return matchedLogin + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolverTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolverTests.swift new file mode 100644 index 0000000000..31bdf7df32 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolverTests.swift @@ -0,0 +1,152 @@ +import BitwardenKitMocks +import BitwardenSdk +import TestHelpers +import XCTest + +@testable import BitwardenShared +@testable import BitwardenSharedMocks + +class SafariExtensionMatchedLoginResolverTests: BitwardenTestCase { + func test_resolveContext_withoutMatchedLogin_classifiesSaveNewLogin() async throws { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + let subject = MockSafariExtensionMatchedLoginResolver(matchedLogin: nil) + + let resolved = try await subject.resolveContext(for: request) + + XCTAssertNil(resolved.matchedLogin) + XCTAssertEqual(resolved.suggestionAction, .saveLogin) + XCTAssertEqual(resolved.submissionAction, .saveNewLogin) + } + + func test_resolveContext_withMatchedLogin_classifiesUpdatePassword() async throws { + let request = SafariExtensionRequest( + kind: .changePassword, + oldPassword: "old-secret", + password: "new-secret", + urlString: "https://example.com/change-password", + ) + let subject = MockSafariExtensionMatchedLoginResolver( + matchedLogin: SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ), + ) + + let resolved = try await subject.resolveContext(for: request) + + XCTAssertEqual(resolved.matchedLogin?.id, "cipher-1") + XCTAssertEqual(resolved.suggestionAction, .updatePassword) + XCTAssertEqual(resolved.submissionAction, .updatePassword) + } + + func test_liveResolver_withNoCipherData_returnsNil() async throws { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + let subject = SafariExtensionMatchedLoginResolver( + cipherMatchingHelperFactory: MockCipherMatchingHelperFactory(), + ciphersClientWrapperService: DefaultCiphersClientWrapperService( + clientService: MockClientService(), + errorReporter: MockErrorReporter(), + ), + cipherService: MockCipherService(), + stateService: MockStateService(), + ) + + let matchedLogin = try await subject.resolveMatchedLogin(for: request) + + XCTAssertNil(matchedLogin) + } + + func test_liveResolver_withMatchingCipher_returnsMatchedLogin() async throws { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "new-secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + let matchingHelper = MockCipherMatchingHelper() + matchingHelper.doesCipherMatchClosure = { cipher, _ in + cipher.id == "cipher-1" ? .exact : .none + } + let matchingHelperFactory = MockCipherMatchingHelperFactory() + matchingHelperFactory.makeReturnValue = matchingHelper + let clientService = MockClientService() + clientService.mockVault.clientCiphers.decryptListWithFailuresResultClosure = { ciphers in + let successes = ciphers.map { cipher in + CipherListView( + id: cipher.id, + organizationId: cipher.organizationId, + folderId: cipher.folderId, + collectionIds: cipher.collectionIds, + key: cipher.key, + name: cipher.name, + subtitle: "", + type: .login(.fixture( + username: cipher.login?.username, + uris: cipher.login?.uris?.map { LoginUriView(loginUri: $0) }, + )), + favorite: cipher.favorite, + reprompt: cipher.reprompt, + organizationUseTotp: cipher.organizationUseTotp, + edit: cipher.edit, + permissions: cipher.permissions, + viewPassword: cipher.viewPassword, + attachments: UInt32(cipher.attachments?.count ?? 0), + hasOldAttachments: false, + creationDate: cipher.creationDate, + deletedDate: cipher.deletedDate, + revisionDate: cipher.revisionDate, + archivedDate: cipher.archivedDate, + copyableFields: [], + localData: cipher.localData.map { LocalDataView(localData: $0) }, + ) + } + return DecryptCipherListResult(successes: successes, failures: []) + } + let cipherService = MockCipherService() + cipherService.fetchAllCiphersResult = .success([ + Cipher.fixture( + id: "cipher-1", + login: .fixture( + password: "stored-secret", + uris: [.fixture(uri: "https://example.com/login")], + username: "user@example.com" + ) + ) + ]) + let subject = SafariExtensionMatchedLoginResolver( + cipherMatchingHelperFactory: matchingHelperFactory, + ciphersClientWrapperService: DefaultCiphersClientWrapperService( + clientService: clientService, + errorReporter: MockErrorReporter(), + ), + cipherService: cipherService, + stateService: MockStateService(), + ) + + let matchedLogin = try await subject.resolveMatchedLogin(for: request) + + XCTAssertEqual(matchedLogin?.id, "cipher-1") + XCTAssertEqual(matchedLogin?.username, "user@example.com") + XCTAssertEqual(matchedLogin?.urlString, "https://example.com/login") + } +} + +private struct MockSafariExtensionMatchedLoginResolver: SafariExtensionMatchedLoginResolving { + var matchedLogin: SafariExtensionMatchedLogin? + + func resolveMatchedLogin(for request: SafariExtensionRequest) async throws -> SafariExtensionMatchedLogin? { + matchedLogin + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolving.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolving.swift new file mode 100644 index 0000000000..b7d1f1312a --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionMatchedLoginResolving.swift @@ -0,0 +1,32 @@ +// MARK: - SafariExtensionResolvedContext + +/// The request plus any matched login, after vault resolution has run. +struct SafariExtensionResolvedContext: Equatable { + var request: SafariExtensionRequest + var matchedLogin: SafariExtensionMatchedLogin? + + var suggestionAction: SafariExtensionSuggestionAction { + SafariExtensionSuggestionAction.from(request) + } + + var submissionAction: SafariExtensionSubmissionAction { + SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin) + } +} + +// MARK: - SafariExtensionMatchedLoginResolving + +/// Resolves an existing login match for Safari save/update/change-password flows. +protocol SafariExtensionMatchedLoginResolving { + func resolveMatchedLogin(for request: SafariExtensionRequest) async throws -> SafariExtensionMatchedLogin? +} + +extension SafariExtensionMatchedLoginResolving { + func resolveContext(for request: SafariExtensionRequest) async throws -> SafariExtensionResolvedContext { + let matchedLogin = try await resolveMatchedLogin(for: request) + return SafariExtensionResolvedContext( + request: request, + matchedLogin: matchedLogin, + ) + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequest.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequest.swift new file mode 100644 index 0000000000..03aa0a9de5 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequest.swift @@ -0,0 +1,123 @@ +import Foundation + +// MARK: - SafariExtensionRequestKind + +/// The high-level action requested by the Safari extension host/content bridge. +public enum SafariExtensionRequestKind: String, Codable, Equatable { + case setup + case fill + case saveLogin + case changePassword + case generatePassword +} + +// MARK: - SafariExtensionRequestTrigger + +/// The originating UX trigger for a Safari extension request. +public enum SafariExtensionRequestTrigger: String, Codable, Equatable { + case suggestedAction + case actionPanelPrimary + case setupButton +} + +// MARK: - SafariExtensionRequestContext + +/// Additional product-level context about how a Safari extension request was initiated. +public struct SafariExtensionRequestContext: Codable, Equatable { + var trigger: SafariExtensionRequestTrigger + var submissionAction: SafariExtensionSubmissionAction? +} + +// MARK: - SafariExtensionRequest + +/// A shared Codable payload for Safari extension requests flowing between web/native layers. +public struct SafariExtensionRequest: Codable, Equatable { + /// The requested action type. + public var kind: SafariExtensionRequestKind + + /// The login title extracted from the page, if any. + var loginTitle: String? + + /// Notes extracted from the page or flow, if any. + var notes: String? + + /// The previous password for change-password flows. + var oldPassword: String? + + /// Additional metadata about how the request was initiated. + var requestContext: SafariExtensionRequestContext? + + /// Parsed page details for page-aware fill/save/update flows. + var pageDetails: PageDetails? + + /// The current or generated password value. + var password: String? + + /// Password generation options associated with the page/action. + var passwordOptions: PasswordGenerationOptions? + + /// The page URL used for matching and save/update suggestions. + var urlString: String? + + /// The username extracted from the page, if any. + var username: String? + + /// Whether this request can drive page-aware autofill. + public var canAutofill: Bool { + kind == .fill && pageDetails?.hasPasswordField == true + } + + /// Whether this request contains enough information to save a login. + public var canSaveLogin: Bool { + kind == .saveLogin && !(username?.isEmpty ?? true) && !(password?.isEmpty ?? true) + } + + /// Whether this request contains enough information to update a password. + public var canChangePassword: Bool { + kind == .changePassword && !(password?.isEmpty ?? true) + } + + /// Whether this request can drive password generation UI. + public var canGeneratePassword: Bool { + kind == .generatePassword + } + + public init(kind: SafariExtensionRequestKind) { + self.init( + kind: kind, + loginTitle: nil, + notes: nil, + oldPassword: nil, + requestContext: nil, + pageDetails: nil, + password: nil, + passwordOptions: nil, + urlString: nil, + username: nil, + ) + } + + init( + kind: SafariExtensionRequestKind, + loginTitle: String? = nil, + notes: String? = nil, + oldPassword: String? = nil, + requestContext: SafariExtensionRequestContext? = nil, + pageDetails: PageDetails? = nil, + password: String? = nil, + passwordOptions: PasswordGenerationOptions? = nil, + urlString: String? = nil, + username: String? = nil, + ) { + self.kind = kind + self.loginTitle = loginTitle + self.notes = notes + self.oldPassword = oldPassword + self.requestContext = requestContext + self.pageDetails = pageDetails + self.password = password + self.passwordOptions = passwordOptions + self.urlString = urlString + self.username = username + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessor.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessor.swift new file mode 100644 index 0000000000..f181c9ed52 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessor.swift @@ -0,0 +1,491 @@ +// MARK: - SafariExtensionRequestProcessor + +import BitwardenKit +import BitwardenSdk + +public struct SafariExtensionRequestProcessor { + private let matchedLoginResolver: (any SafariExtensionMatchedLoginResolving)? + private let credentialStore: (any SafariExtensionCredentialStoring)? + private let passwordGenerator: (PasswordGenerationOptions?) -> String + private let generatedPasswordProducer: ((PasswordGenerationOptions?) async -> String?)? + + public init() { + matchedLoginResolver = nil + credentialStore = nil + passwordGenerator = { _ in "generated-password" } + generatedPasswordProducer = nil + } + + @MainActor + public static func liveForAppExtension(errorReporter: ErrorReporter) -> Self { + live(services: ServiceContainer(appContext: .appExtension, errorReporter: errorReporter)) + } + + @MainActor + static func live(services: ServiceContainer) -> Self { + let matchedLoginResolver = SafariExtensionMatchedLoginResolver( + cipherMatchingHelperFactory: DefaultCipherMatchingHelperFactory( + settingsService: services.settingsService, + stateService: services.stateService, + ), + ciphersClientWrapperService: DefaultCiphersClientWrapperService( + clientService: services.clientService, + errorReporter: services.errorReporter, + ), + cipherService: services.cipherService, + stateService: services.stateService, + ) + let credentialStore = SafariExtensionCredentialStoreService( + cipherService: services.cipherService, + clientService: services.clientService, + ) + + return Self( + matchedLoginResolver: matchedLoginResolver, + credentialStore: credentialStore, + generatedPasswordProducer: { options in + try? await Self.generatePassword(using: services.generatorRepository, options: options) + } + ) + } + + init( + matchedLoginResolver: (any SafariExtensionMatchedLoginResolving)? = nil, + credentialStore: (any SafariExtensionCredentialStoring)? = nil, + passwordGenerator: @escaping (PasswordGenerationOptions?) -> String = { _ in "generated-password" }, + generatedPasswordProducer: ((PasswordGenerationOptions?) async -> String?)? = nil + ) { + self.matchedLoginResolver = matchedLoginResolver + self.credentialStore = credentialStore + self.passwordGenerator = passwordGenerator + self.generatedPasswordProducer = generatedPasswordProducer + } + + public func makeResponse(for request: SafariExtensionRequest) -> SafariExtensionResponse? { + makeResponse(for: request, matchedLogin: nil) + } + + public func makeAsyncResponse(for request: SafariExtensionRequest) async -> SafariExtensionResponse? { + await makeResponse(for: request) + } + + func makeResponse(for request: SafariExtensionRequest) async -> SafariExtensionResponse? { + let matchedLogin = try? await matchedLoginResolver?.resolveMatchedLogin(for: request) + + if request.kind == .generatePassword { + if let generatedPassword = await makeGeneratedPassword(for: request) { + let followUpRequest = makeGeneratedPasswordFollowUpRequest( + for: request, + generatedPassword: generatedPassword + ) + let followUpSubmissionAction = makeGeneratedPasswordFollowUpSubmissionAction( + for: followUpRequest, + matchedLogin: matchedLogin ?? nil + ) + return try? SafariExtensionResponse.generatedPassword( + generatedPassword, + for: request, + matchedLogin: matchedLogin ?? nil, + followUpType: followUpSubmissionAction == nil ? nil : .generatedPassword, + followUpRequest: followUpRequest, + followUpSubmissionAction: followUpSubmissionAction, + ) + } + + return SafariExtensionResponse( + request: request, + suggestionAction: SafariExtensionSuggestionAction.from(request), + submissionAction: .none, + matchedLogin: nil, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: "Couldn’t generate a password in Bitwarden.", + ) + } + + if request.requestContext?.trigger == .actionPanelPrimary { + let submissionAction = request.requestContext?.submissionAction + ?? SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin ?? nil) + if let persistedResponse = await makePersistedResponse( + for: request, + matchedLogin: matchedLogin ?? nil, + submissionAction: submissionAction, + ) { + return persistedResponse + } + } + + return makeResponse(for: request, matchedLogin: matchedLogin ?? nil) + } + + private func makeResponse( + for request: SafariExtensionRequest, + matchedLogin: SafariExtensionMatchedLogin?, + ) -> SafariExtensionResponse? { + switch request.kind { + case .generatePassword: + let followUpRequest = makeGeneratedPasswordFollowUpRequest( + for: request, + generatedPassword: passwordGenerator(request.passwordOptions) + ) + let followUpSubmissionAction = makeGeneratedPasswordFollowUpSubmissionAction( + for: followUpRequest, + matchedLogin: nil + ) + return try? SafariExtensionResponse.generatedPassword( + passwordGenerator(request.passwordOptions), + for: request, + followUpType: followUpSubmissionAction == nil ? nil : .generatedPassword, + followUpRequest: followUpRequest, + followUpSubmissionAction: followUpSubmissionAction, + ) + case .setup: + return SafariExtensionResponse( + request: request, + suggestionAction: .none, + submissionAction: .none, + matchedLogin: nil, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: "Open Bitwarden to finish Safari extension setup.", + ) + case .fill: + guard let matchedLogin, + let username = matchedLogin.username, + let password = matchedLogin.password else { + return SafariExtensionResponse( + request: request, + suggestionAction: SafariExtensionSuggestionAction.from(request), + submissionAction: .none, + matchedLogin: matchedLogin, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: "No matching Bitwarden login found for this page.", + ) + } + + return try? SafariExtensionResponse.fill( + request: request, + username: username, + password: password, + fields: [], + matchedLogin: matchedLogin, + ) + case .saveLogin, .changePassword: + let suggestionAction = SafariExtensionSuggestionAction.from(request) + let submissionAction = SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin) + return SafariExtensionResponse( + request: request, + suggestionAction: suggestionAction, + submissionAction: submissionAction, + matchedLogin: matchedLogin, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: makeUserMessage(for: submissionAction), + ) + } + } + + private func makeGeneratedPassword(for request: SafariExtensionRequest) async -> String? { + if let generatedPasswordProducer { + return await generatedPasswordProducer(request.passwordOptions) + } + + return passwordGenerator(request.passwordOptions) + } + + private func makePersistedResponse( + for request: SafariExtensionRequest, + matchedLogin: SafariExtensionMatchedLogin?, + submissionAction: SafariExtensionSubmissionAction, + ) async -> SafariExtensionResponse? { + guard let credentialStore, + [.saveNewLogin, .updateExistingLogin, .updatePassword].contains(submissionAction) else { + return nil + } + + do { + try await credentialStore.saveCredential( + for: request, + matchedLogin: matchedLogin, + submissionAction: submissionAction, + ) + return SafariExtensionResponse( + request: request, + suggestionAction: SafariExtensionSuggestionAction.from(request), + submissionAction: submissionAction, + matchedLogin: matchedLogin, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: makePersistedUserMessage(for: submissionAction), + ) + } catch { + return SafariExtensionResponse( + request: request, + suggestionAction: SafariExtensionSuggestionAction.from(request), + submissionAction: submissionAction, + matchedLogin: matchedLogin, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: makePersistenceFailureMessage(for: submissionAction), + ) + } + } + + private func makePersistedUserMessage(for submissionAction: SafariExtensionSubmissionAction) -> String? { + switch submissionAction { + case .saveNewLogin: + return "Saved login to Bitwarden." + case .updateExistingLogin: + return "Updated the Bitwarden login." + case .updatePassword: + return "Updated the Bitwarden password." + default: + return nil + } + } + + private func makePersistenceFailureMessage(for submissionAction: SafariExtensionSubmissionAction) -> String? { + switch submissionAction { + case .saveNewLogin: + return "Couldn’t save this login to Bitwarden." + case .updateExistingLogin: + return "Couldn’t update this Bitwarden login." + case .updatePassword: + return "Couldn’t update this Bitwarden password." + default: + return nil + } + } + + private func makeUserMessage(for submissionAction: SafariExtensionSubmissionAction) -> String? { + switch submissionAction { + case .none, .fill: + return nil + case .saveNewLogin: + return "Save this login to Bitwarden." + case .updateExistingLogin: + return "Update the existing Bitwarden login with these changes." + case .updatePassword: + return "Update the password for this Bitwarden login." + case .generatePassword: + return nil + } + } + + private static func generatePassword( + using generatorRepository: GeneratorRepository, + options: PasswordGenerationOptions? + ) async throws -> String { + let resolvedOptions = try await generatorOptions(using: generatorRepository, options: options) + + switch resolvedOptions.type ?? .password { + case .passphrase: + return try await generatorRepository.generatePassphrase( + settings: makePassphraseGeneratorRequest(from: resolvedOptions) + ) + case .password: + return try await generatorRepository.generatePassword( + settings: makePasswordGeneratorRequest(from: resolvedOptions) + ) + } + } + + private static func generatorOptions( + using generatorRepository: GeneratorRepository, + options: PasswordGenerationOptions? + ) async throws -> PasswordGenerationOptions { + if let options { + return options + } + + return try await generatorRepository.getPasswordGenerationOptions() + } + + private static func makePassphraseGeneratorRequest(from options: PasswordGenerationOptions) -> PassphraseGeneratorRequest { + PassphraseGeneratorRequest( + numWords: clampedUInt8(options.numWords ?? 3), + wordSeparator: options.wordSeparator ?? "-", + capitalize: options.capitalize ?? false, + includeNumber: options.includeNumber ?? false + ) + } + + private static func makePasswordGeneratorRequest(from options: PasswordGenerationOptions) -> PasswordGeneratorRequest { + var lowercase = options.lowercase ?? true + var uppercase = options.uppercase ?? true + var numbers = options.number ?? true + var special = options.special ?? true + + if !lowercase, !uppercase, !numbers, !special { + lowercase = true + } + + return PasswordGeneratorRequest( + lowercase: lowercase, + uppercase: uppercase, + numbers: numbers, + special: special, + length: clampedUInt8(options.length ?? 14), + avoidAmbiguous: !(options.allowAmbiguousChar ?? true), + minLowercase: options.minLowercase.map(clampedUInt8), + minUppercase: options.minUppercase.map(clampedUInt8), + minNumber: options.minNumber.map(clampedUInt8), + minSpecial: options.minSpecial.map(clampedUInt8) + ) + } + + private static func clampedUInt8(_ value: Int) -> UInt8 { + UInt8(max(1, min(value, Int(UInt8.max)))) + } +} + +private extension SafariExtensionRequestProcessor { + func makeGeneratedPasswordFollowUpRequest( + for request: SafariExtensionRequest, + generatedPassword: String + ) -> SafariExtensionRequest? { + guard let pageDetails = request.pageDetails else { + return nil + } + + let passwordFields = pageDetails.fields.filter { $0.type == "password" && $0.viewable } + let roles = passwordFields.map(passwordFieldRole) + let hasCurrentPassword = roles.contains(.current) + let hasNewPassword = roles.contains(.new) + let hasConfirmPassword = roles.contains(.confirm) + + if hasCurrentPassword && (hasNewPassword || hasConfirmPassword) { + return SafariExtensionRequest( + kind: .changePassword, + oldPassword: currentPasswordValue(from: pageDetails), + password: generatedPassword, + urlString: request.urlString + ) + } + + if hasNewPassword || hasConfirmPassword { + if preferredUsername(from: pageDetails) == nil, isGeneratedPasswordChangePasswordSurface(pageDetails) { + return SafariExtensionRequest( + kind: .changePassword, + password: generatedPassword, + urlString: request.urlString + ) + } + + return SafariExtensionRequest( + kind: .saveLogin, + password: generatedPassword, + urlString: request.urlString, + username: preferredUsername(from: pageDetails) + ) + } + + return nil + } + + func makeGeneratedPasswordFollowUpSubmissionAction( + for followUpRequest: SafariExtensionRequest?, + matchedLogin: SafariExtensionMatchedLogin? + ) -> SafariExtensionSubmissionAction? { + guard let followUpRequest else { + return nil + } + + let action = SafariExtensionSubmissionAction.classify(followUpRequest, matchedLogin: matchedLogin) + if action != .none { + return action + } + + switch followUpRequest.kind { + case .changePassword: + return .updatePassword + case .saveLogin: + return followUpRequest.username == nil ? nil : .saveNewLogin + default: + return nil + } + } + + func currentPasswordValue(from pageDetails: PageDetails?) -> String? { + guard let pageDetails else { + return nil + } + + let currentPasswordField = pageDetails.fields.first { passwordFieldRole($0) == .current } + return normalizedFieldValue(currentPasswordField) + } + + func preferredUsername(from pageDetails: PageDetails?) -> String? { + guard let pageDetails else { + return nil + } + + let fields = pageDetails.fields + let preferredField = fields.first { $0.type == "email" && $0.viewable } + ?? fields.first { $0.type == "text" && $0.viewable } + ?? fields.first { $0.type == "tel" && $0.viewable } + ?? fields.first { $0.type == "email" } + + return normalizedFieldValue(preferredField) + } + + func normalizedFieldValue(_ field: PageDetails.Field?) -> String? { + guard let value = field?.value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } + + func isGeneratedPasswordChangePasswordSurface(_ pageDetails: PageDetails) -> Bool { + let text = generatedPasswordSurfaceText(pageDetails) + return text.contains(where: isChangePasswordSurfaceText) + } + + func generatedPasswordSurfaceText(_ pageDetails: PageDetails) -> [String] { + var values: [String] = [pageDetails.title] + values.append(contentsOf: pageDetails.forms.values.flatMap { [$0.htmlId, $0.htmlName, $0.htmlAction] }) + values.append(contentsOf: pageDetails.fields.flatMap { + [ + $0.form, + $0.htmlId, + $0.htmlName, + $0.labelLeft, + $0.labelRight, + $0.labelTag, + $0.placeholder, + ] + }.compactMap { $0 }) + return values + } + + func isChangePasswordSurfaceText(_ text: String) -> Bool { + let tokens = ["change password", "update password", "reset password", "new password", "confirm new password"] + return tokens.contains { text.localizedCaseInsensitiveContains($0) } + } + + func passwordFieldRole(_ field: PageDetails.Field) -> PasswordFieldRole { + let source = [field.htmlId, field.htmlName, field.labelTag, field.labelLeft, field.placeholder] + .compactMap { $0?.lowercased() } + .joined(separator: " ") + + if source.contains("current") || source.contains("old") { + return .current + } + if source.contains("confirm") || source.contains("verification") || source.contains("verify") + || source.contains("repeat") || source.contains("again") { + return .confirm + } + if source.contains("new") || source.contains("create") || source.contains("choose") || source.contains("set") { + return .new + } + return .unknown + } + + enum PasswordFieldRole { + case current + case new + case confirm + case unknown + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessorTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessorTests.swift new file mode 100644 index 0000000000..6ac5a9d91d --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestProcessorTests.swift @@ -0,0 +1,649 @@ +import BitwardenKitMocks +import TestHelpers +import XCTest + +@testable import BitwardenShared + +class SafariExtensionRequestProcessorTests: BitwardenTestCase { + func test_makeResponse_generatePassword_returnsGeneratedPasswordResponse() throws { + let subject = SafariExtensionRequestProcessor( + passwordGenerator: { _ in "generated-secret" } + ) + + let response = try XCTUnwrap(subject.makeResponse(for: SafariExtensionRequest(kind: .generatePassword))) + + XCTAssertEqual(response.generatedPassword, "generated-secret") + XCTAssertEqual(response.submissionAction, .generatePassword) + XCTAssertNil(response.followUpType) + XCTAssertNil(response.followUpRequest) + XCTAssertNil(response.followUpSubmissionAction) + } + + func test_makeResponse_generatePassword_withSignupPage_returnsSaveLoginFollowUpContext() throws { + let request = testMakeGeneratePasswordSignupRequest() + let subject = SafariExtensionRequestProcessor( + passwordGenerator: { _ in "generated-secret" } + ) + + let response = try XCTUnwrap(subject.makeResponse(for: request)) + + XCTAssertEqual(response.generatedPassword, "generated-secret") + XCTAssertEqual(response.followUpType, .generatedPassword) + XCTAssertEqual(response.followUpRequest?.kind, .saveLogin) + XCTAssertEqual(response.followUpRequest?.urlString, "https://signup.example.com/register") + XCTAssertEqual(response.followUpRequest?.username, "user@example.com") + XCTAssertEqual(response.followUpSubmissionAction, .saveNewLogin) + } + + func test_makeResponse_generatePassword_withChangePasswordPage_returnsUpdatePasswordFollowUpContext() throws { + let request = testMakeGeneratePasswordChangePasswordRequest(currentPassword: nil) + let subject = SafariExtensionRequestProcessor( + passwordGenerator: { _ in "generated-secret" } + ) + + let response = try XCTUnwrap(subject.makeResponse(for: request)) + + XCTAssertEqual(response.generatedPassword, "generated-secret") + XCTAssertEqual(response.followUpType, .generatedPassword) + XCTAssertEqual(response.followUpRequest?.kind, .changePassword) + XCTAssertEqual(response.followUpRequest?.urlString, "https://accounts.example.com/change-password") + XCTAssertEqual(response.followUpSubmissionAction, .updatePassword) + } + + func test_makeResponse_generatePassword_withPasswordResetSurface_returnsUpdatePasswordFollowUpContext() throws { + let request = testMakeGeneratePasswordResetPasswordRequest() + let subject = SafariExtensionRequestProcessor( + passwordGenerator: { _ in "generated-secret" } + ) + + let response = try XCTUnwrap(subject.makeResponse(for: request)) + + XCTAssertEqual(response.generatedPassword, "generated-secret") + XCTAssertEqual(response.followUpType, .generatedPassword) + XCTAssertEqual(response.followUpRequest?.kind, .changePassword) + XCTAssertEqual(response.followUpRequest?.urlString, "https://example.com/account/security") + XCTAssertEqual(response.followUpSubmissionAction, .updatePassword) + } + + func test_makeAsyncResponse_generatePassword_withMatchedLogin_prefersUpdateExistingLoginFollowUp() async throws { + let request = testMakeGeneratePasswordSignupRequest() + let subject = SafariExtensionRequestProcessor( + matchedLoginResolver: MockSafariExtensionMatchedLoginResolver( + matchedLogin: SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://signup.example.com/register" + ) + ), + passwordGenerator: { _ in "generated-secret" } + ) + + let maybeResponse = await subject.makeAsyncResponse(for: request) + let response = try XCTUnwrap(maybeResponse) + + XCTAssertEqual(response.generatedPassword, "generated-secret") + XCTAssertEqual(response.matchedLogin?.id, "cipher-1") + XCTAssertEqual(response.followUpType, .generatedPassword) + XCTAssertEqual(response.followUpRequest?.kind, .saveLogin) + XCTAssertEqual(response.followUpRequest?.username, "user@example.com") + XCTAssertEqual(response.followUpRequest?.password, "generated-secret") + XCTAssertEqual(response.followUpSubmissionAction, .updateExistingLogin) + } + + func test_makeAsyncResponse_generatePassword_withMatchedLogin_prefersUpdatePasswordFollowUp() async throws { + let request = testMakeGeneratePasswordChangePasswordRequest(currentPassword: "old-secret") + let subject = SafariExtensionRequestProcessor( + matchedLoginResolver: MockSafariExtensionMatchedLoginResolver( + matchedLogin: SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://accounts.example.com/change-password" + ) + ), + passwordGenerator: { _ in "generated-secret" } + ) + + let maybeResponse = await subject.makeAsyncResponse(for: request) + let response = try XCTUnwrap(maybeResponse) + + XCTAssertEqual(response.generatedPassword, "generated-secret") + XCTAssertEqual(response.matchedLogin?.id, "cipher-1") + XCTAssertEqual(response.followUpType, .generatedPassword) + XCTAssertEqual(response.followUpRequest?.kind, .changePassword) + XCTAssertEqual(response.followUpRequest?.oldPassword, "old-secret") + XCTAssertEqual(response.followUpRequest?.password, "generated-secret") + XCTAssertEqual(response.followUpSubmissionAction, .updatePassword) + } + + func test_makeResponse_changePasswordWithMatchedLogin_returnsUpdatePasswordMessage() async throws { + let request = SafariExtensionRequest( + kind: .changePassword, + oldPassword: "old-secret", + password: "new-secret", + urlString: "https://example.com/change-password" + ) + let subject = SafariExtensionRequestProcessor( + matchedLoginResolver: MockSafariExtensionMatchedLoginResolver( + matchedLogin: SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login" + ) + ) + ) + + let maybeResponse = await subject.makeAsyncResponse(for: request) + let response = try XCTUnwrap(maybeResponse) + + XCTAssertEqual(response.suggestionAction, .updatePassword) + XCTAssertEqual(response.submissionAction, .updatePassword) + XCTAssertEqual(response.userMessage, "Update the password for this Bitwarden login.") + } + + @MainActor + func test_liveProcessor_withMockServices_makeResponse_generatePasswordReturnsResponse() async throws { + let subject = SafariExtensionRequestProcessor.live(services: ServiceContainer.withMocks()) + + let maybeResponse = await subject.makeAsyncResponse(for: SafariExtensionRequest(kind: .generatePassword)) + let response = try XCTUnwrap(maybeResponse) + + XCTAssertEqual(response.submissionAction, .generatePassword) + XCTAssertNotNil(response.generatedPassword) + XCTAssertFalse(response.generatedPassword?.isEmpty ?? true) + XCTAssertEqual(response.userMessage, "Generated password with Bitwarden.") + } + + @MainActor + func test_liveProcessor_withMockServices_makeResponse_generatePassword_usesRequestPasswordOptions() async throws { + let generatorRepository = MockGeneratorRepository() + generatorRepository.passwordResult = .success("generated-from-options") + let subject = SafariExtensionRequestProcessor.live( + services: ServiceContainer.withMocks(generatorRepository: generatorRepository) + ) + let request = SafariExtensionRequest( + kind: .generatePassword, + passwordOptions: PasswordGenerationOptions( + allowAmbiguousChar: false, + capitalize: nil, + includeNumber: nil, + length: 20, + lowercase: true, + minLowercase: 2, + minNumber: 3, + minSpecial: nil, + minUppercase: nil, + number: true, + numWords: nil, + special: false, + type: .password, + uppercase: false, + wordSeparator: nil, + overridePasswordType: nil + ) + ) + + let maybeResponse = await subject.makeAsyncResponse(for: request) + let response = try XCTUnwrap(maybeResponse) + let passwordRequest = try XCTUnwrap(generatorRepository.passwordGeneratorRequest) + + XCTAssertEqual(response.generatedPassword, "generated-from-options") + XCTAssertEqual(passwordRequest.length, 20) + XCTAssertEqual(passwordRequest.lowercase, true) + XCTAssertEqual(passwordRequest.numbers, true) + XCTAssertEqual(passwordRequest.special, false) + XCTAssertEqual(passwordRequest.uppercase, false) + XCTAssertEqual(passwordRequest.avoidAmbiguous, true) + XCTAssertEqual(passwordRequest.minLowercase, 2) + XCTAssertEqual(passwordRequest.minNumber, 3) + } + + @MainActor + func test_liveProcessor_withMockServices_makeResponse_generatePassword_usesSavedPassphraseOptionsWhenRequestDoesNotProvideAny() async throws { + let generatorRepository = MockGeneratorRepository() + generatorRepository.passphraseResult = .success("correct-horse-battery-staple") + generatorRepository.getPasswordGenerationOptionsResult = .success( + PasswordGenerationOptions( + allowAmbiguousChar: nil, + capitalize: true, + includeNumber: true, + length: nil, + lowercase: nil, + minLowercase: nil, + minNumber: nil, + minSpecial: nil, + minUppercase: nil, + number: nil, + numWords: 4, + special: nil, + type: .passphrase, + uppercase: nil, + wordSeparator: "-", + overridePasswordType: nil + ) + ) + let subject = SafariExtensionRequestProcessor.live( + services: ServiceContainer.withMocks(generatorRepository: generatorRepository) + ) + + let maybeResponse = await subject.makeAsyncResponse(for: SafariExtensionRequest(kind: .generatePassword)) + let response = try XCTUnwrap(maybeResponse) + let passphraseRequest = try XCTUnwrap(generatorRepository.passphraseGeneratorRequest) + + XCTAssertEqual(response.generatedPassword, "correct-horse-battery-staple") + XCTAssertEqual(passphraseRequest.numWords, 4) + XCTAssertEqual(passphraseRequest.wordSeparator, "-") + XCTAssertEqual(passphraseRequest.capitalize, true) + XCTAssertEqual(passphraseRequest.includeNumber, true) + } + + @MainActor + func test_liveProcessor_withMockServices_makeResponse_generatePassword_whenGeneratorFails_returnsFailureMessageWithoutDummyPassword() async throws { + struct GeneratePasswordError: Error, Equatable {} + + let generatorRepository = MockGeneratorRepository() + generatorRepository.passwordResult = .failure(GeneratePasswordError()) + let subject = SafariExtensionRequestProcessor.live( + services: ServiceContainer.withMocks(generatorRepository: generatorRepository) + ) + let request = SafariExtensionRequest( + kind: .generatePassword, + passwordOptions: PasswordGenerationOptions(type: .password) + ) + + let maybeResponse = await subject.makeAsyncResponse(for: request) + let response = try XCTUnwrap(maybeResponse) + + XCTAssertNil(response.generatedPassword) + XCTAssertEqual(response.submissionAction, .none) + XCTAssertEqual(response.userMessage, "Couldn’t generate a password in Bitwarden.") + } + + func test_makeResponse_setup_returnsSetupMessage() throws { + let subject = SafariExtensionRequestProcessor() + + let response = try XCTUnwrap(subject.makeResponse(for: SafariExtensionRequest(kind: .setup))) + + XCTAssertEqual(response.userMessage, "Open Bitwarden to finish Safari extension setup.") + XCTAssertEqual(response.submissionAction, .none) + } + + func test_makeResponse_saveLogin_returnsSuggestedAction() throws { + let subject = SafariExtensionRequestProcessor() + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com" + ) + + let response = try XCTUnwrap(subject.makeResponse(for: request)) + + XCTAssertEqual(response.suggestionAction, .saveLogin) + XCTAssertEqual(response.submissionAction, .saveNewLogin) + XCTAssertEqual(response.userMessage, "Save this login to Bitwarden.") + } + + func test_makeResponse_fillWithMatchedLogin_returnsFillScriptResponse() async throws { + let request = SafariExtensionRequest( + kind: .fill, + pageDetails: testMakePageDetails(), + urlString: "https://example.com/login" + ) + let subject = SafariExtensionRequestProcessor( + matchedLoginResolver: MockSafariExtensionMatchedLoginResolver( + matchedLogin: SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "secret", + urlString: "https://example.com/login" + ) + ) + ) + + let maybeResponse = await subject.makeAsyncResponse(for: request) + let response = try XCTUnwrap(maybeResponse) + + XCTAssertEqual(response.submissionAction, .fill) + XCTAssertEqual(response.matchedLogin?.id, "cipher-1") + XCTAssertTrue(response.canFinalizeWithScript) + XCTAssertEqual(response.userMessage, "Filled user@example.com from Bitwarden.") + } + + func test_makeResponse_fillWithMatchedLoginWithoutUsername_fallsBackToSiteHost() async throws { + let request = SafariExtensionRequest( + kind: .fill, + pageDetails: testMakePageDetails(), + urlString: "https://accounts.example.com/login" + ) + let subject = SafariExtensionRequestProcessor( + matchedLoginResolver: MockSafariExtensionMatchedLoginResolver( + matchedLogin: SafariExtensionMatchedLogin( + id: "cipher-2", + username: "", + password: "secret", + urlString: "https://accounts.example.com/login" + ) + ) + ) + + let maybeResponse = await subject.makeAsyncResponse(for: request) + let response = try XCTUnwrap(maybeResponse) + + XCTAssertEqual(response.userMessage, "Filled login for accounts.example.com from Bitwarden.") + } + + func test_makeResponse_fillWithoutMatchedLogin_returnsNoMatchMessage() async throws { + let request = SafariExtensionRequest( + kind: .fill, + pageDetails: testMakePageDetails(), + urlString: "https://example.com/login" + ) + let subject = SafariExtensionRequestProcessor( + matchedLoginResolver: MockSafariExtensionMatchedLoginResolver( + matchedLogin: nil + ) + ) + + let maybeResponse = await subject.makeAsyncResponse(for: request) + let response = try XCTUnwrap(maybeResponse) + + XCTAssertEqual(response.submissionAction, .none) + XCTAssertEqual(response.userMessage, "No matching Bitwarden login found for this page.") + XCTAssertFalse(response.canFinalizeWithScript) + } + + func test_makeResponse_saveLoginWithMatchedLogin_returnsUpdateExistingLoginMessage() async throws { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "new-secret", + urlString: "https://example.com/login", + username: "user@example.com" + ) + let subject = SafariExtensionRequestProcessor( + matchedLoginResolver: MockSafariExtensionMatchedLoginResolver( + matchedLogin: SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login" + ) + ) + ) + + let maybeResponse = await subject.makeAsyncResponse(for: request) + let response = try XCTUnwrap(maybeResponse) + + XCTAssertEqual(response.submissionAction, .updateExistingLogin) + XCTAssertEqual(response.userMessage, "Update the existing Bitwarden login with these changes.") + } + + func test_makeResponse_saveLoginConfirmed_persistsCredentialAndReturnsCompletionMessage() async throws { + let request = SafariExtensionRequest( + kind: .saveLogin, + requestContext: SafariExtensionRequestContext( + trigger: .actionPanelPrimary, + submissionAction: .saveNewLogin + ), + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com" + ) + let credentialStore = MockSafariExtensionCredentialStore() + let subject = SafariExtensionRequestProcessor( + credentialStore: credentialStore + ) + + let maybeResponse = await subject.makeAsyncResponse(for: request) + let response = try XCTUnwrap(maybeResponse) + + XCTAssertEqual(credentialStore.savedRequests.count, 1) + XCTAssertEqual(credentialStore.savedRequests.first?.request.username, "user@example.com") + XCTAssertEqual(credentialStore.savedRequests.first?.submissionAction, .saveNewLogin) + XCTAssertEqual(response.submissionAction, .saveNewLogin) + XCTAssertEqual(response.userMessage, "Saved login to Bitwarden.") + } +} + +private struct MockSafariExtensionMatchedLoginResolver: SafariExtensionMatchedLoginResolving { + var matchedLogin: SafariExtensionMatchedLogin? + + func resolveMatchedLogin(for request: SafariExtensionRequest) async throws -> SafariExtensionMatchedLogin? { + matchedLogin + } +} + +private final class MockSafariExtensionCredentialStore: SafariExtensionCredentialStoring { + var savedRequests: [(request: SafariExtensionRequest, matchedLogin: SafariExtensionMatchedLogin?, submissionAction: SafariExtensionSubmissionAction)] = [] + + func saveCredential( + for request: SafariExtensionRequest, + matchedLogin: SafariExtensionMatchedLogin?, + submissionAction: SafariExtensionSubmissionAction + ) async throws { + savedRequests.append((request, matchedLogin, submissionAction)) + } +} + +private func testMakePageDetails() -> PageDetails { + PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 1_715_000_000), + documentUUID: "doc-1", + documentUrl: "https://example.com/login", + fields: [ + testMakeField( + elementNumber: 0, + form: "form__0", + htmlId: "username", + htmlName: "username", + labelTag: "Username", + type: "text" + ), + testMakeField( + elementNumber: 1, + form: "form__0", + htmlId: "password", + htmlName: "password", + labelTag: "Password", + type: "password" + ), + ], + forms: [ + "form__0": PageDetails.Form( + htmlAction: "https://example.com/login", + htmlId: "login-form", + htmlMethod: "post", + htmlName: "login", + opId: "form__0" + ), + ], + tabUrl: "https://example.com/login", + title: "Example", + url: "https://example.com/login" + ) +} + +private func testMakeGeneratePasswordSignupRequest() -> SafariExtensionRequest { + SafariExtensionRequest( + kind: .generatePassword, + pageDetails: PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 1_715_000_000), + documentUUID: "doc-signup", + documentUrl: "https://signup.example.com/register", + fields: [ + testMakeField( + elementNumber: 0, + form: "signup-form", + htmlId: "email", + htmlName: "email", + labelTag: "Email", + placeholder: "Email", + type: "email", + value: "user@example.com" + ), + testMakeField( + elementNumber: 1, + form: "signup-form", + htmlId: "new-password", + htmlName: "newPassword", + labelTag: "New password", + placeholder: "New password", + type: "password" + ), + testMakeField( + elementNumber: 2, + form: "signup-form", + htmlId: "confirm-password", + htmlName: "confirmPassword", + labelTag: "Confirm password", + placeholder: "Confirm password", + type: "password" + ), + ], + forms: [ + "signup-form": PageDetails.Form( + htmlAction: "https://signup.example.com/register", + htmlId: "signup-form", + htmlMethod: "post", + htmlName: "register", + opId: "signup-form" + ), + ], + tabUrl: "https://signup.example.com/register", + title: "Create account", + url: "https://signup.example.com/register" + ), + urlString: "https://signup.example.com/register" + ) +} + +private func testMakeGeneratePasswordChangePasswordRequest(currentPassword: String?) -> SafariExtensionRequest { + SafariExtensionRequest( + kind: .generatePassword, + pageDetails: PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 1_715_000_000), + documentUUID: "doc-change-password", + documentUrl: "https://accounts.example.com/change-password", + fields: [ + testMakeField( + elementNumber: 0, + form: "change-password-form", + htmlId: "current-password", + htmlName: "currentPassword", + labelTag: "Current password", + placeholder: "Current password", + type: "password", + value: currentPassword + ), + testMakeField( + elementNumber: 1, + form: "change-password-form", + htmlId: "new-password", + htmlName: "newPassword", + labelTag: "New password", + placeholder: "New password", + type: "password" + ), + testMakeField( + elementNumber: 2, + form: "change-password-form", + htmlId: "confirm-password", + htmlName: "confirmPassword", + labelTag: "Confirm password", + placeholder: "Confirm password", + type: "password" + ), + ], + forms: [ + "change-password-form": PageDetails.Form( + htmlAction: "https://accounts.example.com/change-password", + htmlId: "change-password-form", + htmlMethod: "post", + htmlName: "changePassword", + opId: "change-password-form" + ), + ], + tabUrl: "https://accounts.example.com/change-password", + title: "Change password", + url: "https://accounts.example.com/change-password" + ), + urlString: "https://accounts.example.com/change-password" + ) +} + +private func testMakeGeneratePasswordResetPasswordRequest() -> SafariExtensionRequest { + SafariExtensionRequest( + kind: .generatePassword, + pageDetails: PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 1_715_000_000), + documentUUID: "doc-reset-password", + documentUrl: "https://example.com/account/security", + fields: [ + testMakeField( + elementNumber: 0, + form: "reset-password-form", + htmlId: "new-password", + htmlName: "newPassword", + labelTag: "New password", + placeholder: "Create a new password", + type: "password" + ), + testMakeField( + elementNumber: 1, + form: "reset-password-form", + htmlId: "confirm-password", + htmlName: "confirmPassword", + labelTag: "Confirm new password", + placeholder: "Confirm new password", + type: "password" + ), + ], + forms: [ + "reset-password-form": PageDetails.Form( + htmlAction: "https://example.com/account/security", + htmlId: "reset-password-form", + htmlMethod: "post", + htmlName: "reset-password-form", + opId: "reset-password-form" + ), + ], + tabUrl: "https://example.com/account/security", + title: "Update your password", + url: "https://example.com/account/security" + ), + urlString: "https://example.com/account/security" + ) +} + +private func testMakeField( + elementNumber: Int, + form: String, + htmlId: String, + htmlName: String, + labelTag: String, + placeholder: String? = nil, + type: String, + value: String? = nil +) -> PageDetails.Field { + PageDetails.Field( + disabled: false, + elementNumber: elementNumber, + form: form, + htmlClass: nil, + htmlId: htmlId, + htmlName: htmlName, + labelLeft: nil, + labelRight: nil, + labelTag: labelTag, + onepasswordFieldType: nil, + opId: "field__\(elementNumber)", + placeholder: placeholder, + readOnly: false, + type: type, + value: value, + viewable: true, + visible: true + ) +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestTests.swift new file mode 100644 index 0000000000..d429518668 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionRequestTests.swift @@ -0,0 +1,127 @@ +import XCTest + +@testable import BitwardenShared + +class SafariExtensionRequestTests: BitwardenTestCase { + func test_roundTripDecodeEncode_fillRequest() throws { + let subject = SafariExtensionRequest( + kind: .fill, + loginTitle: "Bitwarden", + notes: "example", + oldPassword: nil, + pageDetails: PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 1_715_000_000), + documentUUID: "doc-1", + documentUrl: "https://example.com/login", + fields: [ + PageDetails.Field( + disabled: false, + elementNumber: 1, + form: "login-form", + htmlClass: nil, + htmlId: "password", + htmlName: "password", + labelLeft: "Password", + labelRight: nil, + labelTag: "Password", + onepasswordFieldType: nil, + opId: "password-field", + placeholder: "Password", + readOnly: false, + type: "password", + value: nil, + viewable: true, + visible: true, + ), + ], + forms: [ + "login-form": PageDetails.Form( + htmlAction: "/login", + htmlId: "login-form", + htmlMethod: "post", + htmlName: "login", + opId: "login-form", + ), + ], + tabUrl: "https://example.com/login", + title: "Login", + url: "https://example.com/login", + ), + password: "secret", + passwordOptions: PasswordGenerationOptions(length: 20, type: .password), + urlString: "https://example.com/login", + username: "user@example.com", + ) + + let data = try JSONEncoder().encode(subject) + let decoded = try JSONDecoder().decode(SafariExtensionRequest.self, from: data) + + XCTAssertEqual(decoded, subject) + XCTAssertTrue(decoded.canAutofill) + XCTAssertFalse(decoded.canSaveLogin) + XCTAssertFalse(decoded.canChangePassword) + XCTAssertFalse(decoded.canGeneratePassword) + } + + func test_canSaveLogin_requiresUsernameAndPassword() { + let subject = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + + XCTAssertTrue(subject.canSaveLogin) + XCTAssertFalse(subject.canAutofill) + } + + func test_canChangePassword_requiresOldAndNewPassword() { + let subject = SafariExtensionRequest( + kind: .changePassword, + oldPassword: "old-secret", + password: "new-secret", + urlString: "https://example.com/change-password", + ) + + XCTAssertTrue(subject.canChangePassword) + XCTAssertFalse(subject.canSaveLogin) + } + + func test_canChangePassword_allowsResetFlowWithoutOldPassword() { + let subject = SafariExtensionRequest( + kind: .changePassword, + oldPassword: nil, + password: "new-secret", + urlString: "https://example.com/reset-password", + ) + + XCTAssertTrue(subject.canChangePassword) + XCTAssertFalse(subject.canSaveLogin) + } + + func test_canGeneratePassword_trueForGenerateRequest() { + let subject = SafariExtensionRequest(kind: .generatePassword) + + XCTAssertTrue(subject.canGeneratePassword) + XCTAssertFalse(subject.canAutofill) + XCTAssertFalse(subject.canSaveLogin) + XCTAssertFalse(subject.canChangePassword) + } + + func test_roundTripEncodeDecode_setupRequestContext() throws { + let subject = SafariExtensionRequest( + kind: .setup, + requestContext: SafariExtensionRequestContext( + trigger: .setupButton, + submissionAction: nil + ), + urlString: "https://example.com/account" + ) + + let data = try JSONEncoder().encode(subject) + let decoded = try JSONDecoder().decode(SafariExtensionRequest.self, from: data) + + XCTAssertEqual(decoded, subject) + XCTAssertEqual(decoded.requestContext?.trigger, .setupButton) + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponse.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponse.swift new file mode 100644 index 0000000000..80b96dd7a8 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponse.swift @@ -0,0 +1,153 @@ +import Foundation + +// MARK: - SafariExtensionResponseFollowUpType + +/// Additional context for action-panel flows that follow a previous response. +public enum SafariExtensionResponseFollowUpType: String, Codable, Equatable { + case generatedPassword +} + +// MARK: - SafariExtensionResponse + +/// A shared Codable payload returned from the native Safari extension layer back to the web bridge/UI. +public struct SafariExtensionResponse: Codable, Equatable { + /// The originating request. + var request: SafariExtensionRequest + + /// The high-level action the UI should present. + var suggestionAction: SafariExtensionSuggestionAction + + /// The concrete native submission action selected for the request. + var submissionAction: SafariExtensionSubmissionAction + + /// A matching login, when one was found in the vault. + var matchedLogin: SafariExtensionMatchedLogin? + + /// The encoded fill script JSON to send back into the page, when available. + var fillScriptJSON: String? + + /// A generated password to offer back to the page, when available. + var generatedPassword: String? + + /// Optional user-facing copy for setup/save/update flows. + var userMessage: String? + + /// Additional context for flows that were triggered as a follow-up to another response. + var followUpType: SafariExtensionResponseFollowUpType? + + /// A request to use for the next follow-up action, when the native layer already knows it. + var followUpRequest: SafariExtensionRequest? + + /// The submission action to use for the next follow-up action, when the native layer already knows it. + var followUpSubmissionAction: SafariExtensionSubmissionAction? + + /// Whether the response includes a fill script that can be finalized into the page. + public var canFinalizeWithScript: Bool { + submissionAction == .fill && !(fillScriptJSON?.isEmpty ?? true) + } + + /// Whether the response includes a generated password payload. + public var hasGeneratedPassword: Bool { + !(generatedPassword?.isEmpty ?? true) + } + + /// Build a response for page fill flows. + public init( + request: SafariExtensionRequest, + suggestionAction: SafariExtensionSuggestionAction, + submissionAction: SafariExtensionSubmissionAction, + matchedLogin: SafariExtensionMatchedLogin?, + fillScriptJSON: String?, + generatedPassword: String?, + userMessage: String?, + followUpType: SafariExtensionResponseFollowUpType? = nil, + followUpRequest: SafariExtensionRequest? = nil, + followUpSubmissionAction: SafariExtensionSubmissionAction? = nil, + ) { + self.request = request + self.suggestionAction = suggestionAction + self.submissionAction = submissionAction + self.matchedLogin = matchedLogin + self.fillScriptJSON = fillScriptJSON + self.generatedPassword = generatedPassword + self.userMessage = userMessage + self.followUpType = followUpType + self.followUpRequest = followUpRequest + self.followUpSubmissionAction = followUpSubmissionAction + } + + /// Build a response for page fill flows. + public static func fill( + request: SafariExtensionRequest, + username: String, + password: String, + fields: [(String, String)], + matchedLogin: SafariExtensionMatchedLogin?, + ) throws -> Self { + guard request.canAutofill else { + throw CocoaError(.coderInvalidValue) + } + + let fillScript = FillScript( + pageDetails: request.pageDetails, + fillUsername: username, + fillPassword: password, + fillFields: fields, + ) + let fillScriptData = try JSONEncoder().encode(fillScript) + guard let fillScriptJSON = String(data: fillScriptData, encoding: .utf8) else { + throw CocoaError(.coderInvalidValue) + } + return Self( + request: request, + suggestionAction: SafariExtensionSuggestionAction.from(request), + submissionAction: SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), + matchedLogin: matchedLogin, + fillScriptJSON: fillScriptJSON, + generatedPassword: nil, + userMessage: fillCompletionMessage(request: request, matchedLogin: matchedLogin), + ) + } + + private static func fillCompletionMessage( + request: SafariExtensionRequest, + matchedLogin: SafariExtensionMatchedLogin? + ) -> String { + if let username = matchedLogin?.username, !username.isEmpty { + return "Filled \(username) from Bitwarden." + } + let host = matchedLogin?.urlString.flatMap { URL(string: $0)?.host } + ?? request.urlString.flatMap { URL(string: $0)?.host } + if let host, !host.isEmpty { + return "Filled login for \(host) from Bitwarden." + } + return "Filled login from Bitwarden." + } + + /// Build a response for password generation flows. + public static func generatedPassword( + _ generatedPassword: String, + for request: SafariExtensionRequest, + matchedLogin: SafariExtensionMatchedLogin? = nil, + followUpType: SafariExtensionResponseFollowUpType? = nil, + followUpRequest: SafariExtensionRequest? = nil, + followUpSubmissionAction: SafariExtensionSubmissionAction? = nil + ) throws -> Self { + guard request.canGeneratePassword else { + throw CocoaError(.coderInvalidValue) + } + + return Self( + request: request, + suggestionAction: SafariExtensionSuggestionAction.from(request), + submissionAction: SafariExtensionSubmissionAction.classify(request, matchedLogin: nil), + matchedLogin: matchedLogin, + fillScriptJSON: nil, + generatedPassword: generatedPassword, + userMessage: "Generated password with Bitwarden.", + followUpType: followUpType, + followUpRequest: followUpRequest, + followUpSubmissionAction: followUpSubmissionAction, + ) + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponseTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponseTests.swift new file mode 100644 index 0000000000..d2f5728acf --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionResponseTests.swift @@ -0,0 +1,166 @@ +import XCTest + +@testable import BitwardenShared + +class SafariExtensionResponseTests: BitwardenTestCase { + func test_fill_buildsEncodedFillScriptResponse() throws { + let subject = try SafariExtensionResponse.fill( + request: makeFillRequest(), + username: "user@example.com", + password: "secret", + fields: [("otp", "123456")], + matchedLogin: nil, + ) + + XCTAssertEqual(subject.suggestionAction, .fill) + XCTAssertEqual(subject.submissionAction, .fill) + XCTAssertTrue(subject.canFinalizeWithScript) + XCTAssertNil(subject.generatedPassword) + + let fillScriptJSON = try XCTUnwrap(subject.fillScriptJSON) + let scriptData = try XCTUnwrap(fillScriptJSON.data(using: .utf8)) + let fillScript = try JSONDecoder().decode(FillScript.self, from: scriptData) + XCTAssertEqual(fillScript.documentUUID, "doc-1") + XCTAssertFalse(fillScript.script.isEmpty) + } + + func test_generatedPassword_buildsGeneratePasswordResponse() throws { + let request = SafariExtensionRequest(kind: .generatePassword) + + let subject = try SafariExtensionResponse.generatedPassword("generated-secret", for: request) + + XCTAssertEqual(subject.suggestionAction, .generatePassword) + XCTAssertEqual(subject.submissionAction, .generatePassword) + XCTAssertEqual(subject.generatedPassword, "generated-secret") + XCTAssertNil(subject.followUpType) + XCTAssertEqual(subject.userMessage, "Generated password with Bitwarden.") + XCTAssertTrue(subject.hasGeneratedPassword) + XCTAssertFalse(subject.canFinalizeWithScript) + } + + func test_fill_withoutAutofillableRequest_throws() { + let request = SafariExtensionRequest(kind: .fill) + + XCTAssertThrowsError( + try SafariExtensionResponse.fill( + request: request, + username: "user@example.com", + password: "secret", + fields: [], + matchedLogin: nil, + ) + ) + } + + func test_generatedPassword_withNonGenerateRequest_throws() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + + XCTAssertThrowsError(try SafariExtensionResponse.generatedPassword("generated-secret", for: request)) + } + + func test_roundTripEncodeDecode_saveNewLoginResponse() throws { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + let followUpRequest = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/register", + username: "user@example.com" + ) + let subject = SafariExtensionResponse( + request: request, + suggestionAction: .saveLogin, + submissionAction: .saveNewLogin, + matchedLogin: nil, + fillScriptJSON: nil, + generatedPassword: nil, + userMessage: "Save login", + followUpType: .generatedPassword, + followUpRequest: followUpRequest, + followUpSubmissionAction: .saveNewLogin, + ) + + let data = try JSONEncoder().encode(subject) + let decoded = try JSONDecoder().decode(SafariExtensionResponse.self, from: data) + + XCTAssertEqual(decoded, subject) + XCTAssertEqual(decoded.followUpType, .generatedPassword) + XCTAssertEqual(decoded.followUpRequest?.urlString, "https://example.com/register") + XCTAssertEqual(decoded.followUpSubmissionAction, .saveNewLogin) + XCTAssertFalse(decoded.canFinalizeWithScript) + XCTAssertFalse(decoded.hasGeneratedPassword) + } + + private func makeFillRequest() -> SafariExtensionRequest { + SafariExtensionRequest( + kind: .fill, + pageDetails: PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 1_715_000_000), + documentUUID: "doc-1", + documentUrl: "https://example.com/login", + fields: [ + PageDetails.Field( + disabled: false, + elementNumber: 1, + form: "login-form", + htmlClass: nil, + htmlId: "email", + htmlName: "email", + labelLeft: nil, + labelRight: nil, + labelTag: "Email", + onepasswordFieldType: nil, + opId: "username-field", + placeholder: "Email", + readOnly: false, + type: "email", + value: nil, + viewable: true, + visible: true, + ), + PageDetails.Field( + disabled: false, + elementNumber: 2, + form: "login-form", + htmlClass: nil, + htmlId: "password", + htmlName: "password", + labelLeft: nil, + labelRight: nil, + labelTag: "Password", + onepasswordFieldType: nil, + opId: "password-field", + placeholder: "Password", + readOnly: false, + type: "password", + value: nil, + viewable: true, + visible: true, + ), + ], + forms: [ + "login-form": PageDetails.Form( + htmlAction: "/login", + htmlId: "login-form", + htmlMethod: "post", + htmlName: "login", + opId: "login-form", + ), + ], + tabUrl: "https://example.com/login", + title: "Login", + url: "https://example.com/login", + ), + urlString: "https://example.com/login", + ) + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionAction.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionAction.swift new file mode 100644 index 0000000000..f0d89551e6 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionAction.swift @@ -0,0 +1,242 @@ +import Foundation + +// MARK: - SafariExtensionMatchedLogin + +/// A lightweight snapshot of an existing matching login item. +public struct SafariExtensionMatchedLogin: Codable, Equatable { + var id: String + var username: String? + var password: String? + var urlString: String? +} + +// MARK: - SafariExtensionSubmissionAction + +/// The action the native layer should take when deciding between save/update flows. +public enum SafariExtensionSubmissionAction: String, Codable, Equatable { + case none + case fill + case saveNewLogin + case updateExistingLogin + case updatePassword + case generatePassword + + public static func classify( + _ request: SafariExtensionRequest, + matchedLogin: SafariExtensionMatchedLogin?, + ) -> Self { + if request.canAutofill { + return .fill + } + + if request.canGeneratePassword { + return .generatePassword + } + + if request.canChangePassword { + guard let matchedLogin else { + return .none + } + + let normalizedOldPassword = request.oldPassword?.trimmingCharacters(in: .whitespacesAndNewlines) + if let normalizedOldPassword { + let normalizedMatchedPassword = matchedLogin.password?.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedOldPassword == normalizedMatchedPassword else { + return .none + } + } + + return .updatePassword + } + + if request.kind == .saveLogin, !(request.password?.isEmpty ?? true) { + guard let matchedLogin else { + return .saveNewLogin + } + + let normalizedRequestUsername = request.username?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMatchedUsername = matchedLogin.username?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedRequestPassword = request.password?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMatchedPassword = matchedLogin.password?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedRequestURL = request.urlString?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedMatchedURL = matchedLogin.urlString?.trimmingCharacters(in: .whitespacesAndNewlines) + + if normalizedRequestUsername == nil { + let requestSurface = saveLoginSurfaceCategory(for: request) + if requestSurface == .signup { + return .saveNewLogin + } + + let urlsAppearEquivalent = saveLoginURLsAppearEquivalent( + requestURLString: normalizedRequestURL, + matchedURLString: normalizedMatchedURL + ) + if !urlsAppearEquivalent { + let sharesOrigin = saveLoginURLsShareOrigin( + requestURLString: normalizedRequestURL, + matchedURLString: normalizedMatchedURL + ) + guard sharesOrigin, requestSurface == .login else { + return .saveNewLogin + } + } + + if normalizedRequestPassword != normalizedMatchedPassword { + return .updateExistingLogin + } + + return .none + } + + if normalizedRequestUsername != normalizedMatchedUsername { + return .saveNewLogin + } + + if normalizedRequestPassword != normalizedMatchedPassword { + return .updateExistingLogin + } + + return .none + } + + return .none + } + + private static func saveLoginURLsAppearEquivalent( + requestURLString: String?, + matchedURLString: String? + ) -> Bool { + guard let request = normalizedSaveLoginURLContext(from: requestURLString), + let matched = normalizedSaveLoginURLContext(from: matchedURLString) else { + return requestURLString == matchedURLString + } + + guard request.scheme == matched.scheme, + request.host == matched.host, + request.port == matched.port else { + return false + } + + if request.normalizedPath == matched.normalizedPath { + return true + } + + if request.isSignupLikePath || matched.isSignupLikePath { + return false + } + + return request.isLoginLikePath && matched.isLoginLikePath + } + + private static func saveLoginURLsShareOrigin( + requestURLString: String?, + matchedURLString: String? + ) -> Bool { + guard let request = normalizedSaveLoginURLContext(from: requestURLString), + let matched = normalizedSaveLoginURLContext(from: matchedURLString) else { + return false + } + + return request.scheme == matched.scheme + && request.host == matched.host + && request.port == matched.port + } + + private static func saveLoginSurfaceCategory(for request: SafariExtensionRequest) -> SaveLoginSurfaceCategory { + let text = saveLoginSurfaceText(for: request) + if text.contains(where: isSignupLikeText) { + return .signup + } + if text.contains(where: isLoginLikeText) { + return .login + } + return .unknown + } + + private static func saveLoginSurfaceText(for request: SafariExtensionRequest) -> [String] { + var values: [String] = [] + if let loginTitle = request.loginTitle?.trimmingCharacters(in: .whitespacesAndNewlines), !loginTitle.isEmpty { + values.append(loginTitle) + } + if let pageDetails = request.pageDetails { + values.append(pageDetails.title) + values.append(contentsOf: pageDetails.forms.values.flatMap { [$0.htmlId, $0.htmlName, $0.htmlAction] }) + values.append(contentsOf: pageDetails.fields.flatMap { + [ + $0.form, + $0.htmlId, + $0.htmlName, + $0.labelLeft, + $0.labelRight, + $0.labelTag, + $0.placeholder, + ] + }.compactMap { $0 }) + } + return values + } + + private static func normalizedSaveLoginURLContext(from urlString: String?) -> SaveLoginURLContext? { + guard let urlString, + let components = URLComponents(string: urlString), + let scheme = components.scheme?.lowercased(), + let host = components.host?.lowercased() else { + return nil + } + + let normalizedPath = normalizedSaveLoginPath(components.path) + return SaveLoginURLContext( + scheme: scheme, + host: host, + port: components.port, + normalizedPath: normalizedPath, + isLoginLikePath: isLoginLikePath(normalizedPath), + isSignupLikePath: isSignupLikePath(normalizedPath) + ) + } + + private static func normalizedSaveLoginPath(_ path: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != "/" else { + return "/" + } + + let collapsed = trimmed.hasPrefix("/") ? trimmed : "/\(trimmed)" + return collapsed.hasSuffix("/") && collapsed.count > 1 ? String(collapsed.dropLast()) : collapsed + } + + private static func isLoginLikePath(_ path: String) -> Bool { + let loginLikeTokens = ["login", "log-in", "signin", "sign-in", "auth"] + return loginLikeTokens.contains { path.localizedCaseInsensitiveContains($0) } + } + + private static func isSignupLikePath(_ path: String) -> Bool { + let signupLikeTokens = ["signup", "sign-up", "register", "create-account", "createaccount", "join"] + return signupLikeTokens.contains { path.localizedCaseInsensitiveContains($0) } + } + + private static func isLoginLikeText(_ text: String) -> Bool { + let loginLikeTokens = ["login", "log in", "log-in", "sign in", "sign-in", "password"] + return loginLikeTokens.contains { text.localizedCaseInsensitiveContains($0) } + } + + private static func isSignupLikeText(_ text: String) -> Bool { + let signupLikeTokens = ["signup", "sign up", "sign-up", "register", "create account", "create-account", "join"] + return signupLikeTokens.contains { text.localizedCaseInsensitiveContains($0) } + } +} + +private enum SaveLoginSurfaceCategory { + case login + case signup + case unknown +} + +private struct SaveLoginURLContext { + var scheme: String + var host: String + var port: Int? + var normalizedPath: String + var isLoginLikePath: Bool + var isSignupLikePath: Bool +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionActionTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionActionTests.swift new file mode 100644 index 0000000000..e038138af2 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSubmissionActionTests.swift @@ -0,0 +1,323 @@ +import XCTest + +@testable import BitwardenShared + +class SafariExtensionSubmissionActionTests: BitwardenTestCase { + func test_classify_fill_withoutMatchedLogin_returnsFill() { + let request = SafariExtensionRequest( + kind: .fill, + pageDetails: PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 1_715_000_000), + documentUUID: "doc-1", + documentUrl: "https://example.com/login", + fields: [ + PageDetails.Field( + disabled: false, + elementNumber: 1, + form: nil, + htmlClass: nil, + htmlId: "password", + htmlName: "password", + labelLeft: nil, + labelRight: nil, + labelTag: nil, + onepasswordFieldType: nil, + opId: "password-field", + placeholder: nil, + readOnly: false, + type: "password", + value: nil, + viewable: true, + visible: true, + ), + ], + forms: [:], + tabUrl: "https://example.com/login", + title: "Login", + url: "https://example.com/login", + ) + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: nil), .fill) + } + + func test_classify_saveLogin_withoutMatchedLogin_returnsSaveNewLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "new-user@example.com", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: nil), .saveNewLogin) + } + + func test_classify_saveLogin_withMatchedLoginAndDifferentPassword_returnsUpdateExistingLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "new-secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .updateExistingLogin) + } + + func test_classify_changePassword_withMatchedLogin_returnsUpdatePassword() { + let request = SafariExtensionRequest( + kind: .changePassword, + oldPassword: "old-secret", + password: "new-secret", + urlString: "https://example.com/change-password", + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .updatePassword) + } + + func test_classify_changePasswordWithoutOldPassword_withMatchedLogin_returnsUpdatePassword() { + let request = SafariExtensionRequest( + kind: .changePassword, + oldPassword: nil, + password: "new-secret", + urlString: "https://example.com/reset-password", + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .updatePassword) + } + + func test_classify_saveLogin_withMatchedLoginAndDifferentUsername_returnsSaveNewLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "second-user@example.com", + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "first-user@example.com", + password: "secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .saveNewLogin) + } + + func test_classify_changePassword_withMismatchedOldPassword_returnsNone() { + let request = SafariExtensionRequest( + kind: .changePassword, + oldPassword: "typed-old-secret", + password: "new-secret", + urlString: "https://example.com/change-password", + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "different-stored-secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .none) + } + + func test_classify_saveLogin_withMissingUsernameAndMatchingURL_returnsUpdateExistingLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "new-secret", + urlString: "https://example.com/login", + username: nil, + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .updateExistingLogin) + } + + func test_classify_saveLogin_withMissingUsernameAndDifferentURL_returnsSaveNewLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "new-secret", + urlString: "https://example.com/register", + username: nil, + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .saveNewLogin) + } + + func test_classify_saveLogin_withMissingUsernameAndSameOriginLoginPath_returnsUpdateExistingLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "new-secret", + urlString: "https://example.com/sign-in?ref=header", + username: nil, + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .updateExistingLogin) + } + + func test_classify_saveLogin_withMissingUsernameAndDifferentOrigin_returnsSaveNewLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "new-secret", + urlString: "https://accounts.example.net/sign-in", + username: nil, + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .saveNewLogin) + } + + func test_classify_saveLogin_withMissingUsernameAndSignupLikeAuthPath_returnsSaveNewLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "new-secret", + urlString: "https://example.com/auth/register", + username: nil, + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .saveNewLogin) + } + + func test_classify_saveLogin_withMissingUsernameAndSignupSurface_returnsSaveNewLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + pageDetails: testMakePageDetails( + title: "Create your account", + formIdentifier: "signup-form", + passwordLabel: "Create password" + ), + password: "new-secret", + urlString: "https://example.com/account", + username: nil, + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .saveNewLogin) + } + + func test_classify_saveLogin_withMissingUsernameAndLoginSurface_returnsUpdateExistingLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + pageDetails: testMakePageDetails( + title: "Sign in to Example", + formIdentifier: "login-form", + passwordLabel: "Password" + ), + password: "new-secret", + urlString: "https://example.com/account", + username: nil, + ) + let matchedLogin = SafariExtensionMatchedLogin( + id: "cipher-1", + username: "user@example.com", + password: "old-secret", + urlString: "https://example.com/login", + ) + + XCTAssertEqual(SafariExtensionSubmissionAction.classify(request, matchedLogin: matchedLogin), .updateExistingLogin) + } + + func test_classify_incompleteRequest_returnsNone() { + XCTAssertEqual( + SafariExtensionSubmissionAction.classify( + SafariExtensionRequest(kind: .saveLogin), + matchedLogin: nil, + ), + .none, + ) + } +} + +private func testMakePageDetails( + title: String, + formIdentifier: String, + passwordLabel: String +) -> PageDetails { + PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 1_715_000_000), + documentUUID: "doc-surface", + documentUrl: "https://example.com/account", + fields: [ + PageDetails.Field( + disabled: false, + elementNumber: 1, + form: formIdentifier, + htmlClass: nil, + htmlId: "password", + htmlName: "password", + labelLeft: nil, + labelRight: nil, + labelTag: passwordLabel, + onepasswordFieldType: nil, + opId: "password-field", + placeholder: passwordLabel, + readOnly: false, + type: "password", + value: nil, + viewable: true, + visible: true + ), + ], + forms: [ + formIdentifier: PageDetails.Form( + htmlAction: "https://example.com/account", + htmlId: formIdentifier, + htmlMethod: "post", + htmlName: formIdentifier, + opId: formIdentifier + ), + ], + tabUrl: "https://example.com/account", + title: title, + url: "https://example.com/account" + ) +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionAction.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionAction.swift new file mode 100644 index 0000000000..967099381c --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionAction.swift @@ -0,0 +1,26 @@ +// MARK: - SafariExtensionSuggestionAction + +/// The primary action the Safari extension UI should suggest for a given request. +public enum SafariExtensionSuggestionAction: String, Codable, Equatable { + case none + case fill + case saveLogin + case updatePassword + case generatePassword + + public static func from(_ request: SafariExtensionRequest) -> Self { + if request.canAutofill { + return .fill + } + if request.canSaveLogin { + return .saveLogin + } + if request.canChangePassword { + return .updatePassword + } + if request.canGeneratePassword { + return .generatePassword + } + return .none + } +} diff --git a/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionActionTests.swift b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionActionTests.swift new file mode 100644 index 0000000000..1bd01d9b34 --- /dev/null +++ b/BitwardenShared/Core/SafariExtension/Models/SafariExtensionSuggestionActionTests.swift @@ -0,0 +1,88 @@ +import XCTest + +@testable import BitwardenShared + +class SafariExtensionSuggestionActionTests: BitwardenTestCase { + func test_from_fillRequest_returnsFill() { + let request = SafariExtensionRequest( + kind: .fill, + pageDetails: PageDetails( + collectedTimestamp: Date(timeIntervalSince1970: 1_715_000_000), + documentUUID: "doc-1", + documentUrl: "https://example.com/login", + fields: [ + PageDetails.Field( + disabled: false, + elementNumber: 1, + form: nil, + htmlClass: nil, + htmlId: "password", + htmlName: "password", + labelLeft: nil, + labelRight: nil, + labelTag: nil, + onepasswordFieldType: nil, + opId: "password-field", + placeholder: nil, + readOnly: false, + type: "password", + value: nil, + viewable: true, + visible: true, + ), + ], + forms: [:], + tabUrl: "https://example.com/login", + title: "Login", + url: "https://example.com/login", + ), + ) + + XCTAssertEqual(SafariExtensionSuggestionAction.from(request), .fill) + } + + func test_from_saveLoginRequest_returnsSaveLogin() { + let request = SafariExtensionRequest( + kind: .saveLogin, + password: "secret", + urlString: "https://example.com/login", + username: "user@example.com", + ) + + XCTAssertEqual(SafariExtensionSuggestionAction.from(request), .saveLogin) + } + + func test_from_changePasswordRequest_returnsUpdatePassword() { + let request = SafariExtensionRequest( + kind: .changePassword, + oldPassword: "old-secret", + password: "new-secret", + urlString: "https://example.com/change-password", + ) + + XCTAssertEqual(SafariExtensionSuggestionAction.from(request), .updatePassword) + } + + func test_from_changePasswordRequestWithoutOldPassword_returnsUpdatePassword() { + let request = SafariExtensionRequest( + kind: .changePassword, + oldPassword: nil, + password: "new-secret", + urlString: "https://example.com/reset-password", + ) + + XCTAssertEqual(SafariExtensionSuggestionAction.from(request), .updatePassword) + } + + func test_from_generatePasswordRequest_returnsGeneratePassword() { + let request = SafariExtensionRequest(kind: .generatePassword) + + XCTAssertEqual(SafariExtensionSuggestionAction.from(request), .generatePassword) + } + + func test_from_incompleteRequest_returnsNone() { + let request = SafariExtensionRequest(kind: .saveLogin) + + XCTAssertEqual(SafariExtensionSuggestionAction.from(request), .none) + } +} diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillAction.swift b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillAction.swift index 591a5e4da1..4499929650 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillAction.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillAction.swift @@ -20,6 +20,9 @@ enum AutoFillAction: Equatable { /// The password auto-fill button was tapped. case passwordAutoFillTapped + /// The Safari extension button was tapped. + case safariExtensionTapped + /// The copy TOTP automatically toggle value changed. case toggleCopyTOTPToggle(Bool) diff --git a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillProcessor.swift b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillProcessor.swift index f8ceca95df..dfa4b9c834 100644 --- a/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillProcessor.swift +++ b/BitwardenShared/UI/Platform/Settings/Settings/AutoFill/AutoFillProcessor.swift @@ -72,6 +72,8 @@ final class AutoFillProcessor: StateProcessor