mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 20:09:04 +00:00
Use a universal link for the OIDC callback. (#1681)
This commit is contained in:
@@ -45,6 +45,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
|
|
||||||
private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
|
private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
|
||||||
private var authenticationCoordinator: AuthenticationCoordinator?
|
private var authenticationCoordinator: AuthenticationCoordinator?
|
||||||
|
private var softLogoutCoordinator: SoftLogoutScreenCoordinator?
|
||||||
|
|
||||||
private let backgroundTaskService: BackgroundTaskServiceProtocol
|
private let backgroundTaskService: BackgroundTaskServiceProtocol
|
||||||
|
|
||||||
@@ -145,6 +146,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
|
|
||||||
if let route = appRouteURLParser.route(from: url) {
|
if let route = appRouteURLParser.route(from: url) {
|
||||||
switch route {
|
switch route {
|
||||||
|
case .oidcCallback(let url):
|
||||||
|
if stateMachine.state == .softLogout {
|
||||||
|
softLogoutCoordinator?.handleOIDCRedirectURL(url)
|
||||||
|
} else {
|
||||||
|
authenticationCoordinator?.handleOIDCRedirectURL(url)
|
||||||
|
}
|
||||||
case .genericCallLink(let url):
|
case .genericCallLink(let url):
|
||||||
if let userSessionFlowCoordinator {
|
if let userSessionFlowCoordinator {
|
||||||
userSessionFlowCoordinator.handleAppRoute(route, animated: true)
|
userSessionFlowCoordinator.handleAppRoute(route, animated: true)
|
||||||
@@ -158,11 +165,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Until we have an OIDC callback AppRoute, handle it manually.
|
|
||||||
if url.absoluteString.starts(with: appSettings.oidcRedirectURL.absoluteString) {
|
|
||||||
MXLog.error("OIDC callback through Universal Links not implemented.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +172,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
|
|
||||||
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) {
|
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) {
|
||||||
self.userSession = userSession
|
self.userSession = userSession
|
||||||
|
self.authenticationCoordinator = nil
|
||||||
stateMachine.processEvent(.createdUserSession)
|
stateMachine.processEvent(.createdUserSession)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,8 +308,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
break
|
break
|
||||||
case (_, .signOut(let isSoft), .signingOut):
|
case (_, .signOut(let isSoft), .signingOut):
|
||||||
self.logout(isSoft: isSoft)
|
self.logout(isSoft: isSoft)
|
||||||
case (.signingOut, .completedSigningOut(let isSoft), .signedOut):
|
case (.signingOut, .completedSigningOut, .signedOut):
|
||||||
self.presentSplashScreen(isSoftLogout: isSoft)
|
self.presentSplashScreen(isSoftLogout: false)
|
||||||
|
case (.signingOut, .showSoftLogout, .softLogout):
|
||||||
|
self.presentSplashScreen(isSoftLogout: true)
|
||||||
case (.signedIn, .clearCache, .initial):
|
case (.signedIn, .clearCache, .initial):
|
||||||
self.clearCache()
|
self.clearCache()
|
||||||
default:
|
default:
|
||||||
@@ -370,7 +375,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
keyBackupNeeded: false,
|
keyBackupNeeded: false,
|
||||||
userIndicatorController: ServiceLocator.shared.userIndicatorController)
|
userIndicatorController: ServiceLocator.shared.userIndicatorController)
|
||||||
let coordinator = SoftLogoutScreenCoordinator(parameters: parameters)
|
let coordinator = SoftLogoutScreenCoordinator(parameters: parameters)
|
||||||
|
self.softLogoutCoordinator = coordinator
|
||||||
coordinator.actions
|
coordinator.actions
|
||||||
.sink { [weak self] action in
|
.sink { [weak self] action in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -378,8 +383,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
switch action {
|
switch action {
|
||||||
case .signedIn(let session):
|
case .signedIn(let session):
|
||||||
self.userSession = session
|
self.userSession = session
|
||||||
|
self.softLogoutCoordinator = nil
|
||||||
stateMachine.processEvent(.createdUserSession)
|
stateMachine.processEvent(.createdUserSession)
|
||||||
case .clearAllData:
|
case .clearAllData:
|
||||||
|
self.softLogoutCoordinator = nil
|
||||||
stateMachine.processEvent(.signOut(isSoft: false))
|
stateMachine.processEvent(.signOut(isSoft: false))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -437,7 +444,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
userSessionFlowCoordinator?.stop()
|
userSessionFlowCoordinator?.stop()
|
||||||
|
|
||||||
guard !isSoft else {
|
guard !isSoft else {
|
||||||
stateMachine.processEvent(.completedSigningOut(isSoft: isSoft))
|
stateMachine.processEvent(.showSoftLogout)
|
||||||
hideLoadingIndicator()
|
hideLoadingIndicator()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -459,7 +466,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
|
|||||||
ServiceLocator.shared.analytics.optOut()
|
ServiceLocator.shared.analytics.optOut()
|
||||||
ServiceLocator.shared.analytics.resetConsentState()
|
ServiceLocator.shared.analytics.resetConsentState()
|
||||||
|
|
||||||
stateMachine.processEvent(.completedSigningOut(isSoft: isSoft))
|
stateMachine.processEvent(.completedSigningOut)
|
||||||
|
|
||||||
// Handle OIDC's RP-Initiated Logout if needed. Don't fallback to an ASWebAuthenticationSession
|
// Handle OIDC's RP-Initiated Logout if needed. Don't fallback to an ASWebAuthenticationSession
|
||||||
// as it looks weird to show an alert to the user asking them to sign in to their provider.
|
// as it looks weird to show an alert to the user asking them to sign in to their provider.
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ class AppCoordinatorStateMachine {
|
|||||||
enum State: StateType {
|
enum State: StateType {
|
||||||
/// The initial state, used before the AppCoordinator starts
|
/// The initial state, used before the AppCoordinator starts
|
||||||
case initial
|
case initial
|
||||||
/// Showing the login screen
|
/// Showing the authentication flow
|
||||||
case signedOut
|
case signedOut
|
||||||
|
/// Showing the soft logout flow
|
||||||
|
case softLogout
|
||||||
/// Opening an existing session.
|
/// Opening an existing session.
|
||||||
case restoringSession
|
case restoringSession
|
||||||
|
|
||||||
@@ -45,15 +47,17 @@ class AppCoordinatorStateMachine {
|
|||||||
/// Restoring session failed.
|
/// Restoring session failed.
|
||||||
case failedRestoringSession
|
case failedRestoringSession
|
||||||
|
|
||||||
/// A session has been created
|
/// A session has been created.
|
||||||
case createdUserSession
|
case createdUserSession
|
||||||
|
|
||||||
/// Request sign out
|
/// Request sign out.
|
||||||
case signOut(isSoft: Bool)
|
case signOut(isSoft: Bool)
|
||||||
/// Signing out completed
|
/// Request the soft logout screen.
|
||||||
case completedSigningOut(isSoft: Bool)
|
case showSoftLogout
|
||||||
|
/// Signing out completed.
|
||||||
|
case completedSigningOut
|
||||||
|
|
||||||
/// Request cache clearing
|
/// Request cache clearing.
|
||||||
case clearCache
|
case clearCache
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +74,15 @@ class AppCoordinatorStateMachine {
|
|||||||
|
|
||||||
private func configure() {
|
private func configure() {
|
||||||
stateMachine.addRoutes(event: .startWithAuthentication, transitions: [.initial => .signedOut])
|
stateMachine.addRoutes(event: .startWithAuthentication, transitions: [.initial => .signedOut])
|
||||||
stateMachine.addRoutes(event: .createdUserSession, transitions: [.signedOut => .signedIn])
|
stateMachine.addRoutes(event: .createdUserSession, transitions: [.signedOut => .signedIn,
|
||||||
|
.softLogout => .signedIn])
|
||||||
stateMachine.addRoutes(event: .startWithExistingSession, transitions: [.initial => .restoringSession])
|
stateMachine.addRoutes(event: .startWithExistingSession, transitions: [.initial => .restoringSession])
|
||||||
stateMachine.addRoutes(event: .createdUserSession, transitions: [.restoringSession => .signedIn])
|
stateMachine.addRoutes(event: .createdUserSession, transitions: [.restoringSession => .signedIn])
|
||||||
stateMachine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut])
|
stateMachine.addRoutes(event: .failedRestoringSession, transitions: [.restoringSession => .signedOut])
|
||||||
|
|
||||||
|
stateMachine.addRoutes(event: .completedSigningOut, transitions: [.signingOut(isSoft: false) => .signedOut])
|
||||||
|
stateMachine.addRoutes(event: .showSoftLogout, transitions: [.signingOut(isSoft: true) => .softLogout])
|
||||||
|
|
||||||
stateMachine.addRoutes(event: .clearCache, transitions: [.signedIn => .initial])
|
stateMachine.addRoutes(event: .clearCache, transitions: [.signedIn => .initial])
|
||||||
|
|
||||||
// Transitions with associated values need to be handled through `addRouteMapping`
|
// Transitions with associated values need to be handled through `addRouteMapping`
|
||||||
@@ -82,8 +90,6 @@ class AppCoordinatorStateMachine {
|
|||||||
switch (event, fromState) {
|
switch (event, fromState) {
|
||||||
case (.signOut(let isSoft), _):
|
case (.signOut(let isSoft), _):
|
||||||
return .signingOut(isSoft: isSoft)
|
return .signingOut(isSoft: isSoft)
|
||||||
case (.completedSigningOut, .signingOut):
|
|
||||||
return .signedOut
|
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,10 +120,15 @@ final class AppSettings {
|
|||||||
/// The URL that is opened when tapping the Learn more button on the sliding sync alert during authentication.
|
/// The URL that is opened when tapping the Learn more button on the sliding sync alert during authentication.
|
||||||
let slidingSyncLearnMoreURL: URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
|
let slidingSyncLearnMoreURL: URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
|
||||||
|
|
||||||
/// The redirect URL used for OIDC.
|
|
||||||
let oidcRedirectURL: URL = "io.element:/callback"
|
|
||||||
/// Any pre-defined static client registrations for OIDC issuers.
|
/// Any pre-defined static client registrations for OIDC issuers.
|
||||||
let oidcStaticRegistrations: [URL: String] = ["https://id.thirdroom.io/realms/thirdroom": "elementx"]
|
let oidcStaticRegistrations: [URL: String] = ["https://id.thirdroom.io/realms/thirdroom": "elementx"]
|
||||||
|
/// The redirect URL used for OIDC. The bundle ID suffix avoids app association conflicts between Element X, Nightly and PR builds.
|
||||||
|
let oidcRedirectURL = {
|
||||||
|
guard let bundleIDSuffix = InfoPlistReader.main.bundleIdentifier.split(separator: ".").last,
|
||||||
|
let redirectURL = URL(string: "https://mobile.element.io/oidc/\(bundleIDSuffix)")
|
||||||
|
else { fatalError("Failed creating a valid OIDC redirect URL.") }
|
||||||
|
return redirectURL
|
||||||
|
}()
|
||||||
|
|
||||||
/// The date that the call to `/login` completed successfully. This is used to put
|
/// The date that the call to `/login` completed successfully. This is used to put
|
||||||
/// a hard wall on the history of encrypted messages until we have key backup.
|
/// a hard wall on the history of encrypted messages until we have key backup.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ struct AppRouteURLParser {
|
|||||||
|
|
||||||
init(appSettings: AppSettings) {
|
init(appSettings: AppSettings) {
|
||||||
urlParsers = [
|
urlParsers = [
|
||||||
ElementAppURLParser(appSettings: appSettings),
|
OIDCCallbackURLParser(appSettings: appSettings),
|
||||||
ElementCallURLParser()
|
ElementCallURLParser()
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -50,34 +50,24 @@ struct AppRouteURLParser {
|
|||||||
///
|
///
|
||||||
/// The following Universal Links are missing parsers.
|
/// The following Universal Links are missing parsers.
|
||||||
/// - app.element.io
|
/// - app.element.io
|
||||||
|
/// - staging.element.io
|
||||||
|
/// - develop.element.io
|
||||||
/// - mobile.element.io
|
/// - mobile.element.io
|
||||||
protocol URLParser {
|
protocol URLParser {
|
||||||
func route(from url: URL) -> AppRoute?
|
func route(from url: URL) -> AppRoute?
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The parser for the main Element website.
|
/// The parser for the OIDC callback URL. This always returns a `.oidcCallback`.
|
||||||
struct ElementAppURLParser: URLParser {
|
struct OIDCCallbackURLParser: URLParser {
|
||||||
private let knownHosts = ["app.element.io", "staging.element.io", "develop.element.io"]
|
|
||||||
|
|
||||||
let appSettings: AppSettings
|
let appSettings: AppSettings
|
||||||
|
|
||||||
func route(from url: URL) -> AppRoute? {
|
func route(from url: URL) -> AppRoute? {
|
||||||
guard let host = url.host, knownHosts.contains(host) else {
|
guard url.absoluteString.starts(with: appSettings.oidcRedirectURL.absoluteString) else { return nil }
|
||||||
return nil
|
return .oidcCallback(url: url)
|
||||||
}
|
|
||||||
|
|
||||||
let pathComponents = url.pathComponents
|
|
||||||
|
|
||||||
// OIDC callback URL.
|
|
||||||
if pathComponents.count == 3, pathComponents[0] == "mobile", pathComponents[1] == "oidc" {
|
|
||||||
return .oidcCallback(url: url)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The parser for Element Call links. This always returns a `.genericCallLink`
|
/// The parser for Element Call links. This always returns a `.genericCallLink`.
|
||||||
struct ElementCallURLParser: URLParser {
|
struct ElementCallURLParser: URLParser {
|
||||||
private let knownHosts = ["call.element.io"]
|
private let knownHosts = ["call.element.io"]
|
||||||
private let customSchemeURLQueryParameterName = "url"
|
private let customSchemeURLQueryParameterName = "url"
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ class AuthenticationCoordinator: CoordinatorProtocol {
|
|||||||
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
private var oidcPresenter: OIDCAuthenticationPresenter?
|
||||||
|
|
||||||
weak var delegate: AuthenticationCoordinatorDelegate?
|
weak var delegate: AuthenticationCoordinatorDelegate?
|
||||||
|
|
||||||
init(authenticationService: AuthenticationServiceProxyProtocol,
|
init(authenticationService: AuthenticationServiceProxyProtocol,
|
||||||
@@ -53,6 +55,15 @@ class AuthenticationCoordinator: CoordinatorProtocol {
|
|||||||
func stop() {
|
func stop() {
|
||||||
stopLoading()
|
stopLoading()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleOIDCRedirectURL(_ url: URL) {
|
||||||
|
guard let oidcPresenter else {
|
||||||
|
MXLog.error("Failed to find an OIDC request in progress.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oidcPresenter.handleUniversalLinkCallback(url)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
@@ -167,12 +178,14 @@ class AuthenticationCoordinator: CoordinatorProtocol {
|
|||||||
let presenter = OIDCAuthenticationPresenter(authenticationService: authenticationService,
|
let presenter = OIDCAuthenticationPresenter(authenticationService: authenticationService,
|
||||||
oidcRedirectURL: appSettings.oidcRedirectURL,
|
oidcRedirectURL: appSettings.oidcRedirectURL,
|
||||||
presentationAnchor: presentationAnchor)
|
presentationAnchor: presentationAnchor)
|
||||||
|
self.oidcPresenter = presenter
|
||||||
switch await presenter.authenticate(using: oidcData) {
|
switch await presenter.authenticate(using: oidcData) {
|
||||||
case .success(let userSession):
|
case .success(let userSession):
|
||||||
userHasSignedIn(userSession: userSession)
|
userHasSignedIn(userSession: userSession)
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
handleError(error)
|
handleError(error)
|
||||||
}
|
}
|
||||||
|
oidcPresenter = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ class OIDCAuthenticationPresenter: NSObject {
|
|||||||
private let oidcRedirectURL: URL
|
private let oidcRedirectURL: URL
|
||||||
private let presentationAnchor: UIWindow
|
private let presentationAnchor: UIWindow
|
||||||
|
|
||||||
|
/// The data required to complete a request.
|
||||||
|
struct Request {
|
||||||
|
let session: ASWebAuthenticationSession
|
||||||
|
let oidcData: OIDCAuthenticationDataProxy
|
||||||
|
let continuation: CheckedContinuation<Result<UserSessionProtocol, AuthenticationServiceError>, Never>
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The current request in progress. This is a single use value and will be moved on access.
|
||||||
|
@Consumable private var request: Request?
|
||||||
|
|
||||||
init(authenticationService: AuthenticationServiceProxyProtocol, oidcRedirectURL: URL, presentationAnchor: UIWindow) {
|
init(authenticationService: AuthenticationServiceProxyProtocol, oidcRedirectURL: URL, presentationAnchor: UIWindow) {
|
||||||
self.authenticationService = authenticationService
|
self.authenticationService = authenticationService
|
||||||
self.oidcRedirectURL = oidcRedirectURL
|
self.oidcRedirectURL = oidcRedirectURL
|
||||||
@@ -35,41 +45,70 @@ class OIDCAuthenticationPresenter: NSObject {
|
|||||||
await withCheckedContinuation { continuation in
|
await withCheckedContinuation { continuation in
|
||||||
let session = ASWebAuthenticationSession(url: oidcData.url,
|
let session = ASWebAuthenticationSession(url: oidcData.url,
|
||||||
callbackURLScheme: oidcRedirectURL.scheme) { [weak self] url, error in
|
callbackURLScheme: oidcRedirectURL.scheme) { [weak self] url, error in
|
||||||
|
// This closure won't be called if the scheme is https, see handleUniversalLinkCallback for more info.
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
|
|
||||||
guard let url else {
|
guard let url else {
|
||||||
|
// Check for user cancellation to avoid showing an alert in that instance.
|
||||||
if let nsError = error as? NSError,
|
if let nsError = error as? NSError,
|
||||||
nsError.domain == ASWebAuthenticationSessionErrorDomain,
|
nsError.domain == ASWebAuthenticationSessionErrorDomain,
|
||||||
nsError.code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
|
nsError.code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
|
||||||
continuation.resume(returning: .failure(AuthenticationServiceError.oidcError(.userCancellation)))
|
self.completeAuthentication(throwing: .oidcError(.userCancellation))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
continuation.resume(returning: .failure(AuthenticationServiceError.oidcError(.unknown)))
|
self.completeAuthentication(throwing: .oidcError(.unknown))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
completeAuthentication(callbackURL: url, data: oidcData, continuation: continuation)
|
completeAuthentication(callbackURL: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
session.prefersEphemeralWebBrowserSession = false
|
session.prefersEphemeralWebBrowserSession = false
|
||||||
session.presentationContextProvider = self
|
session.presentationContextProvider = self
|
||||||
|
|
||||||
|
request = Request(session: session, oidcData: oidcData, continuation: continuation)
|
||||||
|
|
||||||
session.start()
|
session.start()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func completeAuthentication(callbackURL: URL,
|
/// This method will be used if the `appSettings.oidcRedirectURL`'s scheme is `https`.
|
||||||
data: OIDCAuthenticationDataProxy,
|
/// When using a custom scheme, the redirect will be handled by the web auth session's closure.
|
||||||
continuation: CheckedContinuation<Result<UserSessionProtocol, AuthenticationServiceError>, Never>) {
|
func handleUniversalLinkCallback(_ url: URL) {
|
||||||
|
completeAuthentication(callbackURL: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes the authentication by exchanging the callback URL for a user session.
|
||||||
|
private func completeAuthentication(callbackURL: URL) {
|
||||||
|
guard let request else {
|
||||||
|
MXLog.error("Failed to complete authentication. Missing request.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if callbackURL.scheme?.starts(with: "http") == true {
|
||||||
|
request.session.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
switch await authenticationService.loginWithOIDCCallback(callbackURL, data: data) {
|
switch await authenticationService.loginWithOIDCCallback(callbackURL, data: request.oidcData) {
|
||||||
case .success(let userSession):
|
case .success(let userSession):
|
||||||
continuation.resume(returning: .success(userSession))
|
request.continuation.resume(returning: .success(userSession))
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
continuation.resume(returning: .failure(error))
|
request.continuation.resume(returning: .failure(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Aborts the authentication with the supplied error.
|
||||||
|
private func completeAuthentication(throwing error: AuthenticationServiceError) {
|
||||||
|
guard let request else {
|
||||||
|
MXLog.error("Failed to throw authentication error. Missing request.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
request.continuation.resume(returning: .failure(error))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: ASWebAuthenticationPresentationContextProviding
|
// MARK: ASWebAuthenticationPresentationContextProviding
|
||||||
|
|||||||
+12
@@ -48,6 +48,7 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
|
|||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
|
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
|
||||||
|
private var oidcPresenter: OIDCAuthenticationPresenter?
|
||||||
|
|
||||||
var actions: AnyPublisher<SoftLogoutScreenCoordinatorResult, Never> {
|
var actions: AnyPublisher<SoftLogoutScreenCoordinatorResult, Never> {
|
||||||
actionsSubject.eraseToAnyPublisher()
|
actionsSubject.eraseToAnyPublisher()
|
||||||
@@ -92,6 +93,15 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
|
|||||||
AnyView(SoftLogoutScreen(context: viewModel.context))
|
AnyView(SoftLogoutScreen(context: viewModel.context))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleOIDCRedirectURL(_ url: URL) {
|
||||||
|
guard let oidcPresenter else {
|
||||||
|
MXLog.error("Failed to find an OIDC request in progress.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
oidcPresenter.handleUniversalLinkCallback(url)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private
|
// MARK: - Private
|
||||||
|
|
||||||
private static let loadingIndicatorIdentifier = "SoftLogoutLoading"
|
private static let loadingIndicatorIdentifier = "SoftLogoutLoading"
|
||||||
@@ -151,12 +161,14 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
|
|||||||
let presenter = OIDCAuthenticationPresenter(authenticationService: parameters.authenticationService,
|
let presenter = OIDCAuthenticationPresenter(authenticationService: parameters.authenticationService,
|
||||||
oidcRedirectURL: ServiceLocator.shared.settings.oidcRedirectURL,
|
oidcRedirectURL: ServiceLocator.shared.settings.oidcRedirectURL,
|
||||||
presentationAnchor: presentationAnchor)
|
presentationAnchor: presentationAnchor)
|
||||||
|
self.oidcPresenter = presenter
|
||||||
switch await presenter.authenticate(using: oidcData) {
|
switch await presenter.authenticate(using: oidcData) {
|
||||||
case .success(let userSession):
|
case .success(let userSession):
|
||||||
actionsSubject.send(.signedIn(userSession))
|
actionsSubject.send(.signedIn(userSession))
|
||||||
case .failure(let error):
|
case .failure(let error):
|
||||||
handleError(error)
|
handleError(error)
|
||||||
}
|
}
|
||||||
|
self.oidcPresenter = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,20 +19,29 @@ import XCTest
|
|||||||
@testable import ElementX
|
@testable import ElementX
|
||||||
|
|
||||||
class AppRouteURLParserTests: XCTestCase {
|
class AppRouteURLParserTests: XCTestCase {
|
||||||
|
var appSettings: AppSettings!
|
||||||
|
var appRouteURLParser: AppRouteURLParser!
|
||||||
|
|
||||||
|
override func setUp() {
|
||||||
|
AppSettings.reset()
|
||||||
|
appSettings = AppSettings()
|
||||||
|
appRouteURLParser = AppRouteURLParser(appSettings: appSettings)
|
||||||
|
}
|
||||||
|
|
||||||
func testElementCallRoutes() {
|
func testElementCallRoutes() {
|
||||||
guard let url = URL(string: "https://call.element.io/test") else {
|
guard let url = URL(string: "https://call.element.io/test") else {
|
||||||
XCTFail("URL invalid")
|
XCTFail("URL invalid")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(AppRouteURLParser(appSettings: ServiceLocator.shared.settings).route(from: url), AppRoute.genericCallLink(url: url))
|
XCTAssertEqual(appRouteURLParser.route(from: url), AppRoute.genericCallLink(url: url))
|
||||||
|
|
||||||
guard let customSchemeURL = URL(string: "io.element.call:/?url=https%3A%2F%2Fcall.element.io%2Ftest") else {
|
guard let customSchemeURL = URL(string: "io.element.call:/?url=https%3A%2F%2Fcall.element.io%2Ftest") else {
|
||||||
XCTFail("URL invalid")
|
XCTFail("URL invalid")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(AppRouteURLParser(appSettings: ServiceLocator.shared.settings).route(from: customSchemeURL), AppRoute.genericCallLink(url: url))
|
XCTAssertEqual(appRouteURLParser.route(from: customSchemeURL), AppRoute.genericCallLink(url: url))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCustomDomainUniversalLinkCallRoutes() {
|
func testCustomDomainUniversalLinkCallRoutes() {
|
||||||
@@ -41,7 +50,7 @@ class AppRouteURLParserTests: XCTestCase {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(AppRouteURLParser(appSettings: ServiceLocator.shared.settings).route(from: url), nil)
|
XCTAssertEqual(appRouteURLParser.route(from: url), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCustomSchemeLinkCallRoutes() {
|
func testCustomSchemeLinkCallRoutes() {
|
||||||
@@ -61,7 +70,7 @@ class AppRouteURLParserTests: XCTestCase {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(AppRouteURLParser(appSettings: ServiceLocator.shared.settings).route(from: customSchemeURL), AppRoute.genericCallLink(url: url))
|
XCTAssertEqual(appRouteURLParser.route(from: customSchemeURL), AppRoute.genericCallLink(url: url))
|
||||||
}
|
}
|
||||||
|
|
||||||
func testHttpCustomSchemeLinkCallRoutes() {
|
func testHttpCustomSchemeLinkCallRoutes() {
|
||||||
@@ -70,6 +79,33 @@ class AppRouteURLParserTests: XCTestCase {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
XCTAssertEqual(AppRouteURLParser(appSettings: ServiceLocator.shared.settings).route(from: customSchemeURL), nil)
|
XCTAssertEqual(appRouteURLParser.route(from: customSchemeURL), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOIDCCallbackRoute() {
|
||||||
|
// Given an OIDC callback for this app.
|
||||||
|
let callbackURL = appSettings.oidcRedirectURL.appending(queryItems: [URLQueryItem(name: "state", value: "12345"),
|
||||||
|
URLQueryItem(name: "code", value: "67890")])
|
||||||
|
|
||||||
|
// When parsing that route.
|
||||||
|
let route = appRouteURLParser.route(from: callbackURL)
|
||||||
|
|
||||||
|
// Then it should be considered a valid OIDC callback.
|
||||||
|
XCTAssertEqual(route, AppRoute.oidcCallback(url: callbackURL))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOIDCCallbackAppVariantRoute() {
|
||||||
|
// Given an OIDC callback for a different app variant.
|
||||||
|
let callbackURL = appSettings.oidcRedirectURL
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.appending(component: "elementz")
|
||||||
|
.appending(queryItems: [URLQueryItem(name: "state", value: "12345"),
|
||||||
|
URLQueryItem(name: "code", value: "67890")])
|
||||||
|
|
||||||
|
// When parsing that route in this app.
|
||||||
|
let route = appRouteURLParser.route(from: callbackURL)
|
||||||
|
|
||||||
|
// Then the route shouldn't be considered valid and should be ignored.
|
||||||
|
XCTAssertEqual(route, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Use a universal link for OIDC callbacks.
|
||||||
Reference in New Issue
Block a user