From 7c64e656fff45a217fe950889e87d453e80085b1 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Thu, 16 Oct 2025 11:24:04 +0200 Subject: [PATCH] Refactor (#520) --- .../CredentialIdentityStore.swift | 145 +++++++++--------- .../ios/VaultStoreKit/Utils/Helpers.swift | 20 +++ 2 files changed, 92 insertions(+), 73 deletions(-) create mode 100644 apps/mobile-app/ios/VaultStoreKit/Utils/Helpers.swift diff --git a/apps/mobile-app/ios/VaultStoreKit/CredentialIdentityStore.swift b/apps/mobile-app/ios/VaultStoreKit/CredentialIdentityStore.swift index aa8032b11..ecd6a54d2 100644 --- a/apps/mobile-app/ios/VaultStoreKit/CredentialIdentityStore.swift +++ b/apps/mobile-app/ios/VaultStoreKit/CredentialIdentityStore.swift @@ -19,77 +19,29 @@ public class CredentialIdentityStore { /// - credentials: The credentials to register /// - passkeyOnly: If true, only register passkey identities (skip passwords). Default is false. public func saveCredentialIdentities(_ credentials: [Credential], passkeyOnly: Bool = false) async throws { - // TODO: improve implementation to better separate password and passkey identities. - // As if a record has both a password and passkey, it will not only show up as a password identity. var allIdentities: [ASCredentialIdentity] = [] - // Create password identities (skip if passkeyOnly mode) - let passwordIdentities: [ASPasswordCredentialIdentity] = passkeyOnly ? [] : credentials.compactMap { credential in - guard let urlString = credential.service.url, - let url = URL(string: urlString), - let host = url.host else { - return nil - } - - // Use the same logic as the UI for determining the identifier - let identifier = usernameOrEmail(credential: credential) - guard !identifier.isEmpty else { - return nil // Skip credentials with no identifier - } - - guard let password = credential.password, !password.value.isEmpty else { - return nil // Skip credentials with no password (e.g. applies when this record is a passkey) - } - - let effectiveDomain = Self.effectiveDomain(from: host) - - return ASPasswordCredentialIdentity( - serviceIdentifier: ASCredentialServiceIdentifier(identifier: effectiveDomain, type: .domain), - user: identifier, - recordIdentifier: credential.id.uuidString - ) - } - - allIdentities.append(contentsOf: passwordIdentities) - - // Create passkey identities - let passkeyIdentities: [ASPasskeyCredentialIdentity] = credentials.flatMap { credential -> [ASPasskeyCredentialIdentity] in - guard let passkeys = credential.passkeys else { - return [] - } - - return passkeys.filter { !($0.isDeleted) } - .compactMap { passkey in - // Get the userName for display in iOS AutoFill UI - // Passkeys don't store userName in the database, so we use the credential's username or email - let userName = passkey.userName ?? usernameOrEmail(credential: credential) - - // Convert passkey.Id to bytes for credentialID - let credentialId = try? PasskeyHelper.guidToBytes(passkey.id.uuidString) - - // For passkeys, we use the rpId from the passkey itself, not the service URL - // This is because passkeys are tied to the RP ID, which may differ from the service URL - return ASPasskeyCredentialIdentity( - relyingPartyIdentifier: passkey.rpId, - userName: userName, - credentialID: credentialId ?? Data(), // WebAuthn credential ID (16-byte GUID) - userHandle: passkey.userHandle ?? Data(), - recordIdentifier: passkey.id.uuidString - ) - } - } - - allIdentities.append(contentsOf: passkeyIdentities) - - guard !allIdentities.isEmpty else { - return - } - let state = await storeState() guard state.isEnabled else { return } + // Only save passwords into autofill store on iOS 26+, as it works buggy on older iOS versions. + if #available(iOS 26.0, *) { + if !passkeyOnly { + let passwordIdentities = createPasswordIdentities(from: credentials) + allIdentities.append(contentsOf: passwordIdentities) + } + } + + // Create passkey identities + let passkeyIdentities = createPasskeyIdentities(from: credentials) + allIdentities.append(contentsOf: passkeyIdentities) + + guard !allIdentities.isEmpty else { + return + } + do { // First, remove all existing credential identities to ensure a clean replacement try await store.removeAllCredentialIdentities() @@ -116,7 +68,7 @@ public class CredentialIdentityStore { ) // Use the same logic as the UI for determining the identifier - let identifier = usernameOrEmail(credential: credential) + let identifier = Helpers.usernameOrEmail(credential: credential) guard !identifier.isEmpty else { return nil // Skip credentials with no identifier } @@ -144,15 +96,62 @@ public class CredentialIdentityStore { guard parts.count >= 2 else { return host } return parts.suffix(2).joined(separator: ".") } -} -/// Returns username or email depending on if they are not null -public func usernameOrEmail(credential: Credential) -> String { - if let username = credential.username, !username.isEmpty { - return username + /// Create password credential identities from credentials + private func createPasswordIdentities(from credentials: [Credential]) -> [ASPasswordCredentialIdentity] { + return credentials.compactMap { credential in + guard let urlString = credential.service.url, + let url = URL(string: urlString), + let host = url.host else { + return nil + } + + // Use the same logic as the UI for determining the identifier + let identifier = Helpers.usernameOrEmail(credential: credential) + guard !identifier.isEmpty else { + return nil // Skip credentials with no identifier + } + + guard let password = credential.password, !password.value.isEmpty else { + return nil // Skip credentials with no password (e.g. applies when this record is a passkey) + } + + let effectiveDomain = Self.effectiveDomain(from: host) + + return ASPasswordCredentialIdentity( + serviceIdentifier: ASCredentialServiceIdentifier(identifier: effectiveDomain, type: .domain), + user: identifier, + recordIdentifier: credential.id.uuidString + ) + } } - if let email = credential.alias?.email, !email.isEmpty { - return email + + /// Create passkey credential identities from credentials + private func createPasskeyIdentities(from credentials: [Credential]) -> [ASPasskeyCredentialIdentity] { + return credentials.flatMap { credential -> [ASPasskeyCredentialIdentity] in + guard let passkeys = credential.passkeys else { + return [] + } + + return passkeys.filter { !($0.isDeleted) } + .compactMap { passkey in + // Get the userName for display in iOS AutoFill UI + // Passkeys don't store userName in the database, so we use the credential's username or email + let userName = passkey.userName ?? Helpers.usernameOrEmail(credential: credential) + + // Convert passkey.Id to bytes for credentialID + let credentialId = try? PasskeyHelper.guidToBytes(passkey.id.uuidString) + + // For passkeys, we use the rpId from the passkey itself, not the service URL + // This is because passkeys are tied to the RP ID, which may differ from the service URL + return ASPasskeyCredentialIdentity( + relyingPartyIdentifier: passkey.rpId, + userName: userName, + credentialID: credentialId ?? Data(), // WebAuthn credential ID (16-byte GUID) + userHandle: passkey.userHandle ?? Data(), + recordIdentifier: passkey.id.uuidString + ) + } + } } - return "" } diff --git a/apps/mobile-app/ios/VaultStoreKit/Utils/Helpers.swift b/apps/mobile-app/ios/VaultStoreKit/Utils/Helpers.swift new file mode 100644 index 000000000..6790b573d --- /dev/null +++ b/apps/mobile-app/ios/VaultStoreKit/Utils/Helpers.swift @@ -0,0 +1,20 @@ +import Foundation +import VaultModels + +/** + * Helpers + * ------------------------- + * Generic utility class for general helper functions. + */ +public class Helpers { + /// Returns username or email depending on if they are not null + public static func usernameOrEmail(credential: Credential) -> String { + if let username = credential.username, !username.isEmpty { + return username + } + if let email = credential.alias?.email, !email.isEmpty { + return email + } + return "" + } +}