// // Copyright 2025 Element Creations Ltd. // Copyright 2022-2025 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 class AuthenticationService: AuthenticationServiceProtocol { private var client: ClientProtocol? private var sessionDirectories: SessionDirectories private let passphrase: String private let userSessionStore: UserSessionStoreProtocol private let classicAppManager: ClassicAppManagerProtocol? private let clientFactory: AuthenticationClientFactoryProtocol private let appSettings: AppSettings private let appHooks: AppHooks private let homeserverSubject: CurrentValueSubject var homeserver: CurrentValuePublisher { homeserverSubject.asCurrentValuePublisher() } private(set) var flow: AuthenticationFlow init(userSessionStore: UserSessionStoreProtocol, encryptionKeyProvider: EncryptionKeyProviderProtocol, classicAppManager: ClassicAppManagerProtocol?, clientFactory: AuthenticationClientFactoryProtocol = AuthenticationClientFactory(), appSettings: AppSettings, appHooks: AppHooks) { sessionDirectories = .init() passphrase = encryptionKeyProvider.generateKey().base64EncodedString() self.userSessionStore = userSessionStore self.classicAppManager = classicAppManager self.clientFactory = clientFactory self.appSettings = appSettings self.appHooks = appHooks do { if let classicAppManager { // Just let the app manager log the detected account for now. _ = try classicAppManager.loadAccounts() } else { MXLog.info("Classic App not configured, skipping loadAccounts.") } } catch { // This should show an alert: "We have detected an older version of Element Classic, but no bueno!" MXLog.error("Failed loading accounts from the Classic app: \(error)") } // When updating these, don't forget to update the reset method too. homeserverSubject = .init(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown)) flow = .login } // MARK: - Public func configure(for homeserverAddress: String, flow: AuthenticationFlow) async -> Result { do { var homeserver = LoginHomeserver(address: homeserverAddress, loginMode: .unknown) let client = try await makeClient(homeserverAddress: homeserverAddress) let loginDetails = await client.homeserverLoginDetails() homeserver.loginMode = if loginDetails.supportsOidcLogin() { .oidc(supportsCreatePrompt: loginDetails.supportedOidcPrompts().contains(.create)) } else if loginDetails.supportsPasswordLogin() { .password } else { .unsupported } if flow == .login, homeserver.loginMode == .unsupported { return .failure(.loginNotSupported) } if flow == .register, !homeserver.loginMode.supportsOIDCFlow { return .failure(.registrationNotSupported) } self.client = client self.flow = flow homeserverSubject.send(homeserver) return .success(()) } catch ClientBuildError.WellKnownDeserializationError(let error) { MXLog.error("The user entered a server with an invalid well-known file: \(error)") return .failure(.invalidWellKnown(error)) } catch ClientBuildError.SlidingSyncVersion(let error) { MXLog.info("User entered a homeserver that isn't configured for sliding sync: \(error)") return .failure(.slidingSyncNotAvailable) } catch RemoteSettingsError.elementProRequired(let serverName) { return .failure(.elementProRequired(serverName: serverName)) } catch { MXLog.error("Failed configuring a server: \(error)") return .failure(.invalidHomeserverAddress) } } func urlForOIDCLogin(loginHint: String?) async -> Result { guard let client else { return .failure(.oidcError(.urlFailure)) } do { // The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429 // let prompt: OidcPrompt = flow == .register ? .create : .consent let oidcData = try await client.urlForOidc(oidcConfiguration: appSettings.oidcConfiguration.rustValue, prompt: .consent, loginHint: loginHint, deviceId: nil, additionalScopes: nil) return .success(OIDCAuthorizationDataProxy(underlyingData: oidcData)) } catch { MXLog.error("Failed to get URL for OIDC login: \(error)") return .failure(.oidcError(.urlFailure)) } } func abortOIDCLogin(data: OIDCAuthorizationDataProxy) async { guard let client else { return } MXLog.info("Aborting OIDC login.") await client.abortOidcAuth(authorizationData: data.underlyingData) } func loginWithOIDCCallback(_ callbackURL: URL) async -> Result { guard let client else { return .failure(.failedLoggingIn) } do { try await client.loginWithOidcCallback(callbackUrl: callbackURL.absoluteString) return await userSession(for: client) } catch OidcError.Cancelled { return .failure(.oidcError(.userCancellation)) } catch { MXLog.error("Login with OIDC failed: \(error)") return .failure(.failedLoggingIn) } } func login(username: String, password: String, initialDeviceName: String?, deviceID: String?) async -> Result { guard let client else { return .failure(.failedLoggingIn) } do { try await client.login(username: username, password: password, initialDeviceName: initialDeviceName, deviceId: deviceID) let refreshToken = try? client.session().refreshToken if refreshToken != nil { MXLog.warning("Refresh token found for a non oidc session, can't restore session, logging out") _ = try? await client.logout() return .failure(.sessionTokenRefreshNotSupported) } return await userSession(for: client) } catch let ClientError.MatrixApi(errorKind, _, _, _) { MXLog.error("Failed logging in with error kind: \(errorKind)") switch errorKind { case .forbidden: return .failure(.invalidCredentials) case .userDeactivated: return .failure(.accountDeactivated) default: return .failure(.failedLoggingIn) } } catch { MXLog.error("Failed logging in with error: \(error)") return .failure(.failedLoggingIn) } } func loginWithQRCode(data: Data) -> QRLoginProgressPublisher { let progressSubject = CurrentValueSubject(.starting) let qrData: QrCodeData do { qrData = try QrCodeData.fromBytes(bytes: data) } catch { MXLog.error("QRCode decode error: \(error)") progressSubject.send(completion: .failure(.qrCodeError(.invalidQRCode))) return progressSubject.asCurrentValuePublisher() } // At some stage the SDK will have a `qrCodeData.intent` which we should check before continuing here. // Note the equivalent check will also happen for linking a device by QR in the LinkNewDeviceService. guard let scannedServerName = qrData.serverName() else { MXLog.error("The QR code is from a device that is not yet signed in.") progressSubject.send(completion: .failure(.qrCodeError(.deviceNotSignedIn))) return progressSubject.asCurrentValuePublisher() } if !appSettings.allowOtherAccountProviders, !appSettings.accountProviders.contains(scannedServerName) { MXLog.error("The scanned device's server is not allowed: \(scannedServerName)") progressSubject.send(completion: .failure(.qrCodeError(.providerNotAllowed(scannedProvider: scannedServerName, allowedProviders: appSettings.accountProviders)))) return progressSubject.asCurrentValuePublisher() } let listener = SDKListener { progress in guard let progress = QRLoginProgress(rustProgress: progress) else { return } progressSubject.send(progress) } Task { do { let client = try await makeClient(homeserverAddress: scannedServerName) let qrCodeHandler = client.newLoginWithQrCodeHandler(oidcConfiguration: appSettings.oidcConfiguration.rustValue) try await qrCodeHandler.scan(qrCodeData: qrData, progressListener: listener) switch await userSession(for: client) { case .success(let userSession): progressSubject.send(.signedIn(userSession)) case .failure(let error): progressSubject.send(completion: .failure(error)) } } catch let error as HumanQrLoginError { MXLog.error("QRCode login error: \(error)") progressSubject.send(completion: .failure(error.serviceError)) } catch RemoteSettingsError.elementProRequired(let serverName) { progressSubject.send(completion: .failure(.elementProRequired(serverName: serverName))) } catch { MXLog.error("QRCode login unknown error: \(error)") progressSubject.send(completion: .failure(.qrCodeError(.unknown))) } } return progressSubject.asCurrentValuePublisher() } func reset() { homeserverSubject.send(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown)) flow = .login client = nil } // MARK: - Private 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() let client = try await clientFactory.makeClient(homeserverAddress: homeserverAddress, sessionDirectories: sessionDirectories, passphrase: passphrase, clientSessionDelegate: userSessionStore.clientSessionDelegate, appSettings: appSettings, appHooks: appHooks) try await appHooks.remoteSettingsHook.initializeCache(using: client, applyingTo: appSettings).get() return client } private func rotateSessionDirectory() { sessionDirectories.delete() sessionDirectories = .init() } private func userSession(for client: ClientProtocol) async -> Result { switch await userSessionStore.userSession(for: client, sessionDirectories: sessionDirectories, passphrase: passphrase) { case .success(let clientProxy): return .success(clientProxy) case .failure: return .failure(.failedLoggingIn) } } } 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, .NotFound: // The most likely cause of a .NotFound is that the rendezvous session expired on the server side .qrCodeError(.expired) case .SlidingSyncNotAvailable: .qrCodeError(.slidingSyncNotAvailable) case .OtherDeviceNotSignedIn: .qrCodeError(.deviceNotSignedIn) case .UnsupportedQrCodeType: .qrCodeError(.invalidQRCode) case .Unknown, .OidcMetadataInvalid, .CheckCodeAlreadySent, .CheckCodeCannotBeSent: .qrCodeError(.unknown) } } } // MARK: - Mocks extension AuthenticationService { static var mock: AuthenticationService { AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()), encryptionKeyProvider: EncryptionKeyProvider(), classicAppManager: nil, clientFactory: AuthenticationClientFactoryMock(configuration: .init()), appSettings: ServiceLocator.shared.settings, appHooks: AppHooks()) } }