Allow the app to be configured to bypass the server selection screen. (#4131)

* Make account provider configuration more flexible.

- Change defaultHomeserverAddress to an array of providers (needs UI).
- Add allowOtherAccountProviders to prevent the user from manually entering a provider.

* Refactor QR code scan failures into a common type.

* Validate scanned QR codes against the allowed account providers.

* Hide the login flow on the QR code screen when restricted.
This commit is contained in:
Doug
2025-05-20 11:09:50 +01:00
committed by GitHub
parent 57007f9eea
commit 33fcb8e667
26 changed files with 326 additions and 111 deletions

View File

@@ -92,7 +92,8 @@ final class AppSettings {
// MARK: - Hooks
// swiftlint:disable:next function_parameter_count
func override(defaultHomeserverAddress: String,
func override(accountProviders: [String],
allowOtherAccountProviders: Bool,
pushGatewayBaseURL: URL,
oidcRedirectURL: URL,
websiteURL: URL,
@@ -109,7 +110,8 @@ final class AppSettings {
bugReportApplicationID: String,
analyticsTermsURL: URL?,
mapTilerConfiguration: MapTilerConfiguration) {
self.defaultHomeserverAddress = defaultHomeserverAddress
self.accountProviders = accountProviders
self.allowOtherAccountProviders = allowOtherAccountProviders
self.pushGatewayBaseURL = pushGatewayBaseURL
self.oidcRedirectURL = oidcRedirectURL
self.websiteURL = websiteURL
@@ -151,9 +153,13 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.seenInvites, defaultValue: [], storageType: .userDefaults(store))
var seenInvites: Set<String>
/// The default homeserver address used. This is intentionally a string without a scheme
/// so that it can be passed to Rust as a ServerName for well-known discovery.
private(set) var defaultHomeserverAddress = "matrix.org"
/// The initial set of account providers shown to the user in the authentication flow.
///
/// Account provider is the friendly term for the server name. It should not contain an `https` prefix and should
/// match the last part of the user ID. For example `example.com` and not `https://matrix.example.com`.
private(set) var accountProviders = ["matrix.org"]
/// Whether or not the user is allowed to manually enter their own account provider or must select from one of `defaultAccountProviders`.
private(set) var allowOtherAccountProviders = true
/// The task identifier used for background app refresh. Also used in main target's the Info.plist
let backgroundAppRefreshTaskIdentifier = "io.element.elementx.background.refresh"

View File

@@ -31,8 +31,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
/// The initial screen shown when you first launch the app.
case startScreen
/// The initial screen with a provisioning link applied.
case provisionedStartScreen
/// The initial screen with the selection of account provider having been restricted.
case restrictedStartScreen
/// The screen used for the whole QR Code flow.
case qrCodeLoginScreen
@@ -55,7 +55,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
enum Event: EventType {
/// The flow is being started.
case start
case start(allowOtherAccountProviders: Bool)
/// Modify the flow using the provisioning parameters in the `userInfo`.
case applyProvisioningParameters
@@ -68,7 +68,7 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
case reportProblem
/// The QR login flow was aborted.
case cancelledLoginWithQR
case cancelledLoginWithQR(previousState: State)
/// The user aborted manual login.
case cancelledServerConfirmation
@@ -127,12 +127,17 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
}
func start() {
stateMachine.tryEvent(.start)
stateMachine.tryEvent(.start(allowOtherAccountProviders: appSettings.allowOtherAccountProviders))
}
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
switch appRoute {
case .accountProvisioningLink(let provisioningParameters):
guard appSettings.allowOtherAccountProviders else {
MXLog.error("Provisioning links not allowed, ignoring.")
return
}
if stateMachine.state != .startScreen {
clearRoute(animated: animated)
}
@@ -145,11 +150,11 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
func clearRoute(animated: Bool) {
switch stateMachine.state {
case .initial, .startScreen, .provisionedStartScreen:
case .initial, .startScreen, .restrictedStartScreen:
break
case .qrCodeLoginScreen:
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR) // Needs to be handled manually.
stateMachine.tryEvent(.cancelledLoginWithQR(previousState: .initial)) // Needs to be handled manually.
case .serverConfirmationScreen:
navigationStackCoordinator.popToRoot(animated: animated)
case .serverSelectionScreen:
@@ -170,22 +175,27 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
// MARK: - Setup
private func configureStateMachine() {
stateMachine.addRoutes(event: .start, transitions: [.initial => .startScreen]) { [weak self] _ in
stateMachine.addRoutes(event: .start(allowOtherAccountProviders: true), transitions: [.initial => .startScreen]) { [weak self] _ in
self?.showStartScreen(fromState: .initial)
}
stateMachine.addRoutes(event: .start(allowOtherAccountProviders: false), transitions: [.initial => .restrictedStartScreen]) { [weak self] _ in
self?.showStartScreen(fromState: .initial)
}
stateMachine.addRoutes(event: .applyProvisioningParameters, transitions: [.initial => .provisionedStartScreen,
.startScreen => .provisionedStartScreen]) { [weak self] context in
stateMachine.addRoutes(event: .applyProvisioningParameters, transitions: [.initial => .restrictedStartScreen,
.startScreen => .restrictedStartScreen]) { [weak self] context in
guard let provisioningParameters = context.userInfo as? AccountProvisioningParameters else { fatalError("The authentication configuration is missing.") }
self?.showStartScreen(fromState: context.fromState, applying: provisioningParameters)
}
// QR Code
stateMachine.addRoutes(event: .loginWithQR, transitions: [.startScreen => .qrCodeLoginScreen]) { [weak self] _ in
self?.showQRCodeLoginScreen()
stateMachine.addRoutes(event: .loginWithQR, transitions: [.startScreen => .qrCodeLoginScreen,
.restrictedStartScreen => .qrCodeLoginScreen]) { [weak self] context in
self?.showQRCodeLoginScreen(fromState: context.fromState)
}
stateMachine.addRoutes(event: .cancelledLoginWithQR, transitions: [.qrCodeLoginScreen => .startScreen])
stateMachine.addRoutes(event: .cancelledLoginWithQR(previousState: .startScreen), transitions: [.qrCodeLoginScreen => .startScreen])
stateMachine.addRoutes(event: .cancelledLoginWithQR(previousState: .restrictedStartScreen), transitions: [.qrCodeLoginScreen => .restrictedStartScreen])
// Manual Authentication
@@ -206,31 +216,31 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.addRoutes(event: .dismissedServerSelection, transitions: [.serverSelectionScreen => .serverConfirmationScreen])
stateMachine.addRoutes(event: .continueWithOIDC, transitions: [.serverConfirmationScreen => .oidcAuthentication,
.provisionedStartScreen => .oidcAuthentication]) { [weak self] context in
.restrictedStartScreen => .oidcAuthentication]) { [weak self] context in
guard let (oidcData, window) = context.userInfo as? (OIDCAuthorizationDataProxy, UIWindow) else {
fatalError("Missing the OIDC data and presentation anchor.")
}
self?.showOIDCAuthentication(oidcData: oidcData, presentationAnchor: window, fromState: context.fromState)
}
stateMachine.addRoutes(event: .cancelledOIDCAuthentication(previousState: .serverConfirmationScreen), transitions: [.oidcAuthentication => .serverConfirmationScreen])
stateMachine.addRoutes(event: .cancelledOIDCAuthentication(previousState: .provisionedStartScreen), transitions: [.oidcAuthentication => .provisionedStartScreen])
stateMachine.addRoutes(event: .cancelledOIDCAuthentication(previousState: .restrictedStartScreen), transitions: [.oidcAuthentication => .restrictedStartScreen])
stateMachine.addRoutes(event: .continueWithPassword, transitions: [.serverConfirmationScreen => .loginScreen,
.provisionedStartScreen => .loginScreen]) { [weak self] context in
.restrictedStartScreen => .loginScreen]) { [weak self] context in
let loginHint = context.userInfo as? String
self?.showLoginScreen(loginHint: loginHint, fromState: context.fromState)
}
stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .serverConfirmationScreen), transitions: [.loginScreen => .serverConfirmationScreen])
stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .provisionedStartScreen), transitions: [.loginScreen => .provisionedStartScreen])
stateMachine.addRoutes(event: .cancelledPasswordLogin(previousState: .restrictedStartScreen), transitions: [.loginScreen => .restrictedStartScreen])
// Bug Report
stateMachine.addRoutes(event: .reportProblem, transitions: [.startScreen => .bugReportFlow,
.provisionedStartScreen => .bugReportFlow]) { [weak self] context in
.restrictedStartScreen => .bugReportFlow]) { [weak self] context in
self?.startBugReportFlow(fromState: context.fromState)
}
stateMachine.addRoutes(event: .bugReportFlowComplete(previousState: .startScreen), transitions: [.bugReportFlow => .startScreen])
stateMachine.addRoutes(event: .bugReportFlowComplete(previousState: .provisionedStartScreen), transitions: [.bugReportFlow => .provisionedStartScreen])
stateMachine.addRoutes(event: .bugReportFlowComplete(previousState: .restrictedStartScreen), transitions: [.bugReportFlow => .restrictedStartScreen])
// Completion
@@ -292,8 +302,9 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
// MARK: - QR Code
private func showQRCodeLoginScreen() {
private func showQRCodeLoginScreen(fromState: State) {
let coordinator = QRCodeLoginScreenCoordinator(parameters: .init(qrCodeLoginService: qrCodeLoginService,
canSignInManually: fromState != .restrictedStartScreen,
orientationManager: appMediator.windowManager,
appMediator: appMediator))
coordinator.actionsPublisher.sink { [weak self] action in
@@ -303,11 +314,11 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
switch action {
case .signInManually:
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR)
stateMachine.tryEvent(.cancelledLoginWithQR(previousState: fromState))
stateMachine.tryEvent(.confirmServer(.login))
case .cancel:
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.cancelledLoginWithQR)
stateMachine.tryEvent(.cancelledLoginWithQR(previousState: fromState))
case .done(let userSession):
navigationStackCoordinator.setSheetCoordinator(nil)
// Since the qr code login flow includes verification

View File

@@ -32,14 +32,30 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
self.appSettings = appSettings
self.userIndicatorController = userIndicatorController
// We only show the "Sign in to " button when using a provisioning link.
let showCreateAccountButton = appSettings.showCreateAccountButton && provisioningParameters == nil
let showQRCodeLoginButton = !ProcessInfo.processInfo.isiOSAppOnMac && provisioningParameters == nil
let isQRCodeScanningSupported = !ProcessInfo.processInfo.isiOSAppOnMac
super.init(initialViewState: AuthenticationStartScreenViewState(serverName: provisioningParameters?.accountProvider,
showCreateAccountButton: showCreateAccountButton,
showQRCodeLoginButton: showQRCodeLoginButton,
showReportProblemButton: isBugReportServiceEnabled))
let initialViewState = if !appSettings.allowOtherAccountProviders {
// We don't show the create account button when custom providers are disallowed.
// The assumption here being that if you're running a custom app, your users will already be created.
AuthenticationStartScreenViewState(serverName: appSettings.accountProviders.count == 1 ? appSettings.accountProviders[0] : nil,
showCreateAccountButton: false,
showQRCodeLoginButton: isQRCodeScanningSupported,
showReportProblemButton: isBugReportServiceEnabled)
} else if let provisioningParameters {
// We only show the "Sign in to " button when using a provisioning link.
AuthenticationStartScreenViewState(serverName: provisioningParameters.accountProvider,
showCreateAccountButton: false,
showQRCodeLoginButton: false,
showReportProblemButton: isBugReportServiceEnabled)
} else {
// The default configuration.
AuthenticationStartScreenViewState(serverName: nil,
showCreateAccountButton: appSettings.showCreateAccountButton,
showQRCodeLoginButton: isQRCodeScanningSupported,
showReportProblemButton: isBugReportServiceEnabled)
}
super.init(initialViewState: initialViewState)
}
override func process(viewAction: AuthenticationStartScreenViewAction) {
@@ -61,25 +77,31 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
// MARK: - Private
private func login() async {
if let provisioningParameters {
await configureProvisionedServer(with: provisioningParameters)
if !appSettings.allowOtherAccountProviders {
if appSettings.accountProviders.count == 1 {
await configureAccountProvider(appSettings.accountProviders[0])
} else {
fatalError("WIP: Account provider picker not implemented.")
}
} else if let provisioningParameters {
await configureAccountProvider(provisioningParameters.accountProvider, loginHint: provisioningParameters.loginHint)
} else {
actionsSubject.send(.login) // No need to configure anything here, continue the flow.
}
}
private func configureProvisionedServer(with provisioningParameters: AccountProvisioningParameters) async {
private func configureAccountProvider(_ accountProvider: String, loginHint: String? = nil) async {
startLoading()
defer { stopLoading() }
guard case .success = await authenticationService.configure(for: provisioningParameters.accountProvider, flow: .login) else {
guard case .success = await authenticationService.configure(for: accountProvider, flow: .login) else {
// As the server was provisioned, we don't worry about the specifics and show a generic error to the user.
displayError()
return
}
guard authenticationService.homeserver.value.loginMode.supportsOIDCFlow else {
actionsSubject.send(.loginDirectlyWithPassword(loginHint: provisioningParameters.loginHint))
actionsSubject.send(.loginDirectlyWithPassword(loginHint: loginHint))
return
}
@@ -88,7 +110,7 @@ class AuthenticationStartScreenViewModel: AuthenticationStartScreenViewModelType
return
}
switch await authenticationService.urlForOIDCLogin(loginHint: provisioningParameters.loginHint) {
switch await authenticationService.urlForOIDCLogin(loginHint: loginHint) {
case .success(let oidcData):
actionsSubject.send(.loginDirectlyWithOIDC(data: oidcData, window: window))
case .failure:

View File

@@ -10,6 +10,7 @@ import SwiftUI
struct QRCodeLoginScreenCoordinatorParameters {
let qrCodeLoginService: QRCodeLoginServiceProtocol
let canSignInManually: Bool
let orientationManager: OrientationManagerProtocol
let appMediator: AppMediatorProtocol
}
@@ -33,6 +34,7 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
init(parameters: QRCodeLoginScreenCoordinatorParameters) {
viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: parameters.qrCodeLoginService,
canSignInManually: parameters.canSignInManually,
appMediator: parameters.appMediator)
orientationManager = parameters.orientationManager
}

View File

@@ -15,6 +15,9 @@ enum QRCodeLoginScreenViewModelAction {
struct QRCodeLoginScreenViewState: BindableState {
var state: QRCodeLoginState = .initial
/// Whether or not it is possible for the screen to start the manual sign in flow. This was added to avoid
/// having to handle server configuration when ``AppSettings.allowOtherAccountProviders`` is false.
let canSignInManually: Bool
private static let initialStateListItem3AttributedText = {
let boldPlaceholder = "{bold}"
@@ -74,14 +77,43 @@ enum QRCodeLoginState: Equatable {
}
enum QRCodeLoginScanningState: Equatable {
/// the qr code is scanning
/// the QR code is scanning
case scanning
/// the qr code has been detected and is being processed
/// the QR code has been detected and is being processed
case connecting
/// the qr code has been processed and is invalid
case invalid
/// the qr code has been processed but it belongs to a device not signed in,
case deviceNotSignedIn
/// the QR code was scanned, but an error occurred.
case scanFailed(Error)
enum Error: Equatable {
/// the QR code has been processed and is invalid
case invalid
/// the QR code has been processed but it is for an account provider that isn't allowed.
case notAllowed(scannedProvider: String, allowedProviders: [String])
/// the QR code has been processed but it belongs to a device not signed in
case deviceNotSignedIn
var title: String {
switch self {
case .invalid:
L10n.screenQrCodeLoginInvalidScanStateSubtitle
case .notAllowed(let scannedProvider, _):
L10n.screenChangeServerErrorUnauthorizedHomeserverTitle(scannedProvider)
case .deviceNotSignedIn:
L10n.screenQrCodeLoginDeviceNotSignedInScanStateSubtitle
}
}
var description: String {
switch self {
case .invalid:
L10n.screenQrCodeLoginInvalidScanStateDescription
case .notAllowed(_, let allowedProviders):
L10n.screenChangeServerErrorUnauthorizedHomeserverContent(allowedProviders.formatted(.list(type: .and)))
case .deviceNotSignedIn:
L10n.screenQrCodeLoginDeviceNotSignedInScanStateDescription
}
}
}
}
enum QRCodeLoginDisplayCodeState: Equatable {
@@ -100,39 +132,22 @@ enum QRCodeLoginState: Equatable {
var isScanning: Bool {
switch self {
case .scan(let state):
return state == .scanning
default:
return false
case .scan(.scanning): true
default: false
}
}
var isError: Bool {
switch self {
case .error:
return true
case let .scan(state):
switch state {
case .invalid, .deviceNotSignedIn:
return true
default:
return false
}
default:
return false
case .error, .scan(.scanFailed): true
default: false
}
}
var shouldDisplayCancelButton: Bool {
switch self {
case .initial:
return true
case .scan:
return true
case .error(let error):
return error == .noCameraPermission
default:
return false
case .initial, .scan, .error(.noCameraPermission): true
default: false
}
}
}

View File

@@ -22,10 +22,11 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
private var scanTask: Task<Void, Never>?
init(qrCodeLoginService: QRCodeLoginServiceProtocol,
canSignInManually: Bool,
appMediator: AppMediatorProtocol) {
self.qrCodeLoginService = qrCodeLoginService
self.appMediator = appMediator
super.init(initialViewState: QRCodeLoginScreenViewState())
super.init(initialViewState: QRCodeLoginScreenViewState(canSignInManually: canSignInManually))
setupSubscriptions()
}
@@ -119,9 +120,11 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
MXLog.error("Failed to scan the QR code: \(error)")
switch error {
case .invalidQRCode:
state.state = .scan(.invalid)
state.state = .scan(.scanFailed(.invalid))
case .providerNotAllowed(let scannedProvider, let allowedProviders):
state.state = .scan(.scanFailed(.notAllowed(scannedProvider: scannedProvider, allowedProviders: allowedProviders)))
case .deviceNotSignedIn:
state.state = .scan(.deviceNotSignedIn)
state.state = .scan(.scanFailed(.deviceNotSignedIn))
case .cancelled:
state.state = .error(.cancelled)
case .connectionInsecure:
@@ -140,15 +143,15 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
}
/// Only for mocking initial states
fileprivate init(state: QRCodeLoginState) {
fileprivate init(state: QRCodeLoginState, canSignInManually: Bool) {
qrCodeLoginService = QRCodeLoginServiceMock()
appMediator = AppMediatorMock.default
super.init(initialViewState: .init(state: state))
super.init(initialViewState: .init(state: state, canSignInManually: canSignInManually))
}
}
extension QRCodeLoginScreenViewModel {
static func mock(state: QRCodeLoginState) -> QRCodeLoginScreenViewModel {
QRCodeLoginScreenViewModel(state: state)
static func mock(state: QRCodeLoginState, canSignInManually: Bool = true) -> QRCodeLoginScreenViewModel {
QRCodeLoginScreenViewModel(state: state, canSignInManually: canSignInManually)
}
}

View File

@@ -169,7 +169,7 @@ struct QRCodeLoginScreen: View {
Button("") { }
.buttonStyle(.compound(.primary))
.hidden()
case .invalid:
case .scanFailed(let error):
VStack(spacing: 16) {
Button(L10n.screenQrCodeLoginInvalidScanStateRetryButton) {
context.send(viewAction: .startScan)
@@ -177,7 +177,7 @@ struct QRCodeLoginScreen: View {
.buttonStyle(.compound(.primary))
VStack(spacing: 4) {
Label(L10n.screenQrCodeLoginInvalidScanStateSubtitle,
Label(error.title,
icon: \.errorSolid,
iconSize: .medium,
relativeTo: .compound.bodyMDSemibold)
@@ -185,33 +185,12 @@ struct QRCodeLoginScreen: View {
.font(.compound.bodyMDSemibold)
.foregroundColor(.compound.textCriticalPrimary)
Text(L10n.screenQrCodeLoginInvalidScanStateDescription)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodySM)
.multilineTextAlignment(.center)
}
}
case .deviceNotSignedIn:
VStack(spacing: 16) {
Button(L10n.screenQrCodeLoginInvalidScanStateRetryButton) {
context.send(viewAction: .startScan)
}
.buttonStyle(.compound(.primary))
VStack(spacing: 4) {
Label(L10n.screenQrCodeLoginDeviceNotSignedInScanStateSubtitle,
icon: \.errorSolid,
iconSize: .medium,
relativeTo: .compound.bodyMDSemibold)
.labelStyle(.custom(spacing: 10))
.font(.compound.bodyMDSemibold)
.foregroundColor(.compound.textCriticalPrimary)
Text(L10n.screenQrCodeLoginDeviceNotSignedInScanStateDescription)
Text(error.description)
.foregroundColor(.compound.textSecondary)
.font(.compound.bodySM)
.multilineTextAlignment(.center)
}
.fixedSize(horizontal: false, vertical: true)
}
}
}
@@ -375,10 +354,12 @@ struct QRCodeLoginScreen: View {
.buttonStyle(.compound(.primary))
case .linkingNotSupported:
VStack(spacing: 16) {
Button(L10n.screenOnboardingSignInManually) {
context.send(viewAction: .signInManually)
if context.viewState.canSignInManually {
Button(L10n.screenOnboardingSignInManually) {
context.send(viewAction: .signInManually)
}
.buttonStyle(.compound(.primary))
}
.buttonStyle(.compound(.primary))
Button(L10n.actionCancel) {
context.send(viewAction: .cancel)
@@ -425,9 +406,13 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
static let connectingStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.connecting))
static let invalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.invalid))
static let invalidStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.invalid)))
static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.deviceNotSignedIn))
static let notAllowedStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.notAllowed(scannedProvider: "evil.com",
allowedProviders: ["example.com",
"server.net"]))))
static let deviceNotSignedInStateViewModel = QRCodeLoginScreenViewModel.mock(state: .scan(.scanFailed(.deviceNotSignedIn)))
// Display Code
static let deviceCodeStateViewModel = QRCodeLoginScreenViewModel.mock(state: .displayCode(.deviceCode("12")))
@@ -440,6 +425,7 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
static let connectionNotSecureStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.connectionNotSecure))
static let linkingUnsupportedStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.linkingNotSupported))
static let linkingUnsupportedRestrictedFlowViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.linkingNotSupported), canSignInManually: false)
static let cancelledStateViewModel = QRCodeLoginScreenViewModel.mock(state: .error(.cancelled))
@@ -464,6 +450,9 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
QRCodeLoginScreen(context: invalidStateViewModel.context)
.previewDisplayName("Invalid")
QRCodeLoginScreen(context: notAllowedStateViewModel.context)
.previewDisplayName("Not allowed")
QRCodeLoginScreen(context: deviceNotSignedInStateViewModel.context)
.previewDisplayName("Device not signed in")
@@ -481,6 +470,8 @@ struct QRCodeLoginScreen_Previews: PreviewProvider, TestablePreview {
QRCodeLoginScreen(context: linkingUnsupportedStateViewModel.context)
.previewDisplayName("Linking unsupported")
QRCodeLoginScreen(context: linkingUnsupportedRestrictedFlowViewModel.context)
.previewDisplayName("Linking unsupported restricted flow")
QRCodeLoginScreen(context: cancelledStateViewModel.context)
.previewDisplayName("Cancelled")

View File

@@ -36,7 +36,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
self.appHooks = appHooks
// When updating these, don't forget to update the reset method too.
homeserverSubject = .init(LoginHomeserver(address: appSettings.defaultHomeserverAddress, loginMode: .unknown))
homeserverSubject = .init(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown))
flow = .login
}
@@ -146,7 +146,7 @@ class AuthenticationService: AuthenticationServiceProtocol {
}
func reset() {
homeserverSubject.send(LoginHomeserver(address: appSettings.defaultHomeserverAddress, loginMode: .unknown))
homeserverSubject.send(LoginHomeserver(address: appSettings.accountProviders[0], loginMode: .unknown))
flow = .login
client = nil
}

View File

@@ -43,6 +43,16 @@ final class QRCodeLoginService: QRCodeLoginServiceProtocol {
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)
}

