Use a universal link for the OIDC callback. (#1681)

This commit is contained in:
Doug 2023-09-28 14:12:57 +01:00 committed by GitHub
parent 718e01f3bc
commit ec3f5d425e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 162 additions and 53 deletions

View File

@ -45,6 +45,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
private var userSessionFlowCoordinator: UserSessionFlowCoordinator?
private var authenticationCoordinator: AuthenticationCoordinator?
private var softLogoutCoordinator: SoftLogoutScreenCoordinator?
private let backgroundTaskService: BackgroundTaskServiceProtocol
@ -145,6 +146,12 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
if let route = appRouteURLParser.route(from: url) {
switch route {
case .oidcCallback(let url):
if stateMachine.state == .softLogout {
softLogoutCoordinator?.handleOIDCRedirectURL(url)
} else {
authenticationCoordinator?.handleOIDCRedirectURL(url)
}
case .genericCallLink(let url):
if let userSessionFlowCoordinator {
userSessionFlowCoordinator.handleAppRoute(route, animated: true)
@ -158,11 +165,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
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
}
@ -170,6 +172,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didLoginWithSession userSession: UserSessionProtocol) {
self.userSession = userSession
self.authenticationCoordinator = nil
stateMachine.processEvent(.createdUserSession)
}
@ -305,8 +308,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
break
case (_, .signOut(let isSoft), .signingOut):
self.logout(isSoft: isSoft)
case (.signingOut, .completedSigningOut(let isSoft), .signedOut):
self.presentSplashScreen(isSoftLogout: isSoft)
case (.signingOut, .completedSigningOut, .signedOut):
self.presentSplashScreen(isSoftLogout: false)
case (.signingOut, .showSoftLogout, .softLogout):
self.presentSplashScreen(isSoftLogout: true)
case (.signedIn, .clearCache, .initial):
self.clearCache()
default:
@ -370,7 +375,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
keyBackupNeeded: false,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
let coordinator = SoftLogoutScreenCoordinator(parameters: parameters)
self.softLogoutCoordinator = coordinator
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
@ -378,8 +383,10 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
switch action {
case .signedIn(let session):
self.userSession = session
self.softLogoutCoordinator = nil
stateMachine.processEvent(.createdUserSession)
case .clearAllData:
self.softLogoutCoordinator = nil
stateMachine.processEvent(.signOut(isSoft: false))
}
}
@ -437,7 +444,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
userSessionFlowCoordinator?.stop()
guard !isSoft else {
stateMachine.processEvent(.completedSigningOut(isSoft: isSoft))
stateMachine.processEvent(.showSoftLogout)
hideLoadingIndicator()
return
}
@ -459,7 +466,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
ServiceLocator.shared.analytics.optOut()
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
// as it looks weird to show an alert to the user asking them to sign in to their provider.

View File

@ -22,8 +22,10 @@ class AppCoordinatorStateMachine {
enum State: StateType {
/// The initial state, used before the AppCoordinator starts
case initial
/// Showing the login screen
/// Showing the authentication flow
case signedOut
/// Showing the soft logout flow
case softLogout
/// Opening an existing session.
case restoringSession
@ -45,15 +47,17 @@ class AppCoordinatorStateMachine {
/// Restoring session failed.
case failedRestoringSession
/// A session has been created
/// A session has been created.
case createdUserSession
/// Request sign out
/// Request sign out.
case signOut(isSoft: Bool)
/// Signing out completed
case completedSigningOut(isSoft: Bool)
/// Request the soft logout screen.
case showSoftLogout
/// Signing out completed.
case completedSigningOut
/// Request cache clearing
/// Request cache clearing.
case clearCache
}
@ -70,11 +74,15 @@ class AppCoordinatorStateMachine {
private func configure() {
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: .createdUserSession, transitions: [.restoringSession => .signedIn])
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])
// Transitions with associated values need to be handled through `addRouteMapping`
@ -82,8 +90,6 @@ class AppCoordinatorStateMachine {
switch (event, fromState) {
case (.signOut(let isSoft), _):
return .signingOut(isSoft: isSoft)
case (.completedSigningOut, .signingOut):
return .signedOut
default:
return nil
}

View File

@ -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.
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.
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
/// a hard wall on the history of encrypted messages until we have key backup.

View File

@ -30,7 +30,7 @@ struct AppRouteURLParser {
init(appSettings: AppSettings) {
urlParsers = [
ElementAppURLParser(appSettings: appSettings),
OIDCCallbackURLParser(appSettings: appSettings),
ElementCallURLParser()
]
}
@ -50,34 +50,24 @@ struct AppRouteURLParser {
///
/// The following Universal Links are missing parsers.
/// - app.element.io
/// - staging.element.io
/// - develop.element.io
/// - mobile.element.io
protocol URLParser {
func route(from url: URL) -> AppRoute?
}
/// The parser for the main Element website.
struct ElementAppURLParser: URLParser {
private let knownHosts = ["app.element.io", "staging.element.io", "develop.element.io"]
/// The parser for the OIDC callback URL. This always returns a `.oidcCallback`.
struct OIDCCallbackURLParser: URLParser {
let appSettings: AppSettings
func route(from url: URL) -> AppRoute? {
guard let host = url.host, knownHosts.contains(host) else {
return nil
}
let pathComponents = url.pathComponents
// OIDC callback URL.
if pathComponents.count == 3, pathComponents[0] == "mobile", pathComponents[1] == "oidc" {
return .oidcCallback(url: url)
}
return nil
guard url.absoluteString.starts(with: appSettings.oidcRedirectURL.absoluteString) else { return nil }
return .oidcCallback(url: url)
}
}
/// 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 {
private let knownHosts = ["call.element.io"]
private let customSchemeURLQueryParameterName = "url"

View File

@ -32,6 +32,8 @@ class AuthenticationCoordinator: CoordinatorProtocol {
private var cancellables = Set<AnyCancellable>()
private var oidcPresenter: OIDCAuthenticationPresenter?
weak var delegate: AuthenticationCoordinatorDelegate?
init(authenticationService: AuthenticationServiceProxyProtocol,
@ -53,6 +55,15 @@ class AuthenticationCoordinator: CoordinatorProtocol {
func stop() {
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
@ -167,12 +178,14 @@ class AuthenticationCoordinator: CoordinatorProtocol {
let presenter = OIDCAuthenticationPresenter(authenticationService: authenticationService,
oidcRedirectURL: appSettings.oidcRedirectURL,
presentationAnchor: presentationAnchor)
self.oidcPresenter = presenter
switch await presenter.authenticate(using: oidcData) {
case .success(let userSession):
userHasSignedIn(userSession: userSession)
case .failure(let error):
handleError(error)
}
oidcPresenter = nil
}
}
}

View File

@ -23,6 +23,16 @@ class OIDCAuthenticationPresenter: NSObject {
private let oidcRedirectURL: URL
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) {
self.authenticationService = authenticationService
self.oidcRedirectURL = oidcRedirectURL
@ -35,41 +45,70 @@ class OIDCAuthenticationPresenter: NSObject {
await withCheckedContinuation { continuation in
let session = ASWebAuthenticationSession(url: oidcData.url,
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 url else {
// Check for user cancellation to avoid showing an alert in that instance.
if let nsError = error as? NSError,
nsError.domain == ASWebAuthenticationSessionErrorDomain,
nsError.code == ASWebAuthenticationSessionError.canceledLogin.rawValue {
continuation.resume(returning: .failure(AuthenticationServiceError.oidcError(.userCancellation)))
self.completeAuthentication(throwing: .oidcError(.userCancellation))
return
}
continuation.resume(returning: .failure(AuthenticationServiceError.oidcError(.unknown)))
self.completeAuthentication(throwing: .oidcError(.unknown))
return
}
completeAuthentication(callbackURL: url, data: oidcData, continuation: continuation)
completeAuthentication(callbackURL: url)
}
session.prefersEphemeralWebBrowserSession = false
session.presentationContextProvider = self
request = Request(session: session, oidcData: oidcData, continuation: continuation)
session.start()
}
}
private func completeAuthentication(callbackURL: URL,
data: OIDCAuthenticationDataProxy,
continuation: CheckedContinuation<Result<UserSessionProtocol, AuthenticationServiceError>, Never>) {
/// This method will be used if the `appSettings.oidcRedirectURL`'s scheme is `https`.
/// When using a custom scheme, the redirect will be handled by the web auth session's closure.
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 {
switch await authenticationService.loginWithOIDCCallback(callbackURL, data: data) {
switch await authenticationService.loginWithOIDCCallback(callbackURL, data: request.oidcData) {
case .success(let userSession):
continuation.resume(returning: .success(userSession))
request.continuation.resume(returning: .success(userSession))
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

View File

@ -48,6 +48,7 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
private var cancellables = Set<AnyCancellable>()
private var authenticationService: AuthenticationServiceProxyProtocol { parameters.authenticationService }
private var oidcPresenter: OIDCAuthenticationPresenter?
var actions: AnyPublisher<SoftLogoutScreenCoordinatorResult, Never> {
actionsSubject.eraseToAnyPublisher()
@ -92,6 +93,15 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
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
private static let loadingIndicatorIdentifier = "SoftLogoutLoading"
@ -151,12 +161,14 @@ final class SoftLogoutScreenCoordinator: CoordinatorProtocol {
let presenter = OIDCAuthenticationPresenter(authenticationService: parameters.authenticationService,
oidcRedirectURL: ServiceLocator.shared.settings.oidcRedirectURL,
presentationAnchor: presentationAnchor)
self.oidcPresenter = presenter
switch await presenter.authenticate(using: oidcData) {
case .success(let userSession):
actionsSubject.send(.signedIn(userSession))
case .failure(let error):
handleError(error)
}
self.oidcPresenter = nil
}
}
}

View File

@ -19,20 +19,29 @@ import XCTest
@testable import ElementX
class AppRouteURLParserTests: XCTestCase {
var appSettings: AppSettings!
var appRouteURLParser: AppRouteURLParser!
override func setUp() {
AppSettings.reset()
appSettings = AppSettings()
appRouteURLParser = AppRouteURLParser(appSettings: appSettings)
}
func testElementCallRoutes() {
guard let url = URL(string: "https://call.element.io/test") else {
XCTFail("URL invalid")
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 {
XCTFail("URL invalid")
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() {
@ -41,7 +50,7 @@ class AppRouteURLParserTests: XCTestCase {
return
}
XCTAssertEqual(AppRouteURLParser(appSettings: ServiceLocator.shared.settings).route(from: url), nil)
XCTAssertEqual(appRouteURLParser.route(from: url), nil)
}
func testCustomSchemeLinkCallRoutes() {
@ -61,7 +70,7 @@ class AppRouteURLParserTests: XCTestCase {
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() {
@ -70,6 +79,33 @@ class AppRouteURLParserTests: XCTestCase {
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)
}
}

1
changelog.d/1734.change Normal file
View File

@ -0,0 +1 @@
Use a universal link for OIDC callbacks.