Handle OIDC cancellation and workaround missing progress when linking a new device. (#4935)

* Fix a bug where the link device flow wasn't dismissed when complete.

* Listen for cancellation of the WAS when linking a device (dismissing the QR screen when it happens).

* Add CustomStringConvertible conformances to QRCodeLoginScreen actions.
This commit is contained in:
Doug
2026-01-09 14:08:05 +00:00
committed by GitHub
parent 7cb472a4f7
commit a894da0903
10 changed files with 122 additions and 38 deletions
@@ -322,8 +322,8 @@ class AuthenticationFlowCoordinator: FlowCoordinatorProtocol {
DispatchQueue.main.async { DispatchQueue.main.async {
self.stateMachine.tryEvent(.signedIn, userInfo: userSession) self.stateMachine.tryEvent(.signedIn, userInfo: userSession)
} }
case .requestOIDCAuthorisation: case .requestOIDCAuthorisation, .linkedDevice:
fatalError("QR code login shouldn't request an OIDC flow.") fatalError("QR code login shouldn't request an OIDC flow or link a device.")
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -9,7 +9,7 @@ import Combine
import Foundation import Foundation
enum LinkNewDeviceFlowCoordinatorAction { enum LinkNewDeviceFlowCoordinatorAction {
case requestOIDCAuthorisation(URL) case requestOIDCAuthorisation(URL, OIDCAccountSettingsPresenter.Continuation)
case dismiss case dismiss
} }
@@ -76,8 +76,10 @@ class LinkNewDeviceFlowCoordinator: FlowCoordinatorProtocol {
fatalError("QR linking shouldn't send sign-in actions.") fatalError("QR linking shouldn't send sign-in actions.")
case .dismiss: case .dismiss:
navigationStackCoordinator.pop() navigationStackCoordinator.pop()
case .requestOIDCAuthorisation(let url): case .requestOIDCAuthorisation(let url, let continuation):
actionsSubject.send(.requestOIDCAuthorisation(url)) actionsSubject.send(.requestOIDCAuthorisation(url, continuation))
case .linkedDevice:
actionsSubject.send(.dismiss)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -182,8 +182,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
switch action { switch action {
case .dismiss: case .dismiss:
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
case .requestOIDCAuthorisation(let url): case .requestOIDCAuthorisation(let url, let continuation):
presentAccountManagementURL(url) presentAccountManagementURL(url, continuation: continuation)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -292,12 +292,13 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
// MARK: OIDC Account Management // MARK: OIDC Account Management
private var accountSettingsPresenter: OIDCAccountSettingsPresenter? private var accountSettingsPresenter: OIDCAccountSettingsPresenter?
private func presentAccountManagementURL(_ url: URL) { private func presentAccountManagementURL(_ url: URL, continuation: OIDCAccountSettingsPresenter.Continuation? = nil) {
// Note to anyone in the future if you come back here to make this open in Safari instead of a WAS. // Note to anyone in the future if you come back here to make this open in Safari instead of a WAS.
// As of iOS 16, there is an issue on the simulator with accessing the cookie but it works on a device. 🤷‍♂️ // As of iOS 16, there is an issue on the simulator with accessing the cookie but it works on a device. 🤷‍♂️
accountSettingsPresenter = OIDCAccountSettingsPresenter(accountURL: url, accountSettingsPresenter = OIDCAccountSettingsPresenter(accountURL: url,
presentationAnchor: flowParameters.windowManager.mainWindow, presentationAnchor: flowParameters.windowManager.mainWindow,
appSettings: flowParameters.appSettings) appSettings: flowParameters.appSettings,
continuation: continuation)
accountSettingsPresenter?.start() accountSettingsPresenter?.start()
} }
} }
@@ -50,11 +50,7 @@ class OIDCAuthenticationPresenter: NSObject {
guard let url else { guard let url else {
// Check for user cancellation to avoid showing an alert in that instance. // Check for user cancellation to avoid showing an alert in that instance.
if let nsError = error as? NSError, if error?.isOIDCUserCancellation == true {
nsError.domain == ASWebAuthenticationSessionErrorDomain,
nsError.code == ASWebAuthenticationSessionError.canceledLogin.rawValue,
// If there's a failure reason then the cancellation wasn't made by the user.
nsError.localizedFailureReason == nil {
// No need to show an error here, just abort and return a failure. // No need to show an error here, just abort and return a failure.
await authenticationService.abortOIDCLogin(data: oidcData) await authenticationService.abortOIDCLogin(data: oidcData)
return .failure(.oidcError(.userCancellation)) return .failure(.oidcError(.userCancellation))
@@ -121,3 +117,20 @@ extension ASWebAuthenticationSession.Callback {
} }
} }
} }
// MARK: - Helpers
extension Error {
var isOIDCUserCancellation: Bool {
let nsError = self as NSError
if nsError.domain == ASWebAuthenticationSessionErrorDomain,
nsError.code == ASWebAuthenticationSessionError.canceledLogin.rawValue,
// If there's a failure reason then the cancellation wasn't made by the user.
nsError.localizedFailureReason == nil {
return true
}
return false
}
}
@@ -16,11 +16,22 @@ struct QRCodeLoginScreenCoordinatorParameters {
let appMediator: AppMediatorProtocol let appMediator: AppMediatorProtocol
} }
enum QRCodeLoginScreenCoordinatorAction { enum QRCodeLoginScreenCoordinatorAction: CustomStringConvertible {
case dismiss case dismiss
case signInManually case signInManually
case signedIn(userSession: UserSessionProtocol) case signedIn(userSession: UserSessionProtocol)
case requestOIDCAuthorisation(URL) case requestOIDCAuthorisation(URL, OIDCAccountSettingsPresenter.Continuation)
case linkedDevice
var description: String {
switch self {
case .dismiss: "dismiss"
case .signInManually: "signInManually"
case .signedIn: "signedIn"
case .requestOIDCAuthorisation: "requestOIDCAuthorisation"
case .linkedDevice: "linkedDevice"
}
}
} }
final class QRCodeLoginScreenCoordinator: CoordinatorProtocol { final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
@@ -53,8 +64,10 @@ final class QRCodeLoginScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.dismiss) actionsSubject.send(.dismiss)
case .signedIn(let userSession): case .signedIn(let userSession):
actionsSubject.send(.signedIn(userSession: userSession)) actionsSubject.send(.signedIn(userSession: userSession))
case .requestOIDCAuthorisation(let url): case .requestOIDCAuthorisation(let url, let continuation):
actionsSubject.send(.requestOIDCAuthorisation(url)) actionsSubject.send(.requestOIDCAuthorisation(url, continuation))
case .linkedDevice:
actionsSubject.send(.linkedDevice)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -8,11 +8,22 @@
import SwiftUI import SwiftUI
enum QRCodeLoginScreenViewModelAction { enum QRCodeLoginScreenViewModelAction: CustomStringConvertible {
case dismiss case dismiss
case signInManually case signInManually
case signedIn(userSession: UserSessionProtocol) case signedIn(userSession: UserSessionProtocol)
case requestOIDCAuthorisation(URL) case requestOIDCAuthorisation(URL, OIDCAccountSettingsPresenter.Continuation)
case linkedDevice
var description: String {
switch self {
case .dismiss: "dismiss"
case .signInManually: "signInManually"
case .signedIn: "signedIn"
case .requestOIDCAuthorisation: "requestOIDCAuthorisation"
case .linkedDevice: "linkedDevice"
}
}
} }
enum QRCodeLoginScreenMode { enum QRCodeLoginScreenMode {
@@ -21,6 +21,7 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
} }
private var currentTask: AnyCancellable? private var currentTask: AnyCancellable?
private var oidcResultTask: AnyCancellable?
init(mode: QRCodeLoginScreenMode, init(mode: QRCodeLoginScreenMode,
canSignInManually: Bool, canSignInManually: Bool,
@@ -186,12 +187,12 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
case .establishingSecureChannel(let checkCodeString): case .establishingSecureChannel(let checkCodeString):
state.state = .displayCode(.deviceCode(checkCodeString)) state.state = .displayCode(.deviceCode(checkCodeString))
case .waitingForAuthorisation(let url): case .waitingForAuthorisation(let url):
actionsSubject.send(.requestOIDCAuthorisation(url)) requestOIDCAuthorization(url: url)
case .syncingSecrets: case .syncingSecrets:
break // Nothing to do. break // Nothing to do.
case .done: case .done:
MXLog.info("Link with QR code completed.") MXLog.info("Link with QR code completed.")
actionsSubject.send(.dismiss) actionsSubject.send(.linkedDevice)
} }
} }
} }
@@ -226,12 +227,14 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
case .qrScanned(let checkCodeSender): case .qrScanned(let checkCodeSender):
state.state = .confirmCode(.inputCode(checkCodeSender)) state.state = .confirmCode(.inputCode(checkCodeSender))
case .waitingForAuthorisation(let url): case .waitingForAuthorisation(let url):
actionsSubject.send(.requestOIDCAuthorisation(url)) requestOIDCAuthorization(url: url)
case .syncingSecrets: case .syncingSecrets:
break // Nothing to do. // break // Nothing to do.
case .done: // .done is rarely received at the moment, so lets consider linking to be done here.
MXLog.info("Link with QR code completed.") MXLog.info("Link with QR code completed.")
actionsSubject.send(.dismiss) actionsSubject.send(.linkedDevice)
case .done:
break // Not necessary right now with the workaround above in place.
} }
} }
} }
@@ -261,6 +264,27 @@ class QRCodeLoginScreenViewModel: QRCodeLoginScreenViewModelType, QRCodeLoginScr
} }
} }
private func requestOIDCAuthorization(url: URL) {
let (stream, continuation) = AsyncStream<Result<Void, OIDCError>>.makeStream()
actionsSubject.send(.requestOIDCAuthorisation(url, continuation))
oidcResultTask = Task { [weak self] in
for await result in stream {
guard let self else { return }
switch result {
case .success:
break // The state will be updated by the status publisher.
case .failure(.userCancellation):
MXLog.info("User cancelled the WAS, dismissing.")
actionsSubject.send(.dismiss)
case .failure:
handleError(.unknown)
}
}
}
.asCancellable()
}
private func handleError(_ error: QRCodeLoginError) { private func handleError(_ error: QRCodeLoginError) {
MXLog.error("Failed to scan the QR code: \(error)") MXLog.error("Failed to scan the QR code: \(error)")
switch error { switch error {
@@ -260,13 +260,14 @@ struct QRCodeLoginScreen: View {
} }
} }
} bottomContent: { } bottomContent: {
if case .inputCode = confirmCode { switch confirmCode {
case .inputCode, .sendingCode:
Button(L10n.actionContinue) { Button(L10n.actionContinue) {
context.send(viewAction: .sendCheckCode) context.send(viewAction: .sendCheckCode)
} }
.buttonStyle(.compound(.primary)) .buttonStyle(.compound(.primary))
.disabled(context.checkCodeInput.count < 2 || confirmCode.isSending) .disabled(context.checkCodeInput.count < 2 || confirmCode.isSending)
} else { case .invalidCode:
Button(L10n.actionStartOver) { Button(L10n.actionStartOver) {
context.send(viewAction: .errorAction(.startOver)) context.send(viewAction: .errorAction(.startOver))
} }
@@ -20,21 +20,39 @@ class OIDCAccountSettingsPresenter: NSObject {
private let presentationAnchor: UIWindow private let presentationAnchor: UIWindow
private let oidcRedirectURL: URL private let oidcRedirectURL: URL
init(accountURL: URL, presentationAnchor: UIWindow, appSettings: AppSettings) { typealias Continuation = AsyncStream<Result<Void, OIDCError>>.Continuation
private let continuation: Continuation?
init(accountURL: URL, presentationAnchor: UIWindow, appSettings: AppSettings, continuation: Continuation? = nil) {
self.accountURL = accountURL self.accountURL = accountURL
self.presentationAnchor = presentationAnchor self.presentationAnchor = presentationAnchor
oidcRedirectURL = appSettings.oidcRedirectURL oidcRedirectURL = appSettings.oidcRedirectURL
self.continuation = continuation
super.init() super.init()
} }
/// Presents a web authentication session for the supplied data. /// Presents a web authentication session for the supplied data.
func start() { func start() {
let session = ASWebAuthenticationSession(url: accountURL, callback: .oidcRedirectURL(oidcRedirectURL)) { _, _ in } let session = ASWebAuthenticationSession(url: accountURL, callback: .oidcRedirectURL(oidcRedirectURL)) { [continuation] _, error in
guard let continuation else { return }
if error?.isOIDCUserCancellation == true {
continuation.yield(.failure(.userCancellation))
} else {
let errorDescription = error.map(String.init(describing:)) ?? "Unknown error"
MXLog.error("A web authentication session error occurred: \(errorDescription)")
continuation.yield(.failure(.unknown))
}
continuation.finish()
}
session.prefersEphemeralWebBrowserSession = false session.prefersEphemeralWebBrowserSession = false
session.presentationContextProvider = self session.presentationContextProvider = self
session.additionalHeaderFields = [ session.additionalHeaderFields = [
"X-Element-User-Agent": UserAgentBuilder.makeASCIIUserAgent() "X-Element-User-Agent": UserAgentBuilder.makeASCIIUserAgent()
] ]
session.start() session.start()
} }
} }
@@ -150,7 +150,7 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
try await deferredFailure.fulfill() try await deferredFailure.fulfill()
deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
guard case .dismiss = action else { return false } guard case .linkedDevice = action else { return false }
return true return true
} }
linkDesktopProgressSubject.send(.done) linkDesktopProgressSubject.send(.done)
@@ -179,17 +179,18 @@ final class QRCodeLoginScreenViewModelTests: XCTestCase {
linkMobileProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory)) linkMobileProgressSubject.send(.waitingForAuthorisation(verificationURL: .homeDirectory))
try await deferredAction.fulfill() try await deferredAction.fulfill()
let currentState = context.viewState.state // Note: The SDK rarely sends the done action, so this test has been updated for the workaround of finishing early.
let deferredFailure = deferFailure(context.$viewState, timeout: 1) { $0.state != currentState }
linkMobileProgressSubject.send(.syncingSecrets)
try await deferredFailure.fulfill()
deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in deferredAction = deferFulfillment(viewModel.actionsPublisher) { action in
guard case .dismiss = action else { return false } guard case .linkedDevice = action else { return false }
return true return true
} }
linkMobileProgressSubject.send(.done) linkMobileProgressSubject.send(.syncingSecrets)
try await deferredAction.fulfill() try await deferredAction.fulfill()
let currentState = context.viewState.state
let deferredFailure = deferFailure(context.$viewState, timeout: 1) { $0.state != currentState }
linkMobileProgressSubject.send(.done)
try await deferredFailure.fulfill()
} }
// MARK: - Helpers // MARK: - Helpers