Merge the AuthenticationService with the QRCodeLoginService. (#4323)

* Merge the AuthenticationServer with the QRCodeLoginService.

* Merge AuthenticationClientBuilderFactory and AuthenticationClientBuilder into AuthenticationClientFactory

The separation is no longer needed now that password/OIDC login and QR code login have a similar API shape.
This commit is contained in:
Doug
2025-07-15 17:59:15 +01:00
committed by GitHub
parent 17000687b5
commit 448bc2d4e3
20 changed files with 275 additions and 502 deletions
@@ -1,46 +0,0 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Foundation
import MatrixRustSDK
// sourcery: AutoMockable
protocol AuthenticationClientBuilderProtocol {
func build(homeserverAddress: String) async throws -> ClientProtocol
}
/// A wrapper around `ClientBuilder` to share reusable code between Normal and QR logins.
struct AuthenticationClientBuilder: AuthenticationClientBuilderProtocol {
let sessionDirectories: SessionDirectories
let passphrase: String
let clientSessionDelegate: ClientSessionDelegate
let appSettings: AppSettings
let appHooks: AppHooks
/// Builds a Client for login using OIDC or password authentication.
func build(homeserverAddress: String) async throws -> ClientProtocol {
try await makeClientBuilder().serverNameOrHomeserverUrl(serverNameOrUrl: homeserverAddress).build()
}
// MARK: - Private
/// The base builder configuration used for authentication within the app.
private func makeClientBuilder() -> ClientBuilder {
ClientBuilder
.baseBuilder(httpProxy: appSettings.websiteURL.globalProxy,
slidingSync: .discover,
sessionDelegate: clientSessionDelegate,
appHooks: appHooks,
enableOnlySignedDeviceIsolationMode: appSettings.enableOnlySignedDeviceIsolationMode,
enableKeyShareOnInvite: appSettings.enableKeyShareOnInvite,
threadsEnabled: appSettings.threadsEnabled)
.sessionPaths(dataPath: sessionDirectories.dataPath,
cachePath: sessionDirectories.cachePath)
.sessionPassphrase(passphrase: passphrase)
}
}
@@ -1,33 +0,0 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Foundation
import MatrixRustSDK
// sourcery: AutoMockable
protocol AuthenticationClientBuilderFactoryProtocol {
func makeBuilder(sessionDirectories: SessionDirectories,
passphrase: String,
clientSessionDelegate: ClientSessionDelegate,
appSettings: AppSettings,
appHooks: AppHooks) -> AuthenticationClientBuilderProtocol
}
/// A wrapper around `ClientBuilder` to share reusable code between Normal and QR logins.
struct AuthenticationClientBuilderFactory: AuthenticationClientBuilderFactoryProtocol {
func makeBuilder(sessionDirectories: SessionDirectories,
passphrase: String,
clientSessionDelegate: ClientSessionDelegate,
appSettings: AppSettings,
appHooks: AppHooks) -> AuthenticationClientBuilderProtocol {
AuthenticationClientBuilder(sessionDirectories: sessionDirectories,
passphrase: passphrase,
clientSessionDelegate: clientSessionDelegate,
appSettings: appSettings,
appHooks: appHooks)
}
}
@@ -0,0 +1,43 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Foundation
import MatrixRustSDK
// sourcery: AutoMockable
protocol AuthenticationClientFactoryProtocol {
func makeClient(homeserverAddress: String,
sessionDirectories: SessionDirectories,
passphrase: 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.
struct AuthenticationClientFactory: AuthenticationClientFactoryProtocol {
func makeClient(homeserverAddress: String,
sessionDirectories: SessionDirectories,
passphrase: 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)
.sessionPaths(dataPath: sessionDirectories.dataPath,
cachePath: sessionDirectories.cachePath)
.sessionPassphrase(passphrase: passphrase)
.serverNameOrHomeserverUrl(serverNameOrUrl: homeserverAddress)
.build()
}
}
@@ -14,7 +14,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
private var sessionDirectories: SessionDirectories
private let passphrase: String
private let clientBuilderFactory: AuthenticationClientBuilderFactoryProtocol
private let clientFactory: AuthenticationClientFactoryProtocol
private let userSessionStore: UserSessionStoreProtocol
private let appSettings: AppSettings
private let appHooks: AppHooks
@@ -23,14 +23,19 @@ class AuthenticationService: AuthenticationServiceProtocol {
var homeserver: CurrentValuePublisher<LoginHomeserver, Never> { homeserverSubject.asCurrentValuePublisher() }
private(set) var flow: AuthenticationFlow
private let qrLoginProgressSubject = PassthroughSubject<QrLoginProgress, Never>()
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> {
qrLoginProgressSubject.eraseToAnyPublisher()
}
init(userSessionStore: UserSessionStoreProtocol,
encryptionKeyProvider: EncryptionKeyProviderProtocol,
clientBuilderFactory: AuthenticationClientBuilderFactoryProtocol = AuthenticationClientBuilderFactory(),
clientFactory: AuthenticationClientFactoryProtocol = AuthenticationClientFactory(),
appSettings: AppSettings,
appHooks: AppHooks) {
sessionDirectories = .init()
passphrase = encryptionKeyProvider.generateKey().base64EncodedString()
self.clientBuilderFactory = clientBuilderFactory
self.clientFactory = clientFactory
self.userSessionStore = userSessionStore
self.appSettings = appSettings
self.appHooks = appHooks
@@ -46,7 +51,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
do {
var homeserver = LoginHomeserver(address: homeserverAddress, loginMode: .unknown)
let client = try await makeClientBuilder().build(homeserverAddress: homeserverAddress)
let client = try await makeClient(homeserverAddress: homeserverAddress)
let loginDetails = await client.homeserverLoginDetails()
MXLog.info("Sliding sync: \(client.slidingSyncVersion())")
@@ -146,6 +151,45 @@ class AuthenticationService: AuthenticationServiceProtocol {
}
}
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, AuthenticationServiceError> {
let qrData: QrCodeData
do {
qrData = try QrCodeData.fromBytes(bytes: data)
} catch {
MXLog.error("QRCode decode error: \(error)")
return .failure(.qrCodeError(.invalidQRCode))
}
guard let scannedServerName = qrData.serverName() else {
MXLog.error("The QR code is from a device that is not yet signed in.")
return .failure(.qrCodeError(.deviceNotSignedIn))
}
if !appSettings.allowOtherAccountProviders, !appSettings.accountProviders.contains(scannedServerName) {
MXLog.error("The scanned device's server is not allowed: \(scannedServerName)")
return .failure(.qrCodeError(.providerNotAllowed(scannedProvider: scannedServerName, allowedProviders: appSettings.accountProviders)))
}
let listener = SDKListener { [weak self] progress in
self?.qrLoginProgressSubject.send(progress)
}
do {
let client = try await makeClient(homeserverAddress: scannedServerName)
try await client.loginWithQrCode(qrCodeData: qrData,
oidcConfiguration: appSettings.oidcConfiguration.rustValue,
progressListener: listener)
MXLog.info("Sliding sync: \(client.slidingSyncVersion())")
return await userSession(for: client)
} catch let error as HumanQrLoginError {
MXLog.error("QRCode login error: \(error)")
return .failure(error.serviceError)
} catch {
MXLog.error("QRCode login unknown error: \(error)")
return .failure(.qrCodeError(.unknown))
}
}
func reset() {
homeserverSubject.send(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown))
flow = .login
@@ -154,16 +198,17 @@ class AuthenticationService: AuthenticationServiceProtocol {
// MARK: - Private
private func makeClientBuilder() -> AuthenticationClientBuilderProtocol {
private func makeClient(homeserverAddress: String) async throws -> ClientProtocol {
// Use a fresh session directory each time the user enters a different server
// so that caches (e.g. server versions) are always fresh for the new server.
rotateSessionDirectory()
return clientBuilderFactory.makeBuilder(sessionDirectories: sessionDirectories,
passphrase: passphrase,
clientSessionDelegate: userSessionStore.clientSessionDelegate,
appSettings: appSettings,
appHooks: appHooks)
return try await clientFactory.makeClient(homeserverAddress: homeserverAddress,
sessionDirectories: sessionDirectories,
passphrase: passphrase,
clientSessionDelegate: userSessionStore.clientSessionDelegate,
appSettings: appSettings,
appHooks: appHooks)
}
private func rotateSessionDirectory() {
@@ -181,13 +226,36 @@ class AuthenticationService: AuthenticationServiceProtocol {
}
}
private extension HumanQrLoginError {
var serviceError: AuthenticationServiceError {
switch self {
case .Cancelled:
.qrCodeError(.cancelled)
case .ConnectionInsecure:
.qrCodeError(.connectionInsecure)
case .Declined:
.qrCodeError(.declined)
case .LinkingNotSupported:
.qrCodeError(.linkingNotSupported)
case .Expired:
.qrCodeError(.expired)
case .SlidingSyncNotAvailable:
.qrCodeError(.deviceNotSupported)
case .OtherDeviceNotSignedIn:
.qrCodeError(.deviceNotSignedIn)
case .Unknown, .OidcMetadataInvalid:
.qrCodeError(.unknown)
}
}
}
// MARK: - Mocks
extension AuthenticationService {
static var mock: AuthenticationService {
AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
encryptionKeyProvider: EncryptionKeyProvider(),
clientBuilderFactory: AuthenticationClientBuilderFactoryMock(configuration: .init()),
clientFactory: AuthenticationClientFactoryMock(configuration: .init()),
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks())
}
@@ -5,6 +5,7 @@
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Foundation
import MatrixRustSDK
@@ -19,6 +20,9 @@ enum AuthenticationFlow {
enum AuthenticationServiceError: Error, Equatable {
/// An error occurred during OIDC authentication.
case oidcError(OIDCError)
/// An error occurred during login with QR Code.
case qrCodeError(QRCodeLoginError)
case invalidServer
case invalidCredentials
case invalidHomeserverAddress
@@ -32,7 +36,7 @@ enum AuthenticationServiceError: Error, Equatable {
case failedUsingWebCredentials
}
protocol AuthenticationServiceProtocol {
protocol AuthenticationServiceProtocol: QRCodeLoginServiceProtocol {
/// The currently configured homeserver.
var homeserver: CurrentValuePublisher<LoginHomeserver, Never> { get }
/// The type of flow the service is currently configured with.
@@ -86,3 +90,25 @@ extension OAuthAuthorizationData: @retroactive Hashable {
hasher.combine(loginUrl())
}
}
// MARK: - Login with QR code
enum QRCodeLoginError: Error, Equatable {
case invalidQRCode
case providerNotAllowed(scannedProvider: String, allowedProviders: [String])
case cancelled
case connectionInsecure
case declined
case linkingNotSupported
case expired
case deviceNotSupported
case deviceNotSignedIn
case unknown
}
// sourcery: AutoMockable
protocol QRCodeLoginServiceProtocol {
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> { get }
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, AuthenticationServiceError>
}
@@ -1,127 +0,0 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Foundation
import MatrixRustSDK
final class QRCodeLoginService: QRCodeLoginServiceProtocol {
private var sessionDirectories: SessionDirectories
private let passphrase: String
private let userSessionStore: UserSessionStoreProtocol
private let appSettings: AppSettings
private let appHooks: AppHooks
private let qrLoginProgressSubject = PassthroughSubject<QrLoginProgress, Never>()
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> {
qrLoginProgressSubject.eraseToAnyPublisher()
}
init(encryptionKeyProvider: EncryptionKeyProviderProtocol,
userSessionStore: UserSessionStoreProtocol,
appSettings: AppSettings,
appHooks: AppHooks) {
sessionDirectories = .init()
passphrase = encryptionKeyProvider.generateKey().base64EncodedString()
self.userSessionStore = userSessionStore
self.appSettings = appSettings
self.appHooks = appHooks
}
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, QRCodeLoginServiceError> {
let qrData: QrCodeData
do {
qrData = try QrCodeData.fromBytes(bytes: data)
} catch {
MXLog.error("QRCode decode error: \(error)")
return .failure(.invalidQRCode)
}
guard let scannedServerName = qrData.serverName() else {
MXLog.error("The QR code is from a device that is not yet signed in.")
return .failure(.deviceNotSignedIn)
}
if !appSettings.allowOtherAccountProviders, !appSettings.accountProviders.contains(scannedServerName) {
MXLog.error("The scanned device's server is not allowed: \(scannedServerName)")
return .failure(.providerNotAllowed(scannedProvider: scannedServerName, allowedProviders: appSettings.accountProviders))
}
let listener = SDKListener { [weak self] progress in
self?.qrLoginProgressSubject.send(progress)
}
do {
let client = try await makeClientBuilder().build(homeserverAddress: scannedServerName)
try await client.loginWithQrCode(qrCodeData: qrData,
oidcConfiguration: appSettings.oidcConfiguration.rustValue,
progressListener: listener)
MXLog.info("Sliding sync: \(client.slidingSyncVersion())")
return await userSession(for: client)
} catch let error as HumanQrLoginError {
MXLog.error("QRCode login error: \(error)")
return .failure(error.serviceError)
} catch {
MXLog.error("QRCode login unknown error: \(error)")
return .failure(.unknown)
}
}
// MARK: - Private
private func makeClientBuilder() -> AuthenticationClientBuilder {
// Use a fresh session directory each time the user scans a QR code to ensure caches
// (e.g. server versions) are always fresh in case a different server is used.
rotateSessionDirectory()
return AuthenticationClientBuilder(sessionDirectories: sessionDirectories,
passphrase: passphrase,
clientSessionDelegate: userSessionStore.clientSessionDelegate,
appSettings: appSettings,
appHooks: appHooks)
}
private func rotateSessionDirectory() {
sessionDirectories.delete()
sessionDirectories = .init()
}
private func userSession(for client: ClientProtocol) async -> Result<UserSessionProtocol, QRCodeLoginServiceError> {
switch await userSessionStore.userSession(for: client, sessionDirectories: sessionDirectories, passphrase: passphrase) {
case .success(let session):
return .success(session)
case .failure(let error):
MXLog.error("QRCode login failed error: \(error)")
return .failure(.failedLoggingIn)
}
}
}
private extension HumanQrLoginError {
var serviceError: QRCodeLoginServiceError {
switch self {
case .Cancelled:
return .cancelled
case .ConnectionInsecure:
return .connectionInsecure
case .Declined:
return .declined
case .LinkingNotSupported:
return .linkingNotSupported
case .Expired:
return .expired
case .SlidingSyncNotAvailable:
return .deviceNotSupported
case .OtherDeviceNotSignedIn:
return .deviceNotSignedIn
case .Unknown, .OidcMetadataInvalid:
return .unknown
}
}
}
@@ -1,32 +0,0 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Combine
import Foundation
import MatrixRustSDK
enum QRCodeLoginServiceError: Error {
case failedLoggingIn
case invalidQRCode
case providerNotAllowed(scannedProvider: String, allowedProviders: [String])
case cancelled
case connectionInsecure
case declined
case linkingNotSupported
case expired
case deviceNotSupported
case deviceNotSignedIn
case unknown
}
// sourcery: AutoMockable
protocol QRCodeLoginServiceProtocol {
var qrLoginProgressPublisher: AnyPublisher<QrLoginProgress, Never> { get }
func loginWithQRCode(data: Data) async -> Result<UserSessionProtocol, QRCodeLoginServiceError>
}