View File

@@ -13,6 +13,7 @@ import MatrixRustSDK
enum QRCodeLoginServiceError: Error {
case failedLoggingIn
case invalidQRCode
case providerNotAllowed(scannedProvider: String, allowedProviders: [String])
case cancelled
case connectionInsecure
case declined

View File

@@ -120,13 +120,36 @@ class MockScreen: Identifiable {
userIndicatorController: ServiceLocator.shared.userIndicatorController))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .authenticationFlow, .provisionedAuthenticationFlow:
case .authenticationFlow, .provisionedAuthenticationFlow, .singleProviderAuthenticationFlow:
let appSettings: AppSettings! = ServiceLocator.shared.settings
if id == .singleProviderAuthenticationFlow {
appSettings.override(accountProviders: ["example.com"],
allowOtherAccountProviders: false,
pushGatewayBaseURL: appSettings.pushGatewayBaseURL,
oidcRedirectURL: appSettings.oidcRedirectURL,
websiteURL: appSettings.websiteURL,
logoURL: appSettings.logoURL,
copyrightURL: appSettings.copyrightURL,
acceptableUseURL: appSettings.acceptableUseURL,
privacyURL: appSettings.privacyURL,
encryptionURL: appSettings.encryptionURL,
deviceVerificationURL: appSettings.deviceVerificationURL,
chatBackupDetailsURL: appSettings.chatBackupDetailsURL,
identityPinningViolationDetailsURL: appSettings.identityPinningViolationDetailsURL,
elementWebHosts: appSettings.elementWebHosts,
accountProvisioningHost: appSettings.accountProvisioningHost,
bugReportApplicationID: appSettings.bugReportApplicationID,
analyticsTermsURL: appSettings.analyticsTermsURL,
mapTilerConfiguration: appSettings.mapTilerConfiguration)
}
let flowCoordinator = AuthenticationFlowCoordinator(authenticationService: AuthenticationService.mock,
qrCodeLoginService: QRCodeLoginServiceMock(),
bugReportService: BugReportServiceMock(.init()),
navigationRootCoordinator: navigationRootCoordinator,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
appSettings: appSettings,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
flowCoordinator.start()

View File

@@ -17,6 +17,7 @@ enum UITestsScreenIdentifier: String {
case appLockSetupFlowUnlock
case authenticationFlow
case provisionedAuthenticationFlow
case singleProviderAuthenticationFlow
case bugReport
case createPoll
case createRoom

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4624b04341dd9beefcf689a39b047c9d6ebea797d68996e4ea669b45d6494faf
size 112957

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:58643fdc4bc95fda9cdc91d33b9b98973c6b67de256ca800a2babece767808d2
size 134945

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:75e324ee678e506005f852ac377fc02281ee60335c3f996df7528335b9cc255d
size 66133

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b5407ba8180475d70b3aee7fdcb6a2e8e84fb6977a29b1b223566c3d31872ec2
size 93700

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ecff6f7fb1e12a42c25761e0b83658550e62e6ec4a8fca1e239b63d5851cf31
size 122577

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:77a3fbc2dd4fd8f6c45c205bff44b5544b4e1d368e0457f6e9ae4b17ecca9b7f
size 132753

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:033db8a5d3b9b0cd70b8c8b01856dddf5703f11be3027fe5ac1d1c105ab8f1b6
size 67172

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a355d8275b8be088bfab2b61a630fc164eb6ea3537ad335435df80b899615c48
size 84715

View File

@@ -161,6 +161,35 @@ class AuthenticationFlowCoordinatorUITests: XCTestCase {
// Splash Screen: Tap get started button
app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap()
// No server selection should be shown here
// Login Screen: Wait for continue button to appear
let continueButton = app.buttons[A11yIdentifiers.loginScreen.continue]
XCTAssertTrue(continueButton.waitForExistence(timeout: 2.0))
// Login Screen: Enter valid credentials
app.textFields[A11yIdentifiers.loginScreen.emailUsername].clearAndTypeText("alice\n", app: app)
app.secureTextFields[A11yIdentifiers.loginScreen.password].clearAndTypeText("12345678", app: app)
// Login Screen: Tap next
app.buttons[A11yIdentifiers.loginScreen.continue].tap()
}
func testSingleProviderLoginWithPassword() async throws {
// Given the authentication flow with a single supported server.
let app = Application.launch(.singleProviderAuthenticationFlow)
// Then the start screen should be configured appropriately.
try await app.assertScreenshot()
// Check the bug report flow works.
try await verifyReportBugButton(app)
// Splash Screen: Tap get started button
app.buttons[A11yIdentifiers.authenticationStartScreen.signIn].tap()
// No server selection should be shown here
// Login Screen: Wait for continue button to appear
let continueButton = app.buttons[A11yIdentifiers.loginScreen.continue]
XCTAssertTrue(continueButton.waitForExistence(timeout: 2.0))

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9f9b378ca59a6976c0a5ac40d1b670f5ed813334095f538e63cae6c539311fb0
size 1337155

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:208e50e192c288069dd95ee88619b593b96b203a511c92539c27ddf0982a5aa4
size 1210790

View File

@@ -18,6 +18,16 @@ class AuthenticationStartScreenViewModelTests: XCTestCase {
var viewModel: AuthenticationStartScreenViewModel!
var context: AuthenticationStartScreenViewModel.Context { viewModel.context }
override func setUp() {
AppSettings.resetAllSettings()
let appSettings = AppSettings()
ServiceLocator.shared.register(appSettings: appSettings)
}
override func tearDown() {
AppSettings.resetAllSettings()
}
func testInitialState() async throws {
// Given a view model that has no provisioning parameters.
setupViewModel()
@@ -80,6 +90,44 @@ class AuthenticationStartScreenViewModelTests: XCTestCase {
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .password)
}
func testSingleProviderOIDCState() async throws {
// Given a view model that for an app that only allows the use of a single provider that supports OIDC.
setAllowedAccountProviders(["company.com"])
setupViewModel()
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .unknown)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When tapping the login button the authentication service should be used and the screen
// should request to continue the flow without any server selection needed.
let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithOIDC }
context.send(viewAction: .login)
try await deferred.fulfill()
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintReceivedArguments?.prompt, .consent)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintReceivedArguments?.loginHint, nil)
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .oidc(supportsCreatePrompt: false))
}
func testSingleProviderPasswordState() async throws {
// Given a view model that for an app that only allows the use of a single provider that does not support OIDC.
setAllowedAccountProviders(["company.com"])
setupViewModel(supportsOIDC: false)
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .unknown)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When tapping the login button the authentication service should be used and the screen
// should request to continue the flow without any server selection needed.
let deferred = deferFulfillment(viewModel.actions) { $0.isLoginDirectlyWithPassword }
context.send(viewAction: .login)
try await deferred.fulfill()
// Then a call to configure service should be made.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .password)
}
// MARK: - Helpers
private func setupViewModel(provisioningParameters: AccountProvisioningParameters? = nil, supportsOIDC: Bool = true) {
@@ -106,6 +154,28 @@ class AuthenticationStartScreenViewModelTests: XCTestCase {
// Add a fake window in order for the OIDC flow to continue
viewModel.context.send(viewAction: .updateWindow(UIWindow()))
}
private func setAllowedAccountProviders(_ providers: [String]) {
let appSettings: AppSettings! = ServiceLocator.shared.settings
appSettings.override(accountProviders: providers,
allowOtherAccountProviders: false,
pushGatewayBaseURL: appSettings.pushGatewayBaseURL,
oidcRedirectURL: appSettings.oidcRedirectURL,
websiteURL: appSettings.websiteURL,
logoURL: appSettings.logoURL,
copyrightURL: appSettings.copyrightURL,
acceptableUseURL: appSettings.acceptableUseURL,
privacyURL: appSettings.privacyURL,
encryptionURL: appSettings.encryptionURL,
deviceVerificationURL: appSettings.deviceVerificationURL,
chatBackupDetailsURL: appSettings.chatBackupDetailsURL,
identityPinningViolationDetailsURL: appSettings.identityPinningViolationDetailsURL,
elementWebHosts: appSettings.elementWebHosts,
accountProvisioningHost: appSettings.accountProvisioningHost,
bugReportApplicationID: appSettings.bugReportApplicationID,
analyticsTermsURL: appSettings.analyticsTermsURL,
mapTilerConfiguration: appSettings.mapTilerConfiguration)
}
}
extension AuthenticationStartScreenViewModelAction {

View File

@@ -29,6 +29,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
qrServiceMock.underlyingQrLoginProgressPublisher = qrProgressSubject.eraseToAnyPublisher()
appMediatorMock = AppMediatorMock.default
viewModel = QRCodeLoginScreenViewModel(qrCodeLoginService: qrServiceMock,
canSignInManually: true,
appMediator: appMediatorMock)
}