Add support for Account Provisioning links. (#4108)

* Add support for account provisioning links and route them to the authentication flow.

* Use the provisioning parameters to configure the authentication flow.

* Add UI tests for the provisioned authentication flow.

* Record new preview snapshots.

* Add unit tests.

* Make the domain configurable in the app settings.

* Use the loginHint in the login screen too.
This commit is contained in:
Doug
2025-05-12 13:28:34 +01:00
committed by GitHub
parent e795d07477
commit 876618d557
40 changed files with 698 additions and 159 deletions
@@ -9,6 +9,117 @@ import XCTest
@testable import ElementX
@MainActor
class AuthenticationStartScreenViewModelTests: XCTestCase {
// Nothing to test, the view model has no mutable state.
var clientBuilderFactory: AuthenticationClientBuilderFactoryMock!
var client: ClientSDKMock!
var authenticationService: AuthenticationServiceProtocol!
var viewModel: AuthenticationStartScreenViewModel!
var context: AuthenticationStartScreenViewModel.Context { viewModel.context }
func testInitialState() async throws {
// Given a view model that has no provisioning parameters.
setupViewModel()
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .unknown)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When tapping any of the buttons on the screen
let actions: [(AuthenticationStartScreenViewAction, AuthenticationStartScreenViewModelAction)] = [
(.loginWithQR, .loginWithQR),
(.login, .login),
(.register, .register),
(.reportProblem, .reportProblem)
]
for action in actions {
let deferred = deferFulfillment(viewModel.actions) { $0 == action.1 }
context.send(viewAction: action.0)
try await deferred.fulfill()
// Then the authentication service should not be used yet.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .unknown)
}
}
func testProvisionedOIDCState() async throws {
// Given a view model that has been provisioned with a server that supports OIDC.
setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"))
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, "user@company.com")
XCTAssertEqual(authenticationService.homeserver.value.loginMode, .oidc(supportsCreatePrompt: false))
}
func testProvisionedPasswordState() async throws {
// Given a view model that has been provisioned with a server that does not support OIDC.
setupViewModel(provisioningParameters: .init(accountProvider: "company.com", loginHint: "user@company.com"), 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) {
// Manually create a configuration as the default homeserver address setting is immutable.
client = ClientSDKMock(configuration: .init(oidcLoginURL: supportsOIDC ? "https://account.company.com/authorize" : nil,
supportsOIDCCreatePrompt: false,
supportsPasswordLogin: true))
let configuration = AuthenticationClientBuilderMock.Configuration(homeserverClients: ["company.com": client],
qrCodeClient: client)
clientBuilderFactory = AuthenticationClientBuilderFactoryMock(configuration: .init(builderConfiguration: configuration))
authenticationService = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
encryptionKeyProvider: EncryptionKeyProvider(),
clientBuilderFactory: clientBuilderFactory,
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks())
viewModel = AuthenticationStartScreenViewModel(authenticationService: authenticationService,
provisioningParameters: provisioningParameters,
isBugReportServiceEnabled: true,
appSettings: ServiceLocator.shared.settings,
userIndicatorController: UserIndicatorControllerMock())
// Add a fake window in order for the OIDC flow to continue
viewModel.context.send(viewAction: .updateWindow(UIWindow()))
}
}
extension AuthenticationStartScreenViewModelAction {
var isLoginDirectlyWithOIDC: Bool {
switch self {
case .loginDirectlyWithOIDC: true
default: false
}
}
var isLoginDirectlyWithPassword: Bool {
switch self {
case .loginDirectlyWithPassword: true
default: false
}
}
}
@@ -17,24 +17,6 @@ class LoginScreenViewModelTests: XCTestCase {
var clientBuilderFactory: AuthenticationClientBuilderFactoryMock!
var service: AuthenticationServiceProtocol!
private func setupViewModel(homeserverAddress: String = "example.com") async {
clientBuilderFactory = AuthenticationClientBuilderFactoryMock(configuration: .init())
service = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
encryptionKeyProvider: EncryptionKeyProvider(),
clientBuilderFactory: clientBuilderFactory,
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks())
guard case .success = await service.configure(for: homeserverAddress, flow: .login) else {
XCTFail("A valid server should be configured for the test.")
return
}
viewModel = LoginScreenViewModel(authenticationService: service,
userIndicatorController: UserIndicatorControllerMock(),
analytics: ServiceLocator.shared.analytics)
}
func testBasicServer() async {
// Given the view model configured for a basic server example.com that only supports password authentication.
await setupViewModel()
@@ -160,4 +142,36 @@ class LoginScreenViewModelTests: XCTestCase {
// Then the view state should be updated to show an alert.
XCTAssertEqual(context.alertInfo?.id, .unknown, "An alert should be shown to the user.")
}
func testLoginHint() async throws {
await setupViewModel(loginHint: "")
XCTAssertEqual(context.username, "")
await setupViewModel(loginHint: "alice")
XCTAssertEqual(context.username, "alice")
await setupViewModel(loginHint: "mxid:@alice:example.com")
XCTAssertEqual(context.username, "@alice:example.com")
}
// MARK: - Helpers
private func setupViewModel(homeserverAddress: String = "example.com", loginHint: String? = nil) async {
clientBuilderFactory = AuthenticationClientBuilderFactoryMock(configuration: .init())
service = AuthenticationService(userSessionStore: UserSessionStoreMock(configuration: .init()),
encryptionKeyProvider: EncryptionKeyProvider(),
clientBuilderFactory: clientBuilderFactory,
appSettings: ServiceLocator.shared.settings,
appHooks: AppHooks())
guard case .success = await service.configure(for: homeserverAddress, flow: .login) else {
XCTFail("A valid server should be configured for the test.")
return
}
viewModel = LoginScreenViewModel(authenticationService: service,
loginHint: loginHint,
userIndicatorController: UserIndicatorControllerMock(),
analytics: ServiceLocator.shared.analytics)
}
}
@@ -23,7 +23,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
setupViewModel(authenticationFlow: .login)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -32,8 +32,8 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
// Then a call to configure service should be made.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .consent)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintReceivedArguments?.prompt, .consent)
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
}
@@ -46,7 +46,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
}
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -55,8 +55,8 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
// Then the configured homeserver should be used and no additional client should be built.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .consent)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintReceivedArguments?.prompt, .consent)
}
func testConfirmRegisterWithoutConfiguration() async throws {
@@ -64,7 +64,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
setupViewModel(authenticationFlow: .register)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -73,7 +73,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
// Then a call to configure service should be made.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1)
// The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429
// XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .create)
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
@@ -88,7 +88,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
}
XCTAssertEqual(service.homeserver.value.loginMode, .oidc(supportsCreatePrompt: true))
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithOIDC }
@@ -99,7 +99,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
// The create prompt is broken: https://github.com/element-hq/matrix-authentication-service/issues/3429
// XCTAssertEqual(client.urlForOidcOidcConfigurationPromptReceivedArguments?.prompt, .create)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 1)
}
func testConfirmPasswordLoginWithoutConfiguration() async throws {
@@ -107,7 +107,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
setupViewModel(authenticationFlow: .login, supportsOIDC: false)
XCTAssertEqual(service.homeserver.value.loginMode, .unknown)
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
@@ -116,7 +116,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
// Then a call to configure service should be made, but not for the OIDC URL.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
XCTAssertEqual(service.homeserver.value.loginMode, .password)
}
@@ -129,7 +129,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
}
XCTAssertEqual(service.homeserver.value.loginMode, .password)
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
// When continuing from the confirmation screen.
let deferred = deferFulfillment(viewModel.actions) { $0.isContinueWithPassword }
@@ -138,7 +138,7 @@ class ServerConfirmationScreenViewModelTests: XCTestCase {
// Then the configured homeserver should be used and no additional client should be built, nor a call to get the OIDC URL.
XCTAssertEqual(clientBuilderFactory.makeBuilderSessionDirectoriesPassphraseClientSessionDelegateAppSettingsAppHooksCallsCount, 1)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptCallsCount, 0)
XCTAssertEqual(client.urlForOidcOidcConfigurationPromptLoginHintCallsCount, 0)
}
func testRegistrationNotSupportedAlert() async throws {