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
@@ -55,7 +55,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
private let appRouteURLParser: AppRouteURLParser
@Consumable private var storedAppRoute: AppRoute?
private var storedAppRoute: AppRoute?
@Consumable private var storedInlineReply: (roomID: String, message: String)?
@Consumable private var storedRoomsToAwait: Set<String>?
@@ -219,6 +219,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
if let route = appRouteURLParser.route(from: url) {
switch route {
case .accountProvisioningLink:
handleAppRoute(route)
case .genericCallLink(let url):
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(route, animated: true)
@@ -508,17 +510,23 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
appSettings: appSettings,
appHooks: appHooks)
authenticationFlowCoordinator = AuthenticationFlowCoordinator(authenticationService: authenticationService,
qrCodeLoginService: qrCodeLoginService,
bugReportService: ServiceLocator.shared.bugReportService,
navigationRootCoordinator: navigationRootCoordinator,
appMediator: appMediator,
appSettings: appSettings,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
authenticationFlowCoordinator?.delegate = self
let coordinator = AuthenticationFlowCoordinator(authenticationService: authenticationService,
qrCodeLoginService: qrCodeLoginService,
bugReportService: ServiceLocator.shared.bugReportService,
navigationRootCoordinator: navigationRootCoordinator,
appMediator: appMediator,
appSettings: appSettings,
analytics: ServiceLocator.shared.analytics,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
coordinator.delegate = self
authenticationFlowCoordinator?.start()
authenticationFlowCoordinator = coordinator
coordinator.start()
if storedAppRoute?.isAuthenticationRoute == true,
let storedAppRoute = storedAppRoute.take() {
coordinator.handleAppRoute(storedAppRoute, animated: false)
}
}
private func runPostSessionSetupTasks() async {
@@ -530,7 +538,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
userSession.clientProxy.roomsToAwait = storedRoomsToAwait
}
if let storedAppRoute {
if storedAppRoute?.isAuthenticationRoute == false,
let storedAppRoute = storedAppRoute.take() {
userSessionFlowCoordinator.handleAppRoute(storedAppRoute, animated: false)
}
@@ -796,9 +805,22 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
}
private func handleAppRoute(_ appRoute: AppRoute) {
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active)
} else {
var handled = false
switch appRoute {
case .accountProvisioningLink:
if let authenticationFlowCoordinator {
authenticationFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active)
handled = true
}
default:
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(appRoute, animated: appMediator.appState == .active)
handled = true
}
}
if !handled {
storedAppRoute = appRoute
}
}
@@ -105,6 +105,7 @@ final class AppSettings {
chatBackupDetailsURL: URL,
identityPinningViolationDetailsURL: URL,
elementWebHosts: [String],
accountProvisioningHost: String,
bugReportApplicationID: String,
analyticsTermsURL: URL?,
mapTilerConfiguration: MapTilerConfiguration) {
@@ -121,6 +122,7 @@ final class AppSettings {
self.chatBackupDetailsURL = chatBackupDetailsURL
self.identityPinningViolationDetailsURL = identityPinningViolationDetailsURL
self.elementWebHosts = elementWebHosts
self.accountProvisioningHost = accountProvisioningHost
self.bugReportApplicationID = bugReportApplicationID
self.analyticsTermsURL = analyticsTermsURL
self.mapTilerConfiguration = mapTilerConfiguration
@@ -176,6 +178,8 @@ final class AppSettings {
private(set) var identityPinningViolationDetailsURL: URL = "https://element.io/help#encryption18"
/// Any domains that Element web may be hosted on - used for handling links.
private(set) var elementWebHosts = ["app.element.io", "staging.element.io", "develop.element.io"]
/// The domain that account provisioning links will be hosted on - used for handling the links.
private(set) var accountProvisioningHost = "mobile.element.io"
@UserPreference(key: UserDefaultsKeys.appAppearance, defaultValue: .system, storageType: .userDefaults(store))
var appAppearance: AppAppearance
@@ -8,7 +8,12 @@
import Foundation
import MatrixRustSDK
enum AppRoute: Equatable, Hashable {
// MARK: - Routes
enum AppRoute: Hashable {
/// An account provisioning link generated externally.
case accountProvisioningLink(AccountProvisioningParameters)
/// The app's home screen.
case roomList
/// A room, shown as the root of the stack (popping any child rooms).
@@ -43,6 +48,25 @@ enum AppRoute: Equatable, Hashable {
case chatBackupSettings
/// An external share request e.g. from the ShareExtension
case share(ShareExtensionPayload)
/// Whether or not the route should be handled by the authentication flow.
var isAuthenticationRoute: Bool {
switch self {
case .accountProvisioningLink: true
default: false
}
}
}
/// The parameters parsed out of a provisioning link that can be applied to the authentication flow for a streamlined onboarding experience.
struct AccountProvisioningParameters: Hashable {
let accountProvider: String
let loginHint: String?
enum CodingKeys: String, CodingKey {
case accountProvider = "account_provider"
case loginHint = "login_hint"
}
}
struct AppRouteURLParser {
@@ -53,6 +77,7 @@ struct AppRouteURLParser {
AppGroupURLParser(),
MatrixPermalinkParser(),
ElementWebURLParser(domains: appSettings.elementWebHosts),
AccountProvisioningURLParser(domain: appSettings.accountProvisioningHost),
ElementCallURLParser()
]
}
@@ -68,6 +93,8 @@ struct AppRouteURLParser {
}
}
// MARK: - URL Parsers
/// Represents a type that can parse a `URL` into an `AppRoute`.
///
/// The following Universal Links are missing parsers.
@@ -76,7 +103,8 @@ protocol URLParser {
func route(from url: URL) -> AppRoute?
}
struct AppGroupURLParser: URLParser {
/// The parser for routes that come from app extensions such as the Share Extension.
private struct AppGroupURLParser: URLParser {
func route(from url: URL) -> AppRoute? {
guard let scheme = url.scheme,
scheme == InfoPlistReader.app.appScheme,
@@ -101,7 +129,7 @@ struct AppGroupURLParser: URLParser {
}
/// The parser for Element Call links. This always returns a `.genericCallLink`.
struct ElementCallURLParser: URLParser {
private struct ElementCallURLParser: URLParser {
private let knownHosts = ["call.element.io"]
private let customSchemeURLQueryParameterName = "url"
@@ -139,7 +167,7 @@ struct ElementCallURLParser: URLParser {
}
}
struct MatrixPermalinkParser: URLParser {
private struct MatrixPermalinkParser: URLParser {
func route(from url: URL) -> AppRoute? {
guard let entity = parseMatrixEntityFrom(uri: url.absoluteString) else { return nil }
@@ -158,7 +186,7 @@ struct MatrixPermalinkParser: URLParser {
}
}
struct ElementWebURLParser: URLParser {
private struct ElementWebURLParser: URLParser {
let domains: [String]
let paths = ["room", "user"]
@@ -187,3 +215,21 @@ struct ElementWebURLParser: URLParser {
return url
}
}
/// The parser for user provisioning links.
private struct AccountProvisioningURLParser: URLParser {
let domain: String
func route(from url: URL) -> AppRoute? {
guard url.host() == domain else { return nil }
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let serverName = components.queryItems?.first(where: { $0.name == AccountProvisioningParameters.CodingKeys.accountProvider.rawValue })?.value else {
return nil
}
let loginHint = components.queryItems?.first { $0.name == AccountProvisioningParameters.CodingKeys.loginHint.rawValue }?.value
return .accountProvisioningLink(.init(accountProvider: serverName, loginHint: loginHint))
}
}