Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,7 @@ public class ServiceContainer: Services {
)

let sharedCryptographyService = DefaultAuthenticatorCryptographyService(
sharedKeychainRepository: sharedKeychainRepository
sharedKeychainRepository: sharedKeychainRepository,
)

let sharedDataStore = AuthenticatorBridgeDataStore(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ typealias Services = HasAppInfoService
& HasNotificationCenterService
& HasPasteboardService
& HasStateService
& HasTimeProvider
& HasTOTPExpirationManagerFactory
& HasTOTPService
& HasTimeProvider

/// Protocol for an object that provides an `Application`
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ extension ServiceContainer {
stateService: StateService = MockStateService(),
timeProvider: TimeProvider = MockTimeProvider(.currentTime),
totpExpirationManagerFactory: TOTPExpirationManagerFactory = MockTOTPExpirationManagerFactory(),
totpService: TOTPService = MockTOTPService()
totpService: TOTPService = MockTOTPService(),
) -> ServiceContainer {
ServiceContainer(
application: application,
Expand All @@ -48,7 +48,7 @@ extension ServiceContainer {
stateService: stateService,
timeProvider: timeProvider,
totpExpirationManagerFactory: totpExpirationManagerFactory,
totpService: totpService
totpService: totpService,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -319,18 +319,7 @@ extension CredentialProviderViewController: AppExtensionDelegate {
var isInAppExtension: Bool { true }

var uri: String? {
guard let serviceIdentifiers = context?.serviceIdentifiers,
let serviceIdentifier = serviceIdentifiers.first
else { return nil }

return switch serviceIdentifier.type {
case .domain:
"https://" + serviceIdentifier.identifier
case .URL:
serviceIdentifier.identifier
@unknown default:
serviceIdentifier.identifier
}
context?.uri
}

func completeAutofillRequest(username: String, password: String, fields: [(String, String)]?) {
Expand Down
32 changes: 32 additions & 0 deletions BitwardenKit/Core/Platform/Extensions/String.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,38 @@ public extension String {
}
}

/// Returns a normalized URL string with an HTTPS scheme and no trailing slash.
///
/// This method performs two normalization operations:
/// 1. Removes any trailing slash from the URL
/// 2. Prefixes the URL with `https://` if no scheme (`http://` or `https://`) is present
///
/// If the URL already has an `http://` or `https://` scheme, it is preserved as-is
/// (after removing any trailing slash).
///
/// - Returns: A normalized URL string suitable for consistent URL matching and comparison.
///
/// # Examples
/// ```swift
/// "example.com".httpsNormalized() // "https://example.com"
/// "example.com/".httpsNormalized() // "https://example.com"
/// "http://example.com".httpsNormalized() // "http://example.com"
/// "https://example.com/".httpsNormalized() // "https://example.com"
/// ```
///
func httpsNormalized() -> String {
let stringUrl = if hasSuffix("/") {
String(dropLast())
} else {
self
}

guard stringUrl.starts(with: "https://") || stringUrl.starts(with: "http://") else {
return "https://" + stringUrl
}
return stringUrl
}

/// Creates a new string that has been encoded for use in a url or request header.
///
/// - Returns: A `String` encoded for use in a url or request header.
Expand Down
36 changes: 36 additions & 0 deletions BitwardenKit/Core/Platform/Extensions/StringTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,42 @@ class StringTests: BitwardenTestCase {
XCTAssertEqual(subject.hexSHA256Hash, expected)
}

/// `httpsNormalized()` adds HTTPS prefix when no scheme is present.
func test_httpsNormalized_addsHttpsPrefix() {
XCTAssertEqual("example.com".httpsNormalized(), "https://example.com")
XCTAssertEqual("bitwarden.com".httpsNormalized(), "https://bitwarden.com")
XCTAssertEqual("sub.domain.example.com".httpsNormalized(), "https://sub.domain.example.com")
}

/// `httpsNormalized()` removes trailing slash.
func test_httpsNormalized_removesTrailingSlash() {
XCTAssertEqual("example.com/".httpsNormalized(), "https://example.com")
XCTAssertEqual("https://example.com/".httpsNormalized(), "https://example.com")
XCTAssertEqual("http://example.com/".httpsNormalized(), "http://example.com")
}

/// `httpsNormalized()` preserves existing HTTPS scheme.
func test_httpsNormalized_preservesHttpsScheme() {
XCTAssertEqual("https://example.com".httpsNormalized(), "https://example.com")
XCTAssertEqual("https://bitwarden.com".httpsNormalized(), "https://bitwarden.com")
XCTAssertEqual("https://example.com:8080".httpsNormalized(), "https://example.com:8080")
}

/// `httpsNormalized()` preserves existing HTTP scheme.
func test_httpsNormalized_preservesHttpScheme() {
XCTAssertEqual("http://example.com".httpsNormalized(), "http://example.com")
XCTAssertEqual("http://bitwarden.com".httpsNormalized(), "http://bitwarden.com")
XCTAssertEqual("http://localhost:8080".httpsNormalized(), "http://localhost:8080")
}

/// `httpsNormalized()` handles URLs with paths correctly.
func test_httpsNormalized_withPaths() {
XCTAssertEqual("example.com/path".httpsNormalized(), "https://example.com/path")
XCTAssertEqual("https://example.com/path".httpsNormalized(), "https://example.com/path")
XCTAssertEqual("example.com/path/to/resource".httpsNormalized(), "https://example.com/path/to/resource")
XCTAssertEqual("https://example.com/path/".httpsNormalized(), "https://example.com/path")
}

/// `isValidURL` returns `true` for a valid URL.
func test_isBitwardenAppScheme() {
XCTAssertTrue("bitwarden".isBitwardenAppScheme)
Expand Down
11 changes: 1 addition & 10 deletions BitwardenKit/Core/Platform/Extensions/URL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,7 @@ public extension URL {
/// Returns a sanitized version of the URL. This will add a https scheme to the URL if the
/// scheme is missing and remove a trailing slash.
var sanitized: URL {
let stringUrl = if absoluteString.hasSuffix("/") {
String(absoluteString.dropLast())
} else {
absoluteString
}

guard stringUrl.starts(with: "https://") || stringUrl.starts(with: "http://") else {
return URL(string: "https://" + stringUrl) ?? self
}
return URL(string: stringUrl) ?? self
URL(string: absoluteString.httpsNormalized()) ?? self
}

/// Returns a string of the URL with the scheme removed (e.g. `send.bitwarden.com/39ngaol3`).
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// - MARK: Input Validator
// MARK: Input Validator

/// A protocol for an object that handles validating input for a field.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ class AutofillCredentialServiceAppExtensionTests: BitwardenTestCase {

/// `syncIdentities(vaultLockStatus:)` doesn't update the credential identity store with the identities
/// from the user's vault when the app context is `.appExtension`.
func test_syncIdentities_appExtensionContext() { // swiftlint:disable:this function_body_length
func test_syncIdentities_appExtensionContext() {
prepareDataForIdentitiesReplacement()

vaultTimeoutService.vaultLockStatusSubject.send(VaultLockStatus(isVaultLocked: false, userId: "1"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ public protocol CredentialProviderContext {
var flowWithUserInteraction: Bool { get }
/// The `ASCredentialServiceIdentifier` array depending on the `ExtensionMode`.
var serviceIdentifiers: [ASCredentialServiceIdentifier] { get }
/// The URI of the credential to autofill.
var uri: String? { get }
}

/// Default implementation of `CredentialProviderContext`.
Expand Down Expand Up @@ -89,6 +91,34 @@ public struct DefaultCredentialProviderContext: CredentialProviderContext {
}
}

public var uri: String? {
guard let serviceIdentifier = serviceIdentifiers.first else {
// WORKAROUND: iOS does not consistently send `serviceIdentifiers` in the Fido2 + Passwords
// vault list flow (.autofillFido2VaultList). As a fallback, we use the `relyingPartyIdentifier`
// as the URI for filtering, which provides similar functionality.
//
// This fallback should be retained even if Apple fixes the primary issue, as it ensures
// resilience against future OS regressions and edge cases.
//
// Related: iOS Autofill API behavior - serviceIdentifiers may be empty in certain contexts.
if case let .autofillFido2VaultList(_, passkeyParameters) = extensionMode,
!passkeyParameters.relyingPartyIdentifier.isEmpty {
return passkeyParameters.relyingPartyIdentifier.httpsNormalized()
}

return nil
}

return switch serviceIdentifier.type {
case .domain:
"https://" + serviceIdentifier.identifier
case .URL:
serviceIdentifier.identifier
@unknown default:
serviceIdentifier.identifier
}
}

/// Initializes the context.
/// - Parameter extensionMode: The mode of the extension.
public init(_ extensionMode: AutofillExtensionMode) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import XCTest

@testable import BitwardenShared

class CredentialProviderContextTests: BitwardenTestCase {
class CredentialProviderContextTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Tests

/// `getter:authCompletionRoute` the corresponding route depending on the context mode.
Expand Down Expand Up @@ -294,6 +294,103 @@ class CredentialProviderContextTests: BitwardenTestCase {
let subject6 = DefaultCredentialProviderContext(.autofillText)
XCTAssertEqual(subject6.serviceIdentifiers, expectedIdentifiers)
}

/// `getter:uri` returns the URI with https prefix when service identifier is a domain.
func test_uri_domain() {
let serviceIdentifier = ASCredentialServiceIdentifier.fixture(
identifier: "example.com",
type: .domain,
)
let subject = DefaultCredentialProviderContext(.autofillVaultList([serviceIdentifier]))
XCTAssertEqual(subject.uri, "https://example.com")
}

/// `getter:uri` returns the URI as-is when service identifier is a URL.
func test_uri_url() {
let serviceIdentifier = ASCredentialServiceIdentifier.fixture(
identifier: "https://example.com/path",
type: .URL,
)
let subject = DefaultCredentialProviderContext(.autofillVaultList([serviceIdentifier]))
XCTAssertEqual(subject.uri, "https://example.com/path")
}

/// `getter:uri` returns the first service identifier when multiple identifiers exist.
func test_uri_multipleServiceIdentifiers() {
let identifiers = [
ASCredentialServiceIdentifier.fixture(identifier: "first.com", type: .domain),
ASCredentialServiceIdentifier.fixture(identifier: "second.com", type: .domain),
ASCredentialServiceIdentifier.fixture(identifier: "third.com", type: .domain),
]
let subject = DefaultCredentialProviderContext(.autofillVaultList(identifiers))
XCTAssertEqual(subject.uri, "https://first.com")
}

/// `getter:uri` returns relying party identifier as fallback for autofillFido2VaultList
/// when service identifiers are empty, normalized with HTTPS prefix.
func test_uri_autofillFido2VaultList_relyingPartyFallback() {
let parameters = MockPasskeyCredentialRequestParameters(relyingPartyIdentifier: "passkey.example.com")
let subject = DefaultCredentialProviderContext(.autofillFido2VaultList([], parameters))
XCTAssertEqual(subject.uri, "https://passkey.example.com")
}

/// `getter:uri` returns relying party identifier normalized, preserving existing HTTPS scheme.
func test_uri_autofillFido2VaultList_relyingPartyWithHttpsScheme() {
let parameters = MockPasskeyCredentialRequestParameters(relyingPartyIdentifier: "https://passkey.example.com")
let subject = DefaultCredentialProviderContext(.autofillFido2VaultList([], parameters))
XCTAssertEqual(subject.uri, "https://passkey.example.com")
}

/// `getter:uri` returns the service identifier URI when available for autofillFido2VaultList,
/// ignoring the relying party identifier.
func test_uri_autofillFido2VaultList_withServiceIdentifiers() {
let serviceIdentifier = ASCredentialServiceIdentifier.fixture(
identifier: "actual.example.com",
type: .domain,
)
let parameters = MockPasskeyCredentialRequestParameters(relyingPartyIdentifier: "fallback.example.com")
let subject = DefaultCredentialProviderContext(.autofillFido2VaultList([serviceIdentifier], parameters))
XCTAssertEqual(subject.uri, "https://actual.example.com")
}

/// `getter:uri` returns nil when autofillFido2VaultList has empty service identifiers
/// and empty relying party identifier.
func test_uri_autofillFido2VaultList_emptyRelyingParty() {
let parameters = MockPasskeyCredentialRequestParameters(relyingPartyIdentifier: "")
let subject = DefaultCredentialProviderContext(.autofillFido2VaultList([], parameters))
XCTAssertNil(subject.uri)
}

/// `getter:uri` returns nil when no service identifiers and not autofillFido2VaultList mode.
func test_uri_nil() {
let subject1 = DefaultCredentialProviderContext(.autofillCredential(.fixture(), userInteraction: false))
XCTAssertNil(subject1.uri)

let subject2 = DefaultCredentialProviderContext(
.autofillFido2Credential(MockPasskeyCredentialRequest(), userInteraction: false),
)
XCTAssertNil(subject2.uri)

let subject3 = DefaultCredentialProviderContext(.configureAutofill)
XCTAssertNil(subject3.uri)

let subject4 = DefaultCredentialProviderContext(.registerFido2Credential(MockPasskeyCredentialRequest()))
XCTAssertNil(subject4.uri)

let subject5 = DefaultCredentialProviderContext(
.autofillOTPCredential(MockOneTimeCodeCredentialIdentity(), userInteraction: false),
)
XCTAssertNil(subject5.uri)

let subject6 = DefaultCredentialProviderContext(.autofillText)
XCTAssertNil(subject6.uri)
}

/// `getter:uri` returns nil when service identifiers are empty for autofillVaultList.
func test_uri_autofillVaultList_empty() {
let subject = DefaultCredentialProviderContext(.autofillVaultList([]))
XCTAssertNil(subject.uri)
}
}

class MockPasskeyCredentialRequest: PasskeyCredentialRequest {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -398,4 +398,4 @@ class DefaultNotificationService: NotificationService {
errorReporter.log(error: error)
}
}
}
} // swiftlint:disable:this file_length
27 changes: 14 additions & 13 deletions BitwardenShared/Core/Platform/Services/ServiceContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -723,9 +723,23 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le

let collectionHelper = DefaultCollectionHelper(organizationService: organizationService)

let fido2UserInterfaceHelper = DefaultFido2UserInterfaceHelper(
fido2UserVerificationMediator: DefaultFido2UserVerificationMediator(
authRepository: authRepository,
stateService: stateService,
userVerificationHelper: DefaultUserVerificationHelper(
authRepository: authRepository,
errorReporter: errorReporter,
localAuthService: localAuthService,
),
userVerificationRunner: DefaultUserVerificationRunner(),
),
)

let vaultListDirectorStrategyFactory = DefaultVaultListDirectorStrategyFactory(
cipherService: cipherService,
collectionService: collectionService,
fido2UserInterfaceHelper: fido2UserInterfaceHelper,
folderService: folderService,
vaultListBuilderFactory: DefaultVaultListSectionsBuilderFactory(
clientService: clientService,
Expand Down Expand Up @@ -775,19 +789,6 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
vaultTimeoutService: vaultTimeoutService,
)

let fido2UserInterfaceHelper = DefaultFido2UserInterfaceHelper(
fido2UserVerificationMediator: DefaultFido2UserVerificationMediator(
authRepository: authRepository,
stateService: stateService,
userVerificationHelper: DefaultUserVerificationHelper(
authRepository: authRepository,
errorReporter: errorReporter,
localAuthService: localAuthService,
),
userVerificationRunner: DefaultUserVerificationRunner(),
),
)

#if DEBUG
let fido2CredentialStore = DebuggingFido2CredentialStoreService(
fido2CredentialStore: Fido2CredentialStoreService(
Expand Down
Loading