mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Verify Element X with an existing Element Classic account. (#5374)
* Read and import the secrets from ClassicAppAccounts. * Record snapshots. * Add some documentation, tidy up tests and fix the dismissal of the backup instructions. * Workaround flakey tests (the fulfilments weren't always firing). * Allow a custom Classic App deep link URL to be configured.
This commit is contained in:
@@ -17,6 +17,11 @@ protocol AuthenticationClientFactoryProtocol {
|
||||
clientSessionDelegate: ClientSessionDelegate,
|
||||
appSettings: AppSettings,
|
||||
appHooks: AppHooks) async throws -> ClientProtocol
|
||||
|
||||
func makeInMemoryClient(homeserverAddress: String,
|
||||
clientSessionDelegate: ClientSessionDelegate,
|
||||
appSettings: AppSettings,
|
||||
appHooks: AppHooks) async throws -> ClientProtocol
|
||||
}
|
||||
|
||||
/// A wrapper around `ClientBuilder` to allow for mocked clients to be injected into authentication tests.
|
||||
@@ -40,4 +45,21 @@ struct AuthenticationClientFactory: AuthenticationClientFactoryProtocol {
|
||||
.serverNameOrHomeserverUrl(serverNameOrUrl: homeserverAddress)
|
||||
.build()
|
||||
}
|
||||
|
||||
func makeInMemoryClient(homeserverAddress: String,
|
||||
clientSessionDelegate: ClientSessionDelegate,
|
||||
appSettings: AppSettings,
|
||||
appHooks: AppHooks) async throws -> ClientProtocol {
|
||||
try await ClientBuilder
|
||||
.baseBuilder(httpProxy: appSettings.websiteURL.globalProxy,
|
||||
slidingSync: .discover,
|
||||
sessionDelegate: clientSessionDelegate,
|
||||
appHooks: appHooks,
|
||||
enableOnlySignedDeviceIsolationMode: appSettings.enableOnlySignedDeviceIsolationMode,
|
||||
enableKeyShareOnInvite: appSettings.enableKeyShareOnInvite,
|
||||
threadsEnabled: appSettings.threadsEnabled)
|
||||
.inMemoryStore()
|
||||
.serverNameOrHomeserverUrl(serverNameOrUrl: homeserverAddress)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
||||
|
||||
private(set) var flow: AuthenticationFlow
|
||||
|
||||
let classicAppAccount: ClassicAppAccount?
|
||||
|
||||
init(userSessionStore: UserSessionStoreProtocol,
|
||||
encryptionKeyProvider: EncryptionKeyProviderProtocol,
|
||||
classicAppManager: ClassicAppManagerProtocol?,
|
||||
@@ -45,14 +47,15 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
||||
|
||||
do {
|
||||
if let classicAppManager {
|
||||
// Just let the app manager log the detected account for now.
|
||||
_ = try classicAppManager.loadAccounts()
|
||||
classicAppAccount = try classicAppManager.loadAccounts().first
|
||||
} else {
|
||||
MXLog.info("Classic App not configured, skipping loadAccounts.")
|
||||
classicAppAccount = nil
|
||||
}
|
||||
} catch {
|
||||
// This should show an alert: "We have detected an older version of Element Classic, but no bueno!"
|
||||
// No need to alert the user of the failure, just log it. They can still sign in manually.
|
||||
MXLog.error("Failed loading accounts from the Classic app: \(error)")
|
||||
classicAppAccount = nil
|
||||
}
|
||||
|
||||
// When updating these, don't forget to update the reset method too.
|
||||
@@ -129,6 +132,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
||||
guard let client else { return .failure(.failedLoggingIn) }
|
||||
do {
|
||||
try await client.loginWithOidcCallback(callbackUrl: callbackURL.absoluteString)
|
||||
await verifyClientIfPossible(client: client)
|
||||
return await userSession(for: client)
|
||||
} catch OidcError.Cancelled {
|
||||
return .failure(.oidcError(.userCancellation))
|
||||
@@ -150,6 +154,8 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
||||
return .failure(.sessionTokenRefreshNotSupported)
|
||||
}
|
||||
|
||||
await verifyClientIfPossible(client: client)
|
||||
|
||||
return await userSession(for: client)
|
||||
} catch let ClientError.MatrixApi(errorKind, _, _, _) {
|
||||
MXLog.error("Failed logging in with error kind: \(errorKind)")
|
||||
@@ -203,6 +209,9 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
||||
let qrCodeHandler = client.newLoginWithQrCodeHandler(oidcConfiguration: appSettings.oidcConfiguration.rustValue)
|
||||
try await qrCodeHandler.scan(qrCodeData: qrData, progressListener: listener)
|
||||
|
||||
// Since the QR code login flow includes verification.
|
||||
appSettings.hasRunIdentityConfirmationOnboarding = true
|
||||
|
||||
switch await userSession(for: client) {
|
||||
case .success(let userSession):
|
||||
progressSubject.send(.signedIn(userSession))
|
||||
@@ -260,6 +269,86 @@ class AuthenticationService: AuthenticationServiceProtocol {
|
||||
return .failure(.failedLoggingIn)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Classic App
|
||||
|
||||
/// Populates the Classic app account's state by checking whether the account's homeserver is supported
|
||||
/// (has Sliding Sync and OIDC or password login) and whether all of the required secrets are available.
|
||||
func setupClassicAppAccountState() async {
|
||||
guard let classicAppAccount, classicAppAccount.state.isServerSupported == nil else { return }
|
||||
MXLog.info("Checking Classic app account: \(classicAppAccount)")
|
||||
|
||||
do {
|
||||
let client = try await clientFactory.makeInMemoryClient(homeserverAddress: classicAppAccount.homeserverURL.absoluteString,
|
||||
clientSessionDelegate: userSessionStore.clientSessionDelegate,
|
||||
appSettings: appSettings,
|
||||
appHooks: appHooks)
|
||||
let loginDetails = await client.homeserverLoginDetails()
|
||||
let isServerSupported = loginDetails.supportsOidcLogin() || loginDetails.supportsPasswordLogin()
|
||||
MXLog.info("Classic app homeserver supported: \(isServerSupported)")
|
||||
classicAppAccount.state.isServerSupported = isServerSupported
|
||||
|
||||
await refreshClassicAppAccountState()
|
||||
} catch {
|
||||
MXLog.info("Classic app account support check failed: \(error)")
|
||||
classicAppAccount.state.isServerSupported = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks which encryption secrets are currently available from the Classic app and updates the account's state accordingly. We will handle the
|
||||
/// Classic account differently, depending on which secrets are available:
|
||||
/// - When they're `.complete` (the session is verified and has a key backup) we can automatically verify the account once signed in.
|
||||
/// - When they're `.requiresBackup` we prompt the user to enable a key backup before signing in so that their messages can be decrypted.
|
||||
/// - When they're `.unavailable` (an unverified session without secret storage) we simply show the Classic account to help the user sign in
|
||||
/// faster but they will need to reset their identity and verify the Classic account themselves.
|
||||
///
|
||||
/// This should be called whenever the user has potentially updated their secrets in the Classic app.
|
||||
func refreshClassicAppAccountState() async {
|
||||
guard let classicAppManager, let classicAppAccount, classicAppAccount.state.isServerSupported != nil else { return }
|
||||
|
||||
classicAppAccount.state.availableSecrets = nil
|
||||
|
||||
do {
|
||||
let availableSecrets = try await classicAppManager.availableSecrets(for: classicAppAccount)
|
||||
guard !Task.isCancelled else { return }
|
||||
MXLog.info("Classic app secrets: \(availableSecrets)")
|
||||
classicAppAccount.state.availableSecrets = availableSecrets
|
||||
} catch {
|
||||
MXLog.info("Failed to refresh Classic app account secrets: \(error)")
|
||||
classicAppAccount.state.availableSecrets = .unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports the Classic app's encryption secrets into the signed-in client, automatically verifying the session. This will no-op if
|
||||
/// the user signed in with a different account or when the Classic app doesn't have a complete set of secrets (meaning either
|
||||
/// key backup is disabled or the session hasn't been verified).
|
||||
private func verifyClientIfPossible(client: ClientProtocol) async {
|
||||
guard let classicAppManager, let classicAppAccount else { return }
|
||||
|
||||
// Technically the SDK makes sure the secrets are for the correct account, but as
|
||||
// we want to verify the classic account regardless which flow was used, it seems
|
||||
// sane to avoid loading the secrets when we know that they're not relevant.
|
||||
guard classicAppAccount.userID == (try? client.userId()) else { return }
|
||||
|
||||
guard classicAppAccount.state.availableSecrets == .complete else {
|
||||
MXLog.info("The matching Classic app account is missing secrets, ignoring.")
|
||||
return
|
||||
}
|
||||
|
||||
MXLog.info("Found matching Classic app account, importing secrets.")
|
||||
|
||||
do {
|
||||
let secrets = try await classicAppManager.secretsBundle(for: classicAppAccount)
|
||||
try await client.encryption().importSecretsBundle(secretsBundle: secrets)
|
||||
|
||||
MXLog.info("Classic app account secrets imported.")
|
||||
|
||||
// Importing the secrets automatically verifies the session.
|
||||
appSettings.hasRunIdentityConfirmationOnboarding = true
|
||||
} catch {
|
||||
MXLog.error("Failed to import secrets for Classic app account: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension HumanQrLoginError {
|
||||
@@ -291,9 +380,13 @@ private extension HumanQrLoginError {
|
||||
|
||||
extension AuthenticationService {
|
||||
static var mock: AuthenticationService {
|
||||
mock(classicAppManager: nil)
|
||||
}
|
||||
|
||||
static func mock(classicAppManager: ClassicAppManagerProtocol?) -> AuthenticationService {
|
||||
AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
|
||||
encryptionKeyProvider: EncryptionKeyProvider(),
|
||||
classicAppManager: nil,
|
||||
classicAppManager: classicAppManager,
|
||||
clientFactory: AuthenticationClientFactoryMock(configuration: .init()),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
appHooks: AppHooks())
|
||||
|
||||
@@ -57,6 +57,17 @@ protocol AuthenticationServiceProtocol: QRCodeLoginServiceProtocol {
|
||||
|
||||
/// Resets the current configuration requiring `configure(for:flow:)` to be called again.
|
||||
func reset()
|
||||
|
||||
// MARK: - Classic App
|
||||
|
||||
/// Account details discovered from the Classic app that is used for automatic verification when the same account is authenticated.
|
||||
var classicAppAccount: ClassicAppAccount? { get }
|
||||
/// Populates the Classic app account's state by checking if the homeserver is supported and which secrets are available.
|
||||
///
|
||||
/// **Note:** This is no longer automatic purely for testing purposes. It needs to have been called before using ``classicAppAccount``.
|
||||
func setupClassicAppAccountState() async
|
||||
/// This can be called whenever the user has potentially updated their secrets in the Classic app.
|
||||
func refreshClassicAppAccountState() async
|
||||
}
|
||||
|
||||
// MARK: - OIDC
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct ClassicAppAccount: Equatable, CustomStringConvertible {
|
||||
let userID: String
|
||||
@@ -24,6 +25,27 @@ struct ClassicAppAccount: Equatable, CustomStringConvertible {
|
||||
var description: String {
|
||||
"ClassicAppAccount(userID: \(userID), homeserverURL: \(homeserverURL))"
|
||||
}
|
||||
|
||||
enum AvailableSecrets { case complete, requiresBackup, unavailable }
|
||||
|
||||
@Observable
|
||||
class State: Equatable {
|
||||
static func == (lhs: State, rhs: State) -> Bool {
|
||||
lhs.isServerSupported == rhs.isServerSupported && lhs.availableSecrets == rhs.availableSecrets
|
||||
}
|
||||
|
||||
/// Whether or not the account's server is supported by Element X (or `nil` whilst determining support).
|
||||
///
|
||||
/// The account will be hidden when this value is `false`.
|
||||
var isServerSupported: Bool?
|
||||
/// Information about the secrets available from Element X (or `nil` whilst determining availability).
|
||||
///
|
||||
/// See ``AuthenticationService.refreshClassicAppAccountState`` for details about how
|
||||
/// this property's value affects the authentication flow.
|
||||
var availableSecrets: AvailableSecrets?
|
||||
}
|
||||
|
||||
let state = State()
|
||||
}
|
||||
|
||||
// MARK: NSCoding Types
|
||||
|
||||
@@ -11,13 +11,19 @@ import MatrixRustSDK
|
||||
|
||||
// sourcery: AutoMockable
|
||||
protocol ClassicAppManagerProtocol {
|
||||
/// Loads all of the accounts found in the Classic app's file store.
|
||||
func loadAccounts() throws -> [ClassicAppAccount]
|
||||
/// Determines which secrets will be available when loading the secrets bundle for a given account.
|
||||
func availableSecrets(for account: ClassicAppAccount) async throws -> ClassicAppAccount.AvailableSecrets
|
||||
/// Loads the secrets bundle for a given account.
|
||||
func secretsBundle(for account: ClassicAppAccount) async throws -> SecretsBundleWithUserId
|
||||
}
|
||||
|
||||
enum ClassicAppManagerError: Error {
|
||||
case invalidAppGroupIdentifier(String)
|
||||
case missingAccountKeys
|
||||
case missingCryptoStorePassphrase
|
||||
case missingKeyBackupVersion
|
||||
}
|
||||
|
||||
/// Reads accounts from Element Classic's shared storage.
|
||||
@@ -45,7 +51,6 @@ final class ClassicAppManager: ClassicAppManagerProtocol {
|
||||
keychain = Keychain(service: classicAppKeychainServiceIdentifier, accessGroup: classicAppKeychainAccessGroupIdentifier)
|
||||
}
|
||||
|
||||
/// Loads all of the active accounts from the Classic app.
|
||||
func loadAccounts() throws -> [ClassicAppAccount] {
|
||||
// The account data is stored in the App Group container.
|
||||
guard let url = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: classicAppGroupIdentifier) else {
|
||||
@@ -69,4 +74,41 @@ final class ClassicAppManager: ClassicAppManagerProtocol {
|
||||
accountManager.loadAccounts()
|
||||
return accountManager.accounts
|
||||
}
|
||||
|
||||
func availableSecrets(for account: ClassicAppAccount) async throws -> ClassicAppAccount.AvailableSecrets {
|
||||
switch try await databaseContainsSecretsBundle(databasePath: account.cryptoStoreURL.path(percentEncoded: false),
|
||||
passphrase: account.cryptoStorePassphrase,
|
||||
backupInfo: keyBackupVersion(for: account)) {
|
||||
case .complete: .complete
|
||||
case .none: .unavailable
|
||||
case .withoutBackup, .unusedBackup: .requiresBackup
|
||||
}
|
||||
}
|
||||
|
||||
func secretsBundle(for account: ClassicAppAccount) async throws -> SecretsBundleWithUserId {
|
||||
guard let keyBackupVersion = try await keyBackupVersion(for: account) else {
|
||||
throw ClassicAppManagerError.missingKeyBackupVersion
|
||||
}
|
||||
|
||||
return try await SecretsBundleWithUserId.fromDatabase(databasePath: account.cryptoStoreURL.path(percentEncoded: false),
|
||||
passphrase: account.cryptoStorePassphrase,
|
||||
backupInfo: keyBackupVersion)
|
||||
}
|
||||
|
||||
/// Fetches the current key backup version from the homeserver. This is needed to determine whether
|
||||
/// the backup key from the crypto store is for the backup currently being used by the account.
|
||||
private func keyBackupVersion(for account: ClassicAppAccount) async throws -> String? {
|
||||
let url = account.homeserverURL.appending(path: "_matrix/client/v3/room_keys/version")
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Bearer \(account.accessToken)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
if let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode != 200 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